Add Athena WebRTC bridge for OpenClaw Talk

This commit is contained in:
Mikei386
2026-09-16 14:10:39 +02:00
parent 276df0eae5
commit e86c905968
14 changed files with 1223 additions and 13 deletions
+129 -2
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { generateKeyPairSync, randomUUID, sign, verify } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
const AUDIO_FORMAT = { encoding: "pcm16", sampleRateHz: 24000, channels: 1 } as const;
@@ -13,8 +14,86 @@ type ProviderConfig = {
silenceDurationMs?: number;
prefixPaddingMs?: number;
maxSpeechSeconds?: number;
realtimeUpstreamUrl?: string;
};
const BROWSER_OFFER_PATH = "/plugins/athena-talk/realtime/calls";
const BROWSER_KEY_PATH = "/plugins/athena-talk/realtime/public-key";
const MAX_OFFER_BYTES = 64 * 1024;
const browserKeys = generateKeyPairSync("ed25519");
const publicKeyPem = browserKeys.publicKey.export({ format: "pem", type: "spki" }).toString();
function base64url(value: string): string {
return Buffer.from(value).toString("base64url");
}
function createBrowserToken(): { token: string; expiresAt: number } {
const expiresAt = Date.now() + 60_000;
const payload = base64url(JSON.stringify({ exp: Math.floor(expiresAt / 1000), jti: randomUUID() }));
const signature = sign(null, Buffer.from(payload), browserKeys.privateKey).toString("base64url");
return { token: `${payload}.${signature}`, expiresAt };
}
function validBrowserToken(value: string): boolean {
const [payload, signature, extra] = value.split(".");
if (!payload || !signature || extra !== undefined) return false;
try {
if (!verify(null, Buffer.from(payload), browserKeys.publicKey, Buffer.from(signature, "base64url"))) {
return false;
}
const claims = record(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
return typeof claims.jti === "string" && Number.isInteger(claims.exp) &&
claims.exp > Math.floor(Date.now() / 1000) &&
claims.exp <= Math.floor(Date.now() / 1000) + 65;
} catch {
return false;
}
}
async function handleBrowserOffer(
req: IncomingMessage, res: ServerResponse, upstream: string,
): Promise<boolean> {
if (req.method !== "POST") {
res.writeHead(405).end("Method not allowed");
return true;
}
const token = req.headers.authorization?.replace(/^Bearer /i, "") || "";
if (!validBrowserToken(token)) {
res.writeHead(401).end("Invalid realtime session token");
return true;
}
if (!upstream.startsWith("http://") && !upstream.startsWith("https://")) {
res.writeHead(503).end("Athena realtime upstream is not configured");
return true;
}
const chunks: Buffer[] = [];
let total = 0;
for await (const part of req) {
const chunk = Buffer.from(part);
total += chunk.length;
if (total > MAX_OFFER_BYTES) {
res.writeHead(413).end("WebRTC offer too large");
return true;
}
chunks.push(chunk);
}
try {
const response = await fetch(upstream, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/sdp" },
body: Uint8Array.from(Buffer.concat(chunks)),
signal: AbortSignal.timeout(30_000),
});
const answer = Buffer.from(await response.arrayBuffer());
if (answer.length > MAX_OFFER_BYTES) throw new Error("WebRTC answer too large");
res.writeHead(response.status, { "Content-Type": response.headers.get("content-type") || "text/plain" });
res.end(answer);
} catch {
res.writeHead(502).end("Athena realtime bridge unavailable");
}
return true;
}
function record(value: unknown): Record<string, any> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, any>
@@ -45,6 +124,7 @@ function resolveConfig(req: any): Required<ProviderConfig> {
silenceDurationMs: Number(raw.silenceDurationMs ?? 750),
prefixPaddingMs: Number(raw.prefixPaddingMs ?? 300),
maxSpeechSeconds: Number(raw.maxSpeechSeconds ?? 45),
realtimeUpstreamUrl: String(raw.realtimeUpstreamUrl || ""),
};
}
@@ -338,6 +418,30 @@ export default definePluginEntry({
name: "Athena Local Talk",
description: "Private voice loop using Athena STT and TTS with the normal OpenClaw agent.",
register(api) {
api.registerHttpRoute({
path: BROWSER_KEY_PATH,
auth: "plugin",
match: "exact",
handler: (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "GET") {
res.writeHead(405).end("Method not allowed");
} else {
res.writeHead(200, { "Content-Type": "application/x-pem-file", "Cache-Control": "no-store" });
res.end(publicKeyPem);
}
return true;
},
});
api.registerHttpRoute({
path: BROWSER_OFFER_PATH,
auth: "plugin",
match: "exact",
handler: (req: IncomingMessage, res: ServerResponse) => {
const raw = record(record(record(api.runtime.config.current()).talk).realtime);
const provider = record(record(raw.providers)["athena-talk"]);
return handleBrowserOffer(req, res, String(provider.realtimeUpstreamUrl || ""));
},
});
api.registerRealtimeVoiceProvider({
id: "athena-talk",
label: "Athena Local Talk",
@@ -346,9 +450,10 @@ export default definePluginEntry({
voices: ["alloy"],
autoSelectOrder: 1,
capabilities: {
transports: ["gateway-relay"],
transports: ["gateway-relay", "webrtc"],
inputAudioFormats: [AUDIO_FORMAT],
outputAudioFormats: [AUDIO_FORMAT],
supportsBrowserSession: true,
supportsBargeIn: false,
handlesInputAudioBargeIn: false,
supportsToolCalls: true,
@@ -360,6 +465,28 @@ export default definePluginEntry({
return Boolean(raw.baseUrl || resolveModelProvider(cfg, raw.modelProvider).baseUrl);
},
createBridge: (req: any) => new AthenaTalkBridge(req, resolveConfig(req)),
async createBrowserSession(req: any) {
const config = resolveConfig(req);
if (!config.realtimeUpstreamUrl) {
throw new Error("athena-talk realtimeUpstreamUrl is required for browser Talk");
}
const { token, expiresAt } = createBrowserToken();
return {
provider: "athena-talk",
transport: "webrtc",
clientSecret: token,
offerUrl: BROWSER_OFFER_PATH,
audio: {
inputEncoding: "pcm16",
inputSampleRateHz: 24000,
outputEncoding: "pcm16",
outputSampleRateHz: 24000,
},
model: req.model || "athena-local",
voice: req.voice || config.voice,
expiresAt,
};
},
} as any);
},
});