重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 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,18 @@
import { describe, expect, it } from "vitest";
import { isAllowlistedCaller, normalizePhoneNumber } from "./allowlist.js";
describe("voice-call allowlist", () => {
it("normalizes phone numbers by stripping non-digits", () => {
expect(normalizePhoneNumber("+1 (415) 555-0123")).toBe("14155550123");
expect(normalizePhoneNumber(" 020-7946-0958 ")).toBe("02079460958");
expect(normalizePhoneNumber("")).toBe("");
expect(normalizePhoneNumber()).toBe("");
});
it("matches normalized allowlist entries and rejects blank callers", () => {
expect(isAllowlistedCaller("14155550123", ["+1 (415) 555-0123", " 020-7946-0958 "])).toBe(true);
expect(isAllowlistedCaller("02079460958", ["+1 (415) 555-0123", " 020-7946-0958 "])).toBe(true);
expect(isAllowlistedCaller("", ["+1 (415) 555-0123"])).toBe(false);
expect(isAllowlistedCaller("14155550123", ["", "abc"])).toBe(false);
});
});

View file

@ -0,0 +1,19 @@
export function normalizePhoneNumber(input?: string): string {
if (!input) {
return "";
}
return input.replace(/\D/g, "");
}
export function isAllowlistedCaller(
normalizedFrom: string,
allowFrom: string[] | undefined,
): boolean {
if (!normalizedFrom) {
return false;
}
return (allowFrom ?? []).some((num) => {
const normalizedAllow = normalizePhoneNumber(num);
return normalizedAllow !== "" && normalizedAllow === normalizedFrom;
});
}

View file

@ -0,0 +1,368 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { format } from "node:util";
import type { Command } from "commander";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import { sleep } from "../api.js";
import type { VoiceCallConfig } from "./config.js";
import type { VoiceCallRuntime } from "./runtime.js";
import { resolveUserPath } from "./utils.js";
import {
cleanupTailscaleExposureRoute,
getTailscaleSelfInfo,
setupTailscaleExposureRoute,
} from "./webhook/tailscale.js";
type Logger = {
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
};
function writeStdoutLine(...values: unknown[]): void {
process.stdout.write(`${format(...values)}\n`);
}
function writeStdoutJson(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function resolveMode(input: string): "off" | "serve" | "funnel" {
const raw = normalizeOptionalLowercaseString(input) ?? "";
if (raw === "serve" || raw === "off") {
return raw;
}
return "funnel";
}
function resolveDefaultStorePath(config: VoiceCallConfig): string {
const preferred = path.join(os.homedir(), ".openclaw", "voice-calls");
const resolvedPreferred = resolveUserPath(preferred);
const existing =
[resolvedPreferred].find((dir) => {
try {
return fs.existsSync(path.join(dir, "calls.jsonl")) || fs.existsSync(dir);
} catch {
return false;
}
}) ?? resolvedPreferred;
const base = config.store?.trim() ? resolveUserPath(config.store) : existing;
return path.join(base, "calls.jsonl");
}
function percentile(values: number[], p: number): number {
if (values.length === 0) {
return 0;
}
const sorted = [...values].toSorted((a, b) => a - b);
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
return sorted[idx] ?? 0;
}
function summarizeSeries(values: number[]): {
count: number;
minMs: number;
maxMs: number;
avgMs: number;
p50Ms: number;
p95Ms: number;
} {
if (values.length === 0) {
return { count: 0, minMs: 0, maxMs: 0, avgMs: 0, p50Ms: 0, p95Ms: 0 };
}
const minMs = values.reduce(
(min, value) => (value < min ? value : min),
Number.POSITIVE_INFINITY,
);
const maxMs = values.reduce(
(max, value) => (value > max ? value : max),
Number.NEGATIVE_INFINITY,
);
const avgMs = values.reduce((sum, value) => sum + value, 0) / values.length;
return {
count: values.length,
minMs,
maxMs,
avgMs,
p50Ms: percentile(values, 50),
p95Ms: percentile(values, 95),
};
}
function resolveCallMode(mode?: string): "notify" | "conversation" | undefined {
return mode === "notify" || mode === "conversation" ? mode : undefined;
}
async function initiateCallAndPrintId(params: {
runtime: VoiceCallRuntime;
to: string;
message?: string;
mode?: string;
}) {
const result = await params.runtime.manager.initiateCall(params.to, undefined, {
message: params.message,
mode: resolveCallMode(params.mode),
});
if (!result.success) {
throw new Error(result.error || "initiate failed");
}
writeStdoutJson({ callId: result.callId });
}
export function registerVoiceCallCli(params: {
program: Command;
config: VoiceCallConfig;
ensureRuntime: () => Promise<VoiceCallRuntime>;
logger: Logger;
}) {
const { program, config, ensureRuntime, logger } = params;
const root = program
.command("voicecall")
.description("Voice call utilities")
.addHelpText("after", () => `\nDocs: https://docs.openclaw.ai/cli/voicecall\n`);
root
.command("call")
.description("Initiate an outbound voice call")
.requiredOption("-m, --message <text>", "Message to speak when call connects")
.option(
"-t, --to <phone>",
"Phone number to call (E.164 format, uses config toNumber if not set)",
)
.option(
"--mode <mode>",
"Call mode: notify (hangup after message) or conversation (stay open)",
"conversation",
)
.action(async (options: { message: string; to?: string; mode?: string }) => {
const rt = await ensureRuntime();
const to = options.to ?? rt.config.toNumber;
if (!to) {
throw new Error("Missing --to and no toNumber configured");
}
await initiateCallAndPrintId({
runtime: rt,
to,
message: options.message,
mode: options.mode,
});
});
root
.command("start")
.description("Alias for voicecall call")
.requiredOption("--to <phone>", "Phone number to call")
.option("--message <text>", "Message to speak when call connects")
.option(
"--mode <mode>",
"Call mode: notify (hangup after message) or conversation (stay open)",
"conversation",
)
.action(async (options: { to: string; message?: string; mode?: string }) => {
const rt = await ensureRuntime();
await initiateCallAndPrintId({
runtime: rt,
to: options.to,
message: options.message,
mode: options.mode,
});
});
root
.command("continue")
.description("Speak a message and wait for a response")
.requiredOption("--call-id <id>", "Call ID")
.requiredOption("--message <text>", "Message to speak")
.action(async (options: { callId: string; message: string }) => {
const rt = await ensureRuntime();
const result = await rt.manager.continueCall(options.callId, options.message);
if (!result.success) {
throw new Error(result.error || "continue failed");
}
writeStdoutJson(result);
});
root
.command("speak")
.description("Speak a message without waiting for response")
.requiredOption("--call-id <id>", "Call ID")
.requiredOption("--message <text>", "Message to speak")
.action(async (options: { callId: string; message: string }) => {
const rt = await ensureRuntime();
const result = await rt.manager.speak(options.callId, options.message);
if (!result.success) {
throw new Error(result.error || "speak failed");
}
writeStdoutJson(result);
});
root
.command("end")
.description("Hang up an active call")
.requiredOption("--call-id <id>", "Call ID")
.action(async (options: { callId: string }) => {
const rt = await ensureRuntime();
const result = await rt.manager.endCall(options.callId);
if (!result.success) {
throw new Error(result.error || "end failed");
}
writeStdoutJson(result);
});
root
.command("status")
.description("Show call status")
.requiredOption("--call-id <id>", "Call ID")
.action(async (options: { callId: string }) => {
const rt = await ensureRuntime();
const call = rt.manager.getCall(options.callId);
writeStdoutJson(call ?? { found: false });
});
root
.command("tail")
.description("Tail voice-call JSONL logs (prints new lines; useful during provider tests)")
.option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config))
.option("--since <n>", "Print last N lines first", "25")
.option("--poll <ms>", "Poll interval in ms", "250")
.action(async (options: { file: string; since?: string; poll?: string }) => {
const file = options.file;
const since = Math.max(0, Number(options.since ?? 0));
const pollMs = Math.max(50, Number(options.poll ?? 250));
if (!fs.existsSync(file)) {
logger.error(`No log file at ${file}`);
process.exit(1);
}
const initial = fs.readFileSync(file, "utf8");
const lines = initial.split("\n").filter(Boolean);
for (const line of lines.slice(Math.max(0, lines.length - since))) {
writeStdoutLine(line);
}
let offset = Buffer.byteLength(initial, "utf8");
for (;;) {
try {
const stat = fs.statSync(file);
if (stat.size < offset) {
offset = 0;
}
if (stat.size > offset) {
const fd = fs.openSync(file, "r");
try {
const buf = Buffer.alloc(stat.size - offset);
fs.readSync(fd, buf, 0, buf.length, offset);
offset = stat.size;
const text = buf.toString("utf8");
for (const line of text.split("\n").filter(Boolean)) {
writeStdoutLine(line);
}
} finally {
fs.closeSync(fd);
}
}
} catch {
// ignore and retry
}
await sleep(pollMs);
}
});
root
.command("latency")
.description("Summarize turn latency metrics from voice-call JSONL logs")
.option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config))
.option("--last <n>", "Analyze last N records", "200")
.action(async (options: { file: string; last?: string }) => {
const file = options.file;
const last = Math.max(1, Number(options.last ?? 200));
if (!fs.existsSync(file)) {
throw new Error("No log file at " + file);
}
const content = fs.readFileSync(file, "utf8");
const lines = content.split("\n").filter(Boolean).slice(-last);
const turnLatencyMs: number[] = [];
const listenWaitMs: number[] = [];
for (const line of lines) {
try {
const parsed = JSON.parse(line) as {
metadata?: { lastTurnLatencyMs?: unknown; lastTurnListenWaitMs?: unknown };
};
const latency = parsed.metadata?.lastTurnLatencyMs;
const listenWait = parsed.metadata?.lastTurnListenWaitMs;
if (typeof latency === "number" && Number.isFinite(latency)) {
turnLatencyMs.push(latency);
}
if (typeof listenWait === "number" && Number.isFinite(listenWait)) {
listenWaitMs.push(listenWait);
}
} catch {
// ignore malformed JSON lines
}
}
writeStdoutJson({
recordsScanned: lines.length,
turnLatency: summarizeSeries(turnLatencyMs),
listenWait: summarizeSeries(listenWaitMs),
});
});
root
.command("expose")
.description("Enable/disable Tailscale serve/funnel for the webhook")
.option("--mode <mode>", "off | serve (tailnet) | funnel (public)", "funnel")
.option("--path <path>", "Tailscale path to expose (recommend matching serve.path)")
.option("--port <port>", "Local webhook port")
.option("--serve-path <path>", "Local webhook path")
.action(
async (options: { mode?: string; port?: string; path?: string; servePath?: string }) => {
const mode = resolveMode(options.mode ?? "funnel");
const servePort = Number(options.port ?? config.serve.port ?? 3334);
const servePath = options.servePath ?? config.serve.path ?? "/voice/webhook";
const tsPath = options.path ?? config.tailscale?.path ?? servePath;
const localUrl = `http://127.0.0.1:${servePort}`;
if (mode === "off") {
await cleanupTailscaleExposureRoute({ mode: "serve", path: tsPath });
await cleanupTailscaleExposureRoute({ mode: "funnel", path: tsPath });
writeStdoutJson({ ok: true, mode: "off", path: tsPath });
return;
}
const publicUrl = await setupTailscaleExposureRoute({
mode,
path: tsPath,
localUrl,
});
const tsInfo = publicUrl ? null : await getTailscaleSelfInfo();
const enableUrl = tsInfo?.nodeId
? `https://login.tailscale.com/f/${mode}?node=${tsInfo.nodeId}`
: null;
writeStdoutJson({
ok: Boolean(publicUrl),
mode,
path: tsPath,
localUrl,
publicUrl,
hint: publicUrl
? undefined
: {
note: "Tailscale serve/funnel may be disabled on this tailnet (or require admin enable).",
enableUrl,
},
});
},
);
}

View file

@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import {
VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION,
collectVoiceCallLegacyConfigIssues,
formatVoiceCallLegacyConfigWarnings,
migrateVoiceCallLegacyConfigInput,
normalizeVoiceCallLegacyConfigInput,
parseVoiceCallPluginConfig,
} from "./config-compat.js";
describe("voice-call config compatibility", () => {
it("maps deprecated provider and twilio.from fields into canonical config", () => {
const parsed = parseVoiceCallPluginConfig({
enabled: true,
provider: "log",
twilio: {
from: "+15550001234",
},
});
expect(parsed.provider).toBe("mock");
expect(parsed.fromNumber).toBe("+15550001234");
});
it("moves legacy streaming OpenAI fields into streaming.providers.openai", () => {
const normalized = normalizeVoiceCallLegacyConfigInput({
streaming: {
enabled: true,
sttProvider: "openai",
openaiApiKey: "sk-test", // pragma: allowlist secret
sttModel: "gpt-4o-transcribe",
silenceDurationMs: 700,
vadThreshold: 0.4,
},
});
expect(normalized).toMatchObject({
streaming: {
enabled: true,
provider: "openai",
providers: {
openai: {
apiKey: "sk-test",
model: "gpt-4o-transcribe",
silenceDurationMs: 700,
vadThreshold: 0.4,
},
},
},
});
expect((normalized.streaming as Record<string, unknown>).openaiApiKey).toBeUndefined();
expect((normalized.streaming as Record<string, unknown>).sttModel).toBeUndefined();
});
it("reports doctor-oriented legacy issues and warnings", () => {
const raw = {
provider: "log",
twilio: {
from: "+15550001234",
},
streaming: {
sttProvider: "openai",
openaiApiKey: "sk-test", // pragma: allowlist secret
},
};
expect(collectVoiceCallLegacyConfigIssues(raw)).toEqual([
{
path: "provider",
replacement: "provider",
message: 'Replace provider "log" with "mock".',
},
{
path: "twilio.from",
replacement: "fromNumber",
message: "Move twilio.from to fromNumber.",
},
{
path: "streaming.sttProvider",
replacement: "streaming.provider",
message: "Move streaming.sttProvider to streaming.provider.",
},
{
path: "streaming.openaiApiKey",
replacement: "streaming.providers.openai.apiKey",
message: "Move streaming.openaiApiKey to streaming.providers.openai.apiKey.",
},
]);
expect(
formatVoiceCallLegacyConfigWarnings({
value: raw,
configPathPrefix: "plugins.entries.voice-call.config",
doctorFixCommand: "openclaw doctor --fix",
}),
).toEqual([
`[voice-call] legacy config keys detected under plugins.entries.voice-call.config; runtime loading will not rewrite them, and support for the legacy shape will be removed in ${VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION}. Run "openclaw doctor --fix".`,
'[voice-call] plugins.entries.voice-call.config.provider: Replace provider "log" with "mock".',
"[voice-call] plugins.entries.voice-call.config.twilio.from: Move twilio.from to fromNumber.",
"[voice-call] plugins.entries.voice-call.config.streaming.sttProvider: Move streaming.sttProvider to streaming.provider.",
"[voice-call] plugins.entries.voice-call.config.streaming.openaiApiKey: Move streaming.openaiApiKey to streaming.providers.openai.apiKey.",
]);
});
it("returns doctor migration change lines", () => {
const migration = migrateVoiceCallLegacyConfigInput({
value: {
provider: "log",
streaming: {
sttProvider: "openai",
},
},
configPathPrefix: "plugins.entries.voice-call.config",
});
expect(migration.changes).toEqual([
'Moved plugins.entries.voice-call.config.provider "log" → "mock".',
"Moved plugins.entries.voice-call.config.streaming.sttProvider → plugins.entries.voice-call.config.streaming.provider.",
]);
});
});

View file

@ -0,0 +1,227 @@
import { asOptionalRecord, readStringField } from "openclaw/plugin-sdk/text-runtime";
import type { VoiceCallConfig } from "./config.js";
import { VoiceCallConfigSchema } from "./config.js";
export const VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION = "2026.6.0";
export type VoiceCallLegacyConfigIssue = {
path: string;
replacement: string;
message: string;
};
const asObject = asOptionalRecord;
const getString = readStringField;
function getNumber(obj: Record<string, unknown> | undefined, key: string): number | undefined {
const value = obj?.[key];
return typeof value === "number" ? value : undefined;
}
function mergeProviderConfig(
providersValue: unknown,
providerId: string,
compatValues: Record<string, unknown>,
): Record<string, unknown> | undefined {
if (Object.keys(compatValues).length === 0) {
return asObject(providersValue);
}
const providers = asObject(providersValue) ?? {};
const existing = asObject(providers[providerId]) ?? {};
return {
...providers,
[providerId]: {
...existing,
...compatValues,
},
};
}
export function collectVoiceCallLegacyConfigIssues(value: unknown): VoiceCallLegacyConfigIssue[] {
const raw = asObject(value) ?? {};
const twilio = asObject(raw.twilio);
const streaming = asObject(raw.streaming);
const issues: VoiceCallLegacyConfigIssue[] = [];
if (raw.provider === "log") {
issues.push({
path: "provider",
replacement: "provider",
message: 'Replace provider "log" with "mock".',
});
}
if (typeof twilio?.from === "string") {
issues.push({
path: "twilio.from",
replacement: "fromNumber",
message: "Move twilio.from to fromNumber.",
});
}
if (typeof streaming?.sttProvider === "string") {
issues.push({
path: "streaming.sttProvider",
replacement: "streaming.provider",
message: "Move streaming.sttProvider to streaming.provider.",
});
}
if (typeof streaming?.openaiApiKey === "string") {
issues.push({
path: "streaming.openaiApiKey",
replacement: "streaming.providers.openai.apiKey",
message: "Move streaming.openaiApiKey to streaming.providers.openai.apiKey.",
});
}
if (typeof streaming?.sttModel === "string") {
issues.push({
path: "streaming.sttModel",
replacement: "streaming.providers.openai.model",
message: "Move streaming.sttModel to streaming.providers.openai.model.",
});
}
if (typeof streaming?.silenceDurationMs === "number") {
issues.push({
path: "streaming.silenceDurationMs",
replacement: "streaming.providers.openai.silenceDurationMs",
message: "Move streaming.silenceDurationMs to streaming.providers.openai.silenceDurationMs.",
});
}
if (typeof streaming?.vadThreshold === "number") {
issues.push({
path: "streaming.vadThreshold",
replacement: "streaming.providers.openai.vadThreshold",
message: "Move streaming.vadThreshold to streaming.providers.openai.vadThreshold.",
});
}
return issues;
}
export function formatVoiceCallLegacyConfigWarnings(params: {
value: unknown;
configPathPrefix: string;
doctorFixCommand: string;
}): string[] {
const issues = collectVoiceCallLegacyConfigIssues(params.value);
if (issues.length === 0) {
return [];
}
return [
`[voice-call] legacy config keys detected under ${params.configPathPrefix}; runtime loading will not rewrite them, and support for the legacy shape will be removed in ${VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION}. Run "${params.doctorFixCommand}".`,
...issues.map(
(issue) => `[voice-call] ${params.configPathPrefix}.${issue.path}: ${issue.message}`,
),
];
}
export function migrateVoiceCallLegacyConfigInput(params: {
value: unknown;
configPathPrefix?: string;
}): {
config: Record<string, unknown>;
changes: string[];
issues: VoiceCallLegacyConfigIssue[];
} {
const raw = asObject(params.value) ?? {};
const twilio = asObject(raw.twilio);
const streaming = asObject(raw.streaming);
const configPathPrefix = params.configPathPrefix ?? "plugins.entries.voice-call.config";
const issues = collectVoiceCallLegacyConfigIssues(raw);
const legacyStreamingOpenAICompat: Record<string, unknown> = {};
const streamingOpenAIApiKey = getString(streaming, "openaiApiKey");
if (streamingOpenAIApiKey) {
legacyStreamingOpenAICompat.apiKey = streamingOpenAIApiKey;
}
const streamingSttModel = getString(streaming, "sttModel");
if (streamingSttModel) {
legacyStreamingOpenAICompat.model = streamingSttModel;
}
const streamingSilenceDurationMs = getNumber(streaming, "silenceDurationMs");
if (streamingSilenceDurationMs !== undefined) {
legacyStreamingOpenAICompat.silenceDurationMs = streamingSilenceDurationMs;
}
const streamingVadThreshold = getNumber(streaming, "vadThreshold");
if (streamingVadThreshold !== undefined) {
legacyStreamingOpenAICompat.vadThreshold = streamingVadThreshold;
}
const streamingProvider = getString(streaming, "provider");
const legacyStreamingProvider = getString(streaming, "sttProvider");
const normalizedStreaming: Record<string, unknown> | undefined = streaming
? {
...streaming,
provider: streamingProvider ?? legacyStreamingProvider,
providers: mergeProviderConfig(streaming.providers, "openai", legacyStreamingOpenAICompat),
}
: undefined;
if (normalizedStreaming) {
delete normalizedStreaming.sttProvider;
delete normalizedStreaming.openaiApiKey;
delete normalizedStreaming.sttModel;
delete normalizedStreaming.silenceDurationMs;
delete normalizedStreaming.vadThreshold;
}
const normalizedTwilio = twilio
? {
...twilio,
}
: undefined;
if (normalizedTwilio) {
delete normalizedTwilio.from;
}
const config = {
...raw,
provider: raw.provider === "log" ? "mock" : raw.provider,
fromNumber: raw.fromNumber ?? (typeof twilio?.from === "string" ? twilio.from : undefined),
twilio: normalizedTwilio,
streaming: normalizedStreaming,
};
const changes: string[] = [];
if (raw.provider === "log") {
changes.push(`Moved ${configPathPrefix}.provider "log" → "mock".`);
}
if (typeof twilio?.from === "string" && typeof raw.fromNumber !== "string") {
changes.push(`Moved ${configPathPrefix}.twilio.from → ${configPathPrefix}.fromNumber.`);
}
if (typeof streaming?.sttProvider === "string") {
changes.push(
`Moved ${configPathPrefix}.streaming.sttProvider → ${configPathPrefix}.streaming.provider.`,
);
}
if (typeof streaming?.openaiApiKey === "string") {
changes.push(
`Moved ${configPathPrefix}.streaming.openaiApiKey → ${configPathPrefix}.streaming.providers.openai.apiKey.`,
);
}
if (typeof streaming?.sttModel === "string") {
changes.push(
`Moved ${configPathPrefix}.streaming.sttModel → ${configPathPrefix}.streaming.providers.openai.model.`,
);
}
if (typeof streaming?.silenceDurationMs === "number") {
changes.push(
`Moved ${configPathPrefix}.streaming.silenceDurationMs → ${configPathPrefix}.streaming.providers.openai.silenceDurationMs.`,
);
}
if (typeof streaming?.vadThreshold === "number") {
changes.push(
`Moved ${configPathPrefix}.streaming.vadThreshold → ${configPathPrefix}.streaming.providers.openai.vadThreshold.`,
);
}
return { config, changes, issues };
}
export function normalizeVoiceCallLegacyConfigInput(value: unknown): Record<string, unknown> {
return migrateVoiceCallLegacyConfigInput({ value }).config;
}
export function parseVoiceCallPluginConfig(value: unknown): VoiceCallConfig {
return VoiceCallConfigSchema.parse(normalizeVoiceCallLegacyConfigInput(value));
}

View file

@ -0,0 +1,298 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
validateProviderConfig,
normalizeVoiceCallConfig,
resolveVoiceCallConfig,
type VoiceCallConfig,
} from "./config.js";
import { createVoiceCallBaseConfig } from "./test-fixtures.js";
function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): VoiceCallConfig {
return createVoiceCallBaseConfig({ provider });
}
function requireElevenLabsTtsConfig(config: Pick<VoiceCallConfig, "tts">) {
const tts = config.tts;
const elevenlabs = tts?.providers?.elevenlabs;
if (!elevenlabs || typeof elevenlabs !== "object") {
throw new Error("voice-call config did not preserve nested elevenlabs TTS config");
}
return { tts, elevenlabs };
}
describe("validateProviderConfig", () => {
const originalEnv = { ...process.env };
const clearProviderEnv = () => {
delete process.env.TWILIO_ACCOUNT_SID;
delete process.env.TWILIO_AUTH_TOKEN;
delete process.env.TELNYX_API_KEY;
delete process.env.TELNYX_CONNECTION_ID;
delete process.env.TELNYX_PUBLIC_KEY;
delete process.env.PLIVO_AUTH_ID;
delete process.env.PLIVO_AUTH_TOKEN;
};
beforeEach(() => {
clearProviderEnv();
});
afterEach(() => {
// Restore original env
process.env = { ...originalEnv };
});
describe("provider credential sources", () => {
it("passes validation when credentials come from config or environment", () => {
for (const provider of ["twilio", "telnyx", "plivo"] as const) {
clearProviderEnv();
const fromConfig = createBaseConfig(provider);
if (provider === "twilio") {
fromConfig.twilio = { accountSid: "AC123", authToken: "secret" };
} else if (provider === "telnyx") {
fromConfig.telnyx = {
apiKey: "KEY123",
connectionId: "CONN456",
publicKey: "public-key",
};
} else {
fromConfig.plivo = { authId: "MA123", authToken: "secret" };
}
expect(validateProviderConfig(fromConfig)).toMatchObject({ valid: true, errors: [] });
clearProviderEnv();
if (provider === "twilio") {
process.env.TWILIO_ACCOUNT_SID = "AC123";
process.env.TWILIO_AUTH_TOKEN = "secret";
} else if (provider === "telnyx") {
process.env.TELNYX_API_KEY = "KEY123";
process.env.TELNYX_CONNECTION_ID = "CONN456";
process.env.TELNYX_PUBLIC_KEY = "public-key";
} else {
process.env.PLIVO_AUTH_ID = "MA123";
process.env.PLIVO_AUTH_TOKEN = "secret";
}
const fromEnv = resolveVoiceCallConfig(createBaseConfig(provider));
expect(validateProviderConfig(fromEnv)).toMatchObject({ valid: true, errors: [] });
}
});
});
describe("twilio provider", () => {
it("passes validation with mixed config and env vars", () => {
process.env.TWILIO_AUTH_TOKEN = "secret";
let config = createBaseConfig("twilio");
config.twilio = { accountSid: "AC123" };
config = resolveVoiceCallConfig(config);
const result = validateProviderConfig(config);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("fails validation when required twilio credentials are missing", () => {
process.env.TWILIO_AUTH_TOKEN = "secret";
const missingSid = validateProviderConfig(resolveVoiceCallConfig(createBaseConfig("twilio")));
expect(missingSid.valid).toBe(false);
expect(missingSid.errors).toContain(
"plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)",
);
delete process.env.TWILIO_AUTH_TOKEN;
process.env.TWILIO_ACCOUNT_SID = "AC123";
const missingToken = validateProviderConfig(
resolveVoiceCallConfig(createBaseConfig("twilio")),
);
expect(missingToken.valid).toBe(false);
expect(missingToken.errors).toContain(
"plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)",
);
});
});
describe("telnyx provider", () => {
it("fails validation when apiKey is missing everywhere", () => {
process.env.TELNYX_CONNECTION_ID = "CONN456";
let config = createBaseConfig("telnyx");
config = resolveVoiceCallConfig(config);
const result = validateProviderConfig(config);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
"plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)",
);
});
it("requires a public key unless signature verification is skipped", () => {
const missingPublicKey = createBaseConfig("telnyx");
missingPublicKey.inboundPolicy = "allowlist";
missingPublicKey.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
const missingPublicKeyResult = validateProviderConfig(missingPublicKey);
expect(missingPublicKeyResult.valid).toBe(false);
expect(missingPublicKeyResult.errors).toContain(
"plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)",
);
const withPublicKey = createBaseConfig("telnyx");
withPublicKey.inboundPolicy = "allowlist";
withPublicKey.telnyx = {
apiKey: "KEY123",
connectionId: "CONN456",
publicKey: "public-key",
};
expect(validateProviderConfig(withPublicKey)).toMatchObject({ valid: true, errors: [] });
const skippedVerification = createBaseConfig("telnyx");
skippedVerification.skipSignatureVerification = true;
skippedVerification.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
expect(validateProviderConfig(skippedVerification)).toMatchObject({
valid: true,
errors: [],
});
});
});
describe("plivo provider", () => {
it("fails validation when authId is missing everywhere", () => {
process.env.PLIVO_AUTH_TOKEN = "secret";
let config = createBaseConfig("plivo");
config = resolveVoiceCallConfig(config);
const result = validateProviderConfig(config);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
"plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)",
);
});
});
describe("disabled config", () => {
it("skips validation when enabled is false", () => {
const config = createBaseConfig("twilio");
config.enabled = false;
const result = validateProviderConfig(config);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
});
describe("realtime config", () => {
it("rejects disabled inbound policy for realtime mode", () => {
const config = createBaseConfig("twilio");
config.realtime.enabled = true;
config.inboundPolicy = "disabled";
const result = validateProviderConfig(config);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
'plugins.entries.voice-call.config.inboundPolicy must not be "disabled" when realtime.enabled is true',
);
});
it("rejects enabling realtime and streaming together", () => {
const config = createBaseConfig("twilio");
config.realtime.enabled = true;
config.streaming.enabled = true;
config.inboundPolicy = "allowlist";
const result = validateProviderConfig(config);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
"plugins.entries.voice-call.config.realtime.enabled and plugins.entries.voice-call.config.streaming.enabled cannot both be true",
);
});
});
});
describe("normalizeVoiceCallConfig", () => {
it("fills nested runtime defaults from a partial config boundary", () => {
const normalized = normalizeVoiceCallConfig({
enabled: true,
provider: "mock",
streaming: {
enabled: true,
streamPath: "/custom-stream",
},
});
expect(normalized.serve.path).toBe("/voice/webhook");
expect(normalized.streaming.streamPath).toBe("/custom-stream");
expect(normalized.streaming.provider).toBeUndefined();
expect(normalized.streaming.providers).toEqual({});
expect(normalized.realtime.streamPath).toBe("/voice/stream/realtime");
expect(normalized.tunnel.provider).toBe("none");
expect(normalized.webhookSecurity.allowedHosts).toEqual([]);
});
it("derives the realtime stream path from a custom webhook path", () => {
const normalized = normalizeVoiceCallConfig({
enabled: true,
provider: "twilio",
serve: {
path: "/custom/webhook",
},
});
expect(normalized.realtime.streamPath).toBe("/custom/stream/realtime");
});
it("accepts partial nested TTS overrides and preserves nested objects", () => {
const normalized = normalizeVoiceCallConfig({
tts: {
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: {
source: "env",
provider: "elevenlabs",
id: "ELEVENLABS_API_KEY",
},
voiceSettings: {
speed: 1.1,
},
},
},
},
});
const { tts, elevenlabs } = requireElevenLabsTtsConfig(normalized);
expect(tts.provider).toBe("elevenlabs");
expect(elevenlabs.apiKey).toEqual({
source: "env",
provider: "elevenlabs",
id: "ELEVENLABS_API_KEY",
});
expect(elevenlabs.voiceSettings).toEqual({ speed: 1.1 });
});
});
describe("resolveVoiceCallConfig", () => {
it("preserves configured realtime instructions without env indirection", () => {
const resolved = resolveVoiceCallConfig({
enabled: true,
provider: "twilio",
realtime: {
enabled: true,
instructions: "Stay concise.",
},
});
expect(resolved.realtime.instructions).toBe("Stay concise.");
expect(resolved.realtime.provider).toBeUndefined();
});
it("leaves responseModel unset so voice responses can inherit runtime defaults", () => {
const resolved = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
});
expect(resolved.responseModel).toBeUndefined();
});
});

View file

@ -0,0 +1,625 @@
import { z } from "openclaw/plugin-sdk/zod";
import { TtsAutoSchema, TtsConfigSchema, TtsModeSchema, TtsProviderSchema } from "../api.js";
import { deepMergeDefined } from "./deep-merge.js";
// -----------------------------------------------------------------------------
// Phone Number Validation
// -----------------------------------------------------------------------------
/**
* E.164 phone number format: +[country code][number]
* Examples use 555 prefix (reserved for fictional numbers)
*/
export const E164Schema = z
.string()
.regex(/^\+[1-9]\d{1,14}$/, "Expected E.164 format, e.g. +15550001234");
// -----------------------------------------------------------------------------
// Inbound Policy
// -----------------------------------------------------------------------------
/**
* Controls how inbound calls are handled:
* - "disabled": Block all inbound calls (outbound only)
* - "allowlist": Only accept calls from numbers in allowFrom
* - "pairing": Unknown callers can request pairing (future)
* - "open": Accept all inbound calls (dangerous!)
*/
export const InboundPolicySchema = z.enum(["disabled", "allowlist", "pairing", "open"]);
export type InboundPolicy = z.infer<typeof InboundPolicySchema>;
// -----------------------------------------------------------------------------
// Provider-Specific Configuration
// -----------------------------------------------------------------------------
export const TelnyxConfigSchema = z
.object({
/** Telnyx API v2 key */
apiKey: z.string().min(1).optional(),
/** Telnyx connection ID (from Call Control app) */
connectionId: z.string().min(1).optional(),
/** Public key for webhook signature verification */
publicKey: z.string().min(1).optional(),
})
.strict();
export type TelnyxConfig = z.infer<typeof TelnyxConfigSchema>;
export const TwilioConfigSchema = z
.object({
/** Twilio Account SID */
accountSid: z.string().min(1).optional(),
/** Twilio Auth Token */
authToken: z.string().min(1).optional(),
})
.strict();
export type TwilioConfig = z.infer<typeof TwilioConfigSchema>;
export const PlivoConfigSchema = z
.object({
/** Plivo Auth ID (starts with MA/SA) */
authId: z.string().min(1).optional(),
/** Plivo Auth Token */
authToken: z.string().min(1).optional(),
})
.strict();
export type PlivoConfig = z.infer<typeof PlivoConfigSchema>;
export { TtsAutoSchema, TtsConfigSchema, TtsModeSchema, TtsProviderSchema };
export type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>;
// -----------------------------------------------------------------------------
// Webhook Server Configuration
// -----------------------------------------------------------------------------
export const VoiceCallServeConfigSchema = z
.object({
/** Port to listen on */
port: z.number().int().positive().default(3334),
/** Bind address */
bind: z.string().default("127.0.0.1"),
/** Webhook path */
path: z.string().min(1).default("/voice/webhook"),
})
.strict()
.default({ port: 3334, bind: "127.0.0.1", path: "/voice/webhook" });
export type VoiceCallServeConfig = z.infer<typeof VoiceCallServeConfigSchema>;
export const VoiceCallTailscaleConfigSchema = z
.object({
/**
* Tailscale exposure mode:
* - "off": No Tailscale exposure
* - "serve": Tailscale serve (private to tailnet)
* - "funnel": Tailscale funnel (public HTTPS)
*/
mode: z.enum(["off", "serve", "funnel"]).default("off"),
/** Path for Tailscale serve/funnel (should usually match serve.path) */
path: z.string().min(1).default("/voice/webhook"),
})
.strict()
.default({ mode: "off", path: "/voice/webhook" });
export type VoiceCallTailscaleConfig = z.infer<typeof VoiceCallTailscaleConfigSchema>;
// -----------------------------------------------------------------------------
// Tunnel Configuration (unified ngrok/tailscale)
// -----------------------------------------------------------------------------
export const VoiceCallTunnelConfigSchema = z
.object({
/**
* Tunnel provider:
* - "none": No tunnel (use publicUrl if set, or manual setup)
* - "ngrok": Use ngrok for public HTTPS tunnel
* - "tailscale-serve": Tailscale serve (private to tailnet)
* - "tailscale-funnel": Tailscale funnel (public HTTPS)
*/
provider: z.enum(["none", "ngrok", "tailscale-serve", "tailscale-funnel"]).default("none"),
/** ngrok auth token (optional, enables longer sessions and more features) */
ngrokAuthToken: z.string().min(1).optional(),
/** ngrok custom domain (paid feature, e.g., "myapp.ngrok.io") */
ngrokDomain: z.string().min(1).optional(),
/**
* Allow ngrok free tier compatibility mode.
* When true, forwarded headers may be trusted for loopback requests
* to reconstruct the public ngrok URL used for signing.
*
* IMPORTANT: This does NOT bypass signature verification.
*/
allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
})
.strict()
.default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false });
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
// -----------------------------------------------------------------------------
// Webhook Security Configuration
// -----------------------------------------------------------------------------
export const VoiceCallWebhookSecurityConfigSchema = z
.object({
/**
* Allowed hostnames for webhook URL reconstruction.
* Only these hosts are accepted from forwarding headers.
*/
allowedHosts: z.array(z.string().min(1)).default([]),
/**
* Trust X-Forwarded-* headers without a hostname allowlist.
* WARNING: Only enable if you trust your proxy configuration.
*/
trustForwardingHeaders: z.boolean().default(false),
/**
* Trusted proxy IP addresses. Forwarded headers are only trusted when
* the remote IP matches one of these addresses.
*/
trustedProxyIPs: z.array(z.string().min(1)).default([]),
})
.strict()
.default({ allowedHosts: [], trustForwardingHeaders: false, trustedProxyIPs: [] });
export type WebhookSecurityConfig = z.infer<typeof VoiceCallWebhookSecurityConfigSchema>;
// -----------------------------------------------------------------------------
// Outbound Call Configuration
// -----------------------------------------------------------------------------
/**
* Call mode determines how outbound calls behave:
* - "notify": Deliver message and auto-hangup after delay (one-way notification)
* - "conversation": Stay open for back-and-forth until explicit end or timeout
*/
export const CallModeSchema = z.enum(["notify", "conversation"]);
export type CallMode = z.infer<typeof CallModeSchema>;
export const OutboundConfigSchema = z
.object({
/** Default call mode for outbound calls */
defaultMode: CallModeSchema.default("notify"),
/** Seconds to wait after TTS before auto-hangup in notify mode */
notifyHangupDelaySec: z.number().int().nonnegative().default(3),
})
.strict()
.default({ defaultMode: "notify", notifyHangupDelaySec: 3 });
export type OutboundConfig = z.infer<typeof OutboundConfigSchema>;
// -----------------------------------------------------------------------------
// Realtime Voice Configuration
// -----------------------------------------------------------------------------
export const RealtimeToolSchema = z
.object({
type: z.literal("function"),
name: z.string().min(1),
description: z.string(),
parameters: z.object({
type: z.literal("object"),
properties: z.record(z.string(), z.unknown()),
required: z.array(z.string()).optional(),
}),
})
.strict();
export type RealtimeToolConfig = z.infer<typeof RealtimeToolSchema>;
export const VoiceCallRealtimeProvidersConfigSchema = z
.record(z.string(), z.record(z.string(), z.unknown()))
.default({});
export type VoiceCallRealtimeProvidersConfig = z.infer<
typeof VoiceCallRealtimeProvidersConfigSchema
>;
export const VoiceCallStreamingProvidersConfigSchema = z
.record(z.string(), z.record(z.string(), z.unknown()))
.default({});
export type VoiceCallStreamingProvidersConfig = z.infer<
typeof VoiceCallStreamingProvidersConfigSchema
>;
export const VoiceCallRealtimeConfigSchema = z
.object({
/** Enable realtime voice-to-voice mode. */
enabled: z.boolean().default(false),
/** Provider id from registered realtime voice providers. */
provider: z.string().min(1).optional(),
/** Optional override for the local WebSocket route path. */
streamPath: z.string().min(1).optional(),
/** System instructions passed to the realtime provider. */
instructions: z.string().optional(),
/** Tool definitions exposed to the realtime provider. */
tools: z.array(RealtimeToolSchema).default([]),
/** Provider-owned raw config blobs keyed by provider id. */
providers: VoiceCallRealtimeProvidersConfigSchema,
})
.strict()
.default({ enabled: false, tools: [], providers: {} });
export type VoiceCallRealtimeConfig = z.infer<typeof VoiceCallRealtimeConfigSchema>;
// -----------------------------------------------------------------------------
// Streaming Configuration (Realtime Transcription)
// -----------------------------------------------------------------------------
export const VoiceCallStreamingConfigSchema = z
.object({
/** Enable real-time audio streaming (requires WebSocket support) */
enabled: z.boolean().default(false),
/** Provider id from registered realtime transcription providers. */
provider: z.string().min(1).optional(),
/** WebSocket path for media stream connections */
streamPath: z.string().min(1).default("/voice/stream"),
/** Provider-owned raw config blobs keyed by provider id. */
providers: VoiceCallStreamingProvidersConfigSchema,
/**
* Close unauthenticated media stream sockets if no valid `start` frame arrives in time.
* Protects against pre-auth idle connection hold attacks.
*/
preStartTimeoutMs: z.number().int().positive().default(5000),
/** Maximum number of concurrently pending (pre-start) media stream sockets. */
maxPendingConnections: z.number().int().positive().default(32),
/** Maximum pending media stream sockets per source IP. */
maxPendingConnectionsPerIp: z.number().int().positive().default(4),
/** Hard cap for all open media stream sockets (pending + active). */
maxConnections: z.number().int().positive().default(128),
})
.strict()
.default({
enabled: false,
streamPath: "/voice/stream",
providers: {},
preStartTimeoutMs: 5000,
maxPendingConnections: 32,
maxPendingConnectionsPerIp: 4,
maxConnections: 128,
});
export type VoiceCallStreamingConfig = z.infer<typeof VoiceCallStreamingConfigSchema>;
// -----------------------------------------------------------------------------
// Main Voice Call Configuration
// -----------------------------------------------------------------------------
export const VoiceCallConfigSchema = z
.object({
/** Enable voice call functionality */
enabled: z.boolean().default(false),
/** Active provider (telnyx, twilio, plivo, or mock) */
provider: z.enum(["telnyx", "twilio", "plivo", "mock"]).optional(),
/** Telnyx-specific configuration */
telnyx: TelnyxConfigSchema.optional(),
/** Twilio-specific configuration */
twilio: TwilioConfigSchema.optional(),
/** Plivo-specific configuration */
plivo: PlivoConfigSchema.optional(),
/** Phone number to call from (E.164) */
fromNumber: E164Schema.optional(),
/** Default phone number to call (E.164) */
toNumber: E164Schema.optional(),
/** Inbound call policy */
inboundPolicy: InboundPolicySchema.default("disabled"),
/** Allowlist of phone numbers for inbound calls (E.164) */
allowFrom: z.array(E164Schema).default([]),
/** Greeting message for inbound calls */
inboundGreeting: z.string().optional(),
/** Outbound call configuration */
outbound: OutboundConfigSchema,
/** Maximum call duration in seconds */
maxDurationSeconds: z.number().int().positive().default(300),
/**
* Maximum age of a call in seconds before it is automatically reaped.
* Catches calls stuck in unexpected states (e.g., notify-mode calls that
* never receive a terminal webhook). Set to 0 to disable.
* Default: 0 (disabled). Recommended: 120-300 for production.
*/
staleCallReaperSeconds: z.number().int().nonnegative().default(0),
/** Silence timeout for end-of-speech detection (ms) */
silenceTimeoutMs: z.number().int().positive().default(800),
/** Timeout for user transcript (ms) */
transcriptTimeoutMs: z.number().int().positive().default(180000),
/** Ring timeout for outbound calls (ms) */
ringTimeoutMs: z.number().int().positive().default(30000),
/** Maximum concurrent calls */
maxConcurrentCalls: z.number().int().positive().default(1),
/** Webhook server configuration */
serve: VoiceCallServeConfigSchema,
/** Tailscale exposure configuration (legacy, prefer tunnel config) */
tailscale: VoiceCallTailscaleConfigSchema,
/** Tunnel configuration (unified ngrok/tailscale) */
tunnel: VoiceCallTunnelConfigSchema,
/** Webhook signature reconstruction and proxy trust configuration */
webhookSecurity: VoiceCallWebhookSecurityConfigSchema,
/** Real-time audio streaming configuration */
streaming: VoiceCallStreamingConfigSchema,
/** Realtime voice-to-voice configuration */
realtime: VoiceCallRealtimeConfigSchema,
/** Public webhook URL override (if set, bypasses tunnel auto-detection) */
publicUrl: z.string().url().optional(),
/** Skip webhook signature verification (development only, NOT for production) */
skipSignatureVerification: z.boolean().default(false),
/** TTS override (deep-merges with core messages.tts) */
tts: TtsConfigSchema,
/** Store path for call logs */
store: z.string().optional(),
/** Optional model override for generating voice responses. */
responseModel: z.string().optional(),
/** System prompt for voice responses */
responseSystemPrompt: z.string().optional(),
/** Timeout for response generation in ms (default 30s) */
responseTimeoutMs: z.number().int().positive().default(30000),
})
.strict();
export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;
type DeepPartial<T> =
T extends Array<infer U>
? DeepPartial<U>[]
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
export type VoiceCallConfigInput = DeepPartial<VoiceCallConfig>;
// -----------------------------------------------------------------------------
// Configuration Helpers
// -----------------------------------------------------------------------------
const DEFAULT_VOICE_CALL_CONFIG = VoiceCallConfigSchema.parse({});
function cloneDefaultVoiceCallConfig(): VoiceCallConfig {
return structuredClone(DEFAULT_VOICE_CALL_CONFIG);
}
function normalizeWebhookLikePath(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 defaultRealtimeStreamPathForServePath(servePath: string): string {
const normalized = normalizeWebhookLikePath(servePath);
if (normalized.endsWith("/webhook")) {
return `${normalized.slice(0, -"/webhook".length)}/stream/realtime`;
}
if (normalized === "/") {
return "/voice/stream/realtime";
}
return `${normalized}/stream/realtime`;
}
function normalizeVoiceCallTtsConfig(
defaults: VoiceCallTtsConfig,
overrides: DeepPartial<NonNullable<VoiceCallTtsConfig>> | undefined,
): VoiceCallTtsConfig {
if (!defaults && !overrides) {
return undefined;
}
return TtsConfigSchema.parse(deepMergeDefined(defaults ?? {}, overrides ?? {}));
}
function sanitizeVoiceCallProviderConfigs(
value: Record<string, Record<string, unknown> | undefined> | undefined,
): Record<string, Record<string, unknown>> {
if (!value) {
return {};
}
return Object.fromEntries(
Object.entries(value).filter(
(entry): entry is [string, Record<string, unknown>] => entry[1] !== undefined,
),
);
}
export function normalizeVoiceCallConfig(config: VoiceCallConfigInput): VoiceCallConfig {
const defaults = cloneDefaultVoiceCallConfig();
const serve = { ...defaults.serve, ...config.serve };
const streamingProvider = config.streaming?.provider;
const streamingProviders = sanitizeVoiceCallProviderConfigs(
config.streaming?.providers ?? defaults.streaming.providers,
);
const realtimeProvider = config.realtime?.provider ?? defaults.realtime.provider;
const realtimeProviders = sanitizeVoiceCallProviderConfigs(
config.realtime?.providers ?? defaults.realtime.providers,
);
return {
...defaults,
...config,
allowFrom: config.allowFrom ?? defaults.allowFrom,
outbound: { ...defaults.outbound, ...config.outbound },
serve,
tailscale: { ...defaults.tailscale, ...config.tailscale },
tunnel: { ...defaults.tunnel, ...config.tunnel },
webhookSecurity: {
...defaults.webhookSecurity,
...config.webhookSecurity,
allowedHosts: config.webhookSecurity?.allowedHosts ?? defaults.webhookSecurity.allowedHosts,
trustedProxyIPs:
config.webhookSecurity?.trustedProxyIPs ?? defaults.webhookSecurity.trustedProxyIPs,
},
streaming: {
...defaults.streaming,
...config.streaming,
provider: streamingProvider,
providers: streamingProviders,
},
realtime: {
...defaults.realtime,
...config.realtime,
provider: realtimeProvider,
streamPath:
config.realtime?.streamPath ??
defaultRealtimeStreamPathForServePath(serve.path ?? defaults.serve.path),
tools:
(config.realtime?.tools as RealtimeToolConfig[] | undefined) ?? defaults.realtime.tools,
providers: realtimeProviders,
},
tts: normalizeVoiceCallTtsConfig(defaults.tts, config.tts),
};
}
/**
* Resolves the configuration by merging environment variables into missing fields.
* Returns a new configuration object with environment variables applied.
*/
export function resolveVoiceCallConfig(config: VoiceCallConfigInput): VoiceCallConfig {
const resolved = normalizeVoiceCallConfig(config);
// Telnyx
if (resolved.provider === "telnyx") {
resolved.telnyx = resolved.telnyx ?? {};
resolved.telnyx.apiKey = resolved.telnyx.apiKey ?? process.env.TELNYX_API_KEY;
resolved.telnyx.connectionId = resolved.telnyx.connectionId ?? process.env.TELNYX_CONNECTION_ID;
resolved.telnyx.publicKey = resolved.telnyx.publicKey ?? process.env.TELNYX_PUBLIC_KEY;
}
// Twilio
if (resolved.provider === "twilio") {
resolved.twilio = resolved.twilio ?? {};
resolved.twilio.accountSid = resolved.twilio.accountSid ?? process.env.TWILIO_ACCOUNT_SID;
resolved.twilio.authToken = resolved.twilio.authToken ?? process.env.TWILIO_AUTH_TOKEN;
}
// Plivo
if (resolved.provider === "plivo") {
resolved.plivo = resolved.plivo ?? {};
resolved.plivo.authId = resolved.plivo.authId ?? process.env.PLIVO_AUTH_ID;
resolved.plivo.authToken = resolved.plivo.authToken ?? process.env.PLIVO_AUTH_TOKEN;
}
// Tunnel Config
resolved.tunnel = resolved.tunnel ?? {
provider: "none",
allowNgrokFreeTierLoopbackBypass: false,
};
resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
resolved.tunnel.allowNgrokFreeTierLoopbackBypass ?? false;
resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
// Webhook Security Config
resolved.webhookSecurity = resolved.webhookSecurity ?? {
allowedHosts: [],
trustForwardingHeaders: false,
trustedProxyIPs: [],
};
resolved.webhookSecurity.allowedHosts = resolved.webhookSecurity.allowedHosts ?? [];
resolved.webhookSecurity.trustForwardingHeaders =
resolved.webhookSecurity.trustForwardingHeaders ?? false;
resolved.webhookSecurity.trustedProxyIPs = resolved.webhookSecurity.trustedProxyIPs ?? [];
return normalizeVoiceCallConfig(resolved);
}
/**
* Validate that the configuration has all required fields for the selected provider.
*/
export function validateProviderConfig(config: VoiceCallConfig): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!config.enabled) {
return { valid: true, errors: [] };
}
if (!config.provider) {
errors.push("plugins.entries.voice-call.config.provider is required");
}
if (!config.fromNumber && config.provider !== "mock") {
errors.push("plugins.entries.voice-call.config.fromNumber is required");
}
if (config.provider === "telnyx") {
if (!config.telnyx?.apiKey) {
errors.push(
"plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)",
);
}
if (!config.telnyx?.connectionId) {
errors.push(
"plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)",
);
}
if (!config.skipSignatureVerification && !config.telnyx?.publicKey) {
errors.push(
"plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)",
);
}
}
if (config.provider === "twilio") {
if (!config.twilio?.accountSid) {
errors.push(
"plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)",
);
}
if (!config.twilio?.authToken) {
errors.push(
"plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)",
);
}
}
if (config.provider === "plivo") {
if (!config.plivo?.authId) {
errors.push(
"plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)",
);
}
if (!config.plivo?.authToken) {
errors.push(
"plugins.entries.voice-call.config.plivo.authToken is required (or set PLIVO_AUTH_TOKEN env)",
);
}
}
if (config.realtime.enabled && config.inboundPolicy === "disabled") {
errors.push(
'plugins.entries.voice-call.config.inboundPolicy must not be "disabled" when realtime.enabled is true',
);
}
if (config.realtime.enabled && config.streaming.enabled) {
errors.push(
"plugins.entries.voice-call.config.realtime.enabled and plugins.entries.voice-call.config.streaming.enabled cannot both be true",
);
}
if (config.realtime.enabled && config.provider && config.provider !== "twilio") {
errors.push(
'plugins.entries.voice-call.config.provider must be "twilio" when realtime.enabled is true',
);
}
return { valid: errors.length === 0, errors };
}

View file

@ -0,0 +1,14 @@
import type { OpenClawPluginApi } from "../api.js";
import type { VoiceCallTtsConfig } from "./config.js";
export type CoreConfig = {
session?: {
store?: string;
};
messages?: {
tts?: VoiceCallTtsConfig;
};
[key: string]: unknown;
};
export type CoreAgentDeps = OpenClawPluginApi["runtime"]["agent"];

View file

@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { deepMergeDefined } from "./deep-merge.js";
describe("deepMergeDefined", () => {
it("deep merges nested plain objects and preserves base values for undefined overrides", () => {
expect(
deepMergeDefined(
{
provider: { voice: "alloy", language: "en" },
enabled: true,
},
{
provider: { voice: "echo", language: undefined },
enabled: undefined,
},
),
).toEqual({
provider: { voice: "echo", language: "en" },
enabled: true,
});
});
it("replaces non-objects directly and blocks dangerous prototype keys", () => {
expect(deepMergeDefined(["a"], ["b"])).toEqual(["b"]);
expect(deepMergeDefined("base", undefined)).toBe("base");
expect(
deepMergeDefined(
{ safe: { keep: true } },
{
safe: { next: true },
__proto__: { polluted: true },
constructor: { polluted: true },
prototype: { polluted: true },
},
),
).toEqual({
safe: { keep: true, next: true },
});
});
});

View file

@ -0,0 +1,23 @@
const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]);
export function deepMergeDefined(base: unknown, override: unknown): unknown {
if (!isPlainObject(base) || !isPlainObject(override)) {
return override === undefined ? base : override;
}
const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(override)) {
if (BLOCKED_MERGE_KEYS.has(key) || value === undefined) {
continue;
}
const existing = result[key];
result[key] = key in result ? deepMergeDefined(existing, value) : value;
}
return result;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

View file

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { getHeader } from "./http-headers.js";
describe("getHeader", () => {
it("returns first value when header is an array", () => {
expect(getHeader({ "x-test": ["first", "second"] }, "x-test")).toBe("first");
});
it("matches headers case-insensitively", () => {
expect(getHeader({ "X-Twilio-Signature": "sig-1" }, "x-twilio-signature")).toBe("sig-1");
});
it("returns undefined for missing header", () => {
expect(getHeader({ host: "example.com" }, "x-missing")).toBeUndefined();
});
});

View file

@ -0,0 +1,15 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
export type HttpHeaderMap = Record<string, string | string[] | undefined>;
export function getHeader(headers: HttpHeaderMap, name: string): string | undefined {
const target = normalizeLowercaseStringOrEmpty(name);
const direct = headers[target];
const value =
direct ??
Object.entries(headers).find(([key]) => normalizeLowercaseStringOrEmpty(key) === target)?.[1];
if (Array.isArray(value)) {
return value[0];
}
return value;
}

View file

@ -0,0 +1,236 @@
import { describe, expect, it } from "vitest";
import { createManagerHarness, FakeProvider, markCallAnswered } from "./manager.test-harness.js";
function requireCall(
manager: Awaited<ReturnType<typeof createManagerHarness>>["manager"],
callId: string,
) {
const call = manager.getCall(callId);
if (!call) {
throw new Error(`expected active call ${callId}`);
}
return call;
}
function requireTurnToken(provider: Awaited<ReturnType<typeof createManagerHarness>>["provider"]) {
const firstStart = provider.startListeningCalls[0];
if (!firstStart?.turnToken) {
throw new Error("expected closed-loop turn to capture a turn token");
}
return firstStart.turnToken;
}
describe("CallManager closed-loop turns", () => {
it("completes a closed-loop turn without live audio", async () => {
const { manager, provider } = await createManagerHarness({
transcriptTimeoutMs: 5000,
});
const started = await manager.initiateCall("+15550000003");
expect(started.success).toBe(true);
markCallAnswered(manager, started.callId, "evt-closed-loop-answered");
const turnPromise = manager.continueCall(started.callId, "How can I help?");
await new Promise((resolve) => setTimeout(resolve, 0));
manager.processEvent({
id: "evt-closed-loop-speech",
type: "call.speech",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
transcript: "Please check status",
isFinal: true,
});
const turn = await turnPromise;
expect(turn.success).toBe(true);
expect(turn.transcript).toBe("Please check status");
expect(provider.startListeningCalls).toHaveLength(1);
expect(provider.stopListeningCalls).toHaveLength(1);
const call = requireCall(manager, started.callId);
expect(call.transcript.map((entry) => entry.text)).toEqual([
"How can I help?",
"Please check status",
]);
const metadata = call.metadata ?? {};
expect(typeof metadata.lastTurnLatencyMs).toBe("number");
expect(typeof metadata.lastTurnListenWaitMs).toBe("number");
expect(metadata.turnCount).toBe(1);
});
it("rejects overlapping continueCall requests for the same call", async () => {
const { manager, provider } = await createManagerHarness({
transcriptTimeoutMs: 5000,
});
const started = await manager.initiateCall("+15550000004");
expect(started.success).toBe(true);
markCallAnswered(manager, started.callId, "evt-overlap-answered");
const first = manager.continueCall(started.callId, "First prompt");
const second = await manager.continueCall(started.callId, "Second prompt");
expect(second.success).toBe(false);
expect(second.error).toBe("Already waiting for transcript");
manager.processEvent({
id: "evt-overlap-speech",
type: "call.speech",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
transcript: "Done",
isFinal: true,
});
const firstResult = await first;
expect(firstResult.success).toBe(true);
expect(firstResult.transcript).toBe("Done");
expect(provider.startListeningCalls).toHaveLength(1);
expect(provider.stopListeningCalls).toHaveLength(1);
});
it("ignores speech events with mismatched turnToken while waiting for transcript", async () => {
const { manager, provider } = await createManagerHarness(
{
transcriptTimeoutMs: 5000,
},
new FakeProvider("twilio"),
);
const started = await manager.initiateCall("+15550000004");
expect(started.success).toBe(true);
markCallAnswered(manager, started.callId, "evt-turn-token-answered");
const turnPromise = manager.continueCall(started.callId, "Prompt");
await new Promise((resolve) => setTimeout(resolve, 0));
const expectedTurnToken = requireTurnToken(provider);
manager.processEvent({
id: "evt-turn-token-bad",
type: "call.speech",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
transcript: "stale replay",
isFinal: true,
turnToken: "wrong-token",
});
const pendingState = await Promise.race([
turnPromise.then(() => "resolved"),
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)),
]);
expect(pendingState).toBe("pending");
manager.processEvent({
id: "evt-turn-token-good",
type: "call.speech",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
transcript: "final answer",
isFinal: true,
turnToken: expectedTurnToken,
});
const turnResult = await turnPromise;
expect(turnResult.success).toBe(true);
expect(turnResult.transcript).toBe("final answer");
const call = requireCall(manager, started.callId);
expect(call.transcript.map((entry) => entry.text)).toEqual(["Prompt", "final answer"]);
});
it("tracks latency metadata across multiple closed-loop turns", async () => {
const { manager, provider } = await createManagerHarness({
transcriptTimeoutMs: 5000,
});
const started = await manager.initiateCall("+15550000005");
expect(started.success).toBe(true);
markCallAnswered(manager, started.callId, "evt-multi-answered");
const firstTurn = manager.continueCall(started.callId, "First question");
await new Promise((resolve) => setTimeout(resolve, 0));
manager.processEvent({
id: "evt-multi-speech-1",
type: "call.speech",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
transcript: "First answer",
isFinal: true,
});
await firstTurn;
const secondTurn = manager.continueCall(started.callId, "Second question");
await new Promise((resolve) => setTimeout(resolve, 0));
manager.processEvent({
id: "evt-multi-speech-2",
type: "call.speech",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
transcript: "Second answer",
isFinal: true,
});
const secondResult = await secondTurn;
expect(secondResult.success).toBe(true);
const call = requireCall(manager, started.callId);
expect(call.transcript.map((entry) => entry.text)).toEqual([
"First question",
"First answer",
"Second question",
"Second answer",
]);
const metadata = call.metadata ?? {};
expect(metadata.turnCount).toBe(2);
expect(typeof metadata.lastTurnLatencyMs).toBe("number");
expect(typeof metadata.lastTurnListenWaitMs).toBe("number");
expect(provider.startListeningCalls).toHaveLength(2);
expect(provider.stopListeningCalls).toHaveLength(2);
});
it("handles repeated closed-loop turns without waiter churn", async () => {
const { manager, provider } = await createManagerHarness({
transcriptTimeoutMs: 5000,
});
const started = await manager.initiateCall("+15550000006");
expect(started.success).toBe(true);
markCallAnswered(manager, started.callId, "evt-loop-answered");
for (let i = 1; i <= 5; i++) {
const turnPromise = manager.continueCall(started.callId, `Prompt ${i}`);
await new Promise((resolve) => setTimeout(resolve, 0));
manager.processEvent({
id: `evt-loop-speech-${i}`,
type: "call.speech",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
transcript: `Answer ${i}`,
isFinal: true,
});
const result = await turnPromise;
expect(result.success).toBe(true);
expect(result.transcript).toBe(`Answer ${i}`);
}
const call = requireCall(manager, started.callId);
const metadata = call.metadata ?? {};
expect(metadata.turnCount).toBe(5);
expect(provider.startListeningCalls).toHaveLength(5);
expect(provider.stopListeningCalls).toHaveLength(5);
});
});

View file

@ -0,0 +1,188 @@
import { describe, expect, it } from "vitest";
import { FakeProvider, createManagerHarness } from "./manager.test-harness.js";
describe("CallManager inbound allowlist", () => {
it("rejects inbound calls with missing caller ID when allowlist enabled", async () => {
const { manager, provider } = await createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
manager.processEvent({
id: "evt-allowlist-missing",
type: "call.initiated",
callId: "call-missing",
providerCallId: "provider-missing",
timestamp: Date.now(),
direction: "inbound",
to: "+15550000000",
});
expect(manager.getCallByProviderCallId("provider-missing")).toBeUndefined();
expect(provider.hangupCalls).toEqual([
expect.objectContaining({ providerCallId: "provider-missing" }),
]);
});
it("rejects inbound calls with anonymous caller ID when allowlist enabled", async () => {
const { manager, provider } = await createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
manager.processEvent({
id: "evt-allowlist-anon",
type: "call.initiated",
callId: "call-anon",
providerCallId: "provider-anon",
timestamp: Date.now(),
direction: "inbound",
from: "anonymous",
to: "+15550000000",
});
expect(manager.getCallByProviderCallId("provider-anon")).toBeUndefined();
expect(provider.hangupCalls).toEqual([
expect.objectContaining({ providerCallId: "provider-anon" }),
]);
});
it("rejects inbound calls that only match allowlist suffixes", async () => {
const { manager, provider } = await createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
manager.processEvent({
id: "evt-allowlist-suffix",
type: "call.initiated",
callId: "call-suffix",
providerCallId: "provider-suffix",
timestamp: Date.now(),
direction: "inbound",
from: "+99915550001234",
to: "+15550000000",
});
expect(manager.getCallByProviderCallId("provider-suffix")).toBeUndefined();
expect(provider.hangupCalls).toEqual([
expect.objectContaining({ providerCallId: "provider-suffix" }),
]);
});
it("rejects duplicate inbound events with a single hangup call", async () => {
const { manager, provider } = await createManagerHarness({
inboundPolicy: "disabled",
});
manager.processEvent({
id: "evt-reject-init",
type: "call.initiated",
callId: "provider-dup",
providerCallId: "provider-dup",
timestamp: Date.now(),
direction: "inbound",
from: "+15552222222",
to: "+15550000000",
});
manager.processEvent({
id: "evt-reject-ring",
type: "call.ringing",
callId: "provider-dup",
providerCallId: "provider-dup",
timestamp: Date.now(),
direction: "inbound",
from: "+15552222222",
to: "+15550000000",
});
expect(manager.getCallByProviderCallId("provider-dup")).toBeUndefined();
expect(provider.hangupCalls).toEqual([
expect.objectContaining({ providerCallId: "provider-dup" }),
]);
});
it("retries rejected inbound hangup after a transient provider failure", async () => {
class FlakyHangupProvider extends FakeProvider {
hangupFailuresRemaining = 1;
override async hangupCall(input: Parameters<FakeProvider["hangupCall"]>[0]): Promise<void> {
this.hangupCalls.push(input);
if (this.hangupFailuresRemaining > 0) {
this.hangupFailuresRemaining -= 1;
throw new Error("provider down");
}
}
}
const provider = new FlakyHangupProvider();
const { manager } = await createManagerHarness(
{
inboundPolicy: "disabled",
},
provider,
);
manager.processEvent({
id: "evt-reject-fail-init",
type: "call.initiated",
callId: "provider-flaky",
providerCallId: "provider-flaky",
timestamp: Date.now(),
direction: "inbound",
from: "+15553333333",
to: "+15550000000",
});
await Promise.resolve();
manager.processEvent({
id: "evt-reject-fail-ring",
type: "call.ringing",
callId: "provider-flaky",
providerCallId: "provider-flaky",
timestamp: Date.now(),
direction: "inbound",
from: "+15553333333",
to: "+15550000000",
});
expect(manager.getCallByProviderCallId("provider-flaky")).toBeUndefined();
expect(provider.hangupCalls).toEqual([
expect.objectContaining({ providerCallId: "provider-flaky" }),
expect.objectContaining({ providerCallId: "provider-flaky" }),
]);
});
it("accepts inbound calls that exactly match the allowlist", async () => {
const { manager } = await createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
manager.processEvent({
id: "evt-allowlist-exact",
type: "call.initiated",
callId: "call-exact",
providerCallId: "provider-exact",
timestamp: Date.now(),
direction: "inbound",
from: "+15550001234",
to: "+15550000000",
});
const call = manager.getCallByProviderCallId("provider-exact");
if (!call) {
throw new Error("expected exact allowlist match to keep the inbound call");
}
expect(call).toMatchObject({
providerCallId: "provider-exact",
direction: "inbound",
from: "+15550001234",
to: "+15550000000",
});
expect(call.callId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
});

View file

@ -0,0 +1,325 @@
import { describe, expect, it, vi } from "vitest";
import { createManagerHarness, FakeProvider } from "./manager.test-harness.js";
class FailFirstPlayTtsProvider extends FakeProvider {
private failed = false;
override async playTts(input: Parameters<FakeProvider["playTts"]>[0]): Promise<void> {
this.playTtsCalls.push(input);
if (!this.failed) {
this.failed = true;
throw new Error("synthetic tts failure");
}
}
}
class DelayedPlayTtsProvider extends FakeProvider {
private releasePlayTts: (() => void) | null = null;
private resolvePlayTtsStarted: (() => void) | null = null;
readonly playTtsStarted = vi.fn();
readonly playTtsStartedPromise = new Promise<void>((resolve) => {
this.resolvePlayTtsStarted = resolve;
});
override async playTts(input: Parameters<FakeProvider["playTts"]>[0]): Promise<void> {
this.playTtsCalls.push(input);
this.playTtsStarted();
this.resolvePlayTtsStarted?.();
this.resolvePlayTtsStarted = null;
await new Promise<void>((resolve) => {
this.releasePlayTts = resolve;
});
}
releaseCurrentPlayback(): void {
this.releasePlayTts?.();
this.releasePlayTts = null;
}
}
function requireCall(
manager: Awaited<ReturnType<typeof createManagerHarness>>["manager"],
callId: string,
) {
const call = manager.getCall(callId);
if (!call) {
throw new Error(`expected active call ${callId}`);
}
return call;
}
function requireMappedCall(
manager: Awaited<ReturnType<typeof createManagerHarness>>["manager"],
providerCallId: string,
) {
const call = manager.getCallByProviderCallId(providerCallId);
if (!call) {
throw new Error(`expected mapped provider call ${providerCallId}`);
}
return call;
}
function requireFirstPlayTtsCall(provider: FakeProvider) {
const call = provider.playTtsCalls[0];
if (!call) {
throw new Error("expected provider.playTts to be called once");
}
return call;
}
describe("CallManager notify and mapping", () => {
it("upgrades providerCallId mapping when provider ID changes", async () => {
const { manager } = await createManagerHarness();
const { callId, success, error } = await manager.initiateCall("+15550000001");
expect(success).toBe(true);
expect(error).toBeUndefined();
expect(requireCall(manager, callId).providerCallId).toBe("request-uuid");
expect(requireMappedCall(manager, "request-uuid").callId).toBe(callId);
manager.processEvent({
id: "evt-1",
type: "call.answered",
callId,
providerCallId: "call-uuid",
timestamp: Date.now(),
});
expect(requireCall(manager, callId).providerCallId).toBe("call-uuid");
expect(requireMappedCall(manager, "call-uuid").callId).toBe(callId);
expect(manager.getCallByProviderCallId("request-uuid")).toBeUndefined();
});
it.each(["plivo", "twilio"] as const)(
"speaks initial message on answered for notify mode (%s)",
async (providerName) => {
const { manager, provider } = await createManagerHarness({}, new FakeProvider(providerName));
const { callId, success } = await manager.initiateCall("+15550000002", undefined, {
message: "Hello there",
mode: "notify",
});
expect(success).toBe(true);
manager.processEvent({
id: `evt-2-${providerName}`,
type: "call.answered",
callId,
providerCallId: "call-uuid",
timestamp: Date.now(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(provider.playTtsCalls).toHaveLength(1);
expect(requireFirstPlayTtsCall(provider).text).toBe("Hello there");
},
);
it("speaks initial message on answered for conversation mode with non-stream provider", async () => {
const { manager, provider } = await createManagerHarness({}, new FakeProvider("plivo"));
const { callId, success } = await manager.initiateCall("+15550000003", undefined, {
message: "Hello from conversation",
mode: "conversation",
});
expect(success).toBe(true);
manager.processEvent({
id: "evt-conversation-plivo",
type: "call.answered",
callId,
providerCallId: "call-uuid",
timestamp: Date.now(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(provider.playTtsCalls).toHaveLength(1);
expect(requireFirstPlayTtsCall(provider).text).toBe("Hello from conversation");
});
it("speaks initial message on answered for conversation mode when Twilio streaming is disabled", async () => {
const { manager, provider } = await createManagerHarness(
{ streaming: { enabled: false } },
new FakeProvider("twilio"),
);
const { callId, success } = await manager.initiateCall("+15550000004", undefined, {
message: "Twilio non-stream",
mode: "conversation",
});
expect(success).toBe(true);
manager.processEvent({
id: "evt-conversation-twilio-no-stream",
type: "call.answered",
callId,
providerCallId: "call-uuid",
timestamp: Date.now(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(provider.playTtsCalls).toHaveLength(1);
expect(requireFirstPlayTtsCall(provider).text).toBe("Twilio non-stream");
});
it("waits for stream connect in conversation mode when Twilio streaming is enabled", async () => {
const { manager, provider } = await createManagerHarness(
{ streaming: { enabled: true } },
new FakeProvider("twilio"),
);
const { callId, success } = await manager.initiateCall("+15550000005", undefined, {
message: "Twilio stream",
mode: "conversation",
});
expect(success).toBe(true);
manager.processEvent({
id: "evt-conversation-twilio-stream",
type: "call.answered",
callId,
providerCallId: "call-uuid",
timestamp: Date.now(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(provider.playTtsCalls).toHaveLength(0);
});
it("speaks on answered when Twilio streaming is enabled but stream-connect path is unavailable", async () => {
const twilioProvider = new FakeProvider("twilio");
twilioProvider.twilioStreamConnectEnabled = false;
const { manager, provider } = await createManagerHarness(
{ streaming: { enabled: true } },
twilioProvider,
);
const { callId, success } = await manager.initiateCall("+15550000009", undefined, {
message: "Twilio stream unavailable",
mode: "conversation",
});
expect(success).toBe(true);
manager.processEvent({
id: "evt-conversation-twilio-stream-unavailable",
type: "call.answered",
callId,
providerCallId: "call-uuid",
timestamp: Date.now(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(provider.playTtsCalls).toHaveLength(1);
expect(requireFirstPlayTtsCall(provider).text).toBe("Twilio stream unavailable");
});
it("preserves initialMessage after a failed first playback and retries on next trigger", async () => {
const provider = new FailFirstPlayTtsProvider("plivo");
const { manager } = await createManagerHarness({}, provider);
const { callId, success } = await manager.initiateCall("+15550000006", undefined, {
message: "Retry me",
mode: "notify",
});
expect(success).toBe(true);
manager.processEvent({
id: "evt-retry-1",
type: "call.answered",
callId,
providerCallId: "call-uuid",
timestamp: Date.now(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
const afterFailure = requireCall(manager, callId);
expect(provider.playTtsCalls).toHaveLength(1);
expect(afterFailure.metadata).toEqual(expect.objectContaining({ initialMessage: "Retry me" }));
expect(afterFailure.state).toBe("listening");
manager.processEvent({
id: "evt-retry-2",
type: "call.answered",
callId,
providerCallId: "call-uuid",
timestamp: Date.now(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
const afterSuccess = requireCall(manager, callId);
expect(provider.playTtsCalls).toHaveLength(2);
expect(afterSuccess.metadata).not.toHaveProperty("initialMessage");
});
it("speaks initial message only once on repeated stream-connect triggers", async () => {
const { manager, provider } = await createManagerHarness(
{ streaming: { enabled: true } },
new FakeProvider("twilio"),
);
const { callId, success } = await manager.initiateCall("+15550000007", undefined, {
message: "Stream hello",
mode: "conversation",
});
expect(success).toBe(true);
manager.processEvent({
id: "evt-stream-answered",
type: "call.answered",
callId,
providerCallId: "call-uuid",
timestamp: Date.now(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(provider.playTtsCalls).toHaveLength(0);
await manager.speakInitialMessage("call-uuid");
await manager.speakInitialMessage("call-uuid");
expect(provider.playTtsCalls).toHaveLength(1);
expect(requireFirstPlayTtsCall(provider).text).toBe("Stream hello");
});
it("prevents concurrent initial-message replays while first playback is in flight", async () => {
const provider = new DelayedPlayTtsProvider("twilio");
const { manager } = await createManagerHarness({ streaming: { enabled: true } }, provider);
const { callId, success } = await manager.initiateCall("+15550000008", undefined, {
message: "In-flight hello",
mode: "conversation",
});
expect(success).toBe(true);
manager.processEvent({
id: "evt-stream-answered-concurrent",
type: "call.answered",
callId,
providerCallId: "call-uuid",
timestamp: Date.now(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(provider.playTtsCalls).toHaveLength(0);
const first = manager.speakInitialMessage("call-uuid");
await provider.playTtsStartedPromise;
expect(provider.playTtsStarted).toHaveBeenCalledTimes(1);
const second = manager.speakInitialMessage("call-uuid");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(provider.playTtsCalls).toHaveLength(1);
provider.releaseCurrentPlayback();
await Promise.all([first, second]);
const call = requireCall(manager, callId);
expect(call.metadata).not.toHaveProperty("initialMessage");
expect(provider.playTtsCalls).toHaveLength(1);
expect(requireFirstPlayTtsCall(provider).text).toBe("In-flight hello");
});
});

View file

@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { VoiceCallConfigSchema } from "./config.js";
import { CallManager } from "./manager.js";
import {
createTestStorePath,
FakeProvider,
makePersistedCall,
writeCallsToStore,
} from "./manager.test-harness.js";
function requireSingleActiveCall(manager: CallManager) {
const activeCalls = manager.getActiveCalls();
expect(activeCalls).toHaveLength(1);
const activeCall = activeCalls[0];
if (!activeCall) {
throw new Error("expected restored active call");
}
return activeCall;
}
describe("CallManager verification on restore", () => {
async function initializeManager(params?: {
callOverrides?: Parameters<typeof makePersistedCall>[0];
providerResult?: FakeProvider["getCallStatusResult"];
configureProvider?: (provider: FakeProvider) => void;
configOverrides?: Partial<{ maxDurationSeconds: number }>;
}) {
const storePath = createTestStorePath();
const call = makePersistedCall(params?.callOverrides);
writeCallsToStore(storePath, [call]);
const provider = new FakeProvider();
if (params?.providerResult) {
provider.getCallStatusResult = params.providerResult;
}
params?.configureProvider?.(provider);
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
...params?.configOverrides,
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
return { call, manager };
}
it("skips stale calls reported terminal by provider", async () => {
const { manager } = await initializeManager({
providerResult: { status: "completed", isTerminal: true },
});
expect(manager.getActiveCalls()).toHaveLength(0);
});
it("keeps calls reported active by provider", async () => {
const { call, manager } = await initializeManager({
providerResult: { status: "in-progress", isTerminal: false },
});
const activeCall = requireSingleActiveCall(manager);
expect(activeCall.callId).toBe(call.callId);
});
it("keeps calls when provider returns unknown (transient error)", async () => {
const { call, manager } = await initializeManager({
providerResult: { status: "error", isTerminal: false, isUnknown: true },
});
const activeCall = requireSingleActiveCall(manager);
expect(activeCall.callId).toBe(call.callId);
expect(activeCall.state).toBe(call.state);
});
it("skips calls older than maxDurationSeconds", async () => {
const { manager } = await initializeManager({
callOverrides: {
startedAt: Date.now() - 600_000,
answeredAt: Date.now() - 590_000,
},
configOverrides: { maxDurationSeconds: 300 },
});
expect(manager.getActiveCalls()).toHaveLength(0);
});
it("skips calls without providerCallId", async () => {
const { manager } = await initializeManager({
callOverrides: { providerCallId: undefined, state: "initiated" },
});
expect(manager.getActiveCalls()).toHaveLength(0);
});
it("keeps call when getCallStatus throws (verification failure)", async () => {
const { call, manager } = await initializeManager({
configureProvider: (provider) => {
provider.getCallStatus = async () => {
throw new Error("network failure");
};
},
});
const activeCall = requireSingleActiveCall(manager);
expect(activeCall.callId).toBe(call.callId);
expect(activeCall.state).toBe(call.state);
});
it("restores dedupe keys from terminal persisted calls so replayed webhooks stay ignored", async () => {
const storePath = createTestStorePath();
const persisted = makePersistedCall({
state: "completed",
endedAt: Date.now() - 5_000,
endReason: "completed",
processedEventIds: ["evt-terminal-init"],
});
writeCallsToStore(storePath, [persisted]);
const provider = new FakeProvider();
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
manager.processEvent({
id: "evt-terminal-init",
type: "call.initiated",
callId: String(persisted.providerCallId),
providerCallId: String(persisted.providerCallId),
timestamp: Date.now(),
direction: "outbound",
from: "+15550000000",
to: "+15550000001",
});
expect(manager.getActiveCalls()).toHaveLength(0);
});
});

View file

@ -0,0 +1,127 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { VoiceCallConfigSchema } from "./config.js";
import { CallManager } from "./manager.js";
import type { VoiceCallProvider } from "./providers/base.js";
import type {
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
PlayTtsInput,
ProviderWebhookParseResult,
StartListeningInput,
StopListeningInput,
WebhookContext,
WebhookVerificationResult,
} from "./types.js";
export class FakeProvider implements VoiceCallProvider {
readonly name: "plivo" | "twilio";
twilioStreamConnectEnabled = true;
readonly playTtsCalls: PlayTtsInput[] = [];
readonly hangupCalls: HangupCallInput[] = [];
readonly startListeningCalls: StartListeningInput[] = [];
readonly stopListeningCalls: StopListeningInput[] = [];
getCallStatusResult: GetCallStatusResult = { status: "in-progress", isTerminal: false };
constructor(name: "plivo" | "twilio" = "plivo") {
this.name = name;
}
verifyWebhook(_ctx: WebhookContext): WebhookVerificationResult {
return { ok: true };
}
parseWebhookEvent(_ctx: WebhookContext): ProviderWebhookParseResult {
return { events: [], statusCode: 200 };
}
async initiateCall(_input: InitiateCallInput): Promise<InitiateCallResult> {
return { providerCallId: "request-uuid", status: "initiated" };
}
async hangupCall(input: HangupCallInput): Promise<void> {
this.hangupCalls.push(input);
}
async playTts(input: PlayTtsInput): Promise<void> {
this.playTtsCalls.push(input);
}
async startListening(input: StartListeningInput): Promise<void> {
this.startListeningCalls.push(input);
}
async stopListening(input: StopListeningInput): Promise<void> {
this.stopListeningCalls.push(input);
}
async getCallStatus(_input: GetCallStatusInput): Promise<GetCallStatusResult> {
return this.getCallStatusResult;
}
isConversationStreamConnectEnabled(): boolean {
return this.name === "twilio" && this.twilioStreamConnectEnabled;
}
}
export function createTestStorePath(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-voice-call-test-"));
}
export async function createManagerHarness(
configOverrides: Record<string, unknown> = {},
provider = new FakeProvider(),
): Promise<{
manager: CallManager;
provider: FakeProvider;
}> {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
...configOverrides,
});
const manager = new CallManager(config, createTestStorePath());
await manager.initialize(provider, "https://example.com/voice/webhook");
return { manager, provider };
}
export function markCallAnswered(manager: CallManager, callId: string, eventId: string): void {
manager.processEvent({
id: eventId,
type: "call.answered",
callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
}
export function writeCallsToStore(storePath: string, calls: Record<string, unknown>[]): void {
fs.mkdirSync(storePath, { recursive: true });
const logPath = path.join(storePath, "calls.jsonl");
const lines = calls.map((c) => JSON.stringify(c)).join("\n") + "\n";
fs.writeFileSync(logPath, lines);
}
export function makePersistedCall(
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
callId: `call-${Date.now()}-${Math.random().toString(36).slice(2)}`,
providerCallId: `prov-${Date.now()}-${Math.random().toString(36).slice(2)}`,
provider: "plivo",
direction: "outbound",
state: "answered",
from: "+15550000000",
to: "+15550000001",
startedAt: Date.now() - 30_000,
answeredAt: Date.now() - 25_000,
transcript: [],
processedEventIds: [],
...overrides,
};
}

View file

@ -0,0 +1,349 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { VoiceCallConfig } from "./config.js";
import type { CallManagerContext } from "./manager/context.js";
import { processEvent as processManagerEvent } from "./manager/events.js";
import { getCallByProviderCallId as getCallByProviderCallIdFromMaps } from "./manager/lookup.js";
import {
continueCall as continueCallWithContext,
endCall as endCallWithContext,
initiateCall as initiateCallWithContext,
speak as speakWithContext,
speakInitialMessage as speakInitialMessageWithContext,
} from "./manager/outbound.js";
import { getCallHistoryFromStore, loadActiveCallsFromStore } from "./manager/store.js";
import { startMaxDurationTimer } from "./manager/timers.js";
import type { VoiceCallProvider } from "./providers/base.js";
import {
TerminalStates,
type CallId,
type CallRecord,
type NormalizedEvent,
type OutboundCallOptions,
} from "./types.js";
import { resolveUserPath } from "./utils.js";
function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string {
const rawOverride = storePath?.trim() || config.store?.trim();
if (rawOverride) {
return resolveUserPath(rawOverride);
}
const preferred = path.join(os.homedir(), ".openclaw", "voice-calls");
const candidates = [preferred].map((dir) => resolveUserPath(dir));
const existing =
candidates.find((dir) => {
try {
return fs.existsSync(path.join(dir, "calls.jsonl")) || fs.existsSync(dir);
} catch {
return false;
}
}) ?? resolveUserPath(preferred);
return existing;
}
/**
* Manages voice calls: state ownership and delegation to manager helper modules.
*/
export class CallManager {
private activeCalls = new Map<CallId, CallRecord>();
private providerCallIdMap = new Map<string, CallId>();
private processedEventIds = new Set<string>();
private rejectedProviderCallIds = new Set<string>();
private provider: VoiceCallProvider | null = null;
private config: VoiceCallConfig;
private storePath: string;
private webhookUrl: string | null = null;
private activeTurnCalls = new Set<CallId>();
private transcriptWaiters = new Map<
CallId,
{
resolve: (text: string) => void;
reject: (err: Error) => void;
timeout: NodeJS.Timeout;
}
>();
private maxDurationTimers = new Map<CallId, NodeJS.Timeout>();
private initialMessageInFlight = new Set<CallId>();
constructor(config: VoiceCallConfig, storePath?: string) {
this.config = config;
this.storePath = resolveDefaultStoreBase(config, storePath);
}
/**
* Initialize the call manager with a provider.
* Verifies persisted calls with the provider and restarts timers.
*/
async initialize(provider: VoiceCallProvider, webhookUrl: string): Promise<void> {
this.provider = provider;
this.webhookUrl = webhookUrl;
fs.mkdirSync(this.storePath, { recursive: true });
const persisted = loadActiveCallsFromStore(this.storePath);
this.processedEventIds = persisted.processedEventIds;
this.rejectedProviderCallIds = persisted.rejectedProviderCallIds;
const verified = await this.verifyRestoredCalls(provider, persisted.activeCalls);
this.activeCalls = verified;
// Rebuild providerCallIdMap from verified calls only
this.providerCallIdMap = new Map();
for (const [callId, call] of verified) {
if (call.providerCallId) {
this.providerCallIdMap.set(call.providerCallId, callId);
}
}
// Restart max-duration timers for restored calls that are past the answered state
for (const [callId, call] of verified) {
if (call.answeredAt && !TerminalStates.has(call.state)) {
const elapsed = Date.now() - call.answeredAt;
const maxDurationMs = this.config.maxDurationSeconds * 1000;
if (elapsed >= maxDurationMs) {
// Already expired — remove instead of keeping
verified.delete(callId);
if (call.providerCallId) {
this.providerCallIdMap.delete(call.providerCallId);
}
console.log(
`[voice-call] Skipping restored call ${callId} (max duration already elapsed)`,
);
continue;
}
startMaxDurationTimer({
ctx: this.getContext(),
callId,
onTimeout: async (id) => {
await endCallWithContext(this.getContext(), id, { reason: "timeout" });
},
});
console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`);
}
}
if (verified.size > 0) {
console.log(`[voice-call] Restored ${verified.size} active call(s) from store`);
}
}
/**
* Verify persisted calls with the provider before restoring.
* Calls without providerCallId or older than maxDurationSeconds are skipped.
* Transient provider errors keep the call (rely on timer fallback).
*/
private async verifyRestoredCalls(
provider: VoiceCallProvider,
candidates: Map<CallId, CallRecord>,
): Promise<Map<CallId, CallRecord>> {
if (candidates.size === 0) {
return new Map();
}
const maxAgeMs = this.config.maxDurationSeconds * 1000;
const now = Date.now();
const verified = new Map<CallId, CallRecord>();
const verifyTasks: Array<{ callId: CallId; call: CallRecord; promise: Promise<void> }> = [];
for (const [callId, call] of candidates) {
// Skip calls without a provider ID — can't verify
if (!call.providerCallId) {
console.log(`[voice-call] Skipping restored call ${callId} (no providerCallId)`);
continue;
}
// Skip calls older than maxDurationSeconds (time-based fallback)
if (now - call.startedAt > maxAgeMs) {
console.log(
`[voice-call] Skipping restored call ${callId} (older than maxDurationSeconds)`,
);
continue;
}
const task = {
callId,
call,
promise: provider
.getCallStatus({ providerCallId: call.providerCallId })
.then((result) => {
if (result.isTerminal) {
console.log(
`[voice-call] Skipping restored call ${callId} (provider status: ${result.status})`,
);
} else if (result.isUnknown) {
console.log(
`[voice-call] Keeping restored call ${callId} (provider status unknown, relying on timer)`,
);
verified.set(callId, call);
} else {
verified.set(callId, call);
}
})
.catch(() => {
// Verification failed entirely — keep the call, rely on timer
console.log(
`[voice-call] Keeping restored call ${callId} (verification failed, relying on timer)`,
);
verified.set(callId, call);
}),
};
verifyTasks.push(task);
}
await Promise.allSettled(verifyTasks.map((t) => t.promise));
return verified;
}
/**
* Get the current provider.
*/
getProvider(): VoiceCallProvider | null {
return this.provider;
}
/**
* Initiate an outbound call.
*/
async initiateCall(
to: string,
sessionKey?: string,
options?: OutboundCallOptions | string,
): Promise<{ callId: CallId; success: boolean; error?: string }> {
return initiateCallWithContext(this.getContext(), to, sessionKey, options);
}
/**
* Speak to user in an active call.
*/
async speak(callId: CallId, text: string): Promise<{ success: boolean; error?: string }> {
return speakWithContext(this.getContext(), callId, text);
}
/**
* Speak the initial message for a call (called when media stream connects).
*/
async speakInitialMessage(providerCallId: string): Promise<void> {
return speakInitialMessageWithContext(this.getContext(), providerCallId);
}
/**
* Continue call: speak prompt, then wait for user's final transcript.
*/
async continueCall(
callId: CallId,
prompt: string,
): Promise<{ success: boolean; transcript?: string; error?: string }> {
return continueCallWithContext(this.getContext(), callId, prompt);
}
/**
* End an active call.
*/
async endCall(callId: CallId): Promise<{ success: boolean; error?: string }> {
return endCallWithContext(this.getContext(), callId);
}
private getContext(): CallManagerContext {
return {
activeCalls: this.activeCalls,
providerCallIdMap: this.providerCallIdMap,
processedEventIds: this.processedEventIds,
rejectedProviderCallIds: this.rejectedProviderCallIds,
provider: this.provider,
config: this.config,
storePath: this.storePath,
webhookUrl: this.webhookUrl,
activeTurnCalls: this.activeTurnCalls,
transcriptWaiters: this.transcriptWaiters,
maxDurationTimers: this.maxDurationTimers,
initialMessageInFlight: this.initialMessageInFlight,
onCallAnswered: (call) => {
this.maybeSpeakInitialMessageOnAnswered(call);
},
};
}
/**
* Process a webhook event.
*/
processEvent(event: NormalizedEvent): void {
processManagerEvent(this.getContext(), event);
}
private shouldDeferConversationInitialMessageUntilStreamConnect(): boolean {
if (!this.provider || this.provider.name !== "twilio" || !this.config.streaming.enabled) {
return false;
}
const streamAwareProvider = this.provider as VoiceCallProvider & {
isConversationStreamConnectEnabled?: () => boolean;
};
if (typeof streamAwareProvider.isConversationStreamConnectEnabled !== "function") {
return false;
}
return streamAwareProvider.isConversationStreamConnectEnabled();
}
private maybeSpeakInitialMessageOnAnswered(call: CallRecord): void {
const initialMessage = normalizeOptionalString(call.metadata?.initialMessage) ?? "";
if (!initialMessage) {
return;
}
// Notify mode should speak as soon as the provider reports "answered".
// Conversation mode should defer only when the Twilio stream-connect path
// is actually available; otherwise speak immediately on answered.
const mode = (call.metadata?.mode as string | undefined) ?? "conversation";
if (mode === "conversation") {
const shouldWaitForStreamConnect =
this.shouldDeferConversationInitialMessageUntilStreamConnect();
if (shouldWaitForStreamConnect) {
return;
}
} else if (mode !== "notify") {
return;
}
if (!this.provider || !call.providerCallId) {
return;
}
void this.speakInitialMessage(call.providerCallId);
}
/**
* Get an active call by ID.
*/
getCall(callId: CallId): CallRecord | undefined {
return this.activeCalls.get(callId);
}
/**
* Get an active call by provider call ID (e.g., Twilio CallSid).
*/
getCallByProviderCallId(providerCallId: string): CallRecord | undefined {
return getCallByProviderCallIdFromMaps({
activeCalls: this.activeCalls,
providerCallIdMap: this.providerCallIdMap,
providerCallId,
});
}
/**
* Get all active calls.
*/
getActiveCalls(): CallRecord[] {
return Array.from(this.activeCalls.values());
}
/**
* Get call history (from persisted logs).
*/
async getCallHistory(limit = 50): Promise<CallRecord[]> {
return getCallHistoryFromStore(this.storePath, limit);
}
}

View file

@ -0,0 +1,42 @@
import type { VoiceCallConfig } from "../config.js";
import type { VoiceCallProvider } from "../providers/base.js";
import type { CallId, CallRecord } from "../types.js";
export type TranscriptWaiter = {
resolve: (text: string) => void;
reject: (err: Error) => void;
timeout: NodeJS.Timeout;
turnToken?: string;
};
export type CallManagerRuntimeState = {
activeCalls: Map<CallId, CallRecord>;
providerCallIdMap: Map<string, CallId>;
processedEventIds: Set<string>;
/** Provider call IDs we already sent a reject hangup for; avoids duplicate hangup calls. */
rejectedProviderCallIds: Set<string>;
};
export type CallManagerRuntimeDeps = {
provider: VoiceCallProvider | null;
config: VoiceCallConfig;
storePath: string;
webhookUrl: string | null;
};
export type CallManagerTransientState = {
activeTurnCalls: Set<CallId>;
transcriptWaiters: Map<CallId, TranscriptWaiter>;
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
initialMessageInFlight: Set<CallId>;
};
export type CallManagerHooks = {
/** Optional runtime hook invoked after an event transitions a call into answered state. */
onCallAnswered?: (call: CallRecord) => void;
};
export type CallManagerContext = CallManagerRuntimeState &
CallManagerRuntimeDeps &
CallManagerTransientState &
CallManagerHooks;

View file

@ -0,0 +1,460 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { VoiceCallConfigSchema } from "../config.js";
import type { VoiceCallProvider } from "../providers/base.js";
import type { HangupCallInput, NormalizedEvent } from "../types.js";
import type { CallManagerContext } from "./context.js";
import { processEvent } from "./events.js";
function createContext(overrides: Partial<CallManagerContext> = {}): CallManagerContext {
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-events-test-${Date.now()}`);
fs.mkdirSync(storePath, { recursive: true });
return {
activeCalls: new Map(),
providerCallIdMap: new Map(),
processedEventIds: new Set(),
rejectedProviderCallIds: new Set(),
provider: null,
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
}),
storePath,
webhookUrl: null,
activeTurnCalls: new Set(),
transcriptWaiters: new Map(),
maxDurationTimers: new Map(),
initialMessageInFlight: new Set(),
...overrides,
};
}
function createProvider(overrides: Partial<VoiceCallProvider> = {}): VoiceCallProvider {
return {
name: "plivo",
verifyWebhook: () => ({ ok: true }),
parseWebhookEvent: () => ({ events: [] }),
initiateCall: async () => ({ providerCallId: "provider-call-id", status: "initiated" }),
hangupCall: async () => {},
playTts: async () => {},
startListening: async () => {},
stopListening: async () => {},
getCallStatus: async () => ({ status: "in-progress", isTerminal: false }),
...overrides,
};
}
function createInboundDisabledConfig() {
return VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
});
}
function createInboundInitiatedEvent(params: {
id: string;
providerCallId: string;
from: string;
}): NormalizedEvent {
return {
id: params.id,
type: "call.initiated",
callId: params.providerCallId,
providerCallId: params.providerCallId,
timestamp: Date.now(),
direction: "inbound",
from: params.from,
to: "+15550000000",
};
}
function createRejectingInboundContext(): {
ctx: CallManagerContext;
hangupCalls: HangupCallInput[];
} {
const hangupCalls: HangupCallInput[] = [];
const provider = createProvider({
hangupCall: async (input: HangupCallInput): Promise<void> => {
hangupCalls.push(input);
},
});
const ctx = createContext({
config: createInboundDisabledConfig(),
provider,
});
return { ctx, hangupCalls };
}
function requireFirstActiveCall(ctx: CallManagerContext) {
const call = [...ctx.activeCalls.values()][0];
if (!call) {
throw new Error("expected one active call");
}
return call;
}
describe("processEvent (functional)", () => {
it("calls provider hangup when rejecting inbound call", () => {
const { ctx, hangupCalls } = createRejectingInboundContext();
const event = createInboundInitiatedEvent({
id: "evt-1",
providerCallId: "prov-1",
from: "+15559999999",
});
processEvent(ctx, event);
expect(ctx.activeCalls.size).toBe(0);
expect(hangupCalls).toHaveLength(1);
expect(hangupCalls[0]).toEqual({
callId: "prov-1",
providerCallId: "prov-1",
reason: "hangup-bot",
});
});
it("does not call hangup when provider is null", () => {
const ctx = createContext({
config: createInboundDisabledConfig(),
provider: null,
});
const event = createInboundInitiatedEvent({
id: "evt-2",
providerCallId: "prov-2",
from: "+15551111111",
});
processEvent(ctx, event);
expect(ctx.activeCalls.size).toBe(0);
});
it("calls hangup only once for duplicate events for same rejected call", () => {
const { ctx, hangupCalls } = createRejectingInboundContext();
const event1 = createInboundInitiatedEvent({
id: "evt-init",
providerCallId: "prov-dup",
from: "+15552222222",
});
const event2: NormalizedEvent = {
id: "evt-ring",
type: "call.ringing",
callId: "prov-dup",
providerCallId: "prov-dup",
timestamp: Date.now(),
direction: "inbound",
from: "+15552222222",
to: "+15550000000",
};
processEvent(ctx, event1);
processEvent(ctx, event2);
expect(ctx.activeCalls.size).toBe(0);
expect(hangupCalls).toEqual([
expect.objectContaining({
providerCallId: "prov-dup",
reason: "hangup-bot",
}),
]);
});
it("updates providerCallId map when provider ID changes", () => {
const now = Date.now();
const ctx = createContext();
ctx.activeCalls.set("call-1", {
callId: "call-1",
providerCallId: "request-uuid",
provider: "plivo",
direction: "outbound",
state: "initiated",
from: "+15550000000",
to: "+15550000001",
startedAt: now,
transcript: [],
processedEventIds: [],
metadata: {},
});
ctx.providerCallIdMap.set("request-uuid", "call-1");
processEvent(ctx, {
id: "evt-provider-id-change",
type: "call.answered",
callId: "call-1",
providerCallId: "call-uuid",
timestamp: now + 1,
});
const activeCall = ctx.activeCalls.get("call-1");
if (!activeCall) {
throw new Error("expected active call after provider id change");
}
expect(activeCall.providerCallId).toBe("call-uuid");
expect(ctx.providerCallIdMap.get("call-uuid")).toBe("call-1");
expect(ctx.providerCallIdMap.has("request-uuid")).toBe(false);
});
it("does not burn replay keys for unknown calls before a later replay can resolve them", () => {
const now = Date.now();
const ctx = createContext();
const event: NormalizedEvent = {
id: "evt-late-call",
dedupeKey: "stable-late-call",
type: "call.answered",
callId: "call-late",
providerCallId: "provider-late",
timestamp: now + 1,
};
processEvent(ctx, event);
expect(ctx.processedEventIds.size).toBe(0);
ctx.activeCalls.set("call-late", {
callId: "call-late",
providerCallId: "provider-late",
provider: "plivo",
direction: "inbound",
state: "ringing",
from: "+15550000002",
to: "+15550000000",
startedAt: now,
transcript: [],
processedEventIds: [],
metadata: {},
});
ctx.providerCallIdMap.set("provider-late", "call-late");
processEvent(ctx, event);
const call = ctx.activeCalls.get("call-late");
if (!call) {
throw new Error("expected replayed event to resolve after call registration");
}
expect(call.state).toBe("answered");
expect(call.answeredAt).toBe(now + 1);
expect(Array.from(ctx.processedEventIds)).toEqual(["stable-late-call"]);
});
it("invokes onCallAnswered hook for answered events", () => {
const now = Date.now();
let answeredCallId: string | null = null;
const ctx = createContext({
onCallAnswered: (call) => {
answeredCallId = call.callId;
},
});
ctx.activeCalls.set("call-2", {
callId: "call-2",
providerCallId: "call-2-provider",
provider: "plivo",
direction: "inbound",
state: "ringing",
from: "+15550000002",
to: "+15550000000",
startedAt: now,
transcript: [],
processedEventIds: [],
metadata: {},
});
ctx.providerCallIdMap.set("call-2-provider", "call-2");
processEvent(ctx, {
id: "evt-answered-hook",
type: "call.answered",
callId: "call-2",
providerCallId: "call-2-provider",
timestamp: now + 1,
});
expect(answeredCallId).toBe("call-2");
});
it("when hangup throws, logs and does not throw", () => {
const provider = createProvider({
hangupCall: async (): Promise<void> => {
throw new Error("provider down");
},
});
const ctx = createContext({
config: createInboundDisabledConfig(),
provider,
});
const event = createInboundInitiatedEvent({
id: "evt-fail",
providerCallId: "prov-fail",
from: "+15553333333",
});
expect(() => processEvent(ctx, event)).not.toThrow();
expect(ctx.activeCalls.size).toBe(0);
});
it("auto-registers externally-initiated outbound-api calls with correct direction", () => {
const ctx = createContext();
const event: NormalizedEvent = {
id: "evt-external-1",
type: "call.initiated",
callId: "CA-external-123",
providerCallId: "CA-external-123",
timestamp: Date.now(),
direction: "outbound",
from: "+15550000000",
to: "+15559876543",
};
processEvent(ctx, event);
// Call should be registered in activeCalls and providerCallIdMap
expect(ctx.activeCalls.size).toBe(1);
const call = requireFirstActiveCall(ctx);
expect(ctx.providerCallIdMap.get("CA-external-123")).toBe(call.callId);
expect(call.providerCallId).toBe("CA-external-123");
expect(call.direction).toBe("outbound");
expect(call.from).toBe("+15550000000");
expect(call.to).toBe("+15559876543");
});
it("does not reject externally-initiated outbound calls even with disabled inbound policy", () => {
const { ctx, hangupCalls } = createRejectingInboundContext();
const event: NormalizedEvent = {
id: "evt-external-2",
type: "call.initiated",
callId: "CA-external-456",
providerCallId: "CA-external-456",
timestamp: Date.now(),
direction: "outbound",
from: "+15550000000",
to: "+15559876543",
};
processEvent(ctx, event);
// External outbound calls bypass inbound policy — they should be accepted
expect(ctx.activeCalls.size).toBe(1);
expect(hangupCalls).toHaveLength(0);
const call = requireFirstActiveCall(ctx);
expect(call.direction).toBe("outbound");
});
it("preserves inbound direction for auto-registered inbound calls", () => {
const ctx = createContext({
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "open",
}),
});
const event: NormalizedEvent = {
id: "evt-inbound-dir",
type: "call.initiated",
callId: "CA-inbound-789",
providerCallId: "CA-inbound-789",
timestamp: Date.now(),
direction: "inbound",
from: "+15554444444",
to: "+15550000000",
};
processEvent(ctx, event);
expect(ctx.activeCalls.size).toBe(1);
const call = requireFirstActiveCall(ctx);
expect(call.direction).toBe("inbound");
});
it("deduplicates by dedupeKey even when event IDs differ", () => {
const now = Date.now();
const ctx = createContext();
ctx.activeCalls.set("call-dedupe", {
callId: "call-dedupe",
providerCallId: "provider-dedupe",
provider: "plivo",
direction: "outbound",
state: "answered",
from: "+15550000000",
to: "+15550000001",
startedAt: now,
transcript: [],
processedEventIds: [],
metadata: {},
});
ctx.providerCallIdMap.set("provider-dedupe", "call-dedupe");
processEvent(ctx, {
id: "evt-1",
dedupeKey: "stable-key-1",
type: "call.speech",
callId: "call-dedupe",
providerCallId: "provider-dedupe",
timestamp: now + 1,
transcript: "hello",
isFinal: true,
});
processEvent(ctx, {
id: "evt-2",
dedupeKey: "stable-key-1",
type: "call.speech",
callId: "call-dedupe",
providerCallId: "provider-dedupe",
timestamp: now + 2,
transcript: "hello",
isFinal: true,
});
const call = ctx.activeCalls.get("call-dedupe");
if (!call) {
throw new Error("expected deduped call to remain active");
}
expect(call.transcript).toHaveLength(1);
expect(Array.from(ctx.processedEventIds)).toEqual(["stable-key-1"]);
});
it("keeps retryable call.error events replayable", () => {
const now = Date.now();
const ctx = createContext();
ctx.activeCalls.set("call-retryable-error", {
callId: "call-retryable-error",
providerCallId: "provider-retryable-error",
provider: "plivo",
direction: "outbound",
state: "active",
from: "+15550000000",
to: "+15550000001",
startedAt: now,
transcript: [],
processedEventIds: [],
metadata: {},
});
ctx.providerCallIdMap.set("provider-retryable-error", "call-retryable-error");
const event: NormalizedEvent = {
id: "evt-retryable-error",
dedupeKey: "stable-retryable-error",
type: "call.error",
callId: "call-retryable-error",
providerCallId: "provider-retryable-error",
timestamp: now + 1,
error: "temporary upstream failure",
retryable: true,
};
processEvent(ctx, event);
processEvent(ctx, event);
const call = ctx.activeCalls.get("call-retryable-error");
if (!call) {
throw new Error("expected retryable error call to remain active");
}
expect(call.state).toBe("active");
expect(Array.from(ctx.processedEventIds)).toEqual([]);
expect(call.processedEventIds).toEqual([]);
});
});

View file

@ -0,0 +1,258 @@
import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
import type { CallRecord, NormalizedEvent } from "../types.js";
import type { CallManagerContext } from "./context.js";
import { finalizeCall } from "./lifecycle.js";
import { findCall } from "./lookup.js";
import { endCall } from "./outbound.js";
import { addTranscriptEntry, transitionState } from "./state.js";
import { persistCallRecord } from "./store.js";
import { resolveTranscriptWaiter, startMaxDurationTimer } from "./timers.js";
type EventContext = Pick<
CallManagerContext,
| "activeCalls"
| "providerCallIdMap"
| "processedEventIds"
| "rejectedProviderCallIds"
| "provider"
| "config"
| "storePath"
| "transcriptWaiters"
| "maxDurationTimers"
| "onCallAnswered"
>;
function shouldAcceptInbound(config: EventContext["config"], from: string | undefined): boolean {
const { inboundPolicy: policy, allowFrom } = config;
switch (policy) {
case "disabled":
console.log("[voice-call] Inbound call rejected: policy is disabled");
return false;
case "open":
console.log("[voice-call] Inbound call accepted: policy is open");
return true;
case "allowlist":
case "pairing": {
const normalized = normalizePhoneNumber(from);
if (!normalized) {
console.log("[voice-call] Inbound call rejected: missing caller ID");
return false;
}
const allowed = isAllowlistedCaller(normalized, allowFrom);
const status = allowed ? "accepted" : "rejected";
console.log(
`[voice-call] Inbound call ${status}: ${from} ${allowed ? "is in" : "not in"} allowlist`,
);
return allowed;
}
default:
return false;
}
}
function createWebhookCall(params: {
ctx: EventContext;
providerCallId: string;
direction: "inbound" | "outbound";
from: string;
to: string;
}): CallRecord {
const callId = crypto.randomUUID();
const callRecord: CallRecord = {
callId,
providerCallId: params.providerCallId,
provider: params.ctx.provider?.name || "twilio",
direction: params.direction,
state: "ringing",
from: params.from,
to: params.to,
startedAt: Date.now(),
transcript: [],
processedEventIds: [],
metadata: {
initialMessage:
params.direction === "inbound"
? params.ctx.config.inboundGreeting || "Hello! How can I help you today?"
: undefined,
},
};
params.ctx.activeCalls.set(callId, callRecord);
params.ctx.providerCallIdMap.set(params.providerCallId, callId);
persistCallRecord(params.ctx.storePath, callRecord);
console.log(
`[voice-call] Created ${params.direction} call record: ${callId} from ${params.from}`,
);
return callRecord;
}
export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
const dedupeKey = event.dedupeKey || event.id;
if (ctx.processedEventIds.has(dedupeKey)) {
return;
}
let call = findCall({
activeCalls: ctx.activeCalls,
providerCallIdMap: ctx.providerCallIdMap,
callIdOrProviderCallId: event.callId,
});
const providerCallId = event.providerCallId;
const eventDirection =
event.direction === "inbound" || event.direction === "outbound" ? event.direction : undefined;
// Auto-register untracked calls arriving via webhook. This covers both
// true inbound calls and externally-initiated outbound-api calls (e.g. calls
// placed directly via the Twilio REST API pointing at our webhook URL).
if (!call && providerCallId && eventDirection) {
// Apply inbound policy for true inbound calls; external outbound-api calls
// are implicitly trusted because the caller controls the webhook URL.
if (eventDirection === "inbound" && !shouldAcceptInbound(ctx.config, event.from)) {
const pid = providerCallId;
if (!ctx.provider) {
console.warn(
`[voice-call] Inbound call rejected by policy but no provider to hang up (providerCallId: ${pid}, from: ${event.from}); call will time out on provider side.`,
);
return;
}
ctx.processedEventIds.add(dedupeKey);
if (ctx.rejectedProviderCallIds.has(pid)) {
return;
}
ctx.rejectedProviderCallIds.add(pid);
const callId = event.callId ?? pid;
console.log(`[voice-call] Rejecting inbound call by policy: ${pid}`);
void ctx.provider
.hangupCall({
callId,
providerCallId: pid,
reason: "hangup-bot",
})
.catch((err) => {
ctx.rejectedProviderCallIds.delete(pid);
const message = formatErrorMessage(err);
console.warn(`[voice-call] Failed to reject inbound call ${pid}:`, message);
});
return;
}
call = createWebhookCall({
ctx,
providerCallId,
direction: eventDirection === "outbound" ? "outbound" : "inbound",
from: event.from || "unknown",
to: event.to || ctx.config.fromNumber || "unknown",
});
// Normalize event to internal ID for downstream consumers.
event.callId = call.callId;
}
if (!call) {
return;
}
if (event.providerCallId && event.providerCallId !== call.providerCallId) {
const previousProviderCallId = call.providerCallId;
call.providerCallId = event.providerCallId;
ctx.providerCallIdMap.set(event.providerCallId, call.callId);
if (previousProviderCallId) {
const mapped = ctx.providerCallIdMap.get(previousProviderCallId);
if (mapped === call.callId) {
ctx.providerCallIdMap.delete(previousProviderCallId);
}
}
}
const shouldCommitReplayKey = !(event.type === "call.error" && event.retryable);
if (shouldCommitReplayKey) {
ctx.processedEventIds.add(dedupeKey);
call.processedEventIds.push(dedupeKey);
}
switch (event.type) {
case "call.initiated":
transitionState(call, "initiated");
break;
case "call.ringing":
transitionState(call, "ringing");
break;
case "call.answered":
call.answeredAt = event.timestamp;
transitionState(call, "answered");
startMaxDurationTimer({
ctx,
callId: call.callId,
onTimeout: async (callId) => {
await endCall(ctx, callId, { reason: "timeout" });
},
});
ctx.onCallAnswered?.(call);
break;
case "call.active":
transitionState(call, "active");
break;
case "call.speaking":
transitionState(call, "speaking");
break;
case "call.speech":
if (event.isFinal) {
const hadWaiter = ctx.transcriptWaiters.has(call.callId);
const resolved = resolveTranscriptWaiter(
ctx,
call.callId,
event.transcript,
event.turnToken,
);
if (hadWaiter && !resolved) {
console.warn(
`[voice-call] Ignoring speech event with mismatched turn token for ${call.callId}`,
);
break;
}
addTranscriptEntry(call, "user", event.transcript);
}
transitionState(call, "listening");
break;
case "call.ended":
finalizeCall({
ctx,
call,
endReason: event.reason,
endedAt: event.timestamp,
});
return;
case "call.error":
if (!event.retryable) {
finalizeCall({
ctx,
call,
endReason: "error",
endedAt: event.timestamp,
transcriptRejectReason: `Call error: ${event.error}`,
});
return;
}
// Keep retryable provider errors replayable so a redelivery can still
// drive later recovery or terminal handling for the same event key.
break;
}
persistCallRecord(ctx.storePath, call);
}

View file

@ -0,0 +1,53 @@
import type { CallRecord, EndReason } from "../types.js";
import type { CallManagerContext } from "./context.js";
import { transitionState } from "./state.js";
import { persistCallRecord } from "./store.js";
import { clearMaxDurationTimer, rejectTranscriptWaiter } from "./timers.js";
type CallLifecycleContext = Pick<
CallManagerContext,
"activeCalls" | "providerCallIdMap" | "storePath"
> &
Partial<Pick<CallManagerContext, "transcriptWaiters" | "maxDurationTimers">>;
function removeProviderCallMapping(
providerCallIdMap: Map<string, string>,
call: Pick<CallRecord, "callId" | "providerCallId">,
): void {
if (!call.providerCallId) {
return;
}
const mappedCallId = providerCallIdMap.get(call.providerCallId);
if (mappedCallId === call.callId) {
providerCallIdMap.delete(call.providerCallId);
}
}
export function finalizeCall(params: {
ctx: CallLifecycleContext;
call: CallRecord;
endReason: EndReason;
endedAt?: number;
transcriptRejectReason?: string;
}): void {
const { ctx, call, endReason } = params;
call.endedAt = params.endedAt ?? Date.now();
call.endReason = endReason;
transitionState(call, endReason);
persistCallRecord(ctx.storePath, call);
if (ctx.maxDurationTimers) {
clearMaxDurationTimer({ maxDurationTimers: ctx.maxDurationTimers }, call.callId);
}
if (ctx.transcriptWaiters) {
rejectTranscriptWaiter(
{ transcriptWaiters: ctx.transcriptWaiters },
call.callId,
params.transcriptRejectReason ?? `Call ended: ${endReason}`,
);
}
ctx.activeCalls.delete(call.callId);
removeProviderCallMapping(ctx.providerCallIdMap, call);
}

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { findCall, getCallByProviderCallId } from "./lookup.js";
describe("voice-call manager lookup", () => {
it("resolves provider call ids from the explicit map first", () => {
const activeCalls = new Map([
["call-1", { id: "call-1", providerCallId: "prov-1" }],
["call-2", { id: "call-2", providerCallId: "prov-2" }],
]);
const providerCallIdMap = new Map([["provider-lookup", "call-2"]]);
expect(
getCallByProviderCallId({
activeCalls: activeCalls as never,
providerCallIdMap,
providerCallId: "provider-lookup",
}),
).toEqual({ id: "call-2", providerCallId: "prov-2" });
});
it("falls back to scanning active calls and supports direct call ids", () => {
const activeCalls = new Map([
["call-1", { id: "call-1", providerCallId: "prov-1" }],
["call-2", { id: "call-2", providerCallId: "prov-2" }],
]);
const providerCallIdMap = new Map<string, string>();
expect(
getCallByProviderCallId({
activeCalls: activeCalls as never,
providerCallIdMap,
providerCallId: "prov-1",
}),
).toEqual({ id: "call-1", providerCallId: "prov-1" });
expect(
findCall({
activeCalls: activeCalls as never,
providerCallIdMap,
callIdOrProviderCallId: "call-2",
}),
).toEqual({ id: "call-2", providerCallId: "prov-2" });
expect(
findCall({
activeCalls: activeCalls as never,
providerCallIdMap,
callIdOrProviderCallId: "missing",
}),
).toBeUndefined();
});
});

View file

@ -0,0 +1,35 @@
import type { CallId, CallRecord } from "../types.js";
export function getCallByProviderCallId(params: {
activeCalls: Map<CallId, CallRecord>;
providerCallIdMap: Map<string, CallId>;
providerCallId: string;
}): CallRecord | undefined {
const callId = params.providerCallIdMap.get(params.providerCallId);
if (callId) {
return params.activeCalls.get(callId);
}
for (const call of params.activeCalls.values()) {
if (call.providerCallId === params.providerCallId) {
return call;
}
}
return undefined;
}
export function findCall(params: {
activeCalls: Map<CallId, CallRecord>;
providerCallIdMap: Map<string, CallId>;
callIdOrProviderCallId: string;
}): CallRecord | undefined {
const directCall = params.activeCalls.get(params.callIdOrProviderCallId);
if (directCall) {
return directCall;
}
return getCallByProviderCallId({
activeCalls: params.activeCalls,
providerCallIdMap: params.providerCallIdMap,
providerCallId: params.callIdOrProviderCallId,
});
}

View file

@ -0,0 +1,314 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
addTranscriptEntryMock,
clearMaxDurationTimerMock,
generateNotifyTwimlMock,
getCallByProviderCallIdMock,
mapVoiceToPollyMock,
persistCallRecordMock,
rejectTranscriptWaiterMock,
transitionStateMock,
} = vi.hoisted(() => ({
addTranscriptEntryMock: vi.fn(),
clearMaxDurationTimerMock: vi.fn(),
generateNotifyTwimlMock: vi.fn(),
getCallByProviderCallIdMock: vi.fn(),
mapVoiceToPollyMock: vi.fn(),
persistCallRecordMock: vi.fn(),
rejectTranscriptWaiterMock: vi.fn(),
transitionStateMock: vi.fn(),
}));
vi.mock("./state.js", () => ({
addTranscriptEntry: addTranscriptEntryMock,
transitionState: transitionStateMock,
}));
vi.mock("./store.js", () => ({
persistCallRecord: persistCallRecordMock,
}));
vi.mock("./timers.js", () => ({
clearMaxDurationTimer: clearMaxDurationTimerMock,
clearTranscriptWaiter: vi.fn(),
rejectTranscriptWaiter: rejectTranscriptWaiterMock,
waitForFinalTranscript: vi.fn(),
}));
vi.mock("./lookup.js", () => ({
getCallByProviderCallId: getCallByProviderCallIdMock,
}));
vi.mock("../voice-mapping.js", () => ({
mapVoiceToPolly: mapVoiceToPollyMock,
}));
vi.mock("./twiml.js", () => ({
generateNotifyTwiml: generateNotifyTwimlMock,
}));
import { endCall, initiateCall, speak } from "./outbound.js";
function createActiveCallContext(params: { hangupCall?: ReturnType<typeof vi.fn> } = {}) {
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
const hangupCall = params.hangupCall ?? vi.fn(async () => {});
const ctx = {
activeCalls: new Map([["call-1", call]]),
providerCallIdMap: new Map([["provider-1", "call-1"]]),
provider: { hangupCall },
storePath: "/tmp/voice-call.json",
transcriptWaiters: new Map(),
maxDurationTimers: new Map(),
};
return { call, ctx, hangupCall };
}
describe("voice-call outbound helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
mapVoiceToPollyMock.mockReturnValue("Polly.Joanna");
generateNotifyTwimlMock.mockReturnValue("<Response />");
});
it("guards initiateCall when provider, webhook, capacity, or fromNumber are missing", async () => {
const base = {
activeCalls: new Map(),
providerCallIdMap: new Map(),
config: {
maxConcurrentCalls: 1,
outbound: { defaultMode: "conversation", notifyHangupDelaySec: 0 },
},
storePath: "/tmp/voice-call.json",
webhookUrl: "https://example.com/webhook",
};
await expect(
initiateCall({ ...base, provider: undefined } as never, "+14155550123"),
).resolves.toEqual({
callId: "",
success: false,
error: "Provider not initialized",
});
await expect(
initiateCall(
{ ...base, provider: { name: "twilio" }, webhookUrl: undefined } as never,
"+14155550123",
),
).resolves.toEqual({
callId: "",
success: false,
error: "Webhook URL not configured",
});
const saturated = {
...base,
activeCalls: new Map([["existing", {}]]),
provider: { name: "twilio" },
};
await expect(initiateCall(saturated as never, "+14155550123")).resolves.toEqual({
callId: "",
success: false,
error: "Maximum concurrent calls (1) reached",
});
await expect(
initiateCall(
{
...base,
provider: { name: "twilio" },
config: { ...base.config, fromNumber: "" },
} as never,
"+14155550123",
),
).resolves.toEqual({
callId: "",
success: false,
error: "fromNumber not configured",
});
});
it("initiates notify-mode calls with inline TwiML and records provider ids", async () => {
const initiateProviderCall = vi.fn(async () => ({ providerCallId: "provider-1" }));
const ctx = {
activeCalls: new Map(),
providerCallIdMap: new Map(),
provider: { name: "twilio", initiateCall: initiateProviderCall },
config: {
maxConcurrentCalls: 3,
outbound: { defaultMode: "conversation" },
fromNumber: "+14155550100",
tts: { provider: "openai", providers: { openai: { voice: "nova" } } },
},
storePath: "/tmp/voice-call.json",
webhookUrl: "https://example.com/webhook",
};
const result = await initiateCall(ctx as never, "+14155550123", "session-1", {
mode: "notify",
message: "hello there",
});
expect(result).toEqual({
callId: expect.any(String),
success: true,
});
const callId = result.callId;
expect(mapVoiceToPollyMock).toHaveBeenCalledWith("nova");
expect(generateNotifyTwimlMock).toHaveBeenCalledWith("hello there", "Polly.Joanna");
expect(initiateProviderCall).toHaveBeenCalledWith({
callId,
from: "+14155550100",
to: "+14155550123",
webhookUrl: "https://example.com/webhook",
inlineTwiml: "<Response />",
});
expect(ctx.providerCallIdMap.get("provider-1")).toBe(callId);
expect(persistCallRecordMock).toHaveBeenCalledTimes(2);
});
it("fails initiateCall cleanly when provider initiation throws", async () => {
const ctx = {
activeCalls: new Map(),
providerCallIdMap: new Map(),
provider: {
name: "mock",
initiateCall: vi.fn(async () => {
throw new Error("provider down");
}),
},
config: {
maxConcurrentCalls: 3,
outbound: { defaultMode: "conversation" },
},
storePath: "/tmp/voice-call.json",
webhookUrl: "https://example.com/webhook",
};
await expect(initiateCall(ctx as never, "+14155550123")).resolves.toEqual({
callId: expect.any(String),
success: false,
error: "provider down",
});
expect(ctx.activeCalls.size).toBe(0);
});
it("speaks through connected calls and rolls back to listening on provider errors", async () => {
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
const playTts = vi.fn(async () => {});
const ctx = {
activeCalls: new Map([["call-1", call]]),
providerCallIdMap: new Map(),
provider: { name: "twilio", playTts },
config: { tts: { provider: "openai", providers: { openai: { voice: "alloy" } } } },
storePath: "/tmp/voice-call.json",
};
await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true });
expect(transitionStateMock).toHaveBeenCalledWith(call, "speaking");
expect(playTts).toHaveBeenCalledWith({
callId: "call-1",
providerCallId: "provider-1",
text: "hello",
voice: "alloy",
});
expect(addTranscriptEntryMock).toHaveBeenCalledWith(call, "bot", "hello");
playTts.mockImplementationOnce(async () => {
throw new Error("tts failed");
});
await expect(speak(ctx as never, "call-1", "hello again")).resolves.toEqual({
success: false,
error: "tts failed",
});
expect(transitionStateMock).toHaveBeenLastCalledWith(call, "listening");
});
it("ends connected calls, clears timers, and rejects pending transcripts", async () => {
const { call, ctx, hangupCall } = createActiveCallContext();
await expect(endCall(ctx as never, "call-1")).resolves.toEqual({ success: true });
expect(hangupCall).toHaveBeenCalledWith({
callId: "call-1",
providerCallId: "provider-1",
reason: "hangup-bot",
});
expect(call).toEqual(
expect.objectContaining({
endReason: "hangup-bot",
endedAt: expect.any(Number),
}),
);
expect(transitionStateMock).toHaveBeenCalledWith(call, "hangup-bot");
expect(clearMaxDurationTimerMock).toHaveBeenCalledWith(
{ maxDurationTimers: ctx.maxDurationTimers },
"call-1",
);
expect(rejectTranscriptWaiterMock).toHaveBeenCalledWith(
{ transcriptWaiters: ctx.transcriptWaiters },
"call-1",
"Call ended: hangup-bot",
);
expect(ctx.activeCalls.size).toBe(0);
expect(ctx.providerCallIdMap.size).toBe(0);
});
it("preserves timeout reasons when ending timed out calls", async () => {
const { call, ctx, hangupCall } = createActiveCallContext();
await expect(endCall(ctx as never, "call-1", { reason: "timeout" })).resolves.toEqual({
success: true,
});
expect(hangupCall).toHaveBeenCalledWith({
callId: "call-1",
providerCallId: "provider-1",
reason: "timeout",
});
expect(call).toEqual(
expect.objectContaining({
endReason: "timeout",
endedAt: expect.any(Number),
}),
);
expect(transitionStateMock).toHaveBeenCalledWith(call, "timeout");
expect(rejectTranscriptWaiterMock).toHaveBeenCalledWith(
{ transcriptWaiters: ctx.transcriptWaiters },
"call-1",
"Call ended: timeout",
);
});
it("handles missing, disconnected, and already-ended calls", async () => {
await expect(
speak(
{
activeCalls: new Map(),
providerCallIdMap: new Map(),
provider: { name: "twilio", playTts: vi.fn() },
config: {},
storePath: "/tmp/voice-call.json",
} as never,
"missing",
"hello",
),
).resolves.toEqual({ success: false, error: "Call not found" });
await expect(
endCall(
{
activeCalls: new Map([
["call-1", { callId: "call-1", state: "completed", providerCallId: "provider-1" }],
]),
providerCallIdMap: new Map(),
provider: { hangupCall: vi.fn() },
storePath: "/tmp/voice-call.json",
transcriptWaiters: new Map(),
maxDurationTimers: new Map(),
} as never,
"call-1",
),
).resolves.toEqual({ success: true });
});
});

View file

@ -0,0 +1,390 @@
import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { CallMode } from "../config.js";
import { resolvePreferredTtsVoice } from "../tts-provider-voice.js";
import {
type EndReason,
TerminalStates,
type CallId,
type CallRecord,
type OutboundCallOptions,
} from "../types.js";
import { mapVoiceToPolly } from "../voice-mapping.js";
import type { CallManagerContext } from "./context.js";
import { finalizeCall } from "./lifecycle.js";
import { getCallByProviderCallId } from "./lookup.js";
import { addTranscriptEntry, transitionState } from "./state.js";
import { persistCallRecord } from "./store.js";
import { clearTranscriptWaiter, waitForFinalTranscript } from "./timers.js";
import { generateNotifyTwiml } from "./twiml.js";
type InitiateContext = Pick<
CallManagerContext,
"activeCalls" | "providerCallIdMap" | "provider" | "config" | "storePath" | "webhookUrl"
>;
type SpeakContext = Pick<
CallManagerContext,
"activeCalls" | "providerCallIdMap" | "provider" | "config" | "storePath"
>;
type ConversationContext = Pick<
CallManagerContext,
| "activeCalls"
| "providerCallIdMap"
| "provider"
| "config"
| "storePath"
| "activeTurnCalls"
| "transcriptWaiters"
| "maxDurationTimers"
| "initialMessageInFlight"
>;
type EndCallContext = Pick<
CallManagerContext,
| "activeCalls"
| "providerCallIdMap"
| "provider"
| "storePath"
| "transcriptWaiters"
| "maxDurationTimers"
>;
type ConnectedCallContext = Pick<CallManagerContext, "activeCalls" | "provider">;
type ConnectedCallLookup =
| { kind: "error"; error: string }
| { kind: "ended"; call: CallRecord }
| {
kind: "ok";
call: CallRecord;
providerCallId: string;
provider: NonNullable<ConnectedCallContext["provider"]>;
};
type ConnectedCallResolution =
| { ok: false; error: string }
| {
ok: true;
call: CallRecord;
providerCallId: string;
provider: NonNullable<ConnectedCallContext["provider"]>;
};
function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallLookup {
const call = ctx.activeCalls.get(callId);
if (!call) {
return { kind: "error", error: "Call not found" };
}
if (!ctx.provider || !call.providerCallId) {
return { kind: "error", error: "Call not connected" };
}
if (TerminalStates.has(call.state)) {
return { kind: "ended", call };
}
return { kind: "ok", call, providerCallId: call.providerCallId, provider: ctx.provider };
}
function requireConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallResolution {
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") {
return { ok: false, error: lookup.error };
}
if (lookup.kind === "ended") {
return { ok: false, error: "Call has ended" };
}
return {
ok: true,
call: lookup.call,
providerCallId: lookup.providerCallId,
provider: lookup.provider,
};
}
export async function initiateCall(
ctx: InitiateContext,
to: string,
sessionKey?: string,
options?: OutboundCallOptions | string,
): Promise<{ callId: CallId; success: boolean; error?: string }> {
const opts: OutboundCallOptions =
typeof options === "string" ? { message: options } : (options ?? {});
const initialMessage = opts.message;
const mode = opts.mode ?? ctx.config.outbound.defaultMode;
if (!ctx.provider) {
return { callId: "", success: false, error: "Provider not initialized" };
}
if (!ctx.webhookUrl) {
return { callId: "", success: false, error: "Webhook URL not configured" };
}
if (ctx.activeCalls.size >= ctx.config.maxConcurrentCalls) {
return {
callId: "",
success: false,
error: `Maximum concurrent calls (${ctx.config.maxConcurrentCalls}) reached`,
};
}
const callId = crypto.randomUUID();
const from =
ctx.config.fromNumber || (ctx.provider?.name === "mock" ? "+15550000000" : undefined);
if (!from) {
return { callId: "", success: false, error: "fromNumber not configured" };
}
const callRecord: CallRecord = {
callId,
provider: ctx.provider.name,
direction: "outbound",
state: "initiated",
from,
to,
sessionKey,
startedAt: Date.now(),
transcript: [],
processedEventIds: [],
metadata: {
...(initialMessage && { initialMessage }),
mode,
},
};
ctx.activeCalls.set(callId, callRecord);
persistCallRecord(ctx.storePath, callRecord);
try {
// For notify mode with a message, use inline TwiML with <Say>.
let inlineTwiml: string | undefined;
if (mode === "notify" && initialMessage) {
const pollyVoice = mapVoiceToPolly(resolvePreferredTtsVoice(ctx.config));
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
}
const result = await ctx.provider.initiateCall({
callId,
from,
to,
webhookUrl: ctx.webhookUrl,
inlineTwiml,
});
callRecord.providerCallId = result.providerCallId;
ctx.providerCallIdMap.set(result.providerCallId, callId);
persistCallRecord(ctx.storePath, callRecord);
return { callId, success: true };
} catch (err) {
finalizeCall({
ctx,
call: callRecord,
endReason: "failed",
});
return {
callId,
success: false,
error: formatErrorMessage(err),
};
}
}
export async function speak(
ctx: SpeakContext,
callId: CallId,
text: string,
): Promise<{ success: boolean; error?: string }> {
const connected = requireConnectedCall(ctx, callId);
if (!connected.ok) {
return { success: false, error: connected.error };
}
const { call, providerCallId, provider } = connected;
try {
transitionState(call, "speaking");
persistCallRecord(ctx.storePath, call);
const voice = provider.name === "twilio" ? resolvePreferredTtsVoice(ctx.config) : undefined;
await provider.playTts({
callId,
providerCallId,
text,
voice,
});
addTranscriptEntry(call, "bot", text);
persistCallRecord(ctx.storePath, call);
return { success: true };
} catch (err) {
// A failed playback should not leave the call stuck in speaking state.
transitionState(call, "listening");
persistCallRecord(ctx.storePath, call);
return { success: false, error: formatErrorMessage(err) };
}
}
export async function speakInitialMessage(
ctx: ConversationContext,
providerCallId: string,
): Promise<void> {
const call = getCallByProviderCallId({
activeCalls: ctx.activeCalls,
providerCallIdMap: ctx.providerCallIdMap,
providerCallId,
});
if (!call) {
console.warn(`[voice-call] speakInitialMessage: no call found for ${providerCallId}`);
return;
}
const initialMessage = call.metadata?.initialMessage as string | undefined;
const mode = (call.metadata?.mode as CallMode) ?? "conversation";
if (!initialMessage) {
console.log(`[voice-call] speakInitialMessage: no initial message for ${call.callId}`);
return;
}
if (ctx.initialMessageInFlight.has(call.callId)) {
console.log(
`[voice-call] speakInitialMessage: initial message already in flight for ${call.callId}`,
);
return;
}
ctx.initialMessageInFlight.add(call.callId);
try {
console.log(`[voice-call] Speaking initial message for call ${call.callId} (mode: ${mode})`);
const result = await speak(ctx, call.callId, initialMessage);
if (!result.success) {
console.warn(`[voice-call] Failed to speak initial message: ${result.error}`);
return;
}
// Clear only after successful playback so transient provider failures can retry.
if (call.metadata) {
delete call.metadata.initialMessage;
persistCallRecord(ctx.storePath, call);
}
if (mode === "notify") {
const delaySec = ctx.config.outbound.notifyHangupDelaySec;
console.log(`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`);
setTimeout(async () => {
const currentCall = ctx.activeCalls.get(call.callId);
if (currentCall && !TerminalStates.has(currentCall.state)) {
console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`);
await endCall(ctx, call.callId);
}
}, delaySec * 1000);
}
} finally {
ctx.initialMessageInFlight.delete(call.callId);
}
}
export async function continueCall(
ctx: ConversationContext,
callId: CallId,
prompt: string,
): Promise<{ success: boolean; transcript?: string; error?: string }> {
const connected = requireConnectedCall(ctx, callId);
if (!connected.ok) {
return { success: false, error: connected.error };
}
const { call, providerCallId, provider } = connected;
if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) {
return { success: false, error: "Already waiting for transcript" };
}
ctx.activeTurnCalls.add(callId);
const turnStartedAt = Date.now();
const turnToken = provider.name === "twilio" ? crypto.randomUUID() : undefined;
try {
await speak(ctx, callId, prompt);
transitionState(call, "listening");
persistCallRecord(ctx.storePath, call);
const listenStartedAt = Date.now();
await provider.startListening({ callId, providerCallId, turnToken });
const transcript = await waitForFinalTranscript(ctx, callId, turnToken);
const transcriptReceivedAt = Date.now();
// Best-effort: stop listening after final transcript.
await provider.stopListening({ callId, providerCallId });
const lastTurnLatencyMs = transcriptReceivedAt - turnStartedAt;
const lastTurnListenWaitMs = transcriptReceivedAt - listenStartedAt;
const turnCount =
call.metadata && typeof call.metadata.turnCount === "number"
? call.metadata.turnCount + 1
: 1;
call.metadata = {
...call.metadata,
turnCount,
lastTurnLatencyMs,
lastTurnListenWaitMs,
lastTurnCompletedAt: transcriptReceivedAt,
};
persistCallRecord(ctx.storePath, call);
console.log(
"[voice-call] continueCall latency call=" +
call.callId +
" totalMs=" +
String(lastTurnLatencyMs) +
" listenWaitMs=" +
String(lastTurnListenWaitMs),
);
return { success: true, transcript };
} catch (err) {
return { success: false, error: formatErrorMessage(err) };
} finally {
ctx.activeTurnCalls.delete(callId);
clearTranscriptWaiter(ctx, callId);
}
}
export async function endCall(
ctx: EndCallContext,
callId: CallId,
options?: { reason?: EndReason },
): Promise<{ success: boolean; error?: string }> {
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") {
return { success: false, error: lookup.error };
}
if (lookup.kind === "ended") {
return { success: true };
}
const { call, providerCallId, provider } = lookup;
const reason = options?.reason ?? "hangup-bot";
try {
await provider.hangupCall({
callId,
providerCallId,
reason,
});
finalizeCall({
ctx,
call,
endReason: reason,
});
return { success: true };
} catch (err) {
return { success: false, error: formatErrorMessage(err) };
}
}

View file

@ -0,0 +1,48 @@
import { TerminalStates, type CallRecord, type CallState, type TranscriptEntry } from "../types.js";
const ConversationStates = new Set<CallState>(["speaking", "listening"]);
const StateOrder: readonly CallState[] = [
"initiated",
"ringing",
"answered",
"active",
"speaking",
"listening",
];
export function transitionState(call: CallRecord, newState: CallState): void {
// No-op for same state or already terminal.
if (call.state === newState || TerminalStates.has(call.state)) {
return;
}
// Terminal states can always be reached from non-terminal.
if (TerminalStates.has(newState)) {
call.state = newState;
return;
}
// Allow cycling between speaking and listening (multi-turn conversations).
if (ConversationStates.has(call.state) && ConversationStates.has(newState)) {
call.state = newState;
return;
}
// Only allow forward transitions in state order.
const currentIndex = StateOrder.indexOf(call.state);
const newIndex = StateOrder.indexOf(newState);
if (newIndex > currentIndex) {
call.state = newState;
}
}
export function addTranscriptEntry(call: CallRecord, speaker: "bot" | "user", text: string): void {
const entry: TranscriptEntry = {
timestamp: Date.now(),
speaker,
text,
isFinal: true,
};
call.transcript.push(entry);
}

View file

@ -0,0 +1,94 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { CallRecordSchema, TerminalStates, type CallId, type CallRecord } from "../types.js";
export function persistCallRecord(storePath: string, call: CallRecord): void {
const logPath = path.join(storePath, "calls.jsonl");
const line = `${JSON.stringify(call)}\n`;
// Fire-and-forget async write to avoid blocking event loop.
fsp.appendFile(logPath, line).catch((err) => {
console.error("[voice-call] Failed to persist call record:", err);
});
}
export function loadActiveCallsFromStore(storePath: string): {
activeCalls: Map<CallId, CallRecord>;
providerCallIdMap: Map<string, CallId>;
processedEventIds: Set<string>;
rejectedProviderCallIds: Set<string>;
} {
const logPath = path.join(storePath, "calls.jsonl");
if (!fs.existsSync(logPath)) {
return {
activeCalls: new Map(),
providerCallIdMap: new Map(),
processedEventIds: new Set(),
rejectedProviderCallIds: new Set(),
};
}
const content = fs.readFileSync(logPath, "utf-8");
const lines = content.split("\n");
const callMap = new Map<CallId, CallRecord>();
for (const line of lines) {
if (!line.trim()) {
continue;
}
try {
const call = CallRecordSchema.parse(JSON.parse(line));
callMap.set(call.callId, call);
} catch {
// Skip invalid lines.
}
}
const activeCalls = new Map<CallId, CallRecord>();
const providerCallIdMap = new Map<string, CallId>();
const processedEventIds = new Set<string>();
const rejectedProviderCallIds = new Set<string>();
for (const [callId, call] of callMap) {
for (const eventId of call.processedEventIds) {
processedEventIds.add(eventId);
}
if (TerminalStates.has(call.state)) {
continue;
}
activeCalls.set(callId, call);
if (call.providerCallId) {
providerCallIdMap.set(call.providerCallId, callId);
}
}
return { activeCalls, providerCallIdMap, processedEventIds, rejectedProviderCallIds };
}
export async function getCallHistoryFromStore(
storePath: string,
limit = 50,
): Promise<CallRecord[]> {
const logPath = path.join(storePath, "calls.jsonl");
try {
await fsp.access(logPath);
} catch {
return [];
}
const content = await fsp.readFile(logPath, "utf-8");
const lines = content.trim().split("\n").filter(Boolean);
const calls: CallRecord[] = [];
for (const line of lines.slice(-limit)) {
try {
const parsed = CallRecordSchema.parse(JSON.parse(line));
calls.push(parsed);
} catch {
// Skip invalid lines.
}
}
return calls;
}

View file

@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { persistCallRecordMock } = vi.hoisted(() => ({
persistCallRecordMock: vi.fn(),
}));
vi.mock("./store.js", () => ({
persistCallRecord: persistCallRecordMock,
}));
import {
clearMaxDurationTimer,
clearTranscriptWaiter,
rejectTranscriptWaiter,
resolveTranscriptWaiter,
startMaxDurationTimer,
waitForFinalTranscript,
} from "./timers.js";
describe("voice-call manager timers", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it("starts and clears max duration timers, persisting timeout metadata before delegation", async () => {
const call = { id: "call-1", state: "active" };
const ctx = {
activeCalls: new Map([["call-1", call]]),
maxDurationTimers: new Map(),
config: { maxDurationSeconds: 5 },
storePath: "/tmp/voice-call",
};
const onTimeout = vi.fn(async () => {});
startMaxDurationTimer({
ctx: ctx as never,
callId: "call-1",
onTimeout,
});
expect(ctx.maxDurationTimers.has("call-1")).toBe(true);
await vi.advanceTimersByTimeAsync(5_000);
expect(call).toEqual({ id: "call-1", state: "active", endReason: "timeout" });
expect(persistCallRecordMock).toHaveBeenCalledWith("/tmp/voice-call", call);
expect(onTimeout).toHaveBeenCalledWith("call-1");
expect(ctx.maxDurationTimers.has("call-1")).toBe(false);
startMaxDurationTimer({
ctx: ctx as never,
callId: "call-1",
onTimeout,
});
clearMaxDurationTimer(ctx as never, "call-1");
expect(ctx.maxDurationTimers.has("call-1")).toBe(false);
});
it("does not time out terminal calls", async () => {
const ctx = {
activeCalls: new Map([["call-1", { id: "call-1", state: "completed" }]]),
maxDurationTimers: new Map(),
config: { maxDurationSeconds: 5 },
storePath: "/tmp/voice-call",
};
const onTimeout = vi.fn(async () => {});
startMaxDurationTimer({
ctx: ctx as never,
callId: "call-1",
onTimeout,
});
await vi.advanceTimersByTimeAsync(5_000);
expect(persistCallRecordMock).not.toHaveBeenCalled();
expect(onTimeout).not.toHaveBeenCalled();
});
it("waits for transcripts, resolves matching tokens, rejects mismatches and timeouts", async () => {
const ctx = {
transcriptWaiters: new Map(),
config: { transcriptTimeoutMs: 1_000 },
};
const pending = waitForFinalTranscript(ctx as never, "call-1", "turn-1");
expect(resolveTranscriptWaiter(ctx as never, "call-1", "ignored", "turn-2")).toBe(false);
expect(resolveTranscriptWaiter(ctx as never, "call-1", "final transcript", "turn-1")).toBe(
true,
);
await expect(pending).resolves.toBe("final transcript");
const another = waitForFinalTranscript(ctx as never, "call-2");
rejectTranscriptWaiter(ctx as never, "call-2", "provider failed");
await expect(another).rejects.toThrow("provider failed");
const timedOut = waitForFinalTranscript(ctx as never, "call-3").catch((error) => error);
await vi.advanceTimersByTimeAsync(1_000);
await expect(timedOut).resolves.toEqual(
expect.objectContaining({
message: "Timed out waiting for transcript after 1000ms",
}),
);
const toClear = waitForFinalTranscript(ctx as never, "call-4");
clearTranscriptWaiter(ctx as never, "call-4");
expect(ctx.transcriptWaiters.has("call-4")).toBe(false);
void toClear.catch(() => {});
});
it("rejects duplicate transcript waiters for the same call", async () => {
const ctx = {
transcriptWaiters: new Map(),
config: { transcriptTimeoutMs: 1_000 },
};
const pending = waitForFinalTranscript(ctx as never, "call-1");
await expect(waitForFinalTranscript(ctx as never, "call-1")).rejects.toThrow(
"Already waiting for transcript",
);
rejectTranscriptWaiter(ctx as never, "call-1", "done");
await expect(pending).rejects.toThrow("done");
});
});

View file

@ -0,0 +1,112 @@
import { TerminalStates, type CallId } from "../types.js";
import type { CallManagerContext } from "./context.js";
import { persistCallRecord } from "./store.js";
type TimerContext = Pick<
CallManagerContext,
"activeCalls" | "maxDurationTimers" | "config" | "storePath" | "transcriptWaiters"
>;
type MaxDurationTimerContext = Pick<
TimerContext,
"activeCalls" | "maxDurationTimers" | "config" | "storePath"
>;
type TranscriptWaiterContext = Pick<TimerContext, "transcriptWaiters">;
export function clearMaxDurationTimer(
ctx: Pick<MaxDurationTimerContext, "maxDurationTimers">,
callId: CallId,
): void {
const timer = ctx.maxDurationTimers.get(callId);
if (timer) {
clearTimeout(timer);
ctx.maxDurationTimers.delete(callId);
}
}
export function startMaxDurationTimer(params: {
ctx: MaxDurationTimerContext;
callId: CallId;
onTimeout: (callId: CallId) => Promise<void>;
}): void {
clearMaxDurationTimer(params.ctx, params.callId);
const maxDurationMs = params.ctx.config.maxDurationSeconds * 1000;
console.log(
`[voice-call] Starting max duration timer (${params.ctx.config.maxDurationSeconds}s) for call ${params.callId}`,
);
const timer = setTimeout(async () => {
params.ctx.maxDurationTimers.delete(params.callId);
const call = params.ctx.activeCalls.get(params.callId);
if (call && !TerminalStates.has(call.state)) {
console.log(
`[voice-call] Max duration reached (${params.ctx.config.maxDurationSeconds}s), ending call ${params.callId}`,
);
call.endReason = "timeout";
persistCallRecord(params.ctx.storePath, call);
await params.onTimeout(params.callId);
}
}, maxDurationMs);
params.ctx.maxDurationTimers.set(params.callId, timer);
}
export function clearTranscriptWaiter(ctx: TranscriptWaiterContext, callId: CallId): void {
const waiter = ctx.transcriptWaiters.get(callId);
if (!waiter) {
return;
}
clearTimeout(waiter.timeout);
ctx.transcriptWaiters.delete(callId);
}
export function rejectTranscriptWaiter(
ctx: TranscriptWaiterContext,
callId: CallId,
reason: string,
): void {
const waiter = ctx.transcriptWaiters.get(callId);
if (!waiter) {
return;
}
clearTranscriptWaiter(ctx, callId);
waiter.reject(new Error(reason));
}
export function resolveTranscriptWaiter(
ctx: TranscriptWaiterContext,
callId: CallId,
transcript: string,
turnToken?: string,
): boolean {
const waiter = ctx.transcriptWaiters.get(callId);
if (!waiter) {
return false;
}
if (waiter.turnToken && waiter.turnToken !== turnToken) {
return false;
}
clearTranscriptWaiter(ctx, callId);
waiter.resolve(transcript);
return true;
}
export function waitForFinalTranscript(
ctx: TimerContext,
callId: CallId,
turnToken?: string,
): Promise<string> {
if (ctx.transcriptWaiters.has(callId)) {
return Promise.reject(new Error("Already waiting for transcript"));
}
const timeoutMs = ctx.config.transcriptTimeoutMs;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ctx.transcriptWaiters.delete(callId);
reject(new Error(`Timed out waiting for transcript after ${timeoutMs}ms`));
}, timeoutMs);
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout, turnToken });
});
}

View file

@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { generateNotifyTwiml } from "./twiml.js";
describe("generateNotifyTwiml", () => {
it("renders escaped xml with the requested voice", () => {
expect(generateNotifyTwiml(`Call <ended> & "logged"`, "Polly.Joanna"))
.toBe(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="Polly.Joanna">Call &lt;ended&gt; &amp; &quot;logged&quot;</Say>
<Hangup/>
</Response>`);
});
});

View file

@ -0,0 +1,9 @@
import { escapeXml } from "../voice-mapping.js";
export function generateNotifyTwiml(message: string, voice: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="${voice}">${escapeXml(message)}</Say>
<Hangup/>
</Response>`;
}

View file

@ -0,0 +1,513 @@
import type { IncomingMessage } from "node:http";
import net from "node:net";
import type {
RealtimeTranscriptionProviderPlugin,
RealtimeTranscriptionSession,
} from "openclaw/plugin-sdk/realtime-transcription";
import { describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
import { MediaStreamHandler, sanitizeLogText } from "./media-stream.js";
import {
connectWs,
startUpgradeWsServer,
waitForClose,
withTimeout,
} from "./websocket-test-support.js";
const createStubSession = (): RealtimeTranscriptionSession => ({
connect: async () => {},
sendAudio: () => {},
close: () => {},
isConnected: () => true,
});
const createStubSttProvider = (): RealtimeTranscriptionProviderPlugin =>
({
createSession: () => createStubSession(),
id: "openai",
label: "OpenAI",
isConfigured: () => true,
}) as unknown as RealtimeTranscriptionProviderPlugin;
const flush = async (): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 0));
};
const waitForAbort = (signal: AbortSignal): Promise<void> =>
new Promise((resolve) => {
if (signal.aborted) {
resolve();
return;
}
signal.addEventListener("abort", () => resolve(), { once: true });
});
const startWsServer = async (
handler: MediaStreamHandler,
): Promise<{
url: string;
close: () => Promise<void>;
}> =>
startUpgradeWsServer({
urlPath: "/voice/stream",
onUpgrade: (request, socket, head) => {
handler.handleUpgrade(request, socket, head);
},
});
describe("MediaStreamHandler TTS queue", () => {
it("serializes TTS playback and resolves in order", async () => {
const handler = new MediaStreamHandler({
transcriptionProvider: createStubSttProvider(),
providerConfig: {},
});
const started: number[] = [];
const finished: number[] = [];
let resolveFirst!: () => void;
const firstGate = new Promise<void>((resolve) => {
resolveFirst = resolve;
});
const first = handler.queueTts("stream-1", async () => {
started.push(1);
await firstGate;
finished.push(1);
});
const second = handler.queueTts("stream-1", async () => {
started.push(2);
finished.push(2);
});
await flush();
expect(started).toEqual([1]);
resolveFirst();
await first;
await second;
expect(started).toEqual([1, 2]);
expect(finished).toEqual([1, 2]);
});
it("cancels active playback and clears queued items", async () => {
const handler = new MediaStreamHandler({
transcriptionProvider: createStubSttProvider(),
providerConfig: {},
});
let queuedRan = false;
const started: string[] = [];
const active = handler.queueTts("stream-1", async (signal) => {
started.push("active");
await waitForAbort(signal);
});
void handler.queueTts("stream-1", async () => {
queuedRan = true;
});
await flush();
expect(started).toEqual(["active"]);
handler.clearTtsQueue("stream-1");
await active;
await flush();
expect(queuedRan).toBe(false);
});
});
describe("MediaStreamHandler security hardening", () => {
it("fails sends and closes stream when buffered bytes already exceed the cap", () => {
const handler = new MediaStreamHandler({
transcriptionProvider: createStubSttProvider(),
providerConfig: {},
});
const ws = {
readyState: WebSocket.OPEN,
bufferedAmount: 2 * 1024 * 1024,
send: vi.fn(),
close: vi.fn(),
} as unknown as WebSocket;
(
handler as unknown as {
sessions: Map<
string,
{
callId: string;
streamSid: string;
ws: WebSocket;
sttSession: RealtimeTranscriptionSession;
}
>;
}
).sessions.set("MZ-backpressure", {
callId: "CA-backpressure",
streamSid: "MZ-backpressure",
ws,
sttSession: createStubSession(),
});
const result = handler.sendAudio("MZ-backpressure", Buffer.alloc(160, 0xff));
expect(result.sent).toBe(false);
expect(ws.send).not.toHaveBeenCalled();
expect(ws.close).toHaveBeenCalledWith(1013, "Backpressure: send buffer exceeded");
});
it("fails sends when buffered bytes exceed cap after enqueueing a frame", () => {
const handler = new MediaStreamHandler({
transcriptionProvider: createStubSttProvider(),
providerConfig: {},
});
const ws = {
readyState: WebSocket.OPEN,
bufferedAmount: 0,
send: vi.fn(() => {
(
ws as unknown as {
bufferedAmount: number;
}
).bufferedAmount = 2 * 1024 * 1024;
}),
close: vi.fn(),
} as unknown as WebSocket;
(
handler as unknown as {
sessions: Map<
string,
{
callId: string;
streamSid: string;
ws: WebSocket;
sttSession: RealtimeTranscriptionSession;
}
>;
}
).sessions.set("MZ-overflow", {
callId: "CA-overflow",
streamSid: "MZ-overflow",
ws,
sttSession: createStubSession(),
});
const result = handler.sendMark("MZ-overflow", "mark-1");
expect(ws.send).toHaveBeenCalledTimes(1);
expect(result.sent).toBe(false);
expect(ws.close).toHaveBeenCalledWith(1013, "Backpressure: send buffer exceeded");
});
it("sanitizes websocket close reason before logging", () => {
const reason = sanitizeLogText("forged\nline\r\tentry", 120);
expect(reason).not.toContain("\n");
expect(reason).not.toContain("\r");
expect(reason).not.toContain("\t");
expect(reason).toContain("forged line entry");
});
it("closes idle pre-start connections after timeout", async () => {
const shouldAcceptStreamCalls: Array<{ callId: string; streamSid: string; token?: string }> =
[];
const handler = new MediaStreamHandler({
transcriptionProvider: createStubSttProvider(),
providerConfig: {},
preStartTimeoutMs: 40,
shouldAcceptStream: (params) => {
shouldAcceptStreamCalls.push(params);
return true;
},
});
const server = await startWsServer(handler);
try {
const ws = await connectWs(server.url);
const closed = await waitForClose(ws);
expect(closed.code).toBe(1008);
expect(closed.reason).toBe("Start timeout");
expect(shouldAcceptStreamCalls).toEqual([]);
} finally {
await server.close();
}
});
it("enforces pending connection limits", async () => {
const handler = new MediaStreamHandler({
transcriptionProvider: createStubSttProvider(),
providerConfig: {},
preStartTimeoutMs: 5_000,
maxPendingConnections: 1,
maxPendingConnectionsPerIp: 1,
});
const server = await startWsServer(handler);
try {
const first = await connectWs(server.url);
const second = await connectWs(server.url);
const secondClosed = await waitForClose(second);
expect(secondClosed.code).toBe(1013);
expect(secondClosed.reason).toContain("Too many pending");
expect(first.readyState).toBe(WebSocket.OPEN);
first.close();
await waitForClose(first);
} finally {
await server.close();
}
});
it("uses resolved client IPs for per-IP pending limits", async () => {
const handler = new MediaStreamHandler({
transcriptionProvider: createStubSttProvider(),
providerConfig: {},
preStartTimeoutMs: 5_000,
maxPendingConnections: 10,
maxPendingConnectionsPerIp: 1,
resolveClientIp: (request) => String(request.headers["x-forwarded-for"] ?? ""),
});
const server = await startWsServer(handler);
try {
const first = new WebSocket(server.url, {
headers: { "x-forwarded-for": "198.51.100.10" },
});
await withTimeout(new Promise((resolve) => first.once("open", resolve)));
const second = new WebSocket(server.url, {
headers: { "x-forwarded-for": "203.0.113.20" },
});
await withTimeout(new Promise((resolve) => second.once("open", resolve)));
expect(first.readyState).toBe(WebSocket.OPEN);
expect(second.readyState).toBe(WebSocket.OPEN);
const firstClosed = waitForClose(first);
const secondClosed = waitForClose(second);
first.close();
second.close();
await firstClosed;
await secondClosed;
} finally {
await server.close();
}
});
it("rejects upgrades when max connection cap is reached", async () => {
const handler = new MediaStreamHandler({
transcriptionProvider: createStubSttProvider(),
providerConfig: {},
preStartTimeoutMs: 5_000,
maxConnections: 1,
maxPendingConnections: 10,
maxPendingConnectionsPerIp: 10,
});
const server = await startWsServer(handler);
try {
const first = await connectWs(server.url);
const secondError = await withTimeout(
new Promise<Error>((resolve) => {
const ws = new WebSocket(server.url);
ws.once("error", (err) => resolve(err));
}),
);
expect(secondError.message).toContain("Unexpected server response: 503");
first.close();
await waitForClose(first);
} finally {
await server.close();
}
});
it("counts in-flight upgrades against the max connection cap", () => {
const handler = new MediaStreamHandler({
transcriptionProvider: createStubSttProvider(),
providerConfig: {},
maxConnections: 2,
maxPendingConnections: 10,
maxPendingConnectionsPerIp: 10,
});
const fakeWss = {
clients: new Set([{}]),
handleUpgrade: vi.fn(),
emit: vi.fn(),
on: vi.fn(),
};
let upgradeCallback: ((ws: WebSocket) => void) | null = null;
fakeWss.handleUpgrade.mockImplementation(
(
_request: IncomingMessage,
_socket: unknown,
_head: Buffer,
callback: (ws: WebSocket) => void,
) => {
upgradeCallback = callback;
},
);
(
handler as unknown as {
wss: typeof fakeWss;
}
).wss = fakeWss;
const firstSocket = {
once: vi.fn(),
removeListener: vi.fn(),
write: vi.fn(),
destroy: vi.fn(),
};
handler.handleUpgrade(
{ socket: { remoteAddress: "127.0.0.1" } } as IncomingMessage,
firstSocket as never,
Buffer.alloc(0),
);
const secondSocket = {
once: vi.fn(),
removeListener: vi.fn(),
write: vi.fn(),
destroy: vi.fn(),
};
handler.handleUpgrade(
{ socket: { remoteAddress: "127.0.0.1" } } as IncomingMessage,
secondSocket as never,
Buffer.alloc(0),
);
expect(fakeWss.handleUpgrade).toHaveBeenCalledTimes(1);
expect(secondSocket.write).toHaveBeenCalledOnce();
expect(secondSocket.destroy).toHaveBeenCalledOnce();
expect(upgradeCallback).not.toBeNull();
const completeUpgrade = upgradeCallback as ((ws: WebSocket) => void) | null;
if (!completeUpgrade) {
throw new Error("Expected upgrade callback to be registered");
}
completeUpgrade({} as WebSocket);
expect(fakeWss.emit).toHaveBeenCalledWith(
"connection",
expect.anything(),
expect.objectContaining({ socket: { remoteAddress: "127.0.0.1" } }),
);
});
it("releases in-flight reservations when ws rejects a malformed upgrade before the callback", async () => {
const handler = new MediaStreamHandler({
transcriptionProvider: createStubSttProvider(),
providerConfig: {},
preStartTimeoutMs: 5_000,
maxConnections: 1,
maxPendingConnections: 10,
maxPendingConnectionsPerIp: 10,
});
const server = await startWsServer(handler);
const serverUrl = new URL(server.url);
try {
await withTimeout(
new Promise<void>((resolve, reject) => {
const socket = net.createConnection(
{ host: serverUrl.hostname, port: Number(serverUrl.port) },
() => {
socket.write(
[
"GET /voice/stream HTTP/1.1",
`Host: ${serverUrl.host}`,
"Upgrade: websocket",
"Connection: Upgrade",
"Sec-WebSocket-Version: 13",
"",
"",
].join("\r\n"),
);
},
);
socket.once("error", reject);
socket.once("data", () => {
socket.end();
});
socket.once("close", () => resolve());
}),
);
const ws = await connectWs(server.url);
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
await waitForClose(ws);
} finally {
await server.close();
}
});
it("clears pending state after valid start", async () => {
const handler = new MediaStreamHandler({
transcriptionProvider: createStubSttProvider(),
providerConfig: {},
preStartTimeoutMs: 40,
shouldAcceptStream: () => true,
});
const server = await startWsServer(handler);
try {
const ws = await connectWs(server.url);
ws.send(
JSON.stringify({
event: "start",
streamSid: "MZ123",
start: { callSid: "CA123", customParameters: { token: "token-123" } },
}),
);
await new Promise((resolve) => setTimeout(resolve, 80));
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
await waitForClose(ws);
} finally {
await server.close();
}
});
it("rejects oversized pre-start frames at the websocket maxPayload guard before validation runs", async () => {
const shouldAcceptStreamCalls: Array<{ callId: string; streamSid: string; token?: string }> =
[];
const handler = new MediaStreamHandler({
transcriptionProvider: createStubSttProvider(),
providerConfig: {},
preStartTimeoutMs: 1_000,
shouldAcceptStream: (params) => {
shouldAcceptStreamCalls.push(params);
return true;
},
});
const server = await startWsServer(handler);
try {
const ws = await connectWs(server.url);
ws.send(
JSON.stringify({
event: "start",
streamSid: "MZ-oversized",
start: {
callSid: "CA-oversized",
customParameters: { token: "token-oversized", padding: "A".repeat(256 * 1024) },
},
}),
);
const closed = await waitForClose(ws);
expect(closed.code).toBe(1009);
expect(shouldAcceptStreamCalls).toEqual([]);
} finally {
await server.close();
}
});
});

View file

@ -0,0 +1,670 @@
/**
* Media Stream Handler
*
* Handles bidirectional audio streaming between Twilio and the AI services.
* - Receives mu-law audio from Twilio via WebSocket
* - Forwards to the selected realtime transcription provider
* - Sends TTS audio back to Twilio
*/
import type { IncomingMessage } from "node:http";
import type { Duplex } from "node:stream";
import type {
RealtimeTranscriptionProviderConfig,
RealtimeTranscriptionProviderPlugin,
RealtimeTranscriptionSession,
} from "openclaw/plugin-sdk/realtime-transcription";
import { type RawData, WebSocket, WebSocketServer } from "ws";
/**
* Configuration for the media stream handler.
*/
export interface MediaStreamConfig {
/** Realtime transcription provider for streaming STT. */
transcriptionProvider: RealtimeTranscriptionProviderPlugin;
/** Provider-owned config blob passed into the transcription session. */
providerConfig: RealtimeTranscriptionProviderConfig;
/** Close sockets that never send a valid `start` frame within this window. */
preStartTimeoutMs?: number;
/** Max concurrent pre-start sockets. */
maxPendingConnections?: number;
/** Max concurrent pre-start sockets from a single source IP. */
maxPendingConnectionsPerIp?: number;
/** Max total open sockets (pending + active sessions). */
maxConnections?: number;
/** Optional trusted resolver for the source IP used by pending-connection guards. */
resolveClientIp?: (request: IncomingMessage) => string | undefined;
/** Validate whether to accept a media stream for the given call ID */
shouldAcceptStream?: (params: { callId: string; streamSid: string; token?: string }) => boolean;
/** Callback when transcript is received */
onTranscript?: (callId: string, transcript: string) => void;
/** Callback for partial transcripts (streaming UI) */
onPartialTranscript?: (callId: string, partial: string) => void;
/** Callback when stream connects */
onConnect?: (callId: string, streamSid: string) => void;
/** Callback when speech starts (barge-in) */
onSpeechStart?: (callId: string) => void;
/** Callback when stream disconnects */
onDisconnect?: (callId: string, streamSid: string) => void;
}
/**
* Active media stream session.
*/
interface StreamSession {
callId: string;
streamSid: string;
ws: WebSocket;
sttSession: RealtimeTranscriptionSession;
}
type TtsQueueEntry = {
playFn: (signal: AbortSignal) => Promise<void>;
controller: AbortController;
resolve: () => void;
reject: (error: unknown) => void;
};
type StreamSendResult = {
sent: boolean;
readyState?: number;
bufferedBeforeBytes: number;
bufferedAfterBytes: number;
};
type PendingConnection = {
ip: string;
timeout: ReturnType<typeof setTimeout>;
};
const DEFAULT_PRE_START_TIMEOUT_MS = 5000;
const DEFAULT_MAX_PENDING_CONNECTIONS = 32;
const DEFAULT_MAX_PENDING_CONNECTIONS_PER_IP = 4;
const DEFAULT_MAX_CONNECTIONS = 128;
const MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
const MAX_WS_BUFFERED_BYTES = 1024 * 1024;
const CLOSE_REASON_LOG_MAX_CHARS = 120;
export function sanitizeLogText(value: string, maxChars: number): string {
const sanitized = value
.replace(/\p{Cc}/gu, " ")
.replace(/\s+/g, " ")
.trim();
if (sanitized.length <= maxChars) {
return sanitized;
}
return `${sanitized.slice(0, maxChars)}...`;
}
function normalizeWsMessageData(data: RawData): Buffer {
if (Buffer.isBuffer(data)) {
return data;
}
if (Array.isArray(data)) {
return Buffer.concat(data);
}
return Buffer.from(data);
}
/**
* Manages WebSocket connections for Twilio media streams.
*/
export class MediaStreamHandler {
private wss: WebSocketServer | null = null;
private sessions = new Map<string, StreamSession>();
private config: MediaStreamConfig;
/** Pending sockets that have upgraded but not yet sent an accepted `start` frame. */
private pendingConnections = new Map<WebSocket, PendingConnection>();
/** Pending socket count per remote IP for pre-auth throttling. */
private pendingByIp = new Map<string, number>();
private preStartTimeoutMs: number;
private maxPendingConnections: number;
private maxPendingConnectionsPerIp: number;
private maxConnections: number;
private inflightUpgrades = 0;
/** TTS playback queues per stream (serialize audio to prevent overlap) */
private ttsQueues = new Map<string, TtsQueueEntry[]>();
/** Whether TTS is currently playing per stream */
private ttsPlaying = new Map<string, boolean>();
/** Active TTS playback controllers per stream */
private ttsActiveControllers = new Map<string, AbortController>();
constructor(config: MediaStreamConfig) {
this.config = config;
this.preStartTimeoutMs = config.preStartTimeoutMs ?? DEFAULT_PRE_START_TIMEOUT_MS;
this.maxPendingConnections = config.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS;
this.maxPendingConnectionsPerIp =
config.maxPendingConnectionsPerIp ?? DEFAULT_MAX_PENDING_CONNECTIONS_PER_IP;
this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
}
/**
* Handle WebSocket upgrade for media stream connections.
*/
handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void {
if (!this.wss) {
this.wss = new WebSocketServer({
noServer: true,
// Reject oversized frames before app-level parsing runs on unauthenticated sockets.
maxPayload: MAX_INBOUND_MESSAGE_BYTES,
});
this.wss.on("connection", (ws, req) => this.handleConnection(ws, req));
}
const currentConnections = this.getCurrentConnectionCount();
if (currentConnections >= this.maxConnections) {
this.rejectUpgrade(socket, 503, "Too many media stream connections");
return;
}
this.inflightUpgrades += 1;
let released = false;
const releaseUpgradeReservation = () => {
if (released) {
return;
}
released = true;
this.inflightUpgrades = Math.max(0, this.inflightUpgrades - 1);
};
const handleUpgradeAbort = () => {
socket.removeListener("error", handleUpgradeAbort);
socket.removeListener("close", handleUpgradeAbort);
releaseUpgradeReservation();
};
socket.once("error", handleUpgradeAbort);
socket.once("close", handleUpgradeAbort);
try {
this.wss.handleUpgrade(request, socket, head, (ws) => {
socket.removeListener("error", handleUpgradeAbort);
socket.removeListener("close", handleUpgradeAbort);
releaseUpgradeReservation();
this.wss?.emit("connection", ws, request);
});
} catch (error) {
socket.removeListener("error", handleUpgradeAbort);
socket.removeListener("close", handleUpgradeAbort);
releaseUpgradeReservation();
throw error;
}
}
/**
* Handle new WebSocket connection from Twilio.
*/
private async handleConnection(ws: WebSocket, _request: IncomingMessage): Promise<void> {
let session: StreamSession | null = null;
const streamToken = this.getStreamToken(_request);
const ip = this.getClientIp(_request);
if (!this.registerPendingConnection(ws, ip)) {
ws.close(1013, "Too many pending media stream connections");
return;
}
ws.on("message", async (data: RawData) => {
try {
const raw = normalizeWsMessageData(data);
const message = JSON.parse(raw.toString("utf8")) as TwilioMediaMessage;
switch (message.event) {
case "connected":
console.log("[MediaStream] Twilio connected");
break;
case "start":
session = await this.handleStart(ws, message, streamToken);
if (session) {
this.clearPendingConnection(ws);
}
break;
case "media":
if (session && message.media?.payload) {
// Forward audio to STT
const audioBuffer = Buffer.from(message.media.payload, "base64");
session.sttSession.sendAudio(audioBuffer);
}
break;
case "stop":
if (session) {
this.handleStop(session);
session = null;
}
break;
}
} catch (error) {
console.error("[MediaStream] Error processing message:", error);
}
});
ws.on("close", (code, reason) => {
const rawReason = Buffer.isBuffer(reason) ? reason.toString("utf8") : String(reason || "");
const reasonText = sanitizeLogText(rawReason, CLOSE_REASON_LOG_MAX_CHARS);
console.log(
`[MediaStream] WebSocket closed (code: ${code}, reason: ${reasonText || "none"})`,
);
this.clearPendingConnection(ws);
if (session) {
this.handleStop(session);
}
});
ws.on("error", (error) => {
console.error("[MediaStream] WebSocket error:", error);
});
}
/**
* Handle stream start event.
*/
private async handleStart(
ws: WebSocket,
message: TwilioMediaMessage,
streamToken?: string,
): Promise<StreamSession | null> {
const streamSid = message.streamSid || "";
const callSid = message.start?.callSid || "";
// Prefer token from start message customParameters (set via TwiML <Parameter>),
// falling back to query string token. Twilio strips query params from WebSocket
// URLs but reliably delivers <Parameter> values in customParameters.
const effectiveToken = message.start?.customParameters?.token ?? streamToken;
console.log(`[MediaStream] Stream started: ${streamSid} (call: ${callSid})`);
if (!callSid) {
console.warn("[MediaStream] Missing callSid; closing stream");
ws.close(1008, "Missing callSid");
return null;
}
if (
this.config.shouldAcceptStream &&
!this.config.shouldAcceptStream({ callId: callSid, streamSid, token: effectiveToken })
) {
console.warn(`[MediaStream] Rejecting stream for unknown call: ${callSid}`);
ws.close(1008, "Unknown call");
return null;
}
const sttSession = this.config.transcriptionProvider.createSession({
providerConfig: this.config.providerConfig,
onPartial: (partial) => {
this.config.onPartialTranscript?.(callSid, partial);
},
onTranscript: (transcript) => {
this.config.onTranscript?.(callSid, transcript);
},
onSpeechStart: () => {
this.config.onSpeechStart?.(callSid);
},
onError: (error) => {
console.warn("[MediaStream] Transcription session error:", error.message);
},
});
const session: StreamSession = {
callId: callSid,
streamSid,
ws,
sttSession,
};
this.sessions.set(streamSid, session);
// Notify connection BEFORE STT connect so TTS can work even if STT fails
this.config.onConnect?.(callSid, streamSid);
// Connect to transcription service (non-blocking, log errors but don't fail the call)
sttSession.connect().catch((err) => {
console.warn(`[MediaStream] STT connection failed (TTS still works):`, err.message);
});
return session;
}
/**
* Handle stream stop event.
*/
private handleStop(session: StreamSession): void {
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
this.clearTtsState(session.streamSid);
session.sttSession.close();
this.sessions.delete(session.streamSid);
this.config.onDisconnect?.(session.callId, session.streamSid);
}
private getStreamToken(request: IncomingMessage): string | undefined {
if (!request.url || !request.headers.host) {
return undefined;
}
try {
const url = new URL(request.url, `http://${request.headers.host}`);
return url.searchParams.get("token") ?? undefined;
} catch {
return undefined;
}
}
private getClientIp(request: IncomingMessage): string {
const resolvedIp = this.config.resolveClientIp?.(request)?.trim();
if (resolvedIp) {
return resolvedIp;
}
return request.socket.remoteAddress || "unknown";
}
private getCurrentConnectionCount(): number {
return this.wss ? this.wss.clients.size + this.inflightUpgrades : this.inflightUpgrades;
}
private registerPendingConnection(ws: WebSocket, ip: string): boolean {
if (this.pendingConnections.size >= this.maxPendingConnections) {
console.warn("[MediaStream] Rejecting connection: pending connection limit reached");
return false;
}
const pendingForIp = this.pendingByIp.get(ip) ?? 0;
if (pendingForIp >= this.maxPendingConnectionsPerIp) {
console.warn(`[MediaStream] Rejecting connection: pending per-IP limit reached (${ip})`);
return false;
}
const timeout = setTimeout(() => {
if (!this.pendingConnections.has(ws)) {
return;
}
console.warn(
`[MediaStream] Closing pre-start idle connection after ${this.preStartTimeoutMs}ms (${ip})`,
);
ws.close(1008, "Start timeout");
}, this.preStartTimeoutMs);
timeout.unref?.();
this.pendingConnections.set(ws, { ip, timeout });
this.pendingByIp.set(ip, pendingForIp + 1);
return true;
}
private clearPendingConnection(ws: WebSocket): void {
const pending = this.pendingConnections.get(ws);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
this.pendingConnections.delete(ws);
const current = this.pendingByIp.get(pending.ip) ?? 0;
if (current <= 1) {
this.pendingByIp.delete(pending.ip);
return;
}
this.pendingByIp.set(pending.ip, current - 1);
}
private rejectUpgrade(socket: Duplex, statusCode: 429 | 503, message: string): void {
const statusText = statusCode === 429 ? "Too Many Requests" : "Service Unavailable";
const body = `${message}\n`;
socket.write(
`HTTP/1.1 ${statusCode} ${statusText}\r\n` +
"Connection: close\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
`Content-Length: ${Buffer.byteLength(body)}\r\n` +
"\r\n" +
body,
);
socket.destroy();
}
/**
* Get an active session with an open WebSocket, or undefined if unavailable.
*/
private getOpenSession(streamSid: string): StreamSession | undefined {
const session = this.sessions.get(streamSid);
return session?.ws.readyState === WebSocket.OPEN ? session : undefined;
}
/**
* Send a message to a stream's WebSocket if available.
*/
private sendToStream(streamSid: string, message: unknown): StreamSendResult {
const session = this.sessions.get(streamSid);
if (!session) {
return {
sent: false,
bufferedBeforeBytes: 0,
bufferedAfterBytes: 0,
};
}
const readyState = session.ws.readyState;
const bufferedBeforeBytes = session.ws.bufferedAmount;
if (readyState !== WebSocket.OPEN) {
return {
sent: false,
readyState,
bufferedBeforeBytes,
bufferedAfterBytes: session.ws.bufferedAmount,
};
}
if (bufferedBeforeBytes > MAX_WS_BUFFERED_BYTES) {
try {
session.ws.close(1013, "Backpressure: send buffer exceeded");
} catch {
// Best-effort close; caller still receives sent:false.
}
return {
sent: false,
readyState,
bufferedBeforeBytes,
bufferedAfterBytes: session.ws.bufferedAmount,
};
}
try {
session.ws.send(JSON.stringify(message));
const bufferedAfterBytes = session.ws.bufferedAmount;
if (bufferedAfterBytes > MAX_WS_BUFFERED_BYTES) {
try {
session.ws.close(1013, "Backpressure: send buffer exceeded");
} catch {
// Best-effort close; caller still receives sent:false.
}
return {
sent: false,
readyState,
bufferedBeforeBytes,
bufferedAfterBytes,
};
}
return {
sent: true,
readyState,
bufferedBeforeBytes,
bufferedAfterBytes,
};
} catch {
return {
sent: false,
readyState,
bufferedBeforeBytes,
bufferedAfterBytes: session.ws.bufferedAmount,
};
}
}
/**
* Send audio to a specific stream (for TTS playback).
* Audio should be mu-law encoded at 8kHz mono.
*/
sendAudio(streamSid: string, muLawAudio: Buffer): StreamSendResult {
return this.sendToStream(streamSid, {
event: "media",
streamSid,
media: { payload: muLawAudio.toString("base64") },
});
}
/**
* Send a mark event to track audio playback position.
*/
sendMark(streamSid: string, name: string): StreamSendResult {
return this.sendToStream(streamSid, {
event: "mark",
streamSid,
mark: { name },
});
}
/**
* Clear audio buffer (interrupt playback).
*/
clearAudio(streamSid: string): StreamSendResult {
return this.sendToStream(streamSid, { event: "clear", streamSid });
}
/**
* Queue a TTS operation for sequential playback.
* Only one TTS operation plays at a time per stream to prevent overlap.
*/
async queueTts(streamSid: string, playFn: (signal: AbortSignal) => Promise<void>): Promise<void> {
const queue = this.getTtsQueue(streamSid);
let resolveEntry: () => void;
let rejectEntry: (error: unknown) => void;
const promise = new Promise<void>((resolve, reject) => {
resolveEntry = resolve;
rejectEntry = reject;
});
queue.push({
playFn,
controller: new AbortController(),
resolve: resolveEntry!,
reject: rejectEntry!,
});
if (!this.ttsPlaying.get(streamSid)) {
void this.processQueue(streamSid);
}
return promise;
}
/**
* Clear TTS queue and interrupt current playback (barge-in).
*/
clearTtsQueue(streamSid: string, _reason = "unspecified"): void {
const queue = this.getTtsQueue(streamSid);
queue.length = 0;
this.ttsActiveControllers.get(streamSid)?.abort();
this.clearAudio(streamSid);
}
/**
* Get active session by call ID.
*/
getSessionByCallId(callId: string): StreamSession | undefined {
return [...this.sessions.values()].find((session) => session.callId === callId);
}
/**
* Close all sessions.
*/
closeAll(): void {
for (const session of this.sessions.values()) {
this.clearTtsState(session.streamSid);
session.sttSession.close();
session.ws.close();
}
this.sessions.clear();
}
private getTtsQueue(streamSid: string): TtsQueueEntry[] {
const existing = this.ttsQueues.get(streamSid);
if (existing) {
return existing;
}
const queue: TtsQueueEntry[] = [];
this.ttsQueues.set(streamSid, queue);
return queue;
}
/**
* Process the TTS queue for a stream.
* Uses iterative approach to avoid stack accumulation from recursion.
*/
private async processQueue(streamSid: string): Promise<void> {
this.ttsPlaying.set(streamSid, true);
while (true) {
const queue = this.ttsQueues.get(streamSid);
if (!queue || queue.length === 0) {
this.ttsPlaying.set(streamSid, false);
this.ttsActiveControllers.delete(streamSid);
return;
}
const entry = queue.shift()!;
this.ttsActiveControllers.set(streamSid, entry.controller);
try {
await entry.playFn(entry.controller.signal);
entry.resolve();
} catch (error) {
if (entry.controller.signal.aborted) {
entry.resolve();
} else {
console.error("[MediaStream] TTS playback error:", error);
entry.reject(error);
}
} finally {
if (this.ttsActiveControllers.get(streamSid) === entry.controller) {
this.ttsActiveControllers.delete(streamSid);
}
}
}
}
private clearTtsState(streamSid: string): void {
const queue = this.ttsQueues.get(streamSid);
if (queue) {
queue.length = 0;
}
this.ttsActiveControllers.get(streamSid)?.abort();
this.ttsActiveControllers.delete(streamSid);
this.ttsPlaying.delete(streamSid);
this.ttsQueues.delete(streamSid);
}
}
/**
* Twilio Media Stream message format.
*/
interface TwilioMediaMessage {
event: "connected" | "start" | "media" | "stop" | "mark" | "clear";
sequenceNumber?: string;
streamSid?: string;
start?: {
streamSid: string;
accountSid: string;
callSid: string;
tracks: string[];
customParameters?: Record<string, string>;
mediaFormat: {
encoding: string;
sampleRate: number;
channels: number;
};
};
media?: {
track?: string;
chunk?: string;
timestamp?: string;
payload?: string;
};
mark?: {
name: string;
};
}

View file

@ -0,0 +1,92 @@
import { resolveProviderRawConfig, selectConfiguredOrAutoProvider } from "./provider-selection.js";
type AutoSelectableProvider = {
id: string;
autoSelectOrder?: number;
};
export type ResolvedConfiguredProvider<TProvider, TConfig> =
| {
ok: true;
configuredProviderId?: string;
provider: TProvider;
providerConfig: TConfig;
}
| {
ok: false;
code: "missing-configured-provider" | "no-registered-provider" | "provider-not-configured";
configuredProviderId?: string;
provider?: TProvider;
};
export function resolveConfiguredCapabilityProvider<
TConfig,
TFullConfig,
TProvider extends AutoSelectableProvider,
>(params: {
configuredProviderId?: string;
providerConfigs?: Record<string, Record<string, unknown> | undefined>;
cfg: TFullConfig | undefined;
cfgForResolve: TFullConfig;
getConfiguredProvider: (providerId: string | undefined) => TProvider | undefined;
listProviders: () => Iterable<TProvider>;
resolveProviderConfig: (params: {
provider: TProvider;
cfg: TFullConfig;
rawConfig: Record<string, unknown>;
}) => TConfig;
isProviderConfigured: (params: {
provider: TProvider;
cfg: TFullConfig | undefined;
providerConfig: TConfig;
}) => boolean;
}): ResolvedConfiguredProvider<TProvider, TConfig> {
const selection = selectConfiguredOrAutoProvider({
configuredProviderId: params.configuredProviderId,
getConfiguredProvider: params.getConfiguredProvider,
listProviders: params.listProviders,
});
if (selection.missingConfiguredProvider) {
return {
ok: false,
code: "missing-configured-provider",
configuredProviderId: selection.configuredProviderId,
};
}
const provider = selection.provider;
if (!provider) {
return {
ok: false,
code: "no-registered-provider",
configuredProviderId: selection.configuredProviderId,
};
}
const rawProviderConfig = resolveProviderRawConfig({
providerId: provider.id,
configuredProviderId: selection.configuredProviderId,
providerConfigs: params.providerConfigs,
});
const providerConfig = params.resolveProviderConfig({
provider,
cfg: params.cfgForResolve,
rawConfig: rawProviderConfig,
});
if (!params.isProviderConfigured({ provider, cfg: params.cfg, providerConfig })) {
return {
ok: false,
code: "provider-not-configured",
configuredProviderId: selection.configuredProviderId,
provider,
};
}
return {
ok: true,
configuredProviderId: selection.configuredProviderId,
provider,
providerConfig,
};
}

View file

@ -0,0 +1,61 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
type AutoSelectableProvider = {
autoSelectOrder?: number;
};
export function selectConfiguredOrAutoProvider<TProvider extends AutoSelectableProvider>(params: {
configuredProviderId?: string;
getConfiguredProvider: (providerId: string | undefined) => TProvider | undefined;
listProviders: () => Iterable<TProvider>;
}): {
configuredProviderId?: string;
missingConfiguredProvider: boolean;
provider: TProvider | undefined;
} {
const configuredProviderId = normalizeOptionalString(params.configuredProviderId);
const configuredProvider = params.getConfiguredProvider(configuredProviderId);
if (configuredProviderId && !configuredProvider) {
return {
configuredProviderId,
missingConfiguredProvider: true,
provider: undefined,
};
}
return {
configuredProviderId,
missingConfiguredProvider: false,
provider:
configuredProvider ??
[...params.listProviders()].toSorted(
(left, right) =>
(left.autoSelectOrder ?? Number.MAX_SAFE_INTEGER) -
(right.autoSelectOrder ?? Number.MAX_SAFE_INTEGER),
)[0],
};
}
export function resolveProviderRawConfig(params: {
providerId: string;
configuredProviderId?: string;
providerConfigs?: Record<string, Record<string, unknown> | undefined>;
}): Record<string, unknown> {
const canonicalProviderConfig =
params.providerConfigs?.[params.providerId] &&
typeof params.providerConfigs[params.providerId] === "object"
? (params.providerConfigs[params.providerId] as Record<string, unknown>)
: undefined;
const selectedProviderConfig =
params.configuredProviderId &&
params.providerConfigs?.[params.configuredProviderId] &&
typeof params.providerConfigs[params.configuredProviderId] === "object"
? (params.providerConfigs[params.configuredProviderId] as Record<string, unknown>)
: undefined;
return {
...canonicalProviderConfig,
...selectedProviderConfig,
};
}

View file

@ -0,0 +1,78 @@
import type {
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
PlayTtsInput,
ProviderName,
WebhookParseOptions,
ProviderWebhookParseResult,
StartListeningInput,
StopListeningInput,
WebhookContext,
WebhookVerificationResult,
} from "../types.js";
/**
* Abstract base interface for voice call providers.
*
* Each provider (Telnyx, Twilio, etc.) implements this interface to provide
* a consistent API for the call manager.
*
* Responsibilities:
* - Webhook verification and event parsing
* - Outbound call initiation and hangup
* - Media control (TTS playback, STT listening)
*/
export interface VoiceCallProvider {
/** Provider identifier */
readonly name: ProviderName;
/**
* Verify webhook signature/HMAC before processing.
* Must be called before parseWebhookEvent.
*/
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult;
/**
* Parse provider-specific webhook payload into normalized events.
* Returns events and optional response to send back to provider.
*/
parseWebhookEvent(ctx: WebhookContext, options?: WebhookParseOptions): ProviderWebhookParseResult;
/**
* Initiate an outbound call.
* @returns Provider call ID and status
*/
initiateCall(input: InitiateCallInput): Promise<InitiateCallResult>;
/**
* Hang up an active call.
*/
hangupCall(input: HangupCallInput): Promise<void>;
/**
* Play TTS audio to the caller.
* The provider should handle streaming if supported.
*/
playTts(input: PlayTtsInput): Promise<void>;
/**
* Start listening for user speech (activate STT).
*/
startListening(input: StartListeningInput): Promise<void>;
/**
* Stop listening for user speech (deactivate STT).
*/
stopListening(input: StopListeningInput): Promise<void>;
/**
* Query provider for current call status.
* Used to verify persisted calls are still active on restart.
* Must return `isUnknown: true` for transient errors (network, 5xx)
* so the caller can keep the call and rely on timer-based fallback.
*/
getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult>;
}

View file

@ -0,0 +1,5 @@
export type { VoiceCallProvider } from "./base.js";
export { MockProvider } from "./mock.js";
export { TelnyxProvider } from "./telnyx.js";
export { TwilioProvider } from "./twilio.js";
export { PlivoProvider } from "./plivo.js";

View file

@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import type { WebhookContext } from "../types.js";
import { MockProvider } from "./mock.js";
function createWebhookContext(rawBody: string): WebhookContext {
return {
headers: {},
rawBody,
url: "http://localhost/voice/webhook",
method: "POST",
query: {},
};
}
describe("MockProvider", () => {
it("preserves explicit falsy event values", () => {
const provider = new MockProvider();
const result = provider.parseWebhookEvent(
createWebhookContext(
JSON.stringify({
events: [
{
id: "evt-error",
type: "call.error",
callId: "call-1",
timestamp: 0,
error: "",
retryable: false,
},
{
id: "evt-ended",
type: "call.ended",
callId: "call-2",
reason: "",
},
{
id: "evt-speech",
type: "call.speech",
callId: "call-3",
transcript: "",
isFinal: false,
},
],
}),
),
);
expect(result.events).toEqual([
{
id: "evt-error",
type: "call.error",
callId: "call-1",
providerCallId: undefined,
timestamp: 0,
error: "",
retryable: false,
},
{
id: "evt-ended",
type: "call.ended",
callId: "call-2",
providerCallId: undefined,
timestamp: expect.any(Number),
reason: "",
},
{
id: "evt-speech",
type: "call.speech",
callId: "call-3",
providerCallId: undefined,
timestamp: expect.any(Number),
transcript: "",
isFinal: false,
confidence: undefined,
},
]);
});
});

View file

@ -0,0 +1,180 @@
import crypto from "node:crypto";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type {
EndReason,
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
NormalizedEvent,
PlayTtsInput,
WebhookParseOptions,
ProviderWebhookParseResult,
StartListeningInput,
StopListeningInput,
WebhookContext,
WebhookVerificationResult,
} from "../types.js";
import type { VoiceCallProvider } from "./base.js";
/**
* Mock voice call provider for local testing.
*
* Events are driven via webhook POST with JSON body:
* - { events: NormalizedEvent[] } for bulk events
* - { event: NormalizedEvent } for single event
*/
export class MockProvider implements VoiceCallProvider {
readonly name = "mock" as const;
verifyWebhook(_ctx: WebhookContext): WebhookVerificationResult {
return { ok: true };
}
parseWebhookEvent(
ctx: WebhookContext,
_options?: WebhookParseOptions,
): ProviderWebhookParseResult {
try {
const payload = JSON.parse(ctx.rawBody);
const events: NormalizedEvent[] = [];
if (Array.isArray(payload.events)) {
for (const evt of payload.events) {
const normalized = this.normalizeEvent(evt);
if (normalized) {
events.push(normalized);
}
}
} else if (payload.event) {
const normalized = this.normalizeEvent(payload.event);
if (normalized) {
events.push(normalized);
}
}
return { events, statusCode: 200 };
} catch {
return { events: [], statusCode: 400 };
}
}
private normalizeEvent(evt: Partial<NormalizedEvent>): NormalizedEvent | null {
if (!evt.type || !evt.callId) {
return null;
}
const base = {
id: evt.id ?? crypto.randomUUID(),
callId: evt.callId,
providerCallId: evt.providerCallId,
timestamp: evt.timestamp ?? Date.now(),
};
switch (evt.type) {
case "call.initiated":
case "call.ringing":
case "call.answered":
case "call.active":
return { ...base, type: evt.type };
case "call.speaking": {
const payload = evt as Partial<NormalizedEvent & { text?: string }>;
return {
...base,
type: evt.type,
text: payload.text ?? "",
};
}
case "call.speech": {
const payload = evt as Partial<
NormalizedEvent & {
transcript?: string;
isFinal?: boolean;
confidence?: number;
}
>;
return {
...base,
type: evt.type,
transcript: payload.transcript ?? "",
isFinal: payload.isFinal ?? true,
confidence: payload.confidence,
};
}
case "call.silence": {
const payload = evt as Partial<NormalizedEvent & { durationMs?: number }>;
return {
...base,
type: evt.type,
durationMs: payload.durationMs ?? 0,
};
}
case "call.dtmf": {
const payload = evt as Partial<NormalizedEvent & { digits?: string }>;
return {
...base,
type: evt.type,
digits: payload.digits ?? "",
};
}
case "call.ended": {
const payload = evt as Partial<NormalizedEvent & { reason?: EndReason }>;
return {
...base,
type: evt.type,
reason: payload.reason ?? "completed",
};
}
case "call.error": {
const payload = evt as Partial<NormalizedEvent & { error?: string; retryable?: boolean }>;
return {
...base,
type: evt.type,
error: payload.error ?? "unknown error",
retryable: payload.retryable,
};
}
default:
return null;
}
}
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
return {
providerCallId: `mock-${input.callId}`,
status: "initiated",
};
}
async hangupCall(_input: HangupCallInput): Promise<void> {
// No-op for mock
}
async playTts(_input: PlayTtsInput): Promise<void> {
// No-op for mock
}
async startListening(_input: StartListeningInput): Promise<void> {
// No-op for mock
}
async stopListening(_input: StopListeningInput): Promise<void> {
// No-op for mock
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
const id = normalizeLowercaseStringOrEmpty(input.providerCallId);
if (id.includes("stale") || id.includes("ended") || id.includes("completed")) {
return { status: "completed", isTerminal: true };
}
return { status: "in-progress", isTerminal: false };
}
}

View file

@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { PlivoProvider } from "./plivo.js";
function requireEvent<T>(event: T | undefined, message: string): T {
if (!event) {
throw new Error(message);
}
return event;
}
function requireResponseBody(body: string | undefined): string {
if (!body) {
throw new Error("Plivo provider did not return a response body");
}
return body;
}
describe("PlivoProvider", () => {
it("parses answer callback into call.answered and returns keep-alive XML", () => {
const provider = new PlivoProvider({
authId: "MA000000000000000000",
authToken: "test-token",
});
const result = provider.parseWebhookEvent({
headers: { host: "example.com" },
rawBody:
"CallUUID=call-uuid&CallStatus=in-progress&Direction=outbound&From=%2B15550000000&To=%2B15550000001&Event=StartApp",
url: "https://example.com/voice/webhook?provider=plivo&flow=answer&callId=internal-call-id",
method: "POST",
query: { provider: "plivo", flow: "answer", callId: "internal-call-id" },
});
expect(result.events).toHaveLength(1);
const event = requireEvent(result.events[0], "expected Plivo answer event");
expect(event.type).toBe("call.answered");
expect(event.callId).toBe("internal-call-id");
expect(event.providerCallId).toBe("call-uuid");
const responseBody = requireResponseBody(result.providerResponseBody);
expect(responseBody).toContain("<Wait");
expect(responseBody).toContain('length="300"');
});
it("uses verified request key when provided", () => {
const provider = new PlivoProvider({
authId: "MA000000000000000000",
authToken: "test-token",
});
const result = provider.parseWebhookEvent(
{
headers: { host: "example.com", "x-plivo-signature-v3-nonce": "nonce-1" },
rawBody:
"CallUUID=call-uuid&CallStatus=in-progress&Direction=outbound&From=%2B15550000000&To=%2B15550000001&Event=StartApp",
url: "https://example.com/voice/webhook?provider=plivo&flow=answer&callId=internal-call-id",
method: "POST",
query: { provider: "plivo", flow: "answer", callId: "internal-call-id" },
},
{ verifiedRequestKey: "plivo:v3:verified" },
);
expect(result.events).toHaveLength(1);
expect(requireEvent(result.events[0], "expected verified Plivo event").dedupeKey).toBe(
"plivo:v3:verified",
);
});
it("pins stored callback bases to publicUrl instead of request Host", () => {
const provider = new PlivoProvider(
{
authId: "MA000000000000000000",
authToken: "test-token",
},
{
publicUrl: "https://voice.openclaw.ai/voice/webhook?provider=plivo",
},
);
provider.parseWebhookEvent({
headers: { host: "attacker.example" },
rawBody:
"CallUUID=call-uuid&CallStatus=in-progress&Direction=outbound&From=%2B15550000000&To=%2B15550000001&Event=StartApp",
url: "https://attacker.example/voice/webhook?provider=plivo&flow=answer&callId=internal-call-id",
method: "POST",
query: { provider: "plivo", flow: "answer", callId: "internal-call-id" },
});
const callbackMap = (provider as unknown as { callUuidToWebhookUrl: Map<string, string> })
.callUuidToWebhookUrl;
expect(callbackMap.get("call-uuid")).toBe("https://voice.openclaw.ai/voice/webhook");
});
});

View file

@ -0,0 +1,601 @@
import crypto from "node:crypto";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import type { PlivoConfig, WebhookSecurityConfig } from "../config.js";
import { getHeader } from "../http-headers.js";
import type {
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
NormalizedEvent,
PlayTtsInput,
ProviderWebhookParseResult,
StartListeningInput,
StopListeningInput,
WebhookContext,
WebhookParseOptions,
WebhookVerificationResult,
} from "../types.js";
import { escapeXml } from "../voice-mapping.js";
import { reconstructWebhookUrl, verifyPlivoWebhook } from "../webhook-security.js";
import type { VoiceCallProvider } from "./base.js";
import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
export interface PlivoProviderOptions {
/** Override public URL origin for signature verification */
publicUrl?: string;
/** Skip webhook signature verification (development only) */
skipVerification?: boolean;
/** Outbound ring timeout in seconds */
ringTimeoutSec?: number;
/** Webhook security options (forwarded headers/allowlist) */
webhookSecurity?: WebhookSecurityConfig;
}
type PendingSpeak = { text: string; locale?: string };
type PendingListen = { language?: string };
function createPlivoRequestDedupeKey(ctx: WebhookContext): string {
const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
if (nonceV3) {
return `plivo:v3:${nonceV3}`;
}
const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
if (nonceV2) {
return `plivo:v2:${nonceV2}`;
}
return `plivo:fallback:${crypto.createHash("sha256").update(ctx.rawBody).digest("hex")}`;
}
export class PlivoProvider implements VoiceCallProvider {
readonly name = "plivo" as const;
private readonly authId: string;
private readonly authToken: string;
private readonly baseUrl: string;
private readonly options: PlivoProviderOptions;
private readonly apiHost: string;
// Best-effort mapping between create-call request UUID and call UUID.
private requestUuidToCallUuid = new Map<string, string>();
// Used for transfer URLs and GetInput action URLs.
private callIdToWebhookUrl = new Map<string, string>();
private callUuidToWebhookUrl = new Map<string, string>();
private pendingSpeakByCallId = new Map<string, PendingSpeak>();
private pendingListenByCallId = new Map<string, PendingListen>();
constructor(config: PlivoConfig, options: PlivoProviderOptions = {}) {
if (!config.authId) {
throw new Error("Plivo Auth ID is required");
}
if (!config.authToken) {
throw new Error("Plivo Auth Token is required");
}
this.authId = config.authId;
this.authToken = config.authToken;
this.baseUrl = `https://api.plivo.com/v1/Account/${this.authId}`;
this.apiHost = new URL(this.baseUrl).hostname;
this.options = options;
}
private async apiRequest<T = unknown>(params: {
method: "GET" | "POST" | "DELETE";
endpoint: string;
body?: Record<string, unknown>;
allowNotFound?: boolean;
}): Promise<T> {
const { method, endpoint, body, allowNotFound } = params;
return await guardedJsonApiRequest<T>({
url: `${this.baseUrl}${endpoint}`,
method,
headers: {
Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`,
"Content-Type": "application/json",
},
body,
allowNotFound,
allowedHostnames: [this.apiHost],
auditContext: "voice-call.plivo.api",
errorPrefix: "Plivo API error",
});
}
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
const result = verifyPlivoWebhook(ctx, this.authToken, {
publicUrl: this.options.publicUrl,
skipVerification: this.options.skipVerification,
allowedHosts: this.options.webhookSecurity?.allowedHosts,
trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
remoteIP: ctx.remoteAddress,
});
if (!result.ok) {
console.warn(`[plivo] Webhook verification failed: ${result.reason}`);
}
return {
ok: result.ok,
reason: result.reason,
isReplay: result.isReplay,
verifiedRequestKey: result.verifiedRequestKey,
};
}
parseWebhookEvent(
ctx: WebhookContext,
options?: WebhookParseOptions,
): ProviderWebhookParseResult {
const flow = normalizeOptionalString(ctx.query?.flow) ?? "";
const parsed = this.parseBody(ctx.rawBody);
if (!parsed) {
return { events: [], statusCode: 400 };
}
// Keep providerCallId mapping for later call control.
const callUuid = parsed.get("CallUUID") || undefined;
if (callUuid) {
const webhookBase = this.baseWebhookUrlFromCtx(ctx);
if (webhookBase) {
this.callUuidToWebhookUrl.set(callUuid, webhookBase);
}
}
// Special flows that exist only to return Plivo XML (no events).
if (flow === "xml-speak") {
const callId = this.getCallIdFromQuery(ctx);
const pending = callId ? this.pendingSpeakByCallId.get(callId) : undefined;
if (callId) {
this.pendingSpeakByCallId.delete(callId);
}
const xml = pending
? PlivoProvider.xmlSpeak(pending.text, pending.locale)
: PlivoProvider.xmlKeepAlive();
return {
events: [],
providerResponseBody: xml,
providerResponseHeaders: { "Content-Type": "text/xml" },
statusCode: 200,
};
}
if (flow === "xml-listen") {
const callId = this.getCallIdFromQuery(ctx);
const pending = callId ? this.pendingListenByCallId.get(callId) : undefined;
if (callId) {
this.pendingListenByCallId.delete(callId);
}
const actionUrl = this.buildActionUrl(ctx, {
flow: "getinput",
callId,
});
const xml =
actionUrl && callId
? PlivoProvider.xmlGetInputSpeech({
actionUrl,
language: pending?.language,
})
: PlivoProvider.xmlKeepAlive();
return {
events: [],
providerResponseBody: xml,
providerResponseHeaders: { "Content-Type": "text/xml" },
statusCode: 200,
};
}
// Normal events.
const callIdFromQuery = this.getCallIdFromQuery(ctx);
const dedupeKey = options?.verifiedRequestKey ?? createPlivoRequestDedupeKey(ctx);
const event = this.normalizeEvent(parsed, callIdFromQuery, dedupeKey);
return {
events: event ? [event] : [],
providerResponseBody:
flow === "answer" || flow === "getinput"
? PlivoProvider.xmlKeepAlive()
: PlivoProvider.xmlEmpty(),
providerResponseHeaders: { "Content-Type": "text/xml" },
statusCode: 200,
};
}
private normalizeEvent(
params: URLSearchParams,
callIdOverride?: string,
dedupeKey?: string,
): NormalizedEvent | null {
const callUuid = params.get("CallUUID") || "";
const requestUuid = params.get("RequestUUID") || "";
if (requestUuid && callUuid) {
this.requestUuidToCallUuid.set(requestUuid, callUuid);
}
const direction = params.get("Direction");
const from = params.get("From") || undefined;
const to = params.get("To") || undefined;
const callStatus = params.get("CallStatus");
const baseEvent = {
id: crypto.randomUUID(),
dedupeKey,
callId: callIdOverride || callUuid || requestUuid,
providerCallId: callUuid || requestUuid || undefined,
timestamp: Date.now(),
direction:
direction === "inbound"
? ("inbound" as const)
: direction === "outbound"
? ("outbound" as const)
: undefined,
from,
to,
};
const digits = params.get("Digits");
if (digits) {
return { ...baseEvent, type: "call.dtmf", digits };
}
const transcript = PlivoProvider.extractTranscript(params);
if (transcript) {
return {
...baseEvent,
type: "call.speech",
transcript,
isFinal: true,
};
}
// Call lifecycle.
if (callStatus === "ringing") {
return { ...baseEvent, type: "call.ringing" };
}
if (callStatus === "in-progress") {
return { ...baseEvent, type: "call.answered" };
}
if (
callStatus === "completed" ||
callStatus === "busy" ||
callStatus === "no-answer" ||
callStatus === "failed"
) {
return {
...baseEvent,
type: "call.ended",
reason:
callStatus === "completed"
? "completed"
: callStatus === "busy"
? "busy"
: callStatus === "no-answer"
? "no-answer"
: "failed",
};
}
// Plivo will call our answer_url when the call is answered; if we don't have
// a CallStatus for some reason, treat it as answered so the call can proceed.
if (params.get("Event") === "StartApp" && callUuid) {
return { ...baseEvent, type: "call.answered" };
}
return null;
}
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
const webhookUrl = new URL(input.webhookUrl);
webhookUrl.searchParams.set("provider", "plivo");
webhookUrl.searchParams.set("callId", input.callId);
const answerUrl = new URL(webhookUrl);
answerUrl.searchParams.set("flow", "answer");
const hangupUrl = new URL(webhookUrl);
hangupUrl.searchParams.set("flow", "hangup");
this.callIdToWebhookUrl.set(input.callId, input.webhookUrl);
const ringTimeoutSec = this.options.ringTimeoutSec ?? 30;
const result = await this.apiRequest<PlivoCreateCallResponse>({
method: "POST",
endpoint: "/Call/",
body: {
from: PlivoProvider.normalizeNumber(input.from),
to: PlivoProvider.normalizeNumber(input.to),
answer_url: answerUrl.toString(),
answer_method: "POST",
hangup_url: hangupUrl.toString(),
hangup_method: "POST",
// Plivo's API uses `hangup_on_ring` for outbound ring timeout.
hangup_on_ring: ringTimeoutSec,
},
});
const requestUuid = Array.isArray(result.request_uuid)
? result.request_uuid[0]
: result.request_uuid;
if (!requestUuid) {
throw new Error("Plivo call create returned no request_uuid");
}
return { providerCallId: requestUuid, status: "initiated" };
}
async hangupCall(input: HangupCallInput): Promise<void> {
const callUuid = this.requestUuidToCallUuid.get(input.providerCallId);
if (callUuid) {
await this.apiRequest({
method: "DELETE",
endpoint: `/Call/${callUuid}/`,
allowNotFound: true,
});
return;
}
// Best-effort: try hangup (call UUID), then cancel (request UUID).
await this.apiRequest({
method: "DELETE",
endpoint: `/Call/${input.providerCallId}/`,
allowNotFound: true,
});
await this.apiRequest({
method: "DELETE",
endpoint: `/Request/${input.providerCallId}/`,
allowNotFound: true,
});
}
private resolveCallContext(params: {
providerCallId: string;
callId: string;
operation: string;
}): {
callUuid: string;
webhookBase: string;
} {
const callUuid = this.requestUuidToCallUuid.get(params.providerCallId) ?? params.providerCallId;
const webhookBase =
this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(params.callId);
if (!webhookBase) {
throw new Error("Missing webhook URL for this call (provider state missing)");
}
if (!callUuid) {
throw new Error(`Missing Plivo CallUUID for ${params.operation}`);
}
return { callUuid, webhookBase };
}
private async transferCallLeg(params: {
callUuid: string;
webhookBase: string;
callId: string;
flow: "xml-speak" | "xml-listen";
}): Promise<void> {
const transferUrl = new URL(params.webhookBase);
transferUrl.searchParams.set("provider", "plivo");
transferUrl.searchParams.set("flow", params.flow);
transferUrl.searchParams.set("callId", params.callId);
await this.apiRequest({
method: "POST",
endpoint: `/Call/${params.callUuid}/`,
body: {
legs: "aleg",
aleg_url: transferUrl.toString(),
aleg_method: "POST",
},
});
}
async playTts(input: PlayTtsInput): Promise<void> {
const { callUuid, webhookBase } = this.resolveCallContext({
providerCallId: input.providerCallId,
callId: input.callId,
operation: "playTts",
});
this.pendingSpeakByCallId.set(input.callId, {
text: input.text,
locale: input.locale,
});
await this.transferCallLeg({
callUuid,
webhookBase,
callId: input.callId,
flow: "xml-speak",
});
}
async startListening(input: StartListeningInput): Promise<void> {
const { callUuid, webhookBase } = this.resolveCallContext({
providerCallId: input.providerCallId,
callId: input.callId,
operation: "startListening",
});
this.pendingListenByCallId.set(input.callId, {
language: input.language,
});
await this.transferCallLeg({
callUuid,
webhookBase,
callId: input.callId,
flow: "xml-listen",
});
}
async stopListening(_input: StopListeningInput): Promise<void> {
// GetInput ends automatically when speech ends.
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
const terminalStatuses = new Set([
"completed",
"busy",
"failed",
"timeout",
"no-answer",
"cancel",
"machine",
"hangup",
]);
try {
const data = await guardedJsonApiRequest<{ call_status?: string }>({
url: `${this.baseUrl}/Call/${input.providerCallId}/`,
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`,
},
allowNotFound: true,
allowedHostnames: [this.apiHost],
auditContext: "plivo-get-call-status",
errorPrefix: "Plivo get call status error",
});
if (!data) {
return { status: "not-found", isTerminal: true };
}
const status = data.call_status ?? "unknown";
return { status, isTerminal: terminalStatuses.has(status) };
} catch {
return { status: "error", isTerminal: false, isUnknown: true };
}
}
private static normalizeNumber(numberOrSip: string): string {
const trimmed = numberOrSip.trim();
if (normalizeLowercaseStringOrEmpty(trimmed).startsWith("sip:")) {
return trimmed;
}
return trimmed.replace(/[^\d+]/g, "");
}
private static xmlEmpty(): string {
return `<?xml version="1.0" encoding="UTF-8"?><Response></Response>`;
}
private static xmlKeepAlive(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Wait length="300" />
</Response>`;
}
private static xmlSpeak(text: string, locale?: string): string {
const language = locale || "en-US";
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Speak language="${escapeXml(language)}">${escapeXml(text)}</Speak>
<Wait length="300" />
</Response>`;
}
private static xmlGetInputSpeech(params: { actionUrl: string; language?: string }): string {
const language = params.language || "en-US";
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<GetInput inputType="speech" method="POST" action="${escapeXml(params.actionUrl)}" language="${escapeXml(language)}" executionTimeout="30" speechEndTimeout="1" redirect="false">
</GetInput>
<Wait length="300" />
</Response>`;
}
private getCallIdFromQuery(ctx: WebhookContext): string | undefined {
const callId = normalizeOptionalString(ctx.query?.callId);
return callId || undefined;
}
private buildActionUrl(
ctx: WebhookContext,
opts: { flow: string; callId?: string },
): string | null {
const base = this.baseWebhookUrlFromCtx(ctx);
if (!base) {
return null;
}
const u = new URL(base);
u.searchParams.set("provider", "plivo");
u.searchParams.set("flow", opts.flow);
if (opts.callId) {
u.searchParams.set("callId", opts.callId);
}
return u.toString();
}
private baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
try {
if (this.options.publicUrl) {
const base = new URL(this.options.publicUrl);
const requestUrl = new URL(ctx.url);
base.pathname = requestUrl.pathname;
return `${base.origin}${base.pathname}`;
}
const u = new URL(
reconstructWebhookUrl(ctx, {
allowedHosts: this.options.webhookSecurity?.allowedHosts,
trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
remoteIP: ctx.remoteAddress,
}),
);
return `${u.origin}${u.pathname}`;
} catch {
return null;
}
}
private parseBody(rawBody: string): URLSearchParams | null {
try {
return new URLSearchParams(rawBody);
} catch {
return null;
}
}
private static extractTranscript(params: URLSearchParams): string | null {
const candidates = [
"Speech",
"Transcription",
"TranscriptionText",
"SpeechResult",
"RecognizedSpeech",
"Text",
] as const;
for (const key of candidates) {
const value = params.get(key);
if (value && value.trim()) {
return value.trim();
}
}
return null;
}
}
type PlivoCreateCallResponse = {
api_id?: string;
message?: string;
request_uuid?: string | string[];
};

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
isProviderStatusTerminal,
mapProviderStatusToEndReason,
normalizeProviderStatus,
} from "./call-status.js";
describe("provider call status mapping", () => {
it("normalizes missing statuses to unknown", () => {
expect(normalizeProviderStatus(undefined)).toBe("unknown");
expect(normalizeProviderStatus(" ")).toBe("unknown");
});
it("maps terminal provider statuses to end reasons", () => {
expect(mapProviderStatusToEndReason("completed")).toBe("completed");
expect(mapProviderStatusToEndReason("CANCELED")).toBe("hangup-bot");
expect(mapProviderStatusToEndReason("no-answer")).toBe("no-answer");
});
it("flags terminal provider statuses", () => {
expect(isProviderStatusTerminal("busy")).toBe(true);
expect(isProviderStatusTerminal("in-progress")).toBe(false);
});
});

View file

@ -0,0 +1,24 @@
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import type { EndReason } from "../../types.js";
const TERMINAL_PROVIDER_STATUS_TO_END_REASON: Record<string, EndReason> = {
completed: "completed",
failed: "failed",
busy: "busy",
"no-answer": "no-answer",
canceled: "hangup-bot",
};
export function normalizeProviderStatus(status: string | null | undefined): string {
const normalized = normalizeOptionalLowercaseString(status);
return normalized && normalized.length > 0 ? normalized : "unknown";
}
export function mapProviderStatusToEndReason(status: string | null | undefined): EndReason | null {
const normalized = normalizeProviderStatus(status);
return TERMINAL_PROVIDER_STATUS_TO_END_REASON[normalized] ?? null;
}
export function isProviderStatusTerminal(status: string | null | undefined): boolean {
return mapProviderStatusToEndReason(status) !== null;
}

View file

@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
}));
vi.mock("../../../api.js", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
import { guardedJsonApiRequest } from "./guarded-json-api.js";
describe("guardedJsonApiRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses the SSRF-guarded fetch and parses json responses", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(JSON.stringify({ ok: true }), { status: 200 }),
release,
});
await expect(
guardedJsonApiRequest({
url: "https://api.example.com/v1/calls",
method: "POST",
headers: { Authorization: "Bearer token" },
body: { hello: "world" },
allowedHostnames: ["api.example.com"],
auditContext: "voice-call:test",
errorPrefix: "request failed",
}),
).resolves.toEqual({ ok: true });
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({
url: "https://api.example.com/v1/calls",
init: {
method: "POST",
headers: { Authorization: "Bearer token" },
body: JSON.stringify({ hello: "world" }),
},
policy: { allowedHostnames: ["api.example.com"] },
auditContext: "voice-call:test",
});
expect(release).toHaveBeenCalledTimes(1);
});
it("returns undefined for empty bodies and allowed 404s", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(null, { status: 204 }),
release,
});
await expect(
guardedJsonApiRequest({
url: "https://api.example.com/v1/calls/1",
method: "GET",
headers: {},
allowedHostnames: ["api.example.com"],
auditContext: "voice-call:test",
errorPrefix: "request failed",
}),
).resolves.toBeUndefined();
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response("missing", { status: 404 }),
release,
});
await expect(
guardedJsonApiRequest({
url: "https://api.example.com/v1/calls/2",
method: "GET",
headers: {},
allowNotFound: true,
allowedHostnames: ["api.example.com"],
auditContext: "voice-call:test",
errorPrefix: "request failed",
}),
).resolves.toBeUndefined();
});
it("throws prefixed errors and still releases the response handle", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("boom", { status: 500 }),
release,
});
await expect(
guardedJsonApiRequest({
url: "https://api.example.com/v1/calls/3",
method: "DELETE",
headers: {},
allowedHostnames: ["api.example.com"],
auditContext: "voice-call:test",
errorPrefix: "provider error",
}),
).rejects.toThrow("provider error: 500 boom");
expect(release).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,42 @@
import { fetchWithSsrFGuard } from "../../../api.js";
type GuardedJsonApiRequestParams = {
url: string;
method: "GET" | "POST" | "DELETE" | "PUT" | "PATCH";
headers: Record<string, string>;
body?: Record<string, unknown>;
allowNotFound?: boolean;
allowedHostnames: string[];
auditContext: string;
errorPrefix: string;
};
export async function guardedJsonApiRequest<T = unknown>(
params: GuardedJsonApiRequestParams,
): Promise<T> {
const { response, release } = await fetchWithSsrFGuard({
url: params.url,
init: {
method: params.method,
headers: params.headers,
body: params.body ? JSON.stringify(params.body) : undefined,
},
policy: { allowedHostnames: params.allowedHostnames },
auditContext: params.auditContext,
});
try {
if (!response.ok) {
if (params.allowNotFound && response.status === 404) {
return undefined as T;
}
const errorText = await response.text();
throw new Error(`${params.errorPrefix}: ${response.status} ${errorText}`);
}
const text = await response.text();
return text ? (JSON.parse(text) as T) : (undefined as T);
} finally {
await release();
}
}

View file

@ -0,0 +1,188 @@
import crypto from "node:crypto";
import { describe, expect, it } from "vitest";
import type { WebhookContext } from "../types.js";
import { TelnyxProvider } from "./telnyx.js";
function createCtx(params?: Partial<WebhookContext>): WebhookContext {
return {
headers: {},
rawBody: "{}",
url: "http://localhost/voice/webhook",
method: "POST",
query: {},
remoteAddress: "127.0.0.1",
...params,
};
}
function decodeBase64Url(input: string): Buffer {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padLen = (4 - (normalized.length % 4)) % 4;
const padded = normalized + "=".repeat(padLen);
return Buffer.from(padded, "base64");
}
function createSignedTelnyxCtx(params: {
privateKey: crypto.KeyObject;
rawBody: string;
}): WebhookContext {
const timestamp = String(Math.floor(Date.now() / 1000));
const signedPayload = `${timestamp}|${params.rawBody}`;
const signature = crypto
.sign(null, Buffer.from(signedPayload), params.privateKey)
.toString("base64");
return createCtx({
rawBody: params.rawBody,
headers: {
"telnyx-signature-ed25519": signature,
"telnyx-timestamp": timestamp,
},
});
}
function expectReplayVerification(
results: Array<{ ok: boolean; isReplay?: boolean; verifiedRequestKey?: string }>,
) {
expect(results.map((result) => result.ok)).toEqual([true, true]);
expect(results.map((result) => Boolean(result.isReplay))).toEqual([false, true]);
const firstResult = results[0];
if (!firstResult?.verifiedRequestKey) {
throw new Error("expected Telnyx verification to produce a request key");
}
const secondResult = results[1];
if (!secondResult?.verifiedRequestKey) {
throw new Error("expected replayed Telnyx verification to preserve the request key");
}
const firstKey = firstResult.verifiedRequestKey;
const secondKey = secondResult.verifiedRequestKey;
expect(firstKey.length).toBeGreaterThan(0);
expect(secondKey).toBe(firstKey);
}
function requireJwkX(jwk: JsonWebKey) {
if (typeof jwk.x !== "string" || jwk.x.length === 0) {
throw new Error("expected Ed25519 JWK export to expose x");
}
return jwk.x;
}
function expectWebhookVerificationSucceeds(params: {
publicKey: string;
privateKey: crypto.KeyObject;
}) {
const provider = new TelnyxProvider(
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: params.publicKey },
{ skipVerification: false },
);
const rawBody = JSON.stringify({
event_type: "call.initiated",
payload: { call_control_id: "x" },
});
const result = provider.verifyWebhook(
createSignedTelnyxCtx({ privateKey: params.privateKey, rawBody }),
);
expect(result.ok).toBe(true);
}
describe("TelnyxProvider.verifyWebhook", () => {
it("fails closed when public key is missing and skipVerification is false", () => {
const provider = new TelnyxProvider(
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: undefined },
{ skipVerification: false },
);
const result = provider.verifyWebhook(createCtx());
expect(result.ok).toBe(false);
});
it("allows requests when skipVerification is true (development only)", () => {
const provider = new TelnyxProvider(
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: undefined },
{ skipVerification: true },
);
const result = provider.verifyWebhook(createCtx());
expect(result.ok).toBe(true);
});
it("fails when signature headers are missing (with public key configured)", () => {
const provider = new TelnyxProvider(
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key" },
{ skipVerification: false },
);
const result = provider.verifyWebhook(createCtx({ headers: {} }));
expect(result.ok).toBe(false);
});
it("verifies a valid signature with a raw Ed25519 public key (Base64)", () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const jwk = publicKey.export({ format: "jwk" }) as JsonWebKey;
expect(jwk.kty).toBe("OKP");
expect(jwk.crv).toBe("Ed25519");
const rawPublicKey = decodeBase64Url(requireJwkX(jwk));
const rawPublicKeyBase64 = rawPublicKey.toString("base64");
expectWebhookVerificationSucceeds({ publicKey: rawPublicKeyBase64, privateKey });
});
it("verifies a valid signature with a DER SPKI public key (Base64)", () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer;
const spkiDerBase64 = spkiDer.toString("base64");
expectWebhookVerificationSucceeds({ publicKey: spkiDerBase64, privateKey });
});
it("returns replay status when the same signed request is seen twice", () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer;
const provider = new TelnyxProvider(
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDer.toString("base64") },
{ skipVerification: false },
);
const rawBody = JSON.stringify({
event_type: "call.initiated",
payload: { call_control_id: "call-replay-test" },
nonce: crypto.randomUUID(),
});
const ctx = createSignedTelnyxCtx({ privateKey, rawBody });
const first = provider.verifyWebhook(ctx);
const second = provider.verifyWebhook(ctx);
expectReplayVerification([first, second]);
});
});
describe("TelnyxProvider.parseWebhookEvent", () => {
it("uses verified request key for manager dedupe", () => {
const provider = new TelnyxProvider({
apiKey: "KEY123",
connectionId: "CONN456",
publicKey: undefined,
});
const result = provider.parseWebhookEvent(
createCtx({
rawBody: JSON.stringify({
data: {
id: "evt-123",
event_type: "call.initiated",
payload: { call_control_id: "call-1" },
},
}),
}),
{ verifiedRequestKey: "telnyx:req:abc" },
);
expect(result.events).toHaveLength(1);
const event = result.events[0];
if (!event) {
throw new Error("expected Telnyx parseWebhookEvent to produce one event");
}
expect(event.dedupeKey).toBe("telnyx:req:abc");
});
});

View file

@ -0,0 +1,357 @@
import crypto from "node:crypto";
import type { TelnyxConfig } from "../config.js";
import type {
EndReason,
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
NormalizedEvent,
PlayTtsInput,
ProviderWebhookParseResult,
StartListeningInput,
StopListeningInput,
WebhookContext,
WebhookParseOptions,
WebhookVerificationResult,
} from "../types.js";
import { verifyTelnyxWebhook } from "../webhook-security.js";
import type { VoiceCallProvider } from "./base.js";
import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
/**
* Telnyx Voice API provider implementation.
*
* Uses Telnyx Call Control API v2 for managing calls.
* @see https://developers.telnyx.com/docs/api/v2/call-control
*/
export interface TelnyxProviderOptions {
/** Skip webhook signature verification (development only, NOT for production) */
skipVerification?: boolean;
}
export class TelnyxProvider implements VoiceCallProvider {
readonly name = "telnyx" as const;
private readonly apiKey: string;
private readonly connectionId: string;
private readonly publicKey: string | undefined;
private readonly options: TelnyxProviderOptions;
private readonly baseUrl = "https://api.telnyx.com/v2";
private readonly apiHost = "api.telnyx.com";
constructor(config: TelnyxConfig, options: TelnyxProviderOptions = {}) {
if (!config.apiKey) {
throw new Error("Telnyx API key is required");
}
if (!config.connectionId) {
throw new Error("Telnyx connection ID is required");
}
this.apiKey = config.apiKey;
this.connectionId = config.connectionId;
this.publicKey = config.publicKey;
this.options = options;
}
/**
* Make an authenticated request to the Telnyx API.
*/
private async apiRequest<T = unknown>(
endpoint: string,
body: Record<string, unknown>,
options?: { allowNotFound?: boolean },
): Promise<T> {
return await guardedJsonApiRequest<T>({
url: `${this.baseUrl}${endpoint}`,
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body,
allowNotFound: options?.allowNotFound,
allowedHostnames: [this.apiHost],
auditContext: "voice-call.telnyx.api",
errorPrefix: "Telnyx API error",
});
}
/**
* Verify Telnyx webhook signature using Ed25519.
*/
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
const result = verifyTelnyxWebhook(ctx, this.publicKey, {
skipVerification: this.options.skipVerification,
});
return {
ok: result.ok,
reason: result.reason,
isReplay: result.isReplay,
verifiedRequestKey: result.verifiedRequestKey,
};
}
/**
* Parse Telnyx webhook event into normalized format.
*/
parseWebhookEvent(
ctx: WebhookContext,
options?: WebhookParseOptions,
): ProviderWebhookParseResult {
try {
const payload = JSON.parse(ctx.rawBody);
const data = payload.data;
if (!data || !data.event_type) {
return { events: [], statusCode: 200 };
}
const event = this.normalizeEvent(data, options?.verifiedRequestKey);
return {
events: event ? [event] : [],
statusCode: 200,
};
} catch {
return { events: [], statusCode: 400 };
}
}
/**
* Convert Telnyx event to normalized event format.
*/
private normalizeEvent(data: TelnyxEvent, dedupeKey?: string): NormalizedEvent | null {
// Decode client_state from Base64 (we encode it in initiateCall)
let callId = "";
if (data.payload?.client_state) {
try {
callId = Buffer.from(data.payload.client_state, "base64").toString("utf8");
} catch {
// Fallback if not valid Base64
callId = data.payload.client_state;
}
}
if (!callId) {
callId = data.payload?.call_control_id || "";
}
const baseEvent = {
id: data.id || crypto.randomUUID(),
dedupeKey,
callId,
providerCallId: data.payload?.call_control_id,
timestamp: Date.now(),
};
switch (data.event_type) {
case "call.initiated":
return { ...baseEvent, type: "call.initiated" };
case "call.ringing":
return { ...baseEvent, type: "call.ringing" };
case "call.answered":
return { ...baseEvent, type: "call.answered" };
case "call.bridged":
return { ...baseEvent, type: "call.active" };
case "call.speak.started":
return {
...baseEvent,
type: "call.speaking",
text: data.payload?.text || "",
};
case "call.transcription":
return {
...baseEvent,
type: "call.speech",
transcript: data.payload?.transcription || "",
isFinal: data.payload?.is_final ?? true,
confidence: data.payload?.confidence,
};
case "call.hangup":
return {
...baseEvent,
type: "call.ended",
reason: this.mapHangupCause(data.payload?.hangup_cause),
};
case "call.dtmf.received":
return {
...baseEvent,
type: "call.dtmf",
digits: data.payload?.digit || "",
};
default:
return null;
}
}
/**
* Map Telnyx hangup cause to normalized end reason.
* @see https://developers.telnyx.com/docs/api/v2/call-control/Call-Commands#hangup-causes
*/
private mapHangupCause(cause?: string): EndReason {
switch (cause) {
case "normal_clearing":
case "normal_unspecified":
return "completed";
case "originator_cancel":
return "hangup-bot";
case "call_rejected":
case "user_busy":
return "busy";
case "no_answer":
case "no_user_response":
return "no-answer";
case "destination_out_of_order":
case "network_out_of_order":
case "service_unavailable":
case "recovery_on_timer_expire":
return "failed";
case "machine_detected":
case "fax_detected":
return "voicemail";
case "user_hangup":
case "subscriber_absent":
return "hangup-user";
default:
// Unknown cause - log it for debugging and return completed
if (cause) {
console.warn(`[telnyx] Unknown hangup cause: ${cause}`);
}
return "completed";
}
}
/**
* Initiate an outbound call via Telnyx API.
*/
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
const result = await this.apiRequest<TelnyxCallResponse>("/calls", {
connection_id: this.connectionId,
to: input.to,
from: input.from,
webhook_url: input.webhookUrl,
webhook_url_method: "POST",
client_state: Buffer.from(input.callId).toString("base64"),
timeout_secs: 30,
});
return {
providerCallId: result.data.call_control_id,
status: "initiated",
};
}
/**
* Hang up a call via Telnyx API.
*/
async hangupCall(input: HangupCallInput): Promise<void> {
await this.apiRequest(
`/calls/${input.providerCallId}/actions/hangup`,
{ command_id: crypto.randomUUID() },
{ allowNotFound: true },
);
}
/**
* Play TTS audio via Telnyx speak action.
*/
async playTts(input: PlayTtsInput): Promise<void> {
await this.apiRequest(`/calls/${input.providerCallId}/actions/speak`, {
command_id: crypto.randomUUID(),
payload: input.text,
voice: input.voice || "female",
language: input.locale || "en-US",
});
}
/**
* Start transcription (STT) via Telnyx.
*/
async startListening(input: StartListeningInput): Promise<void> {
await this.apiRequest(`/calls/${input.providerCallId}/actions/transcription_start`, {
command_id: crypto.randomUUID(),
language: input.language || "en",
});
}
/**
* Stop transcription via Telnyx.
*/
async stopListening(input: StopListeningInput): Promise<void> {
await this.apiRequest(
`/calls/${input.providerCallId}/actions/transcription_stop`,
{ command_id: crypto.randomUUID() },
{ allowNotFound: true },
);
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
try {
const data = await guardedJsonApiRequest<{ data?: { state?: string; is_alive?: boolean } }>({
url: `${this.baseUrl}/calls/${input.providerCallId}`,
method: "GET",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
allowNotFound: true,
allowedHostnames: [this.apiHost],
auditContext: "telnyx-get-call-status",
errorPrefix: "Telnyx get call status error",
});
if (!data) {
return { status: "not-found", isTerminal: true };
}
const state = data.data?.state ?? "unknown";
const isAlive = data.data?.is_alive;
// If is_alive is missing, treat as unknown rather than terminal (P1 fix)
if (isAlive === undefined) {
return { status: state, isTerminal: false, isUnknown: true };
}
return { status: state, isTerminal: !isAlive };
} catch {
return { status: "error", isTerminal: false, isUnknown: true };
}
}
}
// -----------------------------------------------------------------------------
// Telnyx-specific types
// -----------------------------------------------------------------------------
interface TelnyxEvent {
id?: string;
event_type: string;
payload?: {
call_control_id?: string;
client_state?: string;
text?: string;
transcription?: string;
is_final?: boolean;
confidence?: number;
hangup_cause?: string;
digit?: string;
[key: string]: unknown;
};
}
interface TelnyxCallResponse {
data: {
call_control_id: string;
call_leg_id: string;
call_session_id: string;
is_alive: boolean;
record_type: string;
};
}

View file

@ -0,0 +1,366 @@
import { describe, expect, it, vi } from "vitest";
import type { WebhookContext } from "../types.js";
import { TwilioProvider } from "./twilio.js";
const STREAM_URL = "wss://example.ngrok.app/voice/stream";
function createProvider(): TwilioProvider {
return new TwilioProvider(
{ accountSid: "AC123", authToken: "secret" },
{ publicUrl: "https://example.ngrok.app", streamPath: "/voice/stream" },
);
}
function createContext(rawBody: string, query?: WebhookContext["query"]): WebhookContext {
return {
headers: {},
rawBody,
url: "https://example.ngrok.app/voice/twilio",
method: "POST",
query,
};
}
function expectStreamingTwiml(body: string) {
expect(body).toContain(STREAM_URL);
expect(body).toContain('<Parameter name="token" value="');
expect(body).toContain("<Connect>");
}
function expectQueueTwiml(body: string) {
expect(body).toContain("Please hold while we connect you.");
expect(body).toContain("<Enqueue");
expect(body).toContain("hold-queue");
}
function requireResponseBody(body: string | undefined): string {
if (!body) {
throw new Error("Twilio provider did not return a response body");
}
return body;
}
function requireEvent<T>(event: T | undefined, message: string): T {
if (!event) {
throw new Error(message);
}
return event;
}
type TwilioApiRequest = (
endpoint: string,
params: Record<string, string | string[]>,
options?: { allowNotFound?: boolean },
) => Promise<unknown>;
function createApiRequestMock() {
return vi.fn<TwilioApiRequest>(async () => ({}));
}
function configureTelephonyTwiMlFallback(params: { providerCallId: string; streamSid?: string }) {
const provider = createProvider();
const apiRequest = createApiRequestMock();
(
provider as unknown as {
apiRequest: TwilioApiRequest;
}
).apiRequest = apiRequest;
(
provider as unknown as {
callWebhookUrls: Map<string, string>;
}
).callWebhookUrls.set(params.providerCallId, "https://example.ngrok.app/voice/twilio");
if (params.streamSid) {
provider.registerCallStream(params.providerCallId, params.streamSid);
}
return { provider, apiRequest };
}
describe("TwilioProvider", () => {
it("returns streaming TwiML for outbound conversation calls before in-progress", () => {
const provider = createProvider();
const ctx = createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA123", {
callId: "call-1",
});
const result = provider.parseWebhookEvent(ctx);
expectStreamingTwiml(requireResponseBody(result.providerResponseBody));
});
it("returns empty TwiML for status callbacks", () => {
const provider = createProvider();
const ctx = createContext("CallStatus=ringing&Direction=outbound-api", {
callId: "call-1",
type: "status",
});
const result = provider.parseWebhookEvent(ctx);
expect(result.providerResponseBody).toBe(
'<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
);
});
it("returns streaming TwiML for inbound calls", () => {
const provider = createProvider();
const ctx = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA456");
const result = provider.parseWebhookEvent(ctx);
expectStreamingTwiml(requireResponseBody(result.providerResponseBody));
});
it("returns queue TwiML for second inbound call when first call is active", () => {
const provider = createProvider();
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA111");
const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA222");
const firstResult = provider.parseWebhookEvent(firstInbound);
const secondResult = provider.parseWebhookEvent(secondInbound);
expectStreamingTwiml(requireResponseBody(firstResult.providerResponseBody));
expectQueueTwiml(requireResponseBody(secondResult.providerResponseBody));
});
it("connects next inbound call after unregisterCallStream cleanup", () => {
const provider = createProvider();
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA311");
const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA322");
provider.parseWebhookEvent(firstInbound);
provider.unregisterCallStream("CA311");
const secondResult = provider.parseWebhookEvent(secondInbound);
const secondBody = requireResponseBody(secondResult.providerResponseBody);
expectStreamingTwiml(secondBody);
expect(secondBody).not.toContain("hold-queue");
});
it("cleans up active inbound call on completed status callback", () => {
const provider = createProvider();
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA411");
const completed = createContext("CallStatus=completed&Direction=inbound&CallSid=CA411", {
type: "status",
});
const nextInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA422");
provider.parseWebhookEvent(firstInbound);
provider.parseWebhookEvent(completed);
const nextResult = provider.parseWebhookEvent(nextInbound);
const nextBody = requireResponseBody(nextResult.providerResponseBody);
expectStreamingTwiml(nextBody);
expect(nextBody).not.toContain("hold-queue");
});
it("cleans up active inbound call on canceled status callback", () => {
const provider = createProvider();
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA511");
const canceled = createContext("CallStatus=canceled&Direction=inbound&CallSid=CA511", {
type: "status",
});
const nextInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA522");
provider.parseWebhookEvent(firstInbound);
provider.parseWebhookEvent(canceled);
const nextResult = provider.parseWebhookEvent(nextInbound);
const nextBody = requireResponseBody(nextResult.providerResponseBody);
expectStreamingTwiml(nextBody);
expect(nextBody).not.toContain("hold-queue");
});
it("QUEUE_TWIML references /voice/hold-music waitUrl", () => {
const provider = createProvider();
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA611");
const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA622");
provider.parseWebhookEvent(firstInbound);
const result = provider.parseWebhookEvent(secondInbound);
expect(requireResponseBody(result.providerResponseBody)).toContain(
'waitUrl="/voice/hold-music"',
);
});
it("uses a stable fallback dedupeKey for identical request payloads", () => {
const provider = createProvider();
const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello";
const ctxA = {
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
headers: { "i-twilio-idempotency-token": "idem-123" },
};
const ctxB = {
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
headers: { "i-twilio-idempotency-token": "idem-123" },
};
const eventA = provider.parseWebhookEvent(ctxA).events[0];
const eventB = provider.parseWebhookEvent(ctxB).events[0];
const first = requireEvent(eventA, "expected first fallback Twilio event");
const second = requireEvent(eventB, "expected second fallback Twilio event");
expect(first.id).not.toBe(second.id);
expect(first.dedupeKey).toContain("twilio:fallback:");
expect(first.dedupeKey).toBe(second.dedupeKey);
});
it("uses verified request key for dedupe and ignores idempotency header changes", () => {
const provider = createProvider();
const rawBody = "CallSid=CA790&Direction=inbound&SpeechResult=hello";
const ctxA = {
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
headers: { "i-twilio-idempotency-token": "idem-a" },
};
const ctxB = {
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
headers: { "i-twilio-idempotency-token": "idem-b" },
};
const eventA = provider.parseWebhookEvent(ctxA, { verifiedRequestKey: "twilio:req:abc" })
.events[0];
const eventB = provider.parseWebhookEvent(ctxB, { verifiedRequestKey: "twilio:req:abc" })
.events[0];
expect(requireEvent(eventA, "expected verified first Twilio event").dedupeKey).toBe(
"twilio:req:abc",
);
expect(requireEvent(eventB, "expected verified second Twilio event").dedupeKey).toBe(
"twilio:req:abc",
);
});
it("keeps turnToken from query on speech events", () => {
const provider = createProvider();
const ctx = createContext("CallSid=CA222&Direction=inbound&SpeechResult=hello", {
callId: "call-2",
turnToken: "turn-xyz",
});
const event = provider.parseWebhookEvent(ctx).events[0];
const parsed = requireEvent(event, "expected speech event from Twilio webhook");
expect(parsed.type).toBe("call.speech");
expect(parsed.turnToken).toBe("turn-xyz");
});
it("fails when an active stream exists but telephony TTS is unavailable", async () => {
const { provider, apiRequest } = configureTelephonyTwiMlFallback({
providerCallId: "CA-stream",
streamSid: "MZ-stream",
});
await expect(
provider.playTts({
callId: "call-stream",
providerCallId: "CA-stream",
text: "Hello stream",
}),
).rejects.toThrow("refusing TwiML fallback");
expect(apiRequest).not.toHaveBeenCalled();
});
it("falls back to TwiML when no active stream exists and telephony TTS is unavailable", async () => {
const { provider, apiRequest } = configureTelephonyTwiMlFallback({
providerCallId: "CA-nostream",
});
await expect(
provider.playTts({
callId: "call-nostream",
providerCallId: "CA-nostream",
text: "Hello TwiML",
}),
).resolves.toBeUndefined();
expect(apiRequest).toHaveBeenCalledTimes(1);
const call = apiRequest.mock.calls[0];
const endpoint = call[0];
const params = call[1] as { Twiml?: string };
expect(endpoint).toBe("/Calls/CA-nostream.json");
expect(params.Twiml).toContain("<Say");
});
it("ignores stale stream unregister requests that do not match current stream SID", () => {
const provider = createProvider();
provider.registerCallStream("CA-reconnect", "MZ-new");
provider.unregisterCallStream("CA-reconnect", "MZ-old");
expect(provider.hasRegisteredStream("CA-reconnect")).toBe(true);
provider.unregisterCallStream("CA-reconnect", "MZ-new");
expect(provider.hasRegisteredStream("CA-reconnect")).toBe(false);
});
it("times out telephony synthesis in stream mode and does not send completion mark", async () => {
vi.useFakeTimers();
try {
const provider = createProvider();
provider.registerCallStream("CA-timeout", "MZ-timeout");
const sendAudio = vi.fn();
const sendMark = vi.fn();
const mediaStreamHandler = {
queueTts: async (
_streamSid: string,
playFn: (signal: AbortSignal) => Promise<void>,
): Promise<void> => {
await playFn(new AbortController().signal);
},
sendAudio,
sendMark,
};
provider.setMediaStreamHandler(mediaStreamHandler as never);
provider.setTTSProvider({
synthesizeForTelephony: async () => await new Promise<Buffer>(() => {}),
});
const playExpectation = expect(
provider.playTts({
callId: "call-timeout",
providerCallId: "CA-timeout",
text: "Timeout me",
}),
).rejects.toThrow("Telephony TTS synthesis timed out");
await vi.advanceTimersByTimeAsync(8_100);
await playExpectation;
expect(sendAudio).toHaveBeenCalled();
expect(sendMark).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("fails stream playback when all audio sends and completion mark are dropped", async () => {
const provider = createProvider();
provider.registerCallStream("CA-dropped", "MZ-dropped");
const sendAudio = vi.fn(() => ({ sent: false }));
const sendMark = vi.fn(() => ({ sent: false }));
const mediaStreamHandler = {
queueTts: async (
_streamSid: string,
playFn: (signal: AbortSignal) => Promise<void>,
): Promise<void> => {
await playFn(new AbortController().signal);
},
sendAudio,
sendMark,
};
provider.setMediaStreamHandler(mediaStreamHandler as never);
provider.setTTSProvider({
synthesizeForTelephony: async () => Buffer.alloc(320),
});
await expect(
provider.playTts({
callId: "call-dropped",
providerCallId: "CA-dropped",
text: "Dropped audio",
}),
).rejects.toThrow("Telephony stream playback failed");
expect(sendAudio).toHaveBeenCalled();
expect(sendMark).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,790 @@
import crypto from "node:crypto";
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { TwilioConfig } from "../config.js";
import { getHeader } from "../http-headers.js";
import type { MediaStreamHandler } from "../media-stream.js";
import { chunkAudio } from "../telephony-audio.js";
import type { TelephonyTtsProvider } from "../telephony-tts.js";
import type {
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
NormalizedEvent,
PlayTtsInput,
ProviderWebhookParseResult,
StartListeningInput,
StopListeningInput,
WebhookContext,
WebhookParseOptions,
WebhookVerificationResult,
} from "../types.js";
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
import type { VoiceCallProvider } from "./base.js";
import {
isProviderStatusTerminal,
mapProviderStatusToEndReason,
normalizeProviderStatus,
} from "./shared/call-status.js";
import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
import type { TwilioProviderOptions } from "./twilio.types.js";
import { twilioApiRequest } from "./twilio/api.js";
import { decideTwimlResponse, readTwimlRequestView } from "./twilio/twiml-policy.js";
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
export type { TwilioProviderOptions } from "./twilio.types.js";
function createTwilioRequestDedupeKey(ctx: WebhookContext, verifiedRequestKey?: string): string {
if (verifiedRequestKey) {
return verifiedRequestKey;
}
const signature = getHeader(ctx.headers, "x-twilio-signature") ?? "";
const params = new URLSearchParams(ctx.rawBody);
const callSid = params.get("CallSid") ?? "";
const callStatus = params.get("CallStatus") ?? "";
const direction = params.get("Direction") ?? "";
const callId = normalizeOptionalString(ctx.query?.callId) ?? "";
const flow = normalizeOptionalString(ctx.query?.flow) ?? "";
const turnToken = normalizeOptionalString(ctx.query?.turnToken) ?? "";
return `twilio:fallback:${crypto
.createHash("sha256")
.update(
`${signature}\n${callSid}\n${callStatus}\n${direction}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`,
)
.digest("hex")}`;
}
type StreamSendResult = {
sent: boolean;
};
export class TwilioProvider implements VoiceCallProvider {
readonly name = "twilio" as const;
private static readonly TTS_SYNTH_TIMEOUT_MS = 8000;
private readonly accountSid: string;
private readonly authToken: string;
private readonly baseUrl: string;
private readonly callWebhookUrls = new Map<string, string>();
private readonly options: TwilioProviderOptions;
/** Current public webhook URL (set when tunnel starts or from config) */
private currentPublicUrl: string | null = null;
/** Optional telephony TTS provider for streaming TTS */
private ttsProvider: TelephonyTtsProvider | null = null;
/** Optional media stream handler for sending audio */
private mediaStreamHandler: MediaStreamHandler | null = null;
/** Map of call SID to stream SID for media streams */
private callStreamMap = new Map<string, string>();
/** Per-call tokens for media stream authentication */
private streamAuthTokens = new Map<string, string>();
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
private readonly twimlStorage = new Map<string, string>();
/** Track notify-mode calls to avoid streaming on follow-up callbacks */
private readonly notifyCalls = new Set<string>();
private readonly activeStreamCalls = new Set<string>();
/**
* Delete stored TwiML for a given `callId`.
*
* We keep TwiML in-memory only long enough to satisfy the initial Twilio
* webhook request (notify mode). Subsequent webhooks should not reuse it.
*/
private deleteStoredTwiml(callId: string): void {
this.twimlStorage.delete(callId);
this.notifyCalls.delete(callId);
}
/**
* Delete stored TwiML for a call, addressed by Twilio's provider call SID.
*
* This is used when we only have `providerCallId` (e.g. hangup).
*/
private deleteStoredTwimlForProviderCall(providerCallId: string): void {
const webhookUrl = this.callWebhookUrls.get(providerCallId);
if (!webhookUrl) {
return;
}
const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
if (!callIdMatch) {
return;
}
this.deleteStoredTwiml(callIdMatch[1]);
this.streamAuthTokens.delete(providerCallId);
}
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
if (!config.accountSid) {
throw new Error("Twilio Account SID is required");
}
if (!config.authToken) {
throw new Error("Twilio Auth Token is required");
}
this.accountSid = config.accountSid;
this.authToken = config.authToken;
this.baseUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.accountSid}`;
this.options = options;
if (options.publicUrl) {
this.currentPublicUrl = options.publicUrl;
}
}
setPublicUrl(url: string): void {
this.currentPublicUrl = url;
}
getPublicUrl(): string | null {
return this.currentPublicUrl;
}
setTTSProvider(provider: TelephonyTtsProvider): void {
this.ttsProvider = provider;
}
setMediaStreamHandler(handler: MediaStreamHandler): void {
this.mediaStreamHandler = handler;
}
registerCallStream(callSid: string, streamSid: string): void {
this.callStreamMap.set(callSid, streamSid);
}
hasRegisteredStream(callSid: string): boolean {
return this.callStreamMap.has(callSid);
}
unregisterCallStream(callSid: string, streamSid?: string): void {
const currentStreamSid = this.callStreamMap.get(callSid);
if (!currentStreamSid) {
if (!streamSid) {
this.activeStreamCalls.delete(callSid);
}
return;
}
if (streamSid && currentStreamSid !== streamSid) {
return;
}
this.callStreamMap.delete(callSid);
this.activeStreamCalls.delete(callSid);
}
isConversationStreamConnectEnabled(): boolean {
return Boolean(this.mediaStreamHandler && this.getStreamUrl());
}
isValidStreamToken(callSid: string, token?: string): boolean {
const expected = this.streamAuthTokens.get(callSid);
if (!expected || !token) {
return false;
}
return safeEqualSecret(expected, token);
}
/**
* Clear TTS queue for a call (barge-in).
* Used when user starts speaking to interrupt current TTS playback.
*/
clearTtsQueue(callSid: string, reason = "unspecified"): void {
const streamSid = this.callStreamMap.get(callSid);
if (!streamSid || !this.mediaStreamHandler) {
return;
}
this.mediaStreamHandler.clearTtsQueue(streamSid, reason);
}
/**
* Make an authenticated request to the Twilio API.
*/
private async apiRequest<T = unknown>(
endpoint: string,
params: Record<string, string | string[]>,
options?: { allowNotFound?: boolean },
): Promise<T> {
return await twilioApiRequest<T>({
baseUrl: this.baseUrl,
accountSid: this.accountSid,
authToken: this.authToken,
endpoint,
body: params,
allowNotFound: options?.allowNotFound,
});
}
/**
* Verify Twilio webhook signature using HMAC-SHA1.
*
* Handles reverse proxy scenarios (Tailscale, nginx, ngrok) by reconstructing
* the public URL from forwarding headers.
*
* @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
*/
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
return verifyTwilioProviderWebhook({
ctx,
authToken: this.authToken,
currentPublicUrl: this.currentPublicUrl,
options: this.options,
});
}
/**
* Parse Twilio webhook event into normalized format.
*/
parseWebhookEvent(
ctx: WebhookContext,
options?: WebhookParseOptions,
): ProviderWebhookParseResult {
try {
const params = new URLSearchParams(ctx.rawBody);
const callIdFromQuery = normalizeOptionalString(ctx.query?.callId);
const turnTokenFromQuery = normalizeOptionalString(ctx.query?.turnToken);
const dedupeKey = createTwilioRequestDedupeKey(ctx, options?.verifiedRequestKey);
const event = this.normalizeEvent(params, {
callIdOverride: callIdFromQuery,
dedupeKey,
turnToken: turnTokenFromQuery,
});
// For Twilio, we must return TwiML. Most actions are driven by Calls API updates,
// so the webhook response is typically a pause to keep the call alive.
const twiml = this.generateTwimlResponse(ctx);
return {
events: event ? [event] : [],
providerResponseBody: twiml,
providerResponseHeaders: { "Content-Type": "application/xml" },
statusCode: 200,
};
} catch {
return { events: [], statusCode: 400 };
}
}
/**
* Parse Twilio direction to normalized format.
*/
private static parseDirection(direction: string | null): "inbound" | "outbound" | undefined {
if (direction === "inbound") {
return "inbound";
}
if (direction === "outbound-api" || direction === "outbound-dial") {
return "outbound";
}
return undefined;
}
/**
* Convert Twilio webhook params to normalized event format.
*/
private normalizeEvent(
params: URLSearchParams,
options?: {
callIdOverride?: string;
dedupeKey?: string;
turnToken?: string;
},
): NormalizedEvent | null {
const callSid = params.get("CallSid") || "";
const callIdOverride = options?.callIdOverride;
const baseEvent = {
id: crypto.randomUUID(),
dedupeKey: options?.dedupeKey,
callId: callIdOverride || callSid,
providerCallId: callSid,
timestamp: Date.now(),
turnToken: options?.turnToken,
direction: TwilioProvider.parseDirection(params.get("Direction")),
from: params.get("From") || undefined,
to: params.get("To") || undefined,
};
// Handle speech result (from <Gather>)
const speechResult = params.get("SpeechResult");
if (speechResult) {
return {
...baseEvent,
type: "call.speech",
transcript: speechResult,
isFinal: true,
confidence: parseFloat(params.get("Confidence") || "0.9"),
};
}
// Handle DTMF
const digits = params.get("Digits");
if (digits) {
return { ...baseEvent, type: "call.dtmf", digits };
}
// Handle call status changes
const callStatus = normalizeProviderStatus(params.get("CallStatus"));
if (callStatus === "initiated") {
return { ...baseEvent, type: "call.initiated" };
}
if (callStatus === "ringing") {
return { ...baseEvent, type: "call.ringing" };
}
if (callStatus === "in-progress") {
return { ...baseEvent, type: "call.answered" };
}
const endReason = mapProviderStatusToEndReason(callStatus);
if (endReason) {
this.streamAuthTokens.delete(callSid);
this.activeStreamCalls.delete(callSid);
if (callIdOverride) {
this.deleteStoredTwiml(callIdOverride);
}
return { ...baseEvent, type: "call.ended", reason: endReason };
}
return null;
}
private static readonly EMPTY_TWIML =
'<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
private static readonly PAUSE_TWIML = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Pause length="30"/>
</Response>`;
private static readonly QUEUE_TWIML = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="alice">Please hold while we connect you.</Say>
<Enqueue waitUrl="/voice/hold-music">hold-queue</Enqueue>
</Response>`;
/**
* Generate TwiML response for webhook.
* When a call is answered, connects to media stream for bidirectional audio.
*/
private generateTwimlResponse(ctx?: WebhookContext): string {
if (!ctx) {
return TwilioProvider.EMPTY_TWIML;
}
const view = readTwimlRequestView(ctx);
const storedTwiml = view.callIdFromQuery
? this.twimlStorage.get(view.callIdFromQuery)
: undefined;
const decision = decideTwimlResponse({
...view,
hasStoredTwiml: Boolean(storedTwiml),
isNotifyCall: view.callIdFromQuery ? this.notifyCalls.has(view.callIdFromQuery) : false,
hasActiveStreams: this.activeStreamCalls.size > 0,
canStream: Boolean(view.callSid && this.getStreamUrl()),
});
if (decision.consumeStoredTwimlCallId) {
this.deleteStoredTwiml(decision.consumeStoredTwimlCallId);
}
if (decision.activateStreamCallSid) {
this.activeStreamCalls.add(decision.activateStreamCallSid);
}
switch (decision.kind) {
case "stored":
return storedTwiml ?? TwilioProvider.EMPTY_TWIML;
case "queue":
return TwilioProvider.QUEUE_TWIML;
case "pause":
return TwilioProvider.PAUSE_TWIML;
case "stream": {
const streamUrl = view.callSid ? this.getStreamUrlForCall(view.callSid) : null;
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
}
case "empty":
default:
return TwilioProvider.EMPTY_TWIML;
}
}
/**
* Get the WebSocket URL for media streaming.
* Derives from the public URL origin + stream path.
*/
private getStreamUrl(): string | null {
if (!this.currentPublicUrl || !this.options.streamPath) {
return null;
}
// Extract just the origin (host) from the public URL, ignoring any path
const url = new URL(this.currentPublicUrl);
const origin = url.origin;
// Convert https:// to wss:// for WebSocket
const wsOrigin = origin.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
// Append the stream path
const path = this.options.streamPath.startsWith("/")
? this.options.streamPath
: `/${this.options.streamPath}`;
return `${wsOrigin}${path}`;
}
private getStreamAuthToken(callSid: string): string {
const existing = this.streamAuthTokens.get(callSid);
if (existing) {
return existing;
}
const token = crypto.randomBytes(16).toString("base64url");
this.streamAuthTokens.set(callSid, token);
return token;
}
private getStreamUrlForCall(callSid: string): string | null {
const baseUrl = this.getStreamUrl();
if (!baseUrl) {
return null;
}
const token = this.getStreamAuthToken(callSid);
const url = new URL(baseUrl);
url.searchParams.set("token", token);
return url.toString();
}
/**
* Generate TwiML to connect a call to a WebSocket media stream.
* This enables bidirectional audio streaming for real-time STT/TTS.
*
* @param streamUrl - WebSocket URL (wss://...) for the media stream
*/
getStreamConnectXml(streamUrl: string): string {
// Extract token from URL and pass via <Parameter> instead of query string.
// Twilio strips query params from WebSocket URLs, but delivers <Parameter>
// values in the "start" message's customParameters field.
const parsed = new URL(streamUrl);
const token = parsed.searchParams.get("token");
parsed.searchParams.delete("token");
const cleanUrl = parsed.toString();
const paramXml = token ? `\n <Parameter name="token" value="${escapeXml(token)}" />` : "";
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="${escapeXml(cleanUrl)}">${paramXml}
</Stream>
</Connect>
</Response>`;
}
/**
* Initiate an outbound call via Twilio API.
* If inlineTwiml is provided, uses that directly (for notify mode).
* Otherwise, uses webhook URL for dynamic TwiML.
*/
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
const url = new URL(input.webhookUrl);
url.searchParams.set("callId", input.callId);
// Create separate URL for status callbacks (required by Twilio)
const statusUrl = new URL(input.webhookUrl);
statusUrl.searchParams.set("callId", input.callId);
statusUrl.searchParams.set("type", "status"); // Differentiate from TwiML requests
// Store TwiML content if provided (for notify mode)
// We now serve it from the webhook endpoint instead of sending inline
if (input.inlineTwiml) {
this.twimlStorage.set(input.callId, input.inlineTwiml);
this.notifyCalls.add(input.callId);
}
// Build request params - always use URL-based TwiML.
// Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
const params: Record<string, string | string[]> = {
To: input.to,
From: input.from,
Url: url.toString(), // TwiML serving endpoint
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"],
Timeout: "30",
};
const result = await this.apiRequest<TwilioCallResponse>("/Calls.json", params);
this.callWebhookUrls.set(result.sid, url.toString());
return {
providerCallId: result.sid,
status: result.status === "queued" ? "queued" : "initiated",
};
}
/**
* Hang up a call via Twilio API.
*/
async hangupCall(input: HangupCallInput): Promise<void> {
this.deleteStoredTwimlForProviderCall(input.providerCallId);
this.callWebhookUrls.delete(input.providerCallId);
this.streamAuthTokens.delete(input.providerCallId);
this.activeStreamCalls.delete(input.providerCallId);
await this.apiRequest(
`/Calls/${input.providerCallId}.json`,
{ Status: "completed" },
{ allowNotFound: true },
);
}
/**
* Play TTS audio via Twilio.
*
* Two modes:
* 1. Core TTS + Media Streams: when an active stream exists, stream playback is required.
* If telephony TTS is unavailable in that state, playback fails rather than mixing paths.
* 2. TwiML <Say>: fallback only when there is no active stream for the call.
*/
async playTts(input: PlayTtsInput): Promise<void> {
const streamSid = this.callStreamMap.get(input.providerCallId);
if (streamSid) {
if (!this.ttsProvider || !this.mediaStreamHandler) {
throw new Error(
"Telephony TTS unavailable while media stream is active; refusing TwiML fallback",
);
}
try {
await this.playTtsViaStream(input.text, streamSid);
return;
} catch (err) {
console.warn(
`[voice-call] Telephony TTS failed:`,
err instanceof Error ? err.message : err,
);
throw err instanceof Error ? err : new Error(String(err));
}
}
// Fall back to TwiML <Say> only when no active stream exists.
const webhookUrl = this.callWebhookUrls.get(input.providerCallId);
if (!webhookUrl) {
throw new Error("Missing webhook URL for this call (provider state not initialized)");
}
console.warn(
"[voice-call] Using TwiML <Say> fallback - telephony TTS not configured or media stream not active",
);
const pollyVoice = mapVoiceToPolly(input.voice);
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="${pollyVoice}" language="${input.locale || "en-US"}">${escapeXml(input.text)}</Say>
<Gather input="speech" speechTimeout="auto" action="${escapeXml(webhookUrl)}" method="POST">
<Say>.</Say>
</Gather>
</Response>`;
await this.apiRequest(`/Calls/${input.providerCallId}.json`, {
Twiml: twiml,
});
}
/**
* Play TTS via core TTS and Twilio Media Streams.
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
* Uses a queue to serialize playback and prevent overlapping audio.
*/
private async playTtsViaStream(text: string, streamSid: string): Promise<void> {
if (!this.ttsProvider || !this.mediaStreamHandler) {
throw new Error("TTS provider and media stream handler required");
}
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
const CHUNK_SIZE = 160;
const CHUNK_DELAY_MS = 20;
const SILENCE_CHUNK = Buffer.alloc(CHUNK_SIZE, 0xff);
const handler = this.mediaStreamHandler;
const ttsProvider = this.ttsProvider;
const normalizeSendResult = (raw: unknown): StreamSendResult => {
if (!raw || typeof raw !== "object") {
return { sent: true };
}
const typed = raw as {
sent?: unknown;
};
return {
sent: typed.sent === undefined ? true : Boolean(typed.sent),
};
};
const sendAudioChunk = (audio: Buffer): StreamSendResult => {
const raw = (handler as { sendAudio: (sid: string, chunk: Buffer) => unknown }).sendAudio(
streamSid,
audio,
);
return normalizeSendResult(raw);
};
const sendPlaybackMark = (name: string): StreamSendResult => {
const raw = (handler as { sendMark: (sid: string, markName: string) => unknown }).sendMark(
streamSid,
name,
);
return normalizeSendResult(raw);
};
await handler.queueTts(streamSid, async (signal) => {
const sendKeepAlive = () => {
sendAudioChunk(SILENCE_CHUNK);
};
sendKeepAlive();
const keepAlive = setInterval(() => {
if (!signal.aborted) {
sendKeepAlive();
}
}, CHUNK_DELAY_MS);
// Generate audio with core TTS (returns mu-law at 8kHz)
let muLawAudio: Buffer;
let synthTimeout: ReturnType<typeof setTimeout> | null = null;
try {
const synthPromise = ttsProvider.synthesizeForTelephony(text);
const timeoutPromise = new Promise<Buffer>((_, reject) => {
synthTimeout = setTimeout(() => {
reject(
new Error(
`Telephony TTS synthesis timed out after ${TwilioProvider.TTS_SYNTH_TIMEOUT_MS}ms`,
),
);
}, TwilioProvider.TTS_SYNTH_TIMEOUT_MS);
});
muLawAudio = await Promise.race([synthPromise, timeoutPromise]);
} finally {
if (synthTimeout) {
clearTimeout(synthTimeout);
}
clearInterval(keepAlive);
}
let chunkAttempts = 0;
let chunkDelivered = 0;
let nextChunkDueAt = Date.now() + CHUNK_DELAY_MS;
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
if (signal.aborted) {
break;
}
chunkAttempts += 1;
const chunkResult = sendAudioChunk(chunk);
if (chunkResult.sent) {
chunkDelivered += 1;
}
// Drift-corrected pacing: schedule against an absolute clock to avoid cumulative delay.
const waitMs = nextChunkDueAt - Date.now();
if (waitMs > 0) {
await new Promise((resolve) => setTimeout(resolve, Math.ceil(waitMs)));
}
nextChunkDueAt += CHUNK_DELAY_MS;
if (signal.aborted) {
break;
}
}
let markSent = true;
if (!signal.aborted) {
// Send a mark to track when audio finishes
markSent = sendPlaybackMark(`tts-${Date.now()}`).sent;
}
if (!signal.aborted && chunkAttempts > 0 && (chunkDelivered === 0 || !markSent)) {
const failures: string[] = [];
if (chunkDelivered === 0) {
failures.push("no audio chunks delivered");
}
if (!markSent) {
failures.push("completion mark not delivered");
}
throw new Error(`Telephony stream playback failed: ${failures.join("; ")}`);
}
});
}
/**
* Start listening for speech via Twilio <Gather>.
*/
async startListening(input: StartListeningInput): Promise<void> {
const webhookUrl = this.callWebhookUrls.get(input.providerCallId);
if (!webhookUrl) {
throw new Error("Missing webhook URL for this call (provider state not initialized)");
}
const actionUrl = new URL(webhookUrl);
if (input.turnToken) {
actionUrl.searchParams.set("turnToken", input.turnToken);
}
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Gather input="speech" speechTimeout="auto" language="${input.language || "en-US"}" action="${escapeXml(actionUrl.toString())}" method="POST">
</Gather>
</Response>`;
await this.apiRequest(`/Calls/${input.providerCallId}.json`, {
Twiml: twiml,
});
}
/**
* Stop listening - for Twilio this is a no-op as <Gather> auto-ends.
*/
async stopListening(_input: StopListeningInput): Promise<void> {
// Twilio's <Gather> automatically stops on speech end
// No explicit action needed
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
try {
const data = await guardedJsonApiRequest<{ status?: string }>({
url: `${this.baseUrl}/Calls/${input.providerCallId}.json`,
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString("base64")}`,
},
allowNotFound: true,
allowedHostnames: ["api.twilio.com"],
auditContext: "twilio-get-call-status",
errorPrefix: "Twilio get call status error",
});
if (!data) {
return { status: "not-found", isTerminal: true };
}
const status = normalizeProviderStatus(data.status);
return { status, isTerminal: isProviderStatusTerminal(status) };
} catch {
// Transient error — keep the call and rely on timer fallback
return { status: "error", isTerminal: false, isUnknown: true };
}
}
}
// -----------------------------------------------------------------------------
// Twilio-specific types
// -----------------------------------------------------------------------------
interface TwilioCallResponse {
sid: string;
status: string;
direction: string;
from: string;
to: string;
uri: string;
}

View file

@ -0,0 +1,17 @@
import type { WebhookSecurityConfig } from "../config.js";
/**
* Twilio Voice API provider options.
*/
export interface TwilioProviderOptions {
/** Allow ngrok free tier compatibility mode (loopback only, less secure) */
allowNgrokFreeTierLoopbackBypass?: boolean;
/** Override public URL for signature verification */
publicUrl?: string;
/** Path for media stream WebSocket (e.g., /voice/stream) */
streamPath?: string;
/** Skip webhook signature verification (development only) */
skipVerification?: boolean;
/** Webhook security options (forwarded headers/allowlist) */
webhookSecurity?: WebhookSecurityConfig;
}

View file

@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { twilioApiRequest } from "./api.js";
const originalFetch = globalThis.fetch;
describe("twilioApiRequest", () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("posts form bodies with basic auth and parses json", async () => {
globalThis.fetch = vi.fn(async () => {
return new Response(JSON.stringify({ sid: "CA123" }), { status: 200 });
}) as unknown as typeof fetch;
await expect(
twilioApiRequest({
baseUrl: "https://api.twilio.com",
accountSid: "AC123",
authToken: "secret",
endpoint: "/Calls.json",
body: {
To: "+14155550123",
StatusCallbackEvent: ["initiated", "completed"],
},
}),
).resolves.toEqual({ sid: "CA123" });
const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0] ?? [];
expect(url).toBe("https://api.twilio.com/Calls.json");
expect(init).toEqual(
expect.objectContaining({
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from("AC123:secret").toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
}),
);
const requestBody = init?.body;
if (!(requestBody instanceof URLSearchParams)) {
throw new Error("expected URLSearchParams request body");
}
expect(requestBody.toString()).toBe(
"To=%2B14155550123&StatusCallbackEvent=initiated&StatusCallbackEvent=completed",
);
});
it("passes through URLSearchParams, allows 404s, and returns undefined for empty bodies", async () => {
const responses = [
new Response(null, { status: 204 }),
new Response("missing", { status: 404 }),
];
globalThis.fetch = vi.fn(async () => responses.shift()!) as unknown as typeof fetch;
await expect(
twilioApiRequest({
baseUrl: "https://api.twilio.com",
accountSid: "AC123",
authToken: "secret",
endpoint: "/Calls.json",
body: new URLSearchParams({ To: "+14155550123" }),
}),
).resolves.toBeUndefined();
await expect(
twilioApiRequest({
baseUrl: "https://api.twilio.com",
accountSid: "AC123",
authToken: "secret",
endpoint: "/Calls/missing.json",
body: {},
allowNotFound: true,
}),
).resolves.toBeUndefined();
});
it("throws twilio api errors for non-ok responses", async () => {
globalThis.fetch = vi.fn(
async () => new Response("bad request", { status: 400 }),
) as unknown as typeof fetch;
await expect(
twilioApiRequest({
baseUrl: "https://api.twilio.com",
accountSid: "AC123",
authToken: "secret",
endpoint: "/Calls.json",
body: {},
}),
).rejects.toThrow("Twilio API error: 400 bad request");
});
});

View file

@ -0,0 +1,42 @@
export async function twilioApiRequest<T = unknown>(params: {
baseUrl: string;
accountSid: string;
authToken: string;
endpoint: string;
body: URLSearchParams | Record<string, string | string[]>;
allowNotFound?: boolean;
}): Promise<T> {
const bodyParams =
params.body instanceof URLSearchParams
? params.body
: Object.entries(params.body).reduce((acc, [key, value]) => {
if (Array.isArray(value)) {
for (const entry of value) {
acc.append(key, entry);
}
} else if (typeof value === "string") {
acc.append(key, value);
}
return acc;
}, new URLSearchParams());
const response = await fetch(`${params.baseUrl}${params.endpoint}`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: bodyParams,
});
if (!response.ok) {
if (params.allowNotFound && response.status === 404) {
return undefined as T;
}
const errorText = await response.text();
throw new Error(`Twilio API error: ${response.status} ${errorText}`);
}
const text = await response.text();
return text ? (JSON.parse(text) as T) : (undefined as T);
}

View file

@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import type { WebhookContext } from "../../types.js";
import { decideTwimlResponse, readTwimlRequestView } from "./twiml-policy.js";
function createContext(rawBody: string, query?: WebhookContext["query"]): WebhookContext {
return {
headers: {},
rawBody,
url: "https://example.ngrok.app/voice/twilio",
method: "POST",
query,
};
}
describe("twiml policy", () => {
it("returns stored twiml decision for initial notify callback", () => {
const view = readTwimlRequestView(
createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA123", {
callId: "call-1",
}),
);
const decision = decideTwimlResponse({
...view,
hasStoredTwiml: true,
isNotifyCall: true,
hasActiveStreams: false,
canStream: true,
});
expect(decision.kind).toBe("stored");
});
it("returns queue for inbound when another stream is active", () => {
const view = readTwimlRequestView(
createContext("CallStatus=ringing&Direction=inbound&CallSid=CA456"),
);
const decision = decideTwimlResponse({
...view,
hasStoredTwiml: false,
isNotifyCall: false,
hasActiveStreams: true,
canStream: true,
});
expect(decision.kind).toBe("queue");
});
it("returns stream + activation for inbound call when available", () => {
const view = readTwimlRequestView(
createContext("CallStatus=ringing&Direction=inbound&CallSid=CA789"),
);
const decision = decideTwimlResponse({
...view,
hasStoredTwiml: false,
isNotifyCall: false,
hasActiveStreams: false,
canStream: true,
});
expect(decision.kind).toBe("stream");
expect(decision.activateStreamCallSid).toBe("CA789");
});
it("returns empty for status callbacks", () => {
const view = readTwimlRequestView(
createContext("CallStatus=completed&Direction=inbound&CallSid=CA123", {
type: "status",
}),
);
const decision = decideTwimlResponse({
...view,
hasStoredTwiml: false,
isNotifyCall: false,
hasActiveStreams: false,
canStream: true,
});
expect(decision.kind).toBe("empty");
});
});

View file

@ -0,0 +1,89 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { WebhookContext } from "../../types.js";
export type TwimlResponseKind = "empty" | "pause" | "queue" | "stored" | "stream";
export type TwimlRequestView = {
callStatus: string | null;
direction: string | null;
isStatusCallback: boolean;
callSid?: string;
callIdFromQuery?: string;
};
export type TwimlPolicyInput = TwimlRequestView & {
hasStoredTwiml: boolean;
isNotifyCall: boolean;
hasActiveStreams: boolean;
canStream: boolean;
};
export type TwimlDecision =
| {
kind: "empty" | "pause" | "queue";
consumeStoredTwimlCallId?: string;
activateStreamCallSid?: string;
}
| {
kind: "stored";
consumeStoredTwimlCallId: string;
activateStreamCallSid?: string;
}
| {
kind: "stream";
consumeStoredTwimlCallId?: string;
activateStreamCallSid?: string;
};
function isOutboundDirection(direction: string | null): boolean {
return direction?.startsWith("outbound") ?? false;
}
export function readTwimlRequestView(ctx: WebhookContext): TwimlRequestView {
const params = new URLSearchParams(ctx.rawBody);
const type = normalizeOptionalString(ctx.query?.type);
const callIdFromQuery = normalizeOptionalString(ctx.query?.callId);
return {
callStatus: params.get("CallStatus"),
direction: params.get("Direction"),
isStatusCallback: type === "status",
callSid: params.get("CallSid") || undefined,
callIdFromQuery,
};
}
export function decideTwimlResponse(input: TwimlPolicyInput): TwimlDecision {
if (input.callIdFromQuery && !input.isStatusCallback) {
if (input.hasStoredTwiml) {
return { kind: "stored", consumeStoredTwimlCallId: input.callIdFromQuery };
}
if (input.isNotifyCall) {
return { kind: "empty" };
}
if (isOutboundDirection(input.direction)) {
return input.canStream ? { kind: "stream" } : { kind: "pause" };
}
}
if (input.isStatusCallback) {
return { kind: "empty" };
}
if (input.direction === "inbound") {
if (input.hasActiveStreams) {
return { kind: "queue" };
}
if (input.canStream && input.callSid) {
return { kind: "stream", activateStreamCallSid: input.callSid };
}
return { kind: "pause" };
}
if (input.callStatus !== "in-progress") {
return { kind: "empty" };
}
return input.canStream ? { kind: "stream" } : { kind: "pause" };
}

View file

@ -0,0 +1,34 @@
import type { WebhookContext, WebhookVerificationResult } from "../../types.js";
import { verifyTwilioWebhook } from "../../webhook-security.js";
import type { TwilioProviderOptions } from "../twilio.types.js";
export function verifyTwilioProviderWebhook(params: {
ctx: WebhookContext;
authToken: string;
currentPublicUrl?: string | null;
options: TwilioProviderOptions;
}): WebhookVerificationResult {
const result = verifyTwilioWebhook(params.ctx, params.authToken, {
publicUrl: params.currentPublicUrl || undefined,
allowNgrokFreeTierLoopbackBypass: params.options.allowNgrokFreeTierLoopbackBypass ?? false,
skipVerification: params.options.skipVerification,
allowedHosts: params.options.webhookSecurity?.allowedHosts,
trustForwardingHeaders: params.options.webhookSecurity?.trustForwardingHeaders,
trustedProxyIPs: params.options.webhookSecurity?.trustedProxyIPs,
remoteIP: params.ctx.remoteAddress,
});
if (!result.ok) {
console.warn(`[twilio] Webhook verification failed: ${result.reason}`);
if (result.verificationUrl) {
console.warn(`[twilio] Verification URL: ${result.verificationUrl}`);
}
}
return {
ok: result.ok,
reason: result.reason,
isReplay: result.isReplay,
verifiedRequestKey: result.verifiedRequestKey,
};
}

View file

@ -0,0 +1,4 @@
export {
getRealtimeTranscriptionProvider,
listRealtimeTranscriptionProviders,
} from "openclaw/plugin-sdk/realtime-transcription";

View file

@ -0,0 +1,4 @@
export {
getRealtimeVoiceProvider,
listRealtimeVoiceProviders,
} from "openclaw/plugin-sdk/realtime-voice";

View file

@ -0,0 +1,129 @@
import { describe, expect, it, vi } from "vitest";
import { VoiceCallConfigSchema } from "./config.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
import { generateVoiceResponse } from "./response-generator.js";
function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
const runEmbeddedPiAgent = vi.fn(async () => ({
payloads,
meta: { durationMs: 12, aborted: false },
}));
const runtime = {
defaults: {
provider: "together",
model: "Qwen/Qwen2.5-7B-Instruct-Turbo",
},
resolveAgentDir: () => "/tmp/openclaw/agents/main",
resolveAgentWorkspaceDir: () => "/tmp/openclaw/workspace/main",
resolveAgentIdentity: () => ({ name: "tester" }),
resolveThinkingDefault: () => "off",
resolveAgentTimeoutMs: () => 30_000,
ensureAgentWorkspace: async () => {},
runEmbeddedPiAgent,
session: {
resolveStorePath: () => "/tmp/openclaw/sessions.json",
loadSessionStore: () => ({}),
saveSessionStore: async () => {},
resolveSessionFilePath: () => "/tmp/openclaw/sessions/session.jsonl",
},
} as unknown as CoreAgentDeps;
return { runtime, runEmbeddedPiAgent };
}
function requireEmbeddedAgentArgs(runEmbeddedPiAgent: ReturnType<typeof vi.fn>) {
const calls = runEmbeddedPiAgent.mock.calls as unknown[][];
const firstCall = calls[0];
if (!firstCall) {
throw new Error("voice response generator did not invoke the embedded agent");
}
const args = firstCall[0] as
| {
extraSystemPrompt?: string;
provider?: string;
model?: string;
}
| undefined;
if (!args?.extraSystemPrompt) {
throw new Error("voice response generator did not pass the spoken-output contract prompt");
}
return args;
}
async function runGenerateVoiceResponse(
payloads: Array<Record<string, unknown>>,
overrides?: {
runtime?: CoreAgentDeps;
transcript?: Array<{ speaker: "user" | "bot"; text: string }>;
},
) {
const voiceConfig = VoiceCallConfigSchema.parse({
responseTimeoutMs: 5000,
});
const coreConfig = {} as CoreConfig;
const runtime = overrides?.runtime ?? createAgentRuntime(payloads).runtime;
const result = await generateVoiceResponse({
voiceConfig,
coreConfig,
agentRuntime: runtime,
callId: "call-123",
from: "+15550001111",
transcript: overrides?.transcript ?? [{ speaker: "user", text: "hello there" }],
userMessage: "hello there",
});
return { result };
}
describe("generateVoiceResponse", () => {
it("suppresses reasoning payloads and reads structured spoken output", async () => {
const { runtime, runEmbeddedPiAgent } = createAgentRuntime([
{ text: "Reasoning: hidden", isReasoning: true },
{ text: '{"spoken":"Hello from JSON."}' },
]);
const { result } = await runGenerateVoiceResponse([], { runtime });
expect(result.text).toBe("Hello from JSON.");
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
const args = requireEmbeddedAgentArgs(runEmbeddedPiAgent);
expect(args.extraSystemPrompt).toContain('{"spoken":"..."}');
expect(args.provider).toBe("together");
expect(args.model).toBe("Qwen/Qwen2.5-7B-Instruct-Turbo");
});
it("extracts spoken text from fenced JSON", async () => {
const { result } = await runGenerateVoiceResponse([
{ text: '```json\n{"spoken":"Fenced JSON works."}\n```' },
]);
expect(result.text).toBe("Fenced JSON works.");
});
it("returns silence for an explicit empty spoken contract response", async () => {
const { result } = await runGenerateVoiceResponse([{ text: '{"spoken":""}' }]);
expect(result.text).toBeNull();
});
it("strips leading planning text when model returns plain text", async () => {
const { result } = await runGenerateVoiceResponse([
{
text:
"The user responded with short text. I should keep the response concise.\n\n" +
"Sounds good. I can help with the next step whenever you are ready.",
},
]);
expect(result.text).toBe("Sounds good. I can help with the next step whenever you are ready.");
});
it("keeps plain conversational output when no JSON contract is followed", async () => {
const { result } = await runGenerateVoiceResponse([
{ text: "Absolutely. Tell me what you want to do next." },
]);
expect(result.text).toBe("Absolutely. Tell me what you want to do next.");
});
});

View file

@ -0,0 +1,279 @@
/**
* Voice call response generator - uses the embedded Pi agent for tool support.
* Routes voice responses through the same agent infrastructure as messaging.
*/
import crypto from "node:crypto";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { SessionEntry } from "../api.js";
import type { VoiceCallConfig } from "./config.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
import { resolveVoiceResponseModel } from "./response-model.js";
export type VoiceResponseParams = {
/** Voice call config */
voiceConfig: VoiceCallConfig;
/** Core OpenClaw config */
coreConfig: CoreConfig;
/** Injected host agent runtime */
agentRuntime: CoreAgentDeps;
/** Call ID for session tracking */
callId: string;
/** Caller's phone number */
from: string;
/** Conversation transcript */
transcript: Array<{ speaker: "user" | "bot"; text: string }>;
/** Latest user message */
userMessage: string;
};
export type VoiceResponseResult = {
text: string | null;
error?: string;
};
type VoiceResponsePayload = {
text?: string;
isError?: boolean;
isReasoning?: boolean;
};
const VOICE_SPOKEN_OUTPUT_CONTRACT = [
"Output format requirements:",
'- Return only valid JSON in this exact shape: {"spoken":"..."}',
"- Do not include markdown, code fences, planning text, or extra keys.",
'- Put exactly what should be spoken to the caller into "spoken".',
'- If there is nothing to say, return {"spoken":""}.',
].join("\n");
function normalizeSpokenText(value: string): string | null {
const normalized = value.replace(/\s+/g, " ").trim();
return normalized.length > 0 ? normalized : null;
}
function tryParseSpokenJson(text: string): string | null {
const candidates: string[] = [];
const trimmed = text.trim();
if (!trimmed) {
return null;
}
candidates.push(trimmed);
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
if (fenced?.[1]) {
candidates.push(fenced[1]);
}
const firstBrace = trimmed.indexOf("{");
const lastBrace = trimmed.lastIndexOf("}");
if (firstBrace >= 0 && lastBrace > firstBrace) {
candidates.push(trimmed.slice(firstBrace, lastBrace + 1));
}
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate) as { spoken?: unknown };
if (typeof parsed?.spoken !== "string") {
continue;
}
return normalizeSpokenText(parsed.spoken) ?? "";
} catch {
// Continue trying other candidates.
}
}
const inlineSpokenMatch = trimmed.match(/"spoken"\s*:\s*"((?:[^"\\]|\\.)*)"/i);
if (!inlineSpokenMatch) {
return null;
}
try {
const decoded = JSON.parse(`"${inlineSpokenMatch[1] ?? ""}"`) as string;
return normalizeSpokenText(decoded) ?? "";
} catch {
return null;
}
}
function isLikelyMetaReasoningParagraph(paragraph: string): boolean {
const lower = normalizeLowercaseStringOrEmpty(paragraph);
if (!lower) {
return false;
}
if (lower.startsWith("thinking process")) {
return true;
}
if (lower.startsWith("reasoning:") || lower.startsWith("analysis:")) {
return true;
}
if (
lower.startsWith("the user ") &&
(lower.includes("i should") || lower.includes("i need to") || lower.includes("i will"))
) {
return true;
}
if (
lower.includes("this is a natural continuation of the conversation") ||
lower.includes("keep the conversation flowing")
) {
return true;
}
return false;
}
function sanitizePlainSpokenText(text: string): string | null {
const withoutCodeFences = text.replace(/```[\s\S]*?```/g, " ").trim();
if (!withoutCodeFences) {
return null;
}
const paragraphs = withoutCodeFences
.split(/\n\s*\n+/)
.map((paragraph) => paragraph.trim())
.filter(Boolean);
while (paragraphs.length > 1 && isLikelyMetaReasoningParagraph(paragraphs[0])) {
paragraphs.shift();
}
return normalizeSpokenText(paragraphs.join(" "));
}
function extractSpokenTextFromPayloads(payloads: VoiceResponsePayload[]): string | null {
const spokenSegments: string[] = [];
for (const payload of payloads) {
if (payload.isError || payload.isReasoning) {
continue;
}
const rawText = payload.text?.trim() ?? "";
if (!rawText) {
continue;
}
const structured = tryParseSpokenJson(rawText);
if (structured !== null) {
if (structured.length > 0) {
spokenSegments.push(structured);
}
continue;
}
const plain = sanitizePlainSpokenText(rawText);
if (plain) {
spokenSegments.push(plain);
}
}
return spokenSegments.length > 0 ? spokenSegments.join(" ").trim() : null;
}
/**
* Generate a voice response using the embedded Pi agent with full tool support.
* Uses the same agent infrastructure as messaging for consistent behavior.
*/
export async function generateVoiceResponse(
params: VoiceResponseParams,
): Promise<VoiceResponseResult> {
const { voiceConfig, callId, from, transcript, userMessage, coreConfig, agentRuntime } = params;
if (!coreConfig) {
return { text: null, error: "Core config unavailable for voice response" };
}
const cfg = coreConfig;
// Build voice-specific session key based on phone number
const normalizedPhone = from.replace(/\D/g, "");
const sessionKey = `voice:${normalizedPhone}`;
const agentId = "main";
// Resolve paths
const storePath = agentRuntime.session.resolveStorePath(cfg.session?.store, { agentId });
const agentDir = agentRuntime.resolveAgentDir(cfg, agentId);
const workspaceDir = agentRuntime.resolveAgentWorkspaceDir(cfg, agentId);
// Ensure workspace exists
await agentRuntime.ensureAgentWorkspace({ dir: workspaceDir });
// Load or create session entry
const sessionStore = agentRuntime.session.loadSessionStore(storePath);
const now = Date.now();
let sessionEntry = sessionStore[sessionKey] as SessionEntry | undefined;
if (!sessionEntry) {
sessionEntry = {
sessionId: crypto.randomUUID(),
updatedAt: now,
};
sessionStore[sessionKey] = sessionEntry;
await agentRuntime.session.saveSessionStore(storePath, sessionStore);
}
const sessionId = sessionEntry.sessionId;
const sessionFile = agentRuntime.session.resolveSessionFilePath(sessionId, sessionEntry, {
agentId,
});
// Resolve model from config
const { provider, model } = resolveVoiceResponseModel({ voiceConfig, agentRuntime });
// Resolve thinking level
const thinkLevel = agentRuntime.resolveThinkingDefault({ cfg, provider, model });
// Resolve agent identity for personalized prompt
const identity = agentRuntime.resolveAgentIdentity(cfg, agentId);
const agentName = identity?.name?.trim() || "assistant";
// Build system prompt with conversation history
const basePrompt =
voiceConfig.responseSystemPrompt ??
`You are ${agentName}, a helpful voice assistant on a phone call. Keep responses brief and conversational (1-2 sentences max). Be natural and friendly. The caller's phone number is ${from}. You have access to tools - use them when helpful.`;
let extraSystemPrompt = basePrompt;
if (transcript.length > 0) {
const history = transcript
.map((entry) => `${entry.speaker === "bot" ? "You" : "Caller"}: ${entry.text}`)
.join("\n");
extraSystemPrompt = `${basePrompt}\n\nConversation so far:\n${history}`;
}
extraSystemPrompt = `${extraSystemPrompt}\n\n${VOICE_SPOKEN_OUTPUT_CONTRACT}`;
// Resolve timeout
const timeoutMs = voiceConfig.responseTimeoutMs ?? agentRuntime.resolveAgentTimeoutMs({ cfg });
const runId = `voice:${callId}:${Date.now()}`;
try {
const result = await agentRuntime.runEmbeddedPiAgent({
sessionId,
sessionKey,
messageProvider: "voice",
sessionFile,
workspaceDir,
config: cfg,
prompt: userMessage,
provider,
model,
thinkLevel,
verboseLevel: "off",
timeoutMs,
runId,
lane: "voice",
extraSystemPrompt,
agentDir,
});
const text = extractSpokenTextFromPayloads((result.payloads ?? []) as VoiceResponsePayload[]);
if (!text && result.meta?.aborted) {
return { text: null, error: "Response generation was aborted" };
}
return { text };
} catch (err) {
console.error(`[voice-call] Response generation failed:`, err);
return { text: null, error: String(err) };
}
}

View file

@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { VoiceCallConfigSchema } from "./config.js";
import type { CoreAgentDeps } from "./core-bridge.js";
import { resolveVoiceResponseModel } from "./response-model.js";
const agentRuntime = {
defaults: {
provider: "together",
model: "Qwen/Qwen2.5-7B-Instruct-Turbo",
},
} as unknown as CoreAgentDeps;
describe("resolveVoiceResponseModel", () => {
it("falls back to the runtime default model", () => {
expect(
resolveVoiceResponseModel({
voiceConfig: VoiceCallConfigSchema.parse({}),
agentRuntime,
}),
).toEqual({
modelRef: "together/Qwen/Qwen2.5-7B-Instruct-Turbo",
provider: "together",
model: "Qwen/Qwen2.5-7B-Instruct-Turbo",
});
});
it("uses an explicit provider/model ref", () => {
expect(
resolveVoiceResponseModel({
voiceConfig: VoiceCallConfigSchema.parse({
responseModel: "openai/gpt-5.4-mini",
}),
agentRuntime,
}),
).toEqual({
modelRef: "openai/gpt-5.4-mini",
provider: "openai",
model: "gpt-5.4-mini",
});
});
it("uses the runtime default provider for bare model overrides", () => {
expect(
resolveVoiceResponseModel({
voiceConfig: VoiceCallConfigSchema.parse({
responseModel: "meta-llama/Llama-4-Scout-17B-16E-Instruct",
}),
agentRuntime,
}),
).toEqual({
modelRef: "meta-llama/Llama-4-Scout-17B-16E-Instruct",
provider: "meta-llama",
model: "Llama-4-Scout-17B-16E-Instruct",
});
});
it("keeps legacy single-segment overrides on the runtime default provider", () => {
expect(
resolveVoiceResponseModel({
voiceConfig: VoiceCallConfigSchema.parse({
responseModel: "gpt-5.4-mini",
}),
agentRuntime,
}),
).toEqual({
modelRef: "gpt-5.4-mini",
provider: "together",
model: "gpt-5.4-mini",
});
});
});

View file

@ -0,0 +1,23 @@
import type { VoiceCallConfig } from "./config.js";
import type { CoreAgentDeps } from "./core-bridge.js";
export function resolveVoiceResponseModel(params: {
voiceConfig: VoiceCallConfig;
agentRuntime: CoreAgentDeps;
}): {
modelRef: string;
provider: string;
model: string;
} {
const modelRef =
params.voiceConfig.responseModel ??
`${params.agentRuntime.defaults.provider}/${params.agentRuntime.defaults.model}`;
const slashIndex = modelRef.indexOf("/");
return {
modelRef,
provider:
slashIndex === -1 ? params.agentRuntime.defaults.provider : modelRef.slice(0, slashIndex),
model: slashIndex === -1 ? modelRef : modelRef.slice(slashIndex + 1),
};
}

View file

@ -0,0 +1,136 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { VoiceCallConfig } from "./config.js";
import type { CoreConfig } from "./core-bridge.js";
import { createVoiceCallBaseConfig } from "./test-fixtures.js";
const mocks = vi.hoisted(() => ({
resolveVoiceCallConfig: vi.fn(),
validateProviderConfig: vi.fn(),
managerInitialize: vi.fn(),
webhookStart: vi.fn(),
webhookStop: vi.fn(),
webhookGetMediaStreamHandler: vi.fn(),
webhookCtorArgs: [] as unknown[][],
startTunnel: vi.fn(),
setupTailscaleExposure: vi.fn(),
cleanupTailscaleExposure: vi.fn(),
}));
vi.mock("./config.js", () => ({
resolveVoiceCallConfig: mocks.resolveVoiceCallConfig,
validateProviderConfig: mocks.validateProviderConfig,
}));
vi.mock("./manager.js", () => ({
CallManager: class {
initialize = mocks.managerInitialize;
},
}));
vi.mock("./webhook.js", () => ({
VoiceCallWebhookServer: class {
constructor(...args: unknown[]) {
mocks.webhookCtorArgs.push(args);
}
start = mocks.webhookStart;
stop = mocks.webhookStop;
getMediaStreamHandler = mocks.webhookGetMediaStreamHandler;
},
}));
vi.mock("./tunnel.js", () => ({
startTunnel: mocks.startTunnel,
}));
vi.mock("./webhook/tailscale.js", () => ({
setupTailscaleExposure: mocks.setupTailscaleExposure,
cleanupTailscaleExposure: mocks.cleanupTailscaleExposure,
}));
import { createVoiceCallRuntime } from "./runtime.js";
function createBaseConfig(): VoiceCallConfig {
return createVoiceCallBaseConfig({ tunnelProvider: "ngrok" });
}
describe("createVoiceCallRuntime lifecycle", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveVoiceCallConfig.mockImplementation((cfg: VoiceCallConfig) => cfg);
mocks.validateProviderConfig.mockReturnValue({ valid: true, errors: [] });
mocks.managerInitialize.mockResolvedValue(undefined);
mocks.webhookStart.mockResolvedValue("http://127.0.0.1:3334/voice/webhook");
mocks.webhookStop.mockResolvedValue(undefined);
mocks.webhookGetMediaStreamHandler.mockReturnValue(undefined);
mocks.webhookCtorArgs.length = 0;
mocks.startTunnel.mockResolvedValue(null);
mocks.setupTailscaleExposure.mockResolvedValue(null);
mocks.cleanupTailscaleExposure.mockResolvedValue(undefined);
});
it("cleans up tunnel, tailscale, and webhook server when init fails after start", async () => {
const tunnelStop = vi.fn().mockResolvedValue(undefined);
mocks.startTunnel.mockResolvedValue({
publicUrl: "https://public.example/voice/webhook",
provider: "ngrok",
stop: tunnelStop,
});
mocks.managerInitialize.mockRejectedValue(new Error("init failed"));
await expect(
createVoiceCallRuntime({
config: createBaseConfig(),
coreConfig: {},
agentRuntime: {} as never,
}),
).rejects.toThrow("init failed");
expect(tunnelStop).toHaveBeenCalledTimes(1);
expect(mocks.cleanupTailscaleExposure).toHaveBeenCalledTimes(1);
expect(mocks.webhookStop).toHaveBeenCalledTimes(1);
});
it("returns an idempotent stop handler", async () => {
const tunnelStop = vi.fn().mockResolvedValue(undefined);
mocks.startTunnel.mockResolvedValue({
publicUrl: "https://public.example/voice/webhook",
provider: "ngrok",
stop: tunnelStop,
});
const runtime = await createVoiceCallRuntime({
config: createBaseConfig(),
coreConfig: {} as CoreConfig,
agentRuntime: {} as never,
});
await runtime.stop();
await runtime.stop();
expect(tunnelStop).toHaveBeenCalledTimes(1);
expect(mocks.cleanupTailscaleExposure).toHaveBeenCalledTimes(1);
expect(mocks.webhookStop).toHaveBeenCalledTimes(1);
});
it("passes fullConfig to the webhook server for streaming provider resolution", async () => {
const coreConfig = { messages: { tts: { provider: "openai" } } } as CoreConfig;
const fullConfig = {
plugins: {
entries: {
openai: { enabled: true },
},
},
} as OpenClawConfig;
await createVoiceCallRuntime({
config: createBaseConfig(),
coreConfig,
fullConfig,
agentRuntime: {} as never,
});
expect(mocks.webhookCtorArgs[0]?.[3]).toBe(coreConfig);
expect(mocks.webhookCtorArgs[0]?.[4]).toBe(fullConfig);
});
});

View file

@ -0,0 +1,391 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type {
RealtimeVoiceProviderConfig,
RealtimeVoiceProviderPlugin,
} from "openclaw/plugin-sdk/realtime-voice";
import type { VoiceCallConfig } from "./config.js";
import { resolveVoiceCallConfig, validateProviderConfig } from "./config.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
import { CallManager } from "./manager.js";
import { resolveConfiguredCapabilityProvider } from "./provider-runtime-resolution.js";
import type { VoiceCallProvider } from "./providers/base.js";
import type { TwilioProvider } from "./providers/twilio.js";
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
import { createTelephonyTtsProvider } from "./telephony-tts.js";
import { startTunnel, type TunnelResult } from "./tunnel.js";
import { VoiceCallWebhookServer } from "./webhook.js";
import { cleanupTailscaleExposure, setupTailscaleExposure } from "./webhook/tailscale.js";
export type VoiceCallRuntime = {
config: VoiceCallConfig;
provider: VoiceCallProvider;
manager: CallManager;
webhookServer: VoiceCallWebhookServer;
webhookUrl: string;
publicUrl: string | null;
stop: () => Promise<void>;
};
type Logger = {
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
debug?: (message: string) => void;
};
type ResolvedRealtimeProvider = {
provider: RealtimeVoiceProviderPlugin;
providerConfig: RealtimeVoiceProviderConfig;
};
type TelnyxProviderModule = typeof import("./providers/telnyx.js");
type TwilioProviderModule = typeof import("./providers/twilio.js");
type PlivoProviderModule = typeof import("./providers/plivo.js");
type MockProviderModule = typeof import("./providers/mock.js");
type RealtimeVoiceRuntimeModule = typeof import("./realtime-voice.runtime.js");
type RealtimeHandlerModule = typeof import("./webhook/realtime-handler.js");
let telnyxProviderPromise: Promise<TelnyxProviderModule> | undefined;
let twilioProviderPromise: Promise<TwilioProviderModule> | undefined;
let plivoProviderPromise: Promise<PlivoProviderModule> | undefined;
let mockProviderPromise: Promise<MockProviderModule> | undefined;
let realtimeVoiceRuntimePromise: Promise<RealtimeVoiceRuntimeModule> | undefined;
let realtimeHandlerPromise: Promise<RealtimeHandlerModule> | undefined;
function loadTelnyxProvider(): Promise<TelnyxProviderModule> {
telnyxProviderPromise ??= import("./providers/telnyx.js");
return telnyxProviderPromise;
}
function loadTwilioProvider(): Promise<TwilioProviderModule> {
twilioProviderPromise ??= import("./providers/twilio.js");
return twilioProviderPromise;
}
function loadPlivoProvider(): Promise<PlivoProviderModule> {
plivoProviderPromise ??= import("./providers/plivo.js");
return plivoProviderPromise;
}
function loadMockProvider(): Promise<MockProviderModule> {
mockProviderPromise ??= import("./providers/mock.js");
return mockProviderPromise;
}
function loadRealtimeVoiceRuntime(): Promise<RealtimeVoiceRuntimeModule> {
realtimeVoiceRuntimePromise ??= import("./realtime-voice.runtime.js");
return realtimeVoiceRuntimePromise;
}
function loadRealtimeHandler(): Promise<RealtimeHandlerModule> {
realtimeHandlerPromise ??= import("./webhook/realtime-handler.js");
return realtimeHandlerPromise;
}
function createRuntimeResourceLifecycle(params: {
config: VoiceCallConfig;
webhookServer: VoiceCallWebhookServer;
}): {
setTunnelResult: (result: TunnelResult | null) => void;
stop: (opts?: { suppressErrors?: boolean }) => Promise<void>;
} {
let tunnelResult: TunnelResult | null = null;
let stopped = false;
const runStep = async (step: () => Promise<void>, suppressErrors: boolean) => {
if (suppressErrors) {
await step().catch(() => {});
return;
}
await step();
};
return {
setTunnelResult: (result) => {
tunnelResult = result;
},
stop: async (opts) => {
if (stopped) {
return;
}
stopped = true;
const suppressErrors = opts?.suppressErrors ?? false;
await runStep(async () => {
if (tunnelResult) {
await tunnelResult.stop();
}
}, suppressErrors);
await runStep(async () => {
await cleanupTailscaleExposure(params.config);
}, suppressErrors);
await runStep(async () => {
await params.webhookServer.stop();
}, suppressErrors);
},
};
}
function isLoopbackBind(bind: string | undefined): boolean {
if (!bind) {
return false;
}
return bind === "127.0.0.1" || bind === "::1" || bind === "localhost";
}
async function resolveProvider(config: VoiceCallConfig): Promise<VoiceCallProvider> {
const allowNgrokFreeTierLoopbackBypass =
config.tunnel?.provider === "ngrok" &&
isLoopbackBind(config.serve?.bind) &&
(config.tunnel?.allowNgrokFreeTierLoopbackBypass ?? false);
switch (config.provider) {
case "telnyx": {
const { TelnyxProvider } = await loadTelnyxProvider();
return new TelnyxProvider(
{
apiKey: config.telnyx?.apiKey,
connectionId: config.telnyx?.connectionId,
publicKey: config.telnyx?.publicKey,
},
{
skipVerification: config.skipSignatureVerification,
},
);
}
case "twilio": {
const { TwilioProvider } = await loadTwilioProvider();
return new TwilioProvider(
{
accountSid: config.twilio?.accountSid,
authToken: config.twilio?.authToken,
},
{
allowNgrokFreeTierLoopbackBypass,
publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification,
streamPath: config.streaming?.enabled ? config.streaming.streamPath : undefined,
webhookSecurity: config.webhookSecurity,
},
);
}
case "plivo": {
const { PlivoProvider } = await loadPlivoProvider();
return new PlivoProvider(
{
authId: config.plivo?.authId,
authToken: config.plivo?.authToken,
},
{
publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification,
ringTimeoutSec: Math.max(1, Math.floor(config.ringTimeoutMs / 1000)),
webhookSecurity: config.webhookSecurity,
},
);
}
case "mock": {
const { MockProvider } = await loadMockProvider();
return new MockProvider();
}
default:
throw new Error(`Unsupported voice-call provider: ${String(config.provider)}`);
}
}
async function resolveRealtimeProvider(params: {
config: VoiceCallConfig;
fullConfig: OpenClawConfig;
}): Promise<ResolvedRealtimeProvider> {
const { getRealtimeVoiceProvider, listRealtimeVoiceProviders } = await loadRealtimeVoiceRuntime();
const resolution = resolveConfiguredCapabilityProvider({
configuredProviderId: params.config.realtime.provider,
providerConfigs: params.config.realtime.providers,
cfg: params.fullConfig,
cfgForResolve: params.fullConfig,
getConfiguredProvider: (providerId) => getRealtimeVoiceProvider(providerId, params.fullConfig),
listProviders: () => listRealtimeVoiceProviders(params.fullConfig),
resolveProviderConfig: ({ provider, cfg, rawConfig }) =>
provider.resolveConfig?.({ cfg, rawConfig }) ?? rawConfig,
isProviderConfigured: ({ provider, cfg, providerConfig }) =>
provider.isConfigured({ cfg, providerConfig }),
});
if (!resolution.ok && resolution.code === "missing-configured-provider") {
throw new Error(
`Realtime voice provider "${resolution.configuredProviderId}" is not registered`,
);
}
if (!resolution.ok && resolution.code === "no-registered-provider") {
throw new Error("No realtime voice provider registered");
}
if (!resolution.ok) {
throw new Error(`Realtime voice provider "${resolution.provider?.id}" is not configured`);
}
const provider = resolution.provider;
return {
provider,
providerConfig: resolution.providerConfig,
};
}
export async function createVoiceCallRuntime(params: {
config: VoiceCallConfig;
coreConfig: CoreConfig;
fullConfig?: OpenClawConfig;
agentRuntime: CoreAgentDeps;
ttsRuntime?: TelephonyTtsRuntime;
logger?: Logger;
}): Promise<VoiceCallRuntime> {
const { config: rawConfig, coreConfig, fullConfig, agentRuntime, ttsRuntime, logger } = params;
const log = logger ?? {
info: console.log,
warn: console.warn,
error: console.error,
debug: console.debug,
};
const config = resolveVoiceCallConfig(rawConfig);
if (!config.enabled) {
throw new Error("Voice call disabled. Enable the plugin entry in config.");
}
if (config.skipSignatureVerification) {
log.warn(
"[voice-call] SECURITY WARNING: skipSignatureVerification=true disables webhook signature verification (development only). Do not use in production.",
);
}
const validation = validateProviderConfig(config);
if (!validation.valid) {
throw new Error(`Invalid voice-call config: ${validation.errors.join("; ")}`);
}
const provider = await resolveProvider(config);
const manager = new CallManager(config);
const realtimeProvider = config.realtime.enabled
? await resolveRealtimeProvider({
config,
fullConfig: fullConfig ?? (coreConfig as OpenClawConfig),
})
: null;
const webhookServer = new VoiceCallWebhookServer(
config,
manager,
provider,
coreConfig,
fullConfig ?? (coreConfig as OpenClawConfig),
agentRuntime,
);
if (realtimeProvider) {
const { RealtimeCallHandler } = await loadRealtimeHandler();
webhookServer.setRealtimeHandler(
new RealtimeCallHandler(
config.realtime,
manager,
provider,
realtimeProvider.provider,
realtimeProvider.providerConfig,
config.serve.path,
),
);
}
const lifecycle = createRuntimeResourceLifecycle({ config, webhookServer });
const localUrl = await webhookServer.start();
// Wrap remaining initialization in try/catch so the webhook server is
// properly stopped if any subsequent step fails. Without this, the server
// keeps the port bound while the runtime promise rejects, causing
// EADDRINUSE on the next attempt. See: #32387
try {
// Determine public URL - priority: config.publicUrl > tunnel > legacy tailscale
let publicUrl: string | null = config.publicUrl ?? null;
if (!publicUrl && config.tunnel?.provider && config.tunnel.provider !== "none") {
try {
const nextTunnelResult = await startTunnel({
provider: config.tunnel.provider,
port: config.serve.port,
path: config.serve.path,
ngrokAuthToken: config.tunnel.ngrokAuthToken,
ngrokDomain: config.tunnel.ngrokDomain,
});
lifecycle.setTunnelResult(nextTunnelResult);
publicUrl = nextTunnelResult?.publicUrl ?? null;
} catch (err) {
log.error(`[voice-call] Tunnel setup failed: ${formatErrorMessage(err)}`);
}
}
if (!publicUrl && config.tailscale?.mode !== "off") {
publicUrl = await setupTailscaleExposure(config);
}
const webhookUrl = publicUrl ?? localUrl;
if (publicUrl && provider.name === "twilio") {
(provider as TwilioProvider).setPublicUrl(publicUrl);
}
if (publicUrl && realtimeProvider) {
webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
}
if (provider.name === "twilio" && config.streaming?.enabled) {
const twilioProvider = provider as TwilioProvider;
if (ttsRuntime?.textToSpeechTelephony) {
try {
const ttsProvider = createTelephonyTtsProvider({
coreConfig,
ttsOverride: config.tts,
runtime: ttsRuntime,
logger: log,
});
twilioProvider.setTTSProvider(ttsProvider);
log.info("[voice-call] Telephony TTS provider configured");
} catch (err) {
log.warn(`[voice-call] Failed to initialize telephony TTS: ${formatErrorMessage(err)}`);
}
} else {
log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled");
}
const mediaHandler = webhookServer.getMediaStreamHandler();
if (mediaHandler) {
twilioProvider.setMediaStreamHandler(mediaHandler);
log.info("[voice-call] Media stream handler wired to provider");
}
}
if (realtimeProvider) {
log.info(`[voice-call] Realtime voice provider: ${realtimeProvider.provider.id}`);
}
await manager.initialize(provider, webhookUrl);
const stop = async () => await lifecycle.stop();
log.info("[voice-call] Runtime initialized");
log.info(`[voice-call] Webhook URL: ${webhookUrl}`);
if (publicUrl) {
log.info(`[voice-call] Public URL: ${publicUrl}`);
}
return {
config,
provider,
manager,
webhookServer,
webhookUrl,
publicUrl,
stop,
};
} catch (err) {
// If any step after the server started fails, clean up every provisioned
// resource (tunnel, tailscale exposure, and webhook server) so retries
// don't leak processes or keep the port bound.
await lifecycle.stop({ suppressErrors: true });
throw err;
}
}

View file

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { convertPcmToMulaw8k, resamplePcmTo8k } from "./telephony-audio.js";
function makeSinePcm(
sampleRate: number,
frequencyHz: number,
durationSeconds: number,
amplitude = 12_000,
): Buffer {
const samples = Math.floor(sampleRate * durationSeconds);
const output = Buffer.alloc(samples * 2);
for (let i = 0; i < samples; i++) {
const value = Math.round(Math.sin((2 * Math.PI * frequencyHz * i) / sampleRate) * amplitude);
output.writeInt16LE(value, i * 2);
}
return output;
}
function rmsPcm(buffer: Buffer): number {
const samples = Math.floor(buffer.length / 2);
if (samples === 0) {
return 0;
}
let sum = 0;
for (let i = 0; i < samples; i++) {
const sample = buffer.readInt16LE(i * 2);
sum += sample * sample;
}
return Math.sqrt(sum / samples);
}
describe("telephony-audio resamplePcmTo8k", () => {
it("returns identical buffer for 8k input", () => {
const pcm8k = makeSinePcm(8_000, 1_000, 0.2);
const resampled = resamplePcmTo8k(pcm8k, 8_000);
expect(resampled).toBe(pcm8k);
});
it("preserves low-frequency speech-band energy when downsampling", () => {
const input = makeSinePcm(48_000, 1_000, 0.6);
const output = resamplePcmTo8k(input, 48_000);
expect(output.length).toBe(9_600);
expect(rmsPcm(output)).toBeGreaterThan(7_500);
});
it("attenuates out-of-band high frequencies before 8k telephony conversion", () => {
const lowTone = resamplePcmTo8k(makeSinePcm(48_000, 1_000, 0.6), 48_000);
const highTone = resamplePcmTo8k(makeSinePcm(48_000, 6_000, 0.6), 48_000);
const ratio = rmsPcm(highTone) / rmsPcm(lowTone);
expect(ratio).toBeLessThan(0.1);
});
});
describe("telephony-audio convertPcmToMulaw8k", () => {
it("converts to 8k mu-law frame length", () => {
const input = makeSinePcm(24_000, 1_000, 0.5);
const mulaw = convertPcmToMulaw8k(input, 24_000);
// 0.5s @ 8kHz => 4000 8-bit samples
expect(mulaw.length).toBe(4_000);
});
});

View file

@ -0,0 +1,135 @@
const TELEPHONY_SAMPLE_RATE = 8000;
const RESAMPLE_FILTER_TAPS = 31;
const RESAMPLE_CUTOFF_GUARD = 0.94;
function clamp16(value: number): number {
return Math.max(-32768, Math.min(32767, value));
}
function sinc(x: number): number {
if (x === 0) {
return 1;
}
return Math.sin(Math.PI * x) / (Math.PI * x);
}
/**
* Build a finite low-pass kernel centered on `srcPos`.
* The kernel is windowed (Hann) to reduce ringing artifacts.
*/
function sampleBandlimited(
input: Buffer,
inputSamples: number,
srcPos: number,
cutoffCyclesPerSample: number,
): number {
const half = Math.floor(RESAMPLE_FILTER_TAPS / 2);
const center = Math.floor(srcPos);
let weighted = 0;
let weightSum = 0;
for (let tap = -half; tap <= half; tap++) {
const sampleIndex = center + tap;
if (sampleIndex < 0 || sampleIndex >= inputSamples) {
continue;
}
const distance = sampleIndex - srcPos;
const lowPass = 2 * cutoffCyclesPerSample * sinc(2 * cutoffCyclesPerSample * distance);
const tapIndex = tap + half;
const window = 0.5 - 0.5 * Math.cos((2 * Math.PI * tapIndex) / (RESAMPLE_FILTER_TAPS - 1));
const coeff = lowPass * window;
weighted += input.readInt16LE(sampleIndex * 2) * coeff;
weightSum += coeff;
}
if (weightSum === 0) {
const nearest = Math.max(0, Math.min(inputSamples - 1, Math.round(srcPos)));
return input.readInt16LE(nearest * 2);
}
return weighted / weightSum;
}
/**
* Resample 16-bit PCM (little-endian mono) to 8kHz using a windowed low-pass kernel.
*/
export function resamplePcmTo8k(input: Buffer, inputSampleRate: number): Buffer {
if (inputSampleRate === TELEPHONY_SAMPLE_RATE) {
return input;
}
const inputSamples = Math.floor(input.length / 2);
if (inputSamples === 0) {
return Buffer.alloc(0);
}
const ratio = inputSampleRate / TELEPHONY_SAMPLE_RATE;
const outputSamples = Math.floor(inputSamples / ratio);
const output = Buffer.alloc(outputSamples * 2);
const maxCutoff = 0.5;
const downsampleCutoff = ratio > 1 ? maxCutoff / ratio : maxCutoff;
const cutoffCyclesPerSample = Math.max(0.01, downsampleCutoff * RESAMPLE_CUTOFF_GUARD);
for (let i = 0; i < outputSamples; i++) {
const srcPos = i * ratio;
const sample = Math.round(
sampleBandlimited(input, inputSamples, srcPos, cutoffCyclesPerSample),
);
output.writeInt16LE(clamp16(sample), i * 2);
}
return output;
}
/**
* Convert 16-bit PCM to 8-bit mu-law (G.711).
*/
export function pcmToMulaw(pcm: Buffer): Buffer {
const samples = Math.floor(pcm.length / 2);
const mulaw = Buffer.alloc(samples);
for (let i = 0; i < samples; i++) {
const sample = pcm.readInt16LE(i * 2);
mulaw[i] = linearToMulaw(sample);
}
return mulaw;
}
export function convertPcmToMulaw8k(pcm: Buffer, inputSampleRate: number): Buffer {
const pcm8k = resamplePcmTo8k(pcm, inputSampleRate);
return pcmToMulaw(pcm8k);
}
/**
* Chunk audio buffer into 20ms frames for streaming (8kHz mono mu-law).
*/
export function chunkAudio(audio: Buffer, chunkSize = 160): Generator<Buffer, void, unknown> {
return (function* () {
for (let i = 0; i < audio.length; i += chunkSize) {
yield audio.subarray(i, Math.min(i + chunkSize, audio.length));
}
})();
}
function linearToMulaw(sample: number): number {
const BIAS = 132;
const CLIP = 32635;
const sign = sample < 0 ? 0x80 : 0;
if (sample < 0) {
sample = -sample;
}
if (sample > CLIP) {
sample = CLIP;
}
sample += BIAS;
let exponent = 7;
for (let expMask = 0x4000; (sample & expMask) === 0 && exponent > 0; exponent--) {
expMask >>= 1;
}
const mantissa = (sample >> (exponent + 3)) & 0x0f;
return ~(sign | (exponent << 4) | mantissa) & 0xff;
}

View file

@ -0,0 +1,119 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { VoiceCallTtsConfig } from "./config.js";
import type { CoreConfig } from "./core-bridge.js";
import { createTelephonyTtsProvider } from "./telephony-tts.js";
function createCoreConfig(): CoreConfig {
const tts: VoiceCallTtsConfig = {
provider: "openai",
providers: {
openai: {
model: "gpt-4o-mini-tts",
voice: "alloy",
},
},
};
return { messages: { tts } };
}
function requireMergedTtsConfig(mergedConfig: CoreConfig | undefined) {
const tts = mergedConfig?.messages?.tts;
if (!tts) {
throw new Error("telephony TTS runtime did not receive merged TTS config");
}
return tts as Record<string, unknown>;
}
function requireOpenAIProviderConfig(tts: Record<string, unknown>): Record<string, unknown> {
const providers =
tts.providers && typeof tts.providers === "object"
? (tts.providers as Record<string, unknown>)
: null;
const openai = providers?.openai;
if (!openai || typeof openai !== "object") {
throw new Error("merged TTS config did not preserve providers.openai");
}
return openai as Record<string, unknown>;
}
async function mergeOverride(override: unknown): Promise<Record<string, unknown>> {
let mergedConfig: CoreConfig | undefined;
const provider = createTelephonyTtsProvider({
coreConfig: createCoreConfig(),
ttsOverride: override as VoiceCallTtsConfig,
runtime: {
textToSpeechTelephony: async ({ cfg }) => {
mergedConfig = cfg;
return {
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
};
},
},
});
await provider.synthesizeForTelephony("hello");
return requireMergedTtsConfig(mergedConfig);
}
afterEach(() => {
delete (Object.prototype as Record<string, unknown>).polluted;
});
describe("createTelephonyTtsProvider deepMerge hardening", () => {
it("merges safe nested overrides", async () => {
const tts = await mergeOverride({
providers: { openai: { voice: "coral" } },
});
const openai = requireOpenAIProviderConfig(tts);
expect(openai.voice).toBe("coral");
expect(openai.model).toBe("gpt-4o-mini-tts");
});
it("blocks top-level __proto__ keys", async () => {
const tts = await mergeOverride(
JSON.parse('{"__proto__":{"polluted":"top"},"providers":{"openai":{"voice":"coral"}}}'),
);
const openai = requireOpenAIProviderConfig(tts);
expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
expect(tts.polluted).toBeUndefined();
expect(openai.voice).toBe("coral");
});
it("blocks nested __proto__ keys", async () => {
const tts = await mergeOverride(
JSON.parse('{"providers":{"openai":{"model":"safe","__proto__":{"polluted":"nested"}}}}'),
);
const openai = requireOpenAIProviderConfig(tts);
expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
expect(openai.polluted).toBeUndefined();
expect(openai.model).toBe("safe");
});
it("logs fallback metadata when telephony TTS uses a fallback provider", async () => {
const warn = vi.fn();
const provider = createTelephonyTtsProvider({
coreConfig: createCoreConfig(),
runtime: {
textToSpeechTelephony: async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
provider: "microsoft",
fallbackFrom: "elevenlabs",
attemptedProviders: ["elevenlabs", "microsoft"],
}),
},
logger: { warn },
});
await provider.synthesizeForTelephony("hello");
expect(warn).toHaveBeenCalledWith(
"[voice-call] Telephony TTS fallback used from=elevenlabs to=microsoft attempts=elevenlabs -> microsoft",
);
});
});

View file

@ -0,0 +1,97 @@
import type { VoiceCallTtsConfig } from "./config.js";
import type { CoreConfig } from "./core-bridge.js";
import { deepMergeDefined } from "./deep-merge.js";
import { convertPcmToMulaw8k } from "./telephony-audio.js";
export type TelephonyTtsRuntime = {
textToSpeechTelephony: (params: {
text: string;
cfg: CoreConfig;
prefsPath?: string;
}) => Promise<{
success: boolean;
audioBuffer?: Buffer;
sampleRate?: number;
provider?: string;
fallbackFrom?: string;
attemptedProviders?: string[];
error?: string;
}>;
};
export type TelephonyTtsProvider = {
synthesizeForTelephony: (text: string) => Promise<Buffer>;
};
export function createTelephonyTtsProvider(params: {
coreConfig: CoreConfig;
ttsOverride?: VoiceCallTtsConfig;
runtime: TelephonyTtsRuntime;
logger?: {
warn?: (message: string) => void;
};
}): TelephonyTtsProvider {
const { coreConfig, ttsOverride, runtime, logger } = params;
const mergedConfig = applyTtsOverride(coreConfig, ttsOverride);
return {
synthesizeForTelephony: async (text: string) => {
const result = await runtime.textToSpeechTelephony({
text,
cfg: mergedConfig,
});
if (!result.success || !result.audioBuffer || !result.sampleRate) {
throw new Error(result.error ?? "TTS conversion failed");
}
if (result.fallbackFrom && result.provider && result.fallbackFrom !== result.provider) {
const attemptedChain =
result.attemptedProviders && result.attemptedProviders.length > 0
? result.attemptedProviders.join(" -> ")
: `${result.fallbackFrom} -> ${result.provider}`;
logger?.warn?.(
`[voice-call] Telephony TTS fallback used from=${result.fallbackFrom} to=${result.provider} attempts=${attemptedChain}`,
);
}
return convertPcmToMulaw8k(result.audioBuffer, result.sampleRate);
},
};
}
function applyTtsOverride(coreConfig: CoreConfig, override?: VoiceCallTtsConfig): CoreConfig {
if (!override) {
return coreConfig;
}
const base = coreConfig.messages?.tts;
const merged = mergeTtsConfig(base, override);
if (!merged) {
return coreConfig;
}
return {
...coreConfig,
messages: {
...coreConfig.messages,
tts: merged,
},
};
}
function mergeTtsConfig(
base?: VoiceCallTtsConfig,
override?: VoiceCallTtsConfig,
): VoiceCallTtsConfig | undefined {
if (!base && !override) {
return undefined;
}
if (!override) {
return base;
}
if (!base) {
return override;
}
return deepMergeDefined(base, override) as VoiceCallTtsConfig;
}

View file

@ -0,0 +1,61 @@
import type { VoiceCallConfig } from "./config.js";
export function createVoiceCallBaseConfig(params?: {
provider?: "telnyx" | "twilio" | "plivo" | "mock";
tunnelProvider?: "none" | "ngrok";
}): VoiceCallConfig {
return {
enabled: true,
provider: params?.provider ?? "mock",
fromNumber: "+15550001234",
inboundPolicy: "disabled",
allowFrom: [],
outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
maxDurationSeconds: 300,
staleCallReaperSeconds: 600,
silenceTimeoutMs: 800,
transcriptTimeoutMs: 180000,
ringTimeoutMs: 30000,
maxConcurrentCalls: 1,
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
tailscale: { mode: "off", path: "/voice/webhook" },
tunnel: {
provider: params?.tunnelProvider ?? "none",
allowNgrokFreeTierLoopbackBypass: false,
},
webhookSecurity: {
allowedHosts: [],
trustForwardingHeaders: false,
trustedProxyIPs: [],
},
streaming: {
enabled: false,
providers: {
openai: {
model: "gpt-4o-transcribe",
silenceDurationMs: 800,
vadThreshold: 0.5,
},
},
streamPath: "/voice/stream",
preStartTimeoutMs: 5000,
maxPendingConnections: 32,
maxPendingConnectionsPerIp: 4,
maxConnections: 128,
},
realtime: {
enabled: false,
streamPath: "/voice/stream/realtime",
tools: [],
providers: {},
},
skipSignatureVerification: false,
tts: {
provider: "openai",
providers: {
openai: { model: "gpt-4o-mini-tts", voice: "coral" },
},
},
responseTimeoutMs: 30000,
};
}

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { resolvePreferredTtsVoice } from "./tts-provider-voice.js";
describe("resolvePreferredTtsVoice", () => {
it("returns provider voice when present", () => {
expect(
resolvePreferredTtsVoice({
tts: {
provider: "openai",
providers: {
openai: {
voice: "coral",
},
},
},
}),
).toBe("coral");
});
it("falls back to voiceId for providers that use that field", () => {
expect(
resolvePreferredTtsVoice({
tts: {
provider: "elevenlabs",
providers: {
elevenlabs: {
voiceId: "voice-123",
},
},
},
}),
).toBe("voice-123");
});
});

View file

@ -0,0 +1,21 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { VoiceCallTtsConfig } from "./config.js";
function resolveProviderVoiceSetting(providerConfig: unknown): string | undefined {
if (!providerConfig || typeof providerConfig !== "object") {
return undefined;
}
const candidate = providerConfig as {
voice?: unknown;
voiceId?: unknown;
};
return normalizeOptionalString(candidate.voice) ?? normalizeOptionalString(candidate.voiceId);
}
export function resolvePreferredTtsVoice(config: { tts?: VoiceCallTtsConfig }): string | undefined {
const providerId = config.tts?.provider;
if (!providerId) {
return undefined;
}
return resolveProviderVoiceSetting(config.tts?.providers?.[providerId]);
}

View file

@ -0,0 +1,314 @@
import { spawn } from "node:child_process";
import { getTailscaleDnsName } from "./webhook/tailscale.js";
/**
* Tunnel configuration for exposing the webhook server.
*/
export interface TunnelConfig {
/** Tunnel provider: ngrok, tailscale-serve, or tailscale-funnel */
provider: "ngrok" | "tailscale-serve" | "tailscale-funnel" | "none";
/** Local port to tunnel */
port: number;
/** Path prefix for the tunnel (e.g., /voice/webhook) */
path: string;
/** ngrok auth token (optional, enables longer sessions) */
ngrokAuthToken?: string;
/** ngrok custom domain (paid feature) */
ngrokDomain?: string;
}
/**
* Result of starting a tunnel.
*/
export interface TunnelResult {
/** The public URL */
publicUrl: string;
/** Function to stop the tunnel */
stop: () => Promise<void>;
/** Tunnel provider name */
provider: string;
}
/**
* Start an ngrok tunnel to expose the local webhook server.
*
* Uses the ngrok CLI which must be installed: https://ngrok.com/download
*
* @example
* const tunnel = await startNgrokTunnel({ port: 3334, path: '/voice/webhook' });
* console.log('Public URL:', tunnel.publicUrl);
* // Later: await tunnel.stop();
*/
export async function startNgrokTunnel(config: {
port: number;
path: string;
authToken?: string;
domain?: string;
}): Promise<TunnelResult> {
// Set auth token if provided
if (config.authToken) {
await runNgrokCommand(["config", "add-authtoken", config.authToken]);
}
// Build ngrok command args
const args = ["http", String(config.port), "--log", "stdout", "--log-format", "json"];
// Add custom domain if provided (paid ngrok feature)
if (config.domain) {
args.push("--domain", config.domain);
}
return new Promise((resolve, reject) => {
const proc = spawn("ngrok", args, {
stdio: ["ignore", "pipe", "pipe"],
});
let resolved = false;
let publicUrl: string | null = null;
let outputBuffer = "";
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
proc.kill("SIGTERM");
reject(new Error("ngrok startup timed out (30s)"));
}
}, 30000);
const processLine = (line: string) => {
try {
const log = JSON.parse(line);
// ngrok logs the public URL in a 'started tunnel' message
if (log.msg === "started tunnel" && log.url) {
publicUrl = log.url;
}
// Also check for the URL field directly
if (log.addr && log.url && !publicUrl) {
publicUrl = log.url;
}
// Check for ready state
if (publicUrl && !resolved) {
resolved = true;
clearTimeout(timeout);
// Add path to the public URL
const fullUrl = publicUrl + config.path;
console.log(`[voice-call] ngrok tunnel active: ${fullUrl}`);
resolve({
publicUrl: fullUrl,
provider: "ngrok",
stop: async () => {
proc.kill("SIGTERM");
await new Promise<void>((res) => {
proc.on("close", () => res());
setTimeout(res, 2000); // Fallback timeout
});
},
});
}
} catch {
// Not JSON, might be startup message
}
};
proc.stdout.on("data", (data: Buffer) => {
outputBuffer += data.toString();
const lines = outputBuffer.split("\n");
outputBuffer = lines.pop() || "";
for (const line of lines) {
if (line.trim()) {
processLine(line);
}
}
});
proc.stderr.on("data", (data: Buffer) => {
const msg = data.toString();
// Check for common errors
if (msg.includes("ERR_NGROK")) {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(new Error(`ngrok error: ${msg}`));
}
}
});
proc.on("error", (err) => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(new Error(`Failed to start ngrok: ${err.message}`));
}
});
proc.on("close", (code) => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(new Error(`ngrok exited unexpectedly with code ${code}`));
}
});
});
}
/**
* Run an ngrok command and wait for completion.
*/
async function runNgrokCommand(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn("ngrok", args, {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (data) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (code === 0) {
resolve(stdout);
} else {
reject(new Error(`ngrok command failed: ${stderr || stdout}`));
}
});
proc.on("error", reject);
});
}
/**
* Check if ngrok is installed and available.
*/
export async function isNgrokAvailable(): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawn("ngrok", ["version"], {
stdio: ["ignore", "pipe", "pipe"],
});
proc.on("close", (code) => {
resolve(code === 0);
});
proc.on("error", () => {
resolve(false);
});
});
}
/**
* Start a Tailscale serve/funnel tunnel.
*/
export async function startTailscaleTunnel(config: {
mode: "serve" | "funnel";
port: number;
path: string;
}): Promise<TunnelResult> {
// Get Tailscale DNS name
const dnsName = await getTailscaleDnsName();
if (!dnsName) {
throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
}
const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
const localUrl = `http://127.0.0.1:${config.port}${path}`;
return new Promise((resolve, reject) => {
const proc = spawn("tailscale", [config.mode, "--bg", "--yes", "--set-path", path, localUrl], {
stdio: ["ignore", "pipe", "pipe"],
});
const timeout = setTimeout(() => {
proc.kill("SIGKILL");
reject(new Error(`Tailscale ${config.mode} timed out`));
}, 10000);
proc.on("close", (code) => {
clearTimeout(timeout);
if (code === 0) {
const publicUrl = `https://${dnsName}${path}`;
console.log(`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`);
resolve({
publicUrl,
provider: `tailscale-${config.mode}`,
stop: async () => {
await stopTailscaleTunnel(config.mode, path);
},
});
} else {
reject(new Error(`Tailscale ${config.mode} failed with code ${code}`));
}
});
proc.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
});
}
/**
* Stop a Tailscale serve/funnel tunnel.
*/
async function stopTailscaleTunnel(mode: "serve" | "funnel", path: string): Promise<void> {
return new Promise((resolve) => {
const proc = spawn("tailscale", [mode, "off", path], {
stdio: "ignore",
});
const timeout = setTimeout(() => {
proc.kill("SIGKILL");
resolve();
}, 5000);
proc.on("close", () => {
clearTimeout(timeout);
resolve();
});
});
}
/**
* Start a tunnel based on configuration.
*/
export async function startTunnel(config: TunnelConfig): Promise<TunnelResult | null> {
switch (config.provider) {
case "ngrok":
return startNgrokTunnel({
port: config.port,
path: config.path,
authToken: config.ngrokAuthToken,
domain: config.ngrokDomain,
});
case "tailscale-serve":
return startTailscaleTunnel({
mode: "serve",
port: config.port,
path: config.path,
});
case "tailscale-funnel":
return startTailscaleTunnel({
mode: "funnel",
port: config.port,
path: config.path,
});
default:
return null;
}
}

View file

@ -0,0 +1,304 @@
import { z } from "openclaw/plugin-sdk/zod";
import type { CallMode } from "./config.js";
// -----------------------------------------------------------------------------
// Provider Identifiers
// -----------------------------------------------------------------------------
export const ProviderNameSchema = z.enum(["telnyx", "twilio", "plivo", "mock"]);
export type ProviderName = z.infer<typeof ProviderNameSchema>;
// -----------------------------------------------------------------------------
// Core Call Identifiers
// -----------------------------------------------------------------------------
/** Internal call identifier (UUID) */
export type CallId = string;
/** Provider-specific call identifier */
export type ProviderCallId = string;
// -----------------------------------------------------------------------------
// Call Lifecycle States
// -----------------------------------------------------------------------------
export const CallStateSchema = z.enum([
// Non-terminal states
"initiated",
"ringing",
"answered",
"active",
"speaking",
"listening",
// Terminal states
"completed",
"hangup-user",
"hangup-bot",
"timeout",
"error",
"failed",
"no-answer",
"busy",
"voicemail",
]);
export type CallState = z.infer<typeof CallStateSchema>;
export const TerminalStates = new Set<CallState>([
"completed",
"hangup-user",
"hangup-bot",
"timeout",
"error",
"failed",
"no-answer",
"busy",
"voicemail",
]);
export const EndReasonSchema = z.enum([
"completed",
"hangup-user",
"hangup-bot",
"timeout",
"error",
"failed",
"no-answer",
"busy",
"voicemail",
]);
export type EndReason = z.infer<typeof EndReasonSchema>;
// -----------------------------------------------------------------------------
// Normalized Call Events
// -----------------------------------------------------------------------------
const BaseEventSchema = z.object({
id: z.string(),
// Stable provider-derived key for idempotency/replay dedupe.
dedupeKey: z.string().optional(),
callId: z.string(),
providerCallId: z.string().optional(),
timestamp: z.number(),
// Optional per-turn nonce for speech events (Twilio <Gather> replay hardening).
turnToken: z.string().optional(),
// Optional fields for inbound call detection
direction: z.enum(["inbound", "outbound"]).optional(),
from: z.string().optional(),
to: z.string().optional(),
});
export const NormalizedEventSchema = z.discriminatedUnion("type", [
BaseEventSchema.extend({
type: z.literal("call.initiated"),
}),
BaseEventSchema.extend({
type: z.literal("call.ringing"),
}),
BaseEventSchema.extend({
type: z.literal("call.answered"),
}),
BaseEventSchema.extend({
type: z.literal("call.active"),
}),
BaseEventSchema.extend({
type: z.literal("call.speaking"),
text: z.string(),
}),
BaseEventSchema.extend({
type: z.literal("call.speech"),
transcript: z.string(),
isFinal: z.boolean(),
confidence: z.number().min(0).max(1).optional(),
}),
BaseEventSchema.extend({
type: z.literal("call.silence"),
durationMs: z.number(),
}),
BaseEventSchema.extend({
type: z.literal("call.dtmf"),
digits: z.string(),
}),
BaseEventSchema.extend({
type: z.literal("call.ended"),
reason: EndReasonSchema,
}),
BaseEventSchema.extend({
type: z.literal("call.error"),
error: z.string(),
retryable: z.boolean().optional(),
}),
]);
export type NormalizedEvent = z.infer<typeof NormalizedEventSchema>;
// -----------------------------------------------------------------------------
// Call Direction
// -----------------------------------------------------------------------------
export const CallDirectionSchema = z.enum(["outbound", "inbound"]);
export type CallDirection = z.infer<typeof CallDirectionSchema>;
// -----------------------------------------------------------------------------
// Call Record
// -----------------------------------------------------------------------------
export const TranscriptEntrySchema = z.object({
timestamp: z.number(),
speaker: z.enum(["bot", "user"]),
text: z.string(),
isFinal: z.boolean().default(true),
});
export type TranscriptEntry = z.infer<typeof TranscriptEntrySchema>;
export const CallRecordSchema = z.object({
callId: z.string(),
providerCallId: z.string().optional(),
provider: ProviderNameSchema,
direction: CallDirectionSchema,
state: CallStateSchema,
from: z.string(),
to: z.string(),
sessionKey: z.string().optional(),
startedAt: z.number(),
answeredAt: z.number().optional(),
endedAt: z.number().optional(),
endReason: EndReasonSchema.optional(),
transcript: z.array(TranscriptEntrySchema).default([]),
processedEventIds: z.array(z.string()).default([]),
metadata: z.record(z.string(), z.unknown()).optional(),
});
export type CallRecord = z.infer<typeof CallRecordSchema>;
// -----------------------------------------------------------------------------
// Webhook Types
// -----------------------------------------------------------------------------
export type WebhookVerificationResult = {
ok: boolean;
reason?: string;
/** Signature is valid, but request was seen before within replay window. */
isReplay?: boolean;
/** Stable key derived from authenticated request material. */
verifiedRequestKey?: string;
};
export type WebhookParseOptions = {
/** Stable request key from verifyWebhook. */
verifiedRequestKey?: string;
};
export type WebhookContext = {
headers: Record<string, string | string[] | undefined>;
rawBody: string;
url: string;
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
query?: Record<string, string | string[] | undefined>;
remoteAddress?: string;
};
export type ProviderWebhookParseResult = {
events: NormalizedEvent[];
providerResponseBody?: string;
providerResponseHeaders?: Record<string, string>;
statusCode?: number;
};
// -----------------------------------------------------------------------------
// Provider Method Types
// -----------------------------------------------------------------------------
export type InitiateCallInput = {
callId: CallId;
from: string;
to: string;
webhookUrl: string;
clientState?: Record<string, string>;
/** Inline TwiML to execute (skips webhook, used for notify mode) */
inlineTwiml?: string;
};
export type InitiateCallResult = {
providerCallId: ProviderCallId;
status: "initiated" | "queued";
};
export type HangupCallInput = {
callId: CallId;
providerCallId: ProviderCallId;
reason: EndReason;
};
export type PlayTtsInput = {
callId: CallId;
providerCallId: ProviderCallId;
text: string;
voice?: string;
locale?: string;
};
export type StartListeningInput = {
callId: CallId;
providerCallId: ProviderCallId;
language?: string;
/** Optional per-turn nonce for provider callbacks (replay hardening). */
turnToken?: string;
};
export type StopListeningInput = {
callId: CallId;
providerCallId: ProviderCallId;
};
// -----------------------------------------------------------------------------
// Call Status Verification (used on restart to verify persisted calls)
// -----------------------------------------------------------------------------
export type GetCallStatusInput = {
providerCallId: ProviderCallId;
};
export type GetCallStatusResult = {
/** Provider-specific status string (e.g. "completed", "in-progress") */
status: string;
/** True when the provider confirms the call has ended */
isTerminal: boolean;
/** True when the status could not be determined (transient error) */
isUnknown?: boolean;
};
// -----------------------------------------------------------------------------
// Outbound Call Options
// -----------------------------------------------------------------------------
export type OutboundCallOptions = {
/** Message to speak when call connects */
message?: string;
/** Call mode (overrides config default) */
mode?: CallMode;
};
// -----------------------------------------------------------------------------
// Tool Result Types
// -----------------------------------------------------------------------------
export type InitiateCallToolResult = {
success: boolean;
callId?: string;
status?: "initiated" | "queued" | "no-answer" | "busy" | "failed";
error?: string;
};
export type ContinueCallToolResult = {
success: boolean;
transcript?: string;
error?: string;
};
export type SpeakToUserToolResult = {
success: boolean;
error?: string;
};
export type EndCallToolResult = {
success: boolean;
error?: string;
};

View file

@ -0,0 +1,17 @@
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveUserPath } from "./utils.js";
describe("resolveUserPath", () => {
it("returns trimmed empty input unchanged", () => {
expect(resolveUserPath(" ")).toBe("");
});
it("expands tildes and resolves relative paths", () => {
expect(resolveUserPath("~/voice-call/config.json")).toBe(
path.resolve(os.homedir(), "voice-call/config.json"),
);
expect(resolveUserPath("./voice-call")).toBe(path.resolve("./voice-call"));
});
});

View file

@ -0,0 +1,14 @@
import os from "node:os";
import path from "node:path";
export function resolveUserPath(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
return trimmed;
}
if (trimmed.startsWith("~")) {
const expanded = trimmed.replace(/^~(?=$|[\\/])/, os.homedir());
return path.resolve(expanded);
}
return path.resolve(trimmed);
}

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_POLLY_VOICE,
escapeXml,
getOpenAiVoiceNames,
isOpenAiVoice,
mapVoiceToPolly,
} from "./voice-mapping.js";
describe("voice mapping", () => {
it("escapes xml-special characters", () => {
expect(escapeXml(`5 < 6 & "quote" 'apostrophe' > 4`)).toBe(
"5 &lt; 6 &amp; &quot;quote&quot; &apos;apostrophe&apos; &gt; 4",
);
});
it("maps openai voices, passes through provider voices, and falls back to default", () => {
expect(mapVoiceToPolly("alloy")).toBe("Polly.Joanna");
expect(mapVoiceToPolly("ECHO")).toBe("Polly.Matthew");
expect(mapVoiceToPolly("Polly.Brian")).toBe("Polly.Brian");
expect(mapVoiceToPolly("Google.en-US-Standard-C")).toBe("Google.en-US-Standard-C");
expect(mapVoiceToPolly("unknown")).toBe(DEFAULT_POLLY_VOICE);
expect(mapVoiceToPolly(undefined)).toBe(DEFAULT_POLLY_VOICE);
});
it("detects known openai voices and lists them", () => {
expect(isOpenAiVoice("nova")).toBe(true);
expect(isOpenAiVoice("NOVA")).toBe(true);
expect(isOpenAiVoice("Polly.Joanna")).toBe(false);
expect(getOpenAiVoiceNames()).toEqual(
expect.arrayContaining(["alloy", "echo", "fable", "nova", "onyx", "shimmer"]),
);
});
});

View file

@ -0,0 +1,68 @@
/**
* Voice mapping and XML utilities for voice call providers.
*/
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
/**
* Escape XML special characters for TwiML and other XML responses.
*/
export function escapeXml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* Map of OpenAI voice names to similar Twilio Polly voices.
*/
const OPENAI_TO_POLLY_MAP: Record<string, string> = {
alloy: "Polly.Joanna", // neutral, warm
echo: "Polly.Matthew", // male, warm
fable: "Polly.Amy", // British, expressive
onyx: "Polly.Brian", // deep male
nova: "Polly.Salli", // female, friendly
shimmer: "Polly.Kimberly", // female, clear
};
/**
* Default Polly voice when no mapping is found.
*/
export const DEFAULT_POLLY_VOICE = "Polly.Joanna";
/**
* Map OpenAI voice names to Twilio Polly equivalents.
* Falls through if already a valid Polly/Google voice.
*
* @param voice - OpenAI voice name (alloy, echo, etc.) or Polly voice name
* @returns Polly voice name suitable for Twilio TwiML
*/
export function mapVoiceToPolly(voice: string | undefined): string {
if (!voice) {
return DEFAULT_POLLY_VOICE;
}
// Already a Polly/Google voice - pass through
if (voice.startsWith("Polly.") || voice.startsWith("Google.")) {
return voice;
}
// Map OpenAI voices to Polly equivalents
return OPENAI_TO_POLLY_MAP[normalizeLowercaseStringOrEmpty(voice)] || DEFAULT_POLLY_VOICE;
}
/**
* Check if a voice name is a known OpenAI voice.
*/
export function isOpenAiVoice(voice: string): boolean {
return normalizeLowercaseStringOrEmpty(voice) in OPENAI_TO_POLLY_MAP;
}
/**
* Get all supported OpenAI voice names.
*/
export function getOpenAiVoiceNames(): string[] {
return Object.keys(OPENAI_TO_POLLY_MAP);
}

View file

@ -0,0 +1,739 @@
import crypto from "node:crypto";
import { describe, expect, it } from "vitest";
import {
verifyPlivoWebhook,
verifyTelnyxWebhook,
verifyTwilioWebhook,
} from "./webhook-security.js";
function canonicalizeBase64(input: string): string {
return Buffer.from(input, "base64").toString("base64");
}
function plivoV2Signature(params: {
authToken: string;
urlNoQuery: string;
nonce: string;
}): string {
const digest = crypto
.createHmac("sha256", params.authToken)
.update(params.urlNoQuery + params.nonce)
.digest("base64");
return canonicalizeBase64(digest);
}
function plivoV3Signature(params: {
authToken: string;
urlWithQuery: string;
postBody: string;
nonce: string;
}): string {
const u = new URL(params.urlWithQuery);
const baseNoQuery = `${u.protocol}//${u.host}${u.pathname}`;
const queryPairs: Array<[string, string]> = [];
for (const [k, v] of u.searchParams.entries()) {
queryPairs.push([k, v]);
}
const queryMap = new Map<string, string[]>();
for (const [k, v] of queryPairs) {
queryMap.set(k, (queryMap.get(k) ?? []).concat(v));
}
const sortedQuery = Array.from(queryMap.keys())
.toSorted()
.flatMap((k) => [...(queryMap.get(k) ?? [])].toSorted().map((v) => `${k}=${v}`))
.join("&");
const postParams = new URLSearchParams(params.postBody);
const postMap = new Map<string, string[]>();
for (const [k, v] of postParams.entries()) {
postMap.set(k, (postMap.get(k) ?? []).concat(v));
}
const sortedPost = Array.from(postMap.keys())
.toSorted()
.flatMap((k) => [...(postMap.get(k) ?? [])].toSorted().map((v) => `${k}${v}`))
.join("");
const hasPost = sortedPost.length > 0;
let baseUrl = baseNoQuery;
if (sortedQuery.length > 0 || hasPost) {
baseUrl = `${baseNoQuery}?${sortedQuery}`;
}
if (sortedQuery.length > 0 && hasPost) {
baseUrl = `${baseUrl}.`;
}
baseUrl = `${baseUrl}${sortedPost}`;
const digest = crypto
.createHmac("sha256", params.authToken)
.update(`${baseUrl}.${params.nonce}`)
.digest("base64");
return canonicalizeBase64(digest);
}
function twilioSignature(params: { authToken: string; url: string; postBody: string }): string {
let dataToSign = params.url;
const sortedParams = Array.from(new URLSearchParams(params.postBody).entries()).toSorted((a, b) =>
a[0].localeCompare(b[0]),
);
for (const [key, value] of sortedParams) {
dataToSign += key + value;
}
return crypto.createHmac("sha1", params.authToken).update(dataToSign).digest("base64");
}
function expectReplayResultPair(
first: { ok: boolean; isReplay?: boolean; verifiedRequestKey?: string },
second: { ok: boolean; isReplay?: boolean; verifiedRequestKey?: string },
) {
expect(first.ok).toBe(true);
expect(first.isReplay).toBeFalsy();
if (!first.verifiedRequestKey) {
throw new Error("verified webhook request did not produce a request key");
}
expect(second.ok).toBe(true);
expect(second.isReplay).toBe(true);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
}
function expectAcceptedWebhookVersion(
result: { ok: boolean; version?: string },
version: "v2" | "v3",
) {
expect(result).toMatchObject({ ok: true, version });
}
function verifyTwilioNgrokLoopback(signature: string) {
return verifyTwilioWebhook(
{
headers: {
host: "127.0.0.1:3334",
"x-forwarded-proto": "https",
"x-forwarded-host": "local.ngrok-free.app",
"x-twilio-signature": signature,
},
rawBody: "CallSid=CS123&CallStatus=completed&From=%2B15550000000",
url: "http://127.0.0.1:3334/voice/webhook",
method: "POST",
remoteAddress: "127.0.0.1",
},
"test-auth-token",
{ allowNgrokFreeTierLoopbackBypass: true },
);
}
function verifyTwilioSignedRequest(params: {
headers: Record<string, string>;
rawBody: string;
authToken: string;
publicUrl: string;
}) {
return verifyTwilioWebhook(
{
headers: params.headers,
rawBody: params.rawBody,
url: "http://local/voice/webhook?callId=abc",
method: "POST",
query: { callId: "abc" },
},
params.authToken,
{ publicUrl: params.publicUrl },
);
}
function createSignedTelnyxWebhookRequest() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const pemPublicKey = publicKey.export({ format: "pem", type: "spki" });
const timestamp = String(Math.floor(Date.now() / 1000));
const rawBody = JSON.stringify({
data: { event_type: "call.initiated", payload: { call_control_id: "call-1" } },
nonce: crypto.randomUUID(),
});
const signedPayload = `${timestamp}|${rawBody}`;
const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
return {
pemPublicKey,
timestamp,
rawBody,
signature,
makeCtx(signatureValue = signature) {
return {
headers: {
"telnyx-signature-ed25519": signatureValue,
"telnyx-timestamp": timestamp,
},
rawBody,
url: "https://example.com/voice/webhook",
method: "POST" as const,
};
},
};
}
describe("verifyPlivoWebhook", () => {
it("accepts valid V2 signature", () => {
const authToken = "test-auth-token";
const nonce = "nonce-123";
const ctxUrl = "http://local/voice/webhook?flow=answer&callId=abc";
const verificationUrl = "https://example.com/voice/webhook";
const signature = plivoV2Signature({
authToken,
urlNoQuery: verificationUrl,
nonce,
});
const result = verifyPlivoWebhook(
{
headers: {
host: "example.com",
"x-forwarded-proto": "https",
"x-plivo-signature-v2": signature,
"x-plivo-signature-v2-nonce": nonce,
},
rawBody: "CallUUID=uuid&CallStatus=in-progress",
url: ctxUrl,
method: "POST",
query: { flow: "answer", callId: "abc" },
},
authToken,
);
expectAcceptedWebhookVersion(result, "v2");
});
it("accepts valid V3 signature (including multi-signature header)", () => {
const authToken = "test-auth-token";
const nonce = "nonce-456";
const urlWithQuery = "https://example.com/voice/webhook?flow=answer&callId=abc";
const postBody = "CallUUID=uuid&CallStatus=in-progress&From=%2B15550000000";
const good = plivoV3Signature({
authToken,
urlWithQuery,
postBody,
nonce,
});
const result = verifyPlivoWebhook(
{
headers: {
host: "example.com",
"x-forwarded-proto": "https",
"x-plivo-signature-v3": `bad, ${good}`,
"x-plivo-signature-v3-nonce": nonce,
},
rawBody: postBody,
url: urlWithQuery,
method: "POST",
query: { flow: "answer", callId: "abc" },
},
authToken,
);
expectAcceptedWebhookVersion(result, "v3");
});
it("rejects missing signatures", () => {
const result = verifyPlivoWebhook(
{
headers: { host: "example.com", "x-forwarded-proto": "https" },
rawBody: "",
url: "https://example.com/voice/webhook",
method: "POST",
},
"token",
);
expect(result.ok).toBe(false);
expect(result.reason).toMatch(/Missing Plivo signature headers/);
});
it("marks replayed valid V3 requests as replay without failing auth", () => {
const authToken = "test-auth-token";
const nonce = "nonce-replay-v3";
const urlWithQuery = "https://example.com/voice/webhook?flow=answer&callId=abc";
const postBody = "CallUUID=uuid&CallStatus=in-progress&From=%2B15550000000";
const signature = plivoV3Signature({
authToken,
urlWithQuery,
postBody,
nonce,
});
const ctx = {
headers: {
host: "example.com",
"x-forwarded-proto": "https",
"x-plivo-signature-v3": signature,
"x-plivo-signature-v3-nonce": nonce,
},
rawBody: postBody,
url: urlWithQuery,
method: "POST" as const,
query: { flow: "answer", callId: "abc" },
};
const first = verifyPlivoWebhook(ctx, authToken);
const second = verifyPlivoWebhook(ctx, authToken);
expectReplayResultPair(first, second);
});
it("treats query-only V2 variants as the same verified request", () => {
const authToken = "test-auth-token";
const nonce = "nonce-replay-v2";
const verificationUrl = "https://example.com/voice/webhook";
const signature = plivoV2Signature({
authToken,
urlNoQuery: verificationUrl,
nonce,
});
const baseHeaders = {
host: "example.com",
"x-forwarded-proto": "https",
"x-plivo-signature-v2": signature,
"x-plivo-signature-v2-nonce": nonce,
};
const rawBody = "CallUUID=uuid&CallStatus=in-progress";
const first = verifyPlivoWebhook(
{
headers: baseHeaders,
rawBody,
url: `${verificationUrl}?flow=answer&callId=abc`,
method: "POST",
query: { flow: "answer", callId: "abc" },
},
authToken,
);
const second = verifyPlivoWebhook(
{
headers: baseHeaders,
rawBody,
url: `${verificationUrl}?flow=getinput&callId=abc`,
method: "POST",
query: { flow: "getinput", callId: "abc" },
},
authToken,
);
expect(first.ok).toBe(true);
expect(first.verifiedRequestKey).toBeDefined();
expect(second.ok).toBe(true);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
expect(second.isReplay).toBe(true);
});
it("returns a stable request key when verification is skipped", () => {
const ctx = {
headers: {},
rawBody: "CallUUID=uuid&CallStatus=in-progress",
url: "https://example.com/voice/webhook",
method: "POST" as const,
};
const first = verifyPlivoWebhook(ctx, "token", { skipVerification: true });
const second = verifyPlivoWebhook(ctx, "token", { skipVerification: true });
expect(first.ok).toBe(true);
expect(first.verifiedRequestKey).toMatch(/^plivo:skip:/);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
expect(second.isReplay).toBe(true);
});
it("detects V3 replay when query parameters are reordered", () => {
const authToken = "test-auth-token";
const nonce = "nonce-v3-reorder";
const postBody = "CallUUID=uuid&CallStatus=in-progress";
const urlA = "https://example.com/voice/webhook?flow=answer&callId=abc";
const urlB = "https://example.com/voice/webhook?callId=abc&flow=answer";
const signatureA = plivoV3Signature({ authToken, urlWithQuery: urlA, postBody, nonce });
const signatureB = plivoV3Signature({ authToken, urlWithQuery: urlB, postBody, nonce });
expect(signatureA).toBe(signatureB);
const first = verifyPlivoWebhook(
{
headers: {
host: "example.com",
"x-forwarded-proto": "https",
"x-plivo-signature-v3": signatureA,
"x-plivo-signature-v3-nonce": nonce,
},
rawBody: postBody,
url: urlA,
method: "POST",
query: { flow: "answer", callId: "abc" },
},
authToken,
);
const second = verifyPlivoWebhook(
{
headers: {
host: "example.com",
"x-forwarded-proto": "https",
"x-plivo-signature-v3": signatureB,
"x-plivo-signature-v3-nonce": nonce,
},
rawBody: postBody,
url: urlB,
method: "POST",
query: { callId: "abc", flow: "answer" },
},
authToken,
);
expectReplayResultPair(first, second);
});
});
describe("verifyTelnyxWebhook", () => {
it("marks replayed valid requests as replay without failing auth", () => {
const request = createSignedTelnyxWebhookRequest();
const first = verifyTelnyxWebhook(request.makeCtx(), request.pemPublicKey);
const second = verifyTelnyxWebhook(request.makeCtx(), request.pemPublicKey);
expectReplayResultPair(first, second);
});
it("treats Base64 and Base64URL signatures as the same replayed request", () => {
const request = createSignedTelnyxWebhookRequest();
const urlSafeSignature = request.signature
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
const first = verifyTelnyxWebhook(request.makeCtx(), request.pemPublicKey);
const second = verifyTelnyxWebhook(request.makeCtx(urlSafeSignature), request.pemPublicKey);
expectReplayResultPair(first, second);
});
it("returns a stable request key when verification is skipped", () => {
const ctx = {
headers: {},
rawBody: JSON.stringify({ data: { event_type: "call.initiated" } }),
url: "https://example.com/voice/webhook",
method: "POST" as const,
};
const first = verifyTelnyxWebhook(ctx, undefined, { skipVerification: true });
const second = verifyTelnyxWebhook(ctx, undefined, { skipVerification: true });
expect(first.ok).toBe(true);
expect(first.verifiedRequestKey).toMatch(/^telnyx:skip:/);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
expect(second.isReplay).toBe(true);
});
});
describe("verifyTwilioWebhook", () => {
it("uses request query when publicUrl omits it", () => {
const authToken = "test-auth-token";
const publicUrl = "https://example.com/voice/webhook";
const urlWithQuery = `${publicUrl}?callId=abc`;
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const signature = twilioSignature({
authToken,
url: urlWithQuery,
postBody,
});
const result = verifyTwilioWebhook(
{
headers: {
host: "example.com",
"x-forwarded-proto": "https",
"x-twilio-signature": signature,
},
rawBody: postBody,
url: "http://local/voice/webhook?callId=abc",
method: "POST",
query: { callId: "abc" },
},
authToken,
{ publicUrl },
);
expect(result.ok).toBe(true);
});
it("marks replayed valid requests as replay without failing auth", () => {
const authToken = "test-auth-token";
const publicUrl = "https://example.com/voice/webhook";
const urlWithQuery = `${publicUrl}?callId=abc`;
const postBody = "CallSid=CS777&CallStatus=completed&From=%2B15550000000";
const signature = twilioSignature({ authToken, url: urlWithQuery, postBody });
const headers = {
host: "example.com",
"x-forwarded-proto": "https",
"x-twilio-signature": signature,
"i-twilio-idempotency-token": "idem-replay-1",
};
const first = verifyTwilioSignedRequest({ headers, rawBody: postBody, authToken, publicUrl });
const second = verifyTwilioSignedRequest({ headers, rawBody: postBody, authToken, publicUrl });
expectReplayResultPair(first, second);
});
it("treats changed idempotency header as replay for identical signed requests", () => {
const authToken = "test-auth-token";
const publicUrl = "https://example.com/voice/webhook";
const urlWithQuery = `${publicUrl}?callId=abc`;
const postBody = "CallSid=CS778&CallStatus=completed&From=%2B15550000000";
const signature = twilioSignature({ authToken, url: urlWithQuery, postBody });
const first = verifyTwilioSignedRequest({
headers: {
host: "example.com",
"x-forwarded-proto": "https",
"x-twilio-signature": signature,
"i-twilio-idempotency-token": "idem-replay-a",
},
rawBody: postBody,
authToken,
publicUrl,
});
const second = verifyTwilioSignedRequest({
headers: {
host: "example.com",
"x-forwarded-proto": "https",
"x-twilio-signature": signature,
"i-twilio-idempotency-token": "idem-replay-b",
},
rawBody: postBody,
authToken,
publicUrl,
});
expectReplayResultPair(first, second);
});
it("rejects invalid signatures even when attacker injects forwarded host", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const result = verifyTwilioWebhook(
{
headers: {
host: "127.0.0.1:3334",
"x-forwarded-proto": "https",
"x-forwarded-host": "attacker.ngrok-free.app",
"x-twilio-signature": "invalid",
},
rawBody: postBody,
url: "http://127.0.0.1:3334/voice/webhook",
method: "POST",
},
authToken,
);
expect(result.ok).toBe(false);
// X-Forwarded-Host is ignored by default, so URL uses Host header
expect(result.isNgrokFreeTier).toBe(false);
expect(result.reason).toMatch(/Invalid signature/);
});
it("accepts valid signatures for ngrok free tier on loopback when compatibility mode is enabled", () => {
const webhookUrl = "https://local.ngrok-free.app/voice/webhook";
const signature = twilioSignature({
authToken: "test-auth-token",
url: webhookUrl,
postBody: "CallSid=CS123&CallStatus=completed&From=%2B15550000000",
});
const result = verifyTwilioNgrokLoopback(signature);
expect(result.ok).toBe(true);
expect(result.verificationUrl).toBe(webhookUrl);
});
it("does not allow invalid signatures for ngrok free tier on loopback", () => {
const result = verifyTwilioNgrokLoopback("invalid");
expect(result.ok).toBe(false);
expect(result.reason).toMatch(/Invalid signature/);
expect(result.isNgrokFreeTier).toBe(true);
});
it("ignores attacker X-Forwarded-Host without allowedHosts or trustForwardingHeaders", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
// Attacker tries to inject their host - should be ignored
const result = verifyTwilioWebhook(
{
headers: {
host: "legitimate.example.com",
"x-forwarded-host": "attacker.evil.com",
"x-twilio-signature": "invalid",
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
},
authToken,
);
expect(result.ok).toBe(false);
// Attacker's host is ignored - uses Host header instead
expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
});
it("uses X-Forwarded-Host when allowedHosts whitelist is provided", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const webhookUrl = "https://myapp.ngrok.io/voice/webhook";
const signature = twilioSignature({ authToken, url: webhookUrl, postBody });
const result = verifyTwilioWebhook(
{
headers: {
host: "localhost:3000",
"x-forwarded-proto": "https",
"x-forwarded-host": "myapp.ngrok.io",
"x-twilio-signature": signature,
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
},
authToken,
{ allowedHosts: ["myapp.ngrok.io"] },
);
expect(result.ok).toBe(true);
expect(result.verificationUrl).toBe(webhookUrl);
});
it("rejects X-Forwarded-Host not in allowedHosts whitelist", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const result = verifyTwilioWebhook(
{
headers: {
host: "localhost:3000",
"x-forwarded-host": "attacker.evil.com",
"x-twilio-signature": "invalid",
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
},
authToken,
{ allowedHosts: ["myapp.ngrok.io", "webhook.example.com"] },
);
expect(result.ok).toBe(false);
// Attacker's host not in whitelist, falls back to Host header
expect(result.verificationUrl).toBe("https://localhost/voice/webhook");
});
it("trusts forwarding headers only from trusted proxy IPs", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const webhookUrl = "https://proxy.example.com/voice/webhook";
const signature = twilioSignature({ authToken, url: webhookUrl, postBody });
const result = verifyTwilioWebhook(
{
headers: {
host: "localhost:3000",
"x-forwarded-proto": "https",
"x-forwarded-host": "proxy.example.com",
"x-twilio-signature": signature,
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
remoteAddress: "203.0.113.10",
},
authToken,
{ trustForwardingHeaders: true, trustedProxyIPs: ["203.0.113.10"] },
);
expect(result.ok).toBe(true);
expect(result.verificationUrl).toBe(webhookUrl);
});
it("ignores forwarding headers when trustedProxyIPs are set but remote IP is missing", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const result = verifyTwilioWebhook(
{
headers: {
host: "legitimate.example.com",
"x-forwarded-proto": "https",
"x-forwarded-host": "proxy.example.com",
"x-twilio-signature": "invalid",
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
},
authToken,
{ trustForwardingHeaders: true, trustedProxyIPs: ["203.0.113.10"] },
);
expect(result.ok).toBe(false);
expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
});
it("returns a stable request key when verification is skipped", () => {
const ctx = {
headers: {},
rawBody: "CallSid=CS123&CallStatus=completed",
url: "https://example.com/voice/webhook",
method: "POST" as const,
};
const first = verifyTwilioWebhook(ctx, "token", { skipVerification: true });
const second = verifyTwilioWebhook(ctx, "token", { skipVerification: true });
expect(first.ok).toBe(true);
expect(first.verifiedRequestKey).toMatch(/^twilio:skip:/);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
expect(second.isReplay).toBe(true);
});
it("succeeds when Twilio signs URL without port but server URL has port", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
// Twilio signs using URL without port.
const urlWithPort = "https://example.com:8443/voice/webhook";
const signedUrl = "https://example.com/voice/webhook";
const signature = twilioSignature({ authToken, url: signedUrl, postBody });
const result = verifyTwilioWebhook(
{
headers: {
host: "example.com:8443",
"x-twilio-signature": signature,
},
rawBody: postBody,
url: urlWithPort,
method: "POST",
},
authToken,
{ publicUrl: urlWithPort },
);
expect(result.ok).toBe(true);
expect(result.verificationUrl).toBe(signedUrl);
expect(result.verifiedRequestKey).toMatch(/^twilio:req:/);
});
});

View file

@ -0,0 +1,994 @@
import crypto from "node:crypto";
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { getHeader } from "./http-headers.js";
import type { WebhookContext } from "./types.js";
const REPLAY_WINDOW_MS = 10 * 60 * 1000;
const REPLAY_CACHE_MAX_ENTRIES = 10_000;
const REPLAY_CACHE_PRUNE_INTERVAL = 64;
type ReplayCache = {
seenUntil: Map<string, number>;
calls: number;
};
const twilioReplayCache: ReplayCache = {
seenUntil: new Map<string, number>(),
calls: 0,
};
const plivoReplayCache: ReplayCache = {
seenUntil: new Map<string, number>(),
calls: 0,
};
const telnyxReplayCache: ReplayCache = {
seenUntil: new Map<string, number>(),
calls: 0,
};
function sha256Hex(input: string): string {
return crypto.createHash("sha256").update(input).digest("hex");
}
function createSkippedVerificationReplayKey(provider: string, ctx: WebhookContext): string {
return `${provider}:skip:${sha256Hex(`${ctx.method}\n${ctx.url}\n${ctx.rawBody}`)}`;
}
function pruneReplayCache(cache: ReplayCache, now: number): void {
for (const [key, expiresAt] of cache.seenUntil) {
if (expiresAt <= now) {
cache.seenUntil.delete(key);
}
}
while (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
const oldest = cache.seenUntil.keys().next().value;
if (!oldest) {
break;
}
cache.seenUntil.delete(oldest);
}
}
function markReplay(cache: ReplayCache, replayKey: string): boolean {
const now = Date.now();
cache.calls += 1;
if (cache.calls % REPLAY_CACHE_PRUNE_INTERVAL === 0) {
pruneReplayCache(cache, now);
}
const existing = cache.seenUntil.get(replayKey);
if (existing && existing > now) {
return true;
}
cache.seenUntil.set(replayKey, now + REPLAY_WINDOW_MS);
if (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
pruneReplayCache(cache, now);
}
return false;
}
/**
* Validate Twilio webhook signature using HMAC-SHA1.
*
* Twilio signs requests by concatenating the URL with sorted POST params,
* then computing HMAC-SHA1 with the auth token.
*
* @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
*/
export function validateTwilioSignature(
authToken: string,
signature: string | undefined,
url: string,
params: URLSearchParams,
): boolean {
if (!signature) {
return false;
}
const dataToSign = buildTwilioDataToSign(url, params);
// HMAC-SHA1 with auth token, then base64 encode
const expectedSignature = crypto
.createHmac("sha1", authToken)
.update(dataToSign)
.digest("base64");
// Use timing-safe comparison to prevent timing attacks
return timingSafeEqual(signature, expectedSignature);
}
function buildTwilioDataToSign(url: string, params: URLSearchParams): string {
let dataToSign = url;
const sortedParams = Array.from(params.entries()).toSorted((a, b) =>
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
);
for (const [key, value] of sortedParams) {
dataToSign += key + value;
}
return dataToSign;
}
function buildCanonicalTwilioParamString(params: URLSearchParams): string {
return Array.from(params.entries())
.toSorted((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
.map(([key, value]) => `${key}=${value}`)
.join("&");
}
/**
* Timing-safe string comparison to prevent timing attacks.
*/
function timingSafeEqual(a: string, b: string): boolean {
return safeEqualSecret(a, b);
}
/**
* Configuration for secure URL reconstruction.
*/
export interface WebhookUrlOptions {
/**
* Whitelist of allowed hostnames. If provided, only these hosts will be
* accepted from forwarding headers. This prevents host header injection attacks.
*
* SECURITY: You must provide this OR set trustForwardingHeaders=true to use
* X-Forwarded-Host headers. Without either, forwarding headers are ignored.
*/
allowedHosts?: string[];
/**
* Explicitly trust X-Forwarded-* headers without a whitelist.
* WARNING: Only set this to true if you trust your proxy configuration
* and understand the security implications.
*
* @default false
*/
trustForwardingHeaders?: boolean;
/**
* List of trusted proxy IP addresses. X-Forwarded-* headers will only be
* trusted if the request comes from one of these IPs.
* Requires remoteIP to be set for validation.
*/
trustedProxyIPs?: string[];
/**
* The IP address of the incoming request (for proxy validation).
*/
remoteIP?: string;
}
/**
* Validate that a hostname matches RFC 1123 format.
* Prevents injection of malformed hostnames.
*/
function isValidHostname(hostname: string): boolean {
if (!hostname || hostname.length > 253) {
return false;
}
// RFC 1123 hostname: alphanumeric, hyphens, dots
// Also allow ngrok/tunnel subdomains
const hostnameRegex =
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
return hostnameRegex.test(hostname);
}
/**
* Safely extract hostname from a host header value.
* Handles IPv6 addresses and prevents injection via malformed values.
*/
function extractHostname(hostHeader: string): string | null {
if (!hostHeader) {
return null;
}
let hostname: string;
// Handle IPv6 addresses: [::1]:8080
if (hostHeader.startsWith("[")) {
const endBracket = hostHeader.indexOf("]");
if (endBracket === -1) {
return null; // Malformed IPv6
}
hostname = hostHeader.substring(1, endBracket);
return normalizeLowercaseStringOrEmpty(hostname);
}
// Handle IPv4/domain with optional port
// Check for @ which could indicate user info injection attempt
if (hostHeader.includes("@")) {
return null; // Reject potential injection: attacker.com:80@legitimate.com
}
hostname = hostHeader.split(":")[0];
// Validate the extracted hostname
if (!isValidHostname(hostname)) {
return null;
}
return normalizeLowercaseStringOrEmpty(hostname);
}
function extractHostnameFromHeader(headerValue: string): string | null {
const first = headerValue.split(",")[0]?.trim();
if (!first) {
return null;
}
return extractHostname(first);
}
function normalizeAllowedHosts(allowedHosts?: string[]): Set<string> | null {
if (!allowedHosts || allowedHosts.length === 0) {
return null;
}
const normalized = new Set<string>();
for (const host of allowedHosts) {
const extracted = extractHostname(host.trim());
if (extracted) {
normalized.add(extracted);
}
}
return normalized.size > 0 ? normalized : null;
}
/**
* Reconstruct the public webhook URL from request headers.
*
* SECURITY: This function validates host headers to prevent host header
* injection attacks. When using forwarding headers (X-Forwarded-Host, etc.),
* always provide allowedHosts to whitelist valid hostnames.
*
* When behind a reverse proxy (Tailscale, nginx, ngrok), the original URL
* used by Twilio differs from the local request URL. We use standard
* forwarding headers to reconstruct it.
*
* Priority order:
* 1. X-Forwarded-Proto + X-Forwarded-Host (standard proxy headers)
* 2. X-Original-Host (nginx)
* 3. Ngrok-Forwarded-Host (ngrok specific)
* 4. Host header (direct connection)
*/
export function reconstructWebhookUrl(ctx: WebhookContext, options?: WebhookUrlOptions): string {
const { headers } = ctx;
// SECURITY: Only trust forwarding headers if explicitly configured.
// Either allowedHosts must be set (for whitelist validation) or
// trustForwardingHeaders must be true (explicit opt-in to trust).
const allowedHosts = normalizeAllowedHosts(options?.allowedHosts);
const hasAllowedHosts = allowedHosts !== null;
const explicitlyTrusted = options?.trustForwardingHeaders === true;
// Also check trusted proxy IPs if configured
const trustedProxyIPs = options?.trustedProxyIPs?.filter(Boolean) ?? [];
const hasTrustedProxyIPs = trustedProxyIPs.length > 0;
const remoteIP = options?.remoteIP ?? ctx.remoteAddress;
const fromTrustedProxy =
!hasTrustedProxyIPs || (remoteIP ? trustedProxyIPs.includes(remoteIP) : false);
// Only trust forwarding headers if: (has whitelist OR explicitly trusted) AND from trusted proxy
const shouldTrustForwardingHeaders = (hasAllowedHosts || explicitlyTrusted) && fromTrustedProxy;
const isAllowedForwardedHost = (host: string): boolean => !allowedHosts || allowedHosts.has(host);
// Determine protocol - only trust X-Forwarded-Proto from trusted proxies
let proto = "https";
if (shouldTrustForwardingHeaders) {
const forwardedProto = getHeader(headers, "x-forwarded-proto");
if (forwardedProto === "http" || forwardedProto === "https") {
proto = forwardedProto;
}
}
// Determine host - with security validation
let host: string | null = null;
if (shouldTrustForwardingHeaders) {
// Try forwarding headers in priority order
const forwardingHeaders = ["x-forwarded-host", "x-original-host", "ngrok-forwarded-host"];
for (const headerName of forwardingHeaders) {
const headerValue = getHeader(headers, headerName);
if (headerValue) {
const extracted = extractHostnameFromHeader(headerValue);
if (extracted && isAllowedForwardedHost(extracted)) {
host = extracted;
break;
}
}
}
}
// Fallback to Host header if no valid forwarding header found
if (!host) {
const hostHeader = getHeader(headers, "host");
if (hostHeader) {
const extracted = extractHostnameFromHeader(hostHeader);
if (extracted) {
host = extracted;
}
}
}
// Last resort: try to extract from ctx.url
if (!host) {
try {
const parsed = new URL(ctx.url);
const extracted = extractHostname(parsed.host);
if (extracted) {
host = extracted;
}
} catch {
// URL parsing failed - use empty string (will result in invalid URL)
host = "";
}
}
if (!host) {
host = "";
}
// Extract path from the context URL (fallback to "/" on parse failure)
let path = "/";
try {
const parsed = new URL(ctx.url);
path = parsed.pathname + parsed.search;
} catch {
// URL parsing failed
}
return `${proto}://${host}${path}`;
}
function buildTwilioVerificationUrl(
ctx: WebhookContext,
publicUrl?: string,
urlOptions?: WebhookUrlOptions,
): string {
if (!publicUrl) {
return reconstructWebhookUrl(ctx, urlOptions);
}
try {
const base = new URL(publicUrl);
const requestUrl = new URL(ctx.url);
base.pathname = requestUrl.pathname;
base.search = requestUrl.search;
return base.toString();
} catch {
return publicUrl;
}
}
function isLoopbackAddress(address?: string): boolean {
if (!address) {
return false;
}
if (address === "127.0.0.1" || address === "::1") {
return true;
}
if (address.startsWith("::ffff:127.")) {
return true;
}
return false;
}
function stripPortFromUrl(url: string): string {
try {
const parsed = new URL(url);
if (!parsed.port) {
return url;
}
parsed.port = "";
return parsed.toString();
} catch {
return url;
}
}
function setPortOnUrl(url: string, port: string): string {
try {
const parsed = new URL(url);
parsed.port = port;
return parsed.toString();
} catch {
return url;
}
}
function extractPortFromHostHeader(hostHeader?: string): string | undefined {
if (!hostHeader) {
return undefined;
}
try {
const parsed = new URL(`https://${hostHeader}`);
return parsed.port || undefined;
} catch {
return undefined;
}
}
/**
* Result of Twilio webhook verification with detailed info.
*/
export interface TwilioVerificationResult {
ok: boolean;
reason?: string;
/** The URL that was used for verification (for debugging) */
verificationUrl?: string;
/** Whether we're running behind ngrok free tier */
isNgrokFreeTier?: boolean;
/** Request is cryptographically valid but was already processed recently. */
isReplay?: boolean;
/** Stable request identity derived from signed Twilio material. */
verifiedRequestKey?: string;
}
export interface TelnyxVerificationResult {
ok: boolean;
reason?: string;
/** Request is cryptographically valid but was already processed recently. */
isReplay?: boolean;
/** Stable request identity derived from signed Telnyx material. */
verifiedRequestKey?: string;
}
function createTwilioReplayKey(params: {
verificationUrl: string;
signature: string;
requestParams: URLSearchParams;
}): string {
const canonicalParams = buildCanonicalTwilioParamString(params.requestParams);
return `twilio:req:${sha256Hex(
`${params.verificationUrl}\n${canonicalParams}\n${params.signature}`,
)}`;
}
function decodeBase64OrBase64Url(input: string): Buffer {
// Telnyx docs say Base64; some tooling emits Base64URL. Accept both.
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padLen = (4 - (normalized.length % 4)) % 4;
const padded = normalized + "=".repeat(padLen);
return Buffer.from(padded, "base64");
}
function base64UrlEncode(buf: Buffer): string {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function importEd25519PublicKey(publicKey: string): crypto.KeyObject | string {
const trimmed = publicKey.trim();
// PEM (spki) support.
if (trimmed.startsWith("-----BEGIN")) {
return trimmed;
}
// Base64-encoded raw Ed25519 key (32 bytes) or Base64-encoded DER SPKI key.
const decoded = decodeBase64OrBase64Url(trimmed);
if (decoded.length === 32) {
// JWK is the easiest portable way to import raw Ed25519 keys in Node crypto.
return crypto.createPublicKey({
key: { kty: "OKP", crv: "Ed25519", x: base64UrlEncode(decoded) },
format: "jwk",
});
}
return crypto.createPublicKey({
key: decoded,
format: "der",
type: "spki",
});
}
/**
* Verify Telnyx webhook signature using Ed25519.
*
* Telnyx signs `timestamp|payload` and provides:
* - `telnyx-signature-ed25519` (Base64 signature)
* - `telnyx-timestamp` (Unix seconds)
*/
export function verifyTelnyxWebhook(
ctx: WebhookContext,
publicKey: string | undefined,
options?: {
/** Skip verification entirely (only for development) */
skipVerification?: boolean;
/** Maximum allowed clock skew (ms). Defaults to 5 minutes. */
maxSkewMs?: number;
},
): TelnyxVerificationResult {
if (options?.skipVerification) {
const replayKey = createSkippedVerificationReplayKey("telnyx", ctx);
const isReplay = markReplay(telnyxReplayCache, replayKey);
return {
ok: true,
reason: "verification skipped (dev mode)",
isReplay,
verifiedRequestKey: replayKey,
};
}
if (!publicKey) {
return { ok: false, reason: "Missing telnyx.publicKey (configure to verify webhooks)" };
}
const signature = getHeader(ctx.headers, "telnyx-signature-ed25519");
const timestamp = getHeader(ctx.headers, "telnyx-timestamp");
if (!signature || !timestamp) {
return { ok: false, reason: "Missing signature or timestamp header" };
}
const eventTimeSec = parseInt(timestamp, 10);
if (!Number.isFinite(eventTimeSec)) {
return { ok: false, reason: "Invalid timestamp header" };
}
try {
const signedPayload = `${timestamp}|${ctx.rawBody}`;
const signatureBuffer = decodeBase64OrBase64Url(signature);
// Canonicalize equivalent Base64/Base64URL encodings before replay hashing.
const canonicalSignature = signatureBuffer.toString("base64");
const key = importEd25519PublicKey(publicKey);
const isValid = crypto.verify(null, Buffer.from(signedPayload), key, signatureBuffer);
if (!isValid) {
return { ok: false, reason: "Invalid signature" };
}
const maxSkewMs = options?.maxSkewMs ?? 5 * 60 * 1000;
const eventTimeMs = eventTimeSec * 1000;
const now = Date.now();
if (Math.abs(now - eventTimeMs) > maxSkewMs) {
return { ok: false, reason: "Timestamp too old" };
}
const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${canonicalSignature}\n${ctx.rawBody}`)}`;
const isReplay = markReplay(telnyxReplayCache, replayKey);
return { ok: true, isReplay, verifiedRequestKey: replayKey };
} catch (err) {
return {
ok: false,
reason: `Verification error: ${formatErrorMessage(err)}`,
};
}
}
/**
* Verify Twilio webhook with full context and detailed result.
*/
export function verifyTwilioWebhook(
ctx: WebhookContext,
authToken: string,
options?: {
/** Override the public URL (e.g., from config) */
publicUrl?: string;
/**
* Allow ngrok free tier compatibility mode (loopback only).
*
* IMPORTANT: This does NOT bypass signature verification.
* It only enables trusting forwarded headers on loopback so we can
* reconstruct the public ngrok URL that Twilio used for signing.
*/
allowNgrokFreeTierLoopbackBypass?: boolean;
/** Skip verification entirely (only for development) */
skipVerification?: boolean;
/**
* Whitelist of allowed hostnames for host header validation.
* Prevents host header injection attacks.
*/
allowedHosts?: string[];
/**
* Explicitly trust X-Forwarded-* headers without a whitelist.
* WARNING: Only enable if you trust your proxy configuration.
* @default false
*/
trustForwardingHeaders?: boolean;
/**
* List of trusted proxy IP addresses. X-Forwarded-* headers will only
* be trusted from these IPs.
*/
trustedProxyIPs?: string[];
/**
* The remote IP address of the request (for proxy validation).
*/
remoteIP?: string;
},
): TwilioVerificationResult {
// Allow skipping verification for development/testing
if (options?.skipVerification) {
const replayKey = createSkippedVerificationReplayKey("twilio", ctx);
const isReplay = markReplay(twilioReplayCache, replayKey);
return {
ok: true,
reason: "verification skipped (dev mode)",
isReplay,
verifiedRequestKey: replayKey,
};
}
const signature = getHeader(ctx.headers, "x-twilio-signature");
if (!signature) {
return { ok: false, reason: "Missing X-Twilio-Signature header" };
}
const isLoopback = isLoopbackAddress(options?.remoteIP ?? ctx.remoteAddress);
const allowLoopbackForwarding = options?.allowNgrokFreeTierLoopbackBypass && isLoopback;
// Reconstruct the URL Twilio used
const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl, {
allowedHosts: options?.allowedHosts,
trustForwardingHeaders: options?.trustForwardingHeaders || allowLoopbackForwarding,
trustedProxyIPs: options?.trustedProxyIPs,
remoteIP: options?.remoteIP,
});
// Parse the body as URL-encoded params
const params = new URLSearchParams(ctx.rawBody);
const isValid = validateTwilioSignature(authToken, signature, verificationUrl, params);
if (isValid) {
const replayKey = createTwilioReplayKey({
verificationUrl,
signature,
requestParams: params,
});
const isReplay = markReplay(twilioReplayCache, replayKey);
return { ok: true, verificationUrl, isReplay, verifiedRequestKey: replayKey };
}
// Twilio webhook signatures can differ in whether port is included.
// Retry a small, deterministic set of URL variants before failing closed.
const variants = new Set<string>();
variants.add(verificationUrl);
variants.add(stripPortFromUrl(verificationUrl));
if (options?.publicUrl) {
try {
const publicPort = new URL(options.publicUrl).port;
if (publicPort) {
variants.add(setPortOnUrl(verificationUrl, publicPort));
}
} catch {
// ignore invalid publicUrl; primary verification already used best effort
}
}
const hostHeaderPort = extractPortFromHostHeader(getHeader(ctx.headers, "host"));
if (hostHeaderPort) {
variants.add(setPortOnUrl(verificationUrl, hostHeaderPort));
}
for (const candidateUrl of variants) {
if (candidateUrl === verificationUrl) {
continue;
}
const isValidCandidate = validateTwilioSignature(authToken, signature, candidateUrl, params);
if (!isValidCandidate) {
continue;
}
const replayKey = createTwilioReplayKey({
verificationUrl: candidateUrl,
signature,
requestParams: params,
});
const isReplay = markReplay(twilioReplayCache, replayKey);
return { ok: true, verificationUrl: candidateUrl, isReplay, verifiedRequestKey: replayKey };
}
// Check if this is ngrok free tier - the URL might have different format
const isNgrokFreeTier =
verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
return {
ok: false,
reason: `Invalid signature for URL: ${verificationUrl}`,
verificationUrl,
isNgrokFreeTier,
};
}
// -----------------------------------------------------------------------------
// Plivo webhook verification
// -----------------------------------------------------------------------------
/**
* Result of Plivo webhook verification with detailed info.
*/
export interface PlivoVerificationResult {
ok: boolean;
reason?: string;
verificationUrl?: string;
/** Signature version used for verification */
version?: "v3" | "v2";
/** Request is cryptographically valid but was already processed recently. */
isReplay?: boolean;
/** Stable request identity derived from signed Plivo material. */
verifiedRequestKey?: string;
}
function normalizeSignatureBase64(input: string): string {
// Canonicalize base64 to match Plivo SDK behavior (decode then re-encode).
return Buffer.from(input, "base64").toString("base64");
}
function getBaseUrlNoQuery(url: string): string {
const u = new URL(url);
return `${u.protocol}//${u.host}${u.pathname}`;
}
function createPlivoV2ReplayKey(url: string, nonce: string): string {
return `plivo:v2:${sha256Hex(`${getBaseUrlNoQuery(url)}\n${nonce}`)}`;
}
function createPlivoV3ReplayKey(params: {
method: "GET" | "POST";
url: string;
postParams: PlivoParamMap;
nonce: string;
}): string {
const baseUrl = constructPlivoV3BaseUrl({
method: params.method,
url: params.url,
postParams: params.postParams,
});
return `plivo:v3:${sha256Hex(`${baseUrl}\n${params.nonce}`)}`;
}
function timingSafeEqualString(a: string, b: string): boolean {
return safeEqualSecret(a, b);
}
function validatePlivoV2Signature(params: {
authToken: string;
signature: string;
nonce: string;
url: string;
}): boolean {
const baseUrl = getBaseUrlNoQuery(params.url);
const digest = crypto
.createHmac("sha256", params.authToken)
.update(baseUrl + params.nonce)
.digest("base64");
const expected = normalizeSignatureBase64(digest);
const provided = normalizeSignatureBase64(params.signature);
return timingSafeEqualString(expected, provided);
}
type PlivoParamMap = Record<string, string[]>;
function toParamMapFromSearchParams(sp: URLSearchParams): PlivoParamMap {
const map: PlivoParamMap = {};
for (const [key, value] of sp.entries()) {
if (!map[key]) {
map[key] = [];
}
map[key].push(value);
}
return map;
}
function sortedQueryString(params: PlivoParamMap): string {
const parts: string[] = [];
for (const key of Object.keys(params).toSorted()) {
const values = [...params[key]].toSorted();
for (const value of values) {
parts.push(`${key}=${value}`);
}
}
return parts.join("&");
}
function sortedParamsString(params: PlivoParamMap): string {
const parts: string[] = [];
for (const key of Object.keys(params).toSorted()) {
const values = [...params[key]].toSorted();
for (const value of values) {
parts.push(`${key}${value}`);
}
}
return parts.join("");
}
function constructPlivoV3BaseUrl(params: {
method: "GET" | "POST";
url: string;
postParams: PlivoParamMap;
}): string {
const hasPostParams = Object.keys(params.postParams).length > 0;
const u = new URL(params.url);
const baseNoQuery = `${u.protocol}//${u.host}${u.pathname}`;
const queryMap = toParamMapFromSearchParams(u.searchParams);
const queryString = sortedQueryString(queryMap);
// In the Plivo V3 algorithm, the query portion is always sorted, and if we
// have POST params we add a '.' separator after the query string.
let baseUrl = baseNoQuery;
if (queryString.length > 0 || hasPostParams) {
baseUrl = `${baseNoQuery}?${queryString}`;
}
if (queryString.length > 0 && hasPostParams) {
baseUrl = `${baseUrl}.`;
}
if (params.method === "GET") {
return baseUrl;
}
return baseUrl + sortedParamsString(params.postParams);
}
function validatePlivoV3Signature(params: {
authToken: string;
signatureHeader: string;
nonce: string;
method: "GET" | "POST";
url: string;
postParams: PlivoParamMap;
}): boolean {
const baseUrl = constructPlivoV3BaseUrl({
method: params.method,
url: params.url,
postParams: params.postParams,
});
const hmacBase = `${baseUrl}.${params.nonce}`;
const digest = crypto.createHmac("sha256", params.authToken).update(hmacBase).digest("base64");
const expected = normalizeSignatureBase64(digest);
// Header can contain multiple signatures separated by commas.
const provided = params.signatureHeader
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((s) => normalizeSignatureBase64(s));
for (const sig of provided) {
if (timingSafeEqualString(expected, sig)) {
return true;
}
}
return false;
}
/**
* Verify Plivo webhooks using V3 signature if present; fall back to V2.
*
* Header names (case-insensitive; Node provides lower-case keys):
* - V3: X-Plivo-Signature-V3 / X-Plivo-Signature-V3-Nonce
* - V2: X-Plivo-Signature-V2 / X-Plivo-Signature-V2-Nonce
*/
export function verifyPlivoWebhook(
ctx: WebhookContext,
authToken: string,
options?: {
/** Override the public URL origin (host) used for verification */
publicUrl?: string;
/** Skip verification entirely (only for development) */
skipVerification?: boolean;
/**
* Whitelist of allowed hostnames for host header validation.
* Prevents host header injection attacks.
*/
allowedHosts?: string[];
/**
* Explicitly trust X-Forwarded-* headers without a whitelist.
* WARNING: Only enable if you trust your proxy configuration.
* @default false
*/
trustForwardingHeaders?: boolean;
/**
* List of trusted proxy IP addresses. X-Forwarded-* headers will only
* be trusted from these IPs.
*/
trustedProxyIPs?: string[];
/**
* The remote IP address of the request (for proxy validation).
*/
remoteIP?: string;
},
): PlivoVerificationResult {
if (options?.skipVerification) {
const replayKey = createSkippedVerificationReplayKey("plivo", ctx);
const isReplay = markReplay(plivoReplayCache, replayKey);
return {
ok: true,
reason: "verification skipped (dev mode)",
isReplay,
verifiedRequestKey: replayKey,
};
}
const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3");
const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
const signatureV2 = getHeader(ctx.headers, "x-plivo-signature-v2");
const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
const reconstructed = reconstructWebhookUrl(ctx, {
allowedHosts: options?.allowedHosts,
trustForwardingHeaders: options?.trustForwardingHeaders,
trustedProxyIPs: options?.trustedProxyIPs,
remoteIP: options?.remoteIP,
});
let verificationUrl = reconstructed;
if (options?.publicUrl) {
try {
const req = new URL(reconstructed);
const base = new URL(options.publicUrl);
base.pathname = req.pathname;
base.search = req.search;
verificationUrl = base.toString();
} catch {
verificationUrl = reconstructed;
}
}
if (signatureV3 && nonceV3) {
const method = ctx.method === "GET" || ctx.method === "POST" ? ctx.method : null;
if (!method) {
return {
ok: false,
version: "v3",
verificationUrl,
reason: `Unsupported HTTP method for Plivo V3 signature: ${ctx.method}`,
};
}
const postParams = toParamMapFromSearchParams(new URLSearchParams(ctx.rawBody));
const ok = validatePlivoV3Signature({
authToken,
signatureHeader: signatureV3,
nonce: nonceV3,
method,
url: verificationUrl,
postParams,
});
if (!ok) {
return {
ok: false,
version: "v3",
verificationUrl,
reason: "Invalid Plivo V3 signature",
};
}
const replayKey = createPlivoV3ReplayKey({
method,
url: verificationUrl,
postParams,
nonce: nonceV3,
});
const isReplay = markReplay(plivoReplayCache, replayKey);
return { ok: true, version: "v3", verificationUrl, isReplay, verifiedRequestKey: replayKey };
}
if (signatureV2 && nonceV2) {
const ok = validatePlivoV2Signature({
authToken,
signature: signatureV2,
nonce: nonceV2,
url: verificationUrl,
});
if (!ok) {
return {
ok: false,
version: "v2",
verificationUrl,
reason: "Invalid Plivo V2 signature",
};
}
const replayKey = createPlivoV2ReplayKey(verificationUrl, nonceV2);
const isReplay = markReplay(plivoReplayCache, replayKey);
return { ok: true, version: "v2", verificationUrl, isReplay, verifiedRequestKey: replayKey };
}
return {
ok: false,
reason: "Missing Plivo signature headers (V3 or V2)",
verificationUrl,
};
}

View file

@ -0,0 +1,135 @@
import { afterEach, describe, expect, it } from "vitest";
import { VoiceCallConfigSchema, type VoiceCallConfig } from "./config.js";
import { CallManager } from "./manager.js";
import { createTestStorePath, FakeProvider } from "./manager.test-harness.js";
import type { WebhookContext, WebhookParseOptions } from "./types.js";
import { VoiceCallWebhookServer } from "./webhook.js";
const createConfig = (overrides: Partial<VoiceCallConfig> = {}): VoiceCallConfig => {
const base = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
});
base.serve.port = 0;
return {
...base,
...overrides,
serve: {
...base.serve,
...overrides.serve,
},
};
};
async function postWebhookForm(server: VoiceCallWebhookServer, baseUrl: string, body: string) {
const address = (
server as unknown as { server?: { address?: () => unknown } }
).server?.address?.();
const requestUrl = new URL(baseUrl);
if (
!address ||
typeof address !== "object" ||
!("port" in address) ||
(typeof address.port !== "number" && typeof address.port !== "string") ||
!address.port
) {
throw new Error("voice webhook server did not expose a bound port");
}
requestUrl.port = String(address.port);
return await fetch(requestUrl.toString(), {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
"x-plivo-signature-v2": "sig",
"x-plivo-signature-v2-nonce": "nonce",
},
body,
});
}
async function runDuplicateInboundReplayLifecycleTest(provider: FakeProvider) {
const config = createConfig();
const manager = new CallManager(config, createTestStorePath());
await manager.initialize(provider, "https://example.com/voice/webhook");
const server = new VoiceCallWebhookServer(config, manager, provider);
try {
const baseUrl = await server.start();
const first = await postWebhookForm(server, baseUrl, "CallSid=CA123&From=%2B15552222222");
const second = await postWebhookForm(server, baseUrl, "CallSid=CA123&From=%2B15552222222");
return { first, second, manager };
} finally {
await server.stop();
}
}
function expectSingleRejectedReplayHangup(params: {
first: Response;
second: Response;
provider: FakeProvider;
manager: CallManager;
}) {
expect(params.first.status).toBe(200);
expect(params.second.status).toBe(200);
expect(params.provider.hangupCalls).toHaveLength(1);
expect(params.provider.hangupCalls[0]).toEqual(
expect.objectContaining({
providerCallId: "provider-inbound-1",
reason: "hangup-bot",
}),
);
expect(params.manager.getCallByProviderCallId("provider-inbound-1")).toBeUndefined();
}
class RejectInboundReplayProvider extends FakeProvider {
override verifyWebhook() {
return { ok: true, verifiedRequestKey: "verified:req:reject-once" };
}
override parseWebhookEvent(_ctx: WebhookContext, options?: WebhookParseOptions) {
return {
statusCode: 200,
events: [
{
id: "evt-reject-once",
dedupeKey: options?.verifiedRequestKey,
type: "call.initiated" as const,
callId: "provider-inbound-1",
providerCallId: "provider-inbound-1",
timestamp: Date.now(),
direction: "inbound" as const,
from: "+15552222222",
to: "+15550000000",
},
],
};
}
}
class RejectInboundReplayWithHangupFailureProvider extends RejectInboundReplayProvider {
override async hangupCall(input: Parameters<FakeProvider["hangupCall"]>[0]): Promise<void> {
this.hangupCalls.push(input);
throw new Error("hangup failed");
}
}
describe("Voice-call webhook hangup-once lifecycle", () => {
afterEach(() => {
// Each test uses an isolated store path, so only server cleanup is needed.
});
it("hangs up a rejected inbound replay only once across duplicate webhook delivery", async () => {
const provider = new RejectInboundReplayProvider("plivo");
const { first, second, manager } = await runDuplicateInboundReplayLifecycleTest(provider);
expectSingleRejectedReplayHangup({ first, second, provider, manager });
});
it("does not attempt a second hangup when replay arrives after the first hangup fails", async () => {
const provider = new RejectInboundReplayWithHangupFailureProvider("plivo");
const { first, second, manager } = await runDuplicateInboundReplayLifecycleTest(provider);
expectSingleRejectedReplayHangup({ first, second, provider, manager });
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,806 @@
import http from "node:http";
import { URL } from "node:url";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
createWebhookInFlightLimiter,
WEBHOOK_BODY_READ_DEFAULTS,
} from "openclaw/plugin-sdk/webhook-ingress";
import {
isRequestBodyLimitError,
readRequestBodyWithLimit,
requestBodyErrorToText,
} from "../api.js";
import { normalizeVoiceCallConfig, type VoiceCallConfig } from "./config.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
import { getHeader } from "./http-headers.js";
import type { CallManager } from "./manager.js";
import type { MediaStreamConfig } from "./media-stream.js";
import { MediaStreamHandler } from "./media-stream.js";
import { resolveConfiguredCapabilityProvider } from "./provider-runtime-resolution.js";
import type { VoiceCallProvider } from "./providers/base.js";
import { isProviderStatusTerminal } from "./providers/shared/call-status.js";
import type { TwilioProvider } from "./providers/twilio.js";
import type { CallRecord, NormalizedEvent, WebhookContext } from "./types.js";
import type { WebhookResponsePayload } from "./webhook.types.js";
import type { RealtimeCallHandler } from "./webhook/realtime-handler.js";
import { startStaleCallReaper } from "./webhook/stale-call-reaper.js";
const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2000;
const TRANSCRIPT_LOG_MAX_CHARS = 200;
type RealtimeTranscriptionRuntime = typeof import("./realtime-transcription.runtime.js");
type ResponseGeneratorModule = typeof import("./response-generator.js");
let realtimeTranscriptionRuntimePromise: Promise<RealtimeTranscriptionRuntime> | undefined;
let responseGeneratorModulePromise: Promise<ResponseGeneratorModule> | undefined;
function loadRealtimeTranscriptionRuntime(): Promise<RealtimeTranscriptionRuntime> {
realtimeTranscriptionRuntimePromise ??= import("./realtime-transcription.runtime.js");
return realtimeTranscriptionRuntimePromise;
}
function loadResponseGeneratorModule(): Promise<ResponseGeneratorModule> {
responseGeneratorModulePromise ??= import("./response-generator.js");
return responseGeneratorModulePromise;
}
type WebhookHeaderGateResult =
| { ok: true }
| {
ok: false;
reason: string;
};
function sanitizeTranscriptForLog(value: string): string {
const sanitized = value
.replace(/\p{Cc}/gu, " ")
.replace(/\s+/g, " ")
.trim();
if (sanitized.length <= TRANSCRIPT_LOG_MAX_CHARS) {
return sanitized;
}
return `${sanitized.slice(0, TRANSCRIPT_LOG_MAX_CHARS)}...`;
}
function buildRequestUrl(
requestUrl: string | undefined,
requestHost: string | undefined,
fallbackHost = "localhost",
): URL {
return new URL(requestUrl ?? "/", `http://${requestHost ?? fallbackHost}`);
}
function normalizeProxyIp(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
const unwrapped =
trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
const normalized = unwrapped.toLowerCase();
const mappedIpv4Prefix = "::ffff:";
if (normalized.startsWith(mappedIpv4Prefix)) {
const mappedIpv4 = normalized.slice(mappedIpv4Prefix.length);
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(mappedIpv4)) {
return mappedIpv4;
}
}
return normalized;
}
function resolveForwardedClientIp(
request: http.IncomingMessage,
trustedProxyIPs: readonly string[],
): string | undefined {
const normalizedTrustedProxyIps = new Set(
trustedProxyIPs.map((ip) => normalizeProxyIp(ip)).filter((ip): ip is string => Boolean(ip)),
);
const forwardedFor = getHeader(request.headers, "x-forwarded-for");
if (forwardedFor) {
const forwardedIps = forwardedFor
.split(",")
.map((part) => part.trim())
.filter(Boolean);
if (forwardedIps.length > 0) {
if (normalizedTrustedProxyIps.size === 0) {
return forwardedIps[0];
}
for (let index = forwardedIps.length - 1; index >= 0; index -= 1) {
const hop = forwardedIps[index];
if (!normalizedTrustedProxyIps.has(normalizeProxyIp(hop) ?? "")) {
return hop;
}
}
return forwardedIps[0];
}
}
const realIp = getHeader(request.headers, "x-real-ip")?.trim();
return realIp || undefined;
}
function normalizeWebhookResponse(parsed: {
statusCode?: number;
providerResponseHeaders?: Record<string, string>;
providerResponseBody?: string;
}): WebhookResponsePayload {
return {
statusCode: parsed.statusCode ?? 200,
headers: parsed.providerResponseHeaders,
body: parsed.providerResponseBody ?? "OK",
};
}
/**
* HTTP server for receiving voice call webhooks from providers.
* Supports WebSocket upgrades for media streams when streaming is enabled.
*/
export class VoiceCallWebhookServer {
private server: http.Server | null = null;
private listeningUrl: string | null = null;
private config: VoiceCallConfig;
private manager: CallManager;
private provider: VoiceCallProvider;
private coreConfig: CoreConfig | null;
private fullConfig: OpenClawConfig | null;
private agentRuntime: CoreAgentDeps | null;
private stopStaleCallReaper: (() => void) | null = null;
private readonly webhookInFlightLimiter = createWebhookInFlightLimiter();
/** Media stream handler for bidirectional audio (when streaming enabled) */
private mediaStreamHandler: MediaStreamHandler | null = null;
/** Delayed auto-hangup timers keyed by provider call ID after stream disconnect. */
private pendingDisconnectHangups = new Map<string, ReturnType<typeof setTimeout>>();
/** Realtime voice handler for duplex provider bridges. */
private realtimeHandler: RealtimeCallHandler | null = null;
constructor(
config: VoiceCallConfig,
manager: CallManager,
provider: VoiceCallProvider,
coreConfig?: CoreConfig,
fullConfig?: OpenClawConfig,
agentRuntime?: CoreAgentDeps,
) {
this.config = normalizeVoiceCallConfig(config);
this.manager = manager;
this.provider = provider;
this.coreConfig = coreConfig ?? null;
this.fullConfig = fullConfig ?? null;
this.agentRuntime = agentRuntime ?? null;
}
/**
* Get the media stream handler (for wiring to provider).
*/
getMediaStreamHandler(): MediaStreamHandler | null {
return this.mediaStreamHandler;
}
getRealtimeHandler(): RealtimeCallHandler | null {
return this.realtimeHandler;
}
setRealtimeHandler(handler: RealtimeCallHandler): void {
this.realtimeHandler = handler;
}
private clearPendingDisconnectHangup(providerCallId: string): void {
const existing = this.pendingDisconnectHangups.get(providerCallId);
if (!existing) {
return;
}
clearTimeout(existing);
this.pendingDisconnectHangups.delete(providerCallId);
}
private resolveMediaStreamClientIp(request: http.IncomingMessage): string | undefined {
const remoteIp = request.socket.remoteAddress ?? undefined;
const trustedProxyIPs = this.config.webhookSecurity.trustedProxyIPs.filter(Boolean);
const normalizedTrustedProxyIps = new Set(
trustedProxyIPs.map((ip) => normalizeProxyIp(ip)).filter((ip): ip is string => Boolean(ip)),
);
const normalizedRemoteIp = normalizeProxyIp(remoteIp);
const fromTrustedProxy =
normalizedTrustedProxyIps.size > 0 &&
normalizedRemoteIp !== undefined &&
normalizedTrustedProxyIps.has(normalizedRemoteIp);
const shouldTrustForwardingHeaders =
this.config.webhookSecurity.trustForwardingHeaders && fromTrustedProxy;
if (shouldTrustForwardingHeaders) {
const forwardedIp = resolveForwardedClientIp(request, trustedProxyIPs);
if (forwardedIp) {
return forwardedIp;
}
}
return remoteIp;
}
private shouldSuppressBargeInForInitialMessage(call: CallRecord | undefined): boolean {
if (!call || call.direction !== "outbound") {
return false;
}
// Suppress only while the initial greeting is actively being played.
// If playback fails and the call leaves "speaking", do not block auto-response.
if (call.state !== "speaking") {
return false;
}
const mode = (call.metadata?.mode as string | undefined) ?? "conversation";
if (mode !== "conversation") {
return false;
}
const initialMessage = normalizeOptionalString(call.metadata?.initialMessage) ?? "";
return initialMessage.length > 0;
}
/**
* Initialize media streaming with the selected realtime transcription provider.
*/
private async initializeMediaStreaming(): Promise<void> {
const streaming = this.config.streaming;
const pluginConfig =
this.fullConfig ?? (this.coreConfig as unknown as OpenClawConfig | undefined);
const { getRealtimeTranscriptionProvider, listRealtimeTranscriptionProviders } =
await loadRealtimeTranscriptionRuntime();
const resolution = resolveConfiguredCapabilityProvider({
configuredProviderId: streaming.provider,
providerConfigs: streaming.providers,
cfg: pluginConfig,
cfgForResolve: pluginConfig ?? ({} as OpenClawConfig),
getConfiguredProvider: (providerId) =>
getRealtimeTranscriptionProvider(providerId, pluginConfig),
listProviders: () => listRealtimeTranscriptionProviders(pluginConfig),
resolveProviderConfig: ({ provider, cfg, rawConfig }) =>
provider.resolveConfig?.({ cfg, rawConfig }) ?? rawConfig,
isProviderConfigured: ({ provider, cfg, providerConfig }) =>
provider.isConfigured({ cfg, providerConfig }),
});
if (!resolution.ok && resolution.code === "missing-configured-provider") {
console.warn(
`[voice-call] Streaming enabled but realtime transcription provider "${resolution.configuredProviderId}" is not registered`,
);
return;
}
if (!resolution.ok && resolution.code === "no-registered-provider") {
console.warn(
"[voice-call] Streaming enabled but no realtime transcription provider is registered",
);
return;
}
if (!resolution.ok) {
console.warn(
`[voice-call] Streaming enabled but provider "${resolution.provider?.id}" is not configured`,
);
return;
}
const provider = resolution.provider;
const providerConfig = resolution.providerConfig;
const streamConfig: MediaStreamConfig = {
transcriptionProvider: provider,
providerConfig,
preStartTimeoutMs: streaming.preStartTimeoutMs,
maxPendingConnections: streaming.maxPendingConnections,
maxPendingConnectionsPerIp: streaming.maxPendingConnectionsPerIp,
maxConnections: streaming.maxConnections,
resolveClientIp: (request) => this.resolveMediaStreamClientIp(request),
shouldAcceptStream: ({ callId, token }) => {
const call = this.manager.getCallByProviderCallId(callId);
if (!call) {
return false;
}
if (this.provider.name === "twilio") {
const twilio = this.provider as TwilioProvider;
if (!twilio.isValidStreamToken(callId, token)) {
console.warn(`[voice-call] Rejecting media stream: invalid token for ${callId}`);
return false;
}
}
return true;
},
onTranscript: (providerCallId, transcript) => {
const safeTranscript = sanitizeTranscriptForLog(transcript);
console.log(
`[voice-call] Transcript for ${providerCallId}: ${safeTranscript} (chars=${transcript.length})`,
);
const call = this.manager.getCallByProviderCallId(providerCallId);
if (!call) {
console.warn(`[voice-call] No active call found for provider ID: ${providerCallId}`);
return;
}
const suppressBargeIn = this.shouldSuppressBargeInForInitialMessage(call);
if (suppressBargeIn) {
console.log(
`[voice-call] Ignoring barge transcript while initial message is still playing (${providerCallId})`,
);
return;
}
// Clear TTS queue on barge-in (user started speaking, interrupt current playback)
if (this.provider.name === "twilio") {
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
}
// Create a speech event and process it through the manager
const event: NormalizedEvent = {
id: `stream-transcript-${Date.now()}`,
type: "call.speech",
callId: call.callId,
providerCallId,
timestamp: Date.now(),
transcript,
isFinal: true,
};
this.manager.processEvent(event);
// Auto-respond in conversation mode (inbound always, outbound if mode is conversation)
const callMode = call.metadata?.mode as string | undefined;
const shouldRespond = call.direction === "inbound" || callMode === "conversation";
if (shouldRespond) {
this.handleInboundResponse(call.callId, transcript).catch((err) => {
console.warn(`[voice-call] Failed to auto-respond:`, err);
});
}
},
onSpeechStart: (providerCallId) => {
if (this.provider.name !== "twilio") {
return;
}
const call = this.manager.getCallByProviderCallId(providerCallId);
if (this.shouldSuppressBargeInForInitialMessage(call)) {
return;
}
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
},
onPartialTranscript: (callId, partial) => {
const safePartial = sanitizeTranscriptForLog(partial);
console.log(`[voice-call] Partial for ${callId}: ${safePartial} (chars=${partial.length})`);
},
onConnect: (callId, streamSid) => {
console.log(`[voice-call] Media stream connected: ${callId} -> ${streamSid}`);
this.clearPendingDisconnectHangup(callId);
// Register stream with provider for TTS routing
if (this.provider.name === "twilio") {
(this.provider as TwilioProvider).registerCallStream(callId, streamSid);
}
// Speak initial message immediately (no delay) to avoid stream timeout
this.manager.speakInitialMessage(callId).catch((err) => {
console.warn(`[voice-call] Failed to speak initial message:`, err);
});
},
onDisconnect: (callId, streamSid) => {
console.log(`[voice-call] Media stream disconnected: ${callId} (${streamSid})`);
if (this.provider.name === "twilio") {
(this.provider as TwilioProvider).unregisterCallStream(callId, streamSid);
}
this.clearPendingDisconnectHangup(callId);
const timer = setTimeout(() => {
this.pendingDisconnectHangups.delete(callId);
const disconnectedCall = this.manager.getCallByProviderCallId(callId);
if (!disconnectedCall) {
return;
}
if (this.provider.name === "twilio") {
const twilio = this.provider as TwilioProvider;
if (twilio.hasRegisteredStream(callId)) {
return;
}
}
console.log(
`[voice-call] Auto-ending call ${disconnectedCall.callId} after stream disconnect grace`,
);
void this.manager.endCall(disconnectedCall.callId).catch((err) => {
console.warn(`[voice-call] Failed to auto-end call ${disconnectedCall.callId}:`, err);
});
}, STREAM_DISCONNECT_HANGUP_GRACE_MS);
timer.unref?.();
this.pendingDisconnectHangups.set(callId, timer);
},
};
this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
console.log("[voice-call] Media streaming initialized");
}
/**
* Start the webhook server.
* Idempotent: returns immediately if the server is already listening.
*/
async start(): Promise<string> {
const { port, bind, path: webhookPath } = this.config.serve;
const streamPath = this.config.streaming.streamPath;
// Guard: if a server is already listening, return the existing URL.
// This prevents EADDRINUSE when start() is called more than once on the
// same instance (e.g. during config hot-reload or concurrent ensureRuntime).
if (this.server?.listening) {
return this.listeningUrl ?? this.resolveListeningUrl(bind, webhookPath);
}
if (this.config.streaming.enabled && !this.mediaStreamHandler) {
await this.initializeMediaStreaming();
}
return new Promise((resolve, reject) => {
this.server = http.createServer((req, res) => {
this.handleRequest(req, res, webhookPath).catch((err) => {
console.error("[voice-call] Webhook error:", err);
res.statusCode = 500;
res.end("Internal Server Error");
});
});
// Handle WebSocket upgrades for realtime voice and media streams.
if (this.realtimeHandler || this.mediaStreamHandler) {
this.server.on("upgrade", (request, socket, head) => {
if (this.realtimeHandler && this.isRealtimeWebSocketUpgrade(request)) {
this.realtimeHandler.handleWebSocketUpgrade(request, socket, head);
return;
}
const path = this.getUpgradePathname(request);
if (path === streamPath && this.mediaStreamHandler) {
this.mediaStreamHandler?.handleUpgrade(request, socket, head);
} else {
socket.destroy();
}
});
}
this.server.on("error", reject);
this.server.listen(port, bind, () => {
const url = this.resolveListeningUrl(bind, webhookPath);
this.listeningUrl = url;
console.log(`[voice-call] Webhook server listening on ${url}`);
if (this.mediaStreamHandler) {
const address = this.server?.address();
const actualPort =
address && typeof address === "object" ? address.port : this.config.serve.port;
console.log(
`[voice-call] Media stream WebSocket on ws://${bind}:${actualPort}${streamPath}`,
);
}
resolve(url);
// Start the stale call reaper if configured
this.stopStaleCallReaper = startStaleCallReaper({
manager: this.manager,
staleCallReaperSeconds: this.config.staleCallReaperSeconds,
});
});
});
}
/**
* Stop the webhook server.
*/
async stop(): Promise<void> {
for (const timer of this.pendingDisconnectHangups.values()) {
clearTimeout(timer);
}
this.pendingDisconnectHangups.clear();
this.webhookInFlightLimiter.clear();
if (this.stopStaleCallReaper) {
this.stopStaleCallReaper();
this.stopStaleCallReaper = null;
}
return new Promise((resolve) => {
if (this.server) {
this.server.close(() => {
this.server = null;
this.listeningUrl = null;
resolve();
});
} else {
this.listeningUrl = null;
resolve();
}
});
}
private resolveListeningUrl(bind: string, webhookPath: string): string {
const address = this.server?.address();
if (address && typeof address === "object") {
const host = address.address && address.address.length > 0 ? address.address : bind;
const normalizedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
return `http://${normalizedHost}:${address.port}${webhookPath}`;
}
return `http://${bind}:${this.config.serve.port}${webhookPath}`;
}
private getUpgradePathname(request: http.IncomingMessage): string | null {
try {
return buildRequestUrl(request.url, request.headers.host).pathname;
} catch {
return null;
}
}
private normalizeWebhookPathForMatch(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;
}
private isWebhookPathMatch(requestPath: string, configuredPath: string): boolean {
return (
this.normalizeWebhookPathForMatch(requestPath) ===
this.normalizeWebhookPathForMatch(configuredPath)
);
}
/**
* Handle incoming HTTP request.
*/
private async handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse,
webhookPath: string,
): Promise<void> {
const payload = await this.runWebhookPipeline(req, webhookPath);
this.writeWebhookResponse(res, payload);
}
private async runWebhookPipeline(
req: http.IncomingMessage,
webhookPath: string,
): Promise<WebhookResponsePayload> {
const url = buildRequestUrl(req.url, req.headers.host);
if (url.pathname === "/voice/hold-music") {
return {
statusCode: 200,
headers: { "Content-Type": "text/xml" },
body: `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="alice">All agents are currently busy. Please hold.</Say>
<Play loop="0">https://s3.amazonaws.com/com.twilio.music.classical/BusyStrings.mp3</Play>
</Response>`,
};
}
if (!this.isWebhookPathMatch(url.pathname, webhookPath)) {
return { statusCode: 404, body: "Not Found" };
}
if (req.method !== "POST") {
return { statusCode: 405, body: "Method Not Allowed" };
}
const headerGate = this.verifyPreAuthWebhookHeaders(req.headers);
if (!headerGate.ok) {
console.warn(`[voice-call] Webhook rejected before body read: ${headerGate.reason}`);
return { statusCode: 401, body: "Unauthorized" };
}
const inFlightKey = req.socket.remoteAddress ?? "";
if (!this.webhookInFlightLimiter.tryAcquire(inFlightKey)) {
console.warn(`[voice-call] Webhook rejected before body read: too many in-flight requests`);
return { statusCode: 429, body: "Too Many Requests" };
}
try {
let body = "";
try {
body = await this.readBody(req, MAX_WEBHOOK_BODY_BYTES, WEBHOOK_BODY_TIMEOUT_MS);
} catch (err) {
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
return { statusCode: 413, body: "Payload Too Large" };
}
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
return { statusCode: 408, body: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") };
}
throw err;
}
const ctx: WebhookContext = {
headers: req.headers as Record<string, string | string[] | undefined>,
rawBody: body,
url: url.toString(),
method: "POST",
query: Object.fromEntries(url.searchParams),
remoteAddress: req.socket.remoteAddress ?? undefined,
};
const verification = this.provider.verifyWebhook(ctx);
if (!verification.ok) {
console.warn(`[voice-call] Webhook verification failed: ${verification.reason}`);
return { statusCode: 401, body: "Unauthorized" };
}
if (!verification.verifiedRequestKey) {
console.warn("[voice-call] Webhook verification succeeded without request identity key");
return { statusCode: 401, body: "Unauthorized" };
}
if (this.shouldShortCircuitToRealtimeTwiml(ctx)) {
return this.realtimeHandler!.buildTwiMLPayload(req, new URLSearchParams(ctx.rawBody));
}
const parsed = this.provider.parseWebhookEvent(ctx, {
verifiedRequestKey: verification.verifiedRequestKey,
});
if (verification.isReplay) {
console.warn("[voice-call] Replay detected; skipping event side effects");
} else {
this.processParsedEvents(parsed.events);
}
return normalizeWebhookResponse(parsed);
} finally {
this.webhookInFlightLimiter.release(inFlightKey);
}
}
private verifyPreAuthWebhookHeaders(headers: http.IncomingHttpHeaders): WebhookHeaderGateResult {
if (this.config.skipSignatureVerification) {
return { ok: true };
}
switch (this.provider.name) {
case "telnyx": {
const signature = getHeader(headers, "telnyx-signature-ed25519");
const timestamp = getHeader(headers, "telnyx-timestamp");
if (signature && timestamp) {
return { ok: true };
}
return { ok: false, reason: "missing Telnyx signature or timestamp header" };
}
case "twilio":
if (getHeader(headers, "x-twilio-signature")) {
return { ok: true };
}
return { ok: false, reason: "missing X-Twilio-Signature header" };
case "plivo": {
const hasV3 =
Boolean(getHeader(headers, "x-plivo-signature-v3")) &&
Boolean(getHeader(headers, "x-plivo-signature-v3-nonce"));
const hasV2 =
Boolean(getHeader(headers, "x-plivo-signature-v2")) &&
Boolean(getHeader(headers, "x-plivo-signature-v2-nonce"));
if (hasV3 || hasV2) {
return { ok: true };
}
return { ok: false, reason: "missing Plivo signature headers" };
}
default:
return { ok: true };
}
}
private isRealtimeWebSocketUpgrade(req: http.IncomingMessage): boolean {
try {
const pathname = buildRequestUrl(req.url, req.headers.host).pathname;
const pattern = this.realtimeHandler?.getStreamPathPattern();
return Boolean(pattern && pathname.startsWith(pattern));
} catch {
return false;
}
}
private shouldShortCircuitToRealtimeTwiml(ctx: WebhookContext): boolean {
if (!this.realtimeHandler || this.provider.name !== "twilio") {
return false;
}
const params = new URLSearchParams(ctx.rawBody);
const direction = params.get("Direction");
const isInbound = !direction || direction === "inbound";
if (!isInbound) {
return false;
}
if (ctx.query?.type === "status") {
return false;
}
const callStatus = params.get("CallStatus");
if (callStatus && isProviderStatusTerminal(callStatus)) {
return false;
}
// Replays must return the same TwiML body so Twilio retries reconnect cleanly.
// The one-time token still changes, but the behavior stays identical.
return !params.get("SpeechResult") && !params.get("Digits");
}
private processParsedEvents(events: NormalizedEvent[]): void {
for (const event of events) {
try {
this.manager.processEvent(event);
} catch (err) {
console.error(`[voice-call] Error processing event ${event.type}:`, err);
}
}
}
private writeWebhookResponse(res: http.ServerResponse, payload: WebhookResponsePayload): void {
res.statusCode = payload.statusCode;
if (payload.headers) {
for (const [key, value] of Object.entries(payload.headers)) {
res.setHeader(key, value);
}
}
res.end(payload.body);
}
/**
* Read request body as string with timeout protection.
*/
private readBody(
req: http.IncomingMessage,
maxBytes: number,
timeoutMs = WEBHOOK_BODY_TIMEOUT_MS,
): Promise<string> {
return readRequestBodyWithLimit(req, { maxBytes, timeoutMs });
}
/**
* Handle auto-response for inbound calls using the agent system.
* Supports tool calling for richer voice interactions.
*/
private async handleInboundResponse(callId: string, userMessage: string): Promise<void> {
console.log(`[voice-call] Auto-responding to inbound call ${callId}: "${userMessage}"`);
// Get call context for conversation history
const call = this.manager.getCall(callId);
if (!call) {
console.warn(`[voice-call] Call ${callId} not found for auto-response`);
return;
}
if (!this.coreConfig) {
console.warn("[voice-call] Core config missing; skipping auto-response");
return;
}
if (!this.agentRuntime) {
console.warn("[voice-call] Agent runtime missing; skipping auto-response");
return;
}
try {
const { generateVoiceResponse } = await loadResponseGeneratorModule();
const result = await generateVoiceResponse({
voiceConfig: this.config,
coreConfig: this.coreConfig,
agentRuntime: this.agentRuntime,
callId,
from: call.from,
transcript: call.transcript,
userMessage,
});
if (result.error) {
console.error(`[voice-call] Response generation error: ${result.error}`);
return;
}
if (result.text) {
console.log(`[voice-call] AI response: "${result.text}"`);
await this.manager.speak(callId, result.text);
}
} catch (err) {
console.error(`[voice-call] Auto-response error:`, err);
}
}
}

View file

@ -0,0 +1,5 @@
export type WebhookResponsePayload = {
statusCode: number;
body: string;
headers?: Record<string, string>;
};

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

View file

@ -0,0 +1,72 @@
import { once } from "node:events";
import http from "node:http";
import { WebSocket } from "ws";
export const withTimeout = async <T>(promise: Promise<T>, timeoutMs = 2000): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
};
export const startUpgradeWsServer = async (params: {
urlPath: string;
onUpgrade: (
request: http.IncomingMessage,
socket: Parameters<http.Server["emit"]>[2],
head: Buffer,
) => void;
}): Promise<{
url: string;
close: () => Promise<void>;
}> => {
const server = http.createServer();
server.on("upgrade", (request, socket, head) => {
params.onUpgrade(request, socket, head);
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Failed to resolve test server address");
}
return {
url: `ws://127.0.0.1:${address.port}${params.urlPath}`,
close: async () => {
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
},
};
};
export const connectWs = async (url: string): Promise<WebSocket> => {
const ws = new WebSocket(url);
await withTimeout(once(ws, "open") as Promise<[unknown]>);
return ws;
};
export const waitForClose = async (
ws: WebSocket,
): Promise<{
code: number;
reason: string;
}> => {
const [code, reason] = (await withTimeout(once(ws, "close") as Promise<[number, Buffer]>)) ?? [];
return {
code,
reason: Buffer.isBuffer(reason) ? reason.toString("utf8") : String(reason || ""),
};
};