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
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:
165
extensions/voice-call/README.md
Normal file
165
extensions/voice-call/README.md
Normal 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.
|
||||
18
extensions/voice-call/api.ts
Normal file
18
extensions/voice-call/api.ts
Normal 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";
|
||||
13
extensions/voice-call/cli-metadata.ts
Normal file
13
extensions/voice-call/cli-metadata.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
12
extensions/voice-call/config-api.ts
Normal file
12
extensions/voice-call/config-api.ts
Normal 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";
|
||||
252
extensions/voice-call/doctor-contract-api.test.ts
Normal file
252
extensions/voice-call/doctor-contract-api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
337
extensions/voice-call/doctor-contract-api.ts
Normal file
337
extensions/voice-call/doctor-contract-api.ts
Normal 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 };
|
||||
},
|
||||
},
|
||||
];
|
||||
1187
extensions/voice-call/index.test.ts
Normal file
1187
extensions/voice-call/index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
897
extensions/voice-call/index.ts
Normal file
897
extensions/voice-call/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
71
extensions/voice-call/npm-shrinkwrap.json
generated
Normal file
71
extensions/voice-call/npm-shrinkwrap.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
914
extensions/voice-call/openclaw.plugin.json
Normal file
914
extensions/voice-call/openclaw.plugin.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
48
extensions/voice-call/package.json
Normal file
48
extensions/voice-call/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
20
extensions/voice-call/runtime-api.ts
Normal file
20
extensions/voice-call/runtime-api.ts
Normal 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";
|
||||
3
extensions/voice-call/runtime-entry.ts
Normal file
3
extensions/voice-call/runtime-entry.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Runtime entrypoint for the voice-call plugin package.
|
||||
|
||||
export { createVoiceCallRuntime, type VoiceCallRuntime } from "./src/runtime.js";
|
||||
52
extensions/voice-call/setup-api.ts
Normal file
52
extensions/voice-call/setup-api.ts
Normal 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));
|
||||
},
|
||||
});
|
||||
45
extensions/voice-call/skills/voice-call/SKILL.md
Normal file
45
extensions/voice-call/skills/voice-call/SKILL.md
Normal 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).
|
||||
19
extensions/voice-call/src/allowlist.test.ts
Normal file
19
extensions/voice-call/src/allowlist.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
extensions/voice-call/src/allowlist.ts
Normal file
23
extensions/voice-call/src/allowlist.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
18
extensions/voice-call/src/bounded-child-output.test.ts
Normal file
18
extensions/voice-call/src/bounded-child-output.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
35
extensions/voice-call/src/bounded-child-output.ts
Normal file
35
extensions/voice-call/src/bounded-child-output.ts
Normal 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;
|
||||
}
|
||||
81
extensions/voice-call/src/cli.test.ts
Normal file
81
extensions/voice-call/src/cli.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
929
extensions/voice-call/src/cli.ts
Normal file
929
extensions/voice-call/src/cli.ts
Normal 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 };
|
||||
210
extensions/voice-call/src/config-compat.test.ts
Normal file
210
extensions/voice-call/src/config-compat.test.ts
Normal 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.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
275
extensions/voice-call/src/config-compat.ts
Normal file
275
extensions/voice-call/src/config-compat.ts
Normal 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));
|
||||
}
|
||||
715
extensions/voice-call/src/config.test.ts
Normal file
715
extensions/voice-call/src/config.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
935
extensions/voice-call/src/config.ts
Normal file
935
extensions/voice-call/src/config.ts
Normal 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 };
|
||||
}
|
||||
17
extensions/voice-call/src/core-bridge.ts
Normal file
17
extensions/voice-call/src/core-bridge.ts
Normal 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"];
|
||||
41
extensions/voice-call/src/deep-merge.test.ts
Normal file
41
extensions/voice-call/src/deep-merge.test.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
26
extensions/voice-call/src/deep-merge.ts
Normal file
26
extensions/voice-call/src/deep-merge.ts
Normal 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;
|
||||
}
|
||||
29
extensions/voice-call/src/gateway-continue-operation.test.ts
Normal file
29
extensions/voice-call/src/gateway-continue-operation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
211
extensions/voice-call/src/gateway-continue-operation.ts
Normal file
211
extensions/voice-call/src/gateway-continue-operation.ts
Normal 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 };
|
||||
}
|
||||
17
extensions/voice-call/src/http-headers.test.ts
Normal file
17
extensions/voice-call/src/http-headers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
19
extensions/voice-call/src/http-headers.ts
Normal file
19
extensions/voice-call/src/http-headers.ts
Normal 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;
|
||||
}
|
||||
260
extensions/voice-call/src/manager.closed-loop.test.ts
Normal file
260
extensions/voice-call/src/manager.closed-loop.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
184
extensions/voice-call/src/manager.inbound-allowlist.test.ts
Normal file
184
extensions/voice-call/src/manager.inbound-allowlist.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
393
extensions/voice-call/src/manager.notify.test.ts
Normal file
393
extensions/voice-call/src/manager.notify.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
376
extensions/voice-call/src/manager.restore.test.ts
Normal file
376
extensions/voice-call/src/manager.restore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
165
extensions/voice-call/src/manager.test-harness.ts
Normal file
165
extensions/voice-call/src/manager.test-harness.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
464
extensions/voice-call/src/manager.ts
Normal file
464
extensions/voice-call/src/manager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
52
extensions/voice-call/src/manager/context.ts
Normal file
52
extensions/voice-call/src/manager/context.ts
Normal 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;
|
||||
760
extensions/voice-call/src/manager/events.test.ts
Normal file
760
extensions/voice-call/src/manager/events.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
353
extensions/voice-call/src/manager/events.ts
Normal file
353
extensions/voice-call/src/manager/events.ts
Normal 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);
|
||||
}
|
||||
58
extensions/voice-call/src/manager/lifecycle.ts
Normal file
58
extensions/voice-call/src/manager/lifecycle.ts
Normal 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);
|
||||
}
|
||||
53
extensions/voice-call/src/manager/lookup.test.ts
Normal file
53
extensions/voice-call/src/manager/lookup.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
40
extensions/voice-call/src/manager/lookup.ts
Normal file
40
extensions/voice-call/src/manager/lookup.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
708
extensions/voice-call/src/manager/outbound.test.ts
Normal file
708
extensions/voice-call/src/manager/outbound.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
533
extensions/voice-call/src/manager/outbound.ts
Normal file
533
extensions/voice-call/src/manager/outbound.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
49
extensions/voice-call/src/manager/state.ts
Normal file
49
extensions/voice-call/src/manager/state.ts
Normal 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);
|
||||
}
|
||||
154
extensions/voice-call/src/manager/store.test.ts
Normal file
154
extensions/voice-call/src/manager/store.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
396
extensions/voice-call/src/manager/store.ts
Normal file
396
extensions/voice-call/src/manager/store.ts
Normal 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 [];
|
||||
}
|
||||
18
extensions/voice-call/src/manager/timer-delays.test.ts
Normal file
18
extensions/voice-call/src/manager/timer-delays.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
22
extensions/voice-call/src/manager/timer-delays.ts
Normal file
22
extensions/voice-call/src/manager/timer-delays.ts
Normal 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);
|
||||
}
|
||||
163
extensions/voice-call/src/manager/timers.test.ts
Normal file
163
extensions/voice-call/src/manager/timers.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
154
extensions/voice-call/src/manager/timers.ts
Normal file
154
extensions/voice-call/src/manager/timers.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
14
extensions/voice-call/src/manager/twiml.test.ts
Normal file
14
extensions/voice-call/src/manager/twiml.test.ts
Normal 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 <ended> & "logged"</Say>
|
||||
<Hangup/>
|
||||
</Response>`);
|
||||
});
|
||||
});
|
||||
22
extensions/voice-call/src/manager/twiml.ts
Normal file
22
extensions/voice-call/src/manager/twiml.ts
Normal 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>`;
|
||||
}
|
||||
1020
extensions/voice-call/src/media-stream.test.ts
Normal file
1020
extensions/voice-call/src/media-stream.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
866
extensions/voice-call/src/media-stream.ts
Normal file
866
extensions/voice-call/src/media-stream.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
100
extensions/voice-call/src/providers/base.ts
Normal file
100
extensions/voice-call/src/providers/base.ts
Normal 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>;
|
||||
}
|
||||
87
extensions/voice-call/src/providers/mock.test.ts
Normal file
87
extensions/voice-call/src/providers/mock.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
186
extensions/voice-call/src/providers/mock.ts
Normal file
186
extensions/voice-call/src/providers/mock.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
94
extensions/voice-call/src/providers/plivo.test.ts
Normal file
94
extensions/voice-call/src/providers/plivo.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
602
extensions/voice-call/src/providers/plivo.ts
Normal file
602
extensions/voice-call/src/providers/plivo.ts
Normal 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[];
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
30
extensions/voice-call/src/providers/shared/call-status.ts
Normal file
30
extensions/voice-call/src/providers/shared/call-status.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
83
extensions/voice-call/src/providers/shared/response-body.ts
Normal file
83
extensions/voice-call/src/providers/shared/response-body.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
490
extensions/voice-call/src/providers/telnyx.test.ts
Normal file
490
extensions/voice-call/src/providers/telnyx.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
420
extensions/voice-call/src/providers/telnyx.ts
Normal file
420
extensions/voice-call/src/providers/telnyx.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
653
extensions/voice-call/src/providers/twilio.test.ts
Normal file
653
extensions/voice-call/src/providers/twilio.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
862
extensions/voice-call/src/providers/twilio.ts
Normal file
862
extensions/voice-call/src/providers/twilio.ts
Normal 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;
|
||||
}
|
||||
18
extensions/voice-call/src/providers/twilio.types.ts
Normal file
18
extensions/voice-call/src/providers/twilio.types.ts
Normal 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;
|
||||
}
|
||||
234
extensions/voice-call/src/providers/twilio/api.test.ts
Normal file
234
extensions/voice-call/src/providers/twilio/api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
113
extensions/voice-call/src/providers/twilio/api.ts
Normal file
113
extensions/voice-call/src/providers/twilio/api.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
96
extensions/voice-call/src/providers/twilio/twiml-policy.ts
Normal file
96
extensions/voice-call/src/providers/twilio/twiml-policy.ts
Normal 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" };
|
||||
}
|
||||
38
extensions/voice-call/src/providers/twilio/webhook.ts
Normal file
38
extensions/voice-call/src/providers/twilio/webhook.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
96
extensions/voice-call/src/realtime-agent-context.test.ts
Normal file
96
extensions/voice-call/src/realtime-agent-context.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
121
extensions/voice-call/src/realtime-agent-context.ts
Normal file
121
extensions/voice-call/src/realtime-agent-context.ts
Normal 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");
|
||||
}
|
||||
7
extensions/voice-call/src/realtime-defaults.ts
Normal file
7
extensions/voice-call/src/realtime-defaults.ts
Normal 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.`;
|
||||
75
extensions/voice-call/src/realtime-fast-context.test.ts
Normal file
75
extensions/voice-call/src/realtime-fast-context.test.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
31
extensions/voice-call/src/realtime-fast-context.ts
Normal file
31
extensions/voice-call/src/realtime-fast-context.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Realtime transcription provider facade for the voice-call plugin runtime.
|
||||
|
||||
export {
|
||||
getRealtimeTranscriptionProvider,
|
||||
listRealtimeTranscriptionProviders,
|
||||
} from "openclaw/plugin-sdk/realtime-transcription";
|
||||
7
extensions/voice-call/src/realtime-voice.runtime.ts
Normal file
7
extensions/voice-call/src/realtime-voice.runtime.ts
Normal 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";
|
||||
561
extensions/voice-call/src/response-generator.test.ts
Normal file
561
extensions/voice-call/src/response-generator.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
368
extensions/voice-call/src/response-generator.ts
Normal file
368
extensions/voice-call/src/response-generator.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
72
extensions/voice-call/src/response-model.test.ts
Normal file
72
extensions/voice-call/src/response-model.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
27
extensions/voice-call/src/response-model.ts
Normal file
27
extensions/voice-call/src/response-model.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
18
extensions/voice-call/src/runtime-state.ts
Normal file
18
extensions/voice-call/src/runtime-state.ts
Normal 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 };
|
||||
667
extensions/voice-call/src/runtime.test.ts
Normal file
667
extensions/voice-call/src/runtime.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
511
extensions/voice-call/src/runtime.ts
Normal file
511
extensions/voice-call/src/runtime.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
82
extensions/voice-call/src/telephony-audio.test.ts
Normal file
82
extensions/voice-call/src/telephony-audio.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
13
extensions/voice-call/src/telephony-audio.ts
Normal file
13
extensions/voice-call/src/telephony-audio.ts
Normal 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));
|
||||
}
|
||||
})();
|
||||
}
|
||||
215
extensions/voice-call/src/telephony-tts.test.ts
Normal file
215
extensions/voice-call/src/telephony-tts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
253
extensions/voice-call/src/telephony-tts.ts
Normal file
253
extensions/voice-call/src/telephony-tts.ts
Normal 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;
|
||||
}
|
||||
82
extensions/voice-call/src/test-fixtures.ts
Normal file
82
extensions/voice-call/src/test-fixtures.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
51
extensions/voice-call/src/tts-provider-voice.test.ts
Normal file
51
extensions/voice-call/src/tts-provider-voice.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
33
extensions/voice-call/src/tts-provider-voice.ts
Normal file
33
extensions/voice-call/src/tts-provider-voice.ts
Normal 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]);
|
||||
}
|
||||
233
extensions/voice-call/src/tunnel.test.ts
Normal file
233
extensions/voice-call/src/tunnel.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
322
extensions/voice-call/src/tunnel.ts
Normal file
322
extensions/voice-call/src/tunnel.ts
Normal 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
Reference in New Issue
Block a user