@openclaw/voice-call 2026.9.8 → 2026.10.1-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 (41) hide show
  1. package/dist/.setup/{cli-D6uwMXyS.mjs → cli-Cn1leykM.mjs} +57 -37
  2. package/dist/.setup/{config-Ds7xoZbI.mjs → config-CX9J-053.mjs} +18 -119
  3. package/dist/.setup/{guarded-json-api-Bqc6QS8A.mjs → guarded-json-api-x55yqHvW.mjs} +39 -94
  4. package/dist/.setup/{mock-ClWW6Ufu.mjs → mock-eLv_Dc4o.mjs} +3 -6
  5. package/dist/.setup/{plivo-69ObrQPk.mjs → plivo-CpPIENu4.mjs} +48 -101
  6. package/dist/.setup/{realtime-handler-BgDPcPZt.mjs → realtime-handler-Do7Sz7Gh.mjs} +123 -235
  7. package/dist/.setup/{response-generator-BzKrJMEb.mjs → response-generator-DAhbXEta.mjs} +6 -25
  8. package/dist/.setup/{runtime-entry-BNOQRXii.mjs → runtime-entry-CbSKxSKk.mjs} +554 -425
  9. package/dist/.setup/state-retention-api-ONgqE-_V.mjs +201 -0
  10. package/dist/.setup/store-path-Cfwff6j7.mjs +33 -0
  11. package/dist/.setup/{telnyx-DjFGCfy1.mjs → telnyx-DPilutCH.mjs} +10 -29
  12. package/dist/.setup/{twilio-Bp3gfmUB.mjs → twilio-BWFNGyVx.mjs} +78 -201
  13. package/dist/.setup/{twilio-region-4fkgz3UG.mjs → twilio-region-wBOx-8sz.mjs} +1 -4
  14. package/dist/.setup/voice-mapping-B_iAUkER.mjs +31 -0
  15. package/dist/.setup/{webhook-replay-CCQSoBNF.mjs → webhook-replay-B7RJNWNG.mjs} +2 -5
  16. package/dist/doctor-contract-api.js +7 -330
  17. package/dist/index.js +18 -16
  18. package/dist/runtime-entry.js +1 -1
  19. package/dist/state-retention-api.js +2 -0
  20. package/openclaw.plugin.json +1 -1
  21. package/package.json +4 -6
  22. package/dist/.setup/utils-B5pnM_Ij.mjs +0 -437
  23. package/node_modules/ws/LICENSE +0 -20
  24. package/node_modules/ws/README.md +0 -548
  25. package/node_modules/ws/browser.js +0 -8
  26. package/node_modules/ws/index.js +0 -22
  27. package/node_modules/ws/lib/buffer-util.js +0 -131
  28. package/node_modules/ws/lib/constants.js +0 -19
  29. package/node_modules/ws/lib/event-target.js +0 -292
  30. package/node_modules/ws/lib/extension.js +0 -203
  31. package/node_modules/ws/lib/limiter.js +0 -55
  32. package/node_modules/ws/lib/permessage-deflate.js +0 -530
  33. package/node_modules/ws/lib/receiver.js +0 -743
  34. package/node_modules/ws/lib/sender.js +0 -607
  35. package/node_modules/ws/lib/stream.js +0 -161
  36. package/node_modules/ws/lib/subprotocol.js +0 -62
  37. package/node_modules/ws/lib/validation.js +0 -152
  38. package/node_modules/ws/lib/websocket-server.js +0 -562
  39. package/node_modules/ws/lib/websocket.js +0 -1407
  40. package/node_modules/ws/package.json +0 -74
  41. package/node_modules/ws/wrapper.mjs +0 -21
@@ -1,38 +1,23 @@
1
- import { fetchWithSsrFGuard } from "../api.js";
2
- import { n as requireSupportedTwilioApiHostname, r as resolveTwilioApiBaseUrl } from "./twilio-region-4fkgz3UG.mjs";
3
- import { a as normalizeProviderStatus, b as mapVoiceToPolly, h as chunkAudio, i as mapProviderStatusToEndReason, l as getHeader, r as isProviderStatusTerminal, y as escapeXml } from "./runtime-entry-BNOQRXii.mjs";
4
- import { a as readVoiceCallProviderJsonResponse, i as readProviderErrorResponseSnippet, l as verifyTwilioWebhook, n as readProviderCallStatus, r as cancelProviderResponseBody, t as guardedJsonApiRequest } from "./guarded-json-api-Bqc6QS8A.mjs";
5
- import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
1
+ import { n as requireSupportedTwilioApiHostname, r as resolveTwilioApiBaseUrl } from "./twilio-region-wBOx-8sz.mjs";
2
+ import { a as normalizeProviderStatus, g as generateDtmfRedirectTwiml, i as mapProviderStatusToEndReason, r as isProviderStatusTerminal, s as getHeader } from "./runtime-entry-CbSKxSKk.mjs";
3
+ import { n as mapVoiceToPolly, t as escapeXml } from "./voice-mapping-B_iAUkER.mjs";
4
+ import { n as readProviderCallStatus, o as verifyTwilioWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-x55yqHvW.mjs";
5
+ import { asNullableObjectRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
6
6
  import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
7
7
  import crypto from "node:crypto";
8
+ import { safeParseJson } from "openclaw/plugin-sdk/text-utility-runtime";
8
9
  import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
9
10
  import { retryAsync } from "openclaw/plugin-sdk/retry-runtime";
10
11
  //#region extensions/voice-call/src/providers/twilio/api.ts
11
- const TWILIO_API_TIMEOUT_MS = 3e4;
12
- /** Parse Twilio JSON error responses without trusting response shape. */
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
- /** Error thrown for non-2xx Twilio REST API responses. */
27
12
  var TwilioApiError = class extends Error {
28
13
  constructor(httpStatus, responseText) {
29
- const parsed = parseTwilioApiError(responseText);
30
- const detail = parsed.message ?? responseText;
14
+ const parsed = asNullableObjectRecord(safeParseJson(responseText));
15
+ const detail = typeof parsed?.message === "string" ? parsed.message : responseText;
31
16
  super(`Twilio API error: ${httpStatus} ${detail}`);
32
17
  this.name = "TwilioApiError";
33
18
  this.httpStatus = httpStatus;
34
19
  this.responseText = responseText;
35
- this.twilioCode = parsed.code;
20
+ this.twilioCode = typeof parsed?.code === "number" ? parsed.code : void 0;
36
21
  }
37
22
  };
38
23
  /** POST a form-encoded Twilio REST API request through the SSRF guard. */
@@ -42,95 +27,26 @@ async function twilioApiRequest(params) {
42
27
  else if (typeof value === "string") acc.append(key, value);
43
28
  return acc;
44
29
  }, new URLSearchParams());
45
- const requestUrl = `${params.baseUrl}${params.endpoint}`;
46
- const allowedHostname = requireSupportedTwilioApiHostname(params.baseUrl);
47
- const { response, release } = await fetchWithSsrFGuard({
48
- url: requestUrl,
49
- init: {
50
- method: "POST",
51
- headers: {
52
- Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
53
- "Content-Type": "application/x-www-form-urlencoded"
54
- },
55
- body: bodyParams
30
+ return guardedJsonApiRequest({
31
+ url: `${params.baseUrl}${params.endpoint}`,
32
+ method: "POST",
33
+ headers: {
34
+ Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
35
+ "Content-Type": "application/x-www-form-urlencoded"
56
36
  },
57
- policy: { allowedHostnames: [allowedHostname] },
58
- timeoutMs: TWILIO_API_TIMEOUT_MS,
59
- auditContext: "voice-call.twilio.api"
60
- });
61
- try {
62
- if (!response.ok) {
63
- if (params.allowNotFound && response.status === 404) {
64
- await cancelProviderResponseBody(response);
65
- return;
66
- }
67
- const errorText = await readProviderErrorResponseSnippet(response);
68
- throw new TwilioApiError(response.status, errorText);
69
- }
70
- return await readVoiceCallProviderJsonResponse(response, "Twilio API returned malformed JSON.");
71
- } finally {
72
- await release();
73
- }
74
- }
75
- //#endregion
76
- //#region extensions/voice-call/src/providers/twilio/twiml-policy.ts
77
- /** Read the Twilio request fields needed by TwiML decision logic. */
78
- function readTwimlRequestView(ctx) {
79
- const params = new URLSearchParams(ctx.rawBody);
80
- const type = normalizeOptionalString(ctx.query?.type);
81
- const callIdFromQuery = normalizeOptionalString(ctx.query?.callId);
82
- return {
83
- callStatus: params.get("CallStatus"),
84
- direction: params.get("Direction"),
85
- isStatusCallback: type === "status",
86
- callSid: params.get("CallSid") || void 0,
87
- callIdFromQuery
88
- };
89
- }
90
- /** Decide the TwiML response kind for a Twilio webhook request. */
91
- function decideTwimlResponse(input) {
92
- if (input.callIdFromQuery && !input.isStatusCallback) {
93
- if (input.hasStoredTwiml) return "stored";
94
- if (input.direction?.startsWith("outbound")) return input.canStream ? "stream" : "pause";
95
- }
96
- if (input.isStatusCallback) return "empty";
97
- if (input.direction === "inbound") {
98
- if (input.hasActiveStreams) return "queue";
99
- if (input.canStream && input.callSid) return "stream";
100
- return "pause";
101
- }
102
- if (input.callStatus !== "in-progress") return "empty";
103
- return input.canStream ? "stream" : "pause";
104
- }
105
- //#endregion
106
- //#region extensions/voice-call/src/providers/twilio/webhook.ts
107
- /** Verify a Twilio webhook and map SDK verification details to provider result fields. */
108
- function verifyTwilioProviderWebhook(params) {
109
- const result = verifyTwilioWebhook(params.ctx, params.authToken, {
110
- publicUrl: params.currentPublicUrl || void 0,
111
- allowNgrokFreeTierLoopbackBypass: params.options.allowNgrokFreeTierLoopbackBypass ?? false,
112
- skipVerification: params.options.skipVerification,
113
- allowedHosts: params.options.webhookSecurity?.allowedHosts,
114
- trustForwardingHeaders: params.options.webhookSecurity?.trustForwardingHeaders,
115
- trustedProxyIPs: params.options.webhookSecurity?.trustedProxyIPs,
116
- remoteIP: params.ctx.remoteAddress
37
+ body: bodyParams,
38
+ allowNotFound: params.allowNotFound,
39
+ allowedHostnames: [requireSupportedTwilioApiHostname(params.baseUrl)],
40
+ auditContext: "voice-call.twilio.api",
41
+ errorPrefix: "Twilio API error",
42
+ malformedJsonMessage: "Twilio API returned malformed JSON.",
43
+ createError: (status, text) => new TwilioApiError(status, text)
117
44
  });
118
- if (!result.ok) console.warn(`[twilio] Webhook verification failed: ${result.reason}`);
119
- return {
120
- ok: result.ok,
121
- reason: result.reason,
122
- isReplay: result.isReplay,
123
- verifiedRequestKey: result.verifiedRequestKey,
124
- releaseReplay: result.releaseReplay
125
- };
126
45
  }
127
46
  //#endregion
128
47
  //#region extensions/voice-call/src/providers/twilio.ts
129
48
  const TWILIO_CALL_NOT_IN_PROGRESS_CODE = 21220;
130
49
  const TWILIO_CALL_UPDATE_RETRY_DELAYS_MS = [250, 750];
131
- function isTwilioCallNotInProgressError(err) {
132
- return err instanceof TwilioApiError && err.twilioCode === TWILIO_CALL_NOT_IN_PROGRESS_CODE;
133
- }
134
50
  function createTwilioRequestDedupeKey(ctx, verifiedRequestKey) {
135
51
  if (verifiedRequestKey) return verifiedRequestKey;
136
52
  const signature = getHeader(ctx.headers, "x-twilio-signature") ?? "";
@@ -158,7 +74,6 @@ var TwilioProvider = class TwilioProvider {
158
74
  this.callWebhookUrls.delete(providerCallId);
159
75
  this.callStreamMap.delete(providerCallId);
160
76
  this.streamAuthTokens.delete(providerCallId);
161
- this.activeStreamCalls.delete(providerCallId);
162
77
  }
163
78
  constructor(config, options = {}) {
164
79
  this.name = "twilio";
@@ -170,7 +85,6 @@ var TwilioProvider = class TwilioProvider {
170
85
  this.callStreamMap = /* @__PURE__ */ new Map();
171
86
  this.streamAuthTokens = /* @__PURE__ */ new Map();
172
87
  this.twimlStorage = /* @__PURE__ */ new Map();
173
- this.activeStreamCalls = /* @__PURE__ */ new Set();
174
88
  if (!config.accountSid) throw new Error("Twilio Account SID is required");
175
89
  if (!config.authToken) throw new Error("Twilio Auth Token is required");
176
90
  this.accountSid = config.accountSid;
@@ -193,7 +107,6 @@ var TwilioProvider = class TwilioProvider {
193
107
  }
194
108
  registerCallStream(callSid, streamSid) {
195
109
  this.callStreamMap.set(callSid, streamSid);
196
- this.activeStreamCalls.add(callSid);
197
110
  }
198
111
  hasRegisteredStream(callSid, streamSid) {
199
112
  const current = this.callStreamMap.get(callSid);
@@ -201,13 +114,8 @@ var TwilioProvider = class TwilioProvider {
201
114
  }
202
115
  unregisterCallStream(callSid, streamSid) {
203
116
  const currentStreamSid = this.callStreamMap.get(callSid);
204
- if (!currentStreamSid) {
205
- if (!streamSid) this.activeStreamCalls.delete(callSid);
206
- return;
207
- }
208
117
  if (streamSid && currentStreamSid !== streamSid) return;
209
118
  this.callStreamMap.delete(callSid);
210
- this.activeStreamCalls.delete(callSid);
211
119
  }
212
120
  isConversationStreamConnectEnabled() {
213
121
  return Boolean(this.mediaStreamHandler && this.getStreamUrl());
@@ -217,10 +125,7 @@ var TwilioProvider = class TwilioProvider {
217
125
  if (!expected || !token) return false;
218
126
  return safeEqualSecret(expected, token);
219
127
  }
220
- /**
221
- * Clear TTS queue for a call (barge-in).
222
- * Used when user starts speaking to interrupt current TTS playback.
223
- */
128
+ /** Interrupt active playback and queued TTS on barge-in. */
224
129
  clearTtsQueue(callSid, reason = "unspecified") {
225
130
  const streamSid = this.callStreamMap.get(callSid);
226
131
  if (!streamSid || !this.mediaStreamHandler) return;
@@ -240,7 +145,7 @@ var TwilioProvider = class TwilioProvider {
240
145
  await retryAsync(() => this.apiRequest(`/Calls/${providerCallId}.json`, { Twiml: twiml }), {
241
146
  attempts: TWILIO_CALL_UPDATE_RETRY_DELAYS_MS.length + 1,
242
147
  minDelayMs: 0,
243
- shouldRetry: isTwilioCallNotInProgressError,
148
+ shouldRetry: (err) => err instanceof TwilioApiError && err.twilioCode === TWILIO_CALL_NOT_IN_PROGRESS_CODE,
244
149
  delayMs: ({ attempt }) => TWILIO_CALL_UPDATE_RETRY_DELAYS_MS[attempt - 1] ?? 0,
245
150
  onRetry: ({ delayMs }) => {
246
151
  console.warn(`[voice-call] Twilio ${operation} update hit call state race (21220); retrying in ${delayMs}ms`);
@@ -248,21 +153,24 @@ var TwilioProvider = class TwilioProvider {
248
153
  sleep: (delayMs) => sleepWithAbort(delayMs)
249
154
  });
250
155
  }
251
- /**
252
- * Verify Twilio webhook signature using HMAC-SHA1.
253
- *
254
- * Handles reverse proxy scenarios (Tailscale, nginx, ngrok) by reconstructing
255
- * the public URL from forwarding headers.
256
- *
257
- * @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
258
- */
259
156
  verifyWebhook(ctx) {
260
- return verifyTwilioProviderWebhook({
261
- ctx,
262
- authToken: this.authToken,
263
- currentPublicUrl: this.currentPublicUrl,
264
- options: this.options
157
+ const result = verifyTwilioWebhook(ctx, this.authToken, {
158
+ publicUrl: this.currentPublicUrl || void 0,
159
+ allowNgrokFreeTierLoopbackBypass: this.options.allowNgrokFreeTierLoopbackBypass ?? false,
160
+ skipVerification: this.options.skipVerification,
161
+ allowedHosts: this.options.webhookSecurity?.allowedHosts,
162
+ trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
163
+ trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
164
+ remoteIP: ctx.remoteAddress
265
165
  });
166
+ if (!result.ok) console.warn(`[twilio] Webhook verification failed: ${result.reason}`);
167
+ return {
168
+ ok: result.ok,
169
+ reason: result.reason,
170
+ isReplay: result.isReplay,
171
+ verifiedRequestKey: result.verifiedRequestKey,
172
+ releaseReplay: result.releaseReplay
173
+ };
266
174
  }
267
175
  parseWebhookEvent(ctx, options) {
268
176
  try {
@@ -276,10 +184,9 @@ var TwilioProvider = class TwilioProvider {
276
184
  turnToken: turnTokenFromQuery
277
185
  });
278
186
  if (event?.direction === "inbound" && event.type !== "call.ended" && event.providerCallId && this.currentPublicUrl && !this.callWebhookUrls.has(event.providerCallId)) this.callWebhookUrls.set(event.providerCallId, this.currentPublicUrl);
279
- const twiml = this.generateTwimlResponse(ctx);
280
187
  return {
281
188
  events: event ? [event] : [],
282
- providerResponseBody: twiml,
189
+ providerResponseBody: this.generateTwimlResponse(ctx),
283
190
  providerResponseHeaders: { "Content-Type": "application/xml" },
284
191
  statusCode: 200
285
192
  };
@@ -368,45 +275,40 @@ var TwilioProvider = class TwilioProvider {
368
275
  <Enqueue waitUrl="/voice/hold-music">hold-queue</Enqueue>
369
276
  </Response>`;
370
277
  }
371
- /**
372
- * Generate TwiML response for webhook.
373
- * When a call is answered, connects to media stream for bidirectional audio.
374
- */
375
278
  generateTwimlResponse(ctx) {
376
- if (!ctx) return TwilioProvider.EMPTY_TWIML;
377
- const view = readTwimlRequestView(ctx);
378
- const storedTwiml = view.callIdFromQuery ? this.twimlStorage.get(view.callIdFromQuery) : void 0;
379
- switch (decideTwimlResponse({
380
- ...view,
381
- hasStoredTwiml: Boolean(storedTwiml),
382
- hasActiveStreams: this.activeStreamCalls.size > 0,
383
- canStream: Boolean(view.callSid && this.getStreamUrl())
384
- })) {
385
- case "stored":
386
- if (view.callIdFromQuery) this.twimlStorage.delete(view.callIdFromQuery);
387
- return storedTwiml ?? TwilioProvider.EMPTY_TWIML;
388
- case "queue": return TwilioProvider.QUEUE_TWIML;
389
- case "pause": return TwilioProvider.PAUSE_TWIML;
390
- case "stream": {
391
- const streamUrl = view.callSid ? this.getStreamUrlForCall(view.callSid) : null;
392
- return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
393
- }
394
- default: return TwilioProvider.EMPTY_TWIML;
279
+ const params = new URLSearchParams(ctx.rawBody);
280
+ const isStatusCallback = normalizeOptionalString(ctx.query?.type) === "status";
281
+ const callId = normalizeOptionalString(ctx.query?.callId);
282
+ const callStatus = params.get("CallStatus");
283
+ const direction = params.get("Direction");
284
+ const callSid = params.get("CallSid") || void 0;
285
+ const storedTwiml = callId ? this.twimlStorage.get(callId) : void 0;
286
+ const streamUrl = callSid ? this.getStreamUrl() : null;
287
+ if (callId && !isStatusCallback && storedTwiml) {
288
+ this.twimlStorage.delete(callId);
289
+ return storedTwiml;
395
290
  }
291
+ if (isStatusCallback) return TwilioProvider.EMPTY_TWIML;
292
+ if (direction === "inbound") {
293
+ if (this.callStreamMap.size > 0) return TwilioProvider.QUEUE_TWIML;
294
+ } else if (!(callId && direction?.startsWith("outbound")) && callStatus !== "in-progress") return TwilioProvider.EMPTY_TWIML;
295
+ if (!streamUrl || !callSid) return TwilioProvider.PAUSE_TWIML;
296
+ const url = new URL(streamUrl);
297
+ url.searchParams.set("token", this.getStreamAuthToken(callSid));
298
+ return this.getStreamConnectXml(url.toString());
396
299
  }
397
300
  consumeInitialTwiML(ctx) {
398
- const view = readTwimlRequestView(ctx);
399
- if (!view.callIdFromQuery || view.isStatusCallback) return null;
400
- const storedTwiml = this.twimlStorage.get(view.callIdFromQuery);
301
+ const params = new URLSearchParams(ctx.rawBody);
302
+ const isStatusCallback = normalizeOptionalString(ctx.query?.type) === "status";
303
+ const callId = normalizeOptionalString(ctx.query?.callId);
304
+ const callSid = params.get("CallSid") || void 0;
305
+ if (!callId || isStatusCallback) return null;
306
+ const storedTwiml = this.twimlStorage.get(callId);
401
307
  if (!storedTwiml) return null;
402
- this.twimlStorage.delete(view.callIdFromQuery);
403
- console.log(`[voice-call] Twilio initial TwiML consumed for call ${view.callIdFromQuery} (kind=pre-connect, callSid=${view.callSid ?? "unknown"})`);
308
+ this.twimlStorage.delete(callId);
309
+ console.log(`[voice-call] Twilio initial TwiML consumed for call ${callId} (kind=pre-connect, callSid=${callSid ?? "unknown"})`);
404
310
  return storedTwiml;
405
311
  }
406
- /**
407
- * Get the WebSocket URL for media streaming.
408
- * Derives from the public URL origin + stream path.
409
- */
410
312
  getStreamUrl() {
411
313
  if (!this.currentPublicUrl || !this.options.streamPath) return null;
412
314
  return `${new URL(this.currentPublicUrl).origin.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://")}${this.options.streamPath.startsWith("/") ? this.options.streamPath : `/${this.options.streamPath}`}`;
@@ -418,20 +320,6 @@ var TwilioProvider = class TwilioProvider {
418
320
  this.streamAuthTokens.set(callSid, token);
419
321
  return token;
420
322
  }
421
- getStreamUrlForCall(callSid) {
422
- const baseUrl = this.getStreamUrl();
423
- if (!baseUrl) return null;
424
- const token = this.getStreamAuthToken(callSid);
425
- const url = new URL(baseUrl);
426
- url.searchParams.set("token", token);
427
- return url.toString();
428
- }
429
- /**
430
- * Generate TwiML to connect a call to a WebSocket media stream.
431
- * This enables bidirectional audio streaming for real-time STT/TTS.
432
- *
433
- * @param streamUrl - WebSocket URL (wss://...) for the media stream
434
- */
435
323
  getStreamConnectXml(streamUrl) {
436
324
  const parsed = new URL(streamUrl);
437
325
  const token = parsed.searchParams.get("token");
@@ -508,8 +396,7 @@ var TwilioProvider = class TwilioProvider {
508
396
  throw err instanceof Error ? err : new Error(String(err));
509
397
  }
510
398
  }
511
- const webhookUrl = this.callWebhookUrls.get(input.providerCallId);
512
- if (!webhookUrl) throw new Error("Missing webhook URL for this call (provider state not initialized)");
399
+ const webhookUrl = this.requireCallWebhookUrl(input.providerCallId);
513
400
  console.warn("[voice-call] Using TwiML <Say> fallback - telephony TTS not configured or media stream not active");
514
401
  const twiml = `<?xml version="1.0" encoding="UTF-8"?>
515
402
  <Response>
@@ -521,14 +408,7 @@ var TwilioProvider = class TwilioProvider {
521
408
  await this.updateLiveCallTwiml(input.providerCallId, twiml, "playTts");
522
409
  }
523
410
  async sendDtmf(input) {
524
- const webhookUrl = this.callWebhookUrls.get(input.providerCallId);
525
- if (!webhookUrl) throw new Error("Missing webhook URL for this call (provider state not initialized)");
526
- const twiml = `<?xml version="1.0" encoding="UTF-8"?>
527
- <Response>
528
- <Play digits="${escapeXml(input.digits)}" />
529
- <Redirect method="POST">${escapeXml(webhookUrl)}</Redirect>
530
- </Response>`;
531
- await this.updateLiveCallTwiml(input.providerCallId, twiml, "sendDtmf");
411
+ await this.updateLiveCallTwiml(input.providerCallId, generateDtmfRedirectTwiml(input.digits, this.requireCallWebhookUrl(input.providerCallId)), "sendDtmf");
532
412
  }
533
413
  /**
534
414
  * Play TTS via core TTS and Twilio Media Streams.
@@ -581,16 +461,14 @@ var TwilioProvider = class TwilioProvider {
581
461
  }
582
462
  if (muLawAudio.length === 0) throw new Error("Telephony TTS produced no audio");
583
463
  let chunkAttempts = 0;
584
- let chunkDelivered = 0;
585
464
  let nextChunkDueAt = Date.now() + CHUNK_DELAY_MS;
586
- for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
465
+ for (let offset = 0; offset < muLawAudio.length; offset += CHUNK_SIZE) {
587
466
  if (signal.aborted) break;
588
467
  chunkAttempts += 1;
589
- if (!handler.sendAudio(streamSid, chunk)) {
468
+ if (!handler.sendAudio(streamSid, muLawAudio.subarray(offset, offset + CHUNK_SIZE))) {
590
469
  handler.clearAudio(streamSid);
591
470
  throw new Error(`Telephony stream playback failed: audio chunk ${chunkAttempts} not delivered`);
592
471
  }
593
- chunkDelivered += 1;
594
472
  const waitMs = nextChunkDueAt - Date.now();
595
473
  if (waitMs > 0) try {
596
474
  await sleepWithAbort(Math.ceil(waitMs), signal);
@@ -602,15 +480,12 @@ var TwilioProvider = class TwilioProvider {
602
480
  if (signal.aborted) break;
603
481
  }
604
482
  if (signal.aborted) return;
605
- if (chunkAttempts === 0 || chunkDelivered !== chunkAttempts) throw new Error("Telephony stream playback failed: incomplete audio delivery");
606
483
  const markName = `tts-${Date.now()}-${++this.playbackMarkSequence}`;
607
484
  await handler.sendMarkAndWait(streamSid, markName, muLawAudio.length / 8, signal);
608
485
  });
609
486
  }
610
487
  async startListening(input) {
611
- const webhookUrl = this.callWebhookUrls.get(input.providerCallId);
612
- if (!webhookUrl) throw new Error("Missing webhook URL for this call (provider state not initialized)");
613
- const actionUrl = new URL(webhookUrl);
488
+ const actionUrl = new URL(this.requireCallWebhookUrl(input.providerCallId));
614
489
  if (input.turnToken) actionUrl.searchParams.set("turnToken", input.turnToken);
615
490
  const twiml = `<?xml version="1.0" encoding="UTF-8"?>
616
491
  <Response>
@@ -619,9 +494,11 @@ var TwilioProvider = class TwilioProvider {
619
494
  </Response>`;
620
495
  await this.updateLiveCallTwiml(input.providerCallId, twiml, "startListening");
621
496
  }
622
- /**
623
- * Stop listening - for Twilio this is a no-op as <Gather> auto-ends.
624
- */
497
+ requireCallWebhookUrl(providerCallId) {
498
+ const webhookUrl = this.callWebhookUrls.get(providerCallId);
499
+ if (!webhookUrl) throw new Error("Missing webhook URL for this call (provider state not initialized)");
500
+ return webhookUrl;
501
+ }
625
502
  async stopListening(_input) {}
626
503
  async getCallStatus(input) {
627
504
  return readProviderCallStatus(() => guardedJsonApiRequest({
@@ -10,11 +10,8 @@ const TWILIO_API_HOSTNAME_BY_REGION = {
10
10
  au1: "api.sydney.au1.twilio.com"
11
11
  };
12
12
  const TWILIO_API_HOSTNAMES = new Set(Object.values(TWILIO_API_HOSTNAME_BY_REGION));
13
- function resolveTwilioApiHostname(region) {
14
- return TWILIO_API_HOSTNAME_BY_REGION[region ?? "us1"];
15
- }
16
13
  function resolveTwilioApiBaseUrl(params) {
17
- return `https://${resolveTwilioApiHostname(params.region)}/2010-04-01/Accounts/${params.accountSid}`;
14
+ return `https://${TWILIO_API_HOSTNAME_BY_REGION[params.region ?? "us1"]}/2010-04-01/Accounts/${params.accountSid}`;
18
15
  }
19
16
  function requireSupportedTwilioApiHostname(baseUrl) {
20
17
  const hostname = new URL(baseUrl).hostname;
@@ -0,0 +1,31 @@
1
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
2
+ //#region extensions/voice-call/src/voice-mapping.ts
3
+ /**
4
+ * Escape XML special characters for TwiML and other XML responses.
5
+ */
6
+ function escapeXml(text) {
7
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
8
+ }
9
+ const OPENAI_TO_POLLY_MAP = /* @__PURE__ */ new Map([
10
+ ["alloy", "Polly.Joanna"],
11
+ ["echo", "Polly.Matthew"],
12
+ ["fable", "Polly.Amy"],
13
+ ["onyx", "Polly.Brian"],
14
+ ["nova", "Polly.Salli"],
15
+ ["shimmer", "Polly.Kimberly"]
16
+ ]);
17
+ const DEFAULT_POLLY_VOICE = "Polly.Joanna";
18
+ /**
19
+ * Map OpenAI voice names to Twilio Polly equivalents.
20
+ * Falls through if already a valid Polly/Google voice.
21
+ *
22
+ * @param voice - OpenAI voice name (alloy, echo, etc.) or Polly voice name
23
+ * @returns Polly voice name suitable for Twilio TwiML
24
+ */
25
+ function mapVoiceToPolly(voice) {
26
+ if (!voice) return DEFAULT_POLLY_VOICE;
27
+ if (voice.startsWith("Polly.") || voice.startsWith("Google.")) return voice;
28
+ return OPENAI_TO_POLLY_MAP.get(normalizeLowercaseStringOrEmpty(voice)) ?? DEFAULT_POLLY_VOICE;
29
+ }
30
+ //#endregion
31
+ export { mapVoiceToPolly as n, escapeXml as t };
@@ -1,4 +1,5 @@
1
1
  import { isFutureDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
2
+ import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
2
3
  //#region extensions/voice-call/src/webhook-replay.ts
3
4
  const REPLAY_WINDOW_MS = 6e5;
4
5
  const REPLAY_CACHE_MAX_ENTRIES = 1e4;
@@ -11,11 +12,7 @@ function createWebhookReplayCache() {
11
12
  }
12
13
  function pruneWebhookReplayCache(cache, now) {
13
14
  for (const [key, reservation] of cache.seenUntil) if (!isFutureDateTimestampMs(reservation.expiresAt, { nowMs: now })) cache.seenUntil.delete(key);
14
- while (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
15
- const oldest = cache.seenUntil.keys().next().value;
16
- if (!oldest) break;
17
- cache.seenUntil.delete(oldest);
18
- }
15
+ pruneMapToMaxSize(cache.seenUntil, REPLAY_CACHE_MAX_ENTRIES);
19
16
  }
20
17
  function reserveWebhookReplay(cache, replayKey) {
21
18
  const now = Date.now();