@openclaw/voice-call 2026.2.25 → 2026.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/index.ts +27 -13
  3. package/package.json +1 -1
  4. package/src/cli.ts +1 -1
  5. package/src/http-headers.test.ts +16 -0
  6. package/src/http-headers.ts +12 -0
  7. package/src/manager/events.test.ts +75 -0
  8. package/src/manager/events.ts +25 -9
  9. package/src/manager.closed-loop.test.ts +218 -0
  10. package/src/manager.inbound-allowlist.test.ts +121 -0
  11. package/src/manager.notify.test.ts +53 -0
  12. package/src/manager.restore.test.ts +130 -0
  13. package/src/manager.test-harness.ts +125 -0
  14. package/src/manager.ts +119 -10
  15. package/src/providers/base.ts +12 -1
  16. package/src/providers/mock.ts +15 -1
  17. package/src/providers/plivo.test.ts +22 -0
  18. package/src/providers/plivo.ts +60 -27
  19. package/src/providers/shared/call-status.test.ts +24 -0
  20. package/src/providers/shared/call-status.ts +23 -0
  21. package/src/providers/shared/guarded-json-api.ts +42 -0
  22. package/src/providers/telnyx.test.ts +27 -0
  23. package/src/providers/telnyx.ts +56 -17
  24. package/src/providers/twilio/twiml-policy.test.ts +84 -0
  25. package/src/providers/twilio/twiml-policy.ts +91 -0
  26. package/src/providers/twilio/webhook.ts +1 -0
  27. package/src/providers/twilio.test.ts +93 -2
  28. package/src/providers/twilio.ts +111 -91
  29. package/src/runtime.test.ts +147 -0
  30. package/src/runtime.ts +123 -76
  31. package/src/tunnel.ts +1 -1
  32. package/src/types.ts +24 -0
  33. package/src/webhook/stale-call-reaper.ts +33 -0
  34. package/src/webhook/tailscale.ts +115 -0
  35. package/src/webhook-security.test.ts +135 -4
  36. package/src/webhook-security.ts +142 -42
  37. package/src/webhook.test.ts +168 -14
  38. package/src/webhook.ts +118 -203
  39. package/src/manager.test.ts +0 -467
@@ -60,7 +60,77 @@ describe("TwilioProvider", () => {
60
60
  expect(result.providerResponseBody).toContain("<Connect>");
61
61
  });
62
62
 
