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
709 lines
23 KiB
TypeScript
709 lines
23 KiB
TypeScript
// 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();
|
|
});
|
|
});
|