@openclaw/voice-call 2026.5.2 → 2026.5.3-beta.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 (126) hide show
  1. package/dist/api.js +2 -0
  2. package/dist/call-status-CXldV5o8.js +32 -0
  3. package/dist/cli-metadata.js +12 -0
  4. package/dist/config-7w04YpHh.js +548 -0
  5. package/dist/config-compat-B0me39_4.js +129 -0
  6. package/dist/guarded-json-api-Btx5EE4w.js +591 -0
  7. package/dist/http-headers-BrnxBasF.js +10 -0
  8. package/dist/index.js +1284 -0
  9. package/dist/mock-CeKvfVEd.js +135 -0
  10. package/dist/plivo-B-a7KFoT.js +393 -0
  11. package/dist/realtime-handler-B63CIDP2.js +325 -0
  12. package/dist/realtime-transcription.runtime-B2h70y2W.js +2 -0
  13. package/dist/realtime-voice.runtime-Bkh4nvLn.js +2 -0
  14. package/dist/response-generator-BrcmwDZU.js +182 -0
  15. package/dist/response-model-CyF5K80p.js +12 -0
  16. package/dist/runtime-api.js +6 -0
  17. package/dist/runtime-entry-88ytYAQa.js +3119 -0
  18. package/dist/runtime-entry.js +2 -0
  19. package/dist/setup-api.js +37 -0
  20. package/dist/telnyx-jjBE8boz.js +260 -0
  21. package/dist/twilio-1OqbcXLL.js +676 -0
  22. package/dist/voice-mapping-BYDGdWGx.js +40 -0
  23. package/package.json +14 -6
  24. package/api.ts +0 -16
  25. package/cli-metadata.ts +0 -10
  26. package/config-api.ts +0 -12
  27. package/index.test.ts +0 -943
  28. package/index.ts +0 -794
  29. package/runtime-api.ts +0 -20
  30. package/runtime-entry.ts +0 -1
  31. package/setup-api.ts +0 -47
  32. package/src/allowlist.test.ts +0 -18
  33. package/src/allowlist.ts +0 -19
  34. package/src/cli.ts +0 -845
  35. package/src/config-compat.test.ts +0 -120
  36. package/src/config-compat.ts +0 -227
  37. package/src/config.test.ts +0 -479
  38. package/src/config.ts +0 -808
  39. package/src/core-bridge.ts +0 -14
  40. package/src/deep-merge.test.ts +0 -40
  41. package/src/deep-merge.ts +0 -23
  42. package/src/gateway-continue-operation.ts +0 -200
  43. package/src/http-headers.test.ts +0 -16
  44. package/src/http-headers.ts +0 -15
  45. package/src/manager/context.ts +0 -42
  46. package/src/manager/events.test.ts +0 -581
  47. package/src/manager/events.ts +0 -288
  48. package/src/manager/lifecycle.ts +0 -53
  49. package/src/manager/lookup.test.ts +0 -52
  50. package/src/manager/lookup.ts +0 -35
  51. package/src/manager/outbound.test.ts +0 -528
  52. package/src/manager/outbound.ts +0 -486
  53. package/src/manager/state.ts +0 -48
  54. package/src/manager/store.ts +0 -106
  55. package/src/manager/timers.test.ts +0 -129
  56. package/src/manager/timers.ts +0 -113
  57. package/src/manager/twiml.test.ts +0 -13
  58. package/src/manager/twiml.ts +0 -17
  59. package/src/manager.closed-loop.test.ts +0 -236
  60. package/src/manager.inbound-allowlist.test.ts +0 -188
  61. package/src/manager.notify.test.ts +0 -377
  62. package/src/manager.restore.test.ts +0 -183
  63. package/src/manager.test-harness.ts +0 -127
  64. package/src/manager.ts +0 -392
  65. package/src/media-stream.test.ts +0 -768
  66. package/src/media-stream.ts +0 -708
  67. package/src/providers/base.ts +0 -97
  68. package/src/providers/mock.test.ts +0 -78
  69. package/src/providers/mock.ts +0 -185
  70. package/src/providers/plivo.test.ts +0 -93
  71. package/src/providers/plivo.ts +0 -601
  72. package/src/providers/shared/call-status.test.ts +0 -24
  73. package/src/providers/shared/call-status.ts +0 -24
  74. package/src/providers/shared/guarded-json-api.test.ts +0 -106
  75. package/src/providers/shared/guarded-json-api.ts +0 -42
  76. package/src/providers/telnyx.test.ts +0 -340
  77. package/src/providers/telnyx.ts +0 -394
  78. package/src/providers/twilio/api.test.ts +0 -145
  79. package/src/providers/twilio/api.ts +0 -93
  80. package/src/providers/twilio/twiml-policy.test.ts +0 -84
  81. package/src/providers/twilio/twiml-policy.ts +0 -87
  82. package/src/providers/twilio/webhook.ts +0 -34
  83. package/src/providers/twilio.test.ts +0 -591
  84. package/src/providers/twilio.ts +0 -861
  85. package/src/providers/twilio.types.ts +0 -17
  86. package/src/realtime-defaults.ts +0 -3
  87. package/src/realtime-fast-context.test.ts +0 -88
  88. package/src/realtime-fast-context.ts +0 -165
  89. package/src/realtime-transcription.runtime.ts +0 -4
  90. package/src/realtime-voice.runtime.ts +0 -5
  91. package/src/response-generator.test.ts +0 -321
  92. package/src/response-generator.ts +0 -318
  93. package/src/response-model.test.ts +0 -71
  94. package/src/response-model.ts +0 -23
  95. package/src/runtime.test.ts +0 -536
  96. package/src/runtime.ts +0 -510
  97. package/src/telephony-audio.test.ts +0 -61
  98. package/src/telephony-audio.ts +0 -12
  99. package/src/telephony-tts.test.ts +0 -196
  100. package/src/telephony-tts.ts +0 -235
  101. package/src/test-fixtures.ts +0 -73
  102. package/src/tts-provider-voice.test.ts +0 -34
  103. package/src/tts-provider-voice.ts +0 -21
  104. package/src/tunnel.test.ts +0 -166
  105. package/src/tunnel.ts +0 -314
  106. package/src/types.ts +0 -291
  107. package/src/utils.test.ts +0 -17
  108. package/src/utils.ts +0 -14
  109. package/src/voice-mapping.test.ts +0 -34
  110. package/src/voice-mapping.ts +0 -68
  111. package/src/webhook/realtime-handler.test.ts +0 -598
  112. package/src/webhook/realtime-handler.ts +0 -485
  113. package/src/webhook/stale-call-reaper.test.ts +0 -88
  114. package/src/webhook/stale-call-reaper.ts +0 -38
  115. package/src/webhook/tailscale.test.ts +0 -214
  116. package/src/webhook/tailscale.ts +0 -129
  117. package/src/webhook-exposure.test.ts +0 -33
  118. package/src/webhook-exposure.ts +0 -84
  119. package/src/webhook-security.test.ts +0 -770
  120. package/src/webhook-security.ts +0 -994
  121. package/src/webhook.hangup-once.lifecycle.test.ts +0 -135
  122. package/src/webhook.test.ts +0 -1470
  123. package/src/webhook.ts +0 -908
  124. package/src/webhook.types.ts +0 -5
  125. package/src/websocket-test-support.ts +0 -72
  126. package/tsconfig.json +0 -16
