mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 20:00:46 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
|
|
@ -0,0 +1,172 @@
|
|||
import http from "node:http";
|
||||
import type {
|
||||
RealtimeVoiceBridge,
|
||||
RealtimeVoiceProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/realtime-voice";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import type { VoiceCallRealtimeConfig } from "../config.js";
|
||||
import type { CallManager } from "../manager.js";
|
||||
import type { VoiceCallProvider } from "../providers/base.js";
|
||||
import { connectWs, startUpgradeWsServer, waitForClose } from "../websocket-test-support.js";
|
||||
import { RealtimeCallHandler } from "./realtime-handler.js";
|
||||
|
||||
function makeRequest(url: string, host = "gateway.ts.net"): http.IncomingMessage {
|
||||
const req = new http.IncomingMessage(null as never);
|
||||
req.url = url;
|
||||
req.method = "POST";
|
||||
req.headers = host ? { host } : {};
|
||||
return req;
|
||||
}
|
||||
|
||||
function makeBridge(): RealtimeVoiceBridge {
|
||||
return {
|
||||
connect: async () => {},
|
||||
sendAudio: () => {},
|
||||
setMediaTimestamp: () => {},
|
||||
submitToolResult: () => {},
|
||||
acknowledgeMark: () => {},
|
||||
close: () => {},
|
||||
isConnected: () => true,
|
||||
triggerGreeting: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeRealtimeProvider(
|
||||
createBridge: () => RealtimeVoiceBridge,
|
||||
): RealtimeVoiceProviderPlugin {
|
||||
return {
|
||||
id: "openai",
|
||||
label: "OpenAI",
|
||||
isConfigured: () => true,
|
||||
createBridge,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHandler(
|
||||
overrides?: Partial<VoiceCallRealtimeConfig>,
|
||||
deps?: {
|
||||
manager?: Partial<CallManager>;
|
||||
provider?: Partial<VoiceCallProvider>;
|
||||
realtimeProvider?: RealtimeVoiceProviderPlugin;
|
||||
},
|
||||
) {
|
||||
return new RealtimeCallHandler(
|
||||
{
|
||||
enabled: true,
|
||||
streamPath: "/voice/stream/realtime",
|
||||
instructions: "Be helpful.",
|
||||
tools: [],
|
||||
providers: {},
|
||||
...overrides,
|
||||
},
|
||||
{
|
||||
processEvent: vi.fn(),
|
||||
getCallByProviderCallId: vi.fn(),
|
||||
...deps?.manager,
|
||||
} as unknown as CallManager,
|
||||
{
|
||||
name: "twilio",
|
||||
verifyWebhook: vi.fn(),
|
||||
parseWebhookEvent: vi.fn(),
|
||||
initiateCall: vi.fn(),
|
||||
hangupCall: vi.fn(),
|
||||
playTts: vi.fn(),
|
||||
startListening: vi.fn(),
|
||||
stopListening: vi.fn(),
|
||||
getCallStatus: vi.fn(),
|
||||
...deps?.provider,
|
||||
} as unknown as VoiceCallProvider,
|
||||
deps?.realtimeProvider ?? makeRealtimeProvider(() => makeBridge()),
|
||||
{ apiKey: "test-key" },
|
||||
"/voice/webhook",
|
||||
);
|
||||
}
|
||||
|
||||
const startRealtimeServer = async (
|
||||
handler: RealtimeCallHandler,
|
||||
): Promise<{
|
||||
url: string;
|
||||
close: () => Promise<void>;
|
||||
}> => {
|
||||
const payload = handler.buildTwiMLPayload(makeRequest("/voice/webhook"));
|
||||
const match = payload.body.match(/wss:\/\/[^/]+(\/[^"]+)/);
|
||||
if (!match) {
|
||||
throw new Error("Failed to extract realtime stream path");
|
||||
}
|
||||
|
||||
return await startUpgradeWsServer({
|
||||
urlPath: match[1],
|
||||
onUpgrade: (request, socket, head) => {
|
||||
handler.handleWebSocketUpgrade(request, socket, head);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe("RealtimeCallHandler path routing", () => {
|
||||
it("uses the request host and stream path in TwiML", () => {
|
||||
const handler = makeHandler();
|
||||
const payload = handler.buildTwiMLPayload(makeRequest("/voice/webhook", "gateway.ts.net"));
|
||||
|
||||
expect(payload.statusCode).toBe(200);
|
||||
expect(payload.body).toMatch(
|
||||
/wss:\/\/gateway\.ts\.net\/voice\/stream\/realtime\/[0-9a-f-]{36}/,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a public path prefix ahead of serve.path", () => {
|
||||
const handler = makeHandler({ streamPath: "/custom/stream/realtime" });
|
||||
handler.setPublicUrl("https://public.example/api/voice/webhook");
|
||||
const payload = handler.buildTwiMLPayload(makeRequest("/voice/webhook", "127.0.0.1:3334"));
|
||||
|
||||
expect(handler.getStreamPathPattern()).toBe("/api/custom/stream/realtime");
|
||||
expect(payload.body).toMatch(
|
||||
/wss:\/\/public\.example\/api\/custom\/stream\/realtime\/[0-9a-f-]{36}/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RealtimeCallHandler websocket hardening", () => {
|
||||
it("rejects oversized pre-start frames before bridge setup", async () => {
|
||||
const createBridge = vi.fn(() => makeBridge());
|
||||
const processEvent = vi.fn();
|
||||
const getCallByProviderCallId = vi.fn();
|
||||
const handler = makeHandler(undefined, {
|
||||
manager: {
|
||||
processEvent,
|
||||
getCallByProviderCallId,
|
||||
},
|
||||
realtimeProvider: makeRealtimeProvider(createBridge),
|
||||
});
|
||||
const server = await startRealtimeServer(handler);
|
||||
|
||||
try {
|
||||
const ws = await connectWs(server.url);
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
event: "start",
|
||||
start: {
|
||||
streamSid: "MZ-oversized",
|
||||
callSid: "CA-oversized",
|
||||
padding: "A".repeat(300 * 1024),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const closed = await waitForClose(ws);
|
||||
|
||||
expect(closed.code).toBe(1009);
|
||||
expect(createBridge).not.toHaveBeenCalled();
|
||||
expect(processEvent).not.toHaveBeenCalled();
|
||||
expect(getCallByProviderCallId).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
441
openclaw/extensions/voice-call/src/webhook/realtime-handler.ts
Normal file
441
openclaw/extensions/voice-call/src/webhook/realtime-handler.ts
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import http from "node:http";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type {
|
||||
RealtimeVoiceBridge,
|
||||
RealtimeVoiceProviderConfig,
|
||||
RealtimeVoiceProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/realtime-voice";
|
||||
import WebSocket, { WebSocketServer } from "ws";
|
||||
import type { VoiceCallRealtimeConfig } from "../config.js";
|
||||
import type { CallManager } from "../manager.js";
|
||||
import type { VoiceCallProvider } from "../providers/base.js";
|
||||
import type { CallRecord, NormalizedEvent } from "../types.js";
|
||||
import type { WebhookResponsePayload } from "../webhook.types.js";
|
||||
|
||||
export type ToolHandlerFn = (args: unknown, callId: string) => Promise<unknown>;
|
||||
|
||||
const STREAM_TOKEN_TTL_MS = 30_000;
|
||||
const DEFAULT_HOST = "localhost:8443";
|
||||
const MAX_REALTIME_MESSAGE_BYTES = 256 * 1024;
|
||||
|
||||
function normalizePath(pathname: string): string {
|
||||
const trimmed = pathname.trim();
|
||||
if (!trimmed) {
|
||||
return "/";
|
||||
}
|
||||
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
if (prefixed === "/") {
|
||||
return prefixed;
|
||||
}
|
||||
return prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed;
|
||||
}
|
||||
|
||||
function buildGreetingInstructions(
|
||||
baseInstructions: string | undefined,
|
||||
greeting: string | undefined,
|
||||
): string | undefined {
|
||||
const trimmedGreeting = greeting?.trim();
|
||||
if (!trimmedGreeting) {
|
||||
return baseInstructions;
|
||||
}
|
||||
const intro =
|
||||
"Start the call by greeting the caller naturally. Include this greeting in your first spoken reply:";
|
||||
return baseInstructions
|
||||
? `${baseInstructions}\n\n${intro} "${trimmedGreeting}"`
|
||||
: `${intro} "${trimmedGreeting}"`;
|
||||
}
|
||||
|
||||
type PendingStreamToken = {
|
||||
expiry: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
direction?: "inbound" | "outbound";
|
||||
};
|
||||
|
||||
type CallRegistration = {
|
||||
callId: string;
|
||||
initialGreetingInstructions?: string;
|
||||
};
|
||||
|
||||
type ActiveRealtimeVoiceBridge = Pick<
|
||||
RealtimeVoiceBridge,
|
||||
| "connect"
|
||||
| "sendAudio"
|
||||
| "setMediaTimestamp"
|
||||
| "submitToolResult"
|
||||
| "acknowledgeMark"
|
||||
| "close"
|
||||
| "triggerGreeting"
|
||||
>;
|
||||
|
||||
export class RealtimeCallHandler {
|
||||
private readonly toolHandlers = new Map<string, ToolHandlerFn>();
|
||||
private readonly pendingStreamTokens = new Map<string, PendingStreamToken>();
|
||||
private publicOrigin: string | null = null;
|
||||
private publicPathPrefix = "";
|
||||
|
||||
constructor(
|
||||
private readonly config: VoiceCallRealtimeConfig,
|
||||
private readonly manager: CallManager,
|
||||
private readonly provider: VoiceCallProvider,
|
||||
private readonly realtimeProvider: RealtimeVoiceProviderPlugin,
|
||||
private readonly providerConfig: RealtimeVoiceProviderConfig,
|
||||
private readonly servePath: string,
|
||||
) {}
|
||||
|
||||
setPublicUrl(url: string): void {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
this.publicOrigin = parsed.host;
|
||||
const normalizedServePath = normalizePath(this.servePath);
|
||||
const normalizedPublicPath = normalizePath(parsed.pathname);
|
||||
const idx = normalizedPublicPath.indexOf(normalizedServePath);
|
||||
this.publicPathPrefix = idx > 0 ? normalizedPublicPath.slice(0, idx) : "";
|
||||
} catch {
|
||||
this.publicOrigin = null;
|
||||
this.publicPathPrefix = "";
|
||||
}
|
||||
}
|
||||
|
||||
getStreamPathPattern(): string {
|
||||
return `${this.publicPathPrefix}${normalizePath(this.config.streamPath ?? "/voice/stream/realtime")}`;
|
||||
}
|
||||
|
||||
buildTwiMLPayload(req: http.IncomingMessage, params?: URLSearchParams): WebhookResponsePayload {
|
||||
const host = this.publicOrigin || req.headers.host || DEFAULT_HOST;
|
||||
const rawDirection = params?.get("Direction");
|
||||
const token = this.issueStreamToken({
|
||||
from: params?.get("From") ?? undefined,
|
||||
to: params?.get("To") ?? undefined,
|
||||
direction: rawDirection === "outbound-api" ? "outbound" : "inbound",
|
||||
});
|
||||
const wsUrl = `wss://${host}${this.getStreamPathPattern()}/${token}`;
|
||||
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Connect>
|
||||
<Stream url="${wsUrl}" />
|
||||
</Connect>
|
||||
</Response>`;
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { "Content-Type": "text/xml" },
|
||||
body: twiml,
|
||||
};
|
||||
}
|
||||
|
||||
handleWebSocketUpgrade(request: http.IncomingMessage, socket: Duplex, head: Buffer): void {
|
||||
const url = new URL(request.url ?? "/", "wss://localhost");
|
||||
const token = url.pathname.split("/").pop() ?? null;
|
||||
const callerMeta = token ? this.consumeStreamToken(token) : null;
|
||||
if (!callerMeta) {
|
||||
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
// Reject oversized realtime frames before JSON parsing or bridge setup runs.
|
||||
maxPayload: MAX_REALTIME_MESSAGE_BYTES,
|
||||
});
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
let bridge: ActiveRealtimeVoiceBridge | null = null;
|
||||
let initialized = false;
|
||||
|
||||
ws.on("message", (data: Buffer) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString()) as Record<string, unknown>;
|
||||
if (!initialized && msg.event === "start") {
|
||||
initialized = true;
|
||||
const startData =
|
||||
typeof msg.start === "object" && msg.start !== null
|
||||
? (msg.start as Record<string, unknown>)
|
||||
: undefined;
|
||||
const streamSid =
|
||||
typeof startData?.streamSid === "string" ? startData.streamSid : "unknown";
|
||||
const callSid = typeof startData?.callSid === "string" ? startData.callSid : "unknown";
|
||||
const nextBridge = this.handleCall(streamSid, callSid, ws, callerMeta);
|
||||
if (!nextBridge) {
|
||||
return;
|
||||
}
|
||||
bridge = nextBridge;
|
||||
return;
|
||||
}
|
||||
if (!bridge) {
|
||||
return;
|
||||
}
|
||||
const mediaData =
|
||||
typeof msg.media === "object" && msg.media !== null
|
||||
? (msg.media as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (msg.event === "media" && typeof mediaData?.payload === "string") {
|
||||
bridge.sendAudio(Buffer.from(mediaData.payload, "base64"));
|
||||
if (typeof mediaData.timestamp === "number") {
|
||||
bridge.setMediaTimestamp(mediaData.timestamp);
|
||||
} else if (typeof mediaData.timestamp === "string") {
|
||||
bridge.setMediaTimestamp(Number.parseInt(mediaData.timestamp, 10));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.event === "mark") {
|
||||
bridge.acknowledgeMark();
|
||||
return;
|
||||
}
|
||||
if (msg.event === "stop") {
|
||||
bridge.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[voice-call] realtime WS parse failed:", error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
bridge?.close();
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
console.error("[voice-call] realtime WS error:", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
registerToolHandler(name: string, fn: ToolHandlerFn): void {
|
||||
this.toolHandlers.set(name, fn);
|
||||
}
|
||||
|
||||
private issueStreamToken(meta: Omit<PendingStreamToken, "expiry"> = {}): string {
|
||||
const token = randomUUID();
|
||||
this.pendingStreamTokens.set(token, { expiry: Date.now() + STREAM_TOKEN_TTL_MS, ...meta });
|
||||
for (const [candidate, entry] of this.pendingStreamTokens) {
|
||||
if (Date.now() > entry.expiry) {
|
||||
this.pendingStreamTokens.delete(candidate);
|
||||
}
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
private consumeStreamToken(token: string): Omit<PendingStreamToken, "expiry"> | null {
|
||||
const entry = this.pendingStreamTokens.get(token);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
this.pendingStreamTokens.delete(token);
|
||||
if (Date.now() > entry.expiry) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
from: entry.from,
|
||||
to: entry.to,
|
||||
direction: entry.direction,
|
||||
};
|
||||
}
|
||||
|
||||
private handleCall(
|
||||
streamSid: string,
|
||||
callSid: string,
|
||||
ws: WebSocket,
|
||||
callerMeta: Omit<PendingStreamToken, "expiry">,
|
||||
): ActiveRealtimeVoiceBridge | null {
|
||||
const registration = this.registerCallInManager(callSid, callerMeta);
|
||||
if (!registration) {
|
||||
ws.close(1008, "Caller rejected by policy");
|
||||
return null;
|
||||
}
|
||||
|
||||
const { callId, initialGreetingInstructions } = registration;
|
||||
let callEndEmitted = false;
|
||||
const emitCallEnd = (reason: "completed" | "error") => {
|
||||
if (callEndEmitted) {
|
||||
return;
|
||||
}
|
||||
callEndEmitted = true;
|
||||
this.endCallInManager(callSid, callId, reason);
|
||||
};
|
||||
|
||||
const bridgeRef: { current?: ActiveRealtimeVoiceBridge } = {};
|
||||
const bridge = this.realtimeProvider.createBridge({
|
||||
providerConfig: this.providerConfig,
|
||||
instructions: this.config.instructions,
|
||||
tools: this.config.tools,
|
||||
onAudio: (muLaw) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
event: "media",
|
||||
streamSid,
|
||||
media: { payload: muLaw.toString("base64") },
|
||||
}),
|
||||
);
|
||||
},
|
||||
onClearAudio: () => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
ws.send(JSON.stringify({ event: "clear", streamSid }));
|
||||
},
|
||||
onMark: (markName) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
ws.send(JSON.stringify({ event: "mark", streamSid, mark: { name: markName } }));
|
||||
},
|
||||
onTranscript: (role, text, isFinal) => {
|
||||
if (!isFinal) {
|
||||
return;
|
||||
}
|
||||
if (role === "user") {
|
||||
const event: NormalizedEvent = {
|
||||
id: `realtime-speech-${callSid}-${Date.now()}`,
|
||||
type: "call.speech",
|
||||
callId,
|
||||
providerCallId: callSid,
|
||||
timestamp: Date.now(),
|
||||
transcript: text,
|
||||
isFinal: true,
|
||||
};
|
||||
this.manager.processEvent(event);
|
||||
return;
|
||||
}
|
||||
this.manager.processEvent({
|
||||
id: `realtime-bot-${callSid}-${Date.now()}`,
|
||||
type: "call.speaking",
|
||||
callId,
|
||||
providerCallId: callSid,
|
||||
timestamp: Date.now(),
|
||||
text,
|
||||
});
|
||||
},
|
||||
onToolCall: (toolEvent) => {
|
||||
const activeBridge = bridgeRef.current;
|
||||
if (!activeBridge) {
|
||||
return;
|
||||
}
|
||||
void this.executeToolCall(
|
||||
activeBridge,
|
||||
callId,
|
||||
toolEvent.callId || toolEvent.itemId,
|
||||
toolEvent.name,
|
||||
toolEvent.args,
|
||||
);
|
||||
},
|
||||
onReady: () => {
|
||||
bridgeRef.current?.triggerGreeting?.(initialGreetingInstructions);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("[voice-call] realtime voice error:", error.message);
|
||||
},
|
||||
onClose: (reason) => {
|
||||
if (reason !== "error") {
|
||||
return;
|
||||
}
|
||||
emitCallEnd("error");
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close(1011, "Bridge disconnected");
|
||||
}
|
||||
void this.provider
|
||||
.hangupCall({ callId, providerCallId: callSid, reason: "error" })
|
||||
.catch((error: unknown) => {
|
||||
console.warn(
|
||||
`[voice-call] Failed to hang up realtime call ${callSid}: ${formatErrorMessage(
|
||||
error,
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
bridgeRef.current = bridge;
|
||||
|
||||
bridge.connect().catch((error: Error) => {
|
||||
console.error("[voice-call] Failed to connect realtime bridge:", error);
|
||||
bridge.close();
|
||||
emitCallEnd("error");
|
||||
ws.close(1011, "Failed to connect");
|
||||
});
|
||||
|
||||
return bridge;
|
||||
}
|
||||
|
||||
private registerCallInManager(
|
||||
callSid: string,
|
||||
callerMeta: Omit<PendingStreamToken, "expiry"> = {},
|
||||
): CallRegistration | null {
|
||||
const timestamp = Date.now();
|
||||
const baseFields = {
|
||||
providerCallId: callSid,
|
||||
timestamp,
|
||||
direction: callerMeta.direction ?? "inbound",
|
||||
...(callerMeta.from ? { from: callerMeta.from } : {}),
|
||||
...(callerMeta.to ? { to: callerMeta.to } : {}),
|
||||
};
|
||||
|
||||
this.manager.processEvent({
|
||||
id: `realtime-initiated-${callSid}`,
|
||||
callId: callSid,
|
||||
type: "call.initiated",
|
||||
...baseFields,
|
||||
});
|
||||
|
||||
const callRecord = this.manager.getCallByProviderCallId(callSid);
|
||||
if (!callRecord) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialGreeting = this.extractInitialGreeting(callRecord);
|
||||
if (callRecord.metadata) {
|
||||
delete callRecord.metadata.initialMessage;
|
||||
}
|
||||
|
||||
this.manager.processEvent({
|
||||
id: `realtime-answered-${callSid}`,
|
||||
callId: callSid,
|
||||
type: "call.answered",
|
||||
...baseFields,
|
||||
});
|
||||
|
||||
return {
|
||||
callId: callRecord.callId,
|
||||
initialGreetingInstructions: buildGreetingInstructions(
|
||||
this.config.instructions,
|
||||
initialGreeting,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private extractInitialGreeting(call: CallRecord): string | undefined {
|
||||
return typeof call.metadata?.initialMessage === "string"
|
||||
? call.metadata.initialMessage
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private endCallInManager(callSid: string, callId: string, reason: "completed" | "error"): void {
|
||||
this.manager.processEvent({
|
||||
id: `realtime-ended-${callSid}-${Date.now()}`,
|
||||
type: "call.ended",
|
||||
callId,
|
||||
providerCallId: callSid,
|
||||
timestamp: Date.now(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
private async executeToolCall(
|
||||
bridge: ActiveRealtimeVoiceBridge,
|
||||
callId: string,
|
||||
bridgeCallId: string,
|
||||
name: string,
|
||||
args: unknown,
|
||||
): Promise<void> {
|
||||
const handler = this.toolHandlers.get(name);
|
||||
const result = !handler
|
||||
? { error: `Tool "${name}" not available` }
|
||||
: await handler(args, callId).catch((error: unknown) => ({
|
||||
error: formatErrorMessage(error),
|
||||
}));
|
||||
bridge.submitToolResult(bridgeCallId, result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { startStaleCallReaper } from "./stale-call-reaper.js";
|
||||
|
||||
describe("startStaleCallReaper", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-22T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when disabled or non-positive", () => {
|
||||
const manager = {
|
||||
getActiveCalls: vi.fn(() => []),
|
||||
endCall: vi.fn(),
|
||||
};
|
||||
|
||||
expect(startStaleCallReaper({ manager: manager as never })).toBeNull();
|
||||
expect(
|
||||
startStaleCallReaper({ manager: manager as never, staleCallReaperSeconds: 0 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("reaps stale calls and ignores fresh ones", async () => {
|
||||
const endCall = vi.fn(async () => {});
|
||||
const manager = {
|
||||
getActiveCalls: vi.fn(() => [
|
||||
{
|
||||
callId: "call-stale",
|
||||
startedAt: Date.now() - 61_000,
|
||||
state: "active",
|
||||
},
|
||||
{
|
||||
callId: "call-fresh",
|
||||
startedAt: Date.now() - 10_000,
|
||||
state: "active",
|
||||
},
|
||||
]),
|
||||
endCall,
|
||||
};
|
||||
|
||||
const stop = startStaleCallReaper({
|
||||
manager: manager as never,
|
||||
staleCallReaperSeconds: 60,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
expect(endCall).toHaveBeenCalledTimes(1);
|
||||
expect(endCall).toHaveBeenCalledWith("call-stale");
|
||||
|
||||
stop?.();
|
||||
});
|
||||
|
||||
it("logs and swallows endCall failures", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const manager = {
|
||||
getActiveCalls: vi.fn(() => [
|
||||
{
|
||||
callId: "call-stale",
|
||||
startedAt: Date.now() - 61_000,
|
||||
state: "active",
|
||||
},
|
||||
]),
|
||||
endCall: vi.fn(async () => {
|
||||
throw new Error("network");
|
||||
}),
|
||||
};
|
||||
|
||||
const stop = startStaleCallReaper({
|
||||
manager: manager as never,
|
||||
staleCallReaperSeconds: 60,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[voice-call] Reaper failed to end call call-stale:",
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
stop?.();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import type { CallManager } from "../manager.js";
|
||||
|
||||
const CHECK_INTERVAL_MS = 30_000;
|
||||
|
||||
export function startStaleCallReaper(params: {
|
||||
manager: CallManager;
|
||||
staleCallReaperSeconds?: number;
|
||||
}): (() => void) | null {
|
||||
const maxAgeSeconds = params.staleCallReaperSeconds;
|
||||
if (!maxAgeSeconds || maxAgeSeconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxAgeMs = maxAgeSeconds * 1000;
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const call of params.manager.getActiveCalls()) {
|
||||
const age = now - call.startedAt;
|
||||
if (age > maxAgeMs) {
|
||||
console.log(
|
||||
`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`,
|
||||
);
|
||||
void params.manager.endCall(call.callId).catch((err) => {
|
||||
console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, CHECK_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
195
openclaw/extensions/voice-call/src/webhook/tailscale.test.ts
Normal file
195
openclaw/extensions/voice-call/src/webhook/tailscale.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const { mockNodeBuiltinModule } = await import("../../../../test/helpers/node-builtin-mocks.js");
|
||||
return mockNodeBuiltinModule(
|
||||
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
|
||||
{
|
||||
spawn: spawnMock,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
import {
|
||||
cleanupTailscaleExposure,
|
||||
cleanupTailscaleExposureRoute,
|
||||
getTailscaleDnsName,
|
||||
getTailscaleSelfInfo,
|
||||
setupTailscaleExposure,
|
||||
setupTailscaleExposureRoute,
|
||||
} from "./tailscale.js";
|
||||
|
||||
function createProc(params?: { code?: number; stdout?: string }) {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.kill = vi.fn();
|
||||
setTimeout(() => {
|
||||
if (params?.stdout) {
|
||||
proc.stdout.emit("data", Buffer.from(params.stdout));
|
||||
}
|
||||
proc.emit("close", params?.code ?? 0);
|
||||
}, 0);
|
||||
return proc;
|
||||
}
|
||||
|
||||
describe("voice-call tailscale helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("reads dns and node id from tailscale status json", async () => {
|
||||
spawnMock
|
||||
.mockReturnValueOnce(
|
||||
createProc({
|
||||
stdout: JSON.stringify({
|
||||
Self: {
|
||||
DNSName: "bot.example.ts.net.",
|
||||
ID: "node-123",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
createProc({
|
||||
stdout: JSON.stringify({
|
||||
Self: {
|
||||
DNSName: "bot.example.ts.net.",
|
||||
ID: "node-123",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(getTailscaleSelfInfo()).resolves.toEqual({
|
||||
dnsName: "bot.example.ts.net",
|
||||
nodeId: "node-123",
|
||||
});
|
||||
await expect(getTailscaleDnsName()).resolves.toBe("bot.example.ts.net");
|
||||
});
|
||||
|
||||
it("returns null for failing or invalid status responses", async () => {
|
||||
spawnMock.mockReturnValueOnce(createProc({ code: 1, stdout: "bad" }));
|
||||
await expect(getTailscaleSelfInfo()).resolves.toBeNull();
|
||||
|
||||
spawnMock.mockReturnValueOnce(createProc({ stdout: "{not-json" }));
|
||||
await expect(getTailscaleSelfInfo()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("sets up and cleans up exposure routes with the selected mode", async () => {
|
||||
spawnMock
|
||||
.mockReturnValueOnce(
|
||||
createProc({
|
||||
stdout: JSON.stringify({ Self: { DNSName: "bot.example.ts.net." } }),
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(createProc({ code: 0 }))
|
||||
.mockReturnValueOnce(createProc({ code: 0 }));
|
||||
|
||||
await expect(
|
||||
setupTailscaleExposureRoute({
|
||||
mode: "serve",
|
||||
path: "/voice",
|
||||
localUrl: "http://127.0.0.1:8787/webhook",
|
||||
}),
|
||||
).resolves.toBe("https://bot.example.ts.net/voice");
|
||||
|
||||
await cleanupTailscaleExposureRoute({ mode: "serve", path: "/voice" });
|
||||
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"tailscale",
|
||||
["status", "--json"],
|
||||
expect.objectContaining({ stdio: ["ignore", "pipe", "pipe"] }),
|
||||
);
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"tailscale",
|
||||
["serve", "--bg", "--yes", "--set-path", "/voice", "http://127.0.0.1:8787/webhook"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"tailscale",
|
||||
["serve", "off", "/voice"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when setup cannot resolve dns or route activation fails", async () => {
|
||||
spawnMock
|
||||
.mockReturnValueOnce(createProc({ code: 1 }))
|
||||
.mockReturnValueOnce(
|
||||
createProc({
|
||||
stdout: JSON.stringify({ Self: { DNSName: "bot.example.ts.net." } }),
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(createProc({ code: 1 }));
|
||||
|
||||
await expect(
|
||||
setupTailscaleExposureRoute({
|
||||
mode: "funnel",
|
||||
path: "/voice",
|
||||
localUrl: "http://127.0.0.1:8787/webhook",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await expect(
|
||||
setupTailscaleExposureRoute({
|
||||
mode: "funnel",
|
||||
path: "/voice",
|
||||
localUrl: "http://127.0.0.1:8787/webhook",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("maps config modes to serve or funnel and skips off", async () => {
|
||||
spawnMock
|
||||
.mockReturnValueOnce(
|
||||
createProc({
|
||||
stdout: JSON.stringify({ Self: { DNSName: "bot.example.ts.net." } }),
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(createProc({ code: 0 }))
|
||||
.mockReturnValueOnce(createProc({ code: 0 }));
|
||||
|
||||
await expect(
|
||||
setupTailscaleExposure({
|
||||
tailscale: { mode: "off", path: "/voice" },
|
||||
serve: { port: 8787, path: "/webhook" },
|
||||
} as never),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await expect(
|
||||
setupTailscaleExposure({
|
||||
tailscale: { mode: "funnel", path: "/voice" },
|
||||
serve: { port: 8787, path: "/webhook" },
|
||||
} as never),
|
||||
).resolves.toBe("https://bot.example.ts.net/voice");
|
||||
|
||||
await cleanupTailscaleExposure({
|
||||
tailscale: { mode: "serve", path: "/voice" },
|
||||
serve: { port: 8787, path: "/webhook" },
|
||||
} as never);
|
||||
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"tailscale",
|
||||
["funnel", "--bg", "--yes", "--set-path", "/voice", "http://127.0.0.1:8787/webhook"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"tailscale",
|
||||
["serve", "off", "/voice"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
115
openclaw/extensions/voice-call/src/webhook/tailscale.ts
Normal file
115
openclaw/extensions/voice-call/src/webhook/tailscale.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import type { VoiceCallConfig } from "../config.js";
|
||||
|
||||
export type TailscaleSelfInfo = {
|
||||
dnsName: string | null;
|
||||
nodeId: string | null;
|
||||
};
|
||||
|
||||
function runTailscaleCommand(
|
||||
args: string[],
|
||||
timeoutMs = 2500,
|
||||
): Promise<{ code: number; stdout: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("tailscale", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data;
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill("SIGKILL");
|
||||
resolve({ code: -1, stdout: "" });
|
||||
}, timeoutMs);
|
||||
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code: code ?? -1, stdout });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTailscaleSelfInfo(): Promise<TailscaleSelfInfo | null> {
|
||||
const { code, stdout } = await runTailscaleCommand(["status", "--json"]);
|
||||
if (code !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = JSON.parse(stdout);
|
||||
return {
|
||||
dnsName: status.Self?.DNSName?.replace(/\.$/, "") || null,
|
||||
nodeId: status.Self?.ID || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTailscaleDnsName(): Promise<string | null> {
|
||||
const info = await getTailscaleSelfInfo();
|
||||
return info?.dnsName ?? null;
|
||||
}
|
||||
|
||||
export async function setupTailscaleExposureRoute(opts: {
|
||||
mode: "serve" | "funnel";
|
||||
path: string;
|
||||
localUrl: string;
|
||||
}): Promise<string | null> {
|
||||
const dnsName = await getTailscaleDnsName();
|
||||
if (!dnsName) {
|
||||
console.warn("[voice-call] Could not get Tailscale DNS name");
|
||||
return null;
|
||||
}
|
||||
|
||||
const { code } = await runTailscaleCommand([
|
||||
opts.mode,
|
||||
"--bg",
|
||||
"--yes",
|
||||
"--set-path",
|
||||
opts.path,
|
||||
opts.localUrl,
|
||||
]);
|
||||
|
||||
if (code === 0) {
|
||||
const publicUrl = `https://${dnsName}${opts.path}`;
|
||||
console.log(`[voice-call] Tailscale ${opts.mode} active: ${publicUrl}`);
|
||||
return publicUrl;
|
||||
}
|
||||
|
||||
console.warn(`[voice-call] Tailscale ${opts.mode} failed`);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function cleanupTailscaleExposureRoute(opts: {
|
||||
mode: "serve" | "funnel";
|
||||
path: string;
|
||||
}): Promise<void> {
|
||||
await runTailscaleCommand([opts.mode, "off", opts.path]);
|
||||
}
|
||||
|
||||
export async function setupTailscaleExposure(config: VoiceCallConfig): Promise<string | null> {
|
||||
if (config.tailscale.mode === "off") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
|
||||
const localUrl = `http://127.0.0.1:${config.serve.port}${config.serve.path}`;
|
||||
return setupTailscaleExposureRoute({
|
||||
mode,
|
||||
path: config.tailscale.path,
|
||||
localUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cleanupTailscaleExposure(config: VoiceCallConfig): Promise<void> {
|
||||
if (config.tailscale.mode === "off") {
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
|
||||
await cleanupTailscaleExposureRoute({ mode, path: config.tailscale.path });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue