@openclaw/voice-call 2026.3.13 → 2026.5.2-beta.1

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 (103) hide show
  1. package/README.md +27 -5
  2. package/api.ts +16 -0
  3. package/cli-metadata.ts +10 -0
  4. package/config-api.ts +12 -0
  5. package/index.test.ts +943 -0
  6. package/index.ts +379 -149
  7. package/openclaw.plugin.json +384 -157
  8. package/package.json +35 -5
  9. package/runtime-api.ts +20 -0
  10. package/runtime-entry.ts +1 -0
  11. package/setup-api.ts +47 -0
  12. package/src/allowlist.test.ts +18 -0
  13. package/src/cli.ts +533 -68
  14. package/src/config-compat.test.ts +120 -0
  15. package/src/config-compat.ts +227 -0
  16. package/src/config.test.ts +273 -12
  17. package/src/config.ts +355 -72
  18. package/src/core-bridge.ts +2 -147
  19. package/src/deep-merge.test.ts +40 -0
  20. package/src/gateway-continue-operation.ts +200 -0
  21. package/src/http-headers.ts +6 -3
  22. package/src/manager/context.ts +6 -5
  23. package/src/manager/events.test.ts +243 -19
  24. package/src/manager/events.ts +61 -31
  25. package/src/manager/lifecycle.ts +53 -0
  26. package/src/manager/lookup.test.ts +52 -0
  27. package/src/manager/outbound.test.ts +528 -0
  28. package/src/manager/outbound.ts +163 -57
  29. package/src/manager/store.ts +18 -6
  30. package/src/manager/timers.test.ts +129 -0
  31. package/src/manager/timers.ts +4 -3
  32. package/src/manager/twiml.test.ts +13 -0
  33. package/src/manager/twiml.ts +8 -0
  34. package/src/manager.closed-loop.test.ts +30 -12
  35. package/src/manager.inbound-allowlist.test.ts +77 -10
  36. package/src/manager.notify.test.ts +344 -20
  37. package/src/manager.restore.test.ts +95 -8
  38. package/src/manager.test-harness.ts +8 -6
  39. package/src/manager.ts +79 -5
  40. package/src/media-stream.test.ts +578 -81
  41. package/src/media-stream.ts +235 -54
  42. package/src/providers/base.ts +19 -0
  43. package/src/providers/mock.ts +7 -1
  44. package/src/providers/plivo.test.ts +50 -6
  45. package/src/providers/plivo.ts +14 -6
  46. package/src/providers/shared/call-status.ts +2 -1
  47. package/src/providers/shared/guarded-json-api.test.ts +106 -0
  48. package/src/providers/shared/guarded-json-api.ts +1 -1
  49. package/src/providers/telnyx.test.ts +178 -6
  50. package/src/providers/telnyx.ts +40 -3
  51. package/src/providers/twilio/api.test.ts +145 -0
  52. package/src/providers/twilio/api.ts +67 -16
  53. package/src/providers/twilio/twiml-policy.ts +6 -10
  54. package/src/providers/twilio/webhook.ts +1 -1
  55. package/src/providers/twilio.test.ts +425 -25
  56. package/src/providers/twilio.ts +230 -77
  57. package/src/providers/twilio.types.ts +17 -0
  58. package/src/realtime-defaults.ts +3 -0
  59. package/src/realtime-fast-context.test.ts +88 -0
  60. package/src/realtime-fast-context.ts +165 -0
  61. package/src/realtime-transcription.runtime.ts +4 -0
  62. package/src/realtime-voice.runtime.ts +5 -0
  63. package/src/response-generator.test.ts +321 -0
  64. package/src/response-generator.ts +213 -53
  65. package/src/response-model.test.ts +71 -0
  66. package/src/response-model.ts +23 -0
  67. package/src/runtime.test.ts +429 -0
  68. package/src/runtime.ts +270 -24
  69. package/src/telephony-audio.test.ts +61 -0
  70. package/src/telephony-audio.ts +1 -79
  71. package/src/telephony-tts.test.ts +133 -12
  72. package/src/telephony-tts.ts +155 -2
  73. package/src/test-fixtures.ts +28 -7
  74. package/src/tts-provider-voice.test.ts +34 -0
  75. package/src/tts-provider-voice.ts +21 -0
  76. package/src/tunnel.test.ts +166 -0
  77. package/src/tunnel.ts +1 -1
  78. package/src/types.ts +24 -37
  79. package/src/utils.test.ts +17 -0
  80. package/src/voice-mapping.test.ts +34 -0
  81. package/src/voice-mapping.ts +3 -2
  82. package/src/webhook/realtime-handler.test.ts +598 -0
  83. package/src/webhook/realtime-handler.ts +485 -0
  84. package/src/webhook/stale-call-reaper.test.ts +88 -0
  85. package/src/webhook/stale-call-reaper.ts +5 -0
  86. package/src/webhook/tailscale.test.ts +214 -0
  87. package/src/webhook/tailscale.ts +19 -5
  88. package/src/webhook-exposure.test.ts +33 -0
  89. package/src/webhook-exposure.ts +84 -0
  90. package/src/webhook-security.test.ts +172 -21
  91. package/src/webhook-security.ts +43 -29
  92. package/src/webhook.hangup-once.lifecycle.test.ts +135 -0
  93. package/src/webhook.test.ts +1145 -27
  94. package/src/webhook.ts +523 -102
  95. package/src/webhook.types.ts +5 -0
  96. package/src/websocket-test-support.ts +72 -0
  97. package/tsconfig.json +16 -0
  98. package/CHANGELOG.md +0 -121
  99. package/src/providers/index.ts +0 -10
  100. package/src/providers/stt-openai-realtime.test.ts +0 -42
  101. package/src/providers/stt-openai-realtime.ts +0 -311
  102. package/src/providers/tts-openai.test.ts +0 -43
  103. package/src/providers/tts-openai.ts +0 -221
@@ -1,8 +1,7 @@
1
+ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
1
2
  import type { WebhookContext } from "../../types.js";
2
3
 
3
- export type TwimlResponseKind = "empty" | "pause" | "queue" | "stored" | "stream";
4
-
5
- export type TwimlRequestView = {
4
+ type TwimlRequestView = {
6
5
  callStatus: string | null;
7
6
  direction: string | null;
8
7
  isStatusCallback: boolean;
@@ -10,14 +9,14 @@ export type TwimlRequestView = {
10
9
  callIdFromQuery?: string;
11
10
  };
12
11
 
13
- export type TwimlPolicyInput = TwimlRequestView & {
12
+ type TwimlPolicyInput = TwimlRequestView & {
14
13
  hasStoredTwiml: boolean;
15
14
  isNotifyCall: boolean;
16
15
  hasActiveStreams: boolean;
17
16
  canStream: boolean;
18
17
  };
19
18
 
20
- export type TwimlDecision =
19
+ type TwimlDecision =
21
20
  | {
22
21
  kind: "empty" | "pause" | "queue";
23
22
  consumeStoredTwimlCallId?: string;
@@ -40,11 +39,8 @@ function isOutboundDirection(direction: string | null): boolean {
40
39
 
41
40
  export function readTwimlRequestView(ctx: WebhookContext): TwimlRequestView {
42
41
  const params = new URLSearchParams(ctx.rawBody);
43
- const type = typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined;
44
- const callIdFromQuery =
45
- typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
46
- ? ctx.query.callId.trim()
47
- : undefined;
42
+ const type = normalizeOptionalString(ctx.query?.type);
43
+ const callIdFromQuery = normalizeOptionalString(ctx.query?.callId);
48
44
 
49
45
  return {
50
46
  callStatus: params.get("CallStatus"),
@@ -1,6 +1,6 @@
1
1
  import type { WebhookContext, WebhookVerificationResult } from "../../types.js";
2
2
  import { verifyTwilioWebhook } from "../../webhook-security.js";
3
- import type { TwilioProviderOptions } from "../twilio.js";
3
+ import type { TwilioProviderOptions } from "../twilio.types.js";
4
4
 
5
5
  export function verifyTwilioProviderWebhook(params: {
6
6
  ctx: WebhookContext;
@@ -1,6 +1,7 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import type { WebhookContext } from "../types.js";
3
3
  import { TwilioProvider } from "./twilio.js";
4
+ import { TwilioApiError } from "./twilio/api.js";
4
5
 
5
6
  const STREAM_URL = "wss://example.ngrok.app/voice/stream";
6
7
 
@@ -27,7 +28,123 @@ function expectStreamingTwiml(body: string) {
27
28
  expect(body).toContain("<Connect>");
28
29
  }
29
30
 
31
+ function expectQueueTwiml(body: string) {
32
+ expect(body).toContain("Please hold while we connect you.");
33
+ expect(body).toContain("<Enqueue");
34
+ expect(body).toContain("hold-queue");
35
+ }
36
+
37
+ function requireResponseBody(body: string | undefined): string {
38
+ if (!body) {
39
+ throw new Error("Twilio provider did not return a response body");
40
+ }
41
+ return body;
42
+ }
43
+
44
+ function requireEvent<T>(event: T | undefined, message: string): T {
45
+ if (!event) {
46
+ throw new Error(message);
47
+ }
48
+ return event;
49
+ }
50
+
51
+ type TwilioApiRequest = (
52
+ endpoint: string,
53
+ params: Record<string, string | string[]>,
54
+ options?: { allowNotFound?: boolean },
55
+ ) => Promise<unknown>;
56
+
57
+ function createApiRequestMock(impl?: TwilioApiRequest) {
58
+ return vi.fn<TwilioApiRequest>(impl ?? (async () => ({})));
59
+ }
60
+
61
+ function createTwilioCallStateRaceError(): TwilioApiError {
62
+ return new TwilioApiError(
63
+ 400,
64
+ JSON.stringify({
65
+ code: 21220,
66
+ message: "Call is not in-progress. Cannot redirect.",
67
+ }),
68
+ );
69
+ }
70
+
71
+ function configureTelephonyTwiMlFallback(params: { providerCallId: string; streamSid?: string }) {
72
+ const provider = createProvider();
73
+ const apiRequest = createApiRequestMock();
74
+ (
75
+ provider as unknown as {
76
+ apiRequest: TwilioApiRequest;
77
+ }
78
+ ).apiRequest = apiRequest;
79
+ (
80
+ provider as unknown as {
81
+ callWebhookUrls: Map<string, string>;
82
+ }
83
+ ).callWebhookUrls.set(params.providerCallId, "https://example.ngrok.app/voice/twilio");
84
+ if (params.streamSid) {
85
+ provider.registerCallStream(params.providerCallId, params.streamSid);
86
+ }
87
+ return { provider, apiRequest };
88
+ }
89
+
30
90
  describe("TwilioProvider", () => {
91
+ it("sends direct initial TwiML for notify-mode outbound calls", async () => {
92
+ const provider = createProvider();
93
+ const apiRequest = createApiRequestMock(async () => ({ sid: "CA123", status: "queued" }));
94
+ (
95
+ provider as unknown as {
96
+ apiRequest: TwilioApiRequest;
97
+ }
98
+ ).apiRequest = apiRequest;
99
+
100
+ const result = await provider.initiateCall({
101
+ callId: "call-1",
102
+ from: "+14155550100",
103
+ to: "+14155550123",
104
+ webhookUrl: "https://example.ngrok.app/voice/webhook",
105
+ inlineTwiml: "<Response><Say>Hello</Say></Response>",
106
+ });
107
+
108
+ expect(result).toEqual({ providerCallId: "CA123", status: "queued" });
109
+ expect(apiRequest).toHaveBeenCalledWith(
110
+ "/Calls.json",
111
+ expect.objectContaining({
112
+ To: "+14155550123",
113
+ From: "+14155550100",
114
+ Twiml: "<Response><Say>Hello</Say></Response>",
115
+ StatusCallback: "https://example.ngrok.app/voice/webhook?callId=call-1&type=status",
116
+ StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"],
117
+ }),
118
+ );
119
+ expect(apiRequest.mock.calls[0]?.[1]).not.toHaveProperty("Url");
120
+ });
121
+
122
+ it("uses the webhook URL for conversation outbound calls", async () => {
123
+ const provider = createProvider();
124
+ const apiRequest = createApiRequestMock(async () => ({ sid: "CA123", status: "queued" }));
125
+ (
126
+ provider as unknown as {
127
+ apiRequest: TwilioApiRequest;
128
+ }
129
+ ).apiRequest = apiRequest;
130
+
131
+ await provider.initiateCall({
132
+ callId: "call-1",
133
+ from: "+14155550100",
134
+ to: "+14155550123",
135
+ webhookUrl: "https://example.ngrok.app/voice/webhook",
136
+ });
137
+
138
+ expect(apiRequest).toHaveBeenCalledWith(
139
+ "/Calls.json",
140
+ expect.objectContaining({
141
+ Url: "https://example.ngrok.app/voice/webhook?callId=call-1",
142
+ StatusCallback: "https://example.ngrok.app/voice/webhook?callId=call-1&type=status",
143
+ }),
144
+ );
145
+ expect(apiRequest.mock.calls[0]?.[1]).not.toHaveProperty("Twiml");
146
+ });
147
+
31
148
  it("returns streaming TwiML for outbound conversation calls before in-progress", () => {
32
149
  const provider = createProvider();
33
150
  const ctx = createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA123", {
@@ -36,8 +153,42 @@ describe("TwilioProvider", () => {
36
153
 
37
154
  const result = provider.parseWebhookEvent(ctx);
38
155
 
39
- expect(result.providerResponseBody).toBeDefined();
40
- expectStreamingTwiml(result.providerResponseBody ?? "");
156
+ expectStreamingTwiml(requireResponseBody(result.providerResponseBody));
157
+ });
158
+
159
+ it("serves pre-connect TwiML once before outbound streaming starts", async () => {
160
+ const provider = createProvider();
161
+ (
162
+ provider as unknown as {
163
+ apiRequest: TwilioApiRequest;
164
+ }
165
+ ).apiRequest = vi.fn<TwilioApiRequest>(async () => ({
166
+ sid: "CA999",
167
+ status: "queued",
168
+ }));
169
+ const preConnectTwiml = '<Response><Play digits="ww123456#" /></Response>';
170
+
171
+ await provider.initiateCall({
172
+ callId: "call-1",
173
+ from: "+15550000001",
174
+ to: "+15550000002",
175
+ webhookUrl: "https://example.ngrok.app/voice/twilio",
176
+ preConnectTwiml,
177
+ });
178
+
179
+ const first = provider.parseWebhookEvent(
180
+ createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA999", {
181
+ callId: "call-1",
182
+ }),
183
+ );
184
+ expect(requireResponseBody(first.providerResponseBody)).toBe(preConnectTwiml);
185
+
186
+ const second = provider.parseWebhookEvent(
187
+ createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA999", {
188
+ callId: "call-1",
189
+ }),
190
+ );
191
+ expectStreamingTwiml(requireResponseBody(second.providerResponseBody));
41
192
  });
42
193
 
43
194
  it("returns empty TwiML for status callbacks", () => {
@@ -60,8 +211,7 @@ describe("TwilioProvider", () => {
60
211
 
61
212
  const result = provider.parseWebhookEvent(ctx);
62
213
 
63
- expect(result.providerResponseBody).toBeDefined();
64
- expectStreamingTwiml(result.providerResponseBody ?? "");
214
+ expectStreamingTwiml(requireResponseBody(result.providerResponseBody));
65
215
  });
66
216
 
67
217
  it("returns queue TwiML for second inbound call when first call is active", () => {
@@ -72,10 +222,8 @@ describe("TwilioProvider", () => {
72
222
  const firstResult = provider.parseWebhookEvent(firstInbound);
73
223
  const secondResult = provider.parseWebhookEvent(secondInbound);
74
224
 
75
- expect(firstResult.providerResponseBody).toContain("<Connect>");
76
- expect(secondResult.providerResponseBody).toContain("Please hold while we connect you.");
77
- expect(secondResult.providerResponseBody).toContain("<Enqueue");
78
- expect(secondResult.providerResponseBody).toContain("hold-queue");
225
+ expectStreamingTwiml(requireResponseBody(firstResult.providerResponseBody));
226
+ expectQueueTwiml(requireResponseBody(secondResult.providerResponseBody));
79
227
  });
80
228
 
81
229
  it("connects next inbound call after unregisterCallStream cleanup", () => {
@@ -87,8 +235,9 @@ describe("TwilioProvider", () => {
87
235
  provider.unregisterCallStream("CA311");
88
236
  const secondResult = provider.parseWebhookEvent(secondInbound);
89
237
 
90
- expect(secondResult.providerResponseBody).toContain("<Connect>");
91
- expect(secondResult.providerResponseBody).not.toContain("hold-queue");
238
+ const secondBody = requireResponseBody(secondResult.providerResponseBody);
239
+ expectStreamingTwiml(secondBody);
240
+ expect(secondBody).not.toContain("hold-queue");
92
241
  });
93
242
 
94
243
  it("cleans up active inbound call on completed status callback", () => {
@@ -103,8 +252,9 @@ describe("TwilioProvider", () => {
103
252
  provider.parseWebhookEvent(completed);
104
253
  const nextResult = provider.parseWebhookEvent(nextInbound);
105
254
 
106
- expect(nextResult.providerResponseBody).toContain("<Connect>");
107
- expect(nextResult.providerResponseBody).not.toContain("hold-queue");
255
+ const nextBody = requireResponseBody(nextResult.providerResponseBody);
256
+ expectStreamingTwiml(nextBody);
257
+ expect(nextBody).not.toContain("hold-queue");
108
258
  });
109
259
 
110
260
  it("cleans up active inbound call on canceled status callback", () => {
@@ -119,8 +269,9 @@ describe("TwilioProvider", () => {
119
269
  provider.parseWebhookEvent(canceled);
120
270
  const nextResult = provider.parseWebhookEvent(nextInbound);
121
271
 
122
- expect(nextResult.providerResponseBody).toContain("<Connect>");
123
- expect(nextResult.providerResponseBody).not.toContain("hold-queue");
272
+ const nextBody = requireResponseBody(nextResult.providerResponseBody);
273
+ expectStreamingTwiml(nextBody);
274
+ expect(nextBody).not.toContain("hold-queue");
124
275
  });
125
276
 
126
277
  it("QUEUE_TWIML references /voice/hold-music waitUrl", () => {
@@ -131,7 +282,9 @@ describe("TwilioProvider", () => {
131
282
  provider.parseWebhookEvent(firstInbound);
132
283
  const result = provider.parseWebhookEvent(secondInbound);
133
284
 
134
- expect(result.providerResponseBody).toContain('waitUrl="/voice/hold-music"');
285
+ expect(requireResponseBody(result.providerResponseBody)).toContain(
286
+ 'waitUrl="/voice/hold-music"',
287
+ );
135
288
  });
136
289
 
137
290
  it("uses a stable fallback dedupeKey for identical request payloads", () => {
@@ -149,11 +302,11 @@ describe("TwilioProvider", () => {
149
302
  const eventA = provider.parseWebhookEvent(ctxA).events[0];
150
303
  const eventB = provider.parseWebhookEvent(ctxB).events[0];
151
304
 
152
- expect(eventA).toBeDefined();
153
- expect(eventB).toBeDefined();
154
- expect(eventA?.id).not.toBe(eventB?.id);
155
- expect(eventA?.dedupeKey).toContain("twilio:fallback:");
156
- expect(eventA?.dedupeKey).toBe(eventB?.dedupeKey);
305
+ const first = requireEvent(eventA, "expected first fallback Twilio event");
306
+ const second = requireEvent(eventB, "expected second fallback Twilio event");
307
+ expect(first.id).not.toBe(second.id);
308
+ expect(first.dedupeKey).toContain("twilio:fallback:");
309
+ expect(first.dedupeKey).toBe(second.dedupeKey);
157
310
  });
158
311
 
159
312
  it("uses verified request key for dedupe and ignores idempotency header changes", () => {
@@ -173,8 +326,12 @@ describe("TwilioProvider", () => {
173
326
  const eventB = provider.parseWebhookEvent(ctxB, { verifiedRequestKey: "twilio:req:abc" })
174
327
  .events[0];
175
328
 
176
- expect(eventA?.dedupeKey).toBe("twilio:req:abc");
177
- expect(eventB?.dedupeKey).toBe("twilio:req:abc");
329
+ expect(requireEvent(eventA, "expected verified first Twilio event").dedupeKey).toBe(
330
+ "twilio:req:abc",
331
+ );
332
+ expect(requireEvent(eventB, "expected verified second Twilio event").dedupeKey).toBe(
333
+ "twilio:req:abc",
334
+ );
178
335
  });
179
336
 
180
337
  it("keeps turnToken from query on speech events", () => {
@@ -185,7 +342,250 @@ describe("TwilioProvider", () => {
185
342
  });
186
343
 
187
344
  const event = provider.parseWebhookEvent(ctx).events[0];
188
- expect(event?.type).toBe("call.speech");
189
- expect(event?.turnToken).toBe("turn-xyz");
345
+ const parsed = requireEvent(event, "expected speech event from Twilio webhook");
346
+ expect(parsed.type).toBe("call.speech");
347
+ expect(parsed.turnToken).toBe("turn-xyz");
348
+ });
349
+
350
+ it("fails when an active stream exists but telephony TTS is unavailable", async () => {
351
+ const { provider, apiRequest } = configureTelephonyTwiMlFallback({
352
+ providerCallId: "CA-stream",
353
+ streamSid: "MZ-stream",
354
+ });
355
+
356
+ await expect(
357
+ provider.playTts({
358
+ callId: "call-stream",
359
+ providerCallId: "CA-stream",
360
+ text: "Hello stream",
361
+ }),
362
+ ).rejects.toThrow("refusing TwiML fallback");
363
+ expect(apiRequest).not.toHaveBeenCalled();
364
+ });
365
+
366
+ it("falls back to TwiML when no active stream exists and telephony TTS is unavailable", async () => {
367
+ const { provider, apiRequest } = configureTelephonyTwiMlFallback({
368
+ providerCallId: "CA-nostream",
369
+ });
370
+
371
+ await expect(
372
+ provider.playTts({
373
+ callId: "call-nostream",
374
+ providerCallId: "CA-nostream",
375
+ text: "Hello TwiML",
376
+ }),
377
+ ).resolves.toBeUndefined();
378
+ expect(apiRequest).toHaveBeenCalledTimes(1);
379
+ const call = apiRequest.mock.calls[0];
380
+ const endpoint = call[0];
381
+ const params = call[1] as { Twiml?: string };
382
+ expect(endpoint).toBe("/Calls/CA-nostream.json");
383
+ expect(params.Twiml).toContain("<Say");
384
+ });
385
+
386
+ it("retries TwiML fallback when Twilio briefly rejects a live-call update as not in progress", async () => {
387
+ vi.useFakeTimers();
388
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
389
+ try {
390
+ const { provider, apiRequest } = configureTelephonyTwiMlFallback({
391
+ providerCallId: "CA-race-play",
392
+ });
393
+ apiRequest.mockRejectedValueOnce(createTwilioCallStateRaceError()).mockResolvedValueOnce({});
394
+
395
+ const playback = provider.playTts({
396
+ callId: "call-race-play",
397
+ providerCallId: "CA-race-play",
398
+ text: "Hello after race",
399
+ });
400
+ await Promise.resolve();
401
+ expect(apiRequest).toHaveBeenCalledTimes(1);
402
+
403
+ await vi.advanceTimersByTimeAsync(250);
404
+ await expect(playback).resolves.toBeUndefined();
405
+
406
+ expect(apiRequest).toHaveBeenCalledTimes(2);
407
+ expect(apiRequest.mock.calls[0]?.[0]).toBe("/Calls/CA-race-play.json");
408
+ expect(apiRequest.mock.calls[1]?.[0]).toBe("/Calls/CA-race-play.json");
409
+ expect(warn).toHaveBeenCalledWith(
410
+ "[voice-call] Twilio playTts update hit call state race (21220); retrying in 250ms",
411
+ );
412
+ } finally {
413
+ warn.mockRestore();
414
+ vi.useRealTimers();
415
+ }
416
+ });
417
+
418
+ it("sends DTMF by updating the call and redirecting back to the webhook", async () => {
419
+ const { provider, apiRequest } = configureTelephonyTwiMlFallback({
420
+ providerCallId: "CA-dtmf",
421
+ });
422
+
423
+ await expect(
424
+ provider.sendDtmf({
425
+ callId: "call-dtmf",
426
+ providerCallId: "CA-dtmf",
427
+ digits: "ww123#",
428
+ }),
429
+ ).resolves.toBeUndefined();
430
+
431
+ expect(apiRequest).toHaveBeenCalledTimes(1);
432
+ const call = apiRequest.mock.calls[0];
433
+ const endpoint = call[0];
434
+ const params = call[1] as { Twiml?: string };
435
+ expect(endpoint).toBe("/Calls/CA-dtmf.json");
436
+ expect(params.Twiml).toContain('<Play digits="ww123#"');
437
+ expect(params.Twiml).toContain("<Redirect");
438
+ expect(params.Twiml).toContain("https://example.ngrok.app/voice/twilio");
439
+ });
440
+
441
+ it("retries startListening when Twilio briefly rejects a live-call update as not in progress", async () => {
442
+ vi.useFakeTimers();
443
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
444
+ try {
445
+ const { provider, apiRequest } = configureTelephonyTwiMlFallback({
446
+ providerCallId: "CA-race-listen",
447
+ });
448
+ apiRequest.mockRejectedValueOnce(createTwilioCallStateRaceError()).mockResolvedValueOnce({});
449
+
450
+ const listening = provider.startListening({
451
+ callId: "call-race-listen",
452
+ providerCallId: "CA-race-listen",
453
+ });
454
+ await Promise.resolve();
455
+ expect(apiRequest).toHaveBeenCalledTimes(1);
456
+
457
+ await vi.advanceTimersByTimeAsync(250);
458
+ await expect(listening).resolves.toBeUndefined();
459
+
460
+ expect(apiRequest).toHaveBeenCalledTimes(2);
461
+ expect(apiRequest.mock.calls[0]?.[0]).toBe("/Calls/CA-race-listen.json");
462
+ expect(apiRequest.mock.calls[1]?.[0]).toBe("/Calls/CA-race-listen.json");
463
+ expect(warn).toHaveBeenCalledWith(
464
+ "[voice-call] Twilio startListening update hit call state race (21220); retrying in 250ms",
465
+ );
466
+ } finally {
467
+ warn.mockRestore();
468
+ vi.useRealTimers();
469
+ }
470
+ });
471
+
472
+ it("ignores stale stream unregister requests that do not match current stream SID", () => {
473
+ const provider = createProvider();
474
+ provider.registerCallStream("CA-reconnect", "MZ-new");
475
+
476
+ provider.unregisterCallStream("CA-reconnect", "MZ-old");
477
+ expect(provider.hasRegisteredStream("CA-reconnect")).toBe(true);
478
+
479
+ provider.unregisterCallStream("CA-reconnect", "MZ-new");
480
+ expect(provider.hasRegisteredStream("CA-reconnect")).toBe(false);
481
+ });
482
+
483
+ it("times out telephony synthesis in stream mode and does not send completion mark", async () => {
484
+ vi.useFakeTimers();
485
+ try {
486
+ const provider = createProvider();
487
+ provider.registerCallStream("CA-timeout", "MZ-timeout");
488
+
489
+ const sendAudio = vi.fn();
490
+ const sendMark = vi.fn();
491
+ const mediaStreamHandler = {
492
+ queueTts: async (
493
+ _streamSid: string,
494
+ playFn: (signal: AbortSignal) => Promise<void>,
495
+ ): Promise<void> => {
496
+ await playFn(new AbortController().signal);
497
+ },
498
+ sendAudio,
499
+ sendMark,
500
+ };
501
+
502
+ provider.setMediaStreamHandler(mediaStreamHandler as never);
503
+ provider.setTTSProvider({
504
+ synthesisTimeoutMs: 5000,
505
+ synthesizeForTelephony: async () => await new Promise<Buffer>(() => {}),
506
+ });
507
+
508
+ const playExpectation = expect(
509
+ provider.playTts({
510
+ callId: "call-timeout",
511
+ providerCallId: "CA-timeout",
512
+ text: "Timeout me",
513
+ }),
514
+ ).rejects.toThrow("Telephony TTS synthesis timed out after 5000ms");
515
+ await vi.advanceTimersByTimeAsync(5_100);
516
+ await playExpectation;
517
+ expect(sendAudio).toHaveBeenCalled();
518
+ expect(sendMark).not.toHaveBeenCalled();
519
+ } finally {
520
+ vi.useRealTimers();
521
+ }
522
+ });
523
+
524
+ it("fails stream playback when all audio sends and completion mark are dropped", async () => {
525
+ const provider = createProvider();
526
+ provider.registerCallStream("CA-dropped", "MZ-dropped");
527
+
528
+ const sendAudio = vi.fn(() => ({ sent: false }));
529
+ const sendMark = vi.fn(() => ({ sent: false }));
530
+ const mediaStreamHandler = {
531
+ queueTts: async (
532
+ _streamSid: string,
533
+ playFn: (signal: AbortSignal) => Promise<void>,
534
+ ): Promise<void> => {
535
+ await playFn(new AbortController().signal);
536
+ },
537
+ sendAudio,
538
+ sendMark,
539
+ };
540
+
541
+ provider.setMediaStreamHandler(mediaStreamHandler as never);
542
+ provider.setTTSProvider({
543
+ synthesisTimeoutMs: 5000,
544
+ synthesizeForTelephony: async () => Buffer.alloc(320),
545
+ });
546
+
547
+ await expect(
548
+ provider.playTts({
549
+ callId: "call-dropped",
550
+ providerCallId: "CA-dropped",
551
+ text: "Dropped audio",
552
+ }),
553
+ ).rejects.toThrow("Telephony stream playback failed");
554
+ expect(sendAudio).toHaveBeenCalled();
555
+ expect(sendMark).toHaveBeenCalledTimes(1);
556
+ });
557
+
558
+ it("fails stream playback when telephony synthesis returns empty audio", async () => {
559
+ const provider = createProvider();
560
+ provider.registerCallStream("CA-empty", "MZ-empty");
561
+
562
+ const sendAudio = vi.fn();
563
+ const sendMark = vi.fn();
564
+ const mediaStreamHandler = {
565
+ queueTts: async (
566
+ _streamSid: string,
567
+ playFn: (signal: AbortSignal) => Promise<void>,
568
+ ): Promise<void> => {
569
+ await playFn(new AbortController().signal);
570
+ },
571
+ sendAudio,
572
+ sendMark,
573
+ };
574
+
575
+ provider.setMediaStreamHandler(mediaStreamHandler as never);
576
+ provider.setTTSProvider({
577
+ synthesisTimeoutMs: 5000,
578
+ synthesizeForTelephony: async () => Buffer.alloc(0),
579
+ });
580
+
581
+ await expect(
582
+ provider.playTts({
583
+ callId: "call-empty",
584
+ providerCallId: "CA-empty",
585
+ text: "Empty audio",
586
+ }),
587
+ ).rejects.toThrow("Telephony TTS produced no audio");
588
+ expect(sendAudio).toHaveBeenCalled();
589
+ expect(sendMark).not.toHaveBeenCalled();
190
590
  });
191
591
  });