63
- it("uses a stable dedupeKey for identical request payloads", () => {
63
+ it("returns queue TwiML for second inbound call when first call is active", () => {
64
+ const provider = createProvider();
65
+ const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA111");
66
+ const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA222");
67
+
68
+ const firstResult = provider.parseWebhookEvent(firstInbound);
69
+ const secondResult = provider.parseWebhookEvent(secondInbound);
70
+
71
+ expect(firstResult.providerResponseBody).toContain("<Connect>");
72
+ expect(secondResult.providerResponseBody).toContain("Please hold while we connect you.");
73
+ expect(secondResult.providerResponseBody).toContain("<Enqueue");
74
+ expect(secondResult.providerResponseBody).toContain("hold-queue");
75
+ });
76
+
77
+ it("connects next inbound call after unregisterCallStream cleanup", () => {
78
+ const provider = createProvider();
79
+ const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA311");
80
+ const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA322");
81
+
82
+ provider.parseWebhookEvent(firstInbound);
83
+ provider.unregisterCallStream("CA311");
84
+ const secondResult = provider.parseWebhookEvent(secondInbound);
85
+
86
+ expect(secondResult.providerResponseBody).toContain("<Connect>");
87
+ expect(secondResult.providerResponseBody).not.toContain("hold-queue");
88
+ });
89
+
90
+ it("cleans up active inbound call on completed status callback", () => {
91
+ const provider = createProvider();
92
+ const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA411");
93
+ const completed = createContext("CallStatus=completed&Direction=inbound&CallSid=CA411", {
94
+ type: "status",
95
+ });
96
+ const nextInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA422");
97
+
98
+ provider.parseWebhookEvent(firstInbound);
99
+ provider.parseWebhookEvent(completed);
100
+ const nextResult = provider.parseWebhookEvent(nextInbound);
101
+
102
+ expect(nextResult.providerResponseBody).toContain("<Connect>");
103
+ expect(nextResult.providerResponseBody).not.toContain("hold-queue");
104
+ });
105
+
106
+ it("cleans up active inbound call on canceled status callback", () => {
107
+ const provider = createProvider();
108
+ const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA511");
109
+ const canceled = createContext("CallStatus=canceled&Direction=inbound&CallSid=CA511", {
110
+ type: "status",
111
+ });
112
+ const nextInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA522");
113
+
114
+ provider.parseWebhookEvent(firstInbound);
115
+ provider.parseWebhookEvent(canceled);
116
+ const nextResult = provider.parseWebhookEvent(nextInbound);
117
+
118
+ expect(nextResult.providerResponseBody).toContain("<Connect>");
119
+ expect(nextResult.providerResponseBody).not.toContain("hold-queue");
120
+ });
121
+
122
+ it("QUEUE_TWIML references /voice/hold-music waitUrl", () => {
123
+ const provider = createProvider();
124
+ const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA611");
125
+ const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA622");
126
+
127
+ provider.parseWebhookEvent(firstInbound);
128
+ const result = provider.parseWebhookEvent(secondInbound);
129
+
130
+ expect(result.providerResponseBody).toContain('waitUrl="/voice/hold-music"');
131
+ });
132
+
133
+ it("uses a stable fallback dedupeKey for identical request payloads", () => {
64
134
  const provider = createProvider();
65
135
  const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello";
66
136
  const ctxA = {
@@ -78,10 +148,31 @@ describe("TwilioProvider", () => {
78
148
  expect(eventA).toBeDefined();
79
149
  expect(eventB).toBeDefined();
80
150
  expect(eventA?.id).not.toBe(eventB?.id);
81
- expect(eventA?.dedupeKey).toBe("twilio:idempotency:idem-123");
151
+ expect(eventA?.dedupeKey).toContain("twilio:fallback:");
82
152
  expect(eventA?.dedupeKey).toBe(eventB?.dedupeKey);
83
153
  });
84
154
 
155
+ it("uses verified request key for dedupe and ignores idempotency header changes", () => {
156
+ const provider = createProvider();
157
+ const rawBody = "CallSid=CA790&Direction=inbound&SpeechResult=hello";
158
+ const ctxA = {
159
+ ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
160
+ headers: { "i-twilio-idempotency-token": "idem-a" },
161
+ };
162
+ const ctxB = {
163
+ ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
164
+ headers: { "i-twilio-idempotency-token": "idem-b" },
165
+ };
166
+
167
+ const eventA = provider.parseWebhookEvent(ctxA, { verifiedRequestKey: "twilio:req:abc" })
168
+ .events[0];
169
+ const eventB = provider.parseWebhookEvent(ctxB, { verifiedRequestKey: "twilio:req:abc" })
170
+ .events[0];
171
+
172
+ expect(eventA?.dedupeKey).toBe("twilio:req:abc");
173
+ expect(eventB?.dedupeKey).toBe("twilio:req:abc");
174
+ });
175
+
85
176
  it("keeps turnToken from query on speech events", () => {
86
177
  const provider = createProvider();
87
178
  const ctx = createContext("CallSid=CA222&Direction=inbound&SpeechResult=hello", {
@@ -1,9 +1,12 @@
1
1
  import crypto from "node:crypto";
2
2
  import type { TwilioConfig, WebhookSecurityConfig } from "../config.js";
3
+ import { getHeader } from "../http-headers.js";
3
4
  import type { MediaStreamHandler } from "../media-stream.js";
4
5
  import { chunkAudio } from "../telephony-audio.js";
5
6
  import type { TelephonyTtsProvider } from "../telephony-tts.js";
6
7
  import type {
8
+ GetCallStatusInput,
9
+ GetCallStatusResult,
7
10
  HangupCallInput,
8
11
  InitiateCallInput,
9
12
  InitiateCallResult,
@@ -13,37 +16,39 @@ import type {
13
16
  StartListeningInput,
14
17
  StopListeningInput,
15
18
  WebhookContext,
19
+ WebhookParseOptions,
16
20
  WebhookVerificationResult,
17
21
  } from "../types.js";
18
22
  import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
19
23
  import type { VoiceCallProvider } from "./base.js";
24
+ import {
25
+ isProviderStatusTerminal,
26
+ mapProviderStatusToEndReason,
27
+ normalizeProviderStatus,
28
+ } from "./shared/call-status.js";
29
+ import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
20
30
  import { twilioApiRequest } from "./twilio/api.js";
31
+ import { decideTwimlResponse, readTwimlRequestView } from "./twilio/twiml-policy.js";
21
32
  import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
22
33
 
23
- function getHeader(
24
- headers: Record<string, string | string[] | undefined>,
25
- name: string,
26
- ): string | undefined {
27
- const value = headers[name.toLowerCase()];
28
- if (Array.isArray(value)) {
29
- return value[0];
30
- }
31
- return value;
32
- }
33
-
34
- function createTwilioRequestDedupeKey(ctx: WebhookContext): string {
35
- const idempotencyToken = getHeader(ctx.headers, "i-twilio-idempotency-token");
36
- if (idempotencyToken) {
37
- return `twilio:idempotency:${idempotencyToken}`;
34
+ function createTwilioRequestDedupeKey(ctx: WebhookContext, verifiedRequestKey?: string): string {
35
+ if (verifiedRequestKey) {
36
+ return verifiedRequestKey;
38
37
  }
39
38
 
40
39
  const signature = getHeader(ctx.headers, "x-twilio-signature") ?? "";
40
+ const params = new URLSearchParams(ctx.rawBody);
41
+ const callSid = params.get("CallSid") ?? "";
42
+ const callStatus = params.get("CallStatus") ?? "";
43
+ const direction = params.get("Direction") ?? "";
41
44
  const callId = typeof ctx.query?.callId === "string" ? ctx.query.callId.trim() : "";
42
45
  const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : "";
43
46
  const turnToken = typeof ctx.query?.turnToken === "string" ? ctx.query.turnToken.trim() : "";
44
47
  return `twilio:fallback:${crypto
45
48
  .createHash("sha256")
46
- .update(`${signature}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`)
49
+ .update(
50
+ `${signature}\n${callSid}\n${callStatus}\n${direction}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`,
51
+ )
47
52
  .digest("hex")}`;
48
53
  }
49
54
 
@@ -96,6 +101,7 @@ export class TwilioProvider implements VoiceCallProvider {
96
101
  private readonly twimlStorage = new Map<string, string>();
97
102
  /** Track notify-mode calls to avoid streaming on follow-up callbacks */
98
103
  private readonly notifyCalls = new Set<string>();
104
+ private readonly activeStreamCalls = new Set<string>();
99
105
 
100
106
  /**
101
107
  * Delete stored TwiML for a given `callId`.
@@ -168,6 +174,7 @@ export class TwilioProvider implements VoiceCallProvider {
168
174
 
169
175
  unregisterCallStream(callSid: string): void {
170
176
  this.callStreamMap.delete(callSid);
177
+ this.activeStreamCalls.delete(callSid);
171
178
  }
172
179
 
173
180
  isValidStreamToken(callSid: string, token?: string): boolean {
@@ -232,7 +239,10 @@ export class TwilioProvider implements VoiceCallProvider {
232
239
  /**
233
240
  * Parse Twilio webhook event into normalized format.
234
241
  */
235
- parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
242
+ parseWebhookEvent(
243
+ ctx: WebhookContext,
244
+ options?: WebhookParseOptions,
245
+ ): ProviderWebhookParseResult {
236
246
  try {
237
247
  const params = new URLSearchParams(ctx.rawBody);
238
248
  const callIdFromQuery =
@@ -243,7 +253,7 @@ export class TwilioProvider implements VoiceCallProvider {
243
253
  typeof ctx.query?.turnToken === "string" && ctx.query.turnToken.trim()
244
254
  ? ctx.query.turnToken.trim()
245
255
  : undefined;
246
- const dedupeKey = createTwilioRequestDedupeKey(ctx);
256
+ const dedupeKey = createTwilioRequestDedupeKey(ctx, options?.verifiedRequestKey);
247
257
  const event = this.normalizeEvent(params, {
248
258
  callIdOverride: callIdFromQuery,
249
259
  dedupeKey,
@@ -323,32 +333,28 @@ export class TwilioProvider implements VoiceCallProvider {
323
333
  }
324
334
 
325
335
  // Handle call status changes
326
- const callStatus = params.get("CallStatus");
327
- switch (callStatus) {
328
- case "initiated":
329
- return { ...baseEvent, type: "call.initiated" };
330
- case "ringing":
331
- return { ...baseEvent, type: "call.ringing" };
332
- case "in-progress":
333
- return { ...baseEvent, type: "call.answered" };
334
- case "completed":
335
- case "busy":
336
- case "no-answer":
337
- case "failed":
338
- this.streamAuthTokens.delete(callSid);
339
- if (callIdOverride) {
340
- this.deleteStoredTwiml(callIdOverride);
341
- }
342
- return { ...baseEvent, type: "call.ended", reason: callStatus };
343
- case "canceled":
344
- this.streamAuthTokens.delete(callSid);
345
- if (callIdOverride) {
346
- this.deleteStoredTwiml(callIdOverride);
347
- }
348
- return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
349
- default:
350
- return null;
336
+ const callStatus = normalizeProviderStatus(params.get("CallStatus"));
337
+ if (callStatus === "initiated") {
338
+ return { ...baseEvent, type: "call.initiated" };
339
+ }
340
+ if (callStatus === "ringing") {
341
+ return { ...baseEvent, type: "call.ringing" };
342
+ }
343
+ if (callStatus === "in-progress") {
344
+ return { ...baseEvent, type: "call.answered" };
345
+ }
346
+
347
+ const endReason = mapProviderStatusToEndReason(callStatus);
348
+ if (endReason) {
349
+ this.streamAuthTokens.delete(callSid);
350
+ this.activeStreamCalls.delete(callSid);
351
+ if (callIdOverride) {
352
+ this.deleteStoredTwiml(callIdOverride);
353
+ }
354
+ return { ...baseEvent, type: "call.ended", reason: endReason };
351
355
  }
356
+
357
+ return null;
352
358
  }
353
359
 
354
360
  private static readonly EMPTY_TWIML =
@@ -359,6 +365,12 @@ export class TwilioProvider implements VoiceCallProvider {
359
365
  <Pause length="30"/>
360
366
  </Response>`;
361
367
 
368
+ private static readonly QUEUE_TWIML = `<?xml version="1.0" encoding="UTF-8"?>
369
+ <Response>
370
+ <Say voice="alice">Please hold while we connect you.</Say>
371
+ <Enqueue waitUrl="/voice/hold-music">hold-queue</Enqueue>
372
+ </Response>`;
373
+
362
374
  /**
363
375
  * Generate TwiML response for webhook.
364
376
  * When a call is answered, connects to media stream for bidirectional audio.
@@ -368,59 +380,40 @@ export class TwilioProvider implements VoiceCallProvider {
368
380
  return TwilioProvider.EMPTY_TWIML;
369
381
  }
370
382
 
371
- const params = new URLSearchParams(ctx.rawBody);
372
- const type = typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined;
373
- const isStatusCallback = type === "status";
374
- const callStatus = params.get("CallStatus");
375
- const direction = params.get("Direction");
376
- const isOutbound = direction?.startsWith("outbound") ?? false;
377
- const callSid = params.get("CallSid") || undefined;
378
- const callIdFromQuery =
379
- typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
380
- ? ctx.query.callId.trim()
381
- : undefined;
382
-
383
- // Avoid logging webhook params/TwiML (may contain PII).
384
-
385
- // Handle initial TwiML request (when Twilio first initiates the call)
386
- // Check if we have stored TwiML for this call (notify mode)
387
- if (callIdFromQuery && !isStatusCallback) {
388
- const storedTwiml = this.twimlStorage.get(callIdFromQuery);
389
- if (storedTwiml) {
390
- // Clean up after serving (one-time use)
391
- this.deleteStoredTwiml(callIdFromQuery);
392
- return storedTwiml;
393
- }
394
- if (this.notifyCalls.has(callIdFromQuery)) {
395
- return TwilioProvider.EMPTY_TWIML;
396
- }
397
-
398
- // Conversation mode: return streaming TwiML immediately for outbound calls.
399
- if (isOutbound) {
400
- const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
401
- return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
402
- }
403
- }
383
+ const view = readTwimlRequestView(ctx);
384
+ const storedTwiml = view.callIdFromQuery
385
+ ? this.twimlStorage.get(view.callIdFromQuery)
386
+ : undefined;
387
+ const decision = decideTwimlResponse({
388
+ ...view,
389
+ hasStoredTwiml: Boolean(storedTwiml),
390
+ isNotifyCall: view.callIdFromQuery ? this.notifyCalls.has(view.callIdFromQuery) : false,
391
+ hasActiveStreams: this.activeStreamCalls.size > 0,
392
+ canStream: Boolean(view.callSid && this.getStreamUrl()),
393
+ });
404
394
 
405
- // Status callbacks should not receive TwiML.
406
- if (isStatusCallback) {
407
- return TwilioProvider.EMPTY_TWIML;
395
+ if (decision.consumeStoredTwimlCallId) {
396
+ this.deleteStoredTwiml(decision.consumeStoredTwimlCallId);
408
397
  }
409
-
410
- // Handle subsequent webhook requests (status callbacks, etc.)
411
- // For inbound calls, answer immediately with stream
412
- if (direction === "inbound") {
413
- const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
414
- return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
398
+ if (decision.activateStreamCallSid) {
399
+ this.activeStreamCalls.add(decision.activateStreamCallSid);
415
400
  }
416
401
 
417
- // For outbound calls, only connect to stream when call is in-progress
418
- if (callStatus !== "in-progress") {
419
- return TwilioProvider.EMPTY_TWIML;
402
+ switch (decision.kind) {
403
+ case "stored":
404
+ return storedTwiml ?? TwilioProvider.EMPTY_TWIML;
405
+ case "queue":
406
+ return TwilioProvider.QUEUE_TWIML;
407
+ case "pause":
408
+ return TwilioProvider.PAUSE_TWIML;
409
+ case "stream": {
410
+ const streamUrl = view.callSid ? this.getStreamUrlForCall(view.callSid) : null;
411
+ return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
412
+ }
413
+ case "empty":
414
+ default:
415
+ return TwilioProvider.EMPTY_TWIML;
420
416
  }
421
-
422
- const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
423
- return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
424
417
  }
425
418
 
426
419
  /**
@@ -544,6 +537,7 @@ export class TwilioProvider implements VoiceCallProvider {
544
537
 
545
538
  this.callWebhookUrls.delete(input.providerCallId);
546
539
  this.streamAuthTokens.delete(input.providerCallId);
540
+ this.activeStreamCalls.delete(input.providerCallId);
547
541
 
548
542
  await this.apiRequest(
549
543
  `/Calls/${input.providerCallId}.json`,
@@ -672,6 +666,32 @@ export class TwilioProvider implements VoiceCallProvider {
672
666
  // Twilio's <Gather> automatically stops on speech end
673
667
  // No explicit action needed
674
668
  }
669
+
670
+ async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
671
+ try {
672
+ const data = await guardedJsonApiRequest<{ status?: string }>({
673
+ url: `${this.baseUrl}/Calls/${input.providerCallId}.json`,
674
+ method: "GET",
675
+ headers: {
676
+ Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString("base64")}`,
677
+ },
678
+ allowNotFound: true,
679
+ allowedHostnames: ["api.twilio.com"],
680
+ auditContext: "twilio-get-call-status",
681
+ errorPrefix: "Twilio get call status error",
682
+ });
683
+
684
+ if (!data) {
685
+ return { status: "not-found", isTerminal: true };
686
+ }
687
+
688
+ const status = normalizeProviderStatus(data.status);
689
+ return { status, isTerminal: isProviderStatusTerminal(status) };
690
+ } catch {
691
+ // Transient error — keep the call and rely on timer fallback
692
+ return { status: "error", isTerminal: false, isUnknown: true };
693
+ }
694
+ }
675
695
  }
676
696
 
677
697
  // -----------------------------------------------------------------------------
@@ -0,0 +1,147 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { VoiceCallConfig } from "./config.js";
3
+ import type { CoreConfig } from "./core-bridge.js";
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ resolveVoiceCallConfig: vi.fn(),
7
+ validateProviderConfig: vi.fn(),
8
+ managerInitialize: vi.fn(),
9
+ webhookStart: vi.fn(),
10
+ webhookStop: vi.fn(),
11
+ webhookGetMediaStreamHandler: vi.fn(),
12
+ startTunnel: vi.fn(),
13
+ setupTailscaleExposure: vi.fn(),
14
+ cleanupTailscaleExposure: vi.fn(),
15
+ }));
16
+
17
+ vi.mock("./config.js", () => ({
18
+ resolveVoiceCallConfig: mocks.resolveVoiceCallConfig,
19
+ validateProviderConfig: mocks.validateProviderConfig,
20
+ }));
21
+
22
+ vi.mock("./manager.js", () => ({
23
+ CallManager: class {
24
+ initialize = mocks.managerInitialize;
25
+ },
26
+ }));
27
+
28
+ vi.mock("./webhook.js", () => ({
29
+ VoiceCallWebhookServer: class {
30
+ start = mocks.webhookStart;
31
+ stop = mocks.webhookStop;
32
+ getMediaStreamHandler = mocks.webhookGetMediaStreamHandler;
33
+ },
34
+ }));
35
+
36
+ vi.mock("./tunnel.js", () => ({
37
+ startTunnel: mocks.startTunnel,
38
+ }));
39
+
40
+ vi.mock("./webhook/tailscale.js", () => ({
41
+ setupTailscaleExposure: mocks.setupTailscaleExposure,
42
+ cleanupTailscaleExposure: mocks.cleanupTailscaleExposure,
43
+ }));
44
+
45
+ import { createVoiceCallRuntime } from "./runtime.js";
46
+
47
+ function createBaseConfig(): VoiceCallConfig {
48
+ return {
49
+ enabled: true,
50
+ provider: "mock",
51
+ fromNumber: "+15550001234",
52
+ inboundPolicy: "disabled",
53
+ allowFrom: [],
54
+ outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
55
+ maxDurationSeconds: 300,
56
+ staleCallReaperSeconds: 600,
57
+ silenceTimeoutMs: 800,
58
+ transcriptTimeoutMs: 180000,
59
+ ringTimeoutMs: 30000,
60
+ maxConcurrentCalls: 1,
61
+ serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
62
+ tailscale: { mode: "off", path: "/voice/webhook" },
63
+ tunnel: { provider: "ngrok", allowNgrokFreeTierLoopbackBypass: false },
64
+ webhookSecurity: {
65
+ allowedHosts: [],
66
+ trustForwardingHeaders: false,
67
+ trustedProxyIPs: [],
68
+ },
69
+ streaming: {
70
+ enabled: false,
71
+ sttProvider: "openai-realtime",
72
+ sttModel: "gpt-4o-transcribe",
73
+ silenceDurationMs: 800,
74
+ vadThreshold: 0.5,
75
+ streamPath: "/voice/stream",
76
+ preStartTimeoutMs: 5000,
77
+ maxPendingConnections: 32,
78
+ maxPendingConnectionsPerIp: 4,
79
+ maxConnections: 128,
80
+ },
81
+ skipSignatureVerification: false,
82
+ stt: { provider: "openai", model: "whisper-1" },
83
+ tts: {
84
+ provider: "openai",
85
+ openai: { model: "gpt-4o-mini-tts", voice: "coral" },
86
+ },
87
+ responseModel: "openai/gpt-4o-mini",
88
+ responseTimeoutMs: 30000,
89
+ };
90
+ }
91
+
92
+ describe("createVoiceCallRuntime lifecycle", () => {
93
+ beforeEach(() => {
94
+ vi.clearAllMocks();
95
+ mocks.resolveVoiceCallConfig.mockImplementation((cfg: VoiceCallConfig) => cfg);
96
+ mocks.validateProviderConfig.mockReturnValue({ valid: true, errors: [] });
97
+ mocks.managerInitialize.mockResolvedValue(undefined);
98
+ mocks.webhookStart.mockResolvedValue("http://127.0.0.1:3334/voice/webhook");
99
+ mocks.webhookStop.mockResolvedValue(undefined);
100
+ mocks.webhookGetMediaStreamHandler.mockReturnValue(undefined);
101
+ mocks.startTunnel.mockResolvedValue(null);
102
+ mocks.setupTailscaleExposure.mockResolvedValue(null);
103
+ mocks.cleanupTailscaleExposure.mockResolvedValue(undefined);
104
+ });
105
+
106
+ it("cleans up tunnel, tailscale, and webhook server when init fails after start", async () => {
107
+ const tunnelStop = vi.fn().mockResolvedValue(undefined);
108
+ mocks.startTunnel.mockResolvedValue({
109
+ publicUrl: "https://public.example/voice/webhook",
110
+ provider: "ngrok",
111
+ stop: tunnelStop,
112
+ });
113
+ mocks.managerInitialize.mockRejectedValue(new Error("init failed"));
114
+
115
+ await expect(
116
+ createVoiceCallRuntime({
117
+ config: createBaseConfig(),
118
+ coreConfig: {},
119
+ }),
120
+ ).rejects.toThrow("init failed");
121
+
122
+ expect(tunnelStop).toHaveBeenCalledTimes(1);
123
+ expect(mocks.cleanupTailscaleExposure).toHaveBeenCalledTimes(1);
124
+ expect(mocks.webhookStop).toHaveBeenCalledTimes(1);
125
+ });
126
+
127
+ it("returns an idempotent stop handler", async () => {
128
+ const tunnelStop = vi.fn().mockResolvedValue(undefined);
129
+ mocks.startTunnel.mockResolvedValue({
130
+ publicUrl: "https://public.example/voice/webhook",
131
+ provider: "ngrok",
132
+ stop: tunnelStop,
133
+ });
134
+
135
+ const runtime = await createVoiceCallRuntime({
136
+ config: createBaseConfig(),
137
+ coreConfig: {} as CoreConfig,
138
+ });
139
+
140
+ await runtime.stop();
141
+ await runtime.stop();
142
+
143
+ expect(tunnelStop).toHaveBeenCalledTimes(1);
144
+ expect(mocks.cleanupTailscaleExposure).toHaveBeenCalledTimes(1);
145
+ expect(mocks.webhookStop).toHaveBeenCalledTimes(1);
146
+ });
147
+ });