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:
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user