@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.
- package/dist/.setup/{cli-D6uwMXyS.mjs → cli-Cn1leykM.mjs} +57 -37
- package/dist/.setup/{config-Ds7xoZbI.mjs → config-CX9J-053.mjs} +18 -119
- package/dist/.setup/{guarded-json-api-Bqc6QS8A.mjs → guarded-json-api-x55yqHvW.mjs} +39 -94
- package/dist/.setup/{mock-ClWW6Ufu.mjs → mock-eLv_Dc4o.mjs} +3 -6
- package/dist/.setup/{plivo-69ObrQPk.mjs → plivo-CpPIENu4.mjs} +48 -101
- package/dist/.setup/{realtime-handler-BgDPcPZt.mjs → realtime-handler-Do7Sz7Gh.mjs} +123 -235
- package/dist/.setup/{response-generator-BzKrJMEb.mjs → response-generator-DAhbXEta.mjs} +6 -25
- package/dist/.setup/{runtime-entry-BNOQRXii.mjs → runtime-entry-CbSKxSKk.mjs} +554 -425
- package/dist/.setup/state-retention-api-ONgqE-_V.mjs +201 -0
- package/dist/.setup/store-path-Cfwff6j7.mjs +33 -0
- package/dist/.setup/{telnyx-DjFGCfy1.mjs → telnyx-DPilutCH.mjs} +10 -29
- package/dist/.setup/{twilio-Bp3gfmUB.mjs → twilio-BWFNGyVx.mjs} +78 -201
- package/dist/.setup/{twilio-region-4fkgz3UG.mjs → twilio-region-wBOx-8sz.mjs} +1 -4
- package/dist/.setup/voice-mapping-B_iAUkER.mjs +31 -0
- package/dist/.setup/{webhook-replay-CCQSoBNF.mjs → webhook-replay-B7RJNWNG.mjs} +2 -5
- package/dist/doctor-contract-api.js +7 -330
- package/dist/index.js +18 -16
- package/dist/runtime-entry.js +1 -1
- package/dist/state-retention-api.js +2 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -6
- package/dist/.setup/utils-B5pnM_Ij.mjs +0 -437
- package/node_modules/ws/LICENSE +0 -20
- package/node_modules/ws/README.md +0 -548
- package/node_modules/ws/browser.js +0 -8
- package/node_modules/ws/index.js +0 -22
- package/node_modules/ws/lib/buffer-util.js +0 -131
- package/node_modules/ws/lib/constants.js +0 -19
- package/node_modules/ws/lib/event-target.js +0 -292
- package/node_modules/ws/lib/extension.js +0 -203
- package/node_modules/ws/lib/limiter.js +0 -55
- package/node_modules/ws/lib/permessage-deflate.js +0 -530
- package/node_modules/ws/lib/receiver.js +0 -743
- package/node_modules/ws/lib/sender.js +0 -607
- package/node_modules/ws/lib/stream.js +0 -161
- package/node_modules/ws/lib/subprotocol.js +0 -62
- package/node_modules/ws/lib/validation.js +0 -152
- package/node_modules/ws/lib/websocket-server.js +0 -562
- package/node_modules/ws/lib/websocket.js +0 -1407
- package/node_modules/ws/package.json +0 -74
- package/node_modules/ws/wrapper.mjs +0 -21
|
@@ -1,38 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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 =
|
|
30
|
-
const detail = parsed.message
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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:
|
|
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
|
-
|
|
377
|
-
const
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
399
|
-
|
|
400
|
-
const
|
|
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(
|
|
403
|
-
console.log(`[voice-call] Twilio initial TwiML consumed for call ${
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
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
|
-
|
|
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://${
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
|
|
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();
|