重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -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();
}
});
});

View 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);
}
}

View file

@ -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?.();
});
});

View file

@ -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);
};
}

View 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),
);
});
});

View 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 });
}