@@ -0,0 +1,676 @@
1
+ import { fetchWithSsrFGuard } from "./runtime-api.js";
2
+ import "./api.js";
3
+ import { n as mapVoiceToPolly, t as escapeXml } from "./voice-mapping-BYDGdWGx.js";
4
+ import { i as chunkAudio, n as mapProviderStatusToEndReason, r as normalizeProviderStatus, t as isProviderStatusTerminal } from "./call-status-CXldV5o8.js";
5
+ import { t as getHeader } from "./http-headers-BrnxBasF.js";
6
+ import { a as verifyTwilioWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-Btx5EE4w.js";
7
+ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
8
+ import crypto from "node:crypto";
9
+ import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
10
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
11
+ //#region extensions/voice-call/src/providers/twilio/api.ts
12
+ const TWILIO_API_TIMEOUT_MS = 3e4;
13
+ function parseTwilioApiError(text) {
14
+ try {
15
+ const parsed = JSON.parse(text);
16
+ if (!parsed || typeof parsed !== "object") return {};
17
+ const record = parsed;
18
+ return {
19
+ code: typeof record.code === "number" ? record.code : void 0,
20
+ message: typeof record.message === "string" ? record.message : void 0
21
+ };
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
26
+ var TwilioApiError = class extends Error {
27
+ constructor(httpStatus, responseText) {
28
+ const parsed = parseTwilioApiError(responseText);
29
+ const detail = parsed.message ?? responseText;
30
+ super(`Twilio API error: ${httpStatus} ${detail}`);
31
+ this.name = "TwilioApiError";
32
+ this.httpStatus = httpStatus;
33
+ this.responseText = responseText;
34
+ this.twilioCode = parsed.code;
35
+ }
36
+ };
37
+ async function twilioApiRequest(params) {
38
+ const bodyParams = params.body instanceof URLSearchParams ? params.body : Object.entries(params.body).reduce((acc, [key, value]) => {
39
+ if (Array.isArray(value)) for (const entry of value) acc.append(key, entry);
40
+ else if (typeof value === "string") acc.append(key, value);
41
+ return acc;
42
+ }, new URLSearchParams());
43
+ const { response, release } = await fetchWithSsrFGuard({
44
+ url: `${params.baseUrl}${params.endpoint}`,
45
+ init: {
46
+ method: "POST",
47
+ headers: {
48
+ Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
49
+ "Content-Type": "application/x-www-form-urlencoded"
50
+ },
51
+ body: bodyParams
52
+ },
53
+ policy: { allowedHostnames: ["api.twilio.com"] },
54
+ timeoutMs: TWILIO_API_TIMEOUT_MS,
55
+ auditContext: "voice-call.twilio.api"
56
+ });
57
+ try {
58
+ if (!response.ok) {
59
+ if (params.allowNotFound && response.status === 404) return;
60
+ const errorText = await response.text();
61
+ throw new TwilioApiError(response.status, errorText);
62
+ }
63
+ const text = await response.text();
64
+ return text ? JSON.parse(text) : void 0;
65
+ } finally {
66
+ await release();
67
+ }
68
+ }
69
+ //#endregion
70
+ //#region extensions/voice-call/src/providers/twilio/twiml-policy.ts
71
+ function isOutboundDirection(direction) {
72
+ return direction?.startsWith("outbound") ?? false;
73
+ }
74
+ function readTwimlRequestView(ctx) {
75
+ const params = new URLSearchParams(ctx.rawBody);
76
+ const type = normalizeOptionalString(ctx.query?.type);
77
+ const callIdFromQuery = normalizeOptionalString(ctx.query?.callId);
78
+ return {
79
+ callStatus: params.get("CallStatus"),
80
+ direction: params.get("Direction"),
81
+ isStatusCallback: type === "status",
82
+ callSid: params.get("CallSid") || void 0,
83
+ callIdFromQuery
84
+ };
85
+ }
86
+ function decideTwimlResponse(input) {
87
+ if (input.callIdFromQuery && !input.isStatusCallback) {
88
+ if (input.hasStoredTwiml) return {
89
+ kind: "stored",
90
+ consumeStoredTwimlCallId: input.callIdFromQuery
91
+ };
92
+ if (input.isNotifyCall) return { kind: "empty" };
93
+ if (isOutboundDirection(input.direction)) return input.canStream ? { kind: "stream" } : { kind: "pause" };
94
+ }
95
+ if (input.isStatusCallback) return { kind: "empty" };
96
+ if (input.direction === "inbound") {
97
+ if (input.hasActiveStreams) return { kind: "queue" };
98
+ if (input.canStream && input.callSid) return {
99
+ kind: "stream",
100
+ activateStreamCallSid: input.callSid
101
+ };
102
+ return { kind: "pause" };
103
+ }
104
+ if (input.callStatus !== "in-progress") return { kind: "empty" };
105
+ return input.canStream ? { kind: "stream" } : { kind: "pause" };
106
+ }
107
+ //#endregion
108
+ //#region extensions/voice-call/src/providers/twilio/webhook.ts
109
+ function verifyTwilioProviderWebhook(params) {
110
+ const result = verifyTwilioWebhook(params.ctx, params.authToken, {
111
+ publicUrl: params.currentPublicUrl || void 0,
112
+ allowNgrokFreeTierLoopbackBypass: params.options.allowNgrokFreeTierLoopbackBypass ?? false,
113
+ skipVerification: params.options.skipVerification,
114
+ allowedHosts: params.options.webhookSecurity?.allowedHosts,
115
+ trustForwardingHeaders: params.options.webhookSecurity?.trustForwardingHeaders,
116
+ trustedProxyIPs: params.options.webhookSecurity?.trustedProxyIPs,
117
+ remoteIP: params.ctx.remoteAddress
118
+ });
119
+ if (!result.ok) {
120
+ console.warn(`[twilio] Webhook verification failed: ${result.reason}`);
121
+ if (result.verificationUrl) console.warn(`[twilio] Verification URL: ${result.verificationUrl}`);
122
+ }
123
+ return {
124
+ ok: result.ok,
125
+ reason: result.reason,
126
+ isReplay: result.isReplay,
127
+ verifiedRequestKey: result.verifiedRequestKey
128
+ };
129
+ }
130
+ //#endregion
131
+ //#region extensions/voice-call/src/providers/twilio.ts
132
+ const TWILIO_CALL_NOT_IN_PROGRESS_CODE = 21220;
133
+ const TWILIO_CALL_UPDATE_RETRY_DELAYS_MS = [250, 750];
134
+ function isTwilioCallNotInProgressError(err) {
135
+ return err instanceof TwilioApiError && err.twilioCode === TWILIO_CALL_NOT_IN_PROGRESS_CODE;
136
+ }
137
+ function createTwilioRequestDedupeKey(ctx, verifiedRequestKey) {
138
+ if (verifiedRequestKey) return verifiedRequestKey;
139
+ const signature = getHeader(ctx.headers, "x-twilio-signature") ?? "";
140
+ const params = new URLSearchParams(ctx.rawBody);
141
+ const callSid = params.get("CallSid") ?? "";
142
+ const callStatus = params.get("CallStatus") ?? "";
143
+ const direction = params.get("Direction") ?? "";
144
+ const callId = normalizeOptionalString(ctx.query?.callId) ?? "";
145
+ const flow = normalizeOptionalString(ctx.query?.flow) ?? "";
146
+ const turnToken = normalizeOptionalString(ctx.query?.turnToken) ?? "";
147
+ return `twilio:fallback:${crypto.createHash("sha256").update(`${signature}\n${callSid}\n${callStatus}\n${direction}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`).digest("hex")}`;
148
+ }
149
+ var TwilioProvider = class TwilioProvider {
150
+ /**
151
+ * Delete stored TwiML for a given `callId`.
152
+ *
153
+ * We keep TwiML in-memory only long enough to satisfy the initial Twilio
154
+ * webhook request (notify mode). Subsequent webhooks should not reuse it.
155
+ */
156
+ deleteStoredTwiml(callId) {
157
+ this.twimlStorage.delete(callId);
158
+ this.notifyCalls.delete(callId);
159
+ }
160
+ /**
161
+ * Delete stored TwiML for a call, addressed by Twilio's provider call SID.
162
+ *
163
+ * This is used when we only have `providerCallId` (e.g. hangup).
164
+ */
165
+ deleteStoredTwimlForProviderCall(providerCallId) {
166
+ const webhookUrl = this.callWebhookUrls.get(providerCallId);
167
+ if (!webhookUrl) return;
168
+ const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
169
+ if (!callIdMatch) return;
170
+ this.deleteStoredTwiml(callIdMatch[1]);
171
+ this.streamAuthTokens.delete(providerCallId);
172
+ }
173
+ constructor(config, options = {}) {
174
+ this.name = "twilio";
175
+ this.callWebhookUrls = /* @__PURE__ */ new Map();
176
+ this.currentPublicUrl = null;
177
+ this.ttsProvider = null;
178
+ this.mediaStreamHandler = null;
179
+ this.callStreamMap = /* @__PURE__ */ new Map();
180
+ this.streamAuthTokens = /* @__PURE__ */ new Map();
181
+ this.twimlStorage = /* @__PURE__ */ new Map();
182
+ this.notifyCalls = /* @__PURE__ */ new Set();
183
+ this.activeStreamCalls = /* @__PURE__ */ new Set();
184
+ if (!config.accountSid) throw new Error("Twilio Account SID is required");
185
+ if (!config.authToken) throw new Error("Twilio Auth Token is required");
186
+ this.accountSid = config.accountSid;
187
+ this.authToken = config.authToken;
188
+ this.baseUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.accountSid}`;
189
+ this.options = options;
190
+ if (options.publicUrl) this.currentPublicUrl = options.publicUrl;
191
+ }
192
+ setPublicUrl(url) {
193
+ this.currentPublicUrl = url;
194
+ }
195
+ getPublicUrl() {
196
+ return this.currentPublicUrl;
197
+ }
198
+ setTTSProvider(provider) {
199
+ this.ttsProvider = provider;
200
+ }
201
+ setMediaStreamHandler(handler) {
202
+ this.mediaStreamHandler = handler;
203
+ }
204
+ registerCallStream(callSid, streamSid) {
205
+ this.callStreamMap.set(callSid, streamSid);
206
+ }
207
+ hasRegisteredStream(callSid) {
208
+ return this.callStreamMap.has(callSid);
209
+ }
210
+ unregisterCallStream(callSid, streamSid) {
211
+ const currentStreamSid = this.callStreamMap.get(callSid);
212
+ if (!currentStreamSid) {
213
+ if (!streamSid) this.activeStreamCalls.delete(callSid);
214
+ return;
215
+ }
216
+ if (streamSid && currentStreamSid !== streamSid) return;
217
+ this.callStreamMap.delete(callSid);
218
+ this.activeStreamCalls.delete(callSid);
219
+ }
220
+ isConversationStreamConnectEnabled() {
221
+ return Boolean(this.mediaStreamHandler && this.getStreamUrl());
222
+ }
223
+ isValidStreamToken(callSid, token) {
224
+ const expected = this.streamAuthTokens.get(callSid);
225
+ if (!expected || !token) return false;
226
+ return safeEqualSecret(expected, token);
227
+ }
228
+ /**
229
+ * Clear TTS queue for a call (barge-in).
230
+ * Used when user starts speaking to interrupt current TTS playback.
231
+ */
232
+ clearTtsQueue(callSid, reason = "unspecified") {
233
+ const streamSid = this.callStreamMap.get(callSid);
234
+ if (!streamSid || !this.mediaStreamHandler) return;
235
+ this.mediaStreamHandler.clearTtsQueue(streamSid, reason);
236
+ }
237
+ /**
238
+ * Make an authenticated request to the Twilio API.
239
+ */
240
+ async apiRequest(endpoint, params, options) {
241
+ return await twilioApiRequest({
242
+ baseUrl: this.baseUrl,
243
+ accountSid: this.accountSid,
244
+ authToken: this.authToken,
245
+ endpoint,
246
+ body: params,
247
+ allowNotFound: options?.allowNotFound
248
+ });
249
+ }
250
+ async updateLiveCallTwiml(providerCallId, twiml, operation) {
251
+ let retryIndex = 0;
252
+ while (true) try {
253
+ await this.apiRequest(`/Calls/${providerCallId}.json`, { Twiml: twiml });
254
+ return;
255
+ } catch (err) {
256
+ const retryDelayMs = TWILIO_CALL_UPDATE_RETRY_DELAYS_MS[retryIndex];
257
+ if (retryDelayMs === void 0 || !isTwilioCallNotInProgressError(err)) throw err;
258
+ retryIndex += 1;
259
+ console.warn(`[voice-call] Twilio ${operation} update hit call state race (21220); retrying in ${retryDelayMs}ms`);
260
+ await setTimeout$1(retryDelayMs);
261
+ }
262
+ }
263
+ /**
264
+ * Verify Twilio webhook signature using HMAC-SHA1.
265
+ *
266
+ * Handles reverse proxy scenarios (Tailscale, nginx, ngrok) by reconstructing
267
+ * the public URL from forwarding headers.
268
+ *
269
+ * @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
270
+ */
271
+ verifyWebhook(ctx) {
272
+ return verifyTwilioProviderWebhook({
273
+ ctx,
274
+ authToken: this.authToken,
275
+ currentPublicUrl: this.currentPublicUrl,
276
+ options: this.options
277
+ });
278
+ }
279
+ /**
280
+ * Parse Twilio webhook event into normalized format.
281
+ */
282
+ parseWebhookEvent(ctx, options) {
283
+ try {
284
+ const params = new URLSearchParams(ctx.rawBody);
285
+ const callIdFromQuery = normalizeOptionalString(ctx.query?.callId);
286
+ const turnTokenFromQuery = normalizeOptionalString(ctx.query?.turnToken);
287
+ const dedupeKey = createTwilioRequestDedupeKey(ctx, options?.verifiedRequestKey);
288
+ const event = this.normalizeEvent(params, {
289
+ callIdOverride: callIdFromQuery,
290
+ dedupeKey,
291
+ turnToken: turnTokenFromQuery
292
+ });
293
+ const twiml = this.generateTwimlResponse(ctx);
294
+ return {
295
+ events: event ? [event] : [],
296
+ providerResponseBody: twiml,
297
+ providerResponseHeaders: { "Content-Type": "application/xml" },
298
+ statusCode: 200
299
+ };
300
+ } catch {
301
+ return {
302
+ events: [],
303
+ statusCode: 400
304
+ };
305
+ }
306
+ }
307
+ /**
308
+ * Parse Twilio direction to normalized format.
309
+ */
310
+ static parseDirection(direction) {
311
+ if (direction === "inbound") return "inbound";
312
+ if (direction === "outbound-api" || direction === "outbound-dial") return "outbound";
313
+ }
314
+ /**
315
+ * Convert Twilio webhook params to normalized event format.
316
+ */
317
+ normalizeEvent(params, options) {
318
+ const callSid = params.get("CallSid") || "";
319
+ const callIdOverride = options?.callIdOverride;
320
+ const baseEvent = {
321
+ id: crypto.randomUUID(),
322
+ dedupeKey: options?.dedupeKey,
323
+ callId: callIdOverride || callSid,
324
+ providerCallId: callSid,
325
+ timestamp: Date.now(),
326
+ turnToken: options?.turnToken,
327
+ direction: TwilioProvider.parseDirection(params.get("Direction")),
328
+ from: params.get("From") || void 0,
329
+ to: params.get("To") || void 0
330
+ };
331
+ const speechResult = params.get("SpeechResult");
332
+ if (speechResult) return {
333
+ ...baseEvent,
334
+ type: "call.speech",
335
+ transcript: speechResult,
336
+ isFinal: true,
337
+ confidence: Number.parseFloat(params.get("Confidence") || "0.9")
338
+ };
339
+ const digits = params.get("Digits");
340
+ if (digits) return {
341
+ ...baseEvent,
342
+ type: "call.dtmf",
343
+ digits
344
+ };
345
+ const callStatus = normalizeProviderStatus(params.get("CallStatus"));
346
+ if (callStatus === "initiated") return {
347
+ ...baseEvent,
348
+ type: "call.initiated"
349
+ };
350
+ if (callStatus === "ringing") return {
351
+ ...baseEvent,
352
+ type: "call.ringing"
353
+ };
354
+ if (callStatus === "in-progress") return {
355
+ ...baseEvent,
356
+ type: "call.answered"
357
+ };
358
+ const endReason = mapProviderStatusToEndReason(callStatus);
359
+ if (endReason) {
360
+ this.streamAuthTokens.delete(callSid);
361
+ this.activeStreamCalls.delete(callSid);
362
+ if (callIdOverride) this.deleteStoredTwiml(callIdOverride);
363
+ return {
364
+ ...baseEvent,
365
+ type: "call.ended",
366
+ reason: endReason
367
+ };
368
+ }
369
+ return null;
370
+ }
371
+ static {
372
+ this.EMPTY_TWIML = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response></Response>";
373
+ }
374
+ static {
375
+ this.PAUSE_TWIML = `<?xml version="1.0" encoding="UTF-8"?>
376
+ <Response>
377
+ <Pause length="30"/>
378
+ </Response>`;
379
+ }
380
+ static {
381
+ this.QUEUE_TWIML = `<?xml version="1.0" encoding="UTF-8"?>
382
+ <Response>
383
+ <Say voice="alice">Please hold while we connect you.</Say>
384
+ <Enqueue waitUrl="/voice/hold-music">hold-queue</Enqueue>
385
+ </Response>`;
386
+ }
387
+ /**
388
+ * Generate TwiML response for webhook.
389
+ * When a call is answered, connects to media stream for bidirectional audio.
390
+ */
391
+ generateTwimlResponse(ctx) {
392
+ if (!ctx) return TwilioProvider.EMPTY_TWIML;
393
+ const view = readTwimlRequestView(ctx);
394
+ const storedTwiml = view.callIdFromQuery ? this.twimlStorage.get(view.callIdFromQuery) : void 0;
395
+ const decision = decideTwimlResponse({
396
+ ...view,
397
+ hasStoredTwiml: Boolean(storedTwiml),
398
+ isNotifyCall: view.callIdFromQuery ? this.notifyCalls.has(view.callIdFromQuery) : false,
399
+ hasActiveStreams: this.activeStreamCalls.size > 0,
400
+ canStream: Boolean(view.callSid && this.getStreamUrl())
401
+ });
402
+ if (decision.consumeStoredTwimlCallId) this.deleteStoredTwiml(decision.consumeStoredTwimlCallId);
403
+ if (decision.activateStreamCallSid) this.activeStreamCalls.add(decision.activateStreamCallSid);
404
+ switch (decision.kind) {
405
+ case "stored": return storedTwiml ?? TwilioProvider.EMPTY_TWIML;
406
+ case "queue": return TwilioProvider.QUEUE_TWIML;
407
+ case "pause": return TwilioProvider.PAUSE_TWIML;
408
+ case "stream": {
409
+ const streamUrl = view.callSid ? this.getStreamUrlForCall(view.callSid) : null;
410
+ return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
411
+ }
412
+ default: return TwilioProvider.EMPTY_TWIML;
413
+ }
414
+ }
415
+ consumeInitialTwiML(ctx) {
416
+ const view = readTwimlRequestView(ctx);
417
+ if (!view.callIdFromQuery || view.isStatusCallback) return null;
418
+ const storedTwiml = this.twimlStorage.get(view.callIdFromQuery);
419
+ if (!storedTwiml) return null;
420
+ const kind = this.notifyCalls.has(view.callIdFromQuery) ? "notify" : "pre-connect";
421
+ this.deleteStoredTwiml(view.callIdFromQuery);
422
+ console.log(`[voice-call] Twilio initial TwiML consumed for call ${view.callIdFromQuery} (kind=${kind}, callSid=${view.callSid ?? "unknown"})`);
423
+ return storedTwiml;
424
+ }
425
+ /**
426
+ * Get the WebSocket URL for media streaming.
427
+ * Derives from the public URL origin + stream path.
428
+ */
429
+ getStreamUrl() {
430
+ if (!this.currentPublicUrl || !this.options.streamPath) return null;
431
+ return `${new URL(this.currentPublicUrl).origin.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://")}${this.options.streamPath.startsWith("/") ? this.options.streamPath : `/${this.options.streamPath}`}`;
432
+ }
433
+ getStreamAuthToken(callSid) {
434
+ const existing = this.streamAuthTokens.get(callSid);
435
+ if (existing) return existing;
436
+ const token = crypto.randomBytes(16).toString("base64url");
437
+ this.streamAuthTokens.set(callSid, token);
438
+ return token;
439
+ }
440
+ getStreamUrlForCall(callSid) {
441
+ const baseUrl = this.getStreamUrl();
442
+ if (!baseUrl) return null;
443
+ const token = this.getStreamAuthToken(callSid);
444
+ const url = new URL(baseUrl);
445
+ url.searchParams.set("token", token);
446
+ return url.toString();
447
+ }
448
+ /**
449
+ * Generate TwiML to connect a call to a WebSocket media stream.
450
+ * This enables bidirectional audio streaming for real-time STT/TTS.
451
+ *
452
+ * @param streamUrl - WebSocket URL (wss://...) for the media stream
453
+ */
454
+ getStreamConnectXml(streamUrl) {
455
+ const parsed = new URL(streamUrl);
456
+ const token = parsed.searchParams.get("token");
457
+ parsed.searchParams.delete("token");
458
+ const cleanUrl = parsed.toString();
459
+ const paramXml = token ? `\n <Parameter name="token" value="${escapeXml(token)}" />` : "";
460
+ return `<?xml version="1.0" encoding="UTF-8"?>
461
+ <Response>
462
+ <Connect>
463
+ <Stream url="${escapeXml(cleanUrl)}">${paramXml}
464
+ </Stream>
465
+ </Connect>
466
+ </Response>`;
467
+ }
468
+ /**
469
+ * Initiate an outbound call via Twilio API.
470
+ * If preConnectTwiml is provided, the first webhook request receives that
471
+ * TwiML before normal dynamic TwiML resumes.
472
+ */
473
+ async initiateCall(input) {
474
+ const url = new URL(input.webhookUrl);
475
+ url.searchParams.set("callId", input.callId);
476
+ const statusUrl = new URL(input.webhookUrl);
477
+ statusUrl.searchParams.set("callId", input.callId);
478
+ statusUrl.searchParams.set("type", "status");
479
+ if (!input.inlineTwiml && input.preConnectTwiml) {
480
+ this.twimlStorage.set(input.callId, input.preConnectTwiml);
481
+ console.log(`[voice-call] Stored Twilio initial TwiML for call ${input.callId} (kind=pre-connect)`);
482
+ }
483
+ const params = {
484
+ To: input.to,
485
+ From: input.from,
486
+ StatusCallback: statusUrl.toString(),
487
+ StatusCallbackEvent: [
488
+ "initiated",
489
+ "ringing",
490
+ "answered",
491
+ "completed"
492
+ ],
493
+ Timeout: "30"
494
+ };
495
+ if (input.inlineTwiml) {
496
+ params.Twiml = input.inlineTwiml;
497
+ console.log(`[voice-call] Sending direct Twilio initial TwiML for call ${input.callId} (kind=notify)`);
498
+ } else params.Url = url.toString();
499
+ const result = await this.apiRequest("/Calls.json", params);
500
+ this.callWebhookUrls.set(result.sid, url.toString());
501
+ return {
502
+ providerCallId: result.sid,
503
+ status: result.status === "queued" ? "queued" : "initiated"
504
+ };
505
+ }
506
+ /**
507
+ * Hang up a call via Twilio API.
508
+ */
509
+ async hangupCall(input) {
510
+ this.deleteStoredTwimlForProviderCall(input.providerCallId);
511
+ this.callWebhookUrls.delete(input.providerCallId);
512
+ this.streamAuthTokens.delete(input.providerCallId);
513
+ this.activeStreamCalls.delete(input.providerCallId);
514
+ await this.apiRequest(`/Calls/${input.providerCallId}.json`, { Status: "completed" }, { allowNotFound: true });
515
+ }
516
+ /**
517
+ * Play TTS audio via Twilio.
518
+ *
519
+ * Two modes:
520
+ * 1. Core TTS + Media Streams: when an active stream exists, stream playback is required.
521
+ * If telephony TTS is unavailable in that state, playback fails rather than mixing paths.
522
+ * 2. TwiML <Say>: fallback only when there is no active stream for the call.
523
+ */
524
+ async playTts(input) {
525
+ const streamSid = this.callStreamMap.get(input.providerCallId);
526
+ if (streamSid) {
527
+ if (!this.ttsProvider || !this.mediaStreamHandler) throw new Error("Telephony TTS unavailable while media stream is active; refusing TwiML fallback");
528
+ try {
529
+ await this.playTtsViaStream(input.text, streamSid);
530
+ return;
531
+ } catch (err) {
532
+ console.warn(`[voice-call] Telephony TTS failed:`, err instanceof Error ? err.message : err);
533
+ throw err instanceof Error ? err : new Error(String(err));
534
+ }
535
+ }
536
+ const webhookUrl = this.callWebhookUrls.get(input.providerCallId);
537
+ if (!webhookUrl) throw new Error("Missing webhook URL for this call (provider state not initialized)");
538
+ console.warn("[voice-call] Using TwiML <Say> fallback - telephony TTS not configured or media stream not active");
539
+ const twiml = `<?xml version="1.0" encoding="UTF-8"?>
540
+ <Response>
541
+ <Say voice="${mapVoiceToPolly(input.voice)}" language="${input.locale || "en-US"}">${escapeXml(input.text)}</Say>
542
+ <Gather input="speech" speechTimeout="auto" action="${escapeXml(webhookUrl)}" method="POST">
543
+ <Say>.</Say>
544
+ </Gather>
545
+ </Response>`;
546
+ await this.updateLiveCallTwiml(input.providerCallId, twiml, "playTts");
547
+ }
548
+ async sendDtmf(input) {
549
+ const webhookUrl = this.callWebhookUrls.get(input.providerCallId);
550
+ if (!webhookUrl) throw new Error("Missing webhook URL for this call (provider state not initialized)");
551
+ const twiml = `<?xml version="1.0" encoding="UTF-8"?>
552
+ <Response>
553
+ <Play digits="${escapeXml(input.digits)}" />
554
+ <Redirect method="POST">${escapeXml(webhookUrl)}</Redirect>
555
+ </Response>`;
556
+ await this.updateLiveCallTwiml(input.providerCallId, twiml, "sendDtmf");
557
+ }
558
+ /**
559
+ * Play TTS via core TTS and Twilio Media Streams.
560
+ * Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
561
+ * Uses a queue to serialize playback and prevent overlapping audio.
562
+ */
563
+ async playTtsViaStream(text, streamSid) {
564
+ if (!this.ttsProvider || !this.mediaStreamHandler) throw new Error("TTS provider and media stream handler required");
565
+ const CHUNK_SIZE = 160;
566
+ const CHUNK_DELAY_MS = 20;
567
+ const SILENCE_CHUNK = Buffer.alloc(CHUNK_SIZE, 255);
568
+ const handler = this.mediaStreamHandler;
569
+ const ttsProvider = this.ttsProvider;
570
+ const normalizeSendResult = (raw) => {
571
+ if (!raw || typeof raw !== "object") return { sent: true };
572
+ const typed = raw;
573
+ return { sent: typed.sent === void 0 ? true : Boolean(typed.sent) };
574
+ };
575
+ const sendAudioChunk = (audio) => {
576
+ return normalizeSendResult(handler.sendAudio(streamSid, audio));
577
+ };
578
+ const sendPlaybackMark = (name) => {
579
+ return normalizeSendResult(handler.sendMark(streamSid, name));
580
+ };
581
+ await handler.queueTts(streamSid, async (signal) => {
582
+ const sendKeepAlive = () => {
583
+ sendAudioChunk(SILENCE_CHUNK);
584
+ };
585
+ sendKeepAlive();
586
+ const keepAlive = setInterval(() => {
587
+ if (!signal.aborted) sendKeepAlive();
588
+ }, CHUNK_DELAY_MS);
589
+ let muLawAudio;
590
+ let synthTimeout = null;
591
+ const synthTimeoutMs = ttsProvider.synthesisTimeoutMs;
592
+ try {
593
+ const synthPromise = ttsProvider.synthesizeForTelephony(text);
594
+ const timeoutPromise = new Promise((_, reject) => {
595
+ synthTimeout = setTimeout(() => {
596
+ reject(/* @__PURE__ */ new Error(`Telephony TTS synthesis timed out after ${synthTimeoutMs}ms`));
597
+ }, synthTimeoutMs);
598
+ });
599
+ muLawAudio = await Promise.race([synthPromise, timeoutPromise]);
600
+ } finally {
601
+ if (synthTimeout) clearTimeout(synthTimeout);
602
+ clearInterval(keepAlive);
603
+ }
604
+ if (muLawAudio.length === 0) throw new Error("Telephony TTS produced no audio");
605
+ let chunkAttempts = 0;
606
+ let chunkDelivered = 0;
607
+ let nextChunkDueAt = Date.now() + CHUNK_DELAY_MS;
608
+ for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
609
+ if (signal.aborted) break;
610
+ chunkAttempts += 1;
611
+ if (sendAudioChunk(chunk).sent) chunkDelivered += 1;
612
+ const waitMs = nextChunkDueAt - Date.now();
613
+ if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, Math.ceil(waitMs)));
614
+ nextChunkDueAt += CHUNK_DELAY_MS;
615
+ if (signal.aborted) break;
616
+ }
617
+ let markSent = true;
618
+ if (!signal.aborted) markSent = sendPlaybackMark(`tts-${Date.now()}`).sent;
619
+ if (!signal.aborted && chunkAttempts > 0 && (chunkDelivered === 0 || !markSent)) {
620
+ const failures = [];
621
+ if (chunkDelivered === 0) failures.push("no audio chunks delivered");
622
+ if (!markSent) failures.push("completion mark not delivered");
623
+ throw new Error(`Telephony stream playback failed: ${failures.join("; ")}`);
624
+ }
625
+ });
626
+ }
627
+ /**
628
+ * Start listening for speech via Twilio <Gather>.
629
+ */
630
+ async startListening(input) {
631
+ const webhookUrl = this.callWebhookUrls.get(input.providerCallId);
632
+ if (!webhookUrl) throw new Error("Missing webhook URL for this call (provider state not initialized)");
633
+ const actionUrl = new URL(webhookUrl);
634
+ if (input.turnToken) actionUrl.searchParams.set("turnToken", input.turnToken);
635
+ const twiml = `<?xml version="1.0" encoding="UTF-8"?>
636
+ <Response>
637
+ <Gather input="speech" speechTimeout="auto" language="${input.language || "en-US"}" action="${escapeXml(actionUrl.toString())}" method="POST">
638
+ </Gather>
639
+ </Response>`;
640
+ await this.updateLiveCallTwiml(input.providerCallId, twiml, "startListening");
641
+ }
642
+ /**
643
+ * Stop listening - for Twilio this is a no-op as <Gather> auto-ends.
644
+ */
645
+ async stopListening(_input) {}
646
+ async getCallStatus(input) {
647
+ try {
648
+ const data = await guardedJsonApiRequest({
649
+ url: `${this.baseUrl}/Calls/${input.providerCallId}.json`,
650
+ method: "GET",
651
+ headers: { Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString("base64")}` },
652
+ allowNotFound: true,
653
+ allowedHostnames: ["api.twilio.com"],
654
+ auditContext: "twilio-get-call-status",
655
+ errorPrefix: "Twilio get call status error"
656
+ });
657
+ if (!data) return {
658
+ status: "not-found",
659
+ isTerminal: true
660
+ };
661
+ const status = normalizeProviderStatus(data.status);
662
+ return {
663
+ status,
664
+ isTerminal: isProviderStatusTerminal(status)
665
+ };
666
+ } catch {
667
+ return {
668
+ status: "error",
669
+ isTerminal: false,
670
+ isUnknown: true
671
+ };
672
+ }
673
+ }
674
+ };
675
+ //#endregion
676
+ export { TwilioProvider };