@openclaw/voice-call 2026.5.2 → 2026.5.3-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.
- package/dist/api.js +2 -0
- package/dist/call-status-CXldV5o8.js +32 -0
- package/dist/cli-metadata.js +12 -0
- package/dist/config-7w04YpHh.js +548 -0
- package/dist/config-compat-B0me39_4.js +129 -0
- package/dist/guarded-json-api-Btx5EE4w.js +591 -0
- package/dist/http-headers-BrnxBasF.js +10 -0
- package/dist/index.js +1284 -0
- package/dist/mock-CeKvfVEd.js +135 -0
- package/dist/plivo-B-a7KFoT.js +393 -0
- package/dist/realtime-handler-B63CIDP2.js +325 -0
- package/dist/realtime-transcription.runtime-B2h70y2W.js +2 -0
- package/dist/realtime-voice.runtime-Bkh4nvLn.js +2 -0
- package/dist/response-generator-BrcmwDZU.js +182 -0
- package/dist/response-model-CyF5K80p.js +12 -0
- package/dist/runtime-api.js +6 -0
- package/dist/runtime-entry-88ytYAQa.js +3119 -0
- package/dist/runtime-entry.js +2 -0
- package/dist/setup-api.js +37 -0
- package/dist/telnyx-jjBE8boz.js +260 -0
- package/dist/twilio-1OqbcXLL.js +676 -0
- package/dist/voice-mapping-BYDGdWGx.js +40 -0
- package/package.json +14 -6
- package/api.ts +0 -16
- package/cli-metadata.ts +0 -10
- package/config-api.ts +0 -12
- package/index.test.ts +0 -943
- package/index.ts +0 -794
- package/runtime-api.ts +0 -20
- package/runtime-entry.ts +0 -1
- package/setup-api.ts +0 -47
- package/src/allowlist.test.ts +0 -18
- package/src/allowlist.ts +0 -19
- package/src/cli.ts +0 -845
- package/src/config-compat.test.ts +0 -120
- package/src/config-compat.ts +0 -227
- package/src/config.test.ts +0 -479
- package/src/config.ts +0 -808
- package/src/core-bridge.ts +0 -14
- package/src/deep-merge.test.ts +0 -40
- package/src/deep-merge.ts +0 -23
- package/src/gateway-continue-operation.ts +0 -200
- package/src/http-headers.test.ts +0 -16
- package/src/http-headers.ts +0 -15
- package/src/manager/context.ts +0 -42
- package/src/manager/events.test.ts +0 -581
- package/src/manager/events.ts +0 -288
- package/src/manager/lifecycle.ts +0 -53
- package/src/manager/lookup.test.ts +0 -52
- package/src/manager/lookup.ts +0 -35
- package/src/manager/outbound.test.ts +0 -528
- package/src/manager/outbound.ts +0 -486
- package/src/manager/state.ts +0 -48
- package/src/manager/store.ts +0 -106
- package/src/manager/timers.test.ts +0 -129
- package/src/manager/timers.ts +0 -113
- package/src/manager/twiml.test.ts +0 -13
- package/src/manager/twiml.ts +0 -17
- package/src/manager.closed-loop.test.ts +0 -236
- package/src/manager.inbound-allowlist.test.ts +0 -188
- package/src/manager.notify.test.ts +0 -377
- package/src/manager.restore.test.ts +0 -183
- package/src/manager.test-harness.ts +0 -127
- package/src/manager.ts +0 -392
- package/src/media-stream.test.ts +0 -768
- package/src/media-stream.ts +0 -708
- package/src/providers/base.ts +0 -97
- package/src/providers/mock.test.ts +0 -78
- package/src/providers/mock.ts +0 -185
- package/src/providers/plivo.test.ts +0 -93
- package/src/providers/plivo.ts +0 -601
- package/src/providers/shared/call-status.test.ts +0 -24
- package/src/providers/shared/call-status.ts +0 -24
- package/src/providers/shared/guarded-json-api.test.ts +0 -106
- package/src/providers/shared/guarded-json-api.ts +0 -42
- package/src/providers/telnyx.test.ts +0 -340
- package/src/providers/telnyx.ts +0 -394
- package/src/providers/twilio/api.test.ts +0 -145
- package/src/providers/twilio/api.ts +0 -93
- package/src/providers/twilio/twiml-policy.test.ts +0 -84
- package/src/providers/twilio/twiml-policy.ts +0 -87
- package/src/providers/twilio/webhook.ts +0 -34
- package/src/providers/twilio.test.ts +0 -591
- package/src/providers/twilio.ts +0 -861
- package/src/providers/twilio.types.ts +0 -17
- package/src/realtime-defaults.ts +0 -3
- package/src/realtime-fast-context.test.ts +0 -88
- package/src/realtime-fast-context.ts +0 -165
- package/src/realtime-transcription.runtime.ts +0 -4
- package/src/realtime-voice.runtime.ts +0 -5
- package/src/response-generator.test.ts +0 -321
- package/src/response-generator.ts +0 -318
- package/src/response-model.test.ts +0 -71
- package/src/response-model.ts +0 -23
- package/src/runtime.test.ts +0 -536
- package/src/runtime.ts +0 -510
- package/src/telephony-audio.test.ts +0 -61
- package/src/telephony-audio.ts +0 -12
- package/src/telephony-tts.test.ts +0 -196
- package/src/telephony-tts.ts +0 -235
- package/src/test-fixtures.ts +0 -73
- package/src/tts-provider-voice.test.ts +0 -34
- package/src/tts-provider-voice.ts +0 -21
- package/src/tunnel.test.ts +0 -166
- package/src/tunnel.ts +0 -314
- package/src/types.ts +0 -291
- package/src/utils.test.ts +0 -17
- package/src/utils.ts +0 -14
- package/src/voice-mapping.test.ts +0 -34
- package/src/voice-mapping.ts +0 -68
- package/src/webhook/realtime-handler.test.ts +0 -598
- package/src/webhook/realtime-handler.ts +0 -485
- package/src/webhook/stale-call-reaper.test.ts +0 -88
- package/src/webhook/stale-call-reaper.ts +0 -38
- package/src/webhook/tailscale.test.ts +0 -214
- package/src/webhook/tailscale.ts +0 -129
- package/src/webhook-exposure.test.ts +0 -33
- package/src/webhook-exposure.ts +0 -84
- package/src/webhook-security.test.ts +0 -770
- package/src/webhook-security.ts +0 -994
- package/src/webhook.hangup-once.lifecycle.test.ts +0 -135
- package/src/webhook.test.ts +0 -1470
- package/src/webhook.ts +0 -908
- package/src/webhook.types.ts +0 -5
- package/src/websocket-test-support.ts +0 -72
- 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 };
|