Add Athena WebRTC bridge for OpenClaw Talk
This commit is contained in:
+125
-2
@@ -1,6 +1,80 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { generateKeyPairSync, randomUUID, sign, verify } from "node:crypto";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
const AUDIO_FORMAT = { encoding: "pcm16", sampleRateHz: 24000, channels: 1 };
|
||||
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) {
|
||||
return Buffer.from(value).toString("base64url");
|
||||
}
|
||||
function createBrowserToken() {
|
||||
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) {
|
||||
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, res, upstream) {
|
||||
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 = [];
|
||||
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) {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value
|
||||
@@ -31,6 +105,7 @@ function resolveConfig(req) {
|
||||
silenceDurationMs: Number(raw.silenceDurationMs ?? 750),
|
||||
prefixPaddingMs: Number(raw.prefixPaddingMs ?? 300),
|
||||
maxSpeechSeconds: Number(raw.maxSpeechSeconds ?? 45),
|
||||
realtimeUpstreamUrl: String(raw.realtimeUpstreamUrl || ""),
|
||||
};
|
||||
}
|
||||
function wavFromPcm16(pcm, sampleRate = 24000) {
|
||||
@@ -337,6 +412,31 @@ 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, res) => {
|
||||
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, res) => {
|
||||
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",
|
||||
@@ -345,9 +445,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,
|
||||
@@ -359,6 +460,28 @@ export default definePluginEntry({
|
||||
return Boolean(raw.baseUrl || resolveModelProvider(cfg, raw.modelProvider).baseUrl);
|
||||
},
|
||||
createBridge: (req) => new AthenaTalkBridge(req, resolveConfig(req)),
|
||||
async createBrowserSession(req) {
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user