Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
# @openclaw/voice-call
Official Voice Call plugin for **OpenClaw**.
Providers:
- **Twilio** (Programmable Voice + Media Streams)
- **Telnyx** (Call Control v2)
- **Plivo** (Voice API + XML transfer + GetInput speech)
- **Mock** (dev/no network)
Docs: `https://docs.openclaw.ai/plugins/voice-call`
Plugin system: `https://docs.openclaw.ai/tools/plugin`
## Install
```bash
openclaw plugins install @openclaw/voice-call
```
Restart the Gateway afterwards.
## Local dev install
```bash
PLUGIN_HOME=~/.openclaw/extensions
mkdir -p "$PLUGIN_HOME"
cp -R <local-plugin-checkout> "$PLUGIN_HOME/voice-call"
cd "$PLUGIN_HOME/voice-call" && pnpm install
```
## Config
Put under `plugins.entries.voice-call.config`:
```json5
{
provider: "twilio", // or "telnyx" | "plivo" | "mock"
fromNumber: "+15550001234",
toNumber: "+15550005678",
sessionScope: "per-phone", // or "per-call"
twilio: {
accountSid: "ACxxxxxxxx",
authToken: "your_token",
},
telnyx: {
apiKey: "KEYxxxx",
connectionId: "CONNxxxx",
// Telnyx webhook public key from the Telnyx Mission Control Portal
// (Base64 string; can also be set via TELNYX_PUBLIC_KEY).
publicKey: "...",
},
plivo: {
authId: "MAxxxxxxxxxxxxxxxxxxxx",
authToken: "your_token",
},
// Webhook server
serve: {
port: 3334,
path: "/voice/webhook",
},
// Public exposure (pick one):
// publicUrl: "https://example.ngrok.app/voice/webhook",
// tunnel: { provider: "ngrok" },
// tailscale: { mode: "funnel", path: "/voice/webhook" }
outbound: {
defaultMode: "notify", // or "conversation"
},
// Optional response agent workspace. Defaults to "main".
agentId: "main",
streaming: {
enabled: true,
// optional; if omitted, Voice Call picks the first registered
// realtime-transcription provider by autoSelectOrder
provider: "<realtime-transcription-provider-id>",
streamPath: "/voice/stream",
providers: {
"<realtime-transcription-provider-id>": {
// provider-owned options
},
},
preStartTimeoutMs: 5000,
maxPendingConnections: 32,
maxPendingConnectionsPerIp: 4,
maxConnections: 128,
},
}
```
Notes:
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
- `mock` is a local dev provider (no network calls).
- Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true.
- If older configs still use `provider: "log"`, `twilio.from`, or legacy `streaming.*` OpenAI keys, run `openclaw doctor --fix` to rewrite them.
- advanced webhook, streaming, and tunnel notes: `https://docs.openclaw.ai/plugins/voice-call`
- `responseModel` is optional. When unset, voice responses use the runtime default model.
- `sessionScope` defaults to `per-phone`, preserving caller memory across calls. Use `per-call` for reception, booking, IVR, and bridge flows where each carrier call should start fresh.
- `realtime.consultThinkingLevel` is optional. When set, it overrides the thinking level used by the model behind realtime `openclaw_agent_consult` calls.
- `realtime.consultFastMode` is optional. When set, it toggles fast mode for realtime `openclaw_agent_consult` calls.
## Stale call reaper
See the plugin docs for recommended ranges and production examples:
`https://docs.openclaw.ai/plugins/voice-call#stale-call-reaper`
## TTS for calls
Voice Call uses the core `messages.tts` configuration for
streaming speech on calls. Override examples and provider caveats live here:
`https://docs.openclaw.ai/plugins/voice-call#tts-for-calls`
## CLI
```bash
openclaw voicecall call --to "+15555550123" --message "Hello from OpenClaw"
openclaw voicecall continue --call-id <id> --message "Any questions?"
openclaw voicecall speak --call-id <id> --message "One moment"
openclaw voicecall end --call-id <id>
openclaw voicecall status --json
openclaw voicecall status --call-id <id>
openclaw voicecall tail
openclaw voicecall expose --mode funnel
```
## Tool
Tool name: `voice_call`
Actions:
- `initiate_call` (message, to?, mode?)
- `continue_call` (callId, message)
- `speak_to_user` (callId, message)
- `end_call` (callId)
- `get_status` (callId)
## Gateway RPC
- `voicecall.initiate` (to?, message, mode?)
- `voicecall.continue` (callId, message)
- `voicecall.speak` (callId, message)
- `voicecall.end` (callId)
- `voicecall.status` (callId)
## Notes
- Uses webhook signature verification for Twilio/Telnyx/Plivo.
- Adds replay protection for Twilio and Plivo webhooks (valid duplicate callbacks are ignored safely).
- Twilio speech turns include a per-turn token so stale/replayed callbacks cannot complete a newer turn.
- `responseModel` / `responseSystemPrompt` control AI auto-responses.
- Voice-call auto-responses enforce a spoken JSON contract (`{"spoken":"..."}`) and filter reasoning/meta output before playback.
- While a Twilio stream is active, playback does not fall back to TwiML `<Say>`; stream-TTS failures fail the playback request.
- Outbound conversation calls suppress barge-in only while the initial greeting is actively speaking, then re-enable normal interruption.
- Twilio stream disconnect auto-end uses a short grace window so quick reconnects do not end the call.
- Realtime provider selection is generic. Configure `streaming.provider` / `realtime.provider` and put provider-owned options under `providers.<id>`.
- Runtime fallback still accepts the old voice-call keys for now, but migration is a doctor step and the compat shim is scheduled to go away in a future release.

View File

@@ -0,0 +1,18 @@
// Public voice-call API barrel exposed to plugin-local modules and tests.
export {
definePluginEntry,
fetchWithSsrFGuard,
type GatewayRequestHandlerOptions,
isBlockedHostnameOrIp,
isRequestBodyLimitError,
type OpenClawPluginApi,
readRequestBodyWithLimit,
requestBodyErrorToText,
type SessionEntry,
sleep,
TtsAutoSchema,
TtsConfigSchema,
TtsModeSchema,
TtsProviderSchema,
} from "./runtime-api.js";

View File

@@ -0,0 +1,13 @@
// Voice Call plugin module implements cli metadata behavior.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
// Lightweight CLI metadata entry for exposing the voicecall command.
export default definePluginEntry({
id: "voice-call",
name: "Voice Call",
description: "Voice call channel plugin",
register(api) {
api.registerCli(() => {}, { commands: ["voicecall"] });
},
});

View File

@@ -0,0 +1,12 @@
// Narrow barrel for config compatibility helpers consumed outside the plugin.
// Keep this separate from api.ts so config migration code does not pull in the
// full runtime-oriented voice-call surface.
export {
VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION,
collectVoiceCallLegacyConfigIssues,
formatVoiceCallLegacyConfigWarnings,
migrateVoiceCallLegacyConfigInput,
normalizeVoiceCallLegacyConfigInput,
parseVoiceCallPluginConfig,
} from "./src/config-compat.js";

View File

@@ -0,0 +1,252 @@
// Voice Call tests cover doctor contract api plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
createPluginStateKeyedStoreForTests,
createPluginStateSyncKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type {
OpenKeyedStoreOptions,
PluginDoctorStateMigrationContext,
} from "openclaw/plugin-sdk/runtime-doctor";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveSessionStoreAgentIds, stateMigrations } from "./doctor-contract-api.js";
import {
createTestStorePath,
makePersistedCall,
writeLegacyCallsJsonl,
} from "./src/manager.test-harness.js";
import { getCallHistoryFromStore, loadActiveCallsFromStore } from "./src/manager/store.js";
import { clearVoiceCallStateRuntime, setVoiceCallStateRuntime } from "./src/runtime-state.js";
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
return {
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
return createPluginStateKeyedStoreForTests<T>("voice-call", {
...options,
env: options.env ?? env,
});
},
};
}
function installStateRuntime(): void {
setVoiceCallStateRuntime({
state: {
resolveStateDir: () => "",
openKeyedStore: (() => {
throw new Error("openKeyedStore is not used by voice-call doctor tests");
}) as never,
openSyncKeyedStore: (options: OpenKeyedStoreOptions) =>
createPluginStateSyncKeyedStoreForTests("voice-call", options),
openChannelIngressQueue: (() => {
throw new Error("openChannelIngressQueue is not used by voice-call doctor tests");
}) as never,
},
});
}
describe("voice-call doctor state migration", () => {
let stateDir = "";
let storePath = "";
let env: NodeJS.ProcessEnv;
beforeEach(async () => {
resetPluginStateStoreForTests();
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-voice-call-doctor-"));
storePath = createTestStorePath();
env = { ...process.env, HOME: stateDir, OPENCLAW_STATE_DIR: stateDir };
installStateRuntime();
});
afterEach(async () => {
clearVoiceCallStateRuntime();
resetPluginStateStoreForTests();
await fs.rm(stateDir, { recursive: true, force: true });
await fs.rm(storePath, { recursive: true, force: true });
});
it("reports top-level and per-number session-store agents", () => {
expect(
resolveSessionStoreAgentIds({
cfg: {
plugins: {
entries: {
"voice-call": {
config: {
agentId: "Voice",
numbers: {
"+15550001111": { agentId: "Cards" },
"+15550002222": {},
},
},
},
},
},
},
}),
).toEqual(["cards", "voice"]);
expect(
resolveSessionStoreAgentIds({
cfg: {
plugins: { entries: { "@openclaw/voice-call": { config: {} } } },
},
}),
).toEqual(["main"]);
expect(
resolveSessionStoreAgentIds({
cfg: {
plugins: { entries: { "voice-call": { enabled: true } } },
},
}),
).toEqual(["main"]);
});
it("imports legacy calls.jsonl into plugin state", async () => {
const sourcePath = path.join(storePath, "calls.jsonl");
const call = makePersistedCall({
callId: "call-doctor",
providerCallId: "provider-doctor",
processedEventIds: ["evt-doctor"],
});
writeLegacyCallsJsonl(storePath, [
{
version: 2,
persistedAt: 1000,
sequence: 0,
call,
},
]);
const migration = stateMigrations[0];
const config = {
plugins: {
entries: {
"@openclaw/voice-call": {
config: { store: storePath },
},
},
},
};
await expect(
migration.detectLegacyState({
config,
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
}),
).resolves.toMatchObject({
preview: [expect.stringContaining("1 record")],
});
const result = await migration.migrateLegacyState({
config,
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
});
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
expect.stringContaining("Migrated 1 Voice Call call-log record"),
expect.stringContaining("Archived Voice Call call-log legacy source"),
]);
await expect(fs.access(sourcePath)).rejects.toThrow();
await expect(fs.access(`${sourcePath}.migrated`)).resolves.toBeUndefined();
const restored = loadActiveCallsFromStore(storePath);
expect(restored.activeCalls.get("call-doctor")?.providerCallId).toBe("provider-doctor");
expect(restored.processedEventIds.has("evt-doctor")).toBe(true);
const history = await getCallHistoryFromStore(storePath);
expect(history).toHaveLength(1);
expect(history[0]?.callId).toBe("call-doctor");
});
it("imports the newest legacy call records when the JSONL log is over capacity", async () => {
const calls = Array.from({ length: 1002 }, (_, index) =>
makePersistedCall({
callId: `call-${index}`,
providerCallId: `provider-${index}`,
}),
);
writeLegacyCallsJsonl(storePath, calls);
const config = {
plugins: {
entries: {
"@openclaw/voice-call": {
config: { store: storePath },
},
},
},
};
const result = await stateMigrations[0].migrateLegacyState({
config,
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
});
expect(result.warnings).toEqual([
expect.stringContaining("Pruned 2 older Voice Call call-log records"),
]);
expect(result.changes).toEqual([
expect.stringContaining("Migrated 1000 Voice Call call-log records"),
expect.stringContaining("Archived Voice Call call-log legacy source"),
]);
const restored = loadActiveCallsFromStore(storePath);
expect(restored.activeCalls.has("call-0")).toBe(false);
expect(restored.activeCalls.has("call-1")).toBe(false);
expect(restored.activeCalls.get("call-1001")?.providerCallId).toBe("provider-1001");
const history = await getCallHistoryFromStore(storePath, 1000);
expect(history).toHaveLength(1000);
expect(history[0]?.callId).toBe("call-2");
expect(history.at(-1)?.callId).toBe("call-1001");
});
it("leaves malformed mixed legacy logs in place after importing valid records", async () => {
const sourcePath = path.join(storePath, "calls.jsonl");
const call = makePersistedCall({
callId: "call-valid",
providerCallId: "provider-valid",
});
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
await fs.writeFile(sourcePath, `${JSON.stringify(call)}\n{not json}\n`);
const config = {
plugins: {
entries: {
"@openclaw/voice-call": {
config: { store: storePath },
},
},
},
};
const result = await stateMigrations[0].migrateLegacyState({
config,
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
});
expect(result.changes).toEqual([
expect.stringContaining("Migrated 1 Voice Call call-log record"),
]);
expect(result.warnings).toEqual([
"Skipped malformed Voice Call call-log line 2",
"Left Voice Call call-log source in place because migration was incomplete",
]);
await expect(fs.access(sourcePath)).resolves.toBeUndefined();
await expect(fs.access(`${sourcePath}.migrated`)).rejects.toThrow();
expect(loadActiveCallsFromStore(storePath).activeCalls.has("call-valid")).toBe(true);
});
});

View File

@@ -0,0 +1,337 @@
// Voice Call API module exposes the plugin public contract.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import {
archiveLegacyStateSource,
type PluginDoctorStateMigration,
type PluginStateKeyedStore,
} from "openclaw/plugin-sdk/runtime-doctor";
import {
buildVoiceCallLegacyJsonlEventKey,
CALL_RECORD_CHUNK_MAX_ENTRIES,
CALL_RECORD_EVENT_CHUNKS_NAMESPACE,
CALL_RECORD_EVENT_META_MAX_ENTRIES,
CALL_RECORD_EVENTS_NAMESPACE,
MAX_CALL_RECORD_EVENTS,
MAX_CHUNKS_PER_CALL_RECORD_EVENT,
prepareVoiceCallRecordForStorage,
parseVoiceCallRecordLine,
RAW_CALL_RECORD_CHUNK_BYTES,
resolveVoiceCallLegacyCallLogPath,
} from "./src/manager/store.js";
import type { CallRecord } from "./src/types.js";
// Doctor state migration for Voice Call legacy JSONL call logs.
/** Plugin state metadata row for one migrated call record event. */
type CallRecordEventMeta = {
chunkCount: number;
byteLength: number;
persistedAt?: number;
sequence?: number;
};
/** Plugin state chunk row for one migrated call record event. */
type CallRecordEventChunk = {
index: number;
dataBase64: string;
};
/** Prepared legacy JSONL call record ready for plugin state import. */
type PreparedLegacyCallRecord = {
eventKey: string;
lineNumber: number;
chunks: CallRecordEventChunk[];
meta: CallRecordEventMeta;
};
/** Resolve home from doctor env with OS fallback. */
function resolveHome(env: NodeJS.ProcessEnv): string {
return env.HOME?.trim() || os.homedir();
}
/** Resolve config paths, including "~", against the doctor env home. */
function resolveUserPath(input: string, env: NodeJS.ProcessEnv): string {
const trimmed = input.trim();
if (!trimmed) {
return trimmed;
}
if (trimmed.startsWith("~")) {
return path.resolve(trimmed.replace(/^~(?=$|[\\/])/, resolveHome(env)));
}
return path.resolve(trimmed);
}
/** Read the configured voice-call store path from either package id. */
function getVoiceCallConfigStore(config: PluginDoctorStateMigrationParams["config"]): string {
for (const pluginId of ["voice-call", "@openclaw/voice-call"]) {
const rawConfig = config.plugins?.entries?.[pluginId]?.config;
if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) {
continue;
}
const store = (rawConfig as { store?: unknown }).store;
if (typeof store === "string" && store.trim()) {
return store.trim();
}
}
return "";
}
type PluginDoctorStateMigrationParams = Parameters<
PluginDoctorStateMigration["detectLegacyState"]
>[0];
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
/** Return Voice Call agents whose templated core session stores need migration. */
export function resolveSessionStoreAgentIds(params: { cfg: OpenClawConfig }): string[] {
const agentIds = new Set<string>();
for (const pluginId of ["voice-call", "@openclaw/voice-call"]) {
const entry = params.cfg.plugins?.entries?.[pluginId];
if (!entry) {
continue;
}
const config = entry.config === undefined ? {} : asRecord(entry.config);
if (!config) {
continue;
}
agentIds.add(normalizeAgentId(typeof config.agentId === "string" ? config.agentId : undefined));
const numbers = asRecord(config.numbers);
for (const route of Object.values(numbers ?? {})) {
const agentId = asRecord(route)?.agentId;
if (typeof agentId === "string") {
agentIds.add(normalizeAgentId(agentId));
}
}
}
return [...agentIds].toSorted();
}
/** Resolve the voice-call store path used by legacy and plugin-state call records. */
function resolveVoiceCallStorePath(params: {
config: PluginDoctorStateMigrationParams["config"];
env: NodeJS.ProcessEnv;
}): string {
const configuredStore = getVoiceCallConfigStore(params.config);
if (configuredStore) {
return resolveUserPath(configuredStore, params.env);
}
return path.join(resolveHome(params.env), ".openclaw", "voice-calls");
}
/** Return true when a path exists and is a file. */
/** Build the plugin state key for one migrated event chunk. */
function buildChunkKey(eventKey: string, index: number): string {
return `${eventKey}:chunk:${String(index).padStart(4, "0")}`;
}
/** Chunk a prepared call record into bounded plugin state rows. */
function prepareChunks(call: CallRecord): {
chunks: CallRecordEventChunk[];
meta: CallRecordEventMeta;
} {
const serialized = JSON.stringify(prepareVoiceCallRecordForStorage(call));
const buffer = Buffer.from(serialized, "utf8");
const chunkCount = Math.max(1, Math.ceil(buffer.byteLength / RAW_CALL_RECORD_CHUNK_BYTES));
if (chunkCount > MAX_CHUNKS_PER_CALL_RECORD_EVENT) {
throw new Error(
`voice-call record exceeds SQLite chunk limit (${chunkCount}/${MAX_CHUNKS_PER_CALL_RECORD_EVENT})`,
);
}
const chunks: CallRecordEventChunk[] = [];
for (let index = 0; index < chunkCount; index += 1) {
const chunk = buffer.subarray(
index * RAW_CALL_RECORD_CHUNK_BYTES,
(index + 1) * RAW_CALL_RECORD_CHUNK_BYTES,
);
chunks.push({ index, dataBase64: chunk.toString("base64") });
}
return {
chunks,
meta: {
chunkCount,
byteLength: buffer.byteLength,
},
};
}
/** Read and prepare legacy JSONL call records, collecting line-level warnings. */
async function readLegacyCallRecords(filePath: string): Promise<{
entries: PreparedLegacyCallRecord[];
warnings: string[];
}> {
let content;
try {
content = await fs.readFile(filePath, "utf8");
} catch {
return { entries: [], warnings: [] };
}
const entries: PreparedLegacyCallRecord[] = [];
const warnings: string[] = [];
let index = 0;
for (const line of content.split("\n")) {
const parsed = parseVoiceCallRecordLine(line, index);
if (!parsed) {
if (line.trim()) {
warnings.push(`Skipped malformed Voice Call call-log line ${index + 1}`);
}
index += 1;
continue;
}
try {
const prepared = prepareChunks(parsed.call);
entries.push({
eventKey: buildVoiceCallLegacyJsonlEventKey(line, index),
lineNumber: index + 1,
chunks: prepared.chunks,
meta: {
...prepared.meta,
persistedAt: parsed.persistedAt,
sequence: parsed.sequence,
},
});
} catch (err) {
warnings.push(`Skipped Voice Call call-log line ${index + 1}: ${String(err)}`);
}
index += 1;
}
return { entries, warnings };
}
/** Archive the legacy JSONL source after a complete migration. */
/** Select newest missing records that fit remaining plugin state capacity. */
async function selectEntriesForImport(params: {
entries: PreparedLegacyCallRecord[];
eventStore: PluginStateKeyedStore<CallRecordEventMeta>;
chunkStore: PluginStateKeyedStore<CallRecordEventChunk>;
warnings: string[];
}): Promise<{ existingEventKeys: Set<string>; entries: PreparedLegacyCallRecord[] }> {
const existingEventKeys = new Set((await params.eventStore.entries()).map((entry) => entry.key));
const missingEntries = params.entries.filter((entry) => !existingEventKeys.has(entry.eventKey));
const existingChunks = await params.chunkStore.entries();
let eventRoom = Math.max(0, MAX_CALL_RECORD_EVENTS - existingEventKeys.size);
let chunkRoom = Math.max(0, CALL_RECORD_CHUNK_MAX_ENTRIES - existingChunks.length);
const selected: PreparedLegacyCallRecord[] = [];
let pruned = 0;
for (const entry of missingEntries.toReversed()) {
if (eventRoom <= 0 || entry.chunks.length > chunkRoom) {
pruned++;
continue;
}
selected.push(entry);
eventRoom--;
chunkRoom -= entry.chunks.length;
}
if (pruned > 0) {
params.warnings.push(
`Pruned ${pruned} older Voice Call call-log ${pruned === 1 ? "record" : "records"} during migration because plugin state keeps the newest ${MAX_CALL_RECORD_EVENTS} records`,
);
}
return { existingEventKeys, entries: selected.toReversed() };
}
/** Import prepared legacy call records into plugin state. */
async function importLegacyCallRecords(params: {
entries: PreparedLegacyCallRecord[];
eventStore: PluginStateKeyedStore<CallRecordEventMeta>;
chunkStore: PluginStateKeyedStore<CallRecordEventChunk>;
warnings: string[];
}): Promise<number> {
const selected = await selectEntriesForImport(params);
let imported = 0;
for (const entry of selected.entries) {
if (selected.existingEventKeys.has(entry.eventKey)) {
continue;
}
try {
for (const chunk of entry.chunks) {
await params.chunkStore.register(buildChunkKey(entry.eventKey, chunk.index), chunk);
}
await params.eventStore.register(entry.eventKey, entry.meta);
selected.existingEventKeys.add(entry.eventKey);
imported++;
} catch (err) {
params.warnings.push(
`Failed migrating Voice Call call-log line ${entry.lineNumber}: ${String(err)}`,
);
}
}
return imported;
}
/** Doctor migrations owned by the voice-call plugin. */
export const stateMigrations: PluginDoctorStateMigration[] = [
{
id: "voice-call-calls-jsonl-to-plugin-state",
label: "Voice Call call log",
async detectLegacyState(params) {
const storePath = resolveVoiceCallStorePath(params);
const filePath = resolveVoiceCallLegacyCallLogPath(storePath);
const { entries } = await readLegacyCallRecords(filePath);
if (entries.length === 0) {
return null;
}
return {
preview: [
`- Voice Call call log: ${entries.length} ${entries.length === 1 ? "record" : "records"} -> plugin state (${CALL_RECORD_EVENTS_NAMESPACE})`,
],
};
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
const storePath = resolveVoiceCallStorePath(params);
const filePath = resolveVoiceCallLegacyCallLogPath(storePath);
const { entries, warnings: readWarnings } = await readLegacyCallRecords(filePath);
warnings.push(...readWarnings);
if (entries.length === 0) {
return { changes, warnings };
}
const env = { ...params.env, OPENCLAW_STATE_DIR: storePath };
const eventStore = params.context.openPluginStateKeyedStore<CallRecordEventMeta>({
namespace: CALL_RECORD_EVENTS_NAMESPACE,
maxEntries: CALL_RECORD_EVENT_META_MAX_ENTRIES,
env,
});
const chunkStore = params.context.openPluginStateKeyedStore<CallRecordEventChunk>({
namespace: CALL_RECORD_EVENT_CHUNKS_NAMESPACE,
maxEntries: CALL_RECORD_CHUNK_MAX_ENTRIES,
env,
});
const imported = await importLegacyCallRecords({
entries,
eventStore,
chunkStore,
warnings,
});
if (imported > 0) {
changes.push(
`Migrated ${imported} Voice Call call-log ${imported === 1 ? "record" : "records"} -> plugin state`,
);
}
if (
warnings.some(
(warning) =>
warning.startsWith("Failed migrating Voice Call") ||
warning.startsWith("Skipped malformed Voice Call call-log line") ||
warning.startsWith("Skipped Voice Call call-log line") ||
warning.startsWith("Skipped Voice Call call-log migration"),
)
) {
warnings.push("Left Voice Call call-log source in place because migration was incomplete");
return { changes, warnings };
}
await archiveLegacyStateSource({ filePath, label: "Voice Call call-log", changes, warnings });
return { changes, warnings };
},
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,897 @@
// Voice Call plugin entrypoint registers its OpenClaw integration.
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { jsonResult as json } from "openclaw/plugin-sdk/tool-results";
import { Type } from "typebox";
import {
definePluginEntry,
type GatewayRequestHandlerOptions,
type OpenClawPluginApi,
} from "./api.js";
import { createVoiceCallRuntime, type VoiceCallRuntime } from "./runtime-entry.js";
import { registerVoiceCallCli } from "./src/cli.js";
import {
formatVoiceCallLegacyConfigWarnings,
normalizeVoiceCallLegacyConfigInput,
parseVoiceCallPluginConfig,
} from "./src/config-compat.js";
import {
resolveVoiceCallConfig,
validateProviderConfig,
type VoiceCallConfig,
} from "./src/config.js";
import type { CoreConfig } from "./src/core-bridge.js";
import { createVoiceCallContinueOperationStore } from "./src/gateway-continue-operation.js";
import type { CallRecord } from "./src/types.js";
const VOICE_CALL_WRITE_METHOD_SCOPE = { scope: "operator.write" as const };
const VOICE_CALL_READ_METHOD_SCOPE = { scope: "operator.read" as const };
const voiceCallConfigSchema = {
parse(value: unknown): VoiceCallConfig {
const normalized = normalizeVoiceCallLegacyConfigInput(value);
const enabled = typeof normalized.enabled === "boolean" ? normalized.enabled : true;
return parseVoiceCallPluginConfig({
...normalized,
enabled,
provider: normalized.provider ?? (enabled ? "mock" : undefined),
});
},
uiHints: {
provider: {
label: "Provider",
help: "Use twilio, telnyx, or mock for dev/no-network.",
},
fromNumber: { label: "From Number", placeholder: "+15550001234" },
toNumber: { label: "Default To Number", placeholder: "+15550001234" },
inboundPolicy: { label: "Inbound Policy" },
allowFrom: { label: "Inbound Allowlist" },
inboundGreeting: { label: "Inbound Greeting", advanced: true },
numbers: {
label: "Per-number Routing",
help: "Inbound overrides keyed by dialed E.164 number.",
advanced: true,
},
"telnyx.apiKey": { label: "Telnyx API Key", sensitive: true },
"telnyx.connectionId": { label: "Telnyx Connection ID" },
"telnyx.publicKey": { label: "Telnyx Public Key", sensitive: true },
"twilio.accountSid": { label: "Twilio Account SID" },
"twilio.authToken": { label: "Twilio Auth Token", sensitive: true },
"outbound.defaultMode": { label: "Default Call Mode" },
"outbound.notifyHangupDelaySec": {
label: "Notify Hangup Delay (sec)",
advanced: true,
},
"serve.port": { label: "Webhook Port" },
"serve.bind": { label: "Webhook Bind" },
"serve.path": { label: "Webhook Path" },
"tailscale.mode": { label: "Tailscale Mode", advanced: true },
"tailscale.path": { label: "Tailscale Path", advanced: true },
"tunnel.provider": { label: "Tunnel Provider", advanced: true },
"tunnel.ngrokAuthToken": {
label: "ngrok Auth Token",
sensitive: true,
advanced: true,
},
"tunnel.ngrokDomain": { label: "ngrok Domain", advanced: true },
"tunnel.allowNgrokFreeTierLoopbackBypass": {
label: "Allow ngrok Free Tier (Loopback Bypass)",
advanced: true,
},
"streaming.enabled": { label: "Enable Streaming", advanced: true },
"streaming.provider": {
label: "Streaming Provider",
help: "Uses the first registered realtime transcription provider when unset.",
advanced: true,
},
"streaming.providers": { label: "Streaming Provider Config", advanced: true },
"streaming.streamPath": { label: "Media Stream Path", advanced: true },
"realtime.enabled": { label: "Enable Realtime Voice", advanced: true },
"realtime.provider": {
label: "Realtime Voice Provider",
help: "Uses the first registered realtime voice provider when unset.",
advanced: true,
},
"realtime.streamPath": { label: "Realtime Stream Path", advanced: true },
"realtime.instructions": { label: "Realtime Instructions", advanced: true },
"realtime.toolPolicy": {
label: "Realtime Tool Policy",
help: "Controls the shared openclaw_agent_consult tool.",
advanced: true,
},
"realtime.consultPolicy": {
label: "Realtime Consult Policy",
help: "Guides when the realtime voice model should call openclaw_agent_consult.",
advanced: true,
},
"realtime.fastContext.enabled": {
label: "Enable Fast Realtime Context",
help: "Searches memory/session context before the full consult agent.",
advanced: true,
},
"realtime.fastContext.timeoutMs": {
label: "Fast Context Timeout",
advanced: true,
},
"realtime.fastContext.maxResults": {
label: "Fast Context Result Limit",
advanced: true,
},
"realtime.fastContext.sources": {
label: "Fast Context Sources",
advanced: true,
},
"realtime.fastContext.fallbackToConsult": {
label: "Fallback To Full Consult",
advanced: true,
},
"realtime.agentContext.enabled": {
label: "Enable Agent Voice Context",
help: "Injects a compact agent identity and workspace context capsule into realtime voice instructions.",
advanced: true,
},
"realtime.agentContext.maxChars": {
label: "Agent Voice Context Limit",
advanced: true,
},
"realtime.agentContext.includeIdentity": {
label: "Include Agent Identity",
advanced: true,
},
"realtime.agentContext.includeWorkspaceFiles": {
label: "Include Agent Workspace Files",
advanced: true,
},
"realtime.agentContext.files": {
label: "Agent Voice Context Files",
advanced: true,
},
"realtime.providers": { label: "Realtime Provider Config", advanced: true },
"tts.provider": {
label: "TTS Provider Override",
help: "Deep-merges with messages.tts (Microsoft is ignored for calls).",
advanced: true,
},
"tts.providers": { label: "TTS Provider Config", advanced: true },
publicUrl: { label: "Public Webhook URL", advanced: true },
skipSignatureVerification: {
label: "Skip Signature Verification",
advanced: true,
},
store: { label: "Call Log Store Path", advanced: true },
agentId: {
label: "Response Agent ID",
help: 'Agent workspace used for voice response generation. Defaults to "main".',
advanced: true,
},
responseModel: {
label: "Response Model",
help: "Optional override. Falls back to the runtime default model when unset.",
advanced: true,
},
responseSystemPrompt: { label: "Response System Prompt", advanced: true },
responseTimeoutMs: { label: "Response Timeout (ms)", advanced: true },
},
};
const VoiceCallToolSchema = Type.Union([
Type.Object({
action: Type.Literal("initiate_call"),
to: Type.Optional(Type.String({ description: "Call target" })),
message: Type.String({ description: "Intro message" }),
mode: Type.Optional(Type.Union([Type.Literal("notify"), Type.Literal("conversation")])),
sessionKey: Type.Optional(Type.String({ description: "OpenClaw session key for the call" })),
requesterSessionKey: Type.Optional(
Type.String({ description: "OpenClaw session key that initiated the call" }),
),
dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" })),
}),
Type.Object({
action: Type.Literal("continue_call"),
callId: Type.String({ description: "Call ID" }),
message: Type.String({ description: "Follow-up message" }),
}),
Type.Object({
action: Type.Literal("speak_to_user"),
callId: Type.String({ description: "Call ID" }),
message: Type.String({ description: "Message to speak" }),
}),
Type.Object({
action: Type.Literal("send_dtmf"),
callId: Type.String({ description: "Call ID" }),
digits: Type.String({ description: "DTMF digits to send" }),
}),
Type.Object({
action: Type.Literal("end_call"),
callId: Type.String({ description: "Call ID" }),
}),
Type.Object({
action: Type.Literal("get_status"),
callId: Type.String({ description: "Call ID" }),
}),
Type.Object({
mode: Type.Optional(Type.Union([Type.Literal("call"), Type.Literal("status")])),
to: Type.Optional(Type.String({ description: "Call target" })),
sid: Type.Optional(Type.String({ description: "Call SID" })),
message: Type.Optional(Type.String({ description: "Optional intro message" })),
sessionKey: Type.Optional(Type.String({ description: "OpenClaw session key for the call" })),
requesterSessionKey: Type.Optional(
Type.String({ description: "OpenClaw session key that initiated the call" }),
),
dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" })),
}),
]);
function asParamRecord(params: unknown): Record<string, unknown> {
return params && typeof params === "object" && !Array.isArray(params)
? (params as Record<string, unknown>)
: {};
}
function isCliOnlyProcess(): boolean {
return process.env.OPENCLAW_CLI === "1" && !process.argv.slice(2).includes("gateway");
}
type VoiceCallStatus = Pick<
CallRecord,
| "callId"
| "providerCallId"
| "provider"
| "direction"
| "state"
| "startedAt"
| "answeredAt"
| "endedAt"
| "endReason"
>;
function toVoiceCallStatus(call: CallRecord): VoiceCallStatus {
return {
callId: call.callId,
...(call.providerCallId !== undefined ? { providerCallId: call.providerCallId } : {}),
provider: call.provider,
direction: call.direction,
state: call.state,
startedAt: call.startedAt,
...(call.answeredAt !== undefined ? { answeredAt: call.answeredAt } : {}),
...(call.endedAt !== undefined ? { endedAt: call.endedAt } : {}),
...(call.endReason !== undefined ? { endReason: call.endReason } : {}),
};
}
const VOICE_CALL_RUNTIME_KEY = Symbol.for("openclaw.voice-call.runtime");
const VOICE_CALL_RUNTIME_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimePromise");
const VOICE_CALL_RUNTIME_STOP_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimeStopPromise");
type VoiceCallRuntimeGlobalState = typeof globalThis & {
[VOICE_CALL_RUNTIME_KEY]?: VoiceCallRuntime | null;
[VOICE_CALL_RUNTIME_PROMISE_KEY]?: Promise<VoiceCallRuntime> | null;
[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]?: Promise<void> | null;
};
function getVoiceCallRuntimeGlobalState(): VoiceCallRuntimeGlobalState {
const state = globalThis as VoiceCallRuntimeGlobalState;
state[VOICE_CALL_RUNTIME_KEY] ??= null;
state[VOICE_CALL_RUNTIME_PROMISE_KEY] ??= null;
state[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] ??= null;
return state;
}
export default definePluginEntry({
id: "voice-call",
name: "Voice Call",
description: "Voice-call plugin with Telnyx/Twilio/Plivo providers",
configSchema: voiceCallConfigSchema,
register(api: OpenClawPluginApi) {
const config = resolveVoiceCallConfig(voiceCallConfigSchema.parse(api.pluginConfig));
const validation = validateProviderConfig(config);
if (api.pluginConfig && typeof api.pluginConfig === "object") {
for (const warning of formatVoiceCallLegacyConfigWarnings({
value: api.pluginConfig,
configPathPrefix: "plugins.entries.voice-call.config",
doctorFixCommand: "openclaw doctor --fix",
})) {
api.logger.warn(warning);
}
}
const runtimeState = getVoiceCallRuntimeGlobalState();
const continueOperationStore = createVoiceCallContinueOperationStore({
config,
coreConfig: api.config as CoreConfig,
});
const ensureRuntime = async (): Promise<VoiceCallRuntime> => {
if (!config.enabled) {
throw new Error("Voice call disabled in plugin config");
}
if (!validation.valid) {
throw new Error(validation.errors.join("; "));
}
while (true) {
if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) {
await runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY];
continue;
}
const runtime = runtimeState[VOICE_CALL_RUNTIME_KEY];
if (runtime) {
return runtime;
}
let runtimePromise = runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY];
if (!runtimePromise) {
runtimePromise = createVoiceCallRuntime({
config,
coreConfig: api.config as CoreConfig,
fullConfig: api.config,
agentRuntime: api.runtime.agent,
stateRuntime: api.runtime.state,
ttsRuntime: api.runtime.tts,
logger: api.logger,
});
runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = runtimePromise;
}
try {
const createdRuntime = await runtimePromise;
if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) {
continue;
}
if (runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] !== runtimePromise) {
continue;
}
runtimeState[VOICE_CALL_RUNTIME_KEY] = createdRuntime;
return createdRuntime;
} catch (err) {
if (runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] === runtimePromise) {
// Reset shared state so the next call can retry instead of caching
// a rejected promise across plugin contexts. See: #32387, #58115.
runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = null;
runtimeState[VOICE_CALL_RUNTIME_KEY] = null;
}
throw err;
}
}
};
const respondError = (
respond: GatewayRequestHandlerOptions["respond"],
message: string,
code: (typeof ErrorCodes)[keyof typeof ErrorCodes] = ErrorCodes.UNAVAILABLE,
) => {
respond(false, undefined, errorShape(code, message));
};
const sendError = (respond: GatewayRequestHandlerOptions["respond"], err: unknown) => {
respondError(respond, formatErrorMessage(err));
};
const describeHistoricalCall = async (rt: VoiceCallRuntime, callId: string) => {
const history = await rt.manager.getCallHistory(100);
const call = history
.toReversed()
.find((candidate) => candidate.callId === callId || candidate.providerCallId === callId);
if (!call) {
return undefined;
}
const endedAt = timestampMsToIsoString(call.endedAt);
const details = [
`last state=${call.state}`,
call.endReason ? `endReason=${call.endReason}` : undefined,
endedAt ? `endedAt=${endedAt}` : undefined,
].filter(Boolean);
return `call is not active (${details.join(", ")})`;
};
const resolveCallMessageRequest = async (params: GatewayRequestHandlerOptions["params"]) => {
const callId = normalizeOptionalString(params?.callId) ?? "";
const message = normalizeOptionalString(params?.message) ?? "";
if (!callId || !message) {
return { error: "callId and message required" } as const;
}
const rt = await ensureRuntime();
const activeCall = rt.manager.getCall(callId) ?? rt.manager.getCallByProviderCallId(callId);
if (activeCall) {
return { rt, callId: activeCall.callId, message } as const;
}
return { error: (await describeHistoricalCall(rt, callId)) ?? "Call not found" } as const;
};
const initiateCallAndRespond = async (params: {
rt: VoiceCallRuntime;
respond: GatewayRequestHandlerOptions["respond"];
to: string;
message?: string;
mode?: "notify" | "conversation";
dtmfSequence?: string;
sessionKey?: string;
requesterSessionKey?: string;
}) => {
const result = await params.rt.manager.initiateCall(params.to, params.sessionKey, {
message: params.message,
mode: params.mode,
dtmfSequence: params.dtmfSequence,
...(params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {}),
});
if (!result.success) {
respondError(params.respond, result.error || "initiate failed");
return;
}
params.respond(true, { callId: result.callId, initiated: true });
};
const respondToCallMessageAction = async (params: {
requestParams: GatewayRequestHandlerOptions["params"];
respond: GatewayRequestHandlerOptions["respond"];
action: (
request: Exclude<Awaited<ReturnType<typeof resolveCallMessageRequest>>, { error: string }>,
) => Promise<{
success: boolean;
error?: string;
transcript?: string;
}>;
failure: string;
includeTranscript?: boolean;
}) => {
const request = await resolveCallMessageRequest(params.requestParams);
if ("error" in request) {
respondError(
params.respond,
request.error ?? "callId and message required",
ErrorCodes.INVALID_REQUEST,
);
return;
}
const result = await params.action(request);
if (!result.success) {
respondError(params.respond, result.error || params.failure);
return;
}
params.respond(
true,
params.includeTranscript
? { success: true, transcript: result.transcript }
: { success: true },
);
};
api.registerGatewayMethod(
"voicecall.initiate",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const message = normalizeOptionalString(params?.message) ?? "";
if (!message) {
respondError(respond, "message required", ErrorCodes.INVALID_REQUEST);
return;
}
const rt = await ensureRuntime();
const to = normalizeOptionalString(params?.to) ?? rt.config.toNumber;
if (!to) {
respondError(respond, "to required", ErrorCodes.INVALID_REQUEST);
return;
}
const mode =
params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined;
await initiateCallAndRespond({
rt,
respond,
to,
message,
mode,
sessionKey: normalizeOptionalString(params?.sessionKey),
requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey),
});
} catch (err) {
sendError(respond, err);
}
},
VOICE_CALL_WRITE_METHOD_SCOPE,
);
api.registerGatewayMethod(
"voicecall.continue",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
await respondToCallMessageAction({
requestParams: params,
respond,
action: (request) => request.rt.manager.continueCall(request.callId, request.message),
failure: "continue failed",
includeTranscript: true,
});
} catch (err) {
sendError(respond, err);
}
},
VOICE_CALL_WRITE_METHOD_SCOPE,
);
api.registerGatewayMethod(
"voicecall.continue.start",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const request = await resolveCallMessageRequest(params);
if ("error" in request) {
respondError(
respond,
request.error ?? "callId and message required",
ErrorCodes.INVALID_REQUEST,
);
return;
}
respond(true, continueOperationStore.start(request));
} catch (err) {
sendError(respond, err);
}
},
VOICE_CALL_WRITE_METHOD_SCOPE,
);
api.registerGatewayMethod(
"voicecall.continue.result",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const operationId = normalizeOptionalString(params?.operationId) ?? "";
if (!operationId) {
respondError(respond, "operationId required", ErrorCodes.INVALID_REQUEST);
return;
}
const operation = continueOperationStore.read(operationId);
if (!operation.ok) {
respondError(respond, operation.error, ErrorCodes.INVALID_REQUEST);
return;
}
respond(true, operation.payload);
} catch (err) {
sendError(respond, err);
}
},
VOICE_CALL_READ_METHOD_SCOPE,
);
api.registerGatewayMethod(
"voicecall.speak",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const request = await resolveCallMessageRequest(params);
if ("error" in request) {
respondError(
respond,
request.error ?? "callId and message required",
ErrorCodes.INVALID_REQUEST,
);
return;
}
if (request.rt.config.realtime.enabled) {
const realtimeResult = request.rt.webhookServer.speakRealtime(
request.callId,
request.message,
);
if (realtimeResult.success) {
respond(true, { success: true });
return;
}
if (params?.allowTwimlFallback === false) {
respond(true, {
success: false,
error: realtimeResult.error ?? "Realtime bridge is not active",
});
return;
}
}
const result = await request.rt.manager.speak(request.callId, request.message);
if (!result.success) {
respondError(respond, result.error || "speak failed");
return;
}
respond(true, { success: true });
} catch (err) {
sendError(respond, err);
}
},
VOICE_CALL_WRITE_METHOD_SCOPE,
);
api.registerGatewayMethod(
"voicecall.dtmf",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const callId = normalizeOptionalString(params?.callId) ?? "";
const digits = normalizeOptionalString(params?.digits) ?? "";
if (!callId || !digits) {
respondError(respond, "callId and digits required", ErrorCodes.INVALID_REQUEST);
return;
}
const rt = await ensureRuntime();
const result = await rt.manager.sendDtmf(callId, digits);
if (!result.success) {
respondError(respond, result.error || "dtmf failed");
return;
}
respond(true, { success: true });
} catch (err) {
sendError(respond, err);
}
},
VOICE_CALL_WRITE_METHOD_SCOPE,
);
api.registerGatewayMethod(
"voicecall.end",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const callId = normalizeOptionalString(params?.callId) ?? "";
if (!callId) {
respondError(respond, "callId required", ErrorCodes.INVALID_REQUEST);
return;
}
const rt = await ensureRuntime();
const result = await rt.manager.endCall(callId);
if (!result.success) {
respondError(respond, result.error || "end failed");
return;
}
respond(true, { success: true });
} catch (err) {
sendError(respond, err);
}
},
VOICE_CALL_WRITE_METHOD_SCOPE,
);
api.registerGatewayMethod(
"voicecall.status",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const raw =
normalizeOptionalString(params?.callId) ?? normalizeOptionalString(params?.sid) ?? "";
const rt = await ensureRuntime();
if (!raw) {
respond(true, {
found: true,
calls: rt.manager.getActiveCalls().map(toVoiceCallStatus),
});
return;
}
const call = rt.manager.getCall(raw) || rt.manager.getCallByProviderCallId(raw);
if (!call) {
respond(true, { found: false });
return;
}
respond(true, { found: true, call: toVoiceCallStatus(call) });
} catch (err) {
sendError(respond, err);
}
},
VOICE_CALL_READ_METHOD_SCOPE,
);
api.registerGatewayMethod(
"voicecall.start",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const to = normalizeOptionalString(params?.to) ?? "";
const message = normalizeOptionalString(params?.message) ?? "";
const dtmfSequence = normalizeOptionalString(params?.dtmfSequence);
const sessionKey = normalizeOptionalString(params?.sessionKey);
const requesterSessionKey = normalizeOptionalString(params?.requesterSessionKey);
if (!to) {
respondError(respond, "to required", ErrorCodes.INVALID_REQUEST);
return;
}
const mode =
params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined;
const rt = await ensureRuntime();
await initiateCallAndRespond({
rt,
respond,
to,
message: message || undefined,
mode,
dtmfSequence,
sessionKey,
...(requesterSessionKey ? { requesterSessionKey } : {}),
});
} catch (err) {
sendError(respond, err);
}
},
VOICE_CALL_WRITE_METHOD_SCOPE,
);
api.registerTool({
name: "voice_call",
label: "Voice Call",
description: "Make phone calls and have voice conversations via the voice-call plugin.",
parameters: VoiceCallToolSchema,
async execute(_toolCallId, params) {
const rawParams = asParamRecord(params);
try {
const rt = await ensureRuntime();
if (typeof rawParams.action === "string") {
switch (rawParams.action) {
case "initiate_call": {
const message = normalizeOptionalString(rawParams.message) ?? "";
if (!message) {
throw new Error("message required");
}
const to = normalizeOptionalString(rawParams.to) ?? rt.config.toNumber;
if (!to) {
throw new Error("to required");
}
const result = await rt.manager.initiateCall(to, undefined, {
message,
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
mode:
rawParams.mode === "notify" || rawParams.mode === "conversation"
? rawParams.mode
: undefined,
});
if (!result.success) {
throw new Error(result.error || "initiate failed");
}
return json({ callId: result.callId, initiated: true });
}
case "continue_call": {
const callId = normalizeOptionalString(rawParams.callId) ?? "";
const message = normalizeOptionalString(rawParams.message) ?? "";
if (!callId || !message) {
throw new Error("callId and message required");
}
const result = await rt.manager.continueCall(callId, message);
if (!result.success) {
throw new Error(result.error || "continue failed");
}
return json({ success: true, transcript: result.transcript });
}
case "speak_to_user": {
const callId = normalizeOptionalString(rawParams.callId) ?? "";
const message = normalizeOptionalString(rawParams.message) ?? "";
if (!callId || !message) {
throw new Error("callId and message required");
}
const result = await rt.manager.speak(callId, message);
if (!result.success) {
throw new Error(result.error || "speak failed");
}
return json({ success: true });
}
case "send_dtmf": {
const callId = normalizeOptionalString(rawParams.callId) ?? "";
const digits = normalizeOptionalString(rawParams.digits) ?? "";
if (!callId || !digits) {
throw new Error("callId and digits required");
}
const result = await rt.manager.sendDtmf(callId, digits);
if (!result.success) {
throw new Error(result.error || "dtmf failed");
}
return json({ success: true });
}
case "end_call": {
const callId = normalizeOptionalString(rawParams.callId) ?? "";
if (!callId) {
throw new Error("callId required");
}
const result = await rt.manager.endCall(callId);
if (!result.success) {
throw new Error(result.error || "end failed");
}
return json({ success: true });
}
case "get_status": {
const callId = normalizeOptionalString(rawParams.callId) ?? "";
if (!callId) {
throw new Error("callId required");
}
const call =
rt.manager.getCall(callId) || rt.manager.getCallByProviderCallId(callId);
return json(
call ? { found: true, call: toVoiceCallStatus(call) } : { found: false },
);
}
}
}
const mode = rawParams.mode ?? "call";
if (mode === "status") {
const sid = normalizeOptionalString(rawParams.sid) ?? "";
if (!sid) {
throw new Error("sid required for status");
}
const call = rt.manager.getCall(sid) || rt.manager.getCallByProviderCallId(sid);
return json(call ? { found: true, call: toVoiceCallStatus(call) } : { found: false });
}
const to = normalizeOptionalString(rawParams.to) ?? rt.config.toNumber;
if (!to) {
throw new Error("to required for call");
}
const result = await rt.manager.initiateCall(
to,
normalizeOptionalString(rawParams.sessionKey),
{
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
message: normalizeOptionalString(rawParams.message),
...(normalizeOptionalString(rawParams.requesterSessionKey)
? { requesterSessionKey: normalizeOptionalString(rawParams.requesterSessionKey) }
: {}),
},
);
if (!result.success) {
throw new Error(result.error || "initiate failed");
}
return json({ callId: result.callId, initiated: true });
} catch (err) {
return json({
error: formatErrorMessage(err),
});
}
},
});
api.registerCli(
({ program }) =>
registerVoiceCallCli({
program,
config,
ensureRuntime,
stateRuntime: api.runtime.state,
logger: api.logger,
}),
{ commands: ["voicecall"] },
);
api.registerService({
id: "voicecall",
start: () => {
if (isCliOnlyProcess()) {
return;
}
if (!config.enabled) {
return;
}
if (!validation.valid) {
api.logger.warn(
`[voice-call] Runtime not started; setup incomplete: ${validation.errors.join("; ")}`,
);
return;
}
void ensureRuntime().catch((err: unknown) => {
api.logger.error(`[voice-call] Failed to start runtime: ${formatErrorMessage(err)}`);
});
},
stop: async () => {
if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) {
await runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY];
return;
}
const runtime = runtimeState[VOICE_CALL_RUNTIME_KEY];
const runtimePromise = runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY];
if (!runtime && !runtimePromise) {
return;
}
runtimeState[VOICE_CALL_RUNTIME_KEY] = null;
runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = null;
const stopPromise = (async () => {
const rt = runtime ?? (await runtimePromise!);
await rt.stop();
})();
runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] = stopPromise;
try {
await stopPromise;
} finally {
if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] === stopPromise) {
runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] = null;
}
}
},
});
},
});

View File

@@ -0,0 +1,71 @@
{
"name": "@openclaw/voice-call",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/voice-call",
"version": "2026.6.11",
"dependencies": {
"commander": "15.0.0",
"typebox": "1.3.3",
"ws": "8.21.0",
"zod": "4.4.3"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
}
},
"node_modules/commander": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"license": "MIT",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/typebox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz",
"integrity": "sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -0,0 +1,914 @@
{
"id": "voice-call",
"name": "Voice Call",
"description": "OpenClaw voice-call plugin for Twilio, Telnyx, and Plivo phone calls.",
"skills": ["./skills"],
"commandAliases": [{ "name": "voicecall" }],
"activation": {
"onStartup": true,
"onCommands": ["voicecall"]
},
"contracts": {
"tools": ["voice_call"]
},
"channelEnvVars": {
"voice-call": [
"TELNYX_API_KEY",
"TELNYX_CONNECTION_ID",
"TELNYX_PUBLIC_KEY",
"TWILIO_ACCOUNT_SID",
"TWILIO_AUTH_TOKEN",
"TWILIO_FROM_NUMBER",
"PLIVO_AUTH_ID",
"PLIVO_AUTH_TOKEN",
"NGROK_AUTHTOKEN",
"NGROK_DOMAIN"
]
},
"uiHints": {
"provider": {
"label": "Provider",
"help": "Use twilio, telnyx, or mock for dev/no-network."
},
"fromNumber": {
"label": "From Number",
"placeholder": "+15550001234"
},
"toNumber": {
"label": "Default To Number",
"placeholder": "+15550001234"
},
"inboundPolicy": {
"label": "Inbound Policy"
},
"allowFrom": {
"label": "Inbound Allowlist"
},
"inboundGreeting": {
"label": "Inbound Greeting",
"advanced": true
},
"numbers": {
"label": "Per-number Routing",
"help": "Inbound overrides keyed by dialed E.164 number.",
"advanced": true
},
"telnyx.apiKey": {
"label": "Telnyx API Key",
"sensitive": true
},
"telnyx.connectionId": {
"label": "Telnyx Connection ID"
},
"telnyx.publicKey": {
"label": "Telnyx Public Key",
"sensitive": true
},
"twilio.accountSid": {
"label": "Twilio Account SID"
},
"twilio.authToken": {
"label": "Twilio Auth Token",
"sensitive": true
},
"outbound.defaultMode": {
"label": "Default Call Mode"
},
"outbound.notifyHangupDelaySec": {
"label": "Notify Hangup Delay (sec)",
"advanced": true
},
"serve.port": {
"label": "Webhook Port"
},
"serve.bind": {
"label": "Webhook Bind"
},
"serve.path": {
"label": "Webhook Path"
},
"tailscale.mode": {
"label": "Tailscale Mode",
"advanced": true
},
"tailscale.path": {
"label": "Tailscale Path",
"advanced": true
},
"tunnel.provider": {
"label": "Tunnel Provider",
"advanced": true
},
"tunnel.ngrokAuthToken": {
"label": "ngrok Auth Token",
"sensitive": true,
"advanced": true
},
"tunnel.ngrokDomain": {
"label": "ngrok Domain",
"advanced": true
},
"tunnel.allowNgrokFreeTierLoopbackBypass": {
"label": "Allow ngrok Free Tier (Loopback Bypass)",
"advanced": true
},
"streaming.enabled": {
"label": "Enable Streaming",
"advanced": true
},
"streaming.provider": {
"label": "Streaming Provider",
"help": "Uses the first registered realtime transcription provider when unset.",
"advanced": true
},
"streaming.providers": {
"label": "Streaming Provider Config",
"advanced": true
},
"streaming.streamPath": {
"label": "Media Stream Path",
"advanced": true
},
"realtime.enabled": {
"label": "Enable Realtime Voice",
"advanced": true
},
"realtime.provider": {
"label": "Realtime Voice Provider",
"help": "Uses the first registered realtime voice provider when unset.",
"advanced": true
},
"realtime.streamPath": {
"label": "Realtime Stream Path",
"advanced": true
},
"realtime.instructions": {
"label": "Realtime Instructions",
"advanced": true
},
"realtime.toolPolicy": {
"label": "Realtime Tool Policy",
"help": "Controls the shared openclaw_agent_consult tool.",
"advanced": true
},
"realtime.consultPolicy": {
"label": "Realtime Consult Policy",
"help": "Guides when the realtime voice model should call openclaw_agent_consult.",
"advanced": true
},
"realtime.consultThinkingLevel": {
"label": "Consult Thinking Level",
"help": "Optional override for the regular agent run behind realtime openclaw_agent_consult calls.",
"advanced": true
},
"realtime.consultFastMode": {
"label": "Consult Fast Mode",
"help": "Optional fast mode override for the regular agent run behind realtime openclaw_agent_consult calls.",
"advanced": true
},
"realtime.fastContext.enabled": {
"label": "Enable Fast Realtime Context",
"help": "Searches memory/session context before the full consult agent.",
"advanced": true
},
"realtime.fastContext.timeoutMs": {
"label": "Fast Context Timeout",
"advanced": true
},
"realtime.fastContext.maxResults": {
"label": "Fast Context Result Limit",
"advanced": true
},
"realtime.fastContext.sources": {
"label": "Fast Context Sources",
"advanced": true
},
"realtime.fastContext.fallbackToConsult": {
"label": "Fallback To Full Consult",
"advanced": true
},
"realtime.agentContext.enabled": {
"label": "Enable Agent Voice Context",
"help": "Injects a compact agent identity and workspace context capsule into realtime voice instructions.",
"advanced": true
},
"realtime.agentContext.maxChars": {
"label": "Agent Voice Context Limit",
"advanced": true
},
"realtime.agentContext.includeIdentity": {
"label": "Include Agent Identity",
"advanced": true
},
"realtime.agentContext.includeWorkspaceFiles": {
"label": "Include Agent Workspace Files",
"advanced": true
},
"realtime.agentContext.files": {
"label": "Agent Voice Context Files",
"advanced": true
},
"realtime.providers": {
"label": "Realtime Provider Config",
"advanced": true
},
"tts.provider": {
"label": "TTS Provider Override",
"help": "Deep-merges with messages.tts (Microsoft is ignored for calls).",
"advanced": true
},
"tts.providers": {
"label": "TTS Provider Config",
"advanced": true
},
"publicUrl": {
"label": "Public Webhook URL",
"advanced": true
},
"skipSignatureVerification": {
"label": "Skip Signature Verification",
"advanced": true
},
"store": {
"label": "Call Log Store Path",
"advanced": true
},
"sessionScope": {
"label": "Session Scope",
"help": "Use per-phone to preserve caller memory across calls, or per-call to isolate every call into a fresh voice session."
},
"responseModel": {
"label": "Response Model",
"help": "Optional override. Falls back to the runtime default model when unset.",
"advanced": true
},
"responseSystemPrompt": {
"label": "Response System Prompt",
"advanced": true
},
"responseTimeoutMs": {
"label": "Response Timeout (ms)",
"advanced": true
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"provider": {
"type": "string",
"enum": ["telnyx", "twilio", "plivo", "mock"]
},
"telnyx": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"connectionId": {
"type": "string"
},
"publicKey": {
"type": "string"
}
}
},
"twilio": {
"type": "object",
"additionalProperties": false,
"properties": {
"accountSid": {
"type": "string"
},
"authToken": {
"type": ["string", "object"]
}
}
},
"plivo": {
"type": "object",
"additionalProperties": false,
"properties": {
"authId": {
"type": "string"
},
"authToken": {
"type": "string"
}
}
},
"fromNumber": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
},
"toNumber": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
},
"inboundPolicy": {
"type": "string",
"enum": ["disabled", "allowlist", "pairing", "open"]
},
"allowFrom": {
"type": "array",
"items": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
}
},
"inboundGreeting": {
"type": "string"
},
"numbers": {
"type": "object",
"propertyNames": {
"pattern": "^\\+[1-9]\\d{1,14}$"
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"inboundGreeting": {
"type": "string"
},
"tts": {
"$ref": "#/properties/tts"
},
"agentId": {
"type": "string",
"minLength": 1
},
"responseModel": {
"type": "string"
},
"responseSystemPrompt": {
"type": "string"
},
"responseTimeoutMs": {
"type": "integer",
"minimum": 1
}
}
}
},
"outbound": {
"type": "object",
"additionalProperties": false,
"properties": {
"defaultMode": {
"type": "string",
"enum": ["notify", "conversation"]
},
"notifyHangupDelaySec": {
"type": "integer",
"minimum": 0
}
}
},
"maxDurationSeconds": {
"type": "integer",
"minimum": 1
},
"staleCallReaperSeconds": {
"type": "integer",
"minimum": 0
},
"silenceTimeoutMs": {
"type": "integer",
"minimum": 1
},
"transcriptTimeoutMs": {
"type": "integer",
"minimum": 1
},
"ringTimeoutMs": {
"type": "integer",
"minimum": 1
},
"maxConcurrentCalls": {
"type": "integer",
"minimum": 1
},
"serve": {
"type": "object",
"additionalProperties": false,
"properties": {
"port": {
"type": "integer",
"minimum": 1
},
"bind": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"tailscale": {
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": ["off", "serve", "funnel"]
},
"path": {
"type": "string"
}
}
},
"tunnel": {
"type": "object",
"additionalProperties": false,
"properties": {
"provider": {
"type": "string",
"enum": ["none", "ngrok", "tailscale-serve", "tailscale-funnel"]
},
"ngrokAuthToken": {
"type": "string"
},
"ngrokDomain": {
"type": "string"
},
"allowNgrokFreeTierLoopbackBypass": {
"type": "boolean"
}
}
},
"webhookSecurity": {
"type": "object",
"additionalProperties": false,
"properties": {
"allowedHosts": {
"type": "array",
"items": {
"type": "string"
}
},
"trustForwardingHeaders": {
"type": "boolean"
},
"trustedProxyIPs": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"streaming": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"provider": {
"type": "string"
},
"streamPath": {
"type": "string"
},
"providers": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": true
}
},
"preStartTimeoutMs": {
"type": "integer",
"minimum": 1
},
"maxPendingConnections": {
"type": "integer",
"minimum": 1
},
"maxPendingConnectionsPerIp": {
"type": "integer",
"minimum": 1
},
"maxConnections": {
"type": "integer",
"minimum": 1
}
}
},
"realtime": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"provider": {
"type": "string"
},
"streamPath": {
"type": "string"
},
"instructions": {
"type": "string"
},
"toolPolicy": {
"type": "string",
"enum": ["safe-read-only", "owner", "none"]
},
"consultPolicy": {
"type": "string",
"enum": ["auto", "substantive", "always"]
},
"consultThinkingLevel": {
"type": "string",
"enum": ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max"]
},
"consultFastMode": {
"type": "boolean"
},
"tools": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": ["function"]
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"parameters": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": ["object"]
},
"properties": {
"type": "object",
"additionalProperties": true
},
"required": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["type", "properties"]
}
},
"required": ["type", "name", "description", "parameters"]
}
},
"fastContext": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"timeoutMs": {
"type": "number",
"minimum": 1
},
"maxResults": {
"type": "number",
"minimum": 1
},
"sources": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": ["memory", "sessions"]
}
},
"fallbackToConsult": {
"type": "boolean"
}
}
},
"agentContext": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"maxChars": {
"type": "integer",
"minimum": 1
},
"includeIdentity": {
"type": "boolean"
},
"includeWorkspaceFiles": {
"type": "boolean"
},
"files": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
}
},
"providers": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": true
}
}
}
},
"publicUrl": {
"type": "string"
},
"skipSignatureVerification": {
"type": "boolean"
},
"tts": {
"type": "object",
"additionalProperties": false,
"properties": {
"auto": {
"type": "string",
"enum": ["off", "always", "inbound", "tagged"]
},
"enabled": {
"type": "boolean"
},
"mode": {
"type": "string",
"enum": ["final", "all"]
},
"provider": {
"type": "string"
},
"summaryModel": {
"type": "string"
},
"modelOverrides": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowText": {
"type": "boolean"
},
"allowProvider": {
"type": "boolean"
},
"allowVoice": {
"type": "boolean"
},
"allowModelId": {
"type": "boolean"
},
"allowVoiceSettings": {
"type": "boolean"
},
"allowNormalization": {
"type": "boolean"
},
"allowSeed": {
"type": "boolean"
}
}
},
"providers": {
"type": "object",
"properties": {
"openai": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"baseUrl": {
"type": "string"
},
"model": {
"type": "string"
},
"voice": {
"type": "string"
},
"speed": {
"type": "number",
"minimum": 0.25,
"maximum": 4.0
},
"instructions": {
"type": "string"
}
}
},
"elevenlabs": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"baseUrl": {
"type": "string"
},
"voiceId": {
"type": "string"
},
"modelId": {
"type": "string"
},
"seed": {
"type": "integer",
"minimum": 0,
"maximum": 4294967295
},
"applyTextNormalization": {
"type": "string",
"enum": ["auto", "on", "off"]
},
"languageCode": {
"type": "string"
},
"voiceSettings": {
"type": "object",
"additionalProperties": false,
"properties": {
"stability": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"similarityBoost": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"style": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"useSpeakerBoost": {
"type": "boolean"
},
"speed": {
"type": "number",
"minimum": 0.5,
"maximum": 2
}
}
}
}
},
"microsoft": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"voice": {
"type": "string"
},
"lang": {
"type": "string"
},
"outputFormat": {
"type": "string"
},
"pitch": {
"type": "string"
},
"rate": {
"type": "string"
},
"volume": {
"type": "string"
},
"saveSubtitles": {
"type": "boolean"
},
"proxy": {
"type": "string"
},
"timeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000
}
}
},
"edge": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"voice": {
"type": "string"
},
"lang": {
"type": "string"
},
"outputFormat": {
"type": "string"
},
"pitch": {
"type": "string"
},
"rate": {
"type": "string"
},
"volume": {
"type": "string"
},
"saveSubtitles": {
"type": "boolean"
},
"proxy": {
"type": "string"
},
"timeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000
}
}
}
},
"additionalProperties": {
"type": "object",
"properties": {
"apiKey": {
"type": ["string", "object"]
}
},
"additionalProperties": true
}
},
"prefsPath": {
"type": "string"
},
"maxTextLength": {
"type": "integer",
"minimum": 1
},
"timeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000
}
}
},
"store": {
"type": "string"
},
"sessionScope": {
"type": "string",
"enum": ["per-phone", "per-call"]
},
"responseModel": {
"type": "string"
},
"responseSystemPrompt": {
"type": "string"
},
"responseTimeoutMs": {
"type": "integer",
"minimum": 1
}
}
},
"configContracts": {
"compatibilityMigrationPaths": ["plugins.entries.voice-call.config"],
"secretInputs": {
"paths": [
{ "path": "twilio.authToken", "expected": "string" },
{ "path": "realtime.providers.*.apiKey", "expected": "string" },
{ "path": "streaming.providers.*.apiKey", "expected": "string" },
{ "path": "tts.providers.*.apiKey", "expected": "string" }
]
}
}
}

View File

@@ -0,0 +1,48 @@
{
"name": "@openclaw/voice-call",
"version": "2026.6.11",
"description": "OpenClaw voice-call plugin for Twilio, Telnyx, and Plivo phone calls.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"commander": "15.0.0",
"typebox": "1.3.3",
"ws": "8.21.0",
"zod": "4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"npmSpec": "@openclaw/voice-call",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,20 @@
// Private runtime barrel for the bundled Voice Call extension.
// Keep this barrel thin and aligned with the local extension surface.
export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
export type { GatewayRequestHandlerOptions } from "openclaw/plugin-sdk/gateway-runtime";
export {
isRequestBodyLimitError,
readRequestBodyWithLimit,
requestBodyErrorToText,
} from "openclaw/plugin-sdk/webhook-request-guards";
export { fetchWithSsrFGuard, isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
export type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
export {
TtsAutoSchema,
TtsConfigSchema,
TtsModeSchema,
TtsProviderSchema,
} from "openclaw/plugin-sdk/tts-runtime";
export { sleep } from "openclaw/plugin-sdk/runtime-env";

View File

@@ -0,0 +1,3 @@
// Runtime entrypoint for the voice-call plugin package.
export { createVoiceCallRuntime, type VoiceCallRuntime } from "./src/runtime.js";

View File

@@ -0,0 +1,52 @@
// Voice Call API module exposes the plugin public contract.
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { migrateVoiceCallLegacyConfigInput } from "./config-api.js";
// Setup-time entrypoint for voice-call config migrations.
/** Migrate voice-call plugin config inside the full OpenClaw config object. */
function migrateVoiceCallPluginConfig(config: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} | null {
const rawVoiceCallConfig = config.plugins?.entries?.["voice-call"]?.config;
if (!isRecord(rawVoiceCallConfig)) {
return null;
}
const migration = migrateVoiceCallLegacyConfigInput({
value: rawVoiceCallConfig,
configPathPrefix: "plugins.entries.voice-call.config",
});
if (migration.changes.length === 0) {
return null;
}
const plugins = structuredClone(config.plugins ?? {});
const entries = { ...plugins.entries };
const existingVoiceCallEntry = isRecord(entries["voice-call"])
? (entries["voice-call"] as Record<string, unknown>)
: {};
entries["voice-call"] = {
...existingVoiceCallEntry,
config: migration.config,
};
plugins.entries = entries;
return {
config: {
...config,
plugins,
},
changes: migration.changes,
};
}
/** Setup plugin entry that registers voice-call config migrations. */
export default definePluginEntry({
id: "voice-call",
name: "Voice Call Setup",
description: "Lightweight Voice Call setup hooks",
register(api) {
api.registerConfigMigration((config) => migrateVoiceCallPluginConfig(config));
},
});

View File

@@ -0,0 +1,45 @@
---
name: voice-call
description: "Start voice calls via the OpenClaw voice-call plugin."
metadata:
{
"openclaw":
{
"emoji": "📞",
"skillKey": "voice-call",
"requires": { "config": ["plugins.entries.voice-call.enabled"] },
},
}
---
# Voice Call
Use the voice-call plugin to start or inspect calls (Twilio, Telnyx, Plivo, or mock).
## CLI
```bash
openclaw voicecall call --to "+15555550123" --message "Hello from OpenClaw"
openclaw voicecall status --call-id <id>
```
## Tool
Use `voice_call` for agent-initiated calls.
Actions:
- `initiate_call` (message, to?, mode?)
- `continue_call` (callId, message)
- `speak_to_user` (callId, message)
- `end_call` (callId)
- `get_status` (callId)
Notes:
- Requires the voice-call plugin to be enabled.
- Plugin config lives under `plugins.entries.voice-call.config`.
- Twilio config: `provider: "twilio"` + `twilio.accountSid/authToken` + `fromNumber`.
- Telnyx config: `provider: "telnyx"` + `telnyx.apiKey/connectionId` + `fromNumber`.
- Plivo config: `provider: "plivo"` + `plivo.authId/authToken` + `fromNumber`.
- Dev fallback: `provider: "mock"` (no network).

View File

@@ -0,0 +1,19 @@
// Voice Call tests cover allowlist plugin behavior.
import { describe, expect, it } from "vitest";
import { isAllowlistedCaller, normalizePhoneNumber } from "./allowlist.js";
describe("voice-call allowlist", () => {
it("normalizes phone numbers by stripping non-digits", () => {
expect(normalizePhoneNumber("+1 (415) 555-0123")).toBe("14155550123");
expect(normalizePhoneNumber(" 020-7946-0958 ")).toBe("02079460958");
expect(normalizePhoneNumber("")).toBe("");
expect(normalizePhoneNumber()).toBe("");
});
it("matches normalized allowlist entries and rejects blank callers", () => {
expect(isAllowlistedCaller("14155550123", ["+1 (415) 555-0123", " 020-7946-0958 "])).toBe(true);
expect(isAllowlistedCaller("02079460958", ["+1 (415) 555-0123", " 020-7946-0958 "])).toBe(true);
expect(isAllowlistedCaller("", ["+1 (415) 555-0123"])).toBe(false);
expect(isAllowlistedCaller("14155550123", ["", "abc"])).toBe(false);
});
});

View File

@@ -0,0 +1,23 @@
// Caller allowlist helpers for provider-normalized phone numbers.
/** Normalize a phone number to digits only. */
export function normalizePhoneNumber(input?: string): string {
if (!input) {
return "";
}
return input.replace(/\D/g, "");
}
/** Return true when the normalized caller exactly matches an allowlist entry. */
export function isAllowlistedCaller(
normalizedFrom: string,
allowFrom: string[] | undefined,
): boolean {
if (!normalizedFrom) {
return false;
}
return (allowFrom ?? []).some((num) => {
const normalizedAllow = normalizePhoneNumber(num);
return normalizedAllow !== "" && normalizedAllow === normalizedFrom;
});
}

View File

@@ -0,0 +1,18 @@
// Voice Call tests cover bounded child output plugin behavior.
import { describe, expect, it } from "vitest";
import {
appendBoundedChildOutput,
emptyBoundedChildOutput,
formatBoundedChildOutput,
} from "./bounded-child-output.js";
describe("bounded child output", () => {
it("keeps a bounded tail and records truncation", () => {
const first = appendBoundedChildOutput(emptyBoundedChildOutput(), "abcdef", 5);
expect(first).toEqual({ text: "bcdef", truncated: true });
const second = appendBoundedChildOutput(first, "ghij", 5);
expect(second).toEqual({ text: "fghij", truncated: true });
expect(formatBoundedChildOutput(second)).toBe("[output truncated]\nfghij");
});
});

View File

@@ -0,0 +1,35 @@
// Bounded child-process output buffer for voice-call tunnel/process diagnostics.
const DEFAULT_MAX_OUTPUT_CHARS = 16_384;
/** Captured child output plus truncation flag. */
export type BoundedChildOutput = {
text: string;
truncated: boolean;
};
/** Create an empty bounded output buffer. */
export function emptyBoundedChildOutput(): BoundedChildOutput {
return { text: "", truncated: false };
}
/** Append output while retaining the newest maxChars and recording truncation. */
export function appendBoundedChildOutput(
current: BoundedChildOutput,
chunk: string,
maxChars = DEFAULT_MAX_OUTPUT_CHARS,
): BoundedChildOutput {
const appended = current.text + chunk;
if (appended.length <= maxChars) {
return { text: appended, truncated: current.truncated };
}
return {
text: appended.slice(-maxChars),
truncated: true,
};
}
/** Format captured output with a truncation marker when older text was dropped. */
export function formatBoundedChildOutput(output: BoundedChildOutput): string {
return output.truncated ? `[output truncated]\n${output.text}` : output.text;
}

View File

@@ -0,0 +1,81 @@
// Voice Call tests cover cli plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it } from "vitest";
import { testing } from "./cli.js";
describe("voice-call CLI gateway fallback", () => {
it("treats abnormal local gateway closes as standalone-runtime fallback candidates", () => {
expect(
testing.isGatewayUnavailableForLocalFallback(
new Error("gateway closed (1006 abnormal closure (no close frame)): no close reason"),
),
).toBe(true);
});
});
describe("parseVoiceCallIntOption", () => {
it("parses decimal integer option values", () => {
expect(testing.parseVoiceCallIntOption("250", "--poll", { min: 50 })).toBe(250);
expect(testing.parseVoiceCallIntOption(" 25 ", "--since")).toBe(25);
});
it("rejects non-decimal JavaScript numeric syntax", () => {
expect(() => testing.parseVoiceCallIntOption("0x10", "--last")).toThrow(
"Invalid numeric value for --last: 0x10",
);
expect(() => testing.parseVoiceCallIntOption("1e3", "--last")).toThrow(
"Invalid numeric value for --last: 1e3",
);
});
it("rejects unsafe integers and max-bound violations", () => {
expect(() => testing.parseVoiceCallIntOption("9007199254740993", "--last", { min: 1 })).toThrow(
"Invalid numeric value for --last: 9007199254740993",
);
expect(() =>
testing.parseVoiceCallIntOption("65536", "--port", { min: 1, max: 65535 }),
).toThrow("Invalid numeric value for --port: 65536");
});
});
describe("voice-call CLI timeout helpers", () => {
it("caps gateway operation timeout grace", () => {
expect(testing.resolveGatewayOperationTimeoutMs({ ringTimeoutMs: 10_000 } as never)).toBe(
30_000,
);
expect(testing.resolveGatewayOperationTimeoutMs({ ringTimeoutMs: 60_000 } as never)).toBe(
65_000,
);
expect(
testing.resolveGatewayOperationTimeoutMs({ ringTimeoutMs: Number.MAX_SAFE_INTEGER } as never),
).toBe(MAX_TIMER_TIMEOUT_MS);
expect(
testing.resolveGatewayOperationTimeoutMs({ ringTimeoutMs: Number.MAX_VALUE } as never),
).toBe(MAX_TIMER_TIMEOUT_MS);
});
it("caps gateway continue timeout totals", () => {
expect(testing.resolveGatewayContinueTimeoutMs({ transcriptTimeoutMs: 180_000 } as never)).toBe(
220_000,
);
expect(
testing.resolveGatewayContinueTimeoutMs({
transcriptTimeoutMs: Number.MAX_SAFE_INTEGER,
} as never),
).toBe(MAX_TIMER_TIMEOUT_MS);
});
it("caps gateway polling deadlines", () => {
expect(testing.resolveVoiceCallDeadlineMs(5_000, 10_000)).toBe(15_000);
expect(testing.resolveVoiceCallDeadlineMs(Number.MAX_SAFE_INTEGER, 10_000)).toBe(
10_000 + MAX_TIMER_TIMEOUT_MS,
);
});
it("caps gateway continue poll timeouts from async operation payloads", () => {
expect(
testing.readGatewayPollTimeoutMs({ pollTimeoutMs: Number.MAX_SAFE_INTEGER }, 45_000),
).toBe(MAX_TIMER_TIMEOUT_MS);
expect(testing.readGatewayPollTimeoutMs({ pollTimeoutMs: Number.NaN }, 45_000)).toBe(45_000);
});
});

View File

@@ -0,0 +1,929 @@
// Voice Call plugin module implements cli behavior.
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 { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime";
import {
addTimerTimeoutGraceMs,
clampTimerTimeoutMs,
MAX_TIMER_TIMEOUT_MS,
MAX_TCP_PORT,
parseStrictNonNegativeInteger,
} from "openclaw/plugin-sdk/number-runtime";
import {
isRecord,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { sleep } from "../api.js";
import { validateProviderConfig, type VoiceCallConfig } from "./config.js";
import { getCallHistoryFromStore } from "./manager/store.js";
import { setVoiceCallStateRuntime, type VoiceCallStateRuntime } from "./runtime-state.js";
import type { VoiceCallRuntime } from "./runtime.js";
import { resolveUserPath } from "./utils.js";
import { resolveWebhookExposureStatus } from "./webhook-exposure.js";
import {
cleanupTailscaleExposureRoute,
getTailscaleSelfInfo,
setupTailscaleExposureRoute,
} from "./webhook/tailscale.js";
type Logger = {
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
};
type SetupCheck = {
id: string;
ok: boolean;
message: string;
};
type SetupStatus = {
ok: boolean;
checks: SetupCheck[];
};
type VoiceCallGatewayMethod =
| "voicecall.initiate"
| "voicecall.start"
| "voicecall.continue"
| "voicecall.continue.start"
| "voicecall.continue.result"
| "voicecall.speak"
| "voicecall.dtmf"
| "voicecall.end"
| "voicecall.status";
type VoiceCallGatewayCallResult = { ok: true; payload: unknown } | { ok: false; error: unknown };
const VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS = 5000;
const VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS = 30000;
const VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS = 10000;
const VOICE_CALL_GATEWAY_POLL_INTERVAL_MS = 1000;
const voiceCallCliDeps = {
callGatewayFromCli,
};
export const testing = {
setCallGatewayFromCliForTests(next?: typeof callGatewayFromCli): void {
voiceCallCliDeps.callGatewayFromCli = next ?? callGatewayFromCli;
},
isGatewayUnavailableForLocalFallback,
parseVoiceCallIntOption,
resolveGatewayContinueTimeoutMs,
resolveGatewayOperationTimeoutMs,
readGatewayPollTimeoutMs,
resolveVoiceCallDeadlineMs,
};
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 parseVoiceCallIntOption(
raw: string | undefined,
optionName: string,
opts?: { min?: number; max?: number },
): number {
const min = opts?.min ?? 0;
const value = raw?.trim() ?? "";
const parsed = parseStrictNonNegativeInteger(value);
if (parsed === undefined || parsed < min || (opts?.max !== undefined && parsed > opts.max)) {
throw new Error(`Invalid numeric value for ${optionName}: ${raw ?? ""}`);
}
return parsed;
}
function isGatewayUnavailableForLocalFallback(err: unknown): boolean {
const message = formatErrorMessage(err);
return (
message.includes("ECONNREFUSED") ||
message.includes("ECONNRESET") ||
message.includes("EHOSTUNREACH") ||
message.includes("ENOTFOUND") ||
message.includes("gateway closed (1006") ||
message.includes("gateway not connected")
);
}
async function callVoiceCallGateway(
method: VoiceCallGatewayMethod,
params?: Record<string, unknown>,
opts?: { timeoutMs?: number },
): Promise<VoiceCallGatewayCallResult> {
try {
const timeoutMs =
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
? Math.max(1, Math.ceil(opts.timeoutMs))
: VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS;
const payload = await voiceCallCliDeps.callGatewayFromCli(
method,
{ json: true, timeout: String(timeoutMs) },
params,
{ progress: false },
);
return { ok: true, payload };
} catch (err) {
if (isGatewayUnavailableForLocalFallback(err)) {
return { ok: false, error: err };
}
throw err;
}
}
function resolveGatewayOperationTimeoutMs(config: VoiceCallConfig): number {
return Math.max(
VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS,
addTimerTimeoutGraceMs(config.ringTimeoutMs) ?? 1,
);
}
function resolveGatewayContinueTimeoutMs(config: VoiceCallConfig): number {
return (
clampTimerTimeoutMs(
config.transcriptTimeoutMs +
VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS +
VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS,
) ?? 1
);
}
function resolveVoiceCallDeadlineMs(timeoutMs: number, nowMs = Date.now()): number {
return nowMs + (clampTimerTimeoutMs(timeoutMs) ?? MAX_TIMER_TIMEOUT_MS);
}
function isUnknownGatewayMethod(err: unknown, method: VoiceCallGatewayMethod): boolean {
return formatErrorMessage(err).includes(`unknown method: ${method}`);
}
function readGatewayOperationId(payload: unknown): string {
if (isRecord(payload) && typeof payload.operationId === "string" && payload.operationId) {
return payload.operationId;
}
throw new Error("voicecall gateway response missing operationId");
}
function readGatewayPollTimeoutMs(payload: unknown, fallbackTimeoutMs: number): number {
if (isRecord(payload) && typeof payload.pollTimeoutMs === "number") {
return clampTimerTimeoutMs(payload.pollTimeoutMs) ?? fallbackTimeoutMs;
}
return fallbackTimeoutMs;
}
function readCompletedContinueResult(
payload: unknown,
):
| { status: "pending" }
| { status: "completed"; result: unknown }
| { status: "failed"; error: string } {
if (!isRecord(payload)) {
throw new Error("voicecall gateway response missing operation status");
}
if (payload.status === "pending") {
return { status: "pending" };
}
if (payload.status === "failed") {
return {
status: "failed",
error: typeof payload.error === "string" ? payload.error : "continue failed",
};
}
if (payload.status === "completed") {
return { status: "completed", result: payload.result };
}
throw new Error("voicecall gateway response has unknown operation status");
}
async function pollVoiceCallContinueGateway(params: {
operationId: string;
timeoutMs: number;
}): Promise<unknown> {
const deadlineMs = resolveVoiceCallDeadlineMs(params.timeoutMs);
while (Date.now() <= deadlineMs) {
const gateway = await callVoiceCallGateway(
"voicecall.continue.result",
{ operationId: params.operationId },
{ timeoutMs: VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS },
);
if (!gateway.ok) {
throw new Error(
`gateway unavailable while waiting for voicecall continue result: ${formatErrorMessage(
gateway.error,
)}`,
);
}
const result = readCompletedContinueResult(gateway.payload);
if (result.status === "completed") {
return result.result;
}
if (result.status === "failed") {
throw new Error(result.error);
}
await sleep(
Math.min(VOICE_CALL_GATEWAY_POLL_INTERVAL_MS, Math.max(1, deadlineMs - Date.now())),
);
}
throw new Error("voicecall continue timed out waiting for gateway operation");
}
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;
}
function buildSetupStatus(config: VoiceCallConfig): SetupStatus {
const validation = validateProviderConfig(config);
const webhookExposure = resolveWebhookExposureStatus(config);
const checks: SetupCheck[] = [
{
id: "plugin-enabled",
ok: config.enabled,
message: config.enabled
? "Voice Call plugin is enabled"
: "Enable plugins.entries.voice-call.enabled",
},
{
id: "provider",
ok: Boolean(config.provider),
message: config.provider
? `Provider configured: ${config.provider}`
: "Set plugins.entries.voice-call.config.provider",
},
{
id: "provider-config",
ok: validation.valid,
message: validation.valid
? "Provider credentials/config look complete"
: validation.errors.join("; "),
},
{
id: "webhook-exposure",
ok: webhookExposure.ok,
message: webhookExposure.message,
},
{
id: "mode",
ok: !(config.streaming.enabled && config.realtime.enabled),
message:
config.streaming.enabled && config.realtime.enabled
? "streaming.enabled and realtime.enabled cannot both be true"
: config.realtime.enabled
? `Realtime voice enabled (${config.realtime.provider ?? "first registered provider"})`
: config.streaming.enabled
? `Streaming transcription enabled (${config.streaming.provider ?? "first registered provider"})`
: "Notify/conversation calls use normal TTS/STT flow",
},
];
return {
ok: checks.every((check) => check.ok),
checks,
};
}
function writeSetupStatus(status: SetupStatus): void {
writeStdoutLine("Voice Call setup: %s", status.ok ? "OK" : "needs attention");
for (const check of status.checks) {
writeStdoutLine("%s %s: %s", check.ok ? "OK" : "FAIL", check.id, check.message);
}
}
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 });
}
function writeGatewayCallId(payload: unknown): void {
if (isRecord(payload) && typeof payload.callId === "string") {
writeStdoutJson({ callId: payload.callId });
return;
}
if (isRecord(payload) && typeof payload.error === "string") {
throw new Error(payload.error);
}
throw new Error("voicecall gateway response missing callId");
}
async function initiateCallViaGatewayOrRuntime(params: {
ensureRuntime: () => Promise<VoiceCallRuntime>;
config: VoiceCallConfig;
method: "voicecall.initiate" | "voicecall.start";
to?: string;
message?: string;
mode?: string;
}) {
const mode = resolveCallMode(params.mode);
const gateway = await callVoiceCallGateway(
params.method,
{
...(params.to ? { to: params.to } : {}),
...(params.message ? { message: params.message } : {}),
...(mode ? { mode } : {}),
},
{
timeoutMs: resolveGatewayOperationTimeoutMs(params.config),
},
);
if (gateway.ok) {
writeGatewayCallId(gateway.payload);
return;
}
const rt = await params.ensureRuntime();
const to = params.to ?? rt.config.toNumber;
if (!to) {
throw new Error("Missing --to and no toNumber configured");
}
await initiateCallAndPrintId({
runtime: rt,
to,
message: params.message,
mode: params.mode,
});
}
export function registerVoiceCallCli(params: {
program: Command;
config: VoiceCallConfig;
ensureRuntime: () => Promise<VoiceCallRuntime>;
stateRuntime?: VoiceCallStateRuntime["state"];
logger: Logger;
}) {
const { program, config, ensureRuntime, stateRuntime } = params;
const ensureHistoryStateRuntime = (): void => {
if (stateRuntime) {
setVoiceCallStateRuntime({ state: stateRuntime });
}
};
const root = program
.command("voicecall")
.description("Voice call utilities")
.addHelpText("after", () => `\nDocs: https://docs.openclaw.ai/cli/voicecall\n`);
root
.command("setup")
.description("Show Voice Call provider and webhook setup status")
.option("--json", "Print machine-readable JSON")
.action((options: { json?: boolean }) => {
const status = buildSetupStatus(config);
if (options.json) {
writeStdoutJson(status);
return;
}
writeSetupStatus(status);
});
root
.command("smoke")
.description("Check Voice Call readiness and optionally place a short outbound test call")
.option("-t, --to <phone>", "Phone number to call for a live smoke")
.option(
"--message <text>",
"Message to speak during the smoke call",
"OpenClaw voice call smoke test.",
)
.option("--mode <mode>", "Call mode: notify or conversation", "notify")
.option("--yes", "Actually place the live outbound call")
.option("--json", "Print machine-readable JSON")
.action(
async (options: {
to?: string;
message?: string;
mode?: string;
yes?: boolean;
json?: boolean;
}) => {
const setup = buildSetupStatus(config);
if (!setup.ok) {
if (options.json) {
writeStdoutJson({ ok: false, setup });
} else {
writeSetupStatus(setup);
}
process.exitCode = 1;
return;
}
if (!options.to) {
if (options.json) {
writeStdoutJson({ ok: true, setup, liveCall: false });
} else {
writeSetupStatus(setup);
writeStdoutLine("live-call: skipped (pass --to and --yes to place one)");
}
return;
}
if (!options.yes) {
if (options.json) {
writeStdoutJson({ ok: true, setup, liveCall: false, wouldCall: options.to });
} else {
writeSetupStatus(setup);
writeStdoutLine("live-call: dry run for %s (add --yes to place it)", options.to);
}
return;
}
const mode = resolveCallMode(options.mode) ?? "notify";
const gateway = await callVoiceCallGateway(
"voicecall.start",
{
to: options.to,
...(options.message ? { message: options.message } : {}),
mode,
},
{
timeoutMs: resolveGatewayOperationTimeoutMs(config),
},
);
let callId: unknown;
if (gateway.ok) {
callId = isRecord(gateway.payload) ? gateway.payload.callId : undefined;
} else {
const rt = await ensureRuntime();
const result = await rt.manager.initiateCall(options.to, undefined, {
message: options.message,
mode,
});
if (!result.success) {
throw new Error(result.error || "smoke call failed");
}
callId = result.callId;
}
if (typeof callId !== "string" || !callId) {
throw new Error("smoke call failed");
}
if (options.json) {
writeStdoutJson({ ok: true, setup, liveCall: true, callId });
return;
}
writeSetupStatus(setup);
writeStdoutLine("live-call: started %s", callId);
},
);
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 }) => {
await initiateCallViaGatewayOrRuntime({
ensureRuntime,
config,
method: "voicecall.initiate",
to: options.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 }) => {
await initiateCallViaGatewayOrRuntime({
ensureRuntime,
config,
method: "voicecall.start",
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 }) => {
let gateway: VoiceCallGatewayCallResult;
try {
gateway = await callVoiceCallGateway(
"voicecall.continue.start",
{
callId: options.callId,
message: options.message,
},
{
timeoutMs: resolveGatewayOperationTimeoutMs(config),
},
);
} catch (err) {
if (!isUnknownGatewayMethod(err, "voicecall.continue.start")) {
throw err;
}
gateway = await callVoiceCallGateway(
"voicecall.continue",
{
callId: options.callId,
message: options.message,
},
{
timeoutMs: resolveGatewayContinueTimeoutMs(config),
},
);
}
if (gateway.ok) {
if (isRecord(gateway.payload) && typeof gateway.payload.operationId === "string") {
const result = await pollVoiceCallContinueGateway({
operationId: readGatewayOperationId(gateway.payload),
timeoutMs: readGatewayPollTimeoutMs(
gateway.payload,
resolveGatewayContinueTimeoutMs(config),
),
});
writeStdoutJson(result);
return;
}
writeStdoutJson(gateway.payload);
return;
}
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 gateway = await callVoiceCallGateway("voicecall.speak", {
callId: options.callId,
message: options.message,
});
if (gateway.ok) {
writeStdoutJson(gateway.payload);
return;
}
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("dtmf")
.description("Send DTMF digits to an active call")
.requiredOption("--call-id <id>", "Call ID")
.requiredOption("--digits <digits>", "DTMF digits")
.action(async (options: { callId: string; digits: string }) => {
const gateway = await callVoiceCallGateway("voicecall.dtmf", {
callId: options.callId,
digits: options.digits,
});
if (gateway.ok) {
writeStdoutJson(gateway.payload);
return;
}
const rt = await ensureRuntime();
const result = await rt.manager.sendDtmf(options.callId, options.digits);
if (!result.success) {
throw new Error(result.error || "dtmf failed");
}
writeStdoutJson(result);
});
root
.command("end")
.description("Hang up an active call")
.requiredOption("--call-id <id>", "Call ID")
.action(async (options: { callId: string }) => {
const gateway = await callVoiceCallGateway("voicecall.end", {
callId: options.callId,
});
if (gateway.ok) {
writeStdoutJson(gateway.payload);
return;
}
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")
.option("--call-id <id>", "Call ID")
.option("--json", "Print machine-readable JSON")
.action(async (options: { callId?: string; json?: boolean }) => {
const gateway = await callVoiceCallGateway(
"voicecall.status",
options.callId ? { callId: options.callId } : undefined,
);
if (gateway.ok) {
if (options.callId && isRecord(gateway.payload)) {
if (gateway.payload.found === true && "call" in gateway.payload) {
writeStdoutJson(gateway.payload.call);
return;
}
if (gateway.payload.found === false) {
writeStdoutJson({ found: false });
return;
}
}
writeStdoutJson(gateway.payload);
return;
}
const rt = await ensureRuntime();
if (options.callId) {
const call = rt.manager.getCall(options.callId);
writeStdoutJson(call ?? { found: false });
return;
}
writeStdoutJson({
found: true,
calls: rt.manager.getActiveCalls(),
});
});
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 = parseVoiceCallIntOption(options.since, "--since", { min: 0 });
const pollMs = parseVoiceCallIntOption(options.poll, "--poll", { min: 50 });
const tailSqliteHistory = async (initialLimit: number): Promise<never> => {
ensureHistoryStateRuntime();
const seen = new Set<string>();
const printCall = (call: unknown): void => {
const line = JSON.stringify(call);
if (!seen.has(line)) {
seen.add(line);
writeStdoutLine(line);
}
};
if (initialLimit > 0) {
for (const call of await getCallHistoryFromStore(path.dirname(file), initialLimit)) {
printCall(call);
}
}
for (;;) {
try {
for (const call of await getCallHistoryFromStore(path.dirname(file), 1000)) {
printCall(call);
}
} catch {
// ignore and retry
}
await sleep(pollMs);
}
};
if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") {
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);
}
} else {
await tailSqliteHistory(since);
}
});
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 = parseVoiceCallIntOption(options.last, "--last", { min: 1 });
if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") {
const content = fs.readFileSync(file, "utf8");
const calls = content
.split("\n")
.filter(Boolean)
.slice(-last)
.map((line) => {
try {
const parsed = JSON.parse(line) as { call?: unknown };
return (parsed.call ?? parsed) as { metadata?: Record<string, unknown> };
} catch {
return null;
}
})
.filter((call): call is { metadata?: Record<string, unknown> } => call !== null);
writeVoiceCallLatencySummary(calls);
} else {
ensureHistoryStateRuntime();
writeVoiceCallLatencySummary(await getCallHistoryFromStore(path.dirname(file), last));
}
});
function writeVoiceCallLatencySummary(calls: Array<{ metadata?: Record<string, unknown> }>) {
const turnLatencyMs: number[] = [];
const listenWaitMs: number[] = [];
for (const call of calls) {
const latency = call.metadata?.lastTurnLatencyMs;
const listenWait = call.metadata?.lastTurnListenWaitMs;
if (typeof latency === "number" && Number.isFinite(latency)) {
turnLatencyMs.push(latency);
}
if (typeof listenWait === "number" && Number.isFinite(listenWait)) {
listenWaitMs.push(listenWait);
}
}
writeStdoutJson({
recordsScanned: calls.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 = parseVoiceCallIntOption(
options.port ?? String(config.serve.port ?? 3334),
"--port",
{ min: 1, max: MAX_TCP_PORT },
);
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,
},
});
},
);
}
export { testing as __testing };

View File

@@ -0,0 +1,210 @@
// Voice Call tests cover config compat plugin behavior.
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,
},
});
const streaming = normalized.streaming as
| {
enabled?: boolean;
provider?: string;
providers?: {
openai?: {
apiKey?: string;
model?: string;
silenceDurationMs?: number;
vadThreshold?: number;
};
};
openaiApiKey?: unknown;
sttModel?: unknown;
}
| undefined;
expect(streaming?.enabled).toBe(true);
expect(streaming?.provider).toBe("openai");
expect(streaming?.providers?.openai).toEqual({
apiKey: "sk-test",
model: "gpt-4o-transcribe",
silenceDurationMs: 700,
vadThreshold: 0.4,
});
expect(streaming?.openaiApiKey).toBeUndefined();
expect(streaming?.sttModel).toBeUndefined();
});
it("removes legacy realtime agentContext system prompt toggle", () => {
const normalized = normalizeVoiceCallLegacyConfigInput({
realtime: {
agentContext: {
enabled: true,
includeSystemPrompt: false,
includeWorkspaceFiles: true,
},
},
});
const agentContext = (
normalized.realtime as
| {
agentContext?: {
enabled?: boolean;
includeSystemPrompt?: unknown;
includeWorkspaceFiles?: boolean;
};
}
| undefined
)?.agentContext;
expect(agentContext).toEqual({
enabled: true,
includeWorkspaceFiles: true,
});
});
it("does not migrate non-finite legacy streaming numbers", () => {
const migration = migrateVoiceCallLegacyConfigInput({
value: {
streaming: {
silenceDurationMs: Number.NaN,
vadThreshold: Number.POSITIVE_INFINITY,
},
},
configPathPrefix: "plugins.entries.voice-call.config",
});
const streaming = migration.config.streaming as
| {
providers?: {
openai?: {
silenceDurationMs?: number;
vadThreshold?: number;
};
};
}
| undefined;
expect(streaming?.providers?.openai).toBeUndefined();
expect(migration.changes).toEqual([
"Removed invalid plugins.entries.voice-call.config.streaming.silenceDurationMs.",
"Removed invalid plugins.entries.voice-call.config.streaming.vadThreshold.",
]);
expect(migration.issues.map((issue) => issue.path)).toEqual([
"streaming.silenceDurationMs",
"streaming.vadThreshold",
]);
});
it("reports doctor-oriented legacy issues and warnings", () => {
const raw = {
provider: "log",
twilio: {
from: "+15550001234",
},
streaming: {
sttProvider: "openai",
openaiApiKey: "sk-test", // pragma: allowlist secret
},
realtime: {
agentContext: {
includeSystemPrompt: true,
},
},
};
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.",
},
{
path: "realtime.agentContext.includeSystemPrompt",
replacement: "realtime.agentContext",
message:
"Remove realtime.agentContext.includeSystemPrompt; realtime context now uses the generated agent prompt.",
},
]);
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.",
"[voice-call] plugins.entries.voice-call.config.realtime.agentContext.includeSystemPrompt: Remove realtime.agentContext.includeSystemPrompt; realtime context now uses the generated agent prompt.",
]);
});
it("returns doctor migration change lines", () => {
const migration = migrateVoiceCallLegacyConfigInput({
value: {
provider: "log",
streaming: {
sttProvider: "openai",
},
realtime: {
agentContext: {
includeSystemPrompt: true,
},
},
},
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.",
"Removed plugins.entries.voice-call.config.realtime.agentContext.includeSystemPrompt.",
]);
});
});

View File

@@ -0,0 +1,275 @@
// Voice Call helper module supports config compat behavior.
import { asOptionalRecord, readStringField } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { VoiceCallConfig } from "./config.js";
import { VoiceCallConfigSchema } from "./config.js";
// Legacy voice-call config warnings and doctor-fix migration helpers.
/** Version where legacy voice-call config shape support is removed. */
export const VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION = "2026.6.0";
/** One legacy config issue with the replacement path and message. */
type VoiceCallLegacyConfigIssue = {
path: string;
replacement: string;
message: string;
};
const asObject = asOptionalRecord;
const getString = readStringField;
/** Read finite numeric config values. */
function getNumber(obj: Record<string, unknown> | undefined, key: string): number | undefined {
const value = obj?.[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
/** Merge legacy provider-specific values into the canonical providers map. */
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,
},
};
}
/** Collect legacy voice-call config keys that should be migrated. */
export function collectVoiceCallLegacyConfigIssues(value: unknown): VoiceCallLegacyConfigIssue[] {
const raw = asObject(value) ?? {};
const realtime = asObject(raw.realtime);
const realtimeAgentContext = asObject(realtime?.agentContext);
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.",
});
}
if (realtimeAgentContext && Object.hasOwn(realtimeAgentContext, "includeSystemPrompt")) {
issues.push({
path: "realtime.agentContext.includeSystemPrompt",
replacement: "realtime.agentContext",
message:
"Remove realtime.agentContext.includeSystemPrompt; realtime context now uses the generated agent prompt.",
});
}
return issues;
}
/** Format runtime warnings for legacy voice-call config keys. */
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}`,
),
];
}
/** Migrate legacy voice-call config input to the current canonical shape. */
export function migrateVoiceCallLegacyConfigInput(params: {
value: unknown;
configPathPrefix?: string;
}): {
config: Record<string, unknown>;
changes: string[];
issues: VoiceCallLegacyConfigIssue[];
} {
const raw = asObject(params.value) ?? {};
const realtime = asObject(raw.realtime);
const realtimeAgentContext = asObject(realtime?.agentContext);
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 normalizedRealtimeAgentContext = realtimeAgentContext
? {
...realtimeAgentContext,
}
: undefined;
if (normalizedRealtimeAgentContext) {
delete normalizedRealtimeAgentContext.includeSystemPrompt;
}
const normalizedRealtime = realtime
? {
...realtime,
agentContext: normalizedRealtimeAgentContext ?? realtime.agentContext,
}
: undefined;
const config = {
...raw,
provider: raw.provider === "log" ? "mock" : raw.provider,
fromNumber: raw.fromNumber ?? (typeof twilio?.from === "string" ? twilio.from : undefined),
twilio: normalizedTwilio,
streaming: normalizedStreaming,
realtime: normalizedRealtime,
};
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 (getNumber(streaming, "silenceDurationMs") !== undefined) {
changes.push(
`Moved ${configPathPrefix}.streaming.silenceDurationMs → ${configPathPrefix}.streaming.providers.openai.silenceDurationMs.`,
);
} else if (typeof streaming?.silenceDurationMs === "number") {
changes.push(`Removed invalid ${configPathPrefix}.streaming.silenceDurationMs.`);
}
if (getNumber(streaming, "vadThreshold") !== undefined) {
changes.push(
`Moved ${configPathPrefix}.streaming.vadThreshold → ${configPathPrefix}.streaming.providers.openai.vadThreshold.`,
);
} else if (typeof streaming?.vadThreshold === "number") {
changes.push(`Removed invalid ${configPathPrefix}.streaming.vadThreshold.`);
}
if (realtimeAgentContext && Object.hasOwn(realtimeAgentContext, "includeSystemPrompt")) {
changes.push(`Removed ${configPathPrefix}.realtime.agentContext.includeSystemPrompt.`);
}
return { config, changes, issues };
}
/** Normalize legacy voice-call config input without returning migration metadata. */
export function normalizeVoiceCallLegacyConfigInput(value: unknown): Record<string, unknown> {
return migrateVoiceCallLegacyConfigInput({ value }).config;
}
/** Parse voice-call plugin config after applying legacy normalization. */
export function parseVoiceCallPluginConfig(value: unknown): VoiceCallConfig {
return VoiceCallConfigSchema.parse(normalizeVoiceCallLegacyConfigInput(value));
}

View File

@@ -0,0 +1,715 @@
// Voice Call tests cover config plugin behavior.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
VoiceCallConfigSchema,
resolveVoiceCallAgentSessionKey,
resolveTwilioAuthToken,
resolveVoiceCallEffectiveConfig,
resolveVoiceCallNumberRouteKey,
resolveVoiceCallNumberRouteKeyForCall,
resolveVoiceCallSessionKey,
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 envRef(id: string) {
return { source: "env" as const, provider: "default", id };
}
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.TWILIO_FROM_NUMBER;
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)).toEqual({ valid: true, errors: [] });
clearProviderEnv();
if (provider === "twilio") {
process.env.TWILIO_ACCOUNT_SID = "AC123";
process.env.TWILIO_AUTH_TOKEN = "secret";
process.env.TWILIO_FROM_NUMBER = "+15550001234";
} 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)).toEqual({ valid: true, errors: [] });
}
});
});
describe("twilio provider", () => {
it("accepts SecretRef-backed auth tokens before runtime resolution", () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "twilio",
fromNumber: "+15550001234",
twilio: {
accountSid: "AC123",
authToken: envRef("TWILIO_AUTH_TOKEN"),
},
});
expect(config.twilio?.authToken).toEqual(envRef("TWILIO_AUTH_TOKEN"));
expect(validateProviderConfig(config)).toEqual({ valid: true, errors: [] });
expect(() => resolveTwilioAuthToken(config)).toThrow(
'plugins.entries.voice-call.config.twilio.authToken: unresolved SecretRef "env:default:TWILIO_AUTH_TOKEN"',
);
});
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).toStrictEqual([]);
});
it("resolves the Twilio from number from environment", () => {
process.env.TWILIO_ACCOUNT_SID = "AC123";
process.env.TWILIO_AUTH_TOKEN = "secret";
process.env.TWILIO_FROM_NUMBER = "+15550001234";
const config = resolveVoiceCallConfig({
...createBaseConfig("twilio"),
fromNumber: undefined,
});
expect(config.fromNumber).toBe("+15550001234");
expect(validateProviderConfig(config)).toEqual({ valid: true, errors: [] });
});
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)).toEqual({ valid: true, errors: [] });
const skippedVerification = createBaseConfig("telnyx");
skippedVerification.skipSignatureVerification = true;
skippedVerification.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
expect(validateProviderConfig(skippedVerification)).toEqual({
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).toStrictEqual([]);
});
});
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",
);
});
it("accepts realtime.enabled with provider=telnyx", () => {
const config = createBaseConfig("telnyx");
config.realtime.enabled = true;
config.inboundPolicy = "allowlist";
const result = validateProviderConfig(config);
expect(result.errors).not.toContain(
'plugins.entries.voice-call.config.provider must be "twilio" or "telnyx" when realtime.enabled is true',
);
});
it("rejects realtime.enabled with providers that do not support it yet", () => {
const config = createBaseConfig("plivo");
config.realtime.enabled = true;
config.inboundPolicy = "allowlist";
const result = validateProviderConfig(config);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
'plugins.entries.voice-call.config.provider must be "twilio" or "telnyx" when realtime.enabled is true',
);
});
});
});
describe("resolveVoiceCallConfig session routing", () => {
it("enables the pre-answer stale call reaper by default", () => {
const config = resolveVoiceCallConfig({ enabled: true, provider: "mock" });
expect(config.staleCallReaperSeconds).toBe(120);
});
it("keeps voice sessions scoped by phone by default", () => {
const config = resolveVoiceCallConfig({ enabled: true, provider: "mock" });
expect(config.sessionScope).toBe("per-phone");
expect(
resolveVoiceCallSessionKey({
config,
callId: "call-123",
phone: "+1 (555) 000-1111",
}),
).toBe("agent:main:voice:15550001111");
});
it("scopes generated voice session keys by configured agent", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
agentId: "Voice",
});
expect(
resolveVoiceCallSessionKey({
config,
callId: "CALL-123",
phone: "+1 (555) 000-1111",
}),
).toBe("agent:voice:voice:15550001111");
});
it("can scope voice sessions to each call", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
sessionScope: "per-call",
});
expect(config.sessionScope).toBe("per-call");
expect(
resolveVoiceCallSessionKey({
config,
callId: "call-123",
phone: "+1 (555) 000-1111",
}),
).toBe("agent:main:voice:call:call-123");
});
it("scopes explicit voice session keys by configured agent", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
sessionScope: "per-call",
});
expect(
resolveVoiceCallSessionKey({
config,
callId: "call-123",
phone: "+1 (555) 000-1111",
explicitSessionKey: "Meet-Room-1",
}),
).toBe("agent:main:meet-room-1");
});
it("scopes persisted and explicit keys at the agent session boundary", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
agentId: "Voice",
});
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "voice:call:legacy-call",
}),
).toBe("agent:voice:voice:call:legacy-call");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "meet-room-1",
}),
).toBe("agent:voice:meet-room-1");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:main:shared-room",
}),
).toBe("agent:voice:agent:main:shared-room");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:other:Matrix:Channel:!RoomAbC:example.org",
}),
).toBe("agent:voice:agent:other:matrix:channel:!RoomAbC:example.org");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:voice:agent:other:matrix:channel:!RoomAbC:example.org",
}),
).toBe("agent:voice:agent:other:matrix:channel:!RoomAbC:example.org");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "Signal:Group:AbC123=",
}),
).toBe("agent:voice:signal:group:AbC123=");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:broken",
}),
).toBe("agent:voice:agent:broken");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent::broken",
}),
).toBe("agent:voice:agent::broken");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent::Matrix:Channel:!RoomAbC:example.org",
}),
).toBe("agent:voice:agent::matrix:channel:!RoomAbC:example.org");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:other:room::part",
}),
).toBe("agent:voice:agent:other:room::part");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:voice:room::part",
}),
).toBe("agent:voice:room::part");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:voice::Matrix:Channel:!RoomAbC:example.org",
}),
).toBe("agent:voice:agent:voice::matrix:channel:!RoomAbC:example.org");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:bad/id:room",
}),
).toBe("agent:voice:agent:bad/id:room");
});
it("canonicalizes raw and scoped main aliases with the core session config", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
agentId: "Voice",
});
for (const sessionKey of ["main", "agent:voice:main"]) {
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey,
coreSession: { mainKey: "work" },
}),
).toBe("agent:voice:work");
}
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "main",
coreSession: { scope: "global" },
}),
).toBe("global");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:main:main",
coreSession: { mainKey: "work" },
}),
).toBe("agent:voice:agent:main:main");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:main:main",
coreSession: { scope: "global" },
}),
).toBe("agent:voice:agent:main:main");
});
it("resolves per-number inbound route overrides over global voice settings", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
inboundGreeting: "Hello from global.",
agentId: "main",
responseModel: "openai/gpt-5.4-mini",
responseSystemPrompt: "Global voice assistant.",
responseTimeoutMs: 10000,
tts: {
provider: "openai",
providers: {
openai: { voice: "coral", speed: 1 },
},
},
numbers: {
"+15550001111": {
inboundGreeting: "Silver Fox Cards, how can I help?",
agentId: "cards",
responseModel: "openai/gpt-5.5",
responseSystemPrompt: "You are a baseball card expert.",
responseTimeoutMs: 20000,
tts: {
providers: {
openai: { voice: "alloy" },
},
},
},
},
});
expect(resolveVoiceCallNumberRouteKey(config, "+1 (555) 000-1111")).toBe("+15550001111");
const effective = resolveVoiceCallEffectiveConfig(config, "+1 (555) 000-1111");
expect(effective.numberRouteKey).toBe("+15550001111");
expect(effective.config.inboundGreeting).toBe("Silver Fox Cards, how can I help?");
expect(effective.config.agentId).toBe("cards");
expect(effective.config.responseModel).toBe("openai/gpt-5.5");
expect(effective.config.responseSystemPrompt).toBe("You are a baseball card expert.");
expect(effective.config.responseTimeoutMs).toBe(20000);
expect(effective.config.tts?.provider).toBe("openai");
expect(effective.config.tts?.providers?.openai).toEqual({ voice: "alloy", speed: 1 });
});
it("falls back to global voice settings when no per-number route matches", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
inboundGreeting: "Hello from global.",
numbers: {
"+15550001111": {
inboundGreeting: "Hello from route.",
},
},
});
const effective = resolveVoiceCallEffectiveConfig(config, "+15550002222");
expect(effective.numberRouteKey).toBeUndefined();
expect(effective.config).toBe(config);
expect(effective.config.inboundGreeting).toBe("Hello from global.");
});
it("uses dialed-number fallback only for inbound calls", () => {
expect(
resolveVoiceCallNumberRouteKeyForCall({
direction: "inbound",
to: "+15550001111",
}),
).toBe("+15550001111");
expect(
resolveVoiceCallNumberRouteKeyForCall({
direction: "outbound",
to: "+15550001111",
}),
).toBeUndefined();
expect(
resolveVoiceCallNumberRouteKeyForCall({
direction: "inbound",
to: "+15550001111",
metadata: { numberRouteKey: "+15550002222" },
}),
).toBe("+15550002222");
expect(
resolveVoiceCallNumberRouteKeyForCall({
direction: "outbound",
to: "+15550001111",
metadata: { numberRouteKey: "+15550002222" },
}),
).toBeUndefined();
});
});
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).toStrictEqual({});
expect(normalized.realtime.streamPath).toBe("/voice/stream/realtime");
expect(normalized.realtime.toolPolicy).toBe("safe-read-only");
expect(normalized.realtime.consultPolicy).toBe("auto");
expect(normalized.realtime.fastContext).toEqual({
enabled: false,
timeoutMs: 800,
maxResults: 3,
sources: ["memory", "sessions"],
fallbackToConsult: false,
});
expect(normalized.realtime.consultThinkingLevel).toBeUndefined();
expect(normalized.realtime.consultFastMode).toBeUndefined();
expect(normalized.realtime.agentContext).toEqual({
enabled: false,
maxChars: 6000,
includeIdentity: true,
includeWorkspaceFiles: true,
files: ["SOUL.md", "IDENTITY.md", "USER.md"],
});
expect(normalized.realtime.instructions).toContain("openclaw_agent_consult");
expect(normalized.tunnel.provider).toBe("none");
expect(normalized.webhookSecurity.allowedHosts).toStrictEqual([]);
});
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 realtime settings", () => {
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.toolPolicy).toBe("safe-read-only");
expect(resolved.realtime.consultPolicy).toBe("auto");
expect(resolved.realtime.provider).toBeUndefined();
});
it("preserves configured realtime consult overrides", () => {
const resolved = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
realtime: {
consultThinkingLevel: "low",
consultFastMode: true,
},
});
expect(resolved.realtime.consultThinkingLevel).toBe("low");
expect(resolved.realtime.consultFastMode).toBe(true);
});
it("rejects invalid realtime consult thinking levels", () => {
expect(() =>
resolveVoiceCallConfig({
enabled: true,
provider: "mock",
realtime: {
consultThinkingLevel: "turbo",
},
} as never),
).toThrow(/Invalid option/);
});
it("leaves responseModel unset so voice responses can inherit runtime defaults", () => {
const resolved = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
});
expect(resolved.responseModel).toBeUndefined();
});
it("preserves the configured voice response agent id", () => {
const resolved = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
agentId: "voice",
});
expect(resolved.agentId).toBe("voice");
});
});

View File

@@ -0,0 +1,935 @@
// Voice Call helper module supports config behavior.
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES } from "openclaw/plugin-sdk/realtime-voice";
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
import {
buildSecretInputSchema,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
type SecretInput,
} from "openclaw/plugin-sdk/secret-input";
import {
canonicalizeMainSessionAlias,
type SessionScope,
} from "openclaw/plugin-sdk/session-store-runtime";
import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
import { z } from "zod";
import { TtsConfigSchema } from "../api.js";
import { deepMergeDefined } from "./deep-merge.js";
import { DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS } from "./realtime-defaults.js";
// -----------------------------------------------------------------------------
// Phone Number Validation
// -----------------------------------------------------------------------------
/**
* E.164 phone number format: +[country code][number]
* Examples use 555 prefix (reserved for fictional numbers)
*/
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!)
*/
const InboundPolicySchema = z.enum(["disabled", "allowlist", "pairing", "open"]);
// -----------------------------------------------------------------------------
// Provider-Specific Configuration
// -----------------------------------------------------------------------------
const SecretInputSchema = buildSecretInputSchema();
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>;
const TwilioConfigSchema = z
.object({
/** Twilio Account SID */
accountSid: z.string().min(1).optional(),
/** Twilio Auth Token */
authToken: SecretInputSchema.optional(),
})
.strict();
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 type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>;
const VoiceCallNumberRouteConfigSchema = z
.object({
/** Greeting message for inbound calls to this number. */
inboundGreeting: z.string().optional(),
/** TTS override for inbound calls to this number. Deep-merges with global voice-call TTS. */
tts: TtsConfigSchema,
/** Agent ID to use for voice response generation for this number. */
agentId: z.string().min(1).optional(),
/** Optional model override for voice responses for this number. */
responseModel: z.string().optional(),
/** System prompt for voice responses for this number. */
responseSystemPrompt: z.string().optional(),
/** Timeout for response generation in ms for this number. */
responseTimeoutMs: z.number().int().positive().optional(),
})
.strict();
export type VoiceCallNumberRouteConfig = z.infer<typeof VoiceCallNumberRouteConfigSchema>;
// -----------------------------------------------------------------------------
// Webhook Server Configuration
// -----------------------------------------------------------------------------
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" });
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" });
// -----------------------------------------------------------------------------
// Tunnel Configuration (unified ngrok/tailscale)
// -----------------------------------------------------------------------------
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 });
// -----------------------------------------------------------------------------
// Webhook Security Configuration
// -----------------------------------------------------------------------------
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
*/
const CallModeSchema = z.enum(["notify", "conversation"]);
export type CallMode = z.infer<typeof CallModeSchema>;
const VoiceCallSessionScopeSchema = z.enum(["per-phone", "per-call"]);
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 });
// -----------------------------------------------------------------------------
// Realtime Voice Configuration
// -----------------------------------------------------------------------------
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();
type RealtimeToolConfig = z.infer<typeof RealtimeToolSchema>;
const VoiceCallRealtimeProvidersConfigSchema = z
.record(z.string(), z.record(z.string(), z.unknown()))
.default({});
const VoiceCallRealtimeToolPolicySchema = z.enum(REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES);
const VoiceCallRealtimeConsultPolicySchema = z.enum(["auto", "substantive", "always"]);
const VoiceCallRealtimeFastContextSourceSchema = z.enum(["memory", "sessions"]);
const VoiceCallRealtimeFastContextConfigSchema = z
.object({
/** Enable bounded memory/session lookup before the full consult agent. */
enabled: z.boolean().default(false),
/** Hard deadline for the fast context lookup. */
timeoutMs: z.number().int().positive().default(800),
/** Maximum memory/session hits to inject into the realtime tool result. */
maxResults: z.number().int().positive().default(3),
/** Indexed sources used by the fast context lookup. */
sources: z
.array(VoiceCallRealtimeFastContextSourceSchema)
.min(1)
.default(["memory", "sessions"]),
/** Fall back to the full agent consult when fast context has no answer. */
fallbackToConsult: z.boolean().default(false),
})
.strict()
.default({
enabled: false,
timeoutMs: 800,
maxResults: 3,
sources: ["memory", "sessions"],
fallbackToConsult: false,
});
export type VoiceCallRealtimeFastContextConfig = z.infer<
typeof VoiceCallRealtimeFastContextConfigSchema
>;
const VoiceCallRealtimeAgentContextConfigSchema = z
.object({
/** Inject a compact agent persona/context capsule into realtime voice instructions. */
enabled: z.boolean().default(false),
/** Maximum number of characters from the generated capsule to append. */
maxChars: z.number().int().positive().default(6000),
/** Include configured agent identity fields. */
includeIdentity: z.boolean().default(true),
/** Include selected workspace files such as SOUL.md and IDENTITY.md. */
includeWorkspaceFiles: z.boolean().default(true),
/** Workspace-relative files to include, bounded by maxChars. */
files: z.array(z.string().min(1)).default(["SOUL.md", "IDENTITY.md", "USER.md"]),
})
.strict()
.default({
enabled: false,
maxChars: 6000,
includeIdentity: true,
includeWorkspaceFiles: true,
files: ["SOUL.md", "IDENTITY.md", "USER.md"],
});
export const VoiceCallRealtimeConsultThinkingLevelSchema = z.enum([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"adaptive",
"max",
]);
const VoiceCallStreamingProvidersConfigSchema = z
.record(z.string(), z.record(z.string(), z.unknown()))
.default({});
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().default(DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS),
/** Tool policy for the shared OpenClaw agent consult tool. */
toolPolicy: VoiceCallRealtimeToolPolicySchema.default("safe-read-only"),
/** Guidance for when the realtime model should call the OpenClaw agent consult tool. */
consultPolicy: VoiceCallRealtimeConsultPolicySchema.default("auto"),
/** Optional thinking level override for the regular agent behind realtime consults. */
consultThinkingLevel: VoiceCallRealtimeConsultThinkingLevelSchema.optional(),
/** Optional fast mode override for the regular agent behind realtime consults. */
consultFastMode: z.boolean().optional(),
/** Tool definitions exposed to the realtime provider. */
tools: z.array(RealtimeToolSchema).default([]),
/** Low-latency memory/session context for the consult tool. */
fastContext: VoiceCallRealtimeFastContextConfigSchema,
/** Bounded agent persona/context injection for the fast realtime voice path. */
agentContext: VoiceCallRealtimeAgentContextConfigSchema,
/** Provider-owned raw config blobs keyed by provider id. */
providers: VoiceCallRealtimeProvidersConfigSchema,
})
.strict()
.default({
enabled: false,
instructions: DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS,
toolPolicy: "safe-read-only",
consultPolicy: "auto",
tools: [],
fastContext: {
enabled: false,
timeoutMs: 800,
maxResults: 3,
sources: ["memory", "sessions"],
fallbackToConsult: false,
},
agentContext: {
enabled: false,
maxChars: 6000,
includeIdentity: true,
includeWorkspaceFiles: true,
files: ["SOUL.md", "IDENTITY.md", "USER.md"],
},
providers: {},
});
export type VoiceCallRealtimeConfig = z.infer<typeof VoiceCallRealtimeConfigSchema>;
// -----------------------------------------------------------------------------
// Streaming Configuration (Realtime Transcription)
// -----------------------------------------------------------------------------
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,
});
// -----------------------------------------------------------------------------
// 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(),
/** Per-dialed-number overrides for inbound calls. Keys are E.164 numbers. */
numbers: z.record(E164Schema, VoiceCallNumberRouteConfigSchema).default({}),
/** 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 before answer (for example, local mock calls that
* never receive provider webhooks). Set to 0 to disable.
*/
staleCallReaperSeconds: z.number().int().nonnegative().default(120),
/** 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,
/** @deprecated 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,
/** Session memory scope for voice conversations. */
sessionScope: VoiceCallSessionScopeSchema.default("per-phone"),
/** 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(),
/** Agent ID to use for voice response generation. Defaults to "main". */
agentId: z.string().min(1).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>;
export type VoiceCallEffectiveConfigResult = {
config: VoiceCallConfig;
numberRouteKey?: string;
};
type DeepPartial<T> = T extends SecretInput
? T
: T extends Array<infer U>
? DeepPartial<U>[]
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
export type VoiceCallConfigInput = DeepPartial<VoiceCallConfig>;
const TWILIO_AUTH_TOKEN_PATH = "plugins.entries.voice-call.config.twilio.authToken";
// -----------------------------------------------------------------------------
// Configuration Helpers
// -----------------------------------------------------------------------------
const DEFAULT_VOICE_CALL_CONFIG = VoiceCallConfigSchema.parse({});
function cloneDefaultVoiceCallConfig(): VoiceCallConfig {
return structuredClone(DEFAULT_VOICE_CALL_CONFIG);
}
function defaultRealtimeStreamPathForServePath(servePath: string): string {
const normalized = normalizeWebhookPath(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 normalizePhoneRouteKey(phone: string | undefined): string {
return phone?.replace(/\D/g, "") ?? "";
}
export function resolveVoiceCallNumberRouteKey(
config: Pick<VoiceCallConfig, "numbers">,
phone: string | undefined,
): string | undefined {
const routes = config.numbers;
if (!routes) {
return undefined;
}
if (phone && Object.hasOwn(routes, phone)) {
return phone;
}
const normalizedPhone = normalizePhoneRouteKey(phone);
if (!normalizedPhone) {
return undefined;
}
return Object.keys(routes).find(
(routeKey) => normalizePhoneRouteKey(routeKey) === normalizedPhone,
);
}
/** Resolve inbound-only number routing from a persisted call record. */
export function resolveVoiceCallNumberRouteKeyForCall(call: {
direction?: "inbound" | "outbound";
to?: string;
metadata?: { numberRouteKey?: unknown };
}): string | undefined {
if (call.direction !== "inbound") {
return undefined;
}
const storedRouteKey = call.metadata?.numberRouteKey;
if (typeof storedRouteKey === "string") {
return storedRouteKey;
}
return call.to;
}
export function resolveVoiceCallEffectiveConfig(
config: VoiceCallConfig,
phoneOrRouteKey: string | undefined,
): VoiceCallEffectiveConfigResult {
const numberRouteKey = resolveVoiceCallNumberRouteKey(config, phoneOrRouteKey);
if (!numberRouteKey) {
return { config };
}
const route = config.numbers[numberRouteKey];
if (!route) {
return { config };
}
return {
numberRouteKey,
config: {
...config,
...route,
tts: normalizeVoiceCallTtsConfig(config.tts, route.tts),
numbers: config.numbers,
},
};
}
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,
),
);
}
function sanitizeVoiceCallNumberRoutes(
value: Record<string, unknown> | undefined,
): Record<string, VoiceCallNumberRouteConfig> {
if (!value) {
return {};
}
return Object.fromEntries(
Object.entries(value)
.filter((entry): entry is [string, unknown] => entry[1] !== undefined)
.map(([key, route]) => [key, VoiceCallNumberRouteConfigSchema.parse(route)]),
);
}
export function resolveTwilioAuthToken(
config: Pick<VoiceCallConfig, "twilio">,
): string | undefined {
return normalizeResolvedSecretInputString({
value: config.twilio?.authToken,
path: TWILIO_AUTH_TOKEN_PATH,
});
}
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,
);
const realtimeFastContext = {
...defaults.realtime.fastContext,
...config.realtime?.fastContext,
sources: config.realtime?.fastContext?.sources ?? defaults.realtime.fastContext.sources,
};
const realtimeAgentContext = {
...defaults.realtime.agentContext,
...config.realtime?.agentContext,
files: config.realtime?.agentContext?.files ?? defaults.realtime.agentContext.files,
};
return {
...defaults,
...config,
allowFrom: config.allowFrom ?? defaults.allowFrom,
numbers: sanitizeVoiceCallNumberRoutes(
(config.numbers ?? defaults.numbers) as Record<string, unknown>,
),
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,
consultThinkingLevel: VoiceCallRealtimeConsultThinkingLevelSchema.optional().parse(
config.realtime?.consultThinkingLevel ?? defaults.realtime.consultThinkingLevel,
),
consultFastMode: config.realtime?.consultFastMode ?? defaults.realtime.consultFastMode,
fastContext: realtimeFastContext,
agentContext: realtimeAgentContext,
providers: realtimeProviders,
},
tts: normalizeVoiceCallTtsConfig(defaults.tts, config.tts),
};
}
export type VoiceCallCoreSessionConfig = { mainKey?: string; scope?: SessionScope };
export function resolveVoiceCallSessionKey(params: {
config: Pick<VoiceCallConfig, "agentId" | "sessionScope">;
callId: string;
phone?: string;
explicitSessionKey?: string;
coreSession?: VoiceCallCoreSessionConfig;
}): string {
const explicit = params.explicitSessionKey?.trim();
if (explicit) {
return resolveVoiceCallAgentSessionKey({
config: params.config,
sessionKey: explicit,
coreSession: params.coreSession,
});
}
// Startup migration promotes unambiguous shipped `voice:*` rows;
// generate only canonical keys here so new history never needs repair.
const prefix = `agent:${normalizeAgentId(params.config.agentId)}:voice`;
if (params.config.sessionScope === "per-call") {
return `${prefix}:call:${params.callId}`.toLowerCase();
}
const normalizedPhone = params.phone?.replace(/\D/g, "");
return (
normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`
).toLowerCase();
}
/** Resolve persisted or integration-provided keys into the configured agent namespace. */
export function resolveVoiceCallAgentSessionKey(params: {
config: Pick<VoiceCallConfig, "agentId">;
sessionKey: string;
coreSession?: VoiceCallCoreSessionConfig;
}): string {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
throw new Error("Voice Call session key cannot be empty");
}
const lower = sessionKey.toLowerCase();
const agentId = normalizeAgentId(params.config.agentId);
if (lower === "global" || lower === "unknown") {
return lower;
}
const parsedInput = parseAgentSessionKey(sessionKey);
let normalizedScopedKey: string;
if (
parsedInput &&
normalizeAgentId(parsedInput.agentId) === parsedInput.agentId &&
parsedInput.agentId === agentId
) {
normalizedScopedKey = `agent:${parsedInput.agentId}:${parsedInput.rest}`;
} else {
// Voice Call's configured agent owns both the store and runtime. Foreign or
// malformed agent-shaped input is an opaque integration key, not a route.
const wrappedInput = parseAgentSessionKey(`agent:${agentId}:${sessionKey}`);
if (!wrappedInput) {
throw new Error("Voice Call session key could not be normalized");
}
normalizedScopedKey = `agent:${agentId}:${wrappedInput.rest}`;
}
const canonicalMain = canonicalizeMainSessionAlias({
cfg: { session: params.coreSession },
agentId,
sessionKey: normalizedScopedKey,
});
return canonicalMain === normalizedScopedKey ? normalizedScopedKey : canonicalMain;
}
/**
* 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.fromNumber = resolved.fromNumber ?? process.env.TWILIO_FROM_NUMBER;
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(
config.provider === "twilio"
? "plugins.entries.voice-call.config.fromNumber is required (or set TWILIO_FROM_NUMBER env)"
: "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 (!hasConfiguredSecretInput(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" &&
config.provider !== "telnyx"
) {
errors.push(
'plugins.entries.voice-call.config.provider must be "twilio" or "telnyx" when realtime.enabled is true',
);
}
return { valid: errors.length === 0, errors };
}

View File

@@ -0,0 +1,17 @@
// Voice Call plugin module implements core bridge behavior.
import type { OpenClawPluginApi } from "../api.js";
import type { VoiceCallCoreSessionConfig, VoiceCallTtsConfig } from "./config.js";
// Narrow core runtime/config contracts consumed by the voice-call plugin.
/** Core config subset read by voice-call helpers. */
export type CoreConfig = {
session?: VoiceCallCoreSessionConfig & { store?: string };
messages?: {
tts?: VoiceCallTtsConfig;
};
[key: string]: unknown;
};
/** Agent runtime API subset exposed through the plugin SDK. */
export type CoreAgentDeps = OpenClawPluginApi["runtime"]["agent"];

View File

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

View File

@@ -0,0 +1,26 @@
// Voice Call plugin module implements deep merge behavior.
import { isRecord as isPlainObject } from "openclaw/plugin-sdk/string-coerce-runtime";
// Prototype-safe deep merge for config overrides that ignores undefined values.
const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]);
/** Deep-merge plain objects, keeping base values when overrides are undefined. */
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;
}
// Blocked keys above prevent prototype pollution while preserving normal nested overrides.
const existing = result[key];
result[key] = key in result ? deepMergeDefined(existing, value) : value;
}
return result;
}

View File

@@ -0,0 +1,29 @@
// Voice Call tests cover gateway continue operation plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it } from "vitest";
import { createVoiceCallContinueOperationStore } from "./gateway-continue-operation.js";
describe("voice-call gateway continue operation store", () => {
it("caps async continue poll timeouts from voice and tts config", () => {
const store = createVoiceCallContinueOperationStore({
config: {
transcriptTimeoutMs: Number.MAX_SAFE_INTEGER,
tts: { timeoutMs: Number.MAX_SAFE_INTEGER },
} as never,
coreConfig: { messages: {} } as never,
});
const started = store.start({
callId: "call-1",
message: "hello",
rt: {
config: {},
manager: {
continueCall: async () => new Promise(() => {}),
},
} as never,
});
expect(started.pollTimeoutMs).toBe(MAX_TIMER_TIMEOUT_MS);
});
});

View File

@@ -0,0 +1,211 @@
// Voice Call plugin module implements gateway continue operation behavior.
import { randomUUID } from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import type { VoiceCallConfig } from "./config.js";
import type { CoreConfig } from "./core-bridge.js";
import type { VoiceCallRuntime } from "./runtime.js";
import { TELEPHONY_DEFAULT_TTS_TIMEOUT_MS } from "./telephony-tts.js";
// Async operation store for gateway continue-call requests that outlive one HTTP response.
const VOICE_CALL_CONTINUE_OPERATION_BUFFER_MS = 30000;
const VOICE_CALL_CONTINUE_OPERATION_CLEANUP_MS = 5 * 60 * 1000;
/** Internal lifecycle state for one continue-call operation. */
type VoiceCallContinueOperation =
| {
operationId: string;
status: "pending";
callId: string;
startedAtMs: number;
pollTimeoutMs: number;
}
| {
operationId: string;
status: "completed";
callId: string;
startedAtMs: number;
completedAtMs: number;
pollTimeoutMs: number;
result: { success: true; transcript?: string };
}
| {
operationId: string;
status: "failed";
callId: string;
startedAtMs: number;
completedAtMs: number;
pollTimeoutMs: number;
error: string;
};
/** Payload returned immediately when a continue operation starts. */
type VoiceCallContinueOperationStartPayload = {
operationId: string;
status: "pending";
pollTimeoutMs: number;
};
/** Payload returned while polling a continue operation. */
type VoiceCallContinueOperationResultPayload =
| {
operationId: string;
status: "pending";
pollTimeoutMs: number;
}
| {
operationId: string;
status: "completed";
result: { success: true; transcript?: string };
}
| {
operationId: string;
status: "failed";
error: string;
};
/** Request needed to start a continue-call operation. */
type VoiceCallContinueOperationRequest = {
rt: VoiceCallRuntime;
callId: string;
message: string;
};
/** Create a process-local operation store for gateway continue-call polling. */
export function createVoiceCallContinueOperationStore(params: {
config: VoiceCallConfig;
coreConfig: CoreConfig;
}) {
const operations = new Map<string, VoiceCallContinueOperation>();
const resolvePollTimeoutMs = (rt: VoiceCallRuntime): number => {
const ttsTimeoutMs =
rt.config.tts?.timeoutMs ??
params.config.tts?.timeoutMs ??
params.coreConfig.messages?.tts?.timeoutMs ??
TELEPHONY_DEFAULT_TTS_TIMEOUT_MS;
return resolveTimerTimeoutMs(
(rt.config.transcriptTimeoutMs ?? params.config.transcriptTimeoutMs) +
ttsTimeoutMs +
VOICE_CALL_CONTINUE_OPERATION_BUFFER_MS,
VOICE_CALL_CONTINUE_OPERATION_BUFFER_MS,
);
};
const scheduleCleanup = (operationId: string) => {
const timer = setTimeout(() => {
operations.delete(operationId);
}, VOICE_CALL_CONTINUE_OPERATION_CLEANUP_MS);
timer.unref?.();
};
// continueCall can wait for speech/TTS/transcript work; callers poll this in the meantime.
const start = (
request: VoiceCallContinueOperationRequest,
): VoiceCallContinueOperationStartPayload => {
const operationId = randomUUID();
const startedAtMs = Date.now();
const pollTimeoutMs = resolvePollTimeoutMs(request.rt);
operations.set(operationId, {
operationId,
status: "pending",
callId: request.callId,
startedAtMs,
pollTimeoutMs,
});
void request.rt.manager
.continueCall(request.callId, request.message)
.then((result) => {
const current = operations.get(operationId);
if (!current || current.status !== "pending") {
return;
}
if (!result.success) {
operations.set(operationId, {
operationId,
status: "failed",
callId: request.callId,
startedAtMs,
completedAtMs: Date.now(),
pollTimeoutMs,
error: result.error || "continue failed",
});
return;
}
operations.set(operationId, {
operationId,
status: "completed",
callId: request.callId,
startedAtMs,
completedAtMs: Date.now(),
pollTimeoutMs,
result: { success: true, transcript: result.transcript },
});
})
.catch((err: unknown) => {
const current = operations.get(operationId);
if (!current || current.status !== "pending") {
return;
}
operations.set(operationId, {
operationId,
status: "failed",
callId: request.callId,
startedAtMs,
completedAtMs: Date.now(),
pollTimeoutMs,
error: formatErrorMessage(err),
});
})
.finally(() => {
scheduleCleanup(operationId);
});
return { operationId, status: "pending", pollTimeoutMs };
};
const read = (
operationId: string,
):
| { ok: true; payload: VoiceCallContinueOperationResultPayload }
| { ok: false; error: string } => {
const operation = operations.get(operationId);
if (!operation) {
return { ok: false, error: "operation not found" };
}
if (operation.status === "pending") {
return {
ok: true,
payload: {
operationId,
status: "pending",
pollTimeoutMs: operation.pollTimeoutMs,
},
};
}
if (operation.status === "failed") {
operations.delete(operationId);
return {
ok: true,
payload: {
operationId,
status: "failed",
error: operation.error,
},
};
}
operations.delete(operationId);
return {
ok: true,
payload: {
operationId,
status: "completed",
result: operation.result,
},
};
};
return { start, read };
}

View File

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

View File

@@ -0,0 +1,19 @@
// Voice Call plugin module implements http headers behavior.
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
// Case-insensitive HTTP header lookup for provider webhook handlers.
type HttpHeaderMap = Record<string, string | string[] | undefined>;
/** Return the first value for a header name regardless of caller casing. */
export function getHeader(headers: HttpHeaderMap, name: string): string | undefined {
const target = normalizeLowercaseStringOrEmpty(name);
const direct = headers[target];
const value =
direct ??
Object.entries(headers).find(([key]) => normalizeLowercaseStringOrEmpty(key) === target)?.[1];
if (Array.isArray(value)) {
return value[0];
}
return value;
}

View File

@@ -0,0 +1,260 @@
// Voice Call tests cover manager.closed loop plugin behavior.
import { describe, expect, it, vi } 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;
}
function expectTranscriptWaiter(
manager: Awaited<ReturnType<typeof createManagerHarness>>["manager"],
callId: string,
) {
const waiters = (
manager as unknown as {
transcriptWaiters: Map<string, unknown>;
}
).transcriptWaiters;
expect(waiters.has(callId)).toBe(true);
}
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 vi.waitFor(() => {
expect(provider.startListeningCalls).toHaveLength(1);
expectTranscriptWaiter(manager, started.callId);
});
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 vi.waitFor(() => {
expect(provider.startListeningCalls).toHaveLength(1);
expectTranscriptWaiter(manager, started.callId);
});
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",
});
expectTranscriptWaiter(manager, started.callId);
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 vi.waitFor(() => {
expect(provider.startListeningCalls).toHaveLength(1);
expectTranscriptWaiter(manager, started.callId);
});
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 vi.waitFor(() => {
expect(provider.startListeningCalls).toHaveLength(2);
expectTranscriptWaiter(manager, started.callId);
});
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 vi.waitFor(() => {
expect(provider.startListeningCalls).toHaveLength(i);
expectTranscriptWaiter(manager, started.callId);
});
manager.processEvent({
id: `evt-loop-speech-${i}`,
type: "call.speech",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
transcript: `Answer ${i}`,
isFinal: true,
});
const result = await turnPromise;
expect(result.success).toBe(true);
expect(result.transcript).toBe(`Answer ${i}`);
}
const call = requireCall(manager, started.callId);
const metadata = call.metadata ?? {};
expect(metadata.turnCount).toBe(5);
expect(provider.startListeningCalls).toHaveLength(5);
expect(provider.stopListeningCalls).toHaveLength(5);
});
});

View File

@@ -0,0 +1,184 @@
// Voice Call tests cover manager.inbound allowlist plugin behavior.
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).toHaveLength(1);
expect(provider.hangupCalls[0]?.providerCallId).toBe("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).toHaveLength(1);
expect(provider.hangupCalls[0]?.providerCallId).toBe("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).toHaveLength(1);
expect(provider.hangupCalls[0]?.providerCallId).toBe("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).toHaveLength(1);
expect(provider.hangupCalls[0]?.providerCallId).toBe("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).toHaveLength(2);
expect(provider.hangupCalls.map((call) => call.providerCallId)).toEqual([
"provider-flaky",
"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.providerCallId).toBe("provider-exact");
expect(call.direction).toBe("inbound");
expect(call.from).toBe("+15550001234");
expect(call.to).toBe("+15550000000");
expect(call.callId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
});

View File

@@ -0,0 +1,393 @@
// Voice Call tests cover manager.notify plugin behavior.
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;
}
}
class FailStartListeningProvider extends FakeProvider {
override async startListening(
input: Parameters<FakeProvider["startListening"]>[0],
): Promise<void> {
this.startListeningCalls.push(input);
throw new Error("synthetic start listening failure");
}
}
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.at(0);
if (!call) {
throw new Error("expected provider.playTts to be called once");
}
return call;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label} to be a record`);
}
return value as Record<string, unknown>;
}
function requireSingleStartListeningCall(provider: FakeProvider) {
expect(provider.startListeningCalls).toHaveLength(1);
return requireRecord(provider.startListeningCalls.at(0), "start listening call");
}
function requireFirstMockCall(calls: readonly unknown[][], label: string): unknown[] {
const call = calls.at(0);
if (!call) {
throw new Error(`expected ${label} call`);
}
return call;
}
type HarnessManager = Awaited<ReturnType<typeof createManagerHarness>>["manager"];
async function waitForPlaybackDispatch() {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
async function initiateCallWithMessage(
manager: HarnessManager,
to: string,
message: string,
mode: "notify" | "conversation",
) {
const { callId, success } = await manager.initiateCall(to, undefined, { message, mode });
expect(success).toBe(true);
return callId;
}
async function answerCall(
manager: HarnessManager,
callId: string,
eventId: string,
providerCallId = "call-uuid",
) {
manager.processEvent({
id: eventId,
type: "call.answered",
callId,
providerCallId,
timestamp: Date.now(),
});
await waitForPlaybackDispatch();
}
function expectFirstPlayTtsText(provider: FakeProvider, text: string) {
expect(provider.playTtsCalls).toHaveLength(1);
expect(requireFirstPlayTtsCall(provider).text).toBe(text);
}
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 = await initiateCallWithMessage(
manager,
"+15550000002",
"Hello there",
"notify",
);
await answerCall(manager, callId, `evt-2-${providerName}`);
expectFirstPlayTtsText(provider, "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 = await initiateCallWithMessage(
manager,
"+15550000003",
"Hello from conversation",
"conversation",
);
await answerCall(manager, callId, "evt-conversation-plivo");
expectFirstPlayTtsText(provider, "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 = await initiateCallWithMessage(
manager,
"+15550000004",
"Twilio non-stream",
"conversation",
);
await answerCall(manager, callId, "evt-conversation-twilio-no-stream");
expectFirstPlayTtsText(provider, "Twilio non-stream");
});
it("lets realtime conversations own the initial greeting instead of posting legacy TwiML", async () => {
const { manager, provider } = await createManagerHarness(
{ realtime: { enabled: true, provider: "openai" } },
new FakeProvider("twilio"),
);
const callId = await initiateCallWithMessage(
manager,
"+15550000010",
"Tell Nana dinner is at 6pm.",
"conversation",
);
await answerCall(manager, callId, "evt-conversation-twilio-realtime");
expect(provider.playTtsCalls).toHaveLength(0);
const metadata = requireRecord(requireCall(manager, callId).metadata, "call metadata");
expect(metadata.initialMessage).toBe("Tell Nana dinner is at 6pm.");
});
it("still speaks initial message in notify mode when realtime is enabled", async () => {
const { manager, provider } = await createManagerHarness(
{ realtime: { enabled: true, provider: "openai" } },
new FakeProvider("twilio"),
);
const callId = await initiateCallWithMessage(manager, "+15550000011", "Notify text", "notify");
await answerCall(manager, callId, "evt-notify-twilio-realtime");
expectFirstPlayTtsText(provider, "Notify text");
});
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 = await initiateCallWithMessage(
manager,
"+15550000005",
"Twilio stream",
"conversation",
);
await answerCall(manager, callId, "evt-conversation-twilio-stream");
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 = await initiateCallWithMessage(
manager,
"+15550000009",
"Twilio stream unavailable",
"conversation",
);
await answerCall(manager, callId, "evt-conversation-twilio-stream-unavailable");
expectFirstPlayTtsText(provider, "Twilio stream unavailable");
});
it("starts listening after the initial greeting for Telnyx conversation calls", async () => {
const { manager, provider } = await createManagerHarness({}, new FakeProvider("telnyx"));
const callId = await initiateCallWithMessage(
manager,
"+15550000012",
"Telnyx hello",
"conversation",
);
await answerCall(manager, callId, "evt-conversation-telnyx");
expectFirstPlayTtsText(provider, "Telnyx hello");
const startListeningCall = requireSingleStartListeningCall(provider);
expect(startListeningCall.callId).toBe(callId);
expect(startListeningCall.providerCallId).toBe("call-uuid");
expect(requireCall(manager, callId).state).toBe("listening");
});
it("logs fire-and-forget initial-message failures instead of leaking unhandled rejections", async () => {
const provider = new FailStartListeningProvider("twilio");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const { manager } = await createManagerHarness({ streaming: { enabled: false } }, provider);
const callId = await initiateCallWithMessage(
manager,
"+15550000013",
"Twilio hello",
"conversation",
);
await answerCall(manager, callId, "evt-initial-message-start-listening-fails");
expectFirstPlayTtsText(provider, "Twilio hello");
const startListeningCall = requireSingleStartListeningCall(provider);
expect(startListeningCall.callId).toBe(callId);
expect(startListeningCall.providerCallId).toBe("call-uuid");
expect(warn).toHaveBeenCalledOnce();
expect(String(requireFirstMockCall(warn.mock.calls, "console warn")[0])).toContain(
`[voice-call] Failed to speak initial message for call ${callId}: synthetic start listening failure`,
);
} finally {
warn.mockRestore();
}
});
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 = await initiateCallWithMessage(manager, "+15550000006", "Retry me", "notify");
await answerCall(manager, callId, "evt-retry-1");
const afterFailure = requireCall(manager, callId);
expect(provider.playTtsCalls).toHaveLength(1);
const metadata = requireRecord(afterFailure.metadata, "call metadata after failed playback");
expect(metadata.initialMessage).toBe("Retry me");
expect(afterFailure.state).toBe("listening");
await answerCall(manager, callId, "evt-retry-2");
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 = await initiateCallWithMessage(
manager,
"+15550000007",
"Stream hello",
"conversation",
);
await answerCall(manager, callId, "evt-stream-answered");
expect(provider.playTtsCalls).toHaveLength(0);
await manager.speakInitialMessage("call-uuid");
await manager.speakInitialMessage("call-uuid");
expectFirstPlayTtsText(provider, "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 = await initiateCallWithMessage(
manager,
"+15550000008",
"In-flight hello",
"conversation",
);
await answerCall(manager, callId, "evt-stream-answered-concurrent");
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 waitForPlaybackDispatch();
expect(provider.playTtsCalls).toHaveLength(1);
provider.releaseCurrentPlayback();
await Promise.all([first, second]);
const call = requireCall(manager, callId);
expect(call.metadata).not.toHaveProperty("initialMessage");
expectFirstPlayTtsText(provider, "In-flight hello");
});
});

View File

@@ -0,0 +1,376 @@
// Voice Call tests cover manager.restore plugin behavior.
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateSyncKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { VoiceCallConfigSchema } from "./config.js";
import { CallManager } from "./manager.js";
import {
createTestStorePath,
FakeProvider,
makePersistedCall,
writeCallsToStore,
} from "./manager.test-harness.js";
import { flushPendingCallRecordWritesForTest, loadActiveCallsFromStore } from "./manager/store.js";
import { clearVoiceCallStateRuntime, setVoiceCallStateRuntime } from "./runtime-state.js";
function installStateRuntime(): void {
setVoiceCallStateRuntime({
state: {
resolveStateDir: () => "",
openKeyedStore: (() => {
throw new Error("openKeyedStore is not used by voice-call restore tests");
}) as never,
openSyncKeyedStore: (options: OpenKeyedStoreOptions) =>
createPluginStateSyncKeyedStoreForTests("voice-call", options),
openChannelIngressQueue: (() => {
throw new Error("openChannelIngressQueue is not used by voice-call restore tests");
}) as never,
},
});
}
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;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label} to be a record`);
}
return value as Record<string, unknown>;
}
function requireSingleHangupCall(provider: FakeProvider) {
expect(provider.hangupCalls).toHaveLength(1);
return requireRecord(provider.hangupCalls[0], "hangup call");
}
describe("CallManager verification on restore", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
installStateRuntime();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
clearVoiceCallStateRuntime();
resetPluginStateStoreForTests();
});
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, provider, storePath };
}
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, provider, storePath } = await initializeManager({
callOverrides: {
startedAt: Date.now() - 600_000,
answeredAt: Date.now() - 590_000,
},
configOverrides: { maxDurationSeconds: 300 },
});
expect(manager.getActiveCalls()).toHaveLength(0);
const hangupCall = requireSingleHangupCall(provider);
expect(hangupCall.reason).toBe("timeout");
await flushPendingCallRecordWritesForTest();
expect(loadActiveCallsFromStore(storePath).activeCalls.size).toBe(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("summarizes repeated restored-call verification outcomes", async () => {
const now = Date.now();
const storePath = createTestStorePath();
const calls = [
makePersistedCall({
callId: "missing-provider-a",
providerCallId: undefined,
state: "initiated",
startedAt: now - 10_000,
answeredAt: undefined,
}),
makePersistedCall({
callId: "missing-provider-b",
providerCallId: undefined,
state: "initiated",
startedAt: now - 10_000,
answeredAt: undefined,
}),
makePersistedCall({
callId: "expired-a",
providerCallId: "expired-provider-a",
state: "initiated",
startedAt: now - 600_000,
answeredAt: undefined,
}),
makePersistedCall({
callId: "terminal-a",
providerCallId: "terminal-provider-a",
state: "initiated",
startedAt: now - 20_000,
answeredAt: undefined,
}),
makePersistedCall({
callId: "terminal-b",
providerCallId: "terminal-provider-b",
state: "initiated",
startedAt: now - 20_000,
answeredAt: undefined,
}),
makePersistedCall({
callId: "unknown-a",
providerCallId: "unknown-provider-a",
state: "initiated",
startedAt: now - 20_000,
answeredAt: undefined,
}),
makePersistedCall({
callId: "active-a",
providerCallId: "active-provider-a",
state: "initiated",
startedAt: now - 20_000,
answeredAt: undefined,
}),
makePersistedCall({
callId: "failure-a",
providerCallId: "failure-provider-a",
state: "initiated",
startedAt: now - 20_000,
answeredAt: undefined,
}),
];
writeCallsToStore(storePath, calls);
const provider = new FakeProvider();
provider.getCallStatus = async ({ providerCallId }) => {
if (providerCallId.startsWith("terminal-provider")) {
return { status: "completed", isTerminal: true };
}
if (providerCallId.startsWith("unknown-provider")) {
return { status: "unknown", isTerminal: false, isUnknown: true };
}
if (providerCallId.startsWith("active-provider")) {
return { status: "in-progress", isTerminal: false };
}
throw new Error("network failure");
};
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
maxDurationSeconds: 300,
});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
expect(
manager
.getActiveCalls()
.map((call) => call.callId)
.toSorted(),
).toEqual(["active-a", "failure-a", "unknown-a"]);
const hangupCall = requireSingleHangupCall(provider);
expect(hangupCall.callId).toBe("expired-a");
expect(hangupCall.providerCallId).toBe("expired-provider-a");
expect(hangupCall.reason).toBe("timeout");
expect(logSpy).toHaveBeenCalledWith(
"[voice-call] Skipped 2 restored call(s) with no providerCallId",
);
expect(logSpy).toHaveBeenCalledWith(
"[voice-call] Skipped 1 restored call(s) older than maxDurationSeconds",
);
expect(logSpy).toHaveBeenCalledWith(
"[voice-call] Skipped 2 restored call(s) with provider status: completed",
);
expect(logSpy).toHaveBeenCalledWith(
"[voice-call] Kept 1 restored call(s) confirmed active by provider",
);
expect(logSpy).toHaveBeenCalledWith(
"[voice-call] Kept 1 restored call(s) with unknown provider status (relying on timer)",
);
expect(logSpy).toHaveBeenCalledWith(
"[voice-call] Kept 1 restored call(s) after verification failure (relying on timer)",
);
expect(logSpy.mock.calls.map((call) => String(call[0])).join("\n")).not.toContain("terminal-a");
logSpy.mockRestore();
});
it("uses only remaining max duration for restored answered calls", async () => {
vi.useFakeTimers();
const now = new Date("2026-03-17T03:07:00Z");
vi.setSystemTime(now);
const { manager, provider } = await initializeManager({
callOverrides: {
startedAt: now.getTime() - 290_000,
answeredAt: now.getTime() - 290_000,
state: "answered",
},
configOverrides: { maxDurationSeconds: 300 },
});
expect(manager.getActiveCalls()).toHaveLength(1);
await vi.advanceTimersByTimeAsync(9_000);
expect(manager.getActiveCalls()).toHaveLength(1);
expect(provider.hangupCalls).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_100);
expect(manager.getActiveCalls()).toHaveLength(0);
const hangupCall = requireSingleHangupCall(provider);
expect(hangupCall.reason).toBe("timeout");
});
it.each(["speaking", "listening"] as const)(
"uses call start as max-duration anchor for restored live %s calls without answeredAt",
async (state) => {
vi.useFakeTimers();
const now = new Date("2026-03-17T03:07:00Z").getTime();
vi.setSystemTime(now);
const startedAt = now - 290_000;
const { manager, provider, storePath } = await initializeManager({
callOverrides: {
callId: `call-${state}`,
providerCallId: `provider-${state}`,
state,
startedAt,
answeredAt: undefined,
},
configOverrides: { maxDurationSeconds: 300 },
});
const activeCall = requireSingleActiveCall(manager);
expect(activeCall.state).toBe(state);
expect(activeCall.answeredAt).toBe(startedAt);
expect(
loadActiveCallsFromStore(storePath).activeCalls.get(activeCall.callId)?.answeredAt,
).toBe(startedAt);
await vi.advanceTimersByTimeAsync(9_000);
expect(manager.getActiveCalls()).toHaveLength(1);
expect(provider.hangupCalls).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_100);
expect(manager.getActiveCalls()).toHaveLength(0);
const hangupCall = requireSingleHangupCall(provider);
expect(hangupCall.reason).toBe("timeout");
},
);
it("restores dedupe keys from terminal persisted calls so replayed webhooks stay ignored", async () => {
const storePath = createTestStorePath();
const persisted = makePersistedCall({
state: "completed",
endedAt: Date.now() - 5_000,
endReason: "completed",
processedEventIds: ["evt-terminal-init"],
});
writeCallsToStore(storePath, [persisted]);
const provider = new FakeProvider();
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
manager.processEvent({
id: "evt-terminal-init",
type: "call.initiated",
callId: String(persisted.providerCallId),
providerCallId: String(persisted.providerCallId),
timestamp: Date.now(),
direction: "outbound",
from: "+15550000000",
to: "+15550000001",
});
expect(manager.getActiveCalls()).toHaveLength(0);
});
});

View File

@@ -0,0 +1,165 @@
// Voice Call plugin module implements manager harness behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import { createPluginStateSyncKeyedStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { VoiceCallConfigSchema } from "./config.js";
import { CallManager } from "./manager.js";
import { persistCallRecord } from "./manager/store.js";
import type { VoiceCallProvider } from "./providers/base.js";
import {
getOptionalVoiceCallStateRuntime,
setVoiceCallStateRuntime,
type VoiceCallStateRuntime,
} from "./runtime-state.js";
import { CallRecordSchema } from "./types.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" | "telnyx";
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" | "telnyx" = "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 function createVoiceCallStateRuntimeForTests(): VoiceCallStateRuntime["state"] {
return {
resolveStateDir: () => "",
openKeyedStore: (() => {
throw new Error("openKeyedStore is not used by voice-call manager tests");
}) as VoiceCallStateRuntime["state"]["openKeyedStore"],
openSyncKeyedStore: <T>(options: OpenKeyedStoreOptions) =>
createPluginStateSyncKeyedStoreForTests<T>("voice-call", options),
openChannelIngressQueue: (() => {
throw new Error("openChannelIngressQueue is not used by voice-call manager tests");
}) as VoiceCallStateRuntime["state"]["openChannelIngressQueue"],
};
}
export function installVoiceCallStateRuntimeForTests(): void {
if (!getOptionalVoiceCallStateRuntime()) {
setVoiceCallStateRuntime({ state: createVoiceCallStateRuntimeForTests() });
}
}
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,
});
installVoiceCallStateRuntimeForTests();
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 });
for (const call of calls) {
persistCallRecord(storePath, CallRecordSchema.parse(call));
}
}
export function writeLegacyCallsJsonl(storePath: string, calls: Record<string, unknown>[]): void {
fs.mkdirSync(storePath, { recursive: true });
const logPath = path.join(storePath, "calls.jsonl");
const lines = calls.map((c) => JSON.stringify(c)).join("\n") + "\n";
fs.writeFileSync(logPath, lines);
}
export function makePersistedCall(
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
callId: `call-${Date.now()}-${Math.random().toString(36).slice(2)}`,
providerCallId: `prov-${Date.now()}-${Math.random().toString(36).slice(2)}`,
provider: "plivo",
direction: "outbound",
state: "answered",
from: "+15550000000",
to: "+15550000001",
startedAt: Date.now() - 30_000,
answeredAt: Date.now() - 25_000,
transcript: [],
processedEventIds: [],
...overrides,
};
}

View File

@@ -0,0 +1,464 @@
// Voice Call plugin module implements manager behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { VoiceCallConfig, VoiceCallCoreSessionConfig } from "./config.js";
import type { CallManagerContext, StreamSessionIssuer } 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,
sendDtmf as sendDtmfWithContext,
speak as speakWithContext,
speakInitialMessage as speakInitialMessageWithContext,
} from "./manager/outbound.js";
import {
getCallHistoryFromStore,
loadActiveCallsFromStore,
persistCallRecord,
} from "./manager/store.js";
import { resolveVoiceCallSecondsTimerDelayMs } from "./manager/timer-delays.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 markRestoredCallSkipped(call: CallRecord, endReason: "completed" | "timeout"): void {
call.endedAt = Date.now();
call.endReason = endReason;
call.state = endReason;
}
function incrementRestoreStatusCount(
counts: Map<string, number>,
status: string | undefined,
): void {
const key = normalizeOptionalString(status) ?? "terminal";
counts.set(key, (counts.get(key) ?? 0) + 1);
}
function resolveRestoredMaxDurationAnchor(call: CallRecord): number | undefined {
return (
call.answeredAt ??
(call.state === "speaking" || call.state === "listening" ? call.startedAt : undefined)
);
}
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 coreSession: VoiceCallCoreSessionConfig | undefined;
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>();
/**
* Carrier-side stream session issuer. Wired by the runtime when realtime is
* enabled so the manager can pre-issue stream URLs for providers (e.g.
* Telnyx) that attach Media Streaming at dial or answer time.
*/
streamSessionIssuer: StreamSessionIssuer | undefined;
constructor(
config: VoiceCallConfig,
storePath?: string,
coreSession?: VoiceCallCoreSessionConfig,
) {
this.config = config;
this.coreSession = coreSession;
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/live state.
let skippedAlreadyElapsedTimers = 0;
for (const [callId, call] of verified) {
const maxDurationAnchor = resolveRestoredMaxDurationAnchor(call);
if (maxDurationAnchor !== undefined && !TerminalStates.has(call.state)) {
const elapsed = Date.now() - maxDurationAnchor;
const maxDurationMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds);
if (elapsed >= maxDurationMs) {
// Already expired — remove instead of keeping
verified.delete(callId);
if (call.providerCallId) {
this.providerCallIdMap.delete(call.providerCallId);
}
skippedAlreadyElapsedTimers += 1;
continue;
}
if (call.answeredAt === undefined) {
// Twilio streams can restore directly in speaking/listening without an
// answered webhook; anchoring at startedAt preserves bounded duration.
call.answeredAt = maxDurationAnchor;
persistCallRecord(this.storePath, call);
}
startMaxDurationTimer({
ctx: this.getContext(),
callId,
timeoutMs: maxDurationMs - elapsed,
onTimeout: async (id) => {
await endCallWithContext(this.getContext(), id, { reason: "timeout" });
},
});
console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`);
}
}
if (skippedAlreadyElapsedTimers > 0) {
console.log(
`[voice-call] Skipped ${skippedAlreadyElapsedTimers} restored call(s) whose max-duration timer already elapsed`,
);
}
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 = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds);
const now = Date.now();
const verified = new Map<CallId, CallRecord>();
const verifyTasks: Array<{ callId: CallId; call: CallRecord; promise: Promise<void> }> = [];
let skippedNoProviderCallId = 0;
let skippedOlderThanMaxDuration = 0;
const skippedTerminalStatuses = new Map<string, number>();
let keptVerifiedActive = 0;
let keptUnknownProviderStatus = 0;
let keptVerificationFailures = 0;
for (const [callId, call] of candidates) {
// Skip calls without a provider ID — can't verify
if (!call.providerCallId) {
skippedNoProviderCallId += 1;
continue;
}
// Skip calls older than maxDurationSeconds (time-based fallback)
if (now - call.startedAt > maxAgeMs) {
skippedOlderThanMaxDuration += 1;
markRestoredCallSkipped(call, "timeout");
persistCallRecord(this.storePath, call);
await provider
.hangupCall({
callId,
providerCallId: call.providerCallId,
reason: "timeout",
})
.catch((err: unknown) => {
console.warn(
`[voice-call] Failed to hang up expired restored call ${callId}:`,
err instanceof Error ? err.message : String(err),
);
});
continue;
}
const task = {
callId,
call,
promise: provider
.getCallStatus({ providerCallId: call.providerCallId })
.then((result) => {
if (result.isTerminal) {
incrementRestoreStatusCount(skippedTerminalStatuses, result.status);
markRestoredCallSkipped(call, "completed");
persistCallRecord(this.storePath, call);
} else if (result.isUnknown) {
keptUnknownProviderStatus += 1;
verified.set(callId, call);
} else {
keptVerifiedActive += 1;
verified.set(callId, call);
}
})
.catch(() => {
// Verification failed entirely — keep the call, rely on timer
keptVerificationFailures += 1;
verified.set(callId, call);
}),
};
verifyTasks.push(task);
}
await Promise.allSettled(verifyTasks.map((t) => t.promise));
if (skippedNoProviderCallId > 0) {
console.log(
`[voice-call] Skipped ${skippedNoProviderCallId} restored call(s) with no providerCallId`,
);
}
if (skippedOlderThanMaxDuration > 0) {
console.log(
`[voice-call] Skipped ${skippedOlderThanMaxDuration} restored call(s) older than maxDurationSeconds`,
);
}
for (const [status, count] of [...skippedTerminalStatuses].toSorted(([a], [b]) =>
a.localeCompare(b),
)) {
console.log(`[voice-call] Skipped ${count} restored call(s) with provider status: ${status}`);
}
if (keptVerifiedActive > 0) {
console.log(
`[voice-call] Kept ${keptVerifiedActive} restored call(s) confirmed active by provider`,
);
}
if (keptUnknownProviderStatus > 0) {
console.log(
`[voice-call] Kept ${keptUnknownProviderStatus} restored call(s) with unknown provider status (relying on timer)`,
);
}
if (keptVerificationFailures > 0) {
console.log(
`[voice-call] Kept ${keptVerificationFailures} restored call(s) after verification failure (relying on timer)`,
);
}
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);
}
/**
* Send DTMF digits to an active call.
*/
async sendDtmf(callId: CallId, digits: string): Promise<{ success: boolean; error?: string }> {
return sendDtmfWithContext(this.getContext(), callId, digits);
}
/**
* 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,
coreSession: this.coreSession,
storePath: this.storePath,
webhookUrl: this.webhookUrl,
activeTurnCalls: this.activeTurnCalls,
transcriptWaiters: this.transcriptWaiters,
maxDurationTimers: this.maxDurationTimers,
initialMessageInFlight: this.initialMessageInFlight,
onCallAnswered: (call) => {
this.maybeSpeakInitialMessageOnAnswered(call);
},
streamSessionIssuer: this.streamSessionIssuer,
};
}
/**
* 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") {
if (this.config.realtime.enabled) {
return;
}
const shouldWaitForStreamConnect =
this.shouldDeferConversationInitialMessageUntilStreamConnect();
if (shouldWaitForStreamConnect) {
return;
}
} else if (mode !== "notify") {
return;
}
if (!this.provider || !call.providerCallId) {
return;
}
void this.speakInitialMessage(call.providerCallId).catch((err: unknown) => {
console.warn(
`[voice-call] Failed to speak initial message for call ${call.callId}: ${formatErrorMessage(err)}`,
);
});
}
/**
* Get an active call by ID.
*/
getCall(callId: CallId): CallRecord | undefined {
return this.activeCalls.get(callId);
}
/**
* Get an active call by provider call ID (e.g., Twilio CallSid).
*/
getCallByProviderCallId(providerCallId: string): CallRecord | undefined {
return getCallByProviderCallIdFromMaps({
activeCalls: this.activeCalls,
providerCallIdMap: this.providerCallIdMap,
providerCallId,
});
}
/**
* Get all active calls.
*/
getActiveCalls(): CallRecord[] {
return Array.from(this.activeCalls.values());
}
/**
* Get call history (from persisted logs).
*/
async getCallHistory(limit = 50): Promise<CallRecord[]> {
return getCallHistoryFromStore(this.storePath, limit);
}
}

View File

@@ -0,0 +1,52 @@
// Voice Call plugin module implements context behavior.
import type { VoiceCallConfig, VoiceCallCoreSessionConfig } from "../config.js";
import type { VoiceCallProvider } from "../providers/base.js";
import type { CallId, CallRecord } from "../types.js";
type TranscriptWaiter = {
resolve: (text: string) => void;
reject: (err: Error) => void;
timeout: NodeJS.Timeout;
turnToken?: string;
};
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>;
};
type CallManagerRuntimeDeps = {
provider: VoiceCallProvider | null;
config: VoiceCallConfig;
coreSession?: VoiceCallCoreSessionConfig;
storePath: string;
webhookUrl: string | null;
};
type CallManagerTransientState = {
activeTurnCalls: Set<CallId>;
transcriptWaiters: Map<CallId, TranscriptWaiter>;
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
initialMessageInFlight: Set<CallId>;
};
export type StreamSessionIssuer = (request: {
providerName: "twilio" | "telnyx";
callId: CallId;
from?: string;
to?: string;
direction: "inbound" | "outbound";
}) => { token: string; streamUrl: string } | undefined;
type CallManagerHooks = {
onCallAnswered?: (call: CallRecord) => void;
streamSessionIssuer?: StreamSessionIssuer;
};
export type CallManagerContext = CallManagerRuntimeState &
CallManagerRuntimeDeps &
CallManagerTransientState &
CallManagerHooks;

View File

@@ -0,0 +1,760 @@
// Voice Call tests cover events plugin behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateSyncKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { VoiceCallConfigSchema } from "../config.js";
import type { VoiceCallProvider } from "../providers/base.js";
import { clearVoiceCallStateRuntime, setVoiceCallStateRuntime } from "../runtime-state.js";
import type { AnswerCallInput, HangupCallInput, NormalizedEvent } from "../types.js";
import type { CallManagerContext } from "./context.js";
import { processEvent } from "./events.js";
import { speakInitialMessage } from "./outbound.js";
import { flushPendingCallRecordWritesForTest } from "./store.js";
const contexts: CallManagerContext[] = [];
function installStateRuntime(): void {
setVoiceCallStateRuntime({
state: {
resolveStateDir: () => "",
openKeyedStore: (() => {
throw new Error("openKeyedStore is not used by voice-call event tests");
}) as never,
openSyncKeyedStore: (options: OpenKeyedStoreOptions) =>
createPluginStateSyncKeyedStoreForTests("voice-call", options),
openChannelIngressQueue: (() => {
throw new Error("openChannelIngressQueue is not used by voice-call event tests");
}) as never,
},
});
}
beforeEach(() => {
resetPluginStateStoreForTests();
installStateRuntime();
});
afterEach(async () => {
for (const ctx of contexts.splice(0)) {
for (const timer of ctx.maxDurationTimers.values()) {
clearTimeout(timer);
}
ctx.maxDurationTimers.clear();
for (const waiter of ctx.transcriptWaiters.values()) {
clearTimeout(waiter.timeout);
}
ctx.transcriptWaiters.clear();
await flushPendingCallRecordWritesForTest();
fs.rmSync(ctx.storePath, { recursive: true, force: true });
}
clearVoiceCallStateRuntime();
resetPluginStateStoreForTests();
vi.useRealTimers();
vi.restoreAllMocks();
});
function createContext(overrides: Partial<CallManagerContext> = {}): CallManagerContext {
const storePath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-voice-call-events-test-"));
const ctx: CallManagerContext = {
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,
};
contexts.push(ctx);
return ctx;
}
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([
{
callId: "prov-dup",
providerCallId: "prov-dup",
reason: "hangup-bot",
},
]);
});
it("answers accepted inbound calls when the provider requires an answer command", () => {
const answerCalls: AnswerCallInput[] = [];
const provider = createProvider({
answerCall: async (input: AnswerCallInput): Promise<void> => {
answerCalls.push(input);
},
});
const ctx = createContext({
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "telnyx",
fromNumber: "+15550000000",
inboundPolicy: "open",
telnyx: {
apiKey: "KEY123",
connectionId: "CONN456",
},
skipSignatureVerification: true,
}),
provider,
});
const event = createInboundInitiatedEvent({
id: "evt-answer",
providerCallId: "call-control-1",
from: "+15552222222",
});
processEvent(ctx, event);
const call = requireFirstActiveCall(ctx);
expect(answerCalls).toEqual([
{
callId: call.callId,
providerCallId: "call-control-1",
},
]);
});
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.each([
{
name: "speaking",
expectedState: "speaking",
createEvent: (timestamp: number): NormalizedEvent => ({
id: "evt-live-speaking",
type: "call.speaking",
callId: "call-live",
providerCallId: "provider-live",
timestamp,
text: "hello",
}),
},
{
name: "listening",
expectedState: "listening",
createEvent: (timestamp: number): NormalizedEvent => ({
id: "evt-live-listening",
type: "call.speech",
callId: "call-live",
providerCallId: "provider-live",
timestamp,
transcript: "hello",
isFinal: true,
}),
},
])(
"starts max-duration enforcement when $name arrives before answered",
async ({ expectedState, createEvent }) => {
const now = new Date("2026-03-22T12:00:00.000Z").getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
const hangupCalls: HangupCallInput[] = [];
const ctx = createContext({
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
maxDurationSeconds: 1,
}),
provider: createProvider({
hangupCall: async (input: HangupCallInput): Promise<void> => {
hangupCalls.push(input);
},
}),
});
ctx.activeCalls.set("call-live", {
callId: "call-live",
providerCallId: "provider-live",
provider: "plivo",
direction: "inbound",
state: "ringing",
from: "+15550000002",
to: "+15550000000",
startedAt: now - 120_000,
transcript: [],
processedEventIds: [],
metadata: {},
});
ctx.providerCallIdMap.set("provider-live", "call-live");
const liveTimestamp = now + 250;
processEvent(ctx, createEvent(liveTimestamp));
const call = ctx.activeCalls.get("call-live");
if (!call) {
throw new Error("expected live call to remain active");
}
expect(call.state).toBe(expectedState);
expect(call.answeredAt).toBe(liveTimestamp);
expect(ctx.maxDurationTimers.has("call-live")).toBe(true);
await vi.advanceTimersByTimeAsync(1_000);
expect(hangupCalls).toEqual([
{
callId: "call-live",
providerCallId: "provider-live",
reason: "timeout",
},
]);
expect(ctx.activeCalls.has("call-live")).toBe(false);
vi.useRealTimers();
},
);
it("enforces max duration for Twilio initial-message streams without answeredAt", async () => {
const now = new Date("2026-03-22T12:00:00.000Z").getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
const hangupCalls: HangupCallInput[] = [];
const provider = createProvider({
name: "twilio",
hangupCall: async (input: HangupCallInput): Promise<void> => {
hangupCalls.push(input);
},
}) as VoiceCallProvider & { isConversationStreamConnectEnabled?: () => boolean };
provider.isConversationStreamConnectEnabled = () => true;
const ctx = createContext({
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "twilio",
fromNumber: "+15550000000",
maxDurationSeconds: 1,
streaming: { enabled: true },
}),
provider,
});
ctx.activeCalls.set("call-stream", {
callId: "call-stream",
providerCallId: "provider-stream",
provider: "twilio",
direction: "inbound",
state: "active",
from: "+15550000002",
to: "+15550000000",
startedAt: now - 120_000,
transcript: [],
processedEventIds: [],
metadata: {
initialMessage: "Hello from the bot.",
mode: "conversation",
},
});
ctx.providerCallIdMap.set("provider-stream", "call-stream");
await speakInitialMessage(ctx, "provider-stream");
const call = ctx.activeCalls.get("call-stream");
if (!call) {
throw new Error("expected initial-message call to remain active");
}
expect(call.state).toBe("speaking");
expect(call.answeredAt).toBe(now);
expect(ctx.maxDurationTimers.has("call-stream")).toBe(true);
await vi.advanceTimersByTimeAsync(1_000);
expect(hangupCalls).toEqual([
{
callId: "call-stream",
providerCallId: "provider-stream",
reason: "timeout",
},
]);
expect(ctx.activeCalls.has("call-stream")).toBe(false);
vi.useRealTimers();
});
it("removes active call even when hangup rejects", () => {
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",
});
processEvent(ctx, event);
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("assigns per-call session keys to inbound calls when configured", () => {
const ctx = createContext({
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "open",
sessionScope: "per-call",
}),
});
const event: NormalizedEvent = {
id: "evt-inbound-session-scope",
type: "call.initiated",
callId: "CA-inbound-session-scope",
providerCallId: "CA-inbound-session-scope",
timestamp: Date.now(),
direction: "inbound",
from: "+15554444444",
to: "+15550000000",
};
processEvent(ctx, event);
const call = requireFirstActiveCall(ctx);
expect(call.sessionKey).toBe(`agent:main:voice:call:${call.callId}`);
});
it("applies per-number inbound greeting and stores the matched route key", () => {
const ctx = createContext({
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "open",
inboundGreeting: "Hello from global.",
numbers: {
"+15550002222": {
inboundGreeting: "Silver Fox Cards, how can I help?",
},
},
}),
});
const event: NormalizedEvent = {
id: "evt-inbound-number-route",
type: "call.initiated",
callId: "CA-inbound-number-route",
providerCallId: "CA-inbound-number-route",
timestamp: Date.now(),
direction: "inbound",
from: "+15554444444",
to: "+1 (555) 000-2222",
};
processEvent(ctx, event);
const call = requireFirstActiveCall(ctx);
expect(call.metadata?.initialMessage).toBe("Silver Fox Cards, how can I help?");
expect(call.metadata?.numberRouteKey).toBe("+15550002222");
});
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)).toStrictEqual([]);
expect(call.processedEventIds).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,353 @@
// Voice Call plugin module implements events behavior.
import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
import { resolveVoiceCallEffectiveConfig, resolveVoiceCallSessionKey } from "../config.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 {
ensureMaxDurationTimerForLiveCall,
resolveTranscriptWaiter,
startMaxDurationTimer,
} from "./timers.js";
type EventContext = Pick<
CallManagerContext,
| "activeCalls"
| "providerCallIdMap"
| "processedEventIds"
| "rejectedProviderCallIds"
| "provider"
| "config"
| "storePath"
| "transcriptWaiters"
| "maxDurationTimers"
| "onCallAnswered"
| "streamSessionIssuer"
>;
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 effective = resolveVoiceCallEffectiveConfig(
params.ctx.config,
params.direction === "inbound" ? params.to : undefined,
);
const effectiveConfig = effective.config;
const callRecord: CallRecord = {
callId,
providerCallId: params.providerCallId,
provider: params.ctx.provider?.name || "twilio",
direction: params.direction,
state: "ringing",
from: params.from,
to: params.to,
sessionKey: resolveVoiceCallSessionKey({
config: effectiveConfig,
callId,
phone: params.direction === "outbound" ? params.to : params.from,
}),
startedAt: Date.now(),
transcript: [],
processedEventIds: [],
metadata: {
initialMessage:
params.direction === "inbound"
? effectiveConfig.inboundGreeting || "Hello! How can I help you today?"
: undefined,
...(effective.numberRouteKey ? { numberRouteKey: effective.numberRouteKey } : {}),
},
};
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;
}
function persistRejectedInboundCall(params: {
ctx: EventContext;
event: NormalizedEvent;
dedupeKey: string;
providerCallId: string;
}): void {
const callId = params.event.callId || params.providerCallId;
const now = Date.now();
const rejectedCall: CallRecord = {
callId,
providerCallId: params.providerCallId,
provider: params.ctx.provider?.name || "twilio",
direction: "inbound",
state: "hangup-bot",
from: params.event.from || "unknown",
to: params.event.to || params.ctx.config.fromNumber || "unknown",
startedAt: params.event.timestamp || now,
endedAt: now,
endReason: "hangup-bot",
transcript: [],
processedEventIds: [params.dedupeKey],
metadata: { rejectionReason: "inbound-policy" },
};
persistCallRecord(params.ctx.storePath, rejectedCall);
}
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;
persistRejectedInboundCall({ ctx, event, dedupeKey, providerCallId: pid });
console.log(`[voice-call] Rejecting inbound call by policy: ${pid}`);
void ctx.provider
.hangupCall({
callId,
providerCallId: pid,
reason: "hangup-bot",
})
.catch((err: unknown) => {
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");
if (call.direction === "inbound" && call.providerCallId && ctx.provider?.answerCall) {
const inboundStreamSession =
ctx.config.realtime?.enabled && ctx.provider.name === "telnyx" && ctx.streamSessionIssuer
? ctx.streamSessionIssuer({
providerName: "telnyx",
callId: call.callId,
from: call.from,
to: call.to,
direction: "inbound",
})
: undefined;
void ctx.provider
.answerCall({
callId: call.callId,
providerCallId: call.providerCallId,
...(inboundStreamSession
? {
streamUrl: inboundStreamSession.streamUrl,
streamAuthToken: inboundStreamSession.token,
}
: {}),
})
.catch((err: unknown) => {
const message = formatErrorMessage(err);
console.warn(
`[voice-call] Failed to answer inbound call ${call.providerCallId}:`,
message,
);
});
}
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":
ensureMaxDurationTimerForLiveCall({
ctx,
call,
liveAt: event.timestamp,
onTimeout: async (callId) => {
await endCall(ctx, callId, { reason: "timeout" });
},
});
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);
}
ensureMaxDurationTimerForLiveCall({
ctx,
call,
liveAt: event.timestamp,
onTimeout: async (callId) => {
await endCall(ctx, callId, { reason: "timeout" });
},
});
transitionState(call, "listening");
break;
case "call.silence":
case "call.dtmf":
break;
case "call.ended":
finalizeCall({
ctx,
call,
endReason: event.reason,
endedAt: event.timestamp,
});
return;
case "call.error":
if (!event.retryable) {
finalizeCall({
ctx,
call,
endReason: "error",
endedAt: event.timestamp,
transcriptRejectReason: `Call error: ${event.error}`,
});
return;
}
// Keep retryable provider errors replayable so a redelivery can still
// drive later recovery or terminal handling for the same event key.
break;
}
persistCallRecord(ctx.storePath, call);
}

View File

@@ -0,0 +1,58 @@
// Voice Call plugin module implements lifecycle behavior.
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";
// Shared call finalization path for manager and webhook lifecycle exits.
type CallLifecycleContext = Pick<
CallManagerContext,
"activeCalls" | "providerCallIdMap" | "storePath"
> &
Partial<Pick<CallManagerContext, "transcriptWaiters" | "maxDurationTimers">>;
/** Remove a provider-call mapping only when it still points at this call. */
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);
}
}
/** Persist terminal state, clean timers/waiters, and remove active call indexes. */
export function finalizeCall(params: {
ctx: CallLifecycleContext;
call: CallRecord;
endReason: EndReason;
endedAt?: number;
transcriptRejectReason?: string;
}): void {
const { ctx, call, endReason } = params;
call.endedAt = params.endedAt ?? Date.now();
call.endReason = endReason;
transitionState(call, endReason);
persistCallRecord(ctx.storePath, call);
if (ctx.maxDurationTimers) {
clearMaxDurationTimer({ maxDurationTimers: ctx.maxDurationTimers }, call.callId);
}
if (ctx.transcriptWaiters) {
rejectTranscriptWaiter(
{ transcriptWaiters: ctx.transcriptWaiters },
call.callId,
params.transcriptRejectReason ?? `Call ended: ${endReason}`,
);
}
ctx.activeCalls.delete(call.callId);
removeProviderCallMapping(ctx.providerCallIdMap, call);
}

View File

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

View File

@@ -0,0 +1,40 @@
// Voice Call plugin module implements lookup behavior.
import type { CallId, CallRecord } from "../types.js";
// Lookup helpers for active calls by internal or provider call ids.
/** Resolve an active call from provider call id with map lookup plus stale-map fallback scan. */
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;
}
/** Resolve an active call by internal call id or provider call id. */
export function findCall(params: {
activeCalls: Map<CallId, CallRecord>;
providerCallIdMap: Map<string, CallId>;
callIdOrProviderCallId: string;
}): CallRecord | undefined {
const directCall = params.activeCalls.get(params.callIdOrProviderCallId);
if (directCall) {
return directCall;
}
return getCallByProviderCallId({
activeCalls: params.activeCalls,
providerCallIdMap: params.providerCallIdMap,
providerCallId: params.callIdOrProviderCallId,
});
}

View File

@@ -0,0 +1,708 @@
// Voice Call tests cover outbound plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
addTranscriptEntryMock,
clearMaxDurationTimerMock,
ensureMaxDurationTimerForLiveCallMock,
generateDtmfRedirectTwimlMock,
generateNotifyTwimlMock,
getCallByProviderCallIdMock,
mapVoiceToPollyMock,
persistCallRecordMock,
rejectTranscriptWaiterMock,
transitionStateMock,
} = vi.hoisted(() => ({
addTranscriptEntryMock: vi.fn(),
clearMaxDurationTimerMock: vi.fn(),
ensureMaxDurationTimerForLiveCallMock: vi.fn(
(params: { call: { answeredAt?: number }; liveAt: number }) => {
params.call.answeredAt ??= params.liveAt;
},
),
generateDtmfRedirectTwimlMock: 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(),
ensureMaxDurationTimerForLiveCall: ensureMaxDurationTimerForLiveCallMock,
rejectTranscriptWaiter: rejectTranscriptWaiterMock,
waitForFinalTranscript: vi.fn(),
}));
vi.mock("./lookup.js", () => ({
getCallByProviderCallId: getCallByProviderCallIdMock,
}));
vi.mock("../voice-mapping.js", () => ({
mapVoiceToPolly: mapVoiceToPollyMock,
}));
vi.mock("./twiml.js", () => ({
generateDtmfRedirectTwiml: generateDtmfRedirectTwimlMock,
generateNotifyTwiml: generateNotifyTwimlMock,
}));
import { endCall, initiateCall, sendDtmf, speak, speakInitialMessage } 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");
generateDtmfRedirectTwimlMock.mockReturnValue("<DtmfRedirect />");
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" } } },
},
coreSession: { mainKey: "work" },
storePath: "/tmp/voice-call.json",
webhookUrl: "https://example.com/webhook",
};
const result = await initiateCall(ctx as never, "+14155550123", "main", {
mode: "notify",
message: "hello there",
});
expect(result.success).toBe(true);
expect(result.callId).toBeTypeOf("string");
expect(result.callId).not.toBe("");
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(ctx.activeCalls.get(callId)?.sessionKey).toBe("agent:main:work");
expect(persistCallRecordMock).toHaveBeenCalledTimes(2);
});
it("assigns per-call session keys to outbound calls when configured", 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",
sessionScope: "per-call",
},
storePath: "/tmp/voice-call.json",
webhookUrl: "https://example.com/webhook",
};
const result = await initiateCall(ctx as never, "+14155550123");
expect(result.success).toBe(true);
expect(result.callId).toBeTypeOf("string");
expect(result.callId).not.toBe("");
expect(ctx.activeCalls.get(result.callId)?.sessionKey).toBe(
`agent:main:voice:call:${result.callId}`,
);
});
it("initiates conversation calls with pre-connect DTMF TwiML", 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",
},
storePath: "/tmp/voice-call.json",
webhookUrl: "https://example.com/webhook",
};
const result = await initiateCall(ctx as never, "+14155550123", "session-1", {
mode: "conversation",
message: "hello meet",
dtmfSequence: "ww123456#",
});
expect(result.success).toBe(true);
expect(result.callId).toBeTypeOf("string");
expect(result.callId).not.toBe("");
const callId = result.callId;
expect(generateDtmfRedirectTwimlMock).toHaveBeenCalledWith(
"ww123456#",
"https://example.com/webhook",
);
expect(initiateProviderCall).toHaveBeenCalledWith({
callId,
from: "+14155550100",
to: "+14155550123",
webhookUrl: "https://example.com/webhook",
inlineTwiml: undefined,
preConnectTwiml: "<DtmfRedirect />",
});
const metadata = (
ctx.activeCalls.get(callId) as { metadata?: Record<string, unknown> } | undefined
)?.metadata;
expect(metadata?.initialMessage).toBe("hello meet");
expect(metadata?.mode).toBe("conversation");
});
it("rejects DTMF sequences outside conversation mode", 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: "notify" },
fromNumber: "+14155550100",
},
storePath: "/tmp/voice-call.json",
webhookUrl: "https://example.com/webhook",
};
await expect(
initiateCall(ctx as never, "+14155550123", "session-1", {
message: "hello",
dtmfSequence: "123456#",
}),
).resolves.toEqual({
callId: "",
success: false,
error: "dtmfSequence requires conversation mode",
});
expect(initiateProviderCall).not.toHaveBeenCalled();
expect(ctx.activeCalls.size).toBe(0);
});
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",
};
const result = await initiateCall(ctx as never, "+14155550123");
expect(result.success).toBe(false);
expect(result.error).toBe("provider down");
expect(result.callId).toBeTypeOf("string");
expect(result.callId).not.toBe("");
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("passes configured voice ids through to Telnyx speak", 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: "telnyx", playTts },
config: {
tts: {
provider: "telnyx",
providers: {
telnyx: {
voiceId: "Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc",
},
},
},
},
storePath: "/tmp/voice-call.json",
};
await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true });
expect(playTts).toHaveBeenCalledWith({
callId: "call-1",
providerCallId: "provider-1",
text: "hello",
voice: "Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc",
});
});
it("caps notify-mode auto-hangup delay before scheduling", async () => {
const call = {
callId: "call-1",
providerCallId: "provider-1",
state: "active",
metadata: { initialMessage: "hello", mode: "notify" },
};
const playTts = vi.fn(async () => {});
const timeoutSpy = vi
.spyOn(globalThis, "setTimeout")
.mockReturnValue(1 as unknown as ReturnType<typeof setTimeout>);
getCallByProviderCallIdMock.mockReturnValue(call);
const ctx = {
activeCalls: new Map([["call-1", call]]),
providerCallIdMap: new Map([["provider-1", "call-1"]]),
provider: { name: "twilio", playTts },
initialMessageInFlight: new Set(),
config: {
outbound: { notifyHangupDelaySec: Number.MAX_SAFE_INTEGER },
tts: { provider: "openai", providers: { openai: { voice: "alloy" } } },
},
storePath: "/tmp/voice-call.json",
};
try {
await speakInitialMessage(ctx as never, "provider-1");
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
} finally {
timeoutSpy.mockRestore();
}
});
it("uses per-number route TTS voice for routed inbound calls", async () => {
const call = {
callId: "call-1",
providerCallId: "provider-1",
direction: "inbound",
state: "active",
to: "+15550002222",
metadata: { numberRouteKey: "+15550002222" },
};
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: "coral" } } },
numbers: {
"+15550002222": {
tts: {
providers: {
openai: { voice: "alloy" },
},
},
},
},
},
storePath: "/tmp/voice-call.json",
};
await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true });
expect(playTts).toHaveBeenCalledWith({
callId: "call-1",
providerCallId: "provider-1",
text: "hello",
voice: "alloy",
});
});
it("keeps top-level TTS for outbound calls to a number with an inbound route", async () => {
const call = {
callId: "call-1",
providerCallId: "provider-1",
direction: "outbound",
state: "active",
to: "+15550002222",
};
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: "coral" } } },
numbers: {
"+15550002222": {
tts: { providers: { openai: { voice: "alloy" } } },
},
},
},
storePath: "/tmp/voice-call.json",
};
await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true });
expect(playTts).toHaveBeenCalledWith({
callId: "call-1",
providerCallId: "provider-1",
text: "hello",
voice: "coral",
});
});
it("sends DTMF through connected provider calls", async () => {
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
const sendDtmfProvider = vi.fn(async () => {});
const ctx = {
activeCalls: new Map([["call-1", call]]),
providerCallIdMap: new Map(),
provider: { name: "twilio", sendDtmf: sendDtmfProvider },
config: {},
storePath: "/tmp/voice-call.json",
};
await expect(sendDtmf(ctx as never, "call-1", "ww123#")).resolves.toEqual({
success: true,
});
expect(sendDtmfProvider).toHaveBeenCalledWith({
callId: "call-1",
providerCallId: "provider-1",
digits: "ww123#",
});
});
it("rejects invalid or unsupported outbound DTMF", async () => {
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
const ctx = {
activeCalls: new Map([["call-1", call]]),
providerCallIdMap: new Map(),
provider: { name: "telnyx" },
config: {},
storePath: "/tmp/voice-call.json",
};
await expect(sendDtmf(ctx as never, "call-1", "abc")).resolves.toEqual({
success: false,
error: "digits may only contain digits, *, #, comma, w, p",
});
await expect(sendDtmf(ctx as never, "call-1", "123#")).resolves.toEqual({
success: false,
error: "telnyx does not support outbound DTMF",
});
});
it("ends connected calls, clears timers, and rejects pending transcripts", async () => {
const { call, ctx, hangupCall } = createActiveCallContext();
const beforeEndMs = Date.now();
await expect(endCall(ctx as never, "call-1")).resolves.toEqual({ success: true });
const afterEndMs = Date.now();
expect(hangupCall).toHaveBeenCalledWith({
callId: "call-1",
providerCallId: "provider-1",
reason: "hangup-bot",
});
expect((call as { endReason?: string }).endReason).toBe("hangup-bot");
const endedAt = (call as { endedAt?: unknown }).endedAt;
expect(endedAt).toBeTypeOf("number");
if (typeof endedAt === "number") {
expect(endedAt).toBeGreaterThanOrEqual(beforeEndMs);
expect(endedAt).toBeLessThanOrEqual(afterEndMs);
}
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();
const beforeEndMs = Date.now();
await expect(endCall(ctx as never, "call-1", { reason: "timeout" })).resolves.toEqual({
success: true,
});
const afterEndMs = Date.now();
expect(hangupCall).toHaveBeenCalledWith({
callId: "call-1",
providerCallId: "provider-1",
reason: "timeout",
});
expect((call as { endReason?: string }).endReason).toBe("timeout");
const endedAt = (call as { endedAt?: unknown }).endedAt;
expect(endedAt).toBeTypeOf("number");
if (typeof endedAt === "number") {
expect(endedAt).toBeGreaterThanOrEqual(beforeEndMs);
expect(endedAt).toBeLessThanOrEqual(afterEndMs);
}
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 });
});
it("issues a stream session and threads streamUrl + streamAuthToken through for Telnyx realtime", async () => {
const initiateProviderCall = vi.fn(async () => ({ providerCallId: "call-control-1" }));
const streamSessionIssuer = vi.fn(() => ({
token: "token-xyz",
streamUrl: "wss://example.test/voice/stream/realtime/token-xyz",
}));
const ctx = {
activeCalls: new Map(),
providerCallIdMap: new Map(),
provider: { name: "telnyx", initiateCall: initiateProviderCall },
config: {
maxConcurrentCalls: 3,
outbound: { defaultMode: "conversation" },
fromNumber: "+14155550100",
realtime: { enabled: true },
},
storePath: "/tmp/voice-call.json",
webhookUrl: "https://example.com/webhook",
streamSessionIssuer,
};
const result = await initiateCall(ctx as never, "+14155550123");
expect(result.success).toBe(true);
expect(streamSessionIssuer).toHaveBeenCalledTimes(1);
const issuerCall = (
streamSessionIssuer.mock.calls as unknown as Array<
[{ providerName: string; direction: string; to: string }]
>
)[0]?.[0];
expect(issuerCall?.providerName).toBe("telnyx");
expect(issuerCall?.direction).toBe("outbound");
expect(issuerCall?.to).toBe("+14155550123");
const providerCall = (
initiateProviderCall.mock.calls as unknown as Array<
[{ streamUrl?: string; streamAuthToken?: string }]
>
)[0]?.[0];
expect(providerCall?.streamUrl).toBe("wss://example.test/voice/stream/realtime/token-xyz");
expect(providerCall?.streamAuthToken).toBe("token-xyz");
});
it("skips the stream session for Twilio realtime (Twilio learns the URL from TwiML)", async () => {
const initiateProviderCall = vi.fn(async () => ({ providerCallId: "provider-1" }));
const streamSessionIssuer = vi.fn(() => ({
token: "should-not-be-used",
streamUrl: "wss://example.test/should-not-be-used",
}));
const ctx = {
activeCalls: new Map(),
providerCallIdMap: new Map(),
provider: { name: "twilio", initiateCall: initiateProviderCall },
config: {
maxConcurrentCalls: 3,
outbound: { defaultMode: "conversation" },
fromNumber: "+14155550100",
realtime: { enabled: true },
},
storePath: "/tmp/voice-call.json",
webhookUrl: "https://example.com/webhook",
streamSessionIssuer,
};
const result = await initiateCall(ctx as never, "+14155550123");
expect(result.success).toBe(true);
expect(streamSessionIssuer).not.toHaveBeenCalled();
const providerCall = (
initiateProviderCall.mock.calls as unknown as Array<[Record<string, unknown>]>
)[0]?.[0];
expect(providerCall?.streamUrl).toBeUndefined();
expect(providerCall?.streamAuthToken).toBeUndefined();
});
it("does not issue a stream session when realtime is disabled", async () => {
const initiateProviderCall = vi.fn(async () => ({ providerCallId: "call-control-1" }));
const streamSessionIssuer = vi.fn();
const ctx = {
activeCalls: new Map(),
providerCallIdMap: new Map(),
provider: { name: "telnyx", initiateCall: initiateProviderCall },
config: {
maxConcurrentCalls: 3,
outbound: { defaultMode: "conversation" },
fromNumber: "+14155550100",
realtime: { enabled: false },
},
storePath: "/tmp/voice-call.json",
webhookUrl: "https://example.com/webhook",
streamSessionIssuer,
};
await initiateCall(ctx as never, "+14155550123");
expect(streamSessionIssuer).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,533 @@
// Voice Call plugin module implements outbound behavior.
import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
resolveVoiceCallEffectiveConfig,
resolveVoiceCallNumberRouteKeyForCall,
resolveVoiceCallSessionKey,
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 { resolveVoiceCallSecondsTimerDelayMs } from "./timer-delays.js";
import {
clearTranscriptWaiter,
ensureMaxDurationTimerForLiveCall,
waitForFinalTranscript,
} from "./timers.js";
import { generateDtmfRedirectTwiml, generateNotifyTwiml } from "./twiml.js";
type InitiateContext = Pick<
CallManagerContext,
| "activeCalls"
| "providerCallIdMap"
| "provider"
| "config"
| "coreSession"
| "storePath"
| "webhookUrl"
| "streamSessionIssuer"
>;
type SpeakContext = Pick<
CallManagerContext,
| "activeCalls"
| "providerCallIdMap"
| "provider"
| "config"
| "storePath"
| "transcriptWaiters"
| "maxDurationTimers"
>;
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,
};
}
function validateDtmfDigits(digits: string): string | null {
return /^[0-9*#wWpP,]+$/.test(digits)
? null
: "digits may only contain digits, *, #, comma, w, p";
}
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;
const dtmfSequence = opts.dtmfSequence;
const requesterSessionKey = opts.requesterSessionKey?.trim();
if (dtmfSequence) {
const validationError = validateDtmfDigits(dtmfSequence);
if (validationError) {
return { callId: "", success: false, error: validationError };
}
if (mode !== "conversation") {
return {
callId: "",
success: false,
error: "dtmfSequence requires conversation mode",
};
}
}
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: resolveVoiceCallSessionKey({
config: ctx.config,
callId,
phone: to,
explicitSessionKey: sessionKey,
coreSession: ctx.coreSession,
}),
startedAt: Date.now(),
transcript: [],
processedEventIds: [],
metadata: {
...(initialMessage && { initialMessage }),
mode,
...(requesterSessionKey ? { requesterSessionKey } : {}),
},
};
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;
let preConnectTwiml: 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})`);
} else if (dtmfSequence) {
preConnectTwiml = generateDtmfRedirectTwiml(dtmfSequence, ctx.webhookUrl);
console.log(
`[voice-call] Using pre-connect DTMF TwiML for call ${callId} (digits=${dtmfSequence.length}, initialMessage=${initialMessage ? "yes" : "no"})`,
);
}
const streamSession =
ctx.config.realtime?.enabled && ctx.provider.name === "telnyx" && ctx.streamSessionIssuer
? ctx.streamSessionIssuer({
providerName: "telnyx",
callId,
from,
to,
direction: "outbound",
})
: undefined;
const result = await ctx.provider.initiateCall({
callId,
from,
to,
webhookUrl: ctx.webhookUrl,
inlineTwiml,
preConnectTwiml,
...(streamSession
? { streamUrl: streamSession.streamUrl, streamAuthToken: streamSession.token }
: {}),
});
callRecord.providerCallId = result.providerCallId;
ctx.providerCallIdMap.set(result.providerCallId, callId);
persistCallRecord(ctx.storePath, callRecord);
console.log(
`[voice-call] Outbound call initiated: callId=${callId} providerCallId=${result.providerCallId} mode=${mode} preConnectDtmf=${preConnectTwiml ? "yes" : "no"} initialMessage=${initialMessage ? "yes" : "no"}`,
);
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 {
ensureMaxDurationTimerForLiveCall({
ctx,
call,
liveAt: Date.now(),
onTimeout: async (id) => {
await endCall(ctx, id, { reason: "timeout" });
},
});
transitionState(call, "speaking");
persistCallRecord(ctx.storePath, call);
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
const voice = resolvePreferredTtsVoice(
resolveVoiceCallEffectiveConfig(ctx.config, numberRouteKey).config,
);
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) };
}
}
function shouldStartListeningAfterInitialMessage(ctx: ConversationContext): boolean {
if (ctx.provider?.name !== "twilio") {
return true;
}
if (!ctx.config.streaming.enabled) {
return true;
}
const streamAwareProvider = ctx.provider as typeof ctx.provider & {
isConversationStreamConnectEnabled?: () => boolean;
};
return streamAwareProvider.isConversationStreamConnectEnabled?.() !== true;
}
export async function sendDtmf(
ctx: SpeakContext,
callId: CallId,
digits: string,
): Promise<{ success: boolean; error?: string }> {
const validationError = validateDtmfDigits(digits);
if (validationError) {
return { success: false, error: validationError };
}
const connected = requireConnectedCall(ctx, callId);
if (!connected.ok) {
return { success: false, error: connected.error };
}
if (!connected.provider.sendDtmf) {
return { success: false, error: `${connected.provider.name} does not support outbound DTMF` };
}
try {
await connected.provider.sendDtmf({
callId,
providerCallId: connected.providerCallId,
digits,
});
return { success: true };
} catch (err) {
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;
const delayMs = resolveVoiceCallSecondsTimerDelayMs(delaySec, 0);
console.log(`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`);
setTimeout(() => {
void (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);
}
})();
}, delayMs);
} else if (
mode === "conversation" &&
ctx.provider &&
shouldStartListeningAfterInitialMessage(ctx)
) {
transitionState(call, "listening");
persistCallRecord(ctx.storePath, call);
await ctx.provider.startListening({
callId: call.callId,
providerCallId,
});
}
} finally {
ctx.initialMessageInFlight.delete(call.callId);
}
}
export async function continueCall(
ctx: ConversationContext,
callId: CallId,
prompt: string,
): Promise<{ success: boolean; transcript?: string; error?: string }> {
const connected = requireConnectedCall(ctx, callId);
if (!connected.ok) {
return { success: false, error: connected.error };
}
const { call, providerCallId, provider } = connected;
if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) {
return { success: false, error: "Already waiting for transcript" };
}
ctx.activeTurnCalls.add(callId);
const turnStartedAt = Date.now();
const turnToken = provider.name === "twilio" ? crypto.randomUUID() : undefined;
try {
await speak(ctx, callId, prompt);
transitionState(call, "listening");
persistCallRecord(ctx.storePath, call);
const listenStartedAt = Date.now();
await provider.startListening({ callId, providerCallId, turnToken });
const transcript = await waitForFinalTranscript(ctx, callId, turnToken);
const transcriptReceivedAt = Date.now();
// Best-effort: stop listening after final transcript.
await provider.stopListening({ callId, providerCallId });
const lastTurnLatencyMs = transcriptReceivedAt - turnStartedAt;
const lastTurnListenWaitMs = transcriptReceivedAt - listenStartedAt;
const turnCount =
call.metadata && typeof call.metadata.turnCount === "number"
? call.metadata.turnCount + 1
: 1;
call.metadata = {
...call.metadata,
turnCount,
lastTurnLatencyMs,
lastTurnListenWaitMs,
lastTurnCompletedAt: transcriptReceivedAt,
};
persistCallRecord(ctx.storePath, call);
console.log(
"[voice-call] continueCall latency call=" +
call.callId +
" totalMs=" +
String(lastTurnLatencyMs) +
" listenWaitMs=" +
String(lastTurnListenWaitMs),
);
return { success: true, transcript };
} catch (err) {
return { success: false, error: formatErrorMessage(err) };
} finally {
ctx.activeTurnCalls.delete(callId);
clearTranscriptWaiter(ctx, callId);
}
}
export async function endCall(
ctx: EndCallContext,
callId: CallId,
options?: { reason?: EndReason },
): Promise<{ success: boolean; error?: string }> {
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") {
return { success: false, error: lookup.error };
}
if (lookup.kind === "ended") {
return { success: true };
}
const { call, providerCallId, provider } = lookup;
const reason = options?.reason ?? "hangup-bot";
try {
await provider.hangupCall({
callId,
providerCallId,
reason,
});
finalizeCall({
ctx,
call,
endReason: reason,
});
return { success: true };
} catch (err) {
return { success: false, error: formatErrorMessage(err) };
}
}

View File

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

View File

@@ -0,0 +1,154 @@
// Voice Call tests cover store plugin behavior.
import fs from "node:fs";
import path from "node:path";
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateSyncKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createTestStorePath,
makePersistedCall,
writeLegacyCallsJsonl,
} from "../manager.test-harness.js";
import { clearVoiceCallStateRuntime, setVoiceCallStateRuntime } from "../runtime-state.js";
import { CallRecordSchema } from "../types.js";
import {
flushPendingCallRecordWritesForTest,
getCallHistoryFromStore,
loadActiveCallsFromStore,
persistCallRecord,
} from "./store.js";
function installStateRuntime(): void {
setVoiceCallStateRuntime({
state: {
resolveStateDir: () => "",
openKeyedStore: (() => {
throw new Error("openKeyedStore is not used by voice-call store tests");
}) as never,
openSyncKeyedStore: (options: OpenKeyedStoreOptions) =>
createPluginStateSyncKeyedStoreForTests("voice-call", options),
openChannelIngressQueue: (() => {
throw new Error("openChannelIngressQueue is not used by voice-call store tests");
}) as never,
},
});
}
describe("voice-call call record store", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
installStateRuntime();
});
afterEach(() => {
vi.useRealTimers();
clearVoiceCallStateRuntime();
resetPluginStateStoreForTests();
});
it("does not import legacy JSONL records at runtime", async () => {
const storePath = createTestStorePath();
const call = CallRecordSchema.parse(
makePersistedCall({ callId: "call-legacy", processedEventIds: ["evt-1"] }),
);
writeLegacyCallsJsonl(storePath, [call]);
const restored = loadActiveCallsFromStore(storePath);
expect(restored.activeCalls.has("call-legacy")).toBe(false);
expect(restored.processedEventIds.has("evt-1")).toBe(false);
expect(fs.existsSync(path.join(storePath, "calls.jsonl"))).toBe(true);
const history = await getCallHistoryFromStore(storePath);
expect(history).toEqual([]);
});
it("persists new call snapshots without recreating the JSONL log", async () => {
const storePath = createTestStorePath();
const call = CallRecordSchema.parse(
makePersistedCall({ callId: "call-sqlite", transcript: [] }),
);
persistCallRecord(storePath, call);
await flushPendingCallRecordWritesForTest();
expect(fs.existsSync(path.join(storePath, "calls.jsonl"))).toBe(false);
const restored = loadActiveCallsFromStore(storePath);
expect(restored.activeCalls.get("call-sqlite")?.providerCallId).toBe(call.providerCallId);
});
it("does not read the JSONL fallback when SQLite state cannot open", () => {
const storePath = createTestStorePath();
const call = CallRecordSchema.parse(makePersistedCall({ callId: "call-jsonl" }));
writeLegacyCallsJsonl(storePath, [call]);
setVoiceCallStateRuntime({
state: {
resolveStateDir: () => "",
openKeyedStore: (() => {
throw new Error("openKeyedStore is not used by voice-call store tests");
}) as never,
openSyncKeyedStore: (() => {
throw new Error("sqlite unavailable");
}) as never,
openChannelIngressQueue: (() => {
throw new Error("openChannelIngressQueue is not used by voice-call store tests");
}) as never,
},
});
const restored = loadActiveCallsFromStore(storePath);
expect(restored.activeCalls.has("call-jsonl")).toBe(false);
expect(fs.existsSync(path.join(storePath, "calls.jsonl"))).toBe(true);
});
it("persists oversized records in SQLite without creating a JSONL fallback", async () => {
const storePath = createTestStorePath();
const call = CallRecordSchema.parse(
makePersistedCall({
callId: "call-large",
metadata: { mode: "conversation", numberRouteKey: "+15550000001" },
transcript: [
{
timestamp: Date.now(),
speaker: "user",
text: "x".repeat(3 * 1024 * 1024),
isFinal: true,
},
],
}),
);
persistCallRecord(storePath, call);
await flushPendingCallRecordWritesForTest();
const restored = loadActiveCallsFromStore(storePath);
const restoredCall = restored.activeCalls.get("call-large");
expect(restoredCall?.providerCallId).toBe(call.providerCallId);
expect(restoredCall?.transcript).toEqual([]);
expect(restoredCall?.metadata).toMatchObject({
mode: "conversation",
numberRouteKey: "+15550000001",
voiceCallPersistence: { transcriptTruncated: true },
});
expect(fs.existsSync(path.join(storePath, "calls.jsonl"))).toBe(false);
});
it("replays same-millisecond snapshots in write order", () => {
vi.useFakeTimers({ now: new Date("2026-05-31T10:00:00.000Z") });
const storePath = createTestStorePath();
const first = CallRecordSchema.parse(
makePersistedCall({ callId: "call-order", state: "ringing" }),
);
const second = CallRecordSchema.parse(
makePersistedCall({ callId: "call-order", state: "answered" }),
);
persistCallRecord(storePath, first);
persistCallRecord(storePath, second);
const restored = loadActiveCallsFromStore(storePath);
expect(restored.activeCalls.get("call-order")?.state).toBe("answered");
});
});

View File

@@ -0,0 +1,396 @@
// Voice Call plugin module implements store behavior.
import { createHash, randomUUID } from "node:crypto";
import path from "node:path";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { getOptionalVoiceCallStateRuntime } from "../runtime-state.js";
import { CallRecordSchema, TerminalStates, type CallId, type CallRecord } from "../types.js";
// Persistent voice-call event store backed by plugin state chunk records.
/** Plugin state namespace for call record event metadata. */
export const CALL_RECORD_EVENTS_NAMESPACE = "call-record-events";
/** Plugin state namespace for base64 call record event chunks. */
export const CALL_RECORD_EVENT_CHUNKS_NAMESPACE = "call-record-event-chunks";
/** Maximum retained call record events. */
export const MAX_CALL_RECORD_EVENTS = 1000;
/** Extra metadata entries retained so pruning can safely trim oldest rows. */
export const CALL_RECORD_EVENT_META_MAX_ENTRIES = MAX_CALL_RECORD_EVENTS + 100;
/** Maximum chunks allowed for one persisted call record event. */
export const MAX_CHUNKS_PER_CALL_RECORD_EVENT = 48;
export const CALL_RECORD_CHUNK_MAX_ENTRIES =
MAX_CALL_RECORD_EVENTS * MAX_CHUNKS_PER_CALL_RECORD_EVENT + MAX_CHUNKS_PER_CALL_RECORD_EVENT;
/** Raw UTF-8 bytes stored per call record chunk before base64 encoding. */
export const RAW_CALL_RECORD_CHUNK_BYTES = 47 * 1024;
let callRecordEventSequence = 0;
/** Metadata row for a chunked call record event. */
type CallRecordEventMeta = {
chunkCount: number;
byteLength: number;
persistedAt?: number;
sequence?: number;
};
/** One base64 chunk for a serialized call record event. */
type CallRecordEventChunk = {
index: number;
dataBase64: string;
};
/** Call record plus stable ordering metadata read from persistence. */
export type PersistedCallRecord = {
call: CallRecord;
persistedAt: number;
sequence: number;
orderKey: string;
};
/** Pair of plugin state stores used for call record events. */
type CallRecordStateStores = {
events: PluginStateSyncKeyedStore<CallRecordEventMeta>;
chunks: PluginStateSyncKeyedStore<CallRecordEventChunk>;
};
/** Return the pre-SQLite JSONL call log path for migration/compat checks. */
export function resolveVoiceCallLegacyCallLogPath(storePath: string): string {
return path.join(storePath, "calls.jsonl");
}
/** Build env for plugin state stores rooted at the voice-call store path. */
function resolvePluginStateEnv(storePath: string): NodeJS.ProcessEnv {
return { ...process.env, OPENCLAW_STATE_DIR: storePath };
}
/** Open the plugin state stores when the runtime is available. */
function createCallRecordStateStores(storePath: string): CallRecordStateStores | null {
const runtime = getOptionalVoiceCallStateRuntime();
if (!runtime) {
return null;
}
const env = resolvePluginStateEnv(storePath);
return {
events: runtime.state.openSyncKeyedStore<CallRecordEventMeta>({
namespace: CALL_RECORD_EVENTS_NAMESPACE,
maxEntries: CALL_RECORD_EVENT_META_MAX_ENTRIES,
env,
}),
chunks: runtime.state.openSyncKeyedStore<CallRecordEventChunk>({
namespace: CALL_RECORD_EVENT_CHUNKS_NAMESPACE,
maxEntries: CALL_RECORD_CHUNK_MAX_ENTRIES,
env,
}),
};
}
/** Open call stores and log failures instead of breaking restore paths. */
function tryCreateCallRecordStateStores(storePath: string): CallRecordStateStores | null {
try {
return createCallRecordStateStores(storePath);
} catch (err) {
console.error("[voice-call] Failed to open SQLite call record store:", err);
return null;
}
}
/** Build the stable storage key for one chunk of an event. */
function buildChunkKey(eventKey: string, index: number): string {
return `${eventKey}:chunk:${String(index).padStart(4, "0")}`;
}
/** Build a deterministic key for one legacy JSONL line. */
export function buildVoiceCallLegacyJsonlEventKey(line: string, index: number): string {
return `jsonl:${String(index).padStart(8, "0")}:${createHash("sha256").update(line).digest("hex")}`;
}
/** Allocate monotonic ordering metadata for newly persisted call records. */
function nextCallRecordOrder(): { persistedAt: number; sequence: number } {
const sequence = callRecordEventSequence;
callRecordEventSequence = (callRecordEventSequence + 1) % 1_000_000;
return { persistedAt: Date.now(), sequence };
}
/** Build a unique event key that preserves timestamp and sequence ordering. */
function buildNewEventKey(order: { persistedAt: number; sequence: number }): string {
return `event:${order.persistedAt.toString(36)}:${String(order.sequence).padStart(6, "0")}:${randomUUID()}`;
}
/** Recover the sequence segment from newer event keys. */
function parseEventKeySequence(key: string): number {
const match = /^event:[^:]+:(\d+):/.exec(key);
return match ? Number.parseInt(match[1], 10) : 0;
}
/** Parse a stored call record line from v2 envelope or legacy raw-call JSON. */
export function parseVoiceCallRecordLine(line: string, sequence = 0): PersistedCallRecord | null {
if (!line.trim()) {
return null;
}
try {
const parsed = JSON.parse(line);
if (parsed && typeof parsed === "object" && (parsed as { version?: unknown }).version === 2) {
const envelope = parsed as {
call?: unknown;
persistedAt?: unknown;
sequence?: unknown;
};
const call = CallRecordSchema.parse(envelope.call);
return {
call,
persistedAt:
typeof envelope.persistedAt === "number" && Number.isFinite(envelope.persistedAt)
? envelope.persistedAt
: 0,
sequence:
typeof envelope.sequence === "number" && Number.isFinite(envelope.sequence)
? envelope.sequence
: sequence,
orderKey: "",
};
}
return {
call: CallRecordSchema.parse(parsed),
persistedAt: 0,
sequence,
orderKey: "",
};
} catch {
return null;
}
}
/** Count storage chunks needed for a call record. */
function countCallRecordChunks(call: CallRecord): number {
return Math.max(
1,
Math.ceil(Buffer.byteLength(JSON.stringify(call), "utf8") / RAW_CALL_RECORD_CHUNK_BYTES),
);
}
/** Truncate oversized call records to fit the bounded plugin state chunk budget. */
export function prepareVoiceCallRecordForStorage(call: CallRecord): CallRecord {
if (countCallRecordChunks(call) <= MAX_CHUNKS_PER_CALL_RECORD_EVENT) {
return call;
}
const transcriptEntries = call.transcript.length;
const metadata = {
...call.metadata,
voiceCallPersistence: {
transcriptTruncated: true,
originalTranscriptEntries: transcriptEntries,
},
};
const candidateInputs = [
{ transcript: call.transcript.slice(-20), metadata },
{ transcript: [], metadata },
{
transcript: [],
metadata: {
voiceCallPersistence: {
transcriptTruncated: true,
originalTranscriptEntries: transcriptEntries,
metadataTruncated: true,
},
},
},
];
for (const candidateInput of candidateInputs) {
const candidate = CallRecordSchema.parse({
...call,
...candidateInput,
});
if (countCallRecordChunks(candidate) <= MAX_CHUNKS_PER_CALL_RECORD_EVENT) {
return candidate;
}
}
return call;
}
/** Register a serialized call record event and its chunks, then prune old events. */
function registerCallRecordEvent(
stores: CallRecordStateStores,
eventKey: string,
call: CallRecord,
order?: { persistedAt: number; sequence: number },
): void {
const serialized = JSON.stringify(prepareVoiceCallRecordForStorage(call));
const buffer = Buffer.from(serialized, "utf8");
const chunkCount = Math.max(1, Math.ceil(buffer.byteLength / RAW_CALL_RECORD_CHUNK_BYTES));
if (chunkCount > MAX_CHUNKS_PER_CALL_RECORD_EVENT) {
throw new Error(
`voice-call record exceeds SQLite chunk limit (${chunkCount}/${MAX_CHUNKS_PER_CALL_RECORD_EVENT})`,
);
}
for (let index = 0; index < chunkCount; index += 1) {
const chunk = buffer.subarray(
index * RAW_CALL_RECORD_CHUNK_BYTES,
(index + 1) * RAW_CALL_RECORD_CHUNK_BYTES,
);
stores.chunks.register(buildChunkKey(eventKey, index), {
index,
dataBase64: chunk.toString("base64"),
});
}
stores.events.register(eventKey, {
chunkCount,
byteLength: buffer.byteLength,
persistedAt: order?.persistedAt,
sequence: order?.sequence,
});
pruneCallRecordEvents(stores);
}
/** Delete metadata and all chunk rows for one call record event. */
function deleteCallRecordEventRows(stores: CallRecordStateStores, eventKey: string): void {
const meta = stores.events.lookup(eventKey);
stores.events.delete(eventKey);
if (!meta) {
return;
}
for (let index = 0; index < meta.chunkCount; index += 1) {
stores.chunks.delete(buildChunkKey(eventKey, index));
}
}
/** Keep only the newest bounded call record events. */
function pruneCallRecordEvents(stores: CallRecordStateStores): void {
const rows = stores.events.entries();
if (rows.length <= MAX_CALL_RECORD_EVENTS) {
return;
}
const sorted = rows.toSorted((a, b) => a.createdAt - b.createdAt || a.key.localeCompare(b.key));
for (const row of sorted.slice(0, rows.length - MAX_CALL_RECORD_EVENTS)) {
deleteCallRecordEventRows(stores, row.key);
}
}
/** Read and reassemble one chunked call record event. */
function readCallRecordEvent(stores: CallRecordStateStores, eventKey: string): CallRecord | null {
const meta = stores.events.lookup(eventKey);
if (!meta) {
return null;
}
const chunks: Buffer[] = [];
for (let index = 0; index < meta.chunkCount; index += 1) {
const chunk = stores.chunks.lookup(buildChunkKey(eventKey, index));
if (!chunk || chunk.index !== index) {
return null;
}
chunks.push(Buffer.from(chunk.dataBase64, "base64"));
}
const serialized = Buffer.concat(chunks, meta.byteLength).toString("utf8");
return parseVoiceCallRecordLine(serialized)?.call ?? null;
}
/** Read all persisted call records in stable persisted order. */
function readCallRecordEvents(stores: CallRecordStateStores): CallRecord[] {
const sqliteCalls: PersistedCallRecord[] = stores.events
.entries()
.toSorted((a, b) => a.createdAt - b.createdAt || a.key.localeCompare(b.key))
.map((entry) => {
const call = readCallRecordEvent(stores, entry.key);
return call
? {
call,
persistedAt: entry.value.persistedAt ?? entry.createdAt,
sequence: entry.value.sequence ?? parseEventKeySequence(entry.key),
orderKey: entry.key,
}
: null;
})
.filter((entry): entry is PersistedCallRecord => entry !== null);
return sqliteCalls
.toSorted(
(a, b) =>
a.persistedAt - b.persistedAt ||
a.sequence - b.sequence ||
a.orderKey.localeCompare(b.orderKey),
)
.map((entry) => entry.call);
}
/** Persist one call record event to plugin state. */
export function persistCallRecord(storePath: string, call: CallRecord): void {
try {
const stores = createCallRecordStateStores(storePath);
if (!stores) {
throw new Error("Voice Call state runtime not initialized");
}
const order = nextCallRecordOrder();
registerCallRecordEvent(stores, buildNewEventKey(order), call, order);
} catch (err) {
console.error("[voice-call] Failed to persist call record:", err);
throw err;
}
}
/** Test hook for older async persistence call sites. */
export async function flushPendingCallRecordWritesForTest(): Promise<void> {
await Promise.resolve();
}
/** Restore nonterminal active calls and provider/event indexes from persisted records. */
export function loadActiveCallsFromStore(storePath: string): {
activeCalls: Map<CallId, CallRecord>;
providerCallIdMap: Map<string, CallId>;
processedEventIds: Set<string>;
rejectedProviderCallIds: Set<string>;
} {
const stores = tryCreateCallRecordStateStores(storePath);
let calls: CallRecord[] = [];
try {
calls = stores ? readCallRecordEvents(stores) : [];
} catch (err) {
console.error("[voice-call] Failed to read SQLite call records:", err);
}
if (calls.length === 0) {
return {
activeCalls: new Map(),
providerCallIdMap: new Map(),
processedEventIds: new Set(),
rejectedProviderCallIds: new Set(),
};
}
const callMap = new Map<CallId, CallRecord>();
for (const call of calls) {
callMap.set(call.callId, call);
}
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 };
}
/** Return the newest persisted call history rows up to the requested limit. */
export async function getCallHistoryFromStore(
storePath: string,
limit = 50,
): Promise<CallRecord[]> {
if (limit <= 0) {
return [];
}
const stores = tryCreateCallRecordStateStores(storePath);
if (stores) {
try {
return readCallRecordEvents(stores).slice(-limit);
} catch (err) {
console.error("[voice-call] Failed to read SQLite call history:", err);
}
}
return [];
}

View File

@@ -0,0 +1,18 @@
// Voice Call tests cover timer delays plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it } from "vitest";
import {
resolveVoiceCallSecondsTimerDelayMs,
resolveVoiceCallTimerDelayMs,
} from "./timer-delays.js";
describe("voice-call timer delays", () => {
it("caps second-based delays to timer-safe milliseconds", () => {
expect(resolveVoiceCallSecondsTimerDelayMs(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIMER_TIMEOUT_MS);
expect(resolveVoiceCallSecondsTimerDelayMs(Number.MAX_VALUE)).toBe(MAX_TIMER_TIMEOUT_MS);
});
it("caps millisecond delays to timer-safe values", () => {
expect(resolveVoiceCallTimerDelayMs(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIMER_TIMEOUT_MS);
});
});

View File

@@ -0,0 +1,22 @@
// Voice Call plugin module implements timer delays behavior.
import { MAX_TIMER_TIMEOUT_MS, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
// Timer delay normalization helpers for voice-call lifecycle timers.
/** Convert seconds to a safe timeout delay in milliseconds. */
export function resolveVoiceCallSecondsTimerDelayMs(seconds: number, minMs = 1): number {
if (!Number.isFinite(seconds)) {
return resolveTimerTimeoutMs(MAX_TIMER_TIMEOUT_MS, MAX_TIMER_TIMEOUT_MS, minMs);
}
const timeoutMs = Math.floor(seconds * 1000);
return resolveTimerTimeoutMs(
Number.isFinite(timeoutMs) ? timeoutMs : MAX_TIMER_TIMEOUT_MS,
minMs,
minMs,
);
}
/** Normalize a millisecond timeout delay with fallback behavior. */
export function resolveVoiceCallTimerDelayMs(timeoutMs: number, fallbackMs = 1): number {
return resolveTimerTimeoutMs(timeoutMs, fallbackMs);
}

View File

@@ -0,0 +1,163 @@
// Voice Call tests cover timers plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
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("caps oversized max duration and transcript timers", () => {
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
const ctx = {
activeCalls: new Map([["call-1", { id: "call-1", state: "active" }]]),
maxDurationTimers: new Map(),
transcriptWaiters: new Map(),
config: {
maxDurationSeconds: Number.MAX_SAFE_INTEGER,
transcriptTimeoutMs: Number.MAX_SAFE_INTEGER,
},
storePath: "/tmp/voice-call",
};
try {
startMaxDurationTimer({
ctx: ctx as never,
callId: "call-1",
onTimeout: vi.fn(async () => {}),
});
const transcript = waitForFinalTranscript(ctx as never, "call-2");
expect(
timeoutSpy.mock.calls.filter(([, delay]) => delay === MAX_TIMER_TIMEOUT_MS),
).toHaveLength(2);
clearMaxDurationTimer(ctx as never, "call-1");
rejectTranscriptWaiter(ctx as never, "call-2", "done");
void transcript.catch(() => {});
} finally {
timeoutSpy.mockRestore();
}
});
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: unknown) => error,
);
await vi.advanceTimersByTimeAsync(1_000);
const timeoutError = await timedOut;
expect(timeoutError).toBeInstanceOf(Error);
expect((timeoutError as Error).message).toBe("Timed out waiting for transcript after 1000ms");
const toClear = waitForFinalTranscript(ctx as never, "call-4");
clearTranscriptWaiter(ctx as never, "call-4");
expect(ctx.transcriptWaiters.has("call-4")).toBe(false);
void toClear.catch(() => {});
});
it("rejects duplicate transcript waiters for the same call", async () => {
const ctx = {
transcriptWaiters: new Map(),
config: { transcriptTimeoutMs: 1_000 },
};
const pending = waitForFinalTranscript(ctx as never, "call-1");
await expect(waitForFinalTranscript(ctx as never, "call-1")).rejects.toThrow(
"Already waiting for transcript",
);
rejectTranscriptWaiter(ctx as never, "call-1", "done");
await expect(pending).rejects.toThrow("done");
});
});

View File

@@ -0,0 +1,154 @@
// Voice Call plugin module implements timers behavior.
import { TerminalStates, type CallId, type CallRecord } from "../types.js";
import type { CallManagerContext } from "./context.js";
import { persistCallRecord } from "./store.js";
import {
resolveVoiceCallSecondsTimerDelayMs,
resolveVoiceCallTimerDelayMs,
} from "./timer-delays.js";
// Max-duration and transcript-waiter timers for active voice calls.
type TimerContext = Pick<
CallManagerContext,
"activeCalls" | "maxDurationTimers" | "config" | "storePath" | "transcriptWaiters"
>;
type MaxDurationTimerContext = Pick<
TimerContext,
"activeCalls" | "maxDurationTimers" | "config" | "storePath"
>;
type TranscriptWaiterContext = Pick<TimerContext, "transcriptWaiters">;
/** Clear and forget the max-duration timer for a call. */
export function clearMaxDurationTimer(
ctx: Pick<MaxDurationTimerContext, "maxDurationTimers">,
callId: CallId,
): void {
const timer = ctx.maxDurationTimers.get(callId);
if (timer) {
clearTimeout(timer);
ctx.maxDurationTimers.delete(callId);
}
}
/** Start or replace the max-duration timer for a call. */
export function startMaxDurationTimer(params: {
ctx: MaxDurationTimerContext;
callId: CallId;
onTimeout: (callId: CallId) => Promise<void>;
timeoutMs?: number;
}): void {
clearMaxDurationTimer(params.ctx, params.callId);
const maxDurationMs =
params.timeoutMs === undefined
? resolveVoiceCallSecondsTimerDelayMs(params.ctx.config.maxDurationSeconds)
: resolveVoiceCallTimerDelayMs(params.timeoutMs);
console.log(
`[voice-call] Starting max duration timer (${Math.ceil(maxDurationMs / 1000)}s) for call ${params.callId}`,
);
const timer = setTimeout(() => {
void (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 (${Math.ceil(maxDurationMs / 1000)}s), ending call ${params.callId}`,
);
call.endReason = "timeout";
persistCallRecord(params.ctx.storePath, call);
// Provider-specific timeout handling owns the actual hangup after state persistence.
await params.onTimeout(params.callId);
}
})();
}, maxDurationMs);
params.ctx.maxDurationTimers.set(params.callId, timer);
}
/** Backfill max-duration enforcement from the first live conversation signal. */
export function ensureMaxDurationTimerForLiveCall(params: {
ctx: MaxDurationTimerContext;
call: CallRecord;
liveAt: number;
onTimeout: (callId: CallId) => Promise<void>;
}): void {
if (params.call.answeredAt) {
return;
}
// Realtime streams can prove the call is live before an answered callback;
// use that first live signal so stale cleanup can skip it without losing
// maxDurationSeconds enforcement.
params.call.answeredAt = params.liveAt;
startMaxDurationTimer({
ctx: params.ctx,
callId: params.call.callId,
onTimeout: params.onTimeout,
});
}
/** Clear and forget a pending final-transcript waiter. */
export function clearTranscriptWaiter(ctx: TranscriptWaiterContext, callId: CallId): void {
const waiter = ctx.transcriptWaiters.get(callId);
if (!waiter) {
return;
}
clearTimeout(waiter.timeout);
ctx.transcriptWaiters.delete(callId);
}
/** Reject a pending transcript waiter during call finalization or error paths. */
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));
}
/** Resolve a transcript waiter when the matching turn's final transcript arrives. */
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;
}
/** Wait for the next final transcript for a call, optionally scoped to a turn token. */
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 = resolveVoiceCallTimerDelayMs(ctx.config.transcriptTimeoutMs);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ctx.transcriptWaiters.delete(callId);
reject(new Error(`Timed out waiting for transcript after ${timeoutMs}ms`));
}, timeoutMs);
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout, turnToken });
});
}

View File

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

View File

@@ -0,0 +1,22 @@
// Voice Call plugin module implements twiml behavior.
import { escapeXml } from "../voice-mapping.js";
// TwiML builders for manager-initiated notify and DTMF redirect flows.
/** Generate TwiML that speaks one notification and hangs up. */
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>`;
}
/** Generate TwiML that plays DTMF digits before redirecting to a webhook URL. */
export function generateDtmfRedirectTwiml(digits: string, webhookUrl: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Play digits="${escapeXml(digits)}" />
<Redirect method="POST">${escapeXml(webhookUrl)}</Redirect>
</Response>`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,866 @@
/**
* 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 { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import type {
RealtimeTranscriptionProviderConfig,
RealtimeTranscriptionProviderPlugin,
RealtimeTranscriptionSession,
} from "openclaw/plugin-sdk/realtime-transcription";
import {
createTalkSessionController,
recordTalkObservabilityEvent,
type TalkEvent,
type TalkEventInput,
type TalkSessionController,
} from "openclaw/plugin-sdk/realtime-voice";
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;
/** Full runtime config, used by providers that can resolve OAuth profiles. */
cfg?: OpenClawConfig;
/** 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. Missing validator rejects. */
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 realtime transcription is ready for the stream */
onTranscriptionReady?: (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;
/** Callback for common Talk events emitted by the telephony STT/TTS adapter. */
onTalkEvent?: (callId: string, streamSid: string, event: TalkEvent) => void;
}
/**
* Active media stream session.
*/
interface StreamSession {
callId: string;
streamSid: string;
ws: WebSocket;
sttSession: RealtimeTranscriptionSession;
talk: TalkSessionController;
}
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);
}
export function parseTwilioMediaMessage(data: RawData): TwilioMediaMessage {
const raw = normalizeWsMessageData(data);
try {
return JSON.parse(raw.toString("utf8")) as TwilioMediaMessage;
} catch (cause) {
throw new Error("Twilio media stream message was malformed JSON", { cause });
}
}
/**
* 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 = resolveTimerTimeoutMs(
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) => {
void 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", (data: RawData) => {
try {
const message = parseTwilioMediaMessage(data);
switch (message.event) {
case "connected":
console.log("[MediaStream] Twilio connected");
break;
case "start":
session = 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");
const turnId = this.ensureActiveTurn(session);
this.emitTalkEvent(session, {
type: "input.audio.delta",
turnId,
payload: {
callId: session.callId,
streamSid: session.streamSid,
bytes: audioBuffer.byteLength,
},
});
session.sttSession.sendAudio(audioBuffer);
}
break;
case "stop":
if (session) {
this.handleStop(session);
session = null;
}
break;
case "clear":
case "mark":
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 handleStart(
ws: WebSocket,
message: TwilioMediaMessage,
streamToken?: string,
): 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) {
console.warn("[MediaStream] Rejecting stream without an acceptance validator");
ws.close(1008, "Unauthorized stream");
return null;
}
if (!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({
cfg: this.config.cfg,
providerConfig: this.config.providerConfig,
onPartial: (partial) => {
const session = this.sessions.get(streamSid);
if (session) {
this.emitTalkEvent(session, {
type: "transcript.delta",
turnId: this.ensureActiveTurn(session),
payload: { callId: callSid, streamSid, text: partial, role: "user" },
});
}
this.config.onPartialTranscript?.(callSid, partial);
},
onTranscript: (transcript) => {
const session = this.sessions.get(streamSid);
if (session) {
const turnId = this.ensureActiveTurn(session);
this.emitTalkEvent(session, {
type: "input.audio.committed",
turnId,
final: true,
payload: { callId: callSid, streamSid },
});
this.emitTalkEvent(session, {
type: "transcript.done",
turnId,
final: true,
payload: { callId: callSid, streamSid, text: transcript, role: "user" },
});
}
this.config.onTranscript?.(callSid, transcript);
},
onSpeechStart: () => {
const session = this.sessions.get(streamSid);
if (session) {
this.ensureActiveTurn(session);
}
this.config.onSpeechStart?.(callSid);
},
onError: (error) => {
console.warn("[MediaStream] Transcription session error:", error.message);
const session = this.sessions.get(streamSid);
if (session) {
this.emitTalkEvent(session, {
type: "session.error",
final: true,
payload: { callId: callSid, streamSid, error: error.message },
});
}
},
});
const session: StreamSession = {
callId: callSid,
streamSid,
ws,
sttSession,
talk: this.createTalkEvents(callSid, streamSid),
};
this.sessions.set(streamSid, session);
this.config.onConnect?.(callSid, streamSid);
this.emitTalkEvent(session, {
type: "session.started",
payload: { callId: callSid, streamSid, provider: this.config.transcriptionProvider.id },
});
void this.connectTranscriptionAndNotify(session);
return session;
}
private async connectTranscriptionAndNotify(session: StreamSession): Promise<void> {
try {
await session.sttSession.connect();
} catch (error) {
console.warn(
"[MediaStream] STT connection failed; closing media stream:",
error instanceof Error ? error.message : String(error),
);
this.emitTalkEvent(session, {
type: "session.error",
final: true,
payload: {
callId: session.callId,
streamSid: session.streamSid,
error: error instanceof Error ? error.message : String(error),
},
});
if (
this.sessions.get(session.streamSid) === session &&
session.ws.readyState === WebSocket.OPEN
) {
session.ws.close(1011, "STT connection failed");
} else {
session.sttSession.close();
}
return;
}
if (
this.sessions.get(session.streamSid) !== session ||
session.ws.readyState !== WebSocket.OPEN
) {
session.sttSession.close();
return;
}
this.emitTalkEvent(session, {
type: "session.ready",
payload: { callId: session.callId, streamSid: session.streamSid },
});
this.config.onTranscriptionReady?.(session.callId, session.streamSid);
}
/**
* 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.emitTalkEvent(session, {
type: "session.closed",
final: true,
payload: { callId: session.callId, streamSid: 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 {
const session = this.getOpenSession(streamSid);
if (session) {
this.emitTalkEvent(session, {
type: "output.audio.delta",
turnId: this.ensureActiveTurn(session),
payload: { callId: session.callId, streamSid, bytes: muLawAudio.byteLength },
});
}
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);
this.resolveQueuedTtsEntries(queue);
this.ttsActiveControllers.get(streamSid)?.abort();
const session = this.sessions.get(streamSid);
if (session?.talk.activeTurnId) {
const cancelled = session.talk.cancelTurn({
payload: { callId: session.callId, streamSid, reason: _reason },
});
if (cancelled.ok) {
this.config.onTalkEvent?.(session.callId, session.streamSid, cancelled.event);
}
}
this.clearAudio(streamSid);
}
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);
const session = this.sessions.get(streamSid);
let playbackTurnId: string | undefined;
try {
if (session) {
playbackTurnId = this.ensureActiveTurn(session);
this.emitTalkEvent(session, {
type: "output.audio.started",
turnId: playbackTurnId,
payload: { callId: session.callId, streamSid },
});
}
await entry.playFn(entry.controller.signal);
if (entry.controller.signal.aborted) {
entry.resolve();
continue;
}
if (session) {
const turnId = playbackTurnId ?? this.ensureActiveTurn(session);
this.emitTalkEvent(session, {
type: "output.audio.done",
turnId,
final: true,
payload: { callId: session.callId, streamSid },
});
if (session.talk.activeTurnId) {
const ended = session.talk.endTurn({
payload: { callId: session.callId, streamSid },
});
if (ended.ok) {
this.config.onTalkEvent?.(session.callId, session.streamSid, ended.event);
}
}
}
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 createTalkEvents(callId: string, streamSid: string): TalkSessionController {
return createTalkSessionController(
{
sessionId: `voice-call:${callId}:${streamSid}`,
mode: "stt-tts",
transport: "gateway-relay",
brain: "agent-consult",
provider: this.config.transcriptionProvider.id,
turnIdPrefix: `${streamSid}:turn`,
},
{ onEvent: recordTalkObservabilityEvent },
);
}
private emitTalkEvent(session: StreamSession, input: TalkEventInput): void {
const event = session.talk.emit(input);
this.config.onTalkEvent?.(session.callId, session.streamSid, event);
}
private ensureActiveTurn(session: StreamSession): string {
const turn = session.talk.ensureTurn({
payload: { callId: session.callId, streamSid: session.streamSid },
});
if (turn.event) {
this.config.onTalkEvent?.(session.callId, session.streamSid, turn.event);
}
return turn.turnId;
}
private clearTtsState(streamSid: string): void {
const queue = this.ttsQueues.get(streamSid);
if (queue) {
this.resolveQueuedTtsEntries(queue);
}
this.ttsActiveControllers.get(streamSid)?.abort();
this.ttsActiveControllers.delete(streamSid);
this.ttsPlaying.delete(streamSid);
this.ttsQueues.delete(streamSid);
}
private resolveQueuedTtsEntries(queue: TtsQueueEntry[]): void {
const pending = queue.splice(0);
for (const entry of pending) {
entry.controller.abort();
entry.resolve();
}
}
}
/**
* Twilio Media Stream message format.
*/
interface TwilioMediaMessage {
event: "connected" | "start" | "media" | "stop" | "mark" | "clear";
sequenceNumber?: string;
streamSid?: string;
start?: {
streamSid: string;
accountSid: string;
callSid: string;
tracks: string[];
customParameters?: Record<string, string>;
mediaFormat: {
encoding: string;
sampleRate: number;
channels: number;
};
};
media?: {
track?: string;
chunk?: string;
timestamp?: string;
payload?: string;
};
mark?: {
name: string;
};
}

View File

@@ -0,0 +1,100 @@
// Voice Call plugin module implements base behavior.
import type {
AnswerCallInput,
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
PlayTtsInput,
ProviderName,
SendDtmfInput,
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;
setPublicUrl?(url: string): void;
/**
* 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;
/**
* Consume one-time TwiML that must be served before shortcut handlers such as
* realtime media streams take over the webhook response.
*/
consumeInitialTwiML?: (ctx: WebhookContext) => string | null;
/**
* Initiate an outbound call.
* @returns Provider call ID and status
*/
initiateCall(input: InitiateCallInput): Promise<InitiateCallResult>;
/**
* Answer an accepted inbound call when the provider requires an explicit
* answer command after the initial webhook.
*/
answerCall?: (input: AnswerCallInput) => Promise<void>;
/**
* 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>;
/**
* Send DTMF digits to an active call.
*/
sendDtmf?: (input: SendDtmfInput) => Promise<void>;
/**
* Start listening for user speech (activate STT).
*/
startListening(input: StartListeningInput): Promise<void>;
/**
* Stop listening for user speech (deactivate STT).
*/
stopListening(input: StopListeningInput): Promise<void>;
/**
* Query provider for current call status.
* Used to verify persisted calls are still active on restart.
* Must return `isUnknown: true` for transient errors (network, 5xx)
* so the caller can keep the call and rely on timer-based fallback.
*/
getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult>;
}

View File

@@ -0,0 +1,87 @@
// Voice Call tests cover mock plugin behavior.
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 beforeParse = Date.now();
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,
},
],
}),
),
);
const afterParse = Date.now();
const endedTimestamp = result.events[1]?.timestamp;
const speechTimestamp = result.events[2]?.timestamp;
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: endedTimestamp,
reason: "",
},
{
id: "evt-speech",
type: "call.speech",
callId: "call-3",
providerCallId: undefined,
timestamp: speechTimestamp,
transcript: "",
isFinal: false,
confidence: undefined,
},
]);
expect(endedTimestamp).toBeGreaterThanOrEqual(beforeParse);
expect(endedTimestamp).toBeLessThanOrEqual(afterParse);
expect(speechTimestamp).toBeGreaterThanOrEqual(beforeParse);
expect(speechTimestamp).toBeLessThanOrEqual(afterParse);
});
});

View File

@@ -0,0 +1,186 @@
// Voice Call plugin module implements mock behavior.
import crypto from "node:crypto";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
EndReason,
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
NormalizedEvent,
PlayTtsInput,
WebhookParseOptions,
ProviderWebhookParseResult,
SendDtmfInput,
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 sendDtmf(_input: SendDtmfInput): Promise<void> {
// No-op for mock
}
async startListening(_input: StartListeningInput): Promise<void> {
// No-op for mock
}
async stopListening(_input: StopListeningInput): Promise<void> {
// No-op for mock
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
const id = normalizeLowercaseStringOrEmpty(input.providerCallId);
if (id.includes("stale") || id.includes("ended") || id.includes("completed")) {
return { status: "completed", isTerminal: true };
}
return { status: "in-progress", isTerminal: false };
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
// Voice Call plugin module implements call status behavior.
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { EndReason } from "../../types.js";
// Shared provider status normalization and terminal-state mapping.
const TERMINAL_PROVIDER_STATUS_TO_END_REASON: Record<string, EndReason> = {
completed: "completed",
failed: "failed",
busy: "busy",
"no-answer": "no-answer",
canceled: "hangup-bot",
};
/** Normalize provider status text, falling back to "unknown". */
export function normalizeProviderStatus(status: string | null | undefined): string {
const normalized = normalizeOptionalLowercaseString(status);
return normalized && normalized.length > 0 ? normalized : "unknown";
}
/** Map terminal provider status strings to OpenClaw end reasons. */
export function mapProviderStatusToEndReason(status: string | null | undefined): EndReason | null {
const normalized = normalizeProviderStatus(status);
return TERMINAL_PROVIDER_STATUS_TO_END_REASON[normalized] ?? null;
}
/** Return true when a provider status is terminal. */
export function isProviderStatusTerminal(status: string | null | undefined): boolean {
return mapProviderStatusToEndReason(status) !== null;
}

View File

@@ -0,0 +1,206 @@
// Voice Call tests cover guarded json api plugin behavior.
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";
function cancelTrackedTextResponse(
text: string,
init?: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
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();
const missing = cancelTrackedTextResponse("missing", { status: 404 });
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: missing.response,
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();
expect(missing.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledTimes(2);
});
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);
});
it("bounds provider error bodies and cancels unread overflow", async () => {
const release = vi.fn(async () => {});
const tracked = cancelTrackedTextResponse("x".repeat(9 * 1024), { status: 500 });
fetchWithSsrFGuardMock.mockResolvedValue({
response: tracked.response,
release,
});
let caught: Error | undefined;
try {
await guardedJsonApiRequest({
url: "https://api.example.com/v1/calls/3",
method: "DELETE",
headers: {},
allowedHostnames: ["api.example.com"],
auditContext: "voice-call:test",
errorPrefix: "provider error",
});
} catch (error) {
caught = error as Error;
}
expect(caught?.message).toContain("provider error: 500 ");
expect(caught?.message).toContain("... [truncated]");
expect(caught?.message.length).toBeLessThan(8_300);
expect(tracked.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledTimes(1);
});
it("throws prefixed errors for malformed json success responses", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("{not json", { status: 200 }),
release,
});
await expect(
guardedJsonApiRequest({
url: "https://api.example.com/v1/calls/4",
method: "GET",
headers: {},
allowedHostnames: ["api.example.com"],
auditContext: "voice-call:test",
errorPrefix: "provider error",
}),
).rejects.toThrow("provider error: malformed JSON response");
expect(release).toHaveBeenCalledTimes(1);
});
it("rejects oversized json success bodies and cancels unread overflow", async () => {
const release = vi.fn(async () => {});
const tracked = cancelTrackedTextResponse("x".repeat(1024 * 1024 + 1), { status: 200 });
fetchWithSsrFGuardMock.mockResolvedValue({
response: tracked.response,
release,
});
await expect(
guardedJsonApiRequest({
url: "https://api.example.com/v1/calls/5",
method: "GET",
headers: {},
allowedHostnames: ["api.example.com"],
auditContext: "voice-call:test",
errorPrefix: "provider error",
}),
).rejects.toThrow("provider response body too large: 1048577 bytes (limit: 1048576 bytes)");
expect(tracked.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,60 @@
// Voice Call API module exposes the plugin public contract.
import { fetchWithSsrFGuard } from "../../../api.js";
import {
cancelProviderResponseBody,
readProviderErrorResponseSnippet,
readProviderJsonResponseText,
} from "./response-body.js";
// Shared guarded JSON API client for voice-call providers.
/** Parameters for an SSRF-guarded provider JSON request. */
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;
};
/** Send a provider JSON request through the SSRF guard and parse bounded JSON responses. */
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) {
await cancelProviderResponseBody(response);
return undefined as T;
}
const errorText = await readProviderErrorResponseSnippet(response);
throw new Error(`${params.errorPrefix}: ${response.status} ${errorText}`);
}
const text = await readProviderJsonResponseText(response);
if (!text) {
return undefined as T;
}
try {
return JSON.parse(text) as T;
} catch {
throw new Error(`${params.errorPrefix}: malformed JSON response`);
}
} finally {
await release();
}
}

View File

@@ -0,0 +1,83 @@
// Voice Call provider HTTP clients share bounded response body readers.
const PROVIDER_JSON_RESPONSE_MAX_BYTES = 1 * 1024 * 1024;
const PROVIDER_ERROR_RESPONSE_MAX_BYTES = 8 * 1024;
const TRUNCATED_SUFFIX = "... [truncated]";
type ReadProviderResponseTextParams = {
response: Response;
maxBytes: number;
truncateOnLimit?: boolean;
};
export async function cancelProviderResponseBody(response: Response): Promise<void> {
await response.body?.cancel().catch(() => undefined);
}
function appendTruncatedSuffix(text: string): string {
return `${text.trimEnd()}${TRUNCATED_SUFFIX}`;
}
async function readProviderResponseTextWithLimit(
params: ReadProviderResponseTextParams,
): Promise<string> {
if (!params.response.body) {
return "";
}
const reader = params.response.body.getReader();
const decoder = new TextDecoder();
let totalBytes = 0;
let text = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
return text + decoder.decode();
}
if (!value?.byteLength) {
continue;
}
const remainingBytes = params.maxBytes - totalBytes;
if (value.byteLength > remainingBytes) {
if (params.truncateOnLimit) {
const clipped = remainingBytes > 0 ? value.slice(0, remainingBytes) : undefined;
if (clipped) {
text += decoder.decode(clipped, { stream: true });
}
await reader.cancel().catch(() => undefined);
return appendTruncatedSuffix(text + decoder.decode());
}
await reader.cancel().catch(() => undefined);
throw new Error(
`provider response body too large: ${totalBytes + value.byteLength} bytes ` +
`(limit: ${params.maxBytes} bytes)`,
);
}
text += decoder.decode(value, { stream: true });
totalBytes += value.byteLength;
}
} finally {
try {
reader.releaseLock();
} catch {}
}
}
export async function readProviderJsonResponseText(response: Response): Promise<string> {
return await readProviderResponseTextWithLimit({
response,
maxBytes: PROVIDER_JSON_RESPONSE_MAX_BYTES,
});
}
export async function readProviderErrorResponseSnippet(response: Response): Promise<string> {
return await readProviderResponseTextWithLimit({
response,
maxBytes: PROVIDER_ERROR_RESPONSE_MAX_BYTES,
truncateOnLimit: true,
});
}

View File

@@ -0,0 +1,490 @@
// Voice Call tests cover telnyx plugin behavior.
import crypto from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WebhookContext } from "../types.js";
import { TelnyxProvider } from "./telnyx.js";
const apiMocks = vi.hoisted(() => ({
fetchWithSsrFGuard: vi.fn(),
}));
vi.mock("../../api.js", () => ({
fetchWithSsrFGuard: apiMocks.fetchWithSsrFGuard,
}));
afterEach(() => {
apiMocks.fetchWithSsrFGuard.mockReset();
});
function createCtx(params?: Partial<WebhookContext>): WebhookContext {
return {
headers: {},
rawBody: "{}",
url: "http://localhost/voice/webhook",
method: "POST",
query: {},
remoteAddress: "127.0.0.1",
...params,
};
}
function requireFetchRequest() {
const [call] = apiMocks.fetchWithSsrFGuard.mock.calls;
if (!call) {
throw new Error("expected Telnyx provider to call fetchWithSsrFGuard");
}
const [request] = call;
if (!request || typeof request !== "object" || Array.isArray(request)) {
throw new Error("expected Telnyx provider to call fetchWithSsrFGuard");
}
return request as {
url?: string;
auditContext?: string;
policy?: unknown;
init?: {
method?: string;
body?: unknown;
};
};
}
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");
});
it("maps call direction and phone numbers from Call Control callbacks", () => {
const provider = new TelnyxProvider({
apiKey: "KEY123",
connectionId: "CONN456",
publicKey: undefined,
});
const result = provider.parseWebhookEvent(
createCtx({
rawBody: JSON.stringify({
data: {
id: "evt-inbound",
event_type: "call.initiated",
payload: {
call_control_id: "call-1",
direction: "incoming",
from: "+15551111111",
to: "+15550000000",
},
},
}),
}),
);
expect(result.events).toHaveLength(1);
const event = result.events[0];
expect(event?.type).toBe("call.initiated");
expect(event?.direction).toBe("inbound");
expect(event?.from).toBe("+15551111111");
expect(event?.to).toBe("+15550000000");
});
it("uses raw client_state fallback when client_state is malformed base64", () => {
const provider = new TelnyxProvider({
apiKey: "KEY123",
connectionId: "CONN456",
publicKey: undefined,
});
const result = provider.parseWebhookEvent(
createCtx({
rawBody: JSON.stringify({
data: {
id: "evt-client-state",
event_type: "call.initiated",
payload: {
call_control_id: "call-fallback",
client_state: "call-1@@@",
},
},
}),
}),
);
expect(result.events).toHaveLength(1);
expect(result.events[0]?.callId).toBe("call-1@@@");
});
it("reads transcription text from Telnyx transcription_data payloads", () => {
const provider = new TelnyxProvider({
apiKey: "KEY123",
connectionId: "CONN456",
publicKey: undefined,
});
const result = provider.parseWebhookEvent(
createCtx({
rawBody: JSON.stringify({
data: {
id: "evt-transcription",
event_type: "call.transcription",
payload: {
call_control_id: "call-1",
transcription_data: {
transcript: "hello this is a test speech",
is_final: false,
confidence: 0.977219,
},
},
},
}),
}),
);
expect(result.events).toHaveLength(1);
const event = result.events[0];
expect(event?.type).toBe("call.speech");
if (event?.type !== "call.speech") {
throw new Error("expected Telnyx transcription callback to produce a speech event");
}
expect(event?.transcript).toBe("hello this is a test speech");
expect(event?.isFinal).toBe(false);
expect(event?.confidence).toBe(0.977219);
});
});
describe("TelnyxProvider answer control", () => {
it("answers inbound call-control legs with a deterministic command id", async () => {
const release = vi.fn(async () => {});
apiMocks.fetchWithSsrFGuard.mockResolvedValue({
response: new Response(JSON.stringify({ data: {} }), { status: 200 }),
release,
});
const provider = new TelnyxProvider({
apiKey: "KEY123",
connectionId: "CONN456",
publicKey: undefined,
});
await provider.answerCall({
callId: "call-1",
providerCallId: "call-control-1",
});
expect(apiMocks.fetchWithSsrFGuard).toHaveBeenCalledTimes(1);
const request = requireFetchRequest();
expect(request.url).toBe("https://api.telnyx.com/v2/calls/call-control-1/actions/answer");
expect(request.auditContext).toBe("voice-call.telnyx.api");
expect(request.policy).toEqual({ allowedHostnames: ["api.telnyx.com"] });
expect(request.init?.method).toBe("POST");
expect(request.init?.body).toBe(JSON.stringify({ command_id: "openclaw-answer-call-1" }));
expect(release).toHaveBeenCalledTimes(1);
});
});
describe("TelnyxProvider Media Streaming (PCMU)", () => {
it("embeds streaming fields in the dial payload when streamUrl is provided", async () => {
const release = vi.fn(async () => {});
apiMocks.fetchWithSsrFGuard.mockResolvedValue({
response: new Response(JSON.stringify({ data: { call_control_id: "call-control-1" } }), {
status: 200,
}),
release,
});
const provider = new TelnyxProvider({
apiKey: "KEY123",
connectionId: "CONN456",
publicKey: undefined,
});
await provider.initiateCall({
callId: "call-1",
from: "+15550000001",
to: "+15550000002",
webhookUrl: "https://example.test/voice/webhook",
streamUrl: "wss://example.test/voice/stream/realtime/token-xyz",
streamAuthToken: "token-xyz",
});
const request = requireFetchRequest();
const body = JSON.parse(request.init?.body as string) as Record<string, unknown>;
expect(body.stream_url).toBe("wss://example.test/voice/stream/realtime/token-xyz");
expect(body.stream_track).toBe("inbound_track");
expect(body.stream_codec).toBe("PCMU");
expect(body.stream_bidirectional_mode).toBe("rtp");
expect(body.stream_bidirectional_codec).toBe("PCMU");
expect(body.stream_bidirectional_sampling_rate).toBe(8000);
expect(body.stream_bidirectional_target_legs).toBe("self");
expect(body.stream_auth_token).toBe("token-xyz");
});
it("omits streaming fields from the dial payload when streamUrl is absent", async () => {
apiMocks.fetchWithSsrFGuard.mockResolvedValue({
response: new Response(JSON.stringify({ data: { call_control_id: "call-control-1" } }), {
status: 200,
}),
release: vi.fn(async () => {}),
});
const provider = new TelnyxProvider({
apiKey: "KEY123",
connectionId: "CONN456",
publicKey: undefined,
});
await provider.initiateCall({
callId: "call-1",
from: "+15550000001",
to: "+15550000002",
webhookUrl: "https://example.test/voice/webhook",
});
const body = JSON.parse(requireFetchRequest().init?.body as string) as Record<string, unknown>;
expect(body.stream_url).toBeUndefined();
expect(body.stream_codec).toBeUndefined();
expect(body.stream_bidirectional_codec).toBeUndefined();
expect(body.stream_auth_token).toBeUndefined();
});
it("embeds streaming fields in the answer action when streamUrl is provided", async () => {
apiMocks.fetchWithSsrFGuard.mockResolvedValue({
response: new Response(JSON.stringify({ data: {} }), { status: 200 }),
release: vi.fn(async () => {}),
});
const provider = new TelnyxProvider({
apiKey: "KEY123",
connectionId: "CONN456",
publicKey: undefined,
});
await provider.answerCall({
callId: "call-1",
providerCallId: "call-control-1",
streamUrl: "wss://example.test/voice/stream/realtime/token-xyz",
streamAuthToken: "token-xyz",
});
const body = JSON.parse(requireFetchRequest().init?.body as string) as Record<string, unknown>;
expect(body.command_id).toBe("openclaw-answer-call-1");
expect(body.stream_url).toBe("wss://example.test/voice/stream/realtime/token-xyz");
expect(body.stream_codec).toBe("PCMU");
expect(body.stream_bidirectional_target_legs).toBe("self");
expect(body.stream_auth_token).toBe("token-xyz");
});
it("silently acknowledges streaming.started and streaming.stopped webhooks", () => {
const provider = new TelnyxProvider(
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: undefined },
{ skipVerification: true },
);
// Telnyx documents stream lifecycle webhooks as `streaming.started` and
// `streaming.stopped` (no `call.` prefix). The bridge tracks its own
// lifecycle on the WebSocket; we ack the carrier webhook with 200 and
// emit nothing to avoid duplicate signal at the manager.
for (const eventType of ["streaming.started", "streaming.stopped"]) {
const rawBody = JSON.stringify({
data: {
event_type: eventType,
id: `evt-${eventType}`,
payload: { call_control_id: "call-control-1" },
},
});
const result = provider.parseWebhookEvent(createCtx({ rawBody }), {
verifiedRequestKey: "key-1",
});
expect(result.events).toHaveLength(0);
expect(result.statusCode).toBe(200);
}
});
});
describe("TelnyxProvider speak control", () => {
it("passes custom Telnyx voice ids to the speak action", async () => {
const release = vi.fn(async () => {});
apiMocks.fetchWithSsrFGuard.mockResolvedValue({
response: new Response(JSON.stringify({ data: {} }), { status: 200 }),
release,
});
const provider = new TelnyxProvider({
apiKey: "KEY123",
connectionId: "CONN456",
publicKey: undefined,
});
await provider.playTts({
callId: "call-1",
providerCallId: "call-control-1",
text: "hello",
voice: "Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc",
});
expect(apiMocks.fetchWithSsrFGuard).toHaveBeenCalledTimes(1);
const request = requireFetchRequest();
expect(request.url).toBe("https://api.telnyx.com/v2/calls/call-control-1/actions/speak");
expect(request.auditContext).toBe("voice-call.telnyx.api");
expect(request.policy).toEqual({ allowedHostnames: ["api.telnyx.com"] });
expect(request.init?.method).toBe("POST");
expect(typeof request.init?.body).toBe("string");
const body = JSON.parse(request.init?.body as string) as { voice?: string };
expect(body.voice).toBe("Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc");
expect(release).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,420 @@
// Voice Call plugin module implements telnyx behavior.
import crypto from "node:crypto";
import type { TelnyxConfig } from "../config.js";
import type {
AnswerCallInput,
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;
}
function normalizeTelnyxDirection(
direction: string | undefined,
): "inbound" | "outbound" | undefined {
switch (direction) {
case "incoming":
case "inbound":
return "inbound";
case "outgoing":
case "outbound":
return "outbound";
default:
return undefined;
}
}
function normalizeBase64ForCompare(value: string): string {
return value.replace(/=+$/u, "").replace(/-/gu, "+").replace(/_/gu, "/");
}
function decodeClientStateBase64(value: string): string | null {
const buffer = Buffer.from(value, "base64");
if (normalizeBase64ForCompare(buffer.toString("base64")) !== normalizeBase64ForCompare(value)) {
return null;
}
return buffer.toString("utf8");
}
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) {
callId = decodeClientStateBase64(data.payload.client_state) ?? 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(),
direction: normalizeTelnyxDirection(data.payload?.direction),
from: data.payload?.from,
to: data.payload?.to,
};
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_data?.transcript ?? data.payload?.transcription ?? "",
isFinal: data.payload?.transcription_data?.is_final ?? data.payload?.is_final ?? true,
confidence: data.payload?.transcription_data?.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 || "",
};
case "streaming.started":
case "streaming.stopped":
return null;
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";
}
}
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
const body: Record<string, unknown> = {
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,
...(input.streamUrl
? buildTelnyxStreamingFields(input.streamUrl, input.streamAuthToken)
: {}),
};
const result = await this.apiRequest<TelnyxCallResponse>("/calls", body);
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 },
);
}
async answerCall(input: AnswerCallInput): Promise<void> {
const body: Record<string, unknown> = {
command_id: `openclaw-answer-${input.callId}`,
...(input.streamUrl
? buildTelnyxStreamingFields(input.streamUrl, input.streamAuthToken)
: {}),
};
await this.apiRequest(`/calls/${input.providerCallId}/actions/answer`, body);
}
/**
* 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 };
}
}
}
function buildTelnyxStreamingFields(
streamUrl: string,
streamAuthToken: string | undefined,
): Record<string, unknown> {
return {
stream_url: streamUrl,
stream_track: "inbound_track",
stream_codec: "PCMU",
stream_bidirectional_mode: "rtp",
stream_bidirectional_codec: "PCMU",
stream_bidirectional_sampling_rate: 8000,
stream_bidirectional_target_legs: "self",
...(streamAuthToken ? { stream_auth_token: streamAuthToken } : {}),
};
}
interface TelnyxEvent {
id?: string;
event_type: string;
payload?: {
call_control_id?: string;
client_state?: string;
direction?: string;
from?: string;
to?: string;
text?: string;
transcription?: string;
is_final?: boolean;
confidence?: number;
transcription_data?: {
transcript?: string;
is_final?: boolean;
confidence?: number;
};
hangup_cause?: string;
digit?: string;
[key: string]: unknown;
};
}
interface TelnyxCallResponse {
data: {
call_control_id: string;
call_leg_id: string;
call_session_id: string;
is_alive: boolean;
record_type: string;
};
}

View File

@@ -0,0 +1,653 @@
// Voice Call tests cover twilio plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WebhookContext } from "../types.js";
import { TwilioProvider } from "./twilio.js";
import { TwilioApiError } from "./twilio/api.js";
const STREAM_URL = "wss://example.ngrok.app/voice/stream";
beforeEach(() => {
vi.useRealTimers();
});
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(impl?: TwilioApiRequest) {
return vi.fn<TwilioApiRequest>(impl ?? (async () => ({})));
}
function requireApiRequestCall(
apiRequest: ReturnType<typeof createApiRequestMock>,
index = 0,
): Parameters<TwilioApiRequest> {
const call = apiRequest.mock.calls[index];
if (!call) {
throw new Error(`expected Twilio API request call ${index}`);
}
return call;
}
function expectApiRequestEndpoint(
apiRequest: ReturnType<typeof createApiRequestMock>,
index: number,
endpoint: string,
): void {
const [actualEndpoint] = requireApiRequestCall(apiRequest, index);
expect(actualEndpoint).toBe(endpoint);
}
function createTwilioCallStateRaceError(): TwilioApiError {
return new TwilioApiError(
400,
JSON.stringify({
code: 21220,
message: "Call is not in-progress. Cannot redirect.",
}),
);
}
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("sends direct initial TwiML for notify-mode outbound calls", async () => {
const provider = createProvider();
const apiRequest = createApiRequestMock(async () => ({ sid: "CA123", status: "queued" }));
(
provider as unknown as {
apiRequest: TwilioApiRequest;
}
).apiRequest = apiRequest;
const result = await provider.initiateCall({
callId: "call-1",
from: "+14155550100",
to: "+14155550123",
webhookUrl: "https://example.ngrok.app/voice/webhook",
inlineTwiml: "<Response><Say>Hello</Say></Response>",
});
expect(result).toEqual({ providerCallId: "CA123", status: "queued" });
expect(apiRequest).toHaveBeenCalledTimes(1);
const [endpoint, params] = requireApiRequestCall(apiRequest);
expect(endpoint).toBe("/Calls.json");
expect(params.To).toBe("+14155550123");
expect(params.From).toBe("+14155550100");
expect(params.Twiml).toBe("<Response><Say>Hello</Say></Response>");
expect(params.StatusCallback).toBe(
"https://example.ngrok.app/voice/webhook?callId=call-1&type=status",
);
expect(params.StatusCallbackEvent).toEqual(["initiated", "ringing", "answered", "completed"]);
expect(params).not.toHaveProperty("Url");
});
it("uses the webhook URL for conversation outbound calls", async () => {
const provider = createProvider();
const apiRequest = createApiRequestMock(async () => ({ sid: "CA123", status: "queued" }));
(
provider as unknown as {
apiRequest: TwilioApiRequest;
}
).apiRequest = apiRequest;
await provider.initiateCall({
callId: "call-1",
from: "+14155550100",
to: "+14155550123",
webhookUrl: "https://example.ngrok.app/voice/webhook",
});
expect(apiRequest).toHaveBeenCalledTimes(1);
const [endpoint, params] = requireApiRequestCall(apiRequest);
expect(endpoint).toBe("/Calls.json");
expect(params.Url).toBe("https://example.ngrok.app/voice/webhook?callId=call-1");
expect(params.StatusCallback).toBe(
"https://example.ngrok.app/voice/webhook?callId=call-1&type=status",
);
expect(params).not.toHaveProperty("Twiml");
});
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("serves pre-connect TwiML once before outbound streaming starts", async () => {
const provider = createProvider();
(
provider as unknown as {
apiRequest: TwilioApiRequest;
}
).apiRequest = vi.fn<TwilioApiRequest>(async () => ({
sid: "CA999",
status: "queued",
}));
const preConnectTwiml = '<Response><Play digits="ww123456#" /></Response>';
await provider.initiateCall({
callId: "call-1",
from: "+15550000001",
to: "+15550000002",
webhookUrl: "https://example.ngrok.app/voice/twilio",
preConnectTwiml,
});
const first = provider.parseWebhookEvent(
createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA999", {
callId: "call-1",
}),
);
expect(requireResponseBody(first.providerResponseBody)).toBe(preConnectTwiml);
const second = provider.parseWebhookEvent(
createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA999", {
callId: "call-1",
}),
);
expectStreamingTwiml(requireResponseBody(second.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);
// Simulate the stream actually connecting (the bug: without this, no activeStreamCalls entry exists)
provider.registerCallStream("CA111", "MZ111");
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.registerCallStream("CA311", "MZ311");
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.registerCallStream("CA411", "MZ411");
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.registerCallStream("CA511", "MZ511");
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);
provider.registerCallStream("CA611", "MZ611");
const result = provider.parseWebhookEvent(secondInbound);
expect(requireResponseBody(result.providerResponseBody)).toContain(
'waitUrl="/voice/hold-music"',
);
});
it("does not block subsequent call when first call never opens a media stream", () => {
const provider = createProvider();
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA711");
const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA722");
// First call gets streaming TwiML but never connects a media stream
// (no registerCallStream ever fires for CA711)
provider.parseWebhookEvent(firstInbound);
// Second inbound call should NOT be queued — no active stream is registered
const secondResult = provider.parseWebhookEvent(secondInbound);
const secondBody = requireResponseBody(secondResult.providerResponseBody);
expectStreamingTwiml(secondBody);
expect(secondBody).not.toContain("hold-queue");
});
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("does not coerce partial Twilio speech confidence values", () => {
const provider = createProvider();
const ctx = createContext("CallSid=CA223&Direction=inbound&SpeechResult=hello&Confidence=0.2x");
const event = provider.parseWebhookEvent(ctx).events[0];
const parsed = requireEvent(event, "expected speech event from Twilio webhook");
if (parsed.type !== "call.speech") {
throw new Error("expected speech event from Twilio webhook");
}
expect(parsed.confidence).toBe(0.9);
});
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 [endpoint, params] = requireApiRequestCall(apiRequest) as [string, { Twiml?: string }];
expect(endpoint).toBe("/Calls/CA-nostream.json");
expect(params.Twiml).toContain("<Say");
});
it("retries TwiML fallback when Twilio briefly rejects a live-call update as not in progress", async () => {
vi.useFakeTimers();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const { provider, apiRequest } = configureTelephonyTwiMlFallback({
providerCallId: "CA-race-play",
});
apiRequest.mockRejectedValueOnce(createTwilioCallStateRaceError()).mockResolvedValueOnce({});
const playback = provider.playTts({
callId: "call-race-play",
providerCallId: "CA-race-play",
text: "Hello after race",
});
await Promise.resolve();
expect(apiRequest).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(250);
await expect(playback).resolves.toBeUndefined();
expect(apiRequest).toHaveBeenCalledTimes(2);
expectApiRequestEndpoint(apiRequest, 0, "/Calls/CA-race-play.json");
expectApiRequestEndpoint(apiRequest, 1, "/Calls/CA-race-play.json");
expect(warn).toHaveBeenCalledWith(
"[voice-call] Twilio playTts update hit call state race (21220); retrying in 250ms",
);
} finally {
warn.mockRestore();
vi.useRealTimers();
}
});
it("sends DTMF by updating the call and redirecting back to the webhook", async () => {
const { provider, apiRequest } = configureTelephonyTwiMlFallback({
providerCallId: "CA-dtmf",
});
await expect(
provider.sendDtmf({
callId: "call-dtmf",
providerCallId: "CA-dtmf",
digits: "ww123#",
}),
).resolves.toBeUndefined();
expect(apiRequest).toHaveBeenCalledTimes(1);
const [endpoint, params] = requireApiRequestCall(apiRequest) as [string, { Twiml?: string }];
expect(endpoint).toBe("/Calls/CA-dtmf.json");
expect(params.Twiml).toContain('<Play digits="ww123#"');
expect(params.Twiml).toContain("<Redirect");
expect(params.Twiml).toContain("https://example.ngrok.app/voice/twilio");
});
it("retries startListening when Twilio briefly rejects a live-call update as not in progress", async () => {
vi.useFakeTimers();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const { provider, apiRequest } = configureTelephonyTwiMlFallback({
providerCallId: "CA-race-listen",
});
apiRequest.mockRejectedValueOnce(createTwilioCallStateRaceError()).mockResolvedValueOnce({});
const listening = provider.startListening({
callId: "call-race-listen",
providerCallId: "CA-race-listen",
});
await Promise.resolve();
expect(apiRequest).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(250);
await expect(listening).resolves.toBeUndefined();
expect(apiRequest).toHaveBeenCalledTimes(2);
expectApiRequestEndpoint(apiRequest, 0, "/Calls/CA-race-listen.json");
expectApiRequestEndpoint(apiRequest, 1, "/Calls/CA-race-listen.json");
expect(warn).toHaveBeenCalledWith(
"[voice-call] Twilio startListening update hit call state race (21220); retrying in 250ms",
);
} finally {
warn.mockRestore();
vi.useRealTimers();
}
});
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({
synthesisTimeoutMs: 5000,
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 after 5000ms");
await vi.advanceTimersByTimeAsync(5_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 () => {
vi.useFakeTimers();
try {
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({
synthesisTimeoutMs: 5000,
synthesizeForTelephony: async () => Buffer.alloc(320),
});
const playback = provider.playTts({
callId: "call-dropped",
providerCallId: "CA-dropped",
text: "Dropped audio",
});
const playExpectation = expect(playback).rejects.toThrow("Telephony stream playback failed");
await vi.advanceTimersByTimeAsync(100);
await playExpectation;
expect(sendAudio).toHaveBeenCalled();
expect(sendMark).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("fails stream playback when telephony synthesis returns empty audio", async () => {
const provider = createProvider();
provider.registerCallStream("CA-empty", "MZ-empty");
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({
synthesisTimeoutMs: 5000,
synthesizeForTelephony: async () => Buffer.alloc(0),
});
await expect(
provider.playTts({
callId: "call-empty",
providerCallId: "CA-empty",
text: "Empty audio",
}),
).rejects.toThrow("Telephony TTS produced no audio");
expect(sendAudio).toHaveBeenCalled();
expect(sendMark).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,862 @@
// Voice Call plugin module implements twilio behavior.
import crypto from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
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,
SendDtmfInput,
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 { TwilioApiError, 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";
const TWILIO_CALL_NOT_IN_PROGRESS_CODE = 21220;
const TWILIO_CALL_UPDATE_RETRY_DELAYS_MS = [250, 750] as const;
function isTwilioCallNotInProgressError(err: unknown): boolean {
return err instanceof TwilioApiError && err.twilioCode === TWILIO_CALL_NOT_IN_PROGRESS_CODE;
}
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;
};
type TwilioProviderConfig = {
accountSid?: string;
authToken?: string;
};
export class TwilioProvider implements VoiceCallProvider {
readonly name = "twilio" as const;
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: TwilioProviderConfig, 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;
}
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);
this.activeStreamCalls.add(callSid);
}
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,
});
}
private async updateLiveCallTwiml(
providerCallId: string,
twiml: string,
operation: string,
): Promise<void> {
for (const retryDelayMs of TWILIO_CALL_UPDATE_RETRY_DELAYS_MS) {
try {
await this.apiRequest(`/Calls/${providerCallId}.json`, { Twiml: twiml });
return;
} catch (err) {
if (!isTwilioCallNotInProgressError(err)) {
throw err;
}
console.warn(
`[voice-call] Twilio ${operation} update hit call state race (21220); retrying in ${retryDelayMs}ms`,
);
await sleep(retryDelayMs);
}
}
await this.apiRequest(`/Calls/${providerCallId}.json`, { Twiml: twiml });
}
/**
* 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;
}
private static parseConfidence(value: string | null): number {
const trimmed = value?.trim();
if (!trimmed || !/^\d+(?:\.\d+)?$/.test(trimmed)) {
return 0.9;
}
return Number(trimmed);
}
/**
* 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: TwilioProvider.parseConfidence(params.get("Confidence")),
};
}
// 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);
}
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;
}
default:
return TwilioProvider.EMPTY_TWIML;
}
}
consumeInitialTwiML(ctx: WebhookContext): string | null {
const view = readTwimlRequestView(ctx);
if (!view.callIdFromQuery || view.isStatusCallback) {
return null;
}
const storedTwiml = this.twimlStorage.get(view.callIdFromQuery);
if (!storedTwiml) {
return null;
}
const kind = this.notifyCalls.has(view.callIdFromQuery) ? "notify" : "pre-connect";
this.deleteStoredTwiml(view.callIdFromQuery);
console.log(
`[voice-call] Twilio initial TwiML consumed for call ${view.callIdFromQuery} (kind=${kind}, callSid=${view.callSid ?? "unknown"})`,
);
return storedTwiml;
}
/**
* 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 preConnectTwiml is provided, the first webhook request receives that
* TwiML before normal dynamic TwiML resumes.
*/
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
if (!input.inlineTwiml && input.preConnectTwiml) {
this.twimlStorage.set(input.callId, input.preConnectTwiml);
console.log(
`[voice-call] Stored Twilio initial TwiML for call ${input.callId} (kind=pre-connect)`,
);
}
const params: Record<string, string | string[]> = {
To: input.to,
From: input.from,
StatusCallback: statusUrl.toString(),
StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"],
Timeout: "30",
};
if (input.inlineTwiml) {
params.Twiml = input.inlineTwiml;
console.log(
`[voice-call] Sending direct Twilio initial TwiML for call ${input.callId} (kind=notify)`,
);
} else {
params.Url = url.toString();
}
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.updateLiveCallTwiml(input.providerCallId, twiml, "playTts");
}
async sendDtmf(input: SendDtmfInput): 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 twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Play digits="${escapeXml(input.digits)}" />
<Redirect method="POST">${escapeXml(webhookUrl)}</Redirect>
</Response>`;
await this.updateLiveCallTwiml(input.providerCallId, twiml, "sendDtmf");
}
/**
* 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;
const synthTimeoutMs = ttsProvider.synthesisTimeoutMs;
try {
const synthPromise = ttsProvider.synthesizeForTelephony(text);
const timeoutPromise = new Promise<Buffer>((_, reject) => {
synthTimeout = setTimeout(() => {
reject(new Error(`Telephony TTS synthesis timed out after ${synthTimeoutMs}ms`));
}, synthTimeoutMs);
});
muLawAudio = await Promise.race([synthPromise, timeoutPromise]);
} finally {
if (synthTimeout) {
clearTimeout(synthTimeout);
}
clearInterval(keepAlive);
}
if (muLawAudio.length === 0) {
throw new Error("Telephony TTS produced no audio");
}
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.updateLiveCallTwiml(input.providerCallId, twiml, "startListening");
}
/**
* Stop listening - for Twilio this is a no-op as <Gather> auto-ends.
*/
async stopListening(_input: StopListeningInput): Promise<void> {
// Twilio's <Gather> automatically stops on speech end
// No explicit action needed
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
try {
const data = await guardedJsonApiRequest<{ status?: string }>({
url: `${this.baseUrl}/Calls/${input.providerCallId}.json`,
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString("base64")}`,
},
allowNotFound: true,
allowedHostnames: ["api.twilio.com"],
auditContext: "twilio-get-call-status",
errorPrefix: "Twilio get call status error",
});
if (!data) {
return { status: "not-found", isTerminal: true };
}
const status = normalizeProviderStatus(data.status);
return { status, isTerminal: isProviderStatusTerminal(status) };
} catch {
// Transient error — keep the call and rely on timer fallback
return { status: "error", isTerminal: false, isUnknown: true };
}
}
}
// -----------------------------------------------------------------------------
// Twilio-specific types
// -----------------------------------------------------------------------------
interface TwilioCallResponse {
sid: string;
status: string;
direction: string;
from: string;
to: string;
uri: string;
}

View File

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

View File

@@ -0,0 +1,234 @@
// Voice Call tests cover api plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
}));
vi.mock("../../../api.js", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
import { TwilioApiError, twilioApiRequest } from "./api.js";
type FetchGuardRequest = {
url?: string;
init?: RequestInit;
auditContext?: string;
policy?: unknown;
timeoutMs?: number;
};
function requireFirstFetchGuardRequest(): FetchGuardRequest {
const [call] = fetchWithSsrFGuardMock.mock.calls;
if (!call) {
throw new Error("expected guarded fetch call");
}
const [request] = call;
if (!request || typeof request !== "object" || Array.isArray(request)) {
throw new Error("expected guarded fetch request");
}
return request as FetchGuardRequest;
}
function cancelTrackedTextResponse(
text: string,
init?: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
describe("twilioApiRequest", () => {
afterEach(() => {
fetchWithSsrFGuardMock.mockReset();
});
it("posts form bodies with basic auth and parses json", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(JSON.stringify({ sid: "CA123" }), { status: 200 }),
release,
});
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, auditContext, policy, timeoutMs } = requireFirstFetchGuardRequest();
expect(url).toBe("https://api.twilio.com/Calls.json");
expect(auditContext).toBe("voice-call.twilio.api");
expect(policy).toEqual({ allowedHostnames: ["api.twilio.com"] });
expect(timeoutMs).toBe(30_000);
expect(init?.method).toBe("POST");
expect(init?.headers).toEqual({
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",
);
expect(release).toHaveBeenCalledTimes(1);
});
it("passes through URLSearchParams, allows 404s, and returns undefined for empty bodies", async () => {
const missing = cancelTrackedTextResponse("missing", { status: 404 });
const responses = [new Response(null, { status: 204 }), missing.response];
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockImplementation(async () => ({
response: responses.shift()!,
release,
}));
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();
expect(missing.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledTimes(2);
});
it("throws twilio api errors for non-ok responses", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("bad request", { status: 400 }),
release,
});
await expect(
twilioApiRequest({
baseUrl: "https://api.twilio.com",
accountSid: "AC123",
authToken: "secret",
endpoint: "/Calls.json",
body: {},
}),
).rejects.toThrow("Twilio API error: 400 bad request");
expect(release).toHaveBeenCalledTimes(1);
});
it("bounds twilio error bodies and cancels unread overflow", async () => {
const release = vi.fn(async () => {});
const tracked = cancelTrackedTextResponse("x".repeat(9 * 1024), { status: 400 });
fetchWithSsrFGuardMock.mockResolvedValue({
response: tracked.response,
release,
});
try {
await twilioApiRequest({
baseUrl: "https://api.twilio.com",
accountSid: "AC123",
authToken: "secret",
endpoint: "/Calls.json",
body: {},
});
throw new Error("expected Twilio API request to reject");
} catch (error) {
expect(error).toBeInstanceOf(TwilioApiError);
const twilioError = error as TwilioApiError;
expect(twilioError.message).toContain("Twilio API error: 400 ");
expect(twilioError.message).toContain("... [truncated]");
expect(twilioError.responseText.length).toBeLessThan(8_300);
}
expect(tracked.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledTimes(1);
});
it("wraps malformed json success responses with an owned error", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("{not json", { status: 200 }),
release,
});
await expect(
twilioApiRequest({
baseUrl: "https://api.twilio.com",
accountSid: "AC123",
authToken: "secret",
endpoint: "/Calls.json",
body: {},
}),
).rejects.toThrow("Twilio API returned malformed JSON.");
expect(release).toHaveBeenCalledTimes(1);
});
it("exposes structured Twilio error codes from json error bodies", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
JSON.stringify({
code: 21220,
message: "Call is not in-progress. Cannot redirect.",
}),
{ status: 400 },
),
release,
});
try {
await twilioApiRequest({
baseUrl: "https://api.twilio.com",
accountSid: "AC123",
authToken: "secret",
endpoint: "/Calls/CA123.json",
body: {},
});
throw new Error("expected Twilio API request to reject");
} catch (error) {
expect(error).toBeInstanceOf(TwilioApiError);
const twilioError = error as TwilioApiError;
expect(twilioError.name).toBe("TwilioApiError");
expect(twilioError.httpStatus).toBe(400);
expect(twilioError.twilioCode).toBe(21220);
expect(twilioError.message).toBe(
"Twilio API error: 400 Call is not in-progress. Cannot redirect.",
);
}
expect(release).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,113 @@
// Voice Call API module exposes the plugin public contract.
import { fetchWithSsrFGuard } from "../../../api.js";
import {
cancelProviderResponseBody,
readProviderErrorResponseSnippet,
readProviderJsonResponseText,
} from "../shared/response-body.js";
// Guarded Twilio REST API client helpers.
/** Minimal Twilio REST API error payload. */
type ParsedTwilioApiError = {
code?: number;
message?: string;
};
const TWILIO_API_TIMEOUT_MS = 30_000;
/** Parse Twilio JSON error responses without trusting response shape. */
function parseTwilioApiError(text: string): ParsedTwilioApiError {
try {
const parsed: unknown = JSON.parse(text);
if (!parsed || typeof parsed !== "object") {
return {};
}
const record = parsed as Record<string, unknown>;
return {
code: typeof record.code === "number" ? record.code : undefined,
message: typeof record.message === "string" ? record.message : undefined,
};
} catch {
return {};
}
}
/** Error thrown for non-2xx Twilio REST API responses. */
export class TwilioApiError extends Error {
readonly httpStatus: number;
readonly responseText: string;
readonly twilioCode?: number;
constructor(httpStatus: number, responseText: string) {
const parsed = parseTwilioApiError(responseText);
const detail = parsed.message ?? responseText;
super(`Twilio API error: ${httpStatus} ${detail}`);
this.name = "TwilioApiError";
this.httpStatus = httpStatus;
this.responseText = responseText;
this.twilioCode = parsed.code;
}
}
/** POST a form-encoded Twilio REST API request through the SSRF guard. */
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 requestUrl = `${params.baseUrl}${params.endpoint}`;
const { response, release } = await fetchWithSsrFGuard({
url: requestUrl,
init: {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: bodyParams,
},
policy: { allowedHostnames: ["api.twilio.com"] },
timeoutMs: TWILIO_API_TIMEOUT_MS,
auditContext: "voice-call.twilio.api",
});
try {
if (!response.ok) {
if (params.allowNotFound && response.status === 404) {
await cancelProviderResponseBody(response);
return undefined as T;
}
const errorText = await readProviderErrorResponseSnippet(response);
throw new TwilioApiError(response.status, errorText);
}
const text = await readProviderJsonResponseText(response);
if (!text) {
return undefined as T;
}
try {
return JSON.parse(text) as T;
} catch {
throw new Error("Twilio API returned malformed JSON.");
}
} finally {
await release();
}
}

View File

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

View File

@@ -0,0 +1,96 @@
// Voice Call plugin module implements twiml policy behavior.
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { WebhookContext } from "../../types.js";
// Twilio webhook policy for deciding whether to stream, pause, queue, or serve stored TwiML.
/** Normalized Twilio webhook request fields used by TwiML policy. */
type TwimlRequestView = {
callStatus: string | null;
direction: string | null;
isStatusCallback: boolean;
callSid?: string;
callIdFromQuery?: string;
};
/** Full TwiML policy input including manager/runtime state. */
type TwimlPolicyInput = TwimlRequestView & {
hasStoredTwiml: boolean;
isNotifyCall: boolean;
hasActiveStreams: boolean;
canStream: boolean;
};
/** TwiML response decision plus side effects the caller should apply. */
type TwimlDecision =
| {
kind: "empty" | "pause" | "queue";
consumeStoredTwimlCallId?: string;
activateStreamCallSid?: string;
}
| {
kind: "stored";
consumeStoredTwimlCallId: string;
activateStreamCallSid?: string;
}
| {
kind: "stream";
consumeStoredTwimlCallId?: string;
activateStreamCallSid?: string;
};
/** Return true for Twilio outbound call directions. */
function isOutboundDirection(direction: string | null): boolean {
return direction?.startsWith("outbound") ?? false;
}
/** Read the Twilio request fields needed by TwiML decision logic. */
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,
};
}
/** Decide the TwiML response kind for a Twilio webhook request. */
export function decideTwimlResponse(input: TwimlPolicyInput): TwimlDecision {
if (input.callIdFromQuery && !input.isStatusCallback) {
if (input.hasStoredTwiml) {
return { kind: "stored", consumeStoredTwimlCallId: input.callIdFromQuery };
}
if (input.isNotifyCall) {
return { kind: "empty" };
}
if (isOutboundDirection(input.direction)) {
return input.canStream ? { kind: "stream" } : { kind: "pause" };
}
}
if (input.isStatusCallback) {
return { kind: "empty" };
}
if (input.direction === "inbound") {
if (input.hasActiveStreams) {
return { kind: "queue" };
}
if (input.canStream && input.callSid) {
return { kind: "stream", activateStreamCallSid: input.callSid };
}
return { kind: "pause" };
}
if (input.callStatus !== "in-progress") {
return { kind: "empty" };
}
return input.canStream ? { kind: "stream" } : { kind: "pause" };
}

View File

@@ -0,0 +1,38 @@
// Voice Call plugin module implements webhook behavior.
import type { WebhookContext, WebhookVerificationResult } from "../../types.js";
import { verifyTwilioWebhook } from "../../webhook-security.js";
import type { TwilioProviderOptions } from "../twilio.types.js";
// Twilio-specific webhook verification adapter.
/** Verify a Twilio webhook and map SDK verification details to provider result fields. */
export function verifyTwilioProviderWebhook(params: {
ctx: WebhookContext;
authToken: string;
currentPublicUrl?: string | null;
options: TwilioProviderOptions;
}): WebhookVerificationResult {
const result = verifyTwilioWebhook(params.ctx, params.authToken, {
publicUrl: params.currentPublicUrl || undefined,
allowNgrokFreeTierLoopbackBypass: params.options.allowNgrokFreeTierLoopbackBypass ?? false,
skipVerification: params.options.skipVerification,
allowedHosts: params.options.webhookSecurity?.allowedHosts,
trustForwardingHeaders: params.options.webhookSecurity?.trustForwardingHeaders,
trustedProxyIPs: params.options.webhookSecurity?.trustedProxyIPs,
remoteIP: params.ctx.remoteAddress,
});
if (!result.ok) {
console.warn(`[twilio] Webhook verification failed: ${result.reason}`);
if (result.verificationUrl) {
console.warn(`[twilio] Verification URL: ${result.verificationUrl}`);
}
}
return {
ok: result.ok,
reason: result.reason,
isReplay: result.isReplay,
verifiedRequestKey: result.verifiedRequestKey,
};
}

View File

@@ -0,0 +1,96 @@
// Voice Call tests cover realtime agent context plugin behavior.
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { VoiceCallConfig } from "./config.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
import { buildRealtimeVoiceInstructions } from "./realtime-agent-context.js";
import { createVoiceCallBaseConfig } from "./test-fixtures.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
async function createWorkspace(): Promise<string> {
const workspaceDir = await mkdtemp(path.join(tmpdir(), "openclaw-voice-context-"));
tempDirs.push(workspaceDir);
return workspaceDir;
}
function createConfig(overrides?: Partial<VoiceCallConfig["realtime"]>): VoiceCallConfig {
const config = createVoiceCallBaseConfig();
config.agentId = "voice";
config.realtime.enabled = true;
config.realtime.instructions = "Base voice instructions.";
config.realtime = {
...config.realtime,
...overrides,
fastContext: {
...config.realtime.fastContext,
...overrides?.fastContext,
sources: overrides?.fastContext?.sources ?? config.realtime.fastContext.sources,
},
agentContext: {
...config.realtime.agentContext,
...overrides?.agentContext,
files: overrides?.agentContext?.files ?? config.realtime.agentContext.files,
},
tools: overrides?.tools ?? config.realtime.tools,
providers: overrides?.providers ?? config.realtime.providers,
};
return config;
}
function createAgentRuntime(workspaceDir: string): CoreAgentDeps {
return {
resolveAgentIdentity: vi.fn(() => ({
name: "Claw Voice",
emoji: ":claw:",
theme: "bright",
vibe: "snappy",
creature: "operator",
})),
resolveAgentWorkspaceDir: vi.fn(() => workspaceDir),
} as unknown as CoreAgentDeps;
}
describe("buildRealtimeVoiceInstructions", () => {
it("injects bounded identity and workspace context", async () => {
const workspaceDir = await createWorkspace();
await writeFile(path.join(workspaceDir, "SOUL.md"), "Stay quick, direct, and warm.\n");
await writeFile(path.join(workspaceDir, "IDENTITY.md"), "Name: Claw Voice\nVibe: snappy\n");
await writeFile(path.join(workspaceDir, "SECRET.md"), "do not include\n");
const coreConfig = { agents: { list: [{ id: "voice" }] } } as CoreConfig;
const instructions = await buildRealtimeVoiceInstructions({
baseInstructions: "Base voice instructions.",
config: createConfig({
consultPolicy: "substantive",
agentContext: {
enabled: true,
maxChars: 2000,
includeIdentity: true,
includeWorkspaceFiles: true,
files: ["SOUL.md", "IDENTITY.md", "../SECRET.md"],
},
}),
coreConfig,
agentRuntime: createAgentRuntime(workspaceDir),
});
expect(instructions).toContain("OpenClaw agent voice context:");
expect(instructions).toContain("Consult behavior:");
expect(instructions).toContain("Call openclaw_agent_consult before answering requests");
expect(instructions).toContain("- Agent id: voice");
expect(instructions).toContain("- Name: Claw Voice");
expect(instructions).toContain("- Vibe: snappy");
expect(instructions).toContain("### SOUL.md");
expect(instructions).toContain("Stay quick, direct, and warm.");
expect(instructions).toContain("### IDENTITY.md");
expect(instructions).not.toContain("do not include");
});
});

View File

@@ -0,0 +1,121 @@
// Voice Call plugin module implements realtime agent context behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { buildRealtimeVoiceAgentConsultPolicyInstructions } from "openclaw/plugin-sdk/realtime-voice";
import { root } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString as normalizeString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { VoiceCallConfig } from "./config.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
// Builds compact agent context injected into realtime voice sessions.
/** Agent identity subset used by voice instructions. */
type VoiceIdentityLike = {
name?: unknown;
emoji?: unknown;
theme?: unknown;
creature?: unknown;
vibe?: unknown;
};
/** Limit injected context while preserving an explicit truncation marker. */
function limitText(text: string, maxChars: number): string {
if (text.length <= maxChars) {
return text;
}
return `${text.slice(0, Math.max(0, maxChars - 32)).trimEnd()}\n[truncated]`;
}
/** Read configured workspace context files through the safe workspace root. */
async function readWorkspaceVoiceContextFiles(params: {
workspaceDir: string;
files: readonly string[];
maxChars: number;
}): Promise<string[]> {
const sections: string[] = [];
let remaining = params.maxChars;
const workspaceRoot = await root(params.workspaceDir).catch(() => null);
if (!workspaceRoot) {
return sections;
}
for (const file of params.files) {
if (remaining <= 0) {
continue;
}
const content = await workspaceRoot.readText(file).catch(() => undefined);
const trimmed = content?.trim();
if (!trimmed) {
continue;
}
const body = limitText(trimmed, Math.max(0, remaining - file.length - 16));
const section = `### ${file}\n${body}`;
sections.push(section);
remaining -= section.length;
}
return sections;
}
/** Build final realtime instructions from base instructions, consult policy, and fast context. */
export async function buildRealtimeVoiceInstructions(params: {
baseInstructions: string;
config: VoiceCallConfig;
coreConfig: CoreConfig;
agentRuntime: CoreAgentDeps;
}): Promise<string> {
const { config } = params;
const sections: string[] = [params.baseInstructions];
const consultGuidance = buildRealtimeVoiceAgentConsultPolicyInstructions(config.realtime);
if (consultGuidance) {
sections.push(consultGuidance);
}
const contextConfig = config.realtime.agentContext;
if (!contextConfig.enabled) {
return sections.filter(Boolean).join("\n\n");
}
const agentId = config.agentId ?? "main";
const capsule: string[] = [
"OpenClaw agent voice context:",
`- Agent id: ${agentId}`,
"- Use this context to match the OpenClaw agent's personality and standing preferences on fast voice turns.",
"- Treat this as compact context only; call openclaw_agent_consult when the caller needs the full agent brain, tools, memory, or workspace state.",
];
if (contextConfig.includeIdentity) {
const identity = params.agentRuntime.resolveAgentIdentity(
params.coreConfig as OpenClawConfig,
agentId,
) as VoiceIdentityLike | undefined;
const identityLines = [
normalizeString(identity?.name) ? `- Name: ${normalizeString(identity?.name)}` : undefined,
normalizeString(identity?.emoji) ? `- Emoji: ${normalizeString(identity?.emoji)}` : undefined,
normalizeString(identity?.vibe) ? `- Vibe: ${normalizeString(identity?.vibe)}` : undefined,
normalizeString(identity?.theme) ? `- Theme: ${normalizeString(identity?.theme)}` : undefined,
normalizeString(identity?.creature)
? `- Creature/persona: ${normalizeString(identity?.creature)}`
: undefined,
].filter(Boolean);
if (identityLines.length > 0) {
capsule.push(`Configured identity:\n${identityLines.join("\n")}`);
}
}
if (contextConfig.includeWorkspaceFiles) {
const workspaceDir = params.agentRuntime.resolveAgentWorkspaceDir(
params.coreConfig as OpenClawConfig,
agentId,
);
// Workspace reads stay under the agent root; missing or unreadable context files are omitted.
const fileSections = await readWorkspaceVoiceContextFiles({
workspaceDir,
files: contextConfig.files,
maxChars: contextConfig.maxChars,
});
if (fileSections.length > 0) {
capsule.push(`Workspace voice context:\n${fileSections.join("\n\n")}`);
}
}
sections.push(limitText(capsule.join("\n\n"), contextConfig.maxChars));
return sections.filter(Boolean).join("\n\n");
}

View File

@@ -0,0 +1,7 @@
// Voice Call plugin module implements realtime defaults behavior.
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME } from "openclaw/plugin-sdk/realtime-voice";
// Default realtime instructions for the voice-call plugin's phone interface.
/** Baseline instructions that keep realtime calls brief and route deep work to agent consult. */
export const DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS = `You are OpenClaw's phone-call realtime voice interface. Keep spoken replies brief and natural. When a question needs deeper reasoning, current information, or tools, call ${REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME} before answering.`;

View File

@@ -0,0 +1,75 @@
// Voice Call tests cover realtime fast context plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { VoiceCallRealtimeFastContextConfig } from "./config.js";
const mocks = vi.hoisted(() => ({
resolveRealtimeVoiceFastContextConsult: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/realtime-voice", () => ({
resolveRealtimeVoiceFastContextConsult: mocks.resolveRealtimeVoiceFastContextConsult,
}));
import { resolveRealtimeFastContextConsult } from "./realtime-fast-context.js";
const cfg = {} as OpenClawConfig;
function createFastContextConfig(
overrides: Partial<VoiceCallRealtimeFastContextConfig> = {},
): VoiceCallRealtimeFastContextConfig {
return {
enabled: true,
timeoutMs: 800,
maxResults: 3,
sources: ["memory", "sessions"],
fallbackToConsult: false,
...overrides,
};
}
function createLogger() {
return {
debug: vi.fn(),
warn: vi.fn(),
};
}
describe("resolveRealtimeFastContextConsult", () => {
beforeEach(() => {
mocks.resolveRealtimeVoiceFastContextConsult.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("passes voice-call labels into the SDK fast context resolver", async () => {
const logger = createLogger();
mocks.resolveRealtimeVoiceFastContextConsult.mockResolvedValue({ handled: false });
await expect(
resolveRealtimeFastContextConsult({
cfg,
agentId: "main",
sessionKey: "voice:15550001234",
config: createFastContextConfig({ fallbackToConsult: true }),
args: { question: "What do you remember?" },
logger,
}),
).resolves.toEqual({ handled: false });
expect(mocks.resolveRealtimeVoiceFastContextConsult).toHaveBeenCalledWith({
cfg,
agentId: "main",
sessionKey: "voice:15550001234",
config: createFastContextConfig({ fallbackToConsult: true }),
args: { question: "What do you remember?" },
logger,
labels: {
audienceLabel: "caller",
contextName: "OpenClaw memory or session context",
},
});
});
});

View File

@@ -0,0 +1,31 @@
// Voice Call plugin module implements realtime fast context behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
resolveRealtimeVoiceFastContextConsult,
type RealtimeVoiceFastContextConsultResult,
type RealtimeVoiceFastContextConfig,
} from "openclaw/plugin-sdk/realtime-voice";
type Logger = {
debug?: (message: string) => void;
};
// Voice-call labels for the SDK realtime fast-context resolver.
/** Resolve fast-context consult data using caller-oriented labels. */
export async function resolveRealtimeFastContextConsult(params: {
cfg: OpenClawConfig;
agentId: string;
sessionKey: string;
config: RealtimeVoiceFastContextConfig;
args: unknown;
logger: Logger;
}): Promise<RealtimeVoiceFastContextConsultResult> {
return await resolveRealtimeVoiceFastContextConsult({
...params,
labels: {
audienceLabel: "caller",
contextName: "OpenClaw memory or session context",
},
});
}

View File

@@ -0,0 +1,6 @@
// Realtime transcription provider facade for the voice-call plugin runtime.
export {
getRealtimeTranscriptionProvider,
listRealtimeTranscriptionProviders,
} from "openclaw/plugin-sdk/realtime-transcription";

View File

@@ -0,0 +1,7 @@
// Realtime voice provider facade for the voice-call plugin runtime.
export {
getRealtimeVoiceProvider,
listRealtimeVoiceProviders,
resolveConfiguredRealtimeVoiceProvider,
} from "openclaw/plugin-sdk/realtime-voice";

View File

@@ -0,0 +1,561 @@
// Voice Call tests cover response generator plugin behavior.
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";
type TestSessionEntry = {
sessionId: string;
updatedAt: number;
providerOverride?: string;
modelOverride?: string;
modelOverrideSource?: string;
model?: string;
modelProvider?: string;
contextTokens?: number;
authProfileOverride?: string;
};
type EmbeddedAgentArgs = {
abortSignal?: AbortSignal;
extraSystemPrompt: string;
provider?: string;
model?: string;
sessionKey?: string;
sessionTarget?: {
agentId?: string;
sessionId?: string;
sessionKey?: string;
storePath?: string;
};
sandboxSessionKey?: string;
agentDir?: string;
agentId?: string;
workspaceDir?: string;
sessionFile?: string;
toolsAllow?: string[];
};
function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
const sessionStore: Record<string, TestSessionEntry> = {};
const saveSessionStore = vi.fn(async () => {});
const updateSessionStore = vi.fn(
async (_storePath: string, mutator: (store: Record<string, TestSessionEntry>) => unknown) => {
return await mutator(sessionStore);
},
);
const getSessionEntry = vi.fn(
(params: { sessionKey: string }) => sessionStore[params.sessionKey],
);
const patchSessionEntry = vi.fn(
async (params: {
sessionKey: string;
fallbackEntry?: TestSessionEntry;
replaceEntry?: boolean;
update: (entry: TestSessionEntry) => Partial<TestSessionEntry> | null;
}) => {
const existing = sessionStore[params.sessionKey] ?? params.fallbackEntry;
if (!existing) {
return null;
}
const patch = params.update({ ...existing });
if (!patch) {
return existing;
}
const next = params.replaceEntry ? (patch as TestSessionEntry) : { ...existing, ...patch };
sessionStore[params.sessionKey] = next;
return next;
},
);
const upsertSessionEntry = vi.fn(
async (params: { sessionKey: string; entry: TestSessionEntry }) => {
sessionStore[params.sessionKey] = { ...params.entry };
},
);
const runEmbeddedAgent = vi.fn(async (_args: EmbeddedAgentArgs) => ({
payloads,
meta: { durationMs: 12, aborted: false },
}));
const runWithWorkAdmission = vi.fn(
async (
_params: { storePath: string; sessionKey: string },
run: (signal: AbortSignal) => Promise<unknown>,
) => await run(new AbortController().signal),
);
const resolveAgentDir = vi.fn((_cfg: CoreConfig, agentId: string) => {
return `/tmp/openclaw/agents/${agentId}`;
});
const resolveAgentWorkspaceDir = vi.fn((_cfg: CoreConfig, agentId: string) => {
return `/tmp/openclaw/workspace/${agentId}`;
});
const resolveAgentIdentity = vi.fn((_cfg: CoreConfig, agentId: string) => ({
name: `${agentId} tester`,
}));
const resolveStorePath = vi.fn((_store: string | undefined, params: { agentId?: string }) => {
return `/tmp/openclaw/${params.agentId ?? "main"}/sessions.json`;
});
const resolveSessionFilePath = vi.fn(
(_sessionId: string, _entry: unknown, params: { agentId?: string }) => {
return `/tmp/openclaw/${params.agentId ?? "main"}/sessions/session.jsonl`;
},
);
const runtime = {
defaults: {
provider: "together",
model: "Qwen/Qwen2.5-7B-Instruct-Turbo",
},
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveAgentIdentity,
resolveThinkingDefault: () => "off",
resolveAgentTimeoutMs: () => 30_000,
ensureAgentWorkspace: async () => {},
runEmbeddedAgent,
session: {
resolveStorePath,
loadSessionStore: () => sessionStore,
saveSessionStore,
updateSessionStore,
getSessionEntry,
patchSessionEntry,
upsertSessionEntry,
runWithWorkAdmission,
resolveSessionFilePath,
},
} as unknown as CoreAgentDeps;
return {
runtime,
runEmbeddedAgent,
runWithWorkAdmission,
saveSessionStore,
updateSessionStore,
patchSessionEntry,
sessionStore,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveAgentIdentity,
resolveStorePath,
resolveSessionFilePath,
};
}
function requireEmbeddedAgentArgs(runEmbeddedAgent: ReturnType<typeof vi.fn>) {
const calls = runEmbeddedAgent.mock.calls as unknown[][];
const firstCall = requireFirstMockCall(
calls,
"voice response generator embedded agent invocation",
);
const args = firstCall[0] as Partial<EmbeddedAgentArgs> | undefined;
if (!args?.extraSystemPrompt) {
throw new Error("voice response generator did not pass the spoken-output contract prompt");
}
return args as EmbeddedAgentArgs;
}
function requireFirstMockCall(calls: readonly unknown[][], label: string): unknown[] {
const call = calls.at(0);
if (!call) {
throw new Error(`expected ${label} call`);
}
return call;
}
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, runEmbeddedAgent, runWithWorkAdmission } = createAgentRuntime([
{ text: "Reasoning: hidden", isReasoning: true },
{ text: '{"spoken":"Hello from JSON."}' },
]);
const { result } = await runGenerateVoiceResponse([], { runtime });
expect(result.text).toBe("Hello from JSON.");
expect(runEmbeddedAgent).toHaveBeenCalledTimes(1);
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
expect(args.extraSystemPrompt).toContain('{"spoken":"..."}');
expect(args.provider).toBe("together");
expect(args.model).toBe("Qwen/Qwen2.5-7B-Instruct-Turbo");
expect(args.abortSignal).toBeInstanceOf(AbortSignal);
expect(runWithWorkAdmission).toHaveBeenCalledWith(
{
storePath: "/tmp/openclaw/main/sessions.json",
sessionKey: "agent:main:voice:15550001111",
},
expect.any(Function),
);
});
it("returns the lifecycle rejection without starting the embedded agent", async () => {
const { runtime, runEmbeddedAgent, runWithWorkAdmission } = createAgentRuntime([]);
runWithWorkAdmission.mockRejectedValueOnce(
new Error('Session "agent:main:voice:15550001111" is archived.'),
);
const { result } = await runGenerateVoiceResponse([], { runtime });
expect(result).toEqual({
text: null,
error: 'Error: Session "agent:main:voice:15550001111" is archived.',
});
expect(runEmbeddedAgent).not.toHaveBeenCalled();
});
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.");
});
it("pins the voice session to responseModel before running the embedded agent", async () => {
const { runtime, runEmbeddedAgent, patchSessionEntry, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Pinned model works."}' },
]);
sessionStore["agent:main:voice:15550001111"] = {
sessionId: "existing-session",
updatedAt: 100,
model: "old-model",
modelProvider: "old-provider",
contextTokens: 123,
authProfileOverride: "old-auth-profile",
};
const voiceConfig = VoiceCallConfigSchema.parse({
responseModel: "openai/gpt-4.1-nano",
responseTimeoutMs: 5000,
});
const result = await generateVoiceResponse({
voiceConfig,
coreConfig: {} as CoreConfig,
agentRuntime: runtime,
callId: "call-123",
from: "+15550001111",
transcript: [{ speaker: "user", text: "hello there" }],
userMessage: "hello there",
});
expect(result.text).toBe("Pinned model works.");
const pinnedSessionEntry = sessionStore["agent:main:voice:15550001111"];
expect(pinnedSessionEntry?.providerOverride).toBe("openai");
expect(pinnedSessionEntry?.modelOverride).toBe("gpt-4.1-nano");
expect(pinnedSessionEntry?.modelOverrideSource).toBe("auto");
expect(pinnedSessionEntry?.model).toBeUndefined();
expect(pinnedSessionEntry?.modelProvider).toBeUndefined();
expect(pinnedSessionEntry?.contextTokens).toBeUndefined();
expect(pinnedSessionEntry?.authProfileOverride).toBeUndefined();
const patchSessionEntryCall = requireFirstMockCall(
patchSessionEntry.mock.calls,
"session entry patch",
);
expect(patchSessionEntryCall[0]).toMatchObject({
storePath: "/tmp/openclaw/main/sessions.json",
sessionKey: "agent:main:voice:15550001111",
replaceEntry: true,
});
expect((patchSessionEntryCall[0] as { update?: unknown }).update).toBeTypeOf("function");
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
expect(args.provider).toBe("openai");
expect(args.model).toBe("gpt-4.1-nano");
expect(args.sessionKey).toBe("agent:main:voice:15550001111");
});
it("canonicalizes a restored legacy per-call key for classic responses", async () => {
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Fresh call context."}' },
]);
const voiceConfig = VoiceCallConfigSchema.parse({
sessionScope: "per-call",
responseTimeoutMs: 5000,
});
const result = await generateVoiceResponse({
voiceConfig,
coreConfig: {} as CoreConfig,
agentRuntime: runtime,
callId: "call-123",
sessionKey: "voice:call:call-123",
from: "+15550001111",
transcript: [{ speaker: "user", text: "hello there" }],
userMessage: "hello there",
});
expect(result.text).toBe("Fresh call context.");
const perCallSessionEntry = sessionStore["agent:main:voice:call:call-123"];
expect(perCallSessionEntry?.sessionId).toBeTypeOf("string");
expect(perCallSessionEntry?.sessionId).not.toBe("");
expect(sessionStore["voice:15550001111"]).toBeUndefined();
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
expect(args.sessionKey).toBe("agent:main:voice:call:call-123");
expect(args.sandboxSessionKey).toBe("agent:main:voice:call:call-123");
});
it("preserves an explicit call key while scoping its session-store identity", async () => {
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Shared meeting context."}' },
]);
const voiceConfig = VoiceCallConfigSchema.parse({
agentId: "voice",
responseTimeoutMs: 5000,
});
await generateVoiceResponse({
voiceConfig,
coreConfig: {} as CoreConfig,
agentRuntime: runtime,
callId: "call-123",
sessionKey: "meet-room-1",
from: "+15550001111",
transcript: [],
userMessage: "hello there",
});
expect(sessionStore["agent:voice:meet-room-1"]?.sessionId).toBeTypeOf("string");
expect(sessionStore["meet-room-1"]).toBeUndefined();
expect(requireEmbeddedAgentArgs(runEmbeddedAgent).sessionKey).toBe("agent:voice:meet-room-1");
});
it("keeps wrapped foreign Matrix identities stable across restore", async () => {
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Matrix context."}' },
]);
const voiceConfig = VoiceCallConfigSchema.parse({
agentId: "voice",
responseTimeoutMs: 5000,
});
const canonical = "agent:voice:agent:other:matrix:channel:!RoomAbC:example.org";
const generate = (sessionKey: string) =>
generateVoiceResponse({
voiceConfig,
coreConfig: {} as CoreConfig,
agentRuntime: runtime,
callId: "call-123",
sessionKey,
from: "+15550001111",
transcript: [],
userMessage: "hello there",
});
await generate("agent:other:matrix:channel:!RoomAbC:example.org");
await generate(canonical);
await generate("agent:other:matrix:channel:!Roomabc:example.org");
expect(sessionStore[canonical]?.sessionId).toBeTypeOf("string");
expect(
sessionStore["agent:voice:agent:other:matrix:channel:!Roomabc:example.org"]?.sessionId,
).toBeTypeOf("string");
expect(Object.keys(sessionStore)).toHaveLength(2);
const sessionKeys = runEmbeddedAgent.mock.calls.map(([args]) => args.sessionKey);
expect(sessionKeys).toEqual([
canonical,
canonical,
"agent:voice:agent:other:matrix:channel:!Roomabc:example.org",
]);
});
it("uses the configured core main key for restored call aliases", async () => {
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Main context."}' },
]);
const voiceConfig = VoiceCallConfigSchema.parse({
agentId: "voice",
responseTimeoutMs: 5000,
});
await generateVoiceResponse({
voiceConfig,
coreConfig: { session: { mainKey: "work" } },
agentRuntime: runtime,
callId: "call-123",
sessionKey: "agent:voice:main",
from: "+15550001111",
transcript: [],
userMessage: "hello there",
});
expect(sessionStore["agent:voice:work"]?.sessionId).toBeTypeOf("string");
expect(requireEmbeddedAgentArgs(runEmbeddedAgent).sessionKey).toBe("agent:voice:work");
});
it("uses the main agent workspace when voice config omits agentId", async () => {
const {
runtime,
runEmbeddedAgent,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveAgentIdentity,
resolveStorePath,
sessionStore,
} = createAgentRuntime([{ text: '{"spoken":"Default agent."}' }]);
const coreConfig = {} as CoreConfig;
await generateVoiceResponse({
voiceConfig: VoiceCallConfigSchema.parse({ responseTimeoutMs: 5000 }),
coreConfig,
agentRuntime: runtime,
callId: "call-123",
from: "+15550001111",
transcript: [],
userMessage: "hello there",
});
expect(resolveStorePath).toHaveBeenCalledWith(undefined, { agentId: "main" });
expect(resolveAgentDir).toHaveBeenCalledWith(coreConfig, "main");
expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(coreConfig, "main");
expect(resolveAgentIdentity).toHaveBeenCalledWith(coreConfig, "main");
const defaultSessionEntry = sessionStore["agent:main:voice:15550001111"];
if (!defaultSessionEntry) {
throw new Error("Expected default voice session entry");
}
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
expect(args.agentDir).toBe("/tmp/openclaw/agents/main");
expect(args.agentId).toBe("main");
expect(args.sessionKey).toBe("agent:main:voice:15550001111");
expect(args.sessionTarget).toStrictEqual({
agentId: "main",
sessionId: defaultSessionEntry.sessionId,
sessionKey: "agent:main:voice:15550001111",
storePath: "/tmp/openclaw/main/sessions.json",
});
expect(args.sandboxSessionKey).toBe("agent:main:voice:15550001111");
expect(args.workspaceDir).toBe("/tmp/openclaw/workspace/main");
expect(args.sessionFile).toBeUndefined();
});
it("uses the configured voice response agent workspace", async () => {
const {
runtime,
runEmbeddedAgent,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveAgentIdentity,
resolveStorePath,
sessionStore,
} = createAgentRuntime([{ text: '{"spoken":"Voice agent."}' }]);
const coreConfig = {} as CoreConfig;
const result = await generateVoiceResponse({
voiceConfig: VoiceCallConfigSchema.parse({
agentId: "voice",
responseTimeoutMs: 5000,
}),
coreConfig,
agentRuntime: runtime,
callId: "call-123",
from: "+15550001111",
transcript: [],
userMessage: "hello there",
});
expect(result.text).toBe("Voice agent.");
expect(resolveStorePath).toHaveBeenCalledWith(undefined, { agentId: "voice" });
expect(resolveAgentDir).toHaveBeenCalledWith(coreConfig, "voice");
expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(coreConfig, "voice");
expect(resolveAgentIdentity).toHaveBeenCalledWith(coreConfig, "voice");
const voiceSessionEntry = sessionStore["agent:voice:voice:15550001111"];
if (!voiceSessionEntry) {
throw new Error("Expected routed voice session entry");
}
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
expect(args.agentDir).toBe("/tmp/openclaw/agents/voice");
expect(args.agentId).toBe("voice");
expect(args.sessionKey).toBe("agent:voice:voice:15550001111");
expect(args.sessionTarget).toStrictEqual({
agentId: "voice",
sessionId: voiceSessionEntry.sessionId,
sessionKey: "agent:voice:voice:15550001111",
storePath: "/tmp/openclaw/voice/sessions.json",
});
expect(args.sandboxSessionKey).toBe("agent:voice:voice:15550001111");
expect(args.workspaceDir).toBe("/tmp/openclaw/workspace/voice");
expect(args.sessionFile).toBeUndefined();
});
it("passes the routed voice agent explicit tool allowlist to the embedded run", async () => {
const { runtime, runEmbeddedAgent } = createAgentRuntime([
{ text: '{"spoken":"No tools needed."}' },
]);
const coreConfig = {
agents: {
list: [
{
id: "voice",
tools: { allow: [] },
},
],
},
} as CoreConfig;
const result = await generateVoiceResponse({
voiceConfig: VoiceCallConfigSchema.parse({
agentId: "voice",
responseModel: "ollama/qwen2.5:1.5b",
responseTimeoutMs: 5000,
}),
coreConfig,
agentRuntime: runtime,
callId: "call-123",
from: "+15550001111",
transcript: [],
userMessage: "hello there",
});
expect(result.text).toBe("No tools needed.");
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
expect(args.agentId).toBe("voice");
expect(args.toolsAllow).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,368 @@
/**
* Voice call response generator - uses the embedded OpenClaw agent for tool support.
* Routes voice responses through the same agent infrastructure as messaging.
*/
import crypto from "node:crypto";
import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime";
import {
isRecord,
normalizeLowercaseStringOrEmpty,
normalizeStringEntries,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveVoiceCallSessionKey, 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;
/** Persisted call session key */
sessionKey?: 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;
};
function readExplicitToolsAllow(value: unknown): string[] | undefined {
if (!isRecord(value)) {
return undefined;
}
const allow = value.allow;
if (!Array.isArray(allow)) {
return undefined;
}
return allow.filter((entry): entry is string => typeof entry === "string");
}
function resolveVoiceAgentToolsAllow(config: CoreConfig, agentId: string): string[] | undefined {
const agents = isRecord(config.agents) ? config.agents : undefined;
const list = Array.isArray(agents?.list) ? agents.list : [];
const agent = list.find((entry) => isRecord(entry) && entry.id === agentId);
if (!isRecord(agent)) {
return undefined;
}
return readExplicitToolsAllow(isRecord(agent.tools) ? agent.tools : undefined);
}
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 = normalizeStringEntries(withoutCodeFences.split(/\n\s*\n+/));
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;
}
function resolveVoiceSandboxSessionKey(agentId: string, sessionKey: string): string {
const trimmed = sessionKey.trim();
if (trimmed.toLowerCase().startsWith("agent:")) {
return trimmed;
}
return `agent:${agentId}:${trimmed}`;
}
/**
* Generate a voice response using the embedded OpenClaw 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,
sessionKey,
from,
transcript,
userMessage,
coreConfig,
agentRuntime,
} = params;
if (!coreConfig) {
return { text: null, error: "Core config unavailable for voice response" };
}
const cfg = coreConfig;
const resolvedSessionKey = resolveVoiceCallSessionKey({
config: voiceConfig,
callId,
phone: from,
explicitSessionKey: sessionKey,
coreSession: coreConfig.session,
});
const agentId = voiceConfig.agentId ?? "main";
const toolsAllow = resolveVoiceAgentToolsAllow(cfg, agentId);
// Resolve paths
const storePath = agentRuntime.session.resolveStorePath(cfg.session?.store, { agentId });
try {
return await agentRuntime.session.runWithWorkAdmission(
{ storePath, sessionKey: resolvedSessionKey },
async (abortSignal) => {
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 now = Date.now();
const existingSessionEntry = agentRuntime.session.getSessionEntry({
storePath,
sessionKey: resolvedSessionKey,
});
// Resolve model from config
const { provider, model } = resolveVoiceResponseModel({ voiceConfig, agentRuntime });
let sessionEntry = existingSessionEntry;
if (!sessionEntry?.sessionId || voiceConfig.responseModel) {
sessionEntry =
(await agentRuntime.session.patchSessionEntry({
storePath,
sessionKey: resolvedSessionKey,
replaceEntry: true,
fallbackEntry: sessionEntry ?? {
sessionId: crypto.randomUUID(),
updatedAt: now,
},
update: (entry) => {
const next = entry.sessionId
? { ...entry }
: {
...entry,
sessionId: crypto.randomUUID(),
updatedAt: now,
};
if (voiceConfig.responseModel) {
applyModelOverrideToSessionEntry({
entry: next,
selection: { provider, model },
selectionSource: "auto",
});
}
return next;
},
})) ?? undefined;
}
if (!sessionEntry?.sessionId) {
return { text: null, error: "Voice response session could not be initialized" };
}
const sessionId = sessionEntry.sessionId;
// 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()}`;
const result = await agentRuntime.runEmbeddedAgent({
sessionId,
sessionKey: resolvedSessionKey,
sessionTarget: {
agentId,
sessionId,
sessionKey: resolvedSessionKey,
storePath,
},
sandboxSessionKey: resolveVoiceSandboxSessionKey(agentId, resolvedSessionKey),
agentId,
messageProvider: "voice",
workspaceDir,
config: cfg,
prompt: userMessage,
provider,
model,
thinkLevel,
verboseLevel: "off",
timeoutMs,
runId,
lane: "voice",
extraSystemPrompt,
agentDir,
toolsAllow,
abortSignal,
});
const text = extractSpokenTextFromPayloads(
(result.payloads ?? []) as VoiceResponsePayload[],
);
if (!text && result.meta?.aborted) {
return { text: null, error: "Response generation was aborted" };
}
return { text };
},
);
} catch (err) {
console.error(`[voice-call] Response generation failed:`, err);
return { text: null, error: String(err) };
}
}

View File

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

View File

@@ -0,0 +1,27 @@
// Voice Call plugin module implements response model behavior.
import type { VoiceCallConfig } from "./config.js";
import type { CoreAgentDeps } from "./core-bridge.js";
// Resolves the model used for voice-call text response generation.
/** Resolve provider/model fields from explicit voice config or agent defaults. */
export function resolveVoiceResponseModel(params: {
voiceConfig: VoiceCallConfig;
agentRuntime: CoreAgentDeps;
}): {
modelRef: string;
provider: string;
model: string;
} {
const modelRef =
params.voiceConfig.responseModel ??
`${params.agentRuntime.defaults.provider}/${params.agentRuntime.defaults.model}`;
const slashIndex = modelRef.indexOf("/");
return {
modelRef,
provider:
slashIndex === -1 ? params.agentRuntime.defaults.provider : modelRef.slice(0, slashIndex),
model: slashIndex === -1 ? modelRef : modelRef.slice(slashIndex + 1),
};
}

View File

@@ -0,0 +1,18 @@
// Voice Call plugin module implements runtime state behavior.
import { createPluginRuntimeStore, type PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
// Process-local runtime store used by voice-call persistence helpers.
/** Runtime subset needed by voice-call state persistence. */
export type VoiceCallStateRuntime = Pick<PluginRuntime, "state">;
const {
setRuntime: setVoiceCallStateRuntime,
clearRuntime: clearVoiceCallStateRuntime,
tryGetRuntime: getOptionalVoiceCallStateRuntime,
} = createPluginRuntimeStore<VoiceCallStateRuntime>({
pluginId: "voice-call-state",
errorMessage: "Voice Call state runtime not initialized",
});
export { clearVoiceCallStateRuntime, getOptionalVoiceCallStateRuntime, setVoiceCallStateRuntime };

View File

@@ -0,0 +1,667 @@
// Voice Call tests cover runtime plugin behavior.
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(),
resolveTwilioAuthToken: vi.fn(),
validateProviderConfig: vi.fn(),
managerInitialize: vi.fn(),
managerGetCall: vi.fn(),
webhookStart: vi.fn(),
webhookStop: vi.fn(),
webhookSetRealtimeHandler: vi.fn(),
webhookGetRealtimeHandler: vi.fn(),
webhookGetMediaStreamHandler: vi.fn(),
webhookCtorArgs: [] as unknown[][],
realtimeHandlerCtorArgs: [] as unknown[][],
realtimeHandlerRegisterToolHandler: vi.fn(),
realtimeHandlerSetPublicUrl: vi.fn(),
resolveConfiguredRealtimeVoiceProvider: vi.fn(),
resolveRealtimeFastContextConsult: vi.fn(),
startTunnel: vi.fn(),
setupTailscaleExposure: vi.fn(),
cleanupTailscaleExposure: vi.fn(),
}));
vi.mock("./config.js", () => ({
resolveVoiceCallSessionKey: (params: {
config: Pick<VoiceCallConfig, "agentId" | "sessionScope">;
callId: string;
phone?: string;
explicitSessionKey?: string;
}) => {
const explicit = params.explicitSessionKey?.trim();
if (explicit) {
const lower = explicit.toLowerCase();
return lower === "global" || lower === "unknown" || lower.startsWith("agent:")
? explicit
: `agent:${params.config.agentId?.trim().toLowerCase() || "main"}:${explicit}`;
}
const agentId = params.config.agentId?.trim().toLowerCase() || "main";
const prefix = `agent:${agentId}:voice`;
if (params.config.sessionScope === "per-call") {
return `${prefix}:call:${params.callId}`.toLowerCase();
}
const normalizedPhone = params.phone?.replace(/\D/g, "");
return (
normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`
).toLowerCase();
},
resolveVoiceCallNumberRouteKeyForCall: (call: {
direction?: "inbound" | "outbound";
to?: string;
metadata?: { numberRouteKey?: unknown };
}) =>
call.direction === "inbound"
? typeof call.metadata?.numberRouteKey === "string"
? call.metadata.numberRouteKey
: call.to
: undefined,
resolveVoiceCallEffectiveConfig: (config: VoiceCallConfig, numberRouteKey?: string) => {
const route = numberRouteKey ? config.numbers[numberRouteKey] : undefined;
return route ? { config: { ...config, ...route }, numberRouteKey } : { config };
},
resolveVoiceCallConfig: mocks.resolveVoiceCallConfig,
resolveTwilioAuthToken: mocks.resolveTwilioAuthToken,
validateProviderConfig: mocks.validateProviderConfig,
}));
vi.mock("./manager.js", () => ({
CallManager: class {
initialize = mocks.managerInitialize;
getCall = mocks.managerGetCall;
},
}));
vi.mock("./webhook.js", () => ({
VoiceCallWebhookServer: class {
constructor(...args: unknown[]) {
mocks.webhookCtorArgs.push(args);
}
start = mocks.webhookStart;
stop = mocks.webhookStop;
setRealtimeHandler = mocks.webhookSetRealtimeHandler;
getRealtimeHandler = mocks.webhookGetRealtimeHandler;
getMediaStreamHandler = mocks.webhookGetMediaStreamHandler;
},
}));
vi.mock("./realtime-voice.runtime.js", () => ({
resolveConfiguredRealtimeVoiceProvider: mocks.resolveConfiguredRealtimeVoiceProvider,
}));
vi.mock("./realtime-fast-context.js", () => ({
resolveRealtimeFastContextConsult: mocks.resolveRealtimeFastContextConsult,
}));
vi.mock("./webhook/realtime-handler.js", () => ({
RealtimeCallHandler: class {
constructor(...args: unknown[]) {
mocks.realtimeHandlerCtorArgs.push(args);
}
registerToolHandler = mocks.realtimeHandlerRegisterToolHandler;
setPublicUrl = mocks.realtimeHandlerSetPublicUrl;
},
}));
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" });
}
function createExternalProviderConfig(params: {
provider: "twilio" | "telnyx" | "plivo";
publicUrl?: string;
}): VoiceCallConfig {
const config = createVoiceCallBaseConfig({
provider: params.provider,
tunnelProvider: "none",
});
config.twilio = {
accountSid: "AC123",
authToken: "secret",
};
config.telnyx = {
apiKey: "key",
connectionId: "conn",
publicKey: "pub",
};
config.plivo = {
authId: "MA123",
authToken: "secret",
};
if (params.publicUrl) {
config.publicUrl = params.publicUrl;
}
return config;
}
type RealtimeConsultToolHandler = (
args: unknown,
callId: string,
context?: { partialUserTranscript?: string },
) => Promise<unknown>;
function firstMockCall(calls: readonly unknown[][], label: string): unknown[] {
const call = calls.at(0);
if (!call) {
throw new Error(`expected ${label} call`);
}
return call;
}
function firstCallParam(calls: readonly unknown[][], label: string) {
const call = firstMockCall(calls, label);
return call[0];
}
type MockSessionEntry = {
sessionId?: string;
updatedAt?: number;
[key: string]: unknown;
};
function createMockSessionRuntime(sessionStore: Record<string, unknown>) {
return {
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
loadSessionStore: vi.fn(() => sessionStore),
saveSessionStore: vi.fn(async () => {}),
updateSessionStore: vi.fn(async (_storePath, mutator: (store: never) => unknown) =>
mutator(sessionStore as never),
),
getSessionEntry: vi.fn(
({ sessionKey }: { sessionKey: string }) => sessionStore[sessionKey] as MockSessionEntry,
),
patchSessionEntry: vi.fn(
async ({
sessionKey,
fallbackEntry,
update,
}: {
sessionKey: string;
fallbackEntry: MockSessionEntry;
update: (entry: MockSessionEntry) => Promise<MockSessionEntry> | MockSessionEntry;
}) => {
const current = (sessionStore[sessionKey] as MockSessionEntry | undefined) ?? fallbackEntry;
const patch = await update(current);
const next = { ...current, ...patch };
sessionStore[sessionKey] = next;
return next;
},
),
resolveSessionFilePath: vi.fn(() => "/tmp/session.json"),
};
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label} to be a record`);
}
return value as Record<string, unknown>;
}
function requireRealtimeConsultToolHandler(): RealtimeConsultToolHandler {
const registeredToolHandler = firstMockCall(
mocks.realtimeHandlerRegisterToolHandler.mock.calls,
"realtime tool handler registration",
);
expect(registeredToolHandler[0]).toBe("openclaw_agent_consult");
if (typeof registeredToolHandler[1] !== "function") {
throw new Error("expected realtime tool handler callback");
}
return registeredToolHandler[1] as RealtimeConsultToolHandler;
}
describe("createVoiceCallRuntime lifecycle", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveVoiceCallConfig.mockImplementation((cfg: VoiceCallConfig) => cfg);
mocks.resolveTwilioAuthToken.mockImplementation(
(cfg: VoiceCallConfig) => cfg.twilio?.authToken,
);
mocks.validateProviderConfig.mockReturnValue({ valid: true, errors: [] });
mocks.managerInitialize.mockResolvedValue(undefined);
mocks.managerGetCall.mockReset();
mocks.webhookStart.mockResolvedValue("http://127.0.0.1:3334/voice/webhook");
mocks.webhookStop.mockResolvedValue(undefined);
mocks.webhookSetRealtimeHandler.mockReset();
mocks.webhookGetRealtimeHandler.mockReturnValue({
setPublicUrl: mocks.realtimeHandlerSetPublicUrl,
});
mocks.webhookGetMediaStreamHandler.mockReturnValue(undefined);
mocks.webhookCtorArgs.length = 0;
mocks.realtimeHandlerCtorArgs.length = 0;
mocks.realtimeHandlerRegisterToolHandler.mockReset();
mocks.realtimeHandlerSetPublicUrl.mockReset();
mocks.resolveConfiguredRealtimeVoiceProvider.mockResolvedValue({
provider: { id: "openai" },
providerConfig: { model: "gpt-realtime" },
});
mocks.resolveRealtimeFastContextConsult.mockReset();
mocks.resolveRealtimeFastContextConsult.mockResolvedValue({ handled: false });
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);
});
it.each(["twilio", "telnyx", "plivo"] as const)(
"fails closed when %s falls back to a local-only webhook",
async (provider) => {
await expect(
createVoiceCallRuntime({
config: createExternalProviderConfig({ provider }),
coreConfig: {} as CoreConfig,
agentRuntime: {} as never,
}),
).rejects.toThrow(`${provider} requires a publicly reachable webhook URL`);
expect(mocks.webhookStop).toHaveBeenCalledTimes(1);
},
);
it.each([
"http://127.0.0.1:3334/voice/webhook",
"http://[::1]:3334/voice/webhook",
"http://[fd00::1]/voice/webhook",
])("fails closed when Twilio publicUrl %s points at a local-only webhook", async (publicUrl) => {
await expect(
createVoiceCallRuntime({
config: createExternalProviderConfig({
provider: "twilio",
publicUrl,
}),
coreConfig: {} as CoreConfig,
agentRuntime: {} as never,
}),
).rejects.toThrow("twilio requires a publicly reachable webhook URL");
expect(mocks.webhookStop).toHaveBeenCalledTimes(1);
});
it("accepts an explicit public URL for external voice providers", async () => {
const runtime = await createVoiceCallRuntime({
config: createExternalProviderConfig({
provider: "twilio",
publicUrl: "https://voice.example.com/voice/webhook",
}),
coreConfig: {} as CoreConfig,
agentRuntime: {} as never,
});
expect(runtime.webhookUrl).toBe("https://voice.example.com/voice/webhook");
expect(runtime.publicUrl).toBe("https://voice.example.com/voice/webhook");
await runtime.stop();
});
it("does not log duplicate webhook and public URLs when they match", async () => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const runtime = await createVoiceCallRuntime({
config: createExternalProviderConfig({
provider: "twilio",
publicUrl: "https://voice.example.com/voice/webhook",
}),
coreConfig: {} as CoreConfig,
agentRuntime: {} as never,
logger,
});
expect(logger.info).toHaveBeenCalledWith(
"[voice-call] Webhook URL: https://voice.example.com/voice/webhook",
);
expect(logger.info).not.toHaveBeenCalledWith(
"[voice-call] Public URL: https://voice.example.com/voice/webhook",
);
await runtime.stop();
});
it("wires realtime consults and keeps outbound calls off inbound number routes", async () => {
const config = createBaseConfig();
config.inboundPolicy = "allowlist";
config.numbers["+15550009999"] = {
agentId: "inbound-route",
responseModel: "openai/gpt-5.5",
};
config.realtime.enabled = true;
config.realtime.tools = [
{
type: "function",
name: "custom_tool",
description: "Custom tool",
parameters: { type: "object", properties: {} },
},
];
const sessionStore: Record<string, unknown> = {};
const runEmbeddedAgent = vi.fn(async () => ({
payloads: [{ text: "Use the shipment status." }],
meta: {},
}));
const agentRuntime = {
defaults: { provider: "openai", model: "gpt-5.4" },
resolveAgentDir: vi.fn(() => "/tmp/agent"),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
resolveAgentIdentity: vi.fn(),
resolveThinkingDefault: vi.fn(() => "high"),
resolveAgentTimeoutMs: vi.fn(() => 30_000),
ensureAgentWorkspace: vi.fn(async () => {}),
session: createMockSessionRuntime(sessionStore),
runEmbeddedAgent,
};
mocks.managerGetCall.mockReturnValue({
callId: "call-1",
direction: "outbound",
from: "+15550001234",
to: "+15550009999",
metadata: { requesterSessionKey: "agent:main:discord:channel:general" },
transcript: [{ speaker: "user", text: "Can you check shipment status?" }],
});
await createVoiceCallRuntime({
config,
coreConfig: {} as CoreConfig,
agentRuntime: agentRuntime as never,
});
const realtimeHandlerOptions = requireRecord(
mocks.realtimeHandlerCtorArgs[0]?.[0],
"realtime handler options",
);
const tools = realtimeHandlerOptions.tools;
if (!Array.isArray(tools)) {
throw new Error("expected realtime handler tools to be an array");
}
expect(tools.map((tool) => requireRecord(tool, "realtime tool").name)).toEqual([
"openclaw_agent_consult",
"custom_tool",
]);
const handler = requireRealtimeConsultToolHandler();
await expect(
handler({ question: "What should I say?" }, "call-1", {
partialUserTranscript: "Also check the ETA.",
}),
).resolves.toEqual({
text: "Use the shipment status.",
});
expect(runEmbeddedAgent).toHaveBeenCalledOnce();
const consultParams = requireRecord(
firstCallParam(runEmbeddedAgent.mock.calls as unknown[][], "embedded OpenClaw consult"),
"embedded OpenClaw consult params",
);
expect(consultParams.sessionKey).toBe("agent:main:voice:15550009999");
expect(consultParams.spawnedBy).toBe("agent:main:discord:channel:general");
expect(consultParams.messageProvider).toBe("voice");
expect(consultParams.lane).toBe("voice");
expect(consultParams.provider).toBe("openai");
expect(consultParams.model).toBe("gpt-5.4");
expect(consultParams.toolsAllow).toEqual([
"read",
"web_search",
"web_fetch",
"x_search",
"memory_search",
"memory_get",
]);
expect(consultParams.extraSystemPrompt).toContain("one or two bounded read-only queries");
expect(consultParams.prompt).toContain("Caller: Can you check shipment status?");
expect(consultParams.prompt).toContain("Caller: Also check the ETA.");
});
it("canonicalizes restored legacy per-call keys for realtime consults", async () => {
const config = createBaseConfig();
config.inboundPolicy = "allowlist";
config.realtime.enabled = true;
config.sessionScope = "per-call";
const runEmbeddedAgent = vi.fn(async () => ({
payloads: [{ text: "Per-call consult answer." }],
meta: {},
}));
const sessionStore: Record<string, unknown> = {};
const agentRuntime = {
defaults: { provider: "openai", model: "gpt-5.4" },
resolveAgentDir: vi.fn(() => "/tmp/agent"),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
resolveAgentIdentity: vi.fn(),
resolveThinkingDefault: vi.fn(() => "high"),
resolveAgentTimeoutMs: vi.fn(() => 30_000),
ensureAgentWorkspace: vi.fn(async () => {}),
session: createMockSessionRuntime(sessionStore),
runEmbeddedAgent,
};
mocks.managerGetCall.mockReturnValue({
callId: "call-1",
sessionKey: "voice:call:call-1",
direction: "inbound",
from: "+15550001234",
to: "+15550009999",
transcript: [],
});
await createVoiceCallRuntime({
config,
coreConfig: {} as CoreConfig,
agentRuntime: agentRuntime as never,
});
const handler = requireRealtimeConsultToolHandler();
await expect(handler({ question: "What should I say?" }, "call-1")).resolves.toEqual({
text: "Per-call consult answer.",
});
expect(runEmbeddedAgent).toHaveBeenCalledOnce();
const consultParams = requireRecord(
firstCallParam(
runEmbeddedAgent.mock.calls as unknown[][],
"per-call embedded OpenClaw consult",
),
"per-call embedded OpenClaw consult params",
);
expect(consultParams.sessionKey).toBe("agent:main:voice:call:call-1");
});
it("answers realtime consults from fast memory context before starting the full agent", async () => {
const config = createBaseConfig();
config.realtime.enabled = true;
config.realtime.fastContext = {
enabled: true,
timeoutMs: 800,
maxResults: 2,
sources: ["memory"],
fallbackToConsult: false,
};
const runEmbeddedAgent = vi.fn(async () => ({
payloads: [{ text: "slow answer" }],
meta: {},
}));
const sessionStore: Record<string, unknown> = {};
const agentRuntime = {
resolveAgentDir: vi.fn(() => "/tmp/agent"),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
resolveAgentIdentity: vi.fn(),
resolveThinkingDefault: vi.fn(() => "high"),
resolveAgentTimeoutMs: vi.fn(() => 30_000),
ensureAgentWorkspace: vi.fn(async () => {}),
session: createMockSessionRuntime(sessionStore),
runEmbeddedAgent,
};
mocks.managerGetCall.mockReturnValue({
callId: "call-1",
direction: "inbound",
from: "+15550001234",
to: "+15550009999",
transcript: [],
});
mocks.resolveRealtimeFastContextConsult.mockResolvedValue({
handled: true,
result: {
text: "Fast OpenClaw memory or session context found.\nThe caller's basement lights are on.",
},
});
await createVoiceCallRuntime({
config,
coreConfig: {} as CoreConfig,
agentRuntime: agentRuntime as never,
});
const handler = requireRealtimeConsultToolHandler();
const fastContextResult = await handler({ question: "Are the basement lights on?" }, "call-1");
const fastContextRecord = requireRecord(fastContextResult, "fast context result");
expect(fastContextRecord.text).toContain("The caller's basement lights are on.");
expect(mocks.resolveRealtimeFastContextConsult).toHaveBeenCalledWith({
cfg: {},
agentId: "main",
args: { question: "Are the basement lights on?" },
config: {
enabled: true,
fallbackToConsult: false,
maxResults: 2,
sources: ["memory"],
timeoutMs: 800,
},
logger: {
info: console.log,
warn: console.warn,
error: console.error,
debug: console.debug,
},
sessionKey: "agent:main:voice:15550001234",
});
expect(runEmbeddedAgent).not.toHaveBeenCalled();
});
it("uses the configured realtime consult thinking level when set", async () => {
const config = createBaseConfig();
config.inboundPolicy = "allowlist";
config.realtime.enabled = true;
config.realtime.consultThinkingLevel = "low";
config.realtime.consultFastMode = true;
const sessionStore: Record<string, unknown> = {};
const runEmbeddedAgent = vi.fn(async () => ({
payloads: [{ text: "Done." }],
meta: {},
}));
const agentRuntime = {
defaults: { provider: "openai", model: "gpt-5.4" },
resolveAgentDir: vi.fn(() => "/tmp/agent"),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
resolveAgentIdentity: vi.fn(),
resolveThinkingDefault: vi.fn(() => "high"),
resolveAgentTimeoutMs: vi.fn(() => 30_000),
ensureAgentWorkspace: vi.fn(async () => {}),
session: createMockSessionRuntime(sessionStore),
runEmbeddedAgent,
};
mocks.managerGetCall.mockReturnValue({
callId: "call-1",
direction: "outbound",
from: "+15550001234",
to: "+15550009999",
transcript: [],
});
await createVoiceCallRuntime({
config,
coreConfig: {} as CoreConfig,
agentRuntime: agentRuntime as never,
});
const handler = requireRealtimeConsultToolHandler();
await expect(handler({ question: "Turn on the lights." }, "call-1")).resolves.toEqual({
text: "Done.",
});
expect(agentRuntime.resolveThinkingDefault).not.toHaveBeenCalled();
expect(runEmbeddedAgent).toHaveBeenCalledOnce();
const consultParams = requireRecord(
firstCallParam(
runEmbeddedAgent.mock.calls as unknown[][],
"configured embedded OpenClaw consult",
),
"configured embedded OpenClaw consult params",
);
expect(consultParams.thinkLevel).toBe("low");
expect(consultParams.fastMode).toBe(true);
});
});

View File

@@ -0,0 +1,511 @@
// Voice Call plugin module implements runtime behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import {
consultRealtimeVoiceAgent,
REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME,
resolveRealtimeVoiceAgentConsultTools,
resolveRealtimeVoiceAgentConsultToolsAllow,
type RealtimeVoiceAgentConsultTranscriptEntry,
type ResolvedRealtimeVoiceProvider,
} from "openclaw/plugin-sdk/realtime-voice";
import type { VoiceCallConfig } from "./config.js";
import {
resolveVoiceCallEffectiveConfig,
resolveVoiceCallNumberRouteKeyForCall,
resolveVoiceCallSessionKey,
resolveTwilioAuthToken,
resolveVoiceCallConfig,
validateProviderConfig,
} from "./config.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
import { CallManager } from "./manager.js";
import type { VoiceCallProvider } from "./providers/base.js";
import type { TwilioProvider } from "./providers/twilio.js";
import { buildRealtimeVoiceInstructions } from "./realtime-agent-context.js";
import { resolveRealtimeFastContextConsult } from "./realtime-fast-context.js";
import { resolveVoiceResponseModel } from "./response-model.js";
import { setVoiceCallStateRuntime, type VoiceCallStateRuntime } from "./runtime-state.js";
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
import { createTelephonyTtsProvider } from "./telephony-tts.js";
import { startTunnel, type TunnelResult } from "./tunnel.js";
import {
isProviderUnreachableWebhookUrl,
providerRequiresPublicWebhook,
} from "./webhook-exposure.js";
import { VoiceCallWebhookServer } from "./webhook.js";
import type { ToolHandlerContext } from "./webhook/realtime-handler.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 = ResolvedRealtimeVoiceProvider;
const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
"You are the configured OpenClaw agent receiving delegated requests from a live phone voice bridge.",
"Act on behalf of the caller using the normal available tools when the caller asks you to do work.",
"Prioritize completing the user's request and returning a fast, speakable result over exhaustive investigation.",
"For tool-backed status checks, prefer one or two bounded read-only queries before answering.",
"Do not print secret values or dump environment variables; only check whether required configuration is present.",
"Be accurate, brief, and speakable.",
].join(" ");
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./providers/telnyx.js"));
const loadTwilioProvider = createLazyRuntimeModule(() => import("./providers/twilio.js"));
const loadPlivoProvider = createLazyRuntimeModule(() => import("./providers/plivo.js"));
const loadMockProvider = createLazyRuntimeModule(() => import("./providers/mock.js"));
const loadRealtimeVoiceRuntime = createLazyRuntimeModule(
() => import("./realtime-voice.runtime.js"),
);
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./webhook/realtime-handler.js"));
function resolveVoiceCallConsultSessionKey(call: {
config: VoiceCallConfig;
coreSession?: OpenClawConfig["session"];
sessionKey?: string;
from?: string;
to?: string;
direction?: "inbound" | "outbound";
callId: string;
}): string {
return resolveVoiceCallSessionKey({
config: call.config,
callId: call.callId,
phone: call.direction === "outbound" ? call.to : call.from,
explicitSessionKey: call.sessionKey,
coreSession: call.coreSession,
});
}
function mapVoiceCallConsultTranscript(
call: {
transcript?: Array<{ speaker: "user" | "bot"; text: string }>;
},
context?: ToolHandlerContext,
): RealtimeVoiceAgentConsultTranscriptEntry[] {
const transcript: RealtimeVoiceAgentConsultTranscriptEntry[] = (call.transcript ?? []).map(
(entry) => ({
role: entry.speaker === "bot" ? "assistant" : "user",
text: entry.text,
}),
);
const partial = context?.partialUserTranscript?.trim();
if (partial && transcript.at(-1)?.text !== partial) {
transcript.push({ role: "user", text: partial });
}
return transcript;
}
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);
},
};
}
async function resolveProvider(config: VoiceCallConfig): Promise<VoiceCallProvider> {
const allowNgrokFreeTierLoopbackBypass =
config.tunnel?.provider === "ngrok" &&
isLoopbackHost(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: resolveTwilioAuthToken(config),
},
{
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 { resolveConfiguredRealtimeVoiceProvider } = await loadRealtimeVoiceRuntime();
return resolveConfiguredRealtimeVoiceProvider({
configuredProviderId: params.config.realtime.provider,
providerConfigs: params.config.realtime.providers,
cfg: params.fullConfig,
});
}
export async function createVoiceCallRuntime(params: {
config: VoiceCallConfig;
coreConfig: CoreConfig;
fullConfig?: OpenClawConfig;
agentRuntime: CoreAgentDeps;
stateRuntime?: VoiceCallStateRuntime["state"];
ttsRuntime?: TelephonyTtsRuntime;
logger?: Logger;
}): Promise<VoiceCallRuntime> {
const {
config: rawConfig,
coreConfig,
fullConfig,
agentRuntime,
stateRuntime,
ttsRuntime,
logger,
} = params;
const log = logger ?? {
info: console.log,
warn: console.warn,
error: console.error,
debug: console.debug,
};
const config = resolveVoiceCallConfig(rawConfig);
const cfg = fullConfig ?? (coreConfig as OpenClawConfig);
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);
if (stateRuntime) {
setVoiceCallStateRuntime({ state: stateRuntime });
}
const manager = new CallManager(config, undefined, cfg.session);
const realtimeProvider = config.realtime.enabled
? await resolveRealtimeProvider({
config,
fullConfig: cfg,
})
: null;
const webhookServer = new VoiceCallWebhookServer(
config,
manager,
provider,
coreConfig,
fullConfig ?? (coreConfig as OpenClawConfig),
agentRuntime,
log,
);
if (realtimeProvider) {
const { RealtimeCallHandler } = await loadRealtimeHandler();
const realtimeInstructions = await buildRealtimeVoiceInstructions({
baseInstructions: config.realtime.instructions,
config,
coreConfig,
agentRuntime,
});
const realtimeConfig = {
...config.realtime,
instructions: realtimeInstructions,
tools: resolveRealtimeVoiceAgentConsultTools(
config.realtime.toolPolicy,
config.realtime.tools,
),
};
const realtimeHandler = new RealtimeCallHandler(
realtimeConfig,
manager,
provider,
realtimeProvider.provider,
realtimeProvider.providerConfig,
config.serve.path,
cfg,
);
if (config.realtime.toolPolicy !== "none") {
realtimeHandler.registerToolHandler(
REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME,
async (args, callId, handlerContext) => {
const call = manager.getCall(callId);
if (!call) {
return { error: `Call "${callId}" not found` };
}
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
const agentId = effectiveConfig.agentId ?? "main";
const sessionKey = resolveVoiceCallConsultSessionKey({
...call,
config: effectiveConfig,
coreSession: cfg.session,
});
const requesterSessionKey =
typeof call.metadata?.requesterSessionKey === "string"
? call.metadata.requesterSessionKey
: undefined;
const fastContext = await resolveRealtimeFastContextConsult({
cfg,
agentId,
sessionKey,
config: effectiveConfig.realtime.fastContext,
args,
logger: log,
});
if (fastContext.handled) {
return fastContext.result;
}
const { provider: agentProvider, model } = resolveVoiceResponseModel({
voiceConfig: effectiveConfig,
agentRuntime,
});
const thinkLevel =
effectiveConfig.realtime.consultThinkingLevel ??
agentRuntime.resolveThinkingDefault({
cfg,
provider: agentProvider,
model,
});
return await consultRealtimeVoiceAgent({
cfg,
agentRuntime,
logger: log,
agentId,
sessionKey,
messageProvider: "voice",
lane: "voice",
runIdPrefix: `voice-realtime-consult:${callId}`,
args,
transcript: mapVoiceCallConsultTranscript(call, handlerContext),
surface: "a live phone call",
userLabel: "Caller",
assistantLabel: "Agent",
questionSourceLabel: "caller",
provider: agentProvider,
model,
thinkLevel,
fastMode: effectiveConfig.realtime.consultFastMode,
timeoutMs: effectiveConfig.responseTimeoutMs,
spawnedBy: requesterSessionKey,
contextMode: requesterSessionKey ? "fork" : undefined,
toolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow(
effectiveConfig.realtime.toolPolicy,
),
extraSystemPrompt: REALTIME_VOICE_CONSULT_SYSTEM_PROMPT,
});
},
);
}
webhookServer.setRealtimeHandler(realtimeHandler);
}
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 (
providerRequiresPublicWebhook(provider.name) &&
isProviderUnreachableWebhookUrl(webhookUrl)
) {
throw new Error(
`[voice-call] ${provider.name} requires a publicly reachable webhook URL. ` +
`Refusing to use local-only webhook ${webhookUrl}. ` +
"Set plugins.entries.voice-call.config.publicUrl or enable tunnel/tailscale exposure.",
);
}
if (publicUrl) {
provider.setPublicUrl?.(publicUrl);
}
if (publicUrl && realtimeProvider) {
webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
}
const realtimeHandler = webhookServer.getRealtimeHandler();
if (realtimeHandler) {
manager.streamSessionIssuer = (request) => realtimeHandler.issueStreamSession(request);
}
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 && publicUrl !== webhookUrl) {
log.info(`[voice-call] Public URL: ${publicUrl}`);
}
return {
config,
provider,
manager,
webhookServer,
webhookUrl,
publicUrl,
stop,
};
} catch (err) {
// If any step after the server started fails, clean up every provisioned
// resource (tunnel, tailscale exposure, and webhook server) so retries
// don't leak processes or keep the port bound.
await lifecycle.stop({ suppressErrors: true });
throw err;
}
}

View File

@@ -0,0 +1,82 @@
// Voice Call tests cover telephony audio plugin behavior.
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);
}
function unalignedCopy(buffer: Buffer): Buffer {
const padded = Buffer.alloc(buffer.length + 1);
buffer.copy(padded, 1);
return padded.subarray(1);
}
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);
});
it("matches the typed-array path for unaligned input buffers", () => {
const input = makeSinePcm(48_000, 1_000, 0.2);
const output = resamplePcmTo8k(input, 48_000);
const unalignedOutput = resamplePcmTo8k(unalignedCopy(input), 48_000);
expect(unalignedOutput.equals(output)).toBe(true);
});
});
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);
});
it("matches the typed-array path for unaligned pcm buffers", () => {
const input = makeSinePcm(8_000, 1_000, 0.2);
const mulaw = convertPcmToMulaw8k(input, 8_000);
const unalignedMulaw = convertPcmToMulaw8k(unalignedCopy(input), 8_000);
expect(unalignedMulaw.equals(mulaw)).toBe(true);
});
});

View File

@@ -0,0 +1,13 @@
// Voice Call plugin module implements telephony audio behavior.
export { convertPcmToMulaw8k, resamplePcmTo8k } from "openclaw/plugin-sdk/realtime-voice";
/**
* 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));
}
})();
}

View File

@@ -0,0 +1,215 @@
// Voice Call tests cover telephony tts plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
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",
);
});
it("strips telephony TTS directive tags before synthesis", async () => {
let requestText: string | undefined;
const provider = createTelephonyTtsProvider({
coreConfig: createCoreConfig(),
runtime: {
textToSpeechTelephony: async ({ text }) => {
requestText = text;
return {
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
};
},
},
});
await provider.synthesizeForTelephony("[[tts]]Hello caller[[/tts]]");
expect(requestText).toBe("Hello caller");
});
it("uses hidden telephony TTS directive text for synthesis", async () => {
let requestText: string | undefined;
let requestOverrides: unknown;
const provider = createTelephonyTtsProvider({
coreConfig: createCoreConfig(),
runtime: {
textToSpeechTelephony: async ({ text, overrides }) => {
requestText = text;
requestOverrides = overrides;
return {
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
};
},
},
});
await provider.synthesizeForTelephony(
"Visible text [[tts:text]]Speak this instead[[/tts:text]]",
);
expect(requestText).toBe("Speak this instead");
expect(requestOverrides).toStrictEqual({ ttsText: "Speak this instead" });
});
it("exposes configured timeoutMs as synthesisTimeoutMs", () => {
const provider = createTelephonyTtsProvider({
coreConfig: { messages: { tts: { provider: "openai", timeoutMs: 15000 } } },
runtime: {
textToSpeechTelephony: async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
}),
},
});
expect(provider.synthesisTimeoutMs).toBe(15000);
});
it("clamps oversized configured timeoutMs", () => {
const provider = createTelephonyTtsProvider({
coreConfig: {
messages: { tts: { provider: "openai", timeoutMs: Number.MAX_SAFE_INTEGER } },
},
runtime: {
textToSpeechTelephony: async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
}),
},
});
expect(provider.synthesisTimeoutMs).toBe(MAX_TIMER_TIMEOUT_MS);
});
it("keeps the telephony timeout default when timeoutMs is not configured", () => {
const provider = createTelephonyTtsProvider({
coreConfig: createCoreConfig(),
runtime: {
textToSpeechTelephony: async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
}),
},
});
expect(provider.synthesisTimeoutMs).toBe(8000);
});
});

View File

@@ -0,0 +1,253 @@
// Voice Call plugin module implements telephony tts behavior.
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import {
parseTtsDirectives,
type SpeechModelOverridePolicy,
type SpeechProviderConfig,
type TtsDirectiveOverrides,
} from "openclaw/plugin-sdk/speech";
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";
// Telephony TTS adapter that applies voice-call overrides and emits 8kHz mulaw audio.
/** Core runtime TTS API used by the telephony adapter. */
export type TelephonyTtsRuntime = {
textToSpeechTelephony: (params: {
text: string;
cfg: CoreConfig;
prefsPath?: string;
overrides?: TtsDirectiveOverrides;
}) => Promise<{
success: boolean;
audioBuffer?: Buffer;
sampleRate?: number;
provider?: string;
fallbackFrom?: string;
attemptedProviders?: string[];
error?: string;
}>;
};
/** Provider facade used by Twilio/webhook code for telephony synthesis. */
export type TelephonyTtsProvider = {
synthesisTimeoutMs: number;
synthesizeForTelephony: (text: string) => Promise<Buffer>;
};
/** Default timeout for one telephony synthesis request. */
export const TELEPHONY_DEFAULT_TTS_TIMEOUT_MS = 8000;
/** Voice-call override policy for inline TTS model directives. */
type TelephonyModelOverrideConfig = {
enabled?: boolean;
allowText?: boolean;
allowProvider?: boolean;
allowVoice?: boolean;
allowModelId?: boolean;
allowVoiceSettings?: boolean;
allowNormalization?: boolean;
allowSeed?: boolean;
};
/** Create a TTS provider that honors voice-call overrides and converts PCM to mulaw. */
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);
const ttsConfig = mergedConfig.messages?.tts;
const modelOverrides = resolveTelephonyModelOverridePolicy(
readTelephonyModelOverrides(ttsConfig),
);
const providerConfigs = collectTelephonyProviderConfigs(ttsConfig);
const activeProvider = normalizeProviderId(ttsConfig?.provider);
const synthesisTimeoutMs = resolveTimerTimeoutMs(
mergedConfig.messages?.tts?.timeoutMs,
TELEPHONY_DEFAULT_TTS_TIMEOUT_MS,
);
return {
synthesisTimeoutMs,
synthesizeForTelephony: async (text: string) => {
const directives = parseTtsDirectives(text, modelOverrides, {
cfg: mergedConfig,
providerConfigs,
preferredProviderId: activeProvider,
});
if (directives.warnings.length > 0) {
logger?.warn?.(
`[voice-call] Ignored telephony TTS directive overrides (${directives.warnings.join("; ")})`,
);
}
const cleanText = directives.hasDirective
? directives.ttsText?.trim() || directives.cleanedText.trim()
: text;
const result = await runtime.textToSpeechTelephony({
text: cleanText,
cfg: mergedConfig,
overrides: directives.overrides,
});
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);
},
};
}
/** Apply voice-call TTS overrides to core config without mutating the original object. */
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,
},
};
}
/** Merge core and voice-call TTS config, keeping undefined override fields out. */
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;
}
/** Resolve directive override policy for telephony synthesis. */
function resolveTelephonyModelOverridePolicy(
overrides: TelephonyModelOverrideConfig | undefined,
): SpeechModelOverridePolicy {
const enabled = overrides?.enabled ?? true;
if (!enabled) {
return {
enabled: false,
allowText: false,
allowProvider: false,
allowVoice: false,
allowModelId: false,
allowVoiceSettings: false,
allowNormalization: false,
allowSeed: false,
};
}
const allow = (value: boolean | undefined, defaultValue = true) => value ?? defaultValue;
return {
enabled: true,
allowText: allow(overrides?.allowText),
allowProvider: allow(overrides?.allowProvider, false),
allowVoice: allow(overrides?.allowVoice),
allowModelId: allow(overrides?.allowModelId),
allowVoiceSettings: allow(overrides?.allowVoiceSettings),
allowNormalization: allow(overrides?.allowNormalization),
allowSeed: allow(overrides?.allowSeed),
};
}
/** Read model override policy from TTS config when present. */
function readTelephonyModelOverrides(
ttsConfig: VoiceCallTtsConfig | undefined,
): TelephonyModelOverrideConfig | undefined {
const value = (ttsConfig as Record<string, unknown> | undefined)?.modelOverrides;
return value && typeof value === "object" && !Array.isArray(value)
? (value as TelephonyModelOverrideConfig)
: undefined;
}
/** Normalize provider ids for config lookup. */
function normalizeProviderId(value: unknown): string | undefined {
return typeof value === "string" ? value.trim().toLowerCase() || undefined : undefined;
}
/** Coerce provider config objects while rejecting arrays and primitives. */
function asProviderConfig(value: unknown): SpeechProviderConfig {
return value && typeof value === "object" && !Array.isArray(value)
? (value as SpeechProviderConfig)
: {};
}
/** Collect named provider configs from canonical and legacy TTS config shapes. */
function collectTelephonyProviderConfigs(
ttsConfig: VoiceCallTtsConfig | undefined,
): Record<string, SpeechProviderConfig> {
if (!ttsConfig) {
return {};
}
const entries: Record<string, SpeechProviderConfig> = {};
const rawProviders =
ttsConfig.providers &&
typeof ttsConfig.providers === "object" &&
!Array.isArray(ttsConfig.providers)
? (ttsConfig.providers as Record<string, unknown>)
: {};
for (const [providerId, value] of Object.entries(rawProviders)) {
const normalized = normalizeProviderId(providerId) ?? providerId;
entries[normalized] = asProviderConfig(value);
}
const reservedKeys = new Set([
"auto",
"enabled",
"maxTextLength",
"mode",
"modelOverrides",
"persona",
"personas",
"prefsPath",
"provider",
"providers",
"summaryModel",
"timeoutMs",
]);
for (const [key, value] of Object.entries(ttsConfig as Record<string, unknown>)) {
if (
reservedKeys.has(key) ||
typeof value !== "object" ||
value === null ||
Array.isArray(value)
) {
continue;
}
const normalized = normalizeProviderId(key) ?? key;
entries[normalized] ??= asProviderConfig(value);
}
return entries;
}

View File

@@ -0,0 +1,82 @@
// Voice Call plugin module implements test fixtures behavior.
import type { VoiceCallConfig } from "./config.js";
import { DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS } from "./realtime-defaults.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: [],
numbers: {},
outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
maxDurationSeconds: 300,
staleCallReaperSeconds: 600,
silenceTimeoutMs: 800,
transcriptTimeoutMs: 180000,
ringTimeoutMs: 30000,
maxConcurrentCalls: 1,
sessionScope: "per-phone",
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",
instructions: DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS,
toolPolicy: "safe-read-only",
consultPolicy: "auto",
tools: [],
fastContext: {
enabled: false,
timeoutMs: 800,
maxResults: 3,
sources: ["memory", "sessions"],
fallbackToConsult: false,
},
agentContext: {
enabled: false,
maxChars: 6000,
includeIdentity: true,
includeWorkspaceFiles: true,
files: ["SOUL.md", "IDENTITY.md", "USER.md"],
},
providers: {},
},
skipSignatureVerification: false,
tts: {
provider: "openai",
providers: {
openai: { model: "gpt-4o-mini-tts", voice: "coral" },
},
},
responseTimeoutMs: 30000,
};
}

View File

@@ -0,0 +1,51 @@
// Voice Call tests cover tts provider voice plugin behavior.
import { describe, expect, it } from "vitest";
import { resolvePreferredTtsVoice } from "./tts-provider-voice.js";
describe("resolvePreferredTtsVoice", () => {
it("returns provider speakerVoice when present", () => {
expect(
resolvePreferredTtsVoice({
tts: {
provider: "openai",
providers: {
openai: {
speakerVoice: "coral",
},
},
},
}),
).toBe("coral");
});
it("returns provider speakerVoiceId when present", () => {
expect(
resolvePreferredTtsVoice({
tts: {
provider: "elevenlabs",
providers: {
elevenlabs: {
speakerVoiceId: "voice-123",
},
},
},
}),
).toBe("voice-123");
});
it("keeps legacy voice and voiceId fallback compatibility", () => {
expect(
resolvePreferredTtsVoice({
tts: {
provider: "openai",
providers: {
openai: {
voice: "legacy-voice",
voiceId: "legacy-id",
},
},
},
}),
).toBe("legacy-voice");
});
});

View File

@@ -0,0 +1,33 @@
// Voice Call provider module implements model/runtime integration.
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { VoiceCallTtsConfig } from "./config.js";
// Resolves preferred voice settings from configured TTS provider blocks.
/** Read voice setting aliases from one provider-specific config block. */
function resolveProviderVoiceSetting(providerConfig: unknown): string | undefined {
if (!providerConfig || typeof providerConfig !== "object") {
return undefined;
}
const candidate = providerConfig as {
speakerVoice?: unknown;
speakerVoiceId?: unknown;
voice?: unknown;
voiceId?: unknown;
};
return (
normalizeOptionalString(candidate.speakerVoice) ??
normalizeOptionalString(candidate.speakerVoiceId) ??
normalizeOptionalString(candidate.voice) ??
normalizeOptionalString(candidate.voiceId)
);
}
/** Resolve the active provider's preferred voice id/name from voice-call TTS config. */
export function resolvePreferredTtsVoice(config: { tts?: VoiceCallTtsConfig }): string | undefined {
const providerId = config.tts?.provider;
if (!providerId) {
return undefined;
}
return resolveProviderVoiceSetting(config.tts?.providers?.[providerId]);
}

View File

@@ -0,0 +1,233 @@
// Voice Call tests cover tunnel plugin behavior.
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
class FakeChildProcess extends EventEmitter {
readonly stdout = new EventEmitter();
readonly stderr = new EventEmitter();
killedWith: NodeJS.Signals | null = null;
kill(signal: NodeJS.Signals = "SIGTERM"): boolean {
this.killedWith = signal;
queueMicrotask(() => this.emit("close", null));
return true;
}
close(code: number | null = 0): void {
this.emit("close", code);
}
fail(error: Error): void {
this.emit("error", error);
}
}
const mocks = vi.hoisted(() => ({
spawn: vi.fn(),
getTailscaleDnsName: vi.fn(),
}));
vi.mock("node:child_process", () => ({
spawn: mocks.spawn,
}));
vi.mock("./webhook/tailscale.js", () => ({
getTailscaleDnsName: mocks.getTailscaleDnsName,
}));
import { startNgrokTunnel, startTailscaleTunnel, startTunnel } from "./tunnel.js";
function nextProcess(): FakeChildProcess {
const proc = new FakeChildProcess();
mocks.spawn.mockReturnValueOnce(proc as never);
return proc;
}
function emitNgrokUrl(proc: FakeChildProcess, url: string): void {
proc.stdout.emit("data", Buffer.from(`${JSON.stringify({ msg: "started tunnel", url })}\n`));
}
describe("voice-call tunnels", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getTailscaleDnsName.mockReset();
});
it("starts ngrok and appends the webhook path to the public URL", async () => {
const proc = nextProcess();
const result = startNgrokTunnel({ port: 3334, path: "/voice/webhook" });
emitNgrokUrl(proc, "https://abc.ngrok.io");
const tunnel = await result;
expect(tunnel.publicUrl).toBe("https://abc.ngrok.io/voice/webhook");
expect(tunnel.provider).toBe("ngrok");
expect(tunnel.stop).toBeTypeOf("function");
expect(mocks.spawn).toHaveBeenCalledWith(
"ngrok",
["http", "3334", "--log", "stdout", "--log-format", "json"],
{
stdio: ["ignore", "pipe", "pipe"],
},
);
});
it("parses complete ngrok log lines before bounding the incomplete tail", async () => {
const proc = nextProcess();
const result = startNgrokTunnel({ port: 3334, path: "/voice/webhook" });
proc.stdout.emit(
"data",
Buffer.from(
`${JSON.stringify({ msg: "started tunnel", url: "https://large.ngrok.io" })}\n${"x".repeat(20_000)}`,
),
);
const settled = await Promise.race([
result.then(() => true),
new Promise<boolean>((resolve) => {
setTimeout(() => resolve(false), 20);
}),
]);
expect(settled).toBe(true);
const tunnel = await result;
expect(tunnel.publicUrl).toBe("https://large.ngrok.io/voice/webhook");
});
it("sets ngrok auth token before starting the tunnel", async () => {
const authProc = nextProcess();
const tunnelProc = nextProcess();
const result = startNgrokTunnel({
port: 3334,
path: "/hook",
authToken: "token",
});
authProc.close(0);
await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledTimes(2));
emitNgrokUrl(tunnelProc, "https://auth.ngrok.io");
const tunnel = await result;
expect(tunnel.publicUrl).toBe("https://auth.ngrok.io/hook");
expect(tunnel.provider).toBe("ngrok");
expect(mocks.spawn).toHaveBeenNthCalledWith(1, "ngrok", ["config", "add-authtoken", "token"], {
stdio: ["ignore", "pipe", "pipe"],
});
});
it("bounds ngrok command failure output", async () => {
const authProc = nextProcess();
const result = startNgrokTunnel({
port: 3334,
path: "/hook",
authToken: "token",
});
authProc.stderr.emit("data", Buffer.from(`start-${"x".repeat(20_000)}-end`));
authProc.close(1);
await expect(result).rejects.toThrow("[output truncated]");
await expect(result).rejects.toThrow("-end");
await expect(result).rejects.not.toThrow("start-");
});
it("rejects ngrok startup errors from stderr", async () => {
const proc = nextProcess();
const result = startNgrokTunnel({ port: 3334, path: "/hook" });
proc.stderr.emit("data", Buffer.from("ERR_NGROK_3200: invalid auth token"));
await expect(result).rejects.toThrow("ngrok error:");
});
it("starts Tailscale serve using the resolved tailnet DNS name", async () => {
mocks.getTailscaleDnsName.mockResolvedValue("host.tailnet.ts.net");
const proc = nextProcess();
const result = startTailscaleTunnel({
mode: "serve",
port: 3334,
path: "voice/webhook",
});
await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalled());
proc.close(0);
const tunnel = await result;
expect(tunnel.publicUrl).toBe("https://host.tailnet.ts.net/voice/webhook");
expect(tunnel.provider).toBe("tailscale-serve");
expect(tunnel.stop).toBeTypeOf("function");
expect(mocks.spawn).toHaveBeenCalledWith(
"tailscale",
[
"serve",
"--bg",
"--yes",
"--set-path",
"/voice/webhook",
"http://127.0.0.1:3334/voice/webhook",
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
});
it("drains and bounds Tailscale startup failure output", async () => {
mocks.getTailscaleDnsName.mockResolvedValue("host.tailnet.ts.net");
const proc = nextProcess();
const result = startTailscaleTunnel({
mode: "funnel",
port: 3334,
path: "/voice/webhook",
});
await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalled());
proc.stderr.emit("data", Buffer.from(`start-${"x".repeat(20_000)}-end`));
proc.close(1);
await expect(result).rejects.toThrow("Tailscale funnel failed with code 1");
await expect(result).rejects.toThrow("[output truncated]");
await expect(result).rejects.toThrow("-end");
await expect(result).rejects.not.toThrow("start-");
});
it("rejects Tailscale tunnel startup when the DNS name is unavailable", async () => {
mocks.getTailscaleDnsName.mockResolvedValue(null);
await expect(
startTailscaleTunnel({ mode: "funnel", port: 3334, path: "/hook" }),
).rejects.toThrow("Could not get Tailscale DNS name");
expect(mocks.spawn).not.toHaveBeenCalled();
});
it("dispatches tunnel providers from config", async () => {
await expect(startTunnel({ provider: "none", port: 3334, path: "/hook" })).resolves.toBeNull();
const proc = nextProcess();
const result = startTunnel({ provider: "ngrok", port: 3334, path: "/hook" });
emitNgrokUrl(proc, "https://dispatch.ngrok.io");
const tunnel = await result;
expect(tunnel?.publicUrl).toBe("https://dispatch.ngrok.io/hook");
expect(tunnel?.provider).toBe("ngrok");
});
it("handles spawn errors on tailscale stop cleanup without crashing", async () => {
mocks.getTailscaleDnsName.mockResolvedValue("host.tailnet.ts.net");
// Start the tunnel — first spawn is tailscale serve (succeeds)
const startProc = nextProcess();
const result = startTailscaleTunnel({ mode: "serve", port: 3334, path: "/voice/stop" });
await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalled());
startProc.close(0);
const tunnel = await result;
// Stop the tunnel — second spawn is tailscale stop (errors)
const stopProc = nextProcess();
const stopPromise = tunnel.stop();
await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledTimes(2));
// Emit error on the stop process — without the fix this crashes
stopProc.fail(new Error("tailscale not found"));
// The stop promise must still resolve despite the error
await expect(stopPromise).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,322 @@
// Voice Call plugin module implements tunnel behavior.
import { spawn } from "node:child_process";
import {
appendBoundedChildOutput,
emptyBoundedChildOutput,
formatBoundedChildOutput,
} from "./bounded-child-output.js";
import { getTailscaleDnsName } from "./webhook/tailscale.js";
const NGROK_LOG_BUFFER_MAX_CHARS = 16_384;
/**
* Tunnel configuration for exposing the webhook server.
*/
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) => {
const lines = (outputBuffer + data.toString()).split("\n");
outputBuffer = lines.pop() || "";
if (outputBuffer.length > NGROK_LOG_BUFFER_MAX_CHARS) {
outputBuffer = outputBuffer.slice(-NGROK_LOG_BUFFER_MAX_CHARS);
}
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);
const output = appendBoundedChildOutput(emptyBoundedChildOutput(), msg);
reject(new Error(`ngrok error: ${formatBoundedChildOutput(output)}`));
}
}
});
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 = emptyBoundedChildOutput();
let stderr = emptyBoundedChildOutput();
proc.stdout.on("data", (data) => {
stdout = appendBoundedChildOutput(stdout, data.toString());
});
proc.stderr.on("data", (data) => {
stderr = appendBoundedChildOutput(stderr, data.toString());
});
proc.on("close", (code) => {
if (code === 0) {
resolve(stdout.text);
} else {
const output = stderr.text ? stderr : stdout;
reject(new Error(`ngrok command failed: ${formatBoundedChildOutput(output)}`));
}
});
proc.on("error", reject);
});
}
/**
* 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"],
});
let stdout = emptyBoundedChildOutput();
let stderr = emptyBoundedChildOutput();
const timeout = setTimeout(() => {
proc.kill("SIGKILL");
reject(new Error(`Tailscale ${config.mode} timed out`));
}, 10000);
proc.stdout.on("data", (data) => {
stdout = appendBoundedChildOutput(stdout, data.toString());
});
proc.stderr.on("data", (data) => {
stderr = appendBoundedChildOutput(stderr, data.toString());
});
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 {
const output = stderr.text ? stderr : stdout;
const detail = output.text ? `: ${formatBoundedChildOutput(output)}` : "";
reject(new Error(`Tailscale ${config.mode} failed with code ${code}${detail}`));
}
});
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();
});
proc.on("error", () => {
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;
}
}

Some files were not shown because too many files have changed in this diff Show More