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