@absolutejs/voice 0.0.22-beta.663 → 0.0.22-beta.665
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/README.md +13 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +129 -123
- package/dist/telephony/plivo.d.ts +3 -4
- package/dist/telephony/telnyx.d.ts +3 -4
- package/dist/telephony/twilio.d.ts +11 -0
- package/dist/testing/index.js +119 -39
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1454,6 +1454,11 @@ export const app = new Elysia()
|
|
|
1454
1454
|
options: {
|
|
1455
1455
|
context: {},
|
|
1456
1456
|
outcomePolicy,
|
|
1457
|
+
security: {
|
|
1458
|
+
authToken: process.env.TWILIO_AUTH_TOKEN!,
|
|
1459
|
+
expectedAccountSid: process.env.TWILIO_ACCOUNT_SID!,
|
|
1460
|
+
publicOrigin: "https://voice.example.com",
|
|
1461
|
+
},
|
|
1457
1462
|
session: runtime.session,
|
|
1458
1463
|
stt: deepgram({ apiKey: process.env.DEEPGRAM_API_KEY! }),
|
|
1459
1464
|
streamPath: "/api/voice/twilio/stream",
|
|
@@ -4274,6 +4279,14 @@ await bridge.handleMessage(mediaMessageFromTwilio);
|
|
|
4274
4279
|
|
|
4275
4280
|
The bridge also sends Twilio `clear` events on new inbound media after assistant audio has started streaming, so telephony barge-in can stop queued outbound playback.
|
|
4276
4281
|
|
|
4282
|
+
When mounting `createTwilioVoiceRoutes(...)` or the Twilio carrier in
|
|
4283
|
+
`createVoicePhoneAgent(...)`, `security` is required. The package validates
|
|
4284
|
+
signed GET/POST TwiML requests against the fixed `publicOrigin`, validates the
|
|
4285
|
+
Media Streams WebSocket signature, and can bind both ingress paths to
|
|
4286
|
+
`expectedAccountSid`. It never derives the signed URL from forwarded host or
|
|
4287
|
+
protocol headers. Set `streamVerificationUrl` only when the exact WebSocket URL
|
|
4288
|
+
Twilio signs differs from `wss://<publicOrigin><streamPath>`.
|
|
4289
|
+
|
|
4277
4290
|
## Recommended Production Path
|
|
4278
4291
|
|
|
4279
4292
|
The current best-performing path is:
|
package/dist/index.d.ts
CHANGED
|
@@ -292,7 +292,7 @@ export { evaluateVoiceTelephonyContract } from "./telephony/contract";
|
|
|
292
292
|
export { createMemoryVoiceTelnyxWebhookEventStore, createTelnyxMediaStreamBridge, createTelnyxVoiceResponse, createTelnyxVoiceRoutes, createVoicePostgresTelnyxWebhookEventStore, createVoiceRedisTelnyxWebhookEventStore, createVoiceSQLiteTelnyxWebhookEventStore, createVoiceTelnyxWebhookVerifier, verifyVoiceTelnyxWebhookSignature, } from "./telephony/telnyx";
|
|
293
293
|
export { createMemoryVoicePlivoWebhookNonceStore, createPlivoMediaStreamBridge, createPlivoVoiceResponse, createPlivoVoiceRoutes, createVoicePostgresPlivoWebhookNonceStore, createVoicePlivoWebhookVerifier, createVoiceRedisPlivoWebhookNonceStore, createVoiceSQLitePlivoWebhookNonceStore, signVoicePlivoWebhook, verifyVoicePlivoWebhookSignature, } from "./telephony/plivo";
|
|
294
294
|
export { createVoiceTelephonyCarrierMatrix, createVoiceTelephonyCarrierMatrixRoutes, renderVoiceTelephonyCarrierMatrixHTML, } from "./telephony/matrix";
|
|
295
|
-
export type { TwilioInboundMessage, TwilioMediaStreamBridge, TwilioMediaStreamBridgeOptions, TwilioMediaStreamSocket, TwilioOutboundClearMessage, TwilioOutboundMarkMessage, TwilioOutboundMediaMessage, TwilioOutboundMessage, TwilioVoiceRouteParameters, TwilioVoiceResponseOptions, TwilioVoiceSmokeCheck, TwilioVoiceSmokeOptions, TwilioVoiceSmokeReport, TwilioVoiceSetupOptions, TwilioVoiceSetupStatus, TwilioVoiceRoutesOptions, } from "./telephony/twilio";
|
|
295
|
+
export type { TwilioInboundMessage, TwilioMediaStreamBridge, TwilioMediaStreamBridgeOptions, TwilioMediaStreamSocket, TwilioOutboundClearMessage, TwilioOutboundMarkMessage, TwilioOutboundMediaMessage, TwilioOutboundMessage, TwilioVoiceRouteParameters, TwilioVoiceResponseOptions, TwilioVoiceSmokeCheck, TwilioVoiceSmokeOptions, TwilioVoiceSmokeReport, TwilioVoiceSetupOptions, TwilioVoiceSetupStatus, TwilioVoiceRoutesOptions, TwilioVoiceSecurityOptions, } from "./telephony/twilio";
|
|
296
296
|
export type { VoiceTelephonyWebhookSecurityOptions, VoiceTelephonyWebhookSecurityPreset, VoiceTelephonyWebhookSecurityAssertionInput, VoiceTelephonyWebhookSecurityAssertionReport, VoiceTelephonyWebhookSecurityProviderStatus, VoiceTelephonyWebhookSecurityReport, VoiceTelephonyWebhookSecurityRoutesOptions, VoiceTelephonyWebhookSecurityStorePreset, } from "./telephony/security";
|
|
297
297
|
export type { VoiceTelephonyContractIssue, VoiceTelephonyContractOptions, VoiceTelephonyContractReport, VoiceTelephonyContractRequirement, VoiceTelephonyProvider, VoiceTelephonySetupStatus, VoiceTelephonySmokeCheck, VoiceTelephonySmokeReport, } from "./telephony/contract";
|
|
298
298
|
export type { TelnyxInboundMessage, TelnyxMediaPayload, TelnyxMediaStreamBridge, TelnyxMediaStreamBridgeOptions, TelnyxMediaStreamSocket, TelnyxOutboundClearMessage, TelnyxOutboundMarkMessage, TelnyxOutboundMediaMessage, TelnyxOutboundMessage, TelnyxVoiceResponseOptions, TelnyxVoiceRoutesOptions, TelnyxVoiceSetupOptions, TelnyxVoiceSetupStatus, TelnyxVoiceSmokeCheck, TelnyxVoiceSmokeOptions, TelnyxVoiceSmokeReport, VoicePostgresTelnyxWebhookEventStoreOptions, VoiceRedisTelnyxWebhookEventClient, VoiceRedisTelnyxWebhookEventStoreOptions, VoiceSQLiteTelnyxWebhookEventStoreOptions, VoiceTelnyxWebhookEventStore, VoiceTelnyxWebhookEventStoreOptions, VoiceTelnyxWebhookVerifierOptions, } from "./telephony/telnyx";
|
package/dist/index.js
CHANGED
|
@@ -25346,13 +25346,20 @@ import { Elysia as Elysia37 } from "elysia";
|
|
|
25346
25346
|
var TWILIO_MULAW_SAMPLE_RATE = 8000;
|
|
25347
25347
|
var VOICE_PCM_SAMPLE_RATE = 16000;
|
|
25348
25348
|
var escapeXml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
25349
|
-
var
|
|
25350
|
-
|
|
25351
|
-
|
|
25352
|
-
|
|
25353
|
-
|
|
25354
|
-
|
|
25355
|
-
|
|
25349
|
+
var TWILIO_ACCOUNT_SID = /^AC[0-9a-fA-F]{32}$/;
|
|
25350
|
+
var MAX_TWILIO_VOICE_BODY_BYTES = 64 * 1024;
|
|
25351
|
+
var resolveTwilioPublicOrigin = (security) => {
|
|
25352
|
+
if (security.authToken.trim().length === 0) {
|
|
25353
|
+
throw new Error("Twilio voice security.authToken must not be empty.");
|
|
25354
|
+
}
|
|
25355
|
+
if (security.expectedAccountSid !== undefined && !TWILIO_ACCOUNT_SID.test(security.expectedAccountSid)) {
|
|
25356
|
+
throw new Error("Twilio voice security.expectedAccountSid must be an AC SID.");
|
|
25357
|
+
}
|
|
25358
|
+
const origin = new URL(security.publicOrigin);
|
|
25359
|
+
if (origin.protocol !== "https:" || origin.pathname !== "/" || origin.search.length > 0 || origin.hash.length > 0) {
|
|
25360
|
+
throw new Error("Twilio voice security.publicOrigin must be an HTTPS origin without a path, query, or fragment.");
|
|
25361
|
+
}
|
|
25362
|
+
return origin.origin;
|
|
25356
25363
|
};
|
|
25357
25364
|
var resolveTwilioStreamUrl = async (options, input) => {
|
|
25358
25365
|
if (typeof options.twiml?.streamUrl === "function") {
|
|
@@ -25361,10 +25368,61 @@ var resolveTwilioStreamUrl = async (options, input) => {
|
|
|
25361
25368
|
if (typeof options.twiml?.streamUrl === "string") {
|
|
25362
25369
|
return options.twiml.streamUrl;
|
|
25363
25370
|
}
|
|
25364
|
-
const origin =
|
|
25371
|
+
const origin = resolveTwilioPublicOrigin(options.security);
|
|
25365
25372
|
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
25366
25373
|
return `${wsOrigin}${input.streamPath}`;
|
|
25367
25374
|
};
|
|
25375
|
+
var resolveTrustedTwilioRequestUrl = (security, request) => {
|
|
25376
|
+
const requestUrl = new URL(request.url);
|
|
25377
|
+
return `${resolveTwilioPublicOrigin(security)}${requestUrl.pathname}${requestUrl.search}`;
|
|
25378
|
+
};
|
|
25379
|
+
var resolveTwilioSocketHeaders = (socket) => {
|
|
25380
|
+
if (!socket.data || typeof socket.data !== "object")
|
|
25381
|
+
return new Headers;
|
|
25382
|
+
const data = socket.data;
|
|
25383
|
+
for (const candidate of [data.request?.headers, data.headers]) {
|
|
25384
|
+
if (!candidate)
|
|
25385
|
+
continue;
|
|
25386
|
+
if (typeof candidate === "object" && "get" in candidate && typeof candidate.get === "function") {
|
|
25387
|
+
return candidate;
|
|
25388
|
+
}
|
|
25389
|
+
try {
|
|
25390
|
+
return new Headers(candidate);
|
|
25391
|
+
} catch {}
|
|
25392
|
+
}
|
|
25393
|
+
return new Headers;
|
|
25394
|
+
};
|
|
25395
|
+
var readTwilioVoiceForm = async (request) => {
|
|
25396
|
+
const declaredLength = Number(request.headers.get("content-length"));
|
|
25397
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_TWILIO_VOICE_BODY_BYTES) {
|
|
25398
|
+
throw new Response("Twilio request body is too large.", { status: 413 });
|
|
25399
|
+
}
|
|
25400
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
25401
|
+
if (!contentType.includes("application/x-www-form-urlencoded")) {
|
|
25402
|
+
throw new Response("Expected a form-encoded Twilio request.", {
|
|
25403
|
+
status: 415
|
|
25404
|
+
});
|
|
25405
|
+
}
|
|
25406
|
+
const text = await request.text();
|
|
25407
|
+
if (Buffer3.byteLength(text, "utf8") > MAX_TWILIO_VOICE_BODY_BYTES) {
|
|
25408
|
+
throw new Response("Twilio request body is too large.", { status: 413 });
|
|
25409
|
+
}
|
|
25410
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
25411
|
+
};
|
|
25412
|
+
var verifyTwilioVoiceRequest = async (request, security) => {
|
|
25413
|
+
const body = request.method.toUpperCase() === "POST" ? await readTwilioVoiceForm(request.clone()) : {};
|
|
25414
|
+
const verification = await verifyVoiceTwilioWebhookSignature({
|
|
25415
|
+
authToken: security.authToken,
|
|
25416
|
+
body,
|
|
25417
|
+
headers: request.headers,
|
|
25418
|
+
url: resolveTrustedTwilioRequestUrl(security, request)
|
|
25419
|
+
});
|
|
25420
|
+
const requestUrl = new URL(request.url);
|
|
25421
|
+
const accountSid = typeof body.AccountSid === "string" ? body.AccountSid : requestUrl.searchParams.get("AccountSid");
|
|
25422
|
+
return {
|
|
25423
|
+
ok: verification.ok && (security.expectedAccountSid === undefined || accountSid === security.expectedAccountSid)
|
|
25424
|
+
};
|
|
25425
|
+
};
|
|
25368
25426
|
var resolveTwilioStreamParameters = async (parameters, input) => {
|
|
25369
25427
|
if (typeof parameters === "function") {
|
|
25370
25428
|
return parameters(input);
|
|
@@ -25382,7 +25440,7 @@ var getWebhookVerificationUrl = (webhook, input) => {
|
|
|
25382
25440
|
return webhook.verificationUrl;
|
|
25383
25441
|
};
|
|
25384
25442
|
var buildTwilioVoiceSetupStatus = async (options, input) => {
|
|
25385
|
-
const origin =
|
|
25443
|
+
const origin = resolveTwilioPublicOrigin(options.security);
|
|
25386
25444
|
const stream = await resolveTwilioStreamUrl(options, input);
|
|
25387
25445
|
const twiml = joinUrlPath(origin, input.twimlPath);
|
|
25388
25446
|
const webhook = joinUrlPath(origin, input.webhookPath);
|
|
@@ -25466,8 +25524,13 @@ var runTwilioVoiceSmokeTest = async (input) => {
|
|
|
25466
25524
|
const twimlUrl = new URL(setup.urls.twiml);
|
|
25467
25525
|
twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
|
|
25468
25526
|
twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
|
|
25527
|
+
const twimlHeaders = new Headers(input.request.headers);
|
|
25528
|
+
twimlHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
|
|
25529
|
+
authToken: input.options.security.authToken,
|
|
25530
|
+
url: twimlUrl.toString()
|
|
25531
|
+
}));
|
|
25469
25532
|
const twimlResponse = await input.app.handle(new Request(twimlUrl, {
|
|
25470
|
-
headers:
|
|
25533
|
+
headers: twimlHeaders
|
|
25471
25534
|
}));
|
|
25472
25535
|
const twiml = await twimlResponse.text();
|
|
25473
25536
|
const streamUrl = extractTwilioStreamUrl(twiml);
|
|
@@ -26056,45 +26119,32 @@ var createTwilioVoiceResponse = (options) => {
|
|
|
26056
26119
|
return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${escapeXml2(options.streamUrl)}"${options.track ? ` track="${escapeXml2(options.track)}"` : ""}${options.streamName ? ` name="${escapeXml2(options.streamName)}"` : ""}>${parameters}</Stream></Connect></Response>`;
|
|
26057
26120
|
};
|
|
26058
26121
|
var createTwilioVoiceRoutes = (options) => {
|
|
26122
|
+
resolveTwilioPublicOrigin(options.security);
|
|
26059
26123
|
const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
|
|
26060
26124
|
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
26061
26125
|
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
26062
26126
|
const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
|
|
26063
26127
|
const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
|
|
26064
26128
|
const bridges = new WeakMap;
|
|
26129
|
+
const admittedSockets = new WeakSet;
|
|
26130
|
+
const startedSockets = new WeakSet;
|
|
26065
26131
|
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
26066
|
-
const
|
|
26067
|
-
|
|
26068
|
-
|
|
26069
|
-
|
|
26070
|
-
|
|
26071
|
-
request,
|
|
26072
|
-
streamPath
|
|
26073
|
-
});
|
|
26074
|
-
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
26075
|
-
query,
|
|
26076
|
-
request
|
|
26077
|
-
});
|
|
26078
|
-
return new Response(createTwilioVoiceResponse({
|
|
26079
|
-
parameters,
|
|
26080
|
-
streamName: options.twiml?.streamName,
|
|
26081
|
-
streamUrl,
|
|
26082
|
-
track: options.twiml?.track
|
|
26083
|
-
}), {
|
|
26084
|
-
headers: {
|
|
26085
|
-
"content-type": "text/xml; charset=utf-8"
|
|
26132
|
+
const renderTwiml = async (input) => {
|
|
26133
|
+
try {
|
|
26134
|
+
const verification = await verifyTwilioVoiceRequest(input.request, options.security);
|
|
26135
|
+
if (!verification.ok) {
|
|
26136
|
+
return new Response("Invalid Twilio signature.", { status: 403 });
|
|
26086
26137
|
}
|
|
26087
|
-
})
|
|
26088
|
-
|
|
26138
|
+
} catch (error) {
|
|
26139
|
+
if (error instanceof Response)
|
|
26140
|
+
return error;
|
|
26141
|
+
throw error;
|
|
26142
|
+
}
|
|
26089
26143
|
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
26090
|
-
|
|
26091
|
-
request,
|
|
26144
|
+
...input,
|
|
26092
26145
|
streamPath
|
|
26093
26146
|
});
|
|
26094
|
-
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters,
|
|
26095
|
-
query,
|
|
26096
|
-
request
|
|
26097
|
-
});
|
|
26147
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, input);
|
|
26098
26148
|
return new Response(createTwilioVoiceResponse({
|
|
26099
26149
|
parameters,
|
|
26100
26150
|
streamName: options.twiml?.streamName,
|
|
@@ -26105,15 +26155,45 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
26105
26155
|
"content-type": "text/xml; charset=utf-8"
|
|
26106
26156
|
}
|
|
26107
26157
|
});
|
|
26108
|
-
}
|
|
26158
|
+
};
|
|
26159
|
+
const streamVerificationUrl = options.security.streamVerificationUrl ?? `${resolveTwilioPublicOrigin(options.security).replace(/^https:/, "wss:")}${streamPath}`;
|
|
26160
|
+
const app = new Elysia37({
|
|
26161
|
+
name: options.name ?? "absolutejs-voice-twilio"
|
|
26162
|
+
}).get(twimlPath, renderTwiml).post(twimlPath, renderTwiml, { parse: "none" }).ws(streamPath, {
|
|
26109
26163
|
close: async (ws, _code, reason) => {
|
|
26110
26164
|
const key = ws.raw ?? ws;
|
|
26111
26165
|
const bridge = bridges.get(key);
|
|
26112
26166
|
bridges.delete(key);
|
|
26167
|
+
admittedSockets.delete(key);
|
|
26168
|
+
startedSockets.delete(key);
|
|
26113
26169
|
await bridge?.close(reason);
|
|
26114
26170
|
},
|
|
26115
26171
|
message: async (ws, raw) => {
|
|
26116
26172
|
const key = ws.raw ?? ws;
|
|
26173
|
+
if (!admittedSockets.has(key)) {
|
|
26174
|
+
const verification = await verifyVoiceTwilioWebhookSignature({
|
|
26175
|
+
authToken: options.security.authToken,
|
|
26176
|
+
body: {},
|
|
26177
|
+
headers: resolveTwilioSocketHeaders(ws),
|
|
26178
|
+
url: streamVerificationUrl
|
|
26179
|
+
});
|
|
26180
|
+
if (!verification.ok) {
|
|
26181
|
+
ws.close(4403, "invalid Twilio signature");
|
|
26182
|
+
return;
|
|
26183
|
+
}
|
|
26184
|
+
admittedSockets.add(key);
|
|
26185
|
+
}
|
|
26186
|
+
const message = parseTwilioMessage(raw);
|
|
26187
|
+
if (message.event === "start") {
|
|
26188
|
+
if (startedSockets.has(key) || options.security.expectedAccountSid !== undefined && message.start.accountSid !== options.security.expectedAccountSid) {
|
|
26189
|
+
ws.close(4403, "invalid Twilio stream start");
|
|
26190
|
+
return;
|
|
26191
|
+
}
|
|
26192
|
+
startedSockets.add(key);
|
|
26193
|
+
} else if (message.event !== "connected" && !startedSockets.has(key)) {
|
|
26194
|
+
ws.close(4400, "Twilio stream must start before media");
|
|
26195
|
+
return;
|
|
26196
|
+
}
|
|
26117
26197
|
let bridge = bridges.get(key);
|
|
26118
26198
|
if (!bridge) {
|
|
26119
26199
|
bridge = createTwilioMediaStreamBridge({
|
|
@@ -26206,7 +26286,7 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
26206
26286
|
// src/telephony/plivo.ts
|
|
26207
26287
|
var escapeXml3 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
26208
26288
|
var joinUrlPath2 = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
26209
|
-
var
|
|
26289
|
+
var resolveRequestOrigin = (request) => {
|
|
26210
26290
|
const url = new URL(request.url);
|
|
26211
26291
|
const forwardedHost = request.headers.get("x-forwarded-host");
|
|
26212
26292
|
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
@@ -26235,7 +26315,7 @@ var resolvePlivoStreamUrl = async (options, input) => {
|
|
|
26235
26315
|
if (typeof options.answer?.streamUrl === "string") {
|
|
26236
26316
|
return options.answer.streamUrl;
|
|
26237
26317
|
}
|
|
26238
|
-
const origin =
|
|
26318
|
+
const origin = resolveRequestOrigin(input.request);
|
|
26239
26319
|
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
26240
26320
|
return `${wsOrigin}${input.streamPath}`;
|
|
26241
26321
|
};
|
|
@@ -26413,10 +26493,6 @@ var createMemoryVoicePlivoWebhookNonceStore = () => {
|
|
|
26413
26493
|
}
|
|
26414
26494
|
nonces.add(nonce);
|
|
26415
26495
|
return true;
|
|
26416
|
-
},
|
|
26417
|
-
has: (nonce) => nonces.has(nonce),
|
|
26418
|
-
set: (nonce) => {
|
|
26419
|
-
nonces.add(nonce);
|
|
26420
26496
|
}
|
|
26421
26497
|
};
|
|
26422
26498
|
};
|
|
@@ -26464,20 +26540,13 @@ var createVoiceSQLitePlivoWebhookNonceStore = (options) => {
|
|
|
26464
26540
|
expires_at INTEGER
|
|
26465
26541
|
)`);
|
|
26466
26542
|
const pruneExpired = database.query(`DELETE FROM "${tableName}" WHERE expires_at IS NOT NULL AND expires_at <= ?1`);
|
|
26467
|
-
const select = database.query(`SELECT nonce FROM "${tableName}" WHERE nonce = ?1 AND (expires_at IS NULL OR expires_at > ?2) LIMIT 1`);
|
|
26468
26543
|
const insert = database.query(`INSERT OR IGNORE INTO "${tableName}" (nonce, created_at, expires_at) VALUES (?1, ?2, ?3)`);
|
|
26469
|
-
const upsert = database.query(`INSERT INTO "${tableName}" (nonce, created_at, expires_at) VALUES (?1, ?2, ?3)
|
|
26470
|
-
ON CONFLICT(nonce) DO UPDATE SET expires_at = excluded.expires_at`);
|
|
26471
26544
|
return {
|
|
26472
26545
|
claim: (nonce) => {
|
|
26473
26546
|
const now = Date.now();
|
|
26474
26547
|
pruneExpired.run(now);
|
|
26475
26548
|
const result = insert.run(nonce, now, getPlivoNonceExpiresAt(options.ttlSeconds));
|
|
26476
26549
|
return result.changes > 0;
|
|
26477
|
-
},
|
|
26478
|
-
has: (nonce) => Boolean(select.get(nonce, Date.now())),
|
|
26479
|
-
set: (nonce) => {
|
|
26480
|
-
upsert.run(nonce, Date.now(), getPlivoNonceExpiresAt(options.ttlSeconds));
|
|
26481
26550
|
}
|
|
26482
26551
|
};
|
|
26483
26552
|
};
|
|
@@ -26531,21 +26600,6 @@ var createVoicePostgresPlivoWebhookNonceStore = (options = {}) => {
|
|
|
26531
26600
|
ON CONFLICT (nonce) DO NOTHING
|
|
26532
26601
|
RETURNING nonce`, [nonce, Date.now(), getPlivoNonceExpiresAt(options.ttlSeconds)]);
|
|
26533
26602
|
return rows.length > 0;
|
|
26534
|
-
},
|
|
26535
|
-
has: async (nonce) => {
|
|
26536
|
-
await initialized;
|
|
26537
|
-
const sql = await client;
|
|
26538
|
-
const rows = await sql.unsafe(`SELECT nonce FROM ${qualifiedTableName}
|
|
26539
|
-
WHERE nonce = $1 AND (expires_at IS NULL OR expires_at > $2)
|
|
26540
|
-
LIMIT 1`, [nonce, Date.now()]);
|
|
26541
|
-
return rows.length > 0;
|
|
26542
|
-
},
|
|
26543
|
-
set: async (nonce) => {
|
|
26544
|
-
await initialized;
|
|
26545
|
-
const sql = await client;
|
|
26546
|
-
await sql.unsafe(`INSERT INTO ${qualifiedTableName} (nonce, created_at, expires_at)
|
|
26547
|
-
VALUES ($1, $2, $3)
|
|
26548
|
-
ON CONFLICT (nonce) DO UPDATE SET expires_at = EXCLUDED.expires_at`, [nonce, Date.now(), getPlivoNonceExpiresAt(options.ttlSeconds)]);
|
|
26549
26603
|
}
|
|
26550
26604
|
};
|
|
26551
26605
|
};
|
|
@@ -26572,16 +26626,9 @@ var createVoicePlivoWebhookVerifier = (options) => async (input) => {
|
|
|
26572
26626
|
if (!nonce) {
|
|
26573
26627
|
return { ok: false, reason: "invalid-signature" };
|
|
26574
26628
|
}
|
|
26575
|
-
if (nonceStore.claim) {
|
|
26576
|
-
if (!await nonceStore.claim(nonce)) {
|
|
26577
|
-
return { ok: false, reason: "invalid-signature" };
|
|
26578
|
-
}
|
|
26579
|
-
return verification;
|
|
26580
|
-
}
|
|
26581
|
-
if (await nonceStore.has(nonce)) {
|
|
26629
|
+
if (!await nonceStore.claim(nonce)) {
|
|
26582
26630
|
return { ok: false, reason: "invalid-signature" };
|
|
26583
26631
|
}
|
|
26584
|
-
await nonceStore.set(nonce);
|
|
26585
26632
|
return verification;
|
|
26586
26633
|
};
|
|
26587
26634
|
var createVoiceRedisPlivoWebhookNonceStore = (options = {}) => {
|
|
@@ -26596,15 +26643,11 @@ var createVoiceRedisPlivoWebhookNonceStore = (options = {}) => {
|
|
|
26596
26643
|
return client.set(key, "1", ...nx ? ["NX"] : []);
|
|
26597
26644
|
};
|
|
26598
26645
|
return {
|
|
26599
|
-
claim: async (nonce) => await setNonce(nonce, true) === "OK"
|
|
26600
|
-
has: async (nonce) => Boolean(await client.exists(getPlivoRedisNonceKey(keyPrefix, nonce))),
|
|
26601
|
-
set: async (nonce) => {
|
|
26602
|
-
await setNonce(nonce, false);
|
|
26603
|
-
}
|
|
26646
|
+
claim: async (nonce) => await setNonce(nonce, true) === "OK"
|
|
26604
26647
|
};
|
|
26605
26648
|
};
|
|
26606
26649
|
var buildPlivoVoiceSetupStatus = async (options, input) => {
|
|
26607
|
-
const origin =
|
|
26650
|
+
const origin = resolveRequestOrigin(input.request);
|
|
26608
26651
|
const stream = await resolvePlivoStreamUrl(options, input);
|
|
26609
26652
|
const answer = joinUrlPath2(origin, input.answerPath);
|
|
26610
26653
|
const webhook = joinUrlPath2(origin, input.webhookPath);
|
|
@@ -26856,7 +26899,7 @@ import { Database as Database4 } from "bun:sqlite";
|
|
|
26856
26899
|
import { Elysia as Elysia39 } from "elysia";
|
|
26857
26900
|
var escapeXml4 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
26858
26901
|
var joinUrlPath3 = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
26859
|
-
var
|
|
26902
|
+
var resolveRequestOrigin2 = (request) => {
|
|
26860
26903
|
const url = new URL(request.url);
|
|
26861
26904
|
const forwardedHost = request.headers.get("x-forwarded-host");
|
|
26862
26905
|
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
@@ -26878,7 +26921,7 @@ var resolveTelnyxStreamUrl = async (options, input) => {
|
|
|
26878
26921
|
if (typeof options.texml?.streamUrl === "string") {
|
|
26879
26922
|
return options.texml.streamUrl;
|
|
26880
26923
|
}
|
|
26881
|
-
const origin =
|
|
26924
|
+
const origin = resolveRequestOrigin2(input.request);
|
|
26882
26925
|
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
26883
26926
|
return `${wsOrigin}${input.streamPath}`;
|
|
26884
26927
|
};
|
|
@@ -27012,10 +27055,6 @@ var createMemoryVoiceTelnyxWebhookEventStore = () => {
|
|
|
27012
27055
|
}
|
|
27013
27056
|
eventIds.add(eventId);
|
|
27014
27057
|
return true;
|
|
27015
|
-
},
|
|
27016
|
-
has: (eventId) => eventIds.has(eventId),
|
|
27017
|
-
set: (eventId) => {
|
|
27018
|
-
eventIds.add(eventId);
|
|
27019
27058
|
}
|
|
27020
27059
|
};
|
|
27021
27060
|
};
|
|
@@ -27065,20 +27104,13 @@ var createVoiceSQLiteTelnyxWebhookEventStore = (options) => {
|
|
|
27065
27104
|
expires_at INTEGER
|
|
27066
27105
|
)`);
|
|
27067
27106
|
const pruneExpired = database.query(`DELETE FROM "${tableName}" WHERE expires_at IS NOT NULL AND expires_at <= ?1`);
|
|
27068
|
-
const select = database.query(`SELECT event_id FROM "${tableName}" WHERE event_id = ?1 AND (expires_at IS NULL OR expires_at > ?2) LIMIT 1`);
|
|
27069
27107
|
const insert = database.query(`INSERT OR IGNORE INTO "${tableName}" (event_id, created_at, expires_at) VALUES (?1, ?2, ?3)`);
|
|
27070
|
-
const upsert = database.query(`INSERT INTO "${tableName}" (event_id, created_at, expires_at) VALUES (?1, ?2, ?3)
|
|
27071
|
-
ON CONFLICT(event_id) DO UPDATE SET expires_at = excluded.expires_at`);
|
|
27072
27108
|
return {
|
|
27073
27109
|
claim: (eventId) => {
|
|
27074
27110
|
const now = Date.now();
|
|
27075
27111
|
pruneExpired.run(now);
|
|
27076
27112
|
const result = insert.run(eventId, now, getTelnyxEventExpiresAt(options.ttlSeconds));
|
|
27077
27113
|
return result.changes > 0;
|
|
27078
|
-
},
|
|
27079
|
-
has: (eventId) => Boolean(select.get(eventId, Date.now())),
|
|
27080
|
-
set: (eventId) => {
|
|
27081
|
-
upsert.run(eventId, Date.now(), getTelnyxEventExpiresAt(options.ttlSeconds));
|
|
27082
27114
|
}
|
|
27083
27115
|
};
|
|
27084
27116
|
};
|
|
@@ -27132,21 +27164,6 @@ var createVoicePostgresTelnyxWebhookEventStore = (options = {}) => {
|
|
|
27132
27164
|
ON CONFLICT (event_id) DO NOTHING
|
|
27133
27165
|
RETURNING event_id`, [eventId, Date.now(), getTelnyxEventExpiresAt(options.ttlSeconds)]);
|
|
27134
27166
|
return rows.length > 0;
|
|
27135
|
-
},
|
|
27136
|
-
has: async (eventId) => {
|
|
27137
|
-
await initialized;
|
|
27138
|
-
const sql = await client;
|
|
27139
|
-
const rows = await sql.unsafe(`SELECT event_id FROM ${qualifiedTableName}
|
|
27140
|
-
WHERE event_id = $1 AND (expires_at IS NULL OR expires_at > $2)
|
|
27141
|
-
LIMIT 1`, [eventId, Date.now()]);
|
|
27142
|
-
return rows.length > 0;
|
|
27143
|
-
},
|
|
27144
|
-
set: async (eventId) => {
|
|
27145
|
-
await initialized;
|
|
27146
|
-
const sql = await client;
|
|
27147
|
-
await sql.unsafe(`INSERT INTO ${qualifiedTableName} (event_id, created_at, expires_at)
|
|
27148
|
-
VALUES ($1, $2, $3)
|
|
27149
|
-
ON CONFLICT (event_id) DO UPDATE SET expires_at = EXCLUDED.expires_at`, [eventId, Date.now(), getTelnyxEventExpiresAt(options.ttlSeconds)]);
|
|
27150
27167
|
}
|
|
27151
27168
|
};
|
|
27152
27169
|
};
|
|
@@ -27163,11 +27180,7 @@ var createVoiceRedisTelnyxWebhookEventStore = (options = {}) => {
|
|
|
27163
27180
|
return client.set(key, "1", ...nx ? ["NX"] : []);
|
|
27164
27181
|
};
|
|
27165
27182
|
return {
|
|
27166
|
-
claim: async (eventId) => await setEvent(eventId, true) === "OK"
|
|
27167
|
-
has: async (eventId) => Boolean(await client.exists(getTelnyxRedisEventKey(keyPrefix, eventId))),
|
|
27168
|
-
set: async (eventId) => {
|
|
27169
|
-
await setEvent(eventId, false);
|
|
27170
|
-
}
|
|
27183
|
+
claim: async (eventId) => await setEvent(eventId, true) === "OK"
|
|
27171
27184
|
};
|
|
27172
27185
|
};
|
|
27173
27186
|
var readTelnyxWebhookEventId = (rawBody) => {
|
|
@@ -27208,17 +27221,10 @@ var createVoiceTelnyxWebhookVerifier = (options) => async (input) => {
|
|
|
27208
27221
|
if (!eventId) {
|
|
27209
27222
|
return { ok: false, reason: "invalid-signature" };
|
|
27210
27223
|
}
|
|
27211
|
-
|
|
27212
|
-
return await eventStore.claim(eventId) ? verification : { ok: false, reason: "invalid-signature" };
|
|
27213
|
-
}
|
|
27214
|
-
if (await eventStore.has(eventId)) {
|
|
27215
|
-
return { ok: false, reason: "invalid-signature" };
|
|
27216
|
-
}
|
|
27217
|
-
await eventStore.set(eventId);
|
|
27218
|
-
return verification;
|
|
27224
|
+
return await eventStore.claim(eventId) ? verification : { ok: false, reason: "invalid-signature" };
|
|
27219
27225
|
};
|
|
27220
27226
|
var buildTelnyxVoiceSetupStatus = async (options, input) => {
|
|
27221
|
-
const origin =
|
|
27227
|
+
const origin = resolveRequestOrigin2(input.request);
|
|
27222
27228
|
const stream = await resolveTelnyxStreamUrl(options, input);
|
|
27223
27229
|
const texml = joinUrlPath3(origin, input.texmlPath);
|
|
27224
27230
|
const webhook = joinUrlPath3(origin, input.webhookPath);
|
|
@@ -135,9 +135,8 @@ export type PlivoVoiceSmokeOptions = {
|
|
|
135
135
|
title?: string;
|
|
136
136
|
};
|
|
137
137
|
export type VoicePlivoWebhookNonceStore = {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
set: (nonce: string) => Promise<void> | void;
|
|
138
|
+
/** Atomically accepts an unseen signature nonce and rejects every replay. */
|
|
139
|
+
claim: (nonce: string) => Promise<boolean> | boolean;
|
|
141
140
|
};
|
|
142
141
|
export type VoicePlivoWebhookNonceStoreOptions = {
|
|
143
142
|
ttlSeconds?: number;
|
|
@@ -155,7 +154,7 @@ export type VoicePostgresPlivoWebhookNonceStoreOptions = VoicePlivoWebhookNonceS
|
|
|
155
154
|
tableName?: string;
|
|
156
155
|
tablePrefix?: string;
|
|
157
156
|
};
|
|
158
|
-
export type VoiceRedisPlivoWebhookNonceClient = Pick<RedisClient, "
|
|
157
|
+
export type VoiceRedisPlivoWebhookNonceClient = Pick<RedisClient, "set">;
|
|
159
158
|
export type VoiceRedisPlivoWebhookNonceStoreOptions = VoicePlivoWebhookNonceStoreOptions & {
|
|
160
159
|
client?: VoiceRedisPlivoWebhookNonceClient;
|
|
161
160
|
keyPrefix?: string;
|
|
@@ -162,9 +162,8 @@ export type TelnyxVoiceRoutesOptions<TContext = unknown, TSession extends VoiceS
|
|
|
162
162
|
};
|
|
163
163
|
};
|
|
164
164
|
export type VoiceTelnyxWebhookEventStore = {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
set: (eventId: string) => Promise<void> | void;
|
|
165
|
+
/** Atomically accepts an unseen event id and rejects every duplicate. */
|
|
166
|
+
claim: (eventId: string) => Promise<boolean> | boolean;
|
|
168
167
|
};
|
|
169
168
|
export type VoiceTelnyxWebhookEventStoreOptions = {
|
|
170
169
|
ttlSeconds?: number;
|
|
@@ -182,7 +181,7 @@ export type VoicePostgresTelnyxWebhookEventStoreOptions = VoiceTelnyxWebhookEven
|
|
|
182
181
|
tableName?: string;
|
|
183
182
|
tablePrefix?: string;
|
|
184
183
|
};
|
|
185
|
-
export type VoiceRedisTelnyxWebhookEventClient = Pick<RedisClient, "
|
|
184
|
+
export type VoiceRedisTelnyxWebhookEventClient = Pick<RedisClient, "set">;
|
|
186
185
|
export type VoiceRedisTelnyxWebhookEventStoreOptions = VoiceTelnyxWebhookEventStoreOptions & {
|
|
187
186
|
client?: VoiceRedisTelnyxWebhookEventClient;
|
|
188
187
|
keyPrefix?: string;
|
|
@@ -225,6 +225,7 @@ export type TwilioVoiceSmokeOptions = {
|
|
|
225
225
|
export type TwilioVoiceRoutesOptions<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = TwilioMediaStreamBridgeOptions<TContext, TSession, TResult> & {
|
|
226
226
|
name?: string;
|
|
227
227
|
outcomePolicy?: VoiceTelephonyOutcomePolicy;
|
|
228
|
+
security: TwilioVoiceSecurityOptions;
|
|
228
229
|
smoke?: TwilioVoiceSmokeOptions;
|
|
229
230
|
setup?: TwilioVoiceSetupOptions;
|
|
230
231
|
streamPath?: string;
|
|
@@ -244,6 +245,16 @@ export type TwilioVoiceRoutesOptions<TContext = unknown, TSession extends VoiceS
|
|
|
244
245
|
policy?: VoiceTelephonyOutcomePolicy;
|
|
245
246
|
};
|
|
246
247
|
};
|
|
248
|
+
export type TwilioVoiceSecurityOptions = {
|
|
249
|
+
/** Twilio account Auth Token used only to validate inbound signatures. */
|
|
250
|
+
authToken: string;
|
|
251
|
+
/** Optional account binding for TwiML requests and Media Stream start events. */
|
|
252
|
+
expectedAccountSid?: string;
|
|
253
|
+
/** Fixed public HTTPS origin configured in Twilio. Proxy headers are never trusted. */
|
|
254
|
+
publicOrigin: string;
|
|
255
|
+
/** Exact WebSocket URL Twilio signs when it differs from the generated stream URL. */
|
|
256
|
+
streamVerificationUrl?: string;
|
|
257
|
+
};
|
|
247
258
|
export declare const decodeTwilioMulawBase64: (payload: string) => Int16Array<ArrayBuffer>;
|
|
248
259
|
export declare const encodeTwilioMulawBase64: (samples: Int16Array) => string;
|
|
249
260
|
export declare const transcodePCMToTwilioOutboundPayload: (chunk: Uint8Array, format: AudioFormat) => string;
|
package/dist/testing/index.js
CHANGED
|
@@ -15019,13 +15019,20 @@ var createVoiceTelephonyWebhookRoutes = (options = {}) => {
|
|
|
15019
15019
|
var TWILIO_MULAW_SAMPLE_RATE = 8000;
|
|
15020
15020
|
var VOICE_PCM_SAMPLE_RATE = 16000;
|
|
15021
15021
|
var escapeXml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
15022
|
-
var
|
|
15023
|
-
|
|
15024
|
-
|
|
15025
|
-
|
|
15026
|
-
|
|
15027
|
-
|
|
15028
|
-
|
|
15022
|
+
var TWILIO_ACCOUNT_SID = /^AC[0-9a-fA-F]{32}$/;
|
|
15023
|
+
var MAX_TWILIO_VOICE_BODY_BYTES = 64 * 1024;
|
|
15024
|
+
var resolveTwilioPublicOrigin = (security) => {
|
|
15025
|
+
if (security.authToken.trim().length === 0) {
|
|
15026
|
+
throw new Error("Twilio voice security.authToken must not be empty.");
|
|
15027
|
+
}
|
|
15028
|
+
if (security.expectedAccountSid !== undefined && !TWILIO_ACCOUNT_SID.test(security.expectedAccountSid)) {
|
|
15029
|
+
throw new Error("Twilio voice security.expectedAccountSid must be an AC SID.");
|
|
15030
|
+
}
|
|
15031
|
+
const origin = new URL(security.publicOrigin);
|
|
15032
|
+
if (origin.protocol !== "https:" || origin.pathname !== "/" || origin.search.length > 0 || origin.hash.length > 0) {
|
|
15033
|
+
throw new Error("Twilio voice security.publicOrigin must be an HTTPS origin without a path, query, or fragment.");
|
|
15034
|
+
}
|
|
15035
|
+
return origin.origin;
|
|
15029
15036
|
};
|
|
15030
15037
|
var resolveTwilioStreamUrl = async (options, input) => {
|
|
15031
15038
|
if (typeof options.twiml?.streamUrl === "function") {
|
|
@@ -15034,10 +15041,61 @@ var resolveTwilioStreamUrl = async (options, input) => {
|
|
|
15034
15041
|
if (typeof options.twiml?.streamUrl === "string") {
|
|
15035
15042
|
return options.twiml.streamUrl;
|
|
15036
15043
|
}
|
|
15037
|
-
const origin =
|
|
15044
|
+
const origin = resolveTwilioPublicOrigin(options.security);
|
|
15038
15045
|
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
15039
15046
|
return `${wsOrigin}${input.streamPath}`;
|
|
15040
15047
|
};
|
|
15048
|
+
var resolveTrustedTwilioRequestUrl = (security, request) => {
|
|
15049
|
+
const requestUrl = new URL(request.url);
|
|
15050
|
+
return `${resolveTwilioPublicOrigin(security)}${requestUrl.pathname}${requestUrl.search}`;
|
|
15051
|
+
};
|
|
15052
|
+
var resolveTwilioSocketHeaders = (socket) => {
|
|
15053
|
+
if (!socket.data || typeof socket.data !== "object")
|
|
15054
|
+
return new Headers;
|
|
15055
|
+
const data = socket.data;
|
|
15056
|
+
for (const candidate of [data.request?.headers, data.headers]) {
|
|
15057
|
+
if (!candidate)
|
|
15058
|
+
continue;
|
|
15059
|
+
if (typeof candidate === "object" && "get" in candidate && typeof candidate.get === "function") {
|
|
15060
|
+
return candidate;
|
|
15061
|
+
}
|
|
15062
|
+
try {
|
|
15063
|
+
return new Headers(candidate);
|
|
15064
|
+
} catch {}
|
|
15065
|
+
}
|
|
15066
|
+
return new Headers;
|
|
15067
|
+
};
|
|
15068
|
+
var readTwilioVoiceForm = async (request) => {
|
|
15069
|
+
const declaredLength = Number(request.headers.get("content-length"));
|
|
15070
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_TWILIO_VOICE_BODY_BYTES) {
|
|
15071
|
+
throw new Response("Twilio request body is too large.", { status: 413 });
|
|
15072
|
+
}
|
|
15073
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
15074
|
+
if (!contentType.includes("application/x-www-form-urlencoded")) {
|
|
15075
|
+
throw new Response("Expected a form-encoded Twilio request.", {
|
|
15076
|
+
status: 415
|
|
15077
|
+
});
|
|
15078
|
+
}
|
|
15079
|
+
const text = await request.text();
|
|
15080
|
+
if (Buffer3.byteLength(text, "utf8") > MAX_TWILIO_VOICE_BODY_BYTES) {
|
|
15081
|
+
throw new Response("Twilio request body is too large.", { status: 413 });
|
|
15082
|
+
}
|
|
15083
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
15084
|
+
};
|
|
15085
|
+
var verifyTwilioVoiceRequest = async (request, security) => {
|
|
15086
|
+
const body = request.method.toUpperCase() === "POST" ? await readTwilioVoiceForm(request.clone()) : {};
|
|
15087
|
+
const verification = await verifyVoiceTwilioWebhookSignature({
|
|
15088
|
+
authToken: security.authToken,
|
|
15089
|
+
body,
|
|
15090
|
+
headers: request.headers,
|
|
15091
|
+
url: resolveTrustedTwilioRequestUrl(security, request)
|
|
15092
|
+
});
|
|
15093
|
+
const requestUrl = new URL(request.url);
|
|
15094
|
+
const accountSid = typeof body.AccountSid === "string" ? body.AccountSid : requestUrl.searchParams.get("AccountSid");
|
|
15095
|
+
return {
|
|
15096
|
+
ok: verification.ok && (security.expectedAccountSid === undefined || accountSid === security.expectedAccountSid)
|
|
15097
|
+
};
|
|
15098
|
+
};
|
|
15041
15099
|
var resolveTwilioStreamParameters = async (parameters, input) => {
|
|
15042
15100
|
if (typeof parameters === "function") {
|
|
15043
15101
|
return parameters(input);
|
|
@@ -15055,7 +15113,7 @@ var getWebhookVerificationUrl = (webhook, input) => {
|
|
|
15055
15113
|
return webhook.verificationUrl;
|
|
15056
15114
|
};
|
|
15057
15115
|
var buildTwilioVoiceSetupStatus = async (options, input) => {
|
|
15058
|
-
const origin =
|
|
15116
|
+
const origin = resolveTwilioPublicOrigin(options.security);
|
|
15059
15117
|
const stream = await resolveTwilioStreamUrl(options, input);
|
|
15060
15118
|
const twiml = joinUrlPath(origin, input.twimlPath);
|
|
15061
15119
|
const webhook = joinUrlPath(origin, input.webhookPath);
|
|
@@ -15139,8 +15197,13 @@ var runTwilioVoiceSmokeTest = async (input) => {
|
|
|
15139
15197
|
const twimlUrl = new URL(setup.urls.twiml);
|
|
15140
15198
|
twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
|
|
15141
15199
|
twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
|
|
15200
|
+
const twimlHeaders = new Headers(input.request.headers);
|
|
15201
|
+
twimlHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
|
|
15202
|
+
authToken: input.options.security.authToken,
|
|
15203
|
+
url: twimlUrl.toString()
|
|
15204
|
+
}));
|
|
15142
15205
|
const twimlResponse = await input.app.handle(new Request(twimlUrl, {
|
|
15143
|
-
headers:
|
|
15206
|
+
headers: twimlHeaders
|
|
15144
15207
|
}));
|
|
15145
15208
|
const twiml = await twimlResponse.text();
|
|
15146
15209
|
const streamUrl = extractTwilioStreamUrl(twiml);
|
|
@@ -15729,45 +15792,32 @@ var createTwilioVoiceResponse = (options) => {
|
|
|
15729
15792
|
return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${escapeXml2(options.streamUrl)}"${options.track ? ` track="${escapeXml2(options.track)}"` : ""}${options.streamName ? ` name="${escapeXml2(options.streamName)}"` : ""}>${parameters}</Stream></Connect></Response>`;
|
|
15730
15793
|
};
|
|
15731
15794
|
var createTwilioVoiceRoutes = (options) => {
|
|
15795
|
+
resolveTwilioPublicOrigin(options.security);
|
|
15732
15796
|
const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
|
|
15733
15797
|
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
15734
15798
|
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
15735
15799
|
const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
|
|
15736
15800
|
const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
|
|
15737
15801
|
const bridges = new WeakMap;
|
|
15802
|
+
const admittedSockets = new WeakSet;
|
|
15803
|
+
const startedSockets = new WeakSet;
|
|
15738
15804
|
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
15739
|
-
const
|
|
15740
|
-
|
|
15741
|
-
|
|
15742
|
-
|
|
15743
|
-
|
|
15744
|
-
request,
|
|
15745
|
-
streamPath
|
|
15746
|
-
});
|
|
15747
|
-
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
15748
|
-
query,
|
|
15749
|
-
request
|
|
15750
|
-
});
|
|
15751
|
-
return new Response(createTwilioVoiceResponse({
|
|
15752
|
-
parameters,
|
|
15753
|
-
streamName: options.twiml?.streamName,
|
|
15754
|
-
streamUrl,
|
|
15755
|
-
track: options.twiml?.track
|
|
15756
|
-
}), {
|
|
15757
|
-
headers: {
|
|
15758
|
-
"content-type": "text/xml; charset=utf-8"
|
|
15805
|
+
const renderTwiml = async (input) => {
|
|
15806
|
+
try {
|
|
15807
|
+
const verification = await verifyTwilioVoiceRequest(input.request, options.security);
|
|
15808
|
+
if (!verification.ok) {
|
|
15809
|
+
return new Response("Invalid Twilio signature.", { status: 403 });
|
|
15759
15810
|
}
|
|
15760
|
-
})
|
|
15761
|
-
|
|
15811
|
+
} catch (error) {
|
|
15812
|
+
if (error instanceof Response)
|
|
15813
|
+
return error;
|
|
15814
|
+
throw error;
|
|
15815
|
+
}
|
|
15762
15816
|
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
15763
|
-
|
|
15764
|
-
request,
|
|
15817
|
+
...input,
|
|
15765
15818
|
streamPath
|
|
15766
15819
|
});
|
|
15767
|
-
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters,
|
|
15768
|
-
query,
|
|
15769
|
-
request
|
|
15770
|
-
});
|
|
15820
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, input);
|
|
15771
15821
|
return new Response(createTwilioVoiceResponse({
|
|
15772
15822
|
parameters,
|
|
15773
15823
|
streamName: options.twiml?.streamName,
|
|
@@ -15778,15 +15828,45 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
15778
15828
|
"content-type": "text/xml; charset=utf-8"
|
|
15779
15829
|
}
|
|
15780
15830
|
});
|
|
15781
|
-
}
|
|
15831
|
+
};
|
|
15832
|
+
const streamVerificationUrl = options.security.streamVerificationUrl ?? `${resolveTwilioPublicOrigin(options.security).replace(/^https:/, "wss:")}${streamPath}`;
|
|
15833
|
+
const app = new Elysia6({
|
|
15834
|
+
name: options.name ?? "absolutejs-voice-twilio"
|
|
15835
|
+
}).get(twimlPath, renderTwiml).post(twimlPath, renderTwiml, { parse: "none" }).ws(streamPath, {
|
|
15782
15836
|
close: async (ws, _code, reason) => {
|
|
15783
15837
|
const key = ws.raw ?? ws;
|
|
15784
15838
|
const bridge = bridges.get(key);
|
|
15785
15839
|
bridges.delete(key);
|
|
15840
|
+
admittedSockets.delete(key);
|
|
15841
|
+
startedSockets.delete(key);
|
|
15786
15842
|
await bridge?.close(reason);
|
|
15787
15843
|
},
|
|
15788
15844
|
message: async (ws, raw) => {
|
|
15789
15845
|
const key = ws.raw ?? ws;
|
|
15846
|
+
if (!admittedSockets.has(key)) {
|
|
15847
|
+
const verification = await verifyVoiceTwilioWebhookSignature({
|
|
15848
|
+
authToken: options.security.authToken,
|
|
15849
|
+
body: {},
|
|
15850
|
+
headers: resolveTwilioSocketHeaders(ws),
|
|
15851
|
+
url: streamVerificationUrl
|
|
15852
|
+
});
|
|
15853
|
+
if (!verification.ok) {
|
|
15854
|
+
ws.close(4403, "invalid Twilio signature");
|
|
15855
|
+
return;
|
|
15856
|
+
}
|
|
15857
|
+
admittedSockets.add(key);
|
|
15858
|
+
}
|
|
15859
|
+
const message = parseTwilioMessage(raw);
|
|
15860
|
+
if (message.event === "start") {
|
|
15861
|
+
if (startedSockets.has(key) || options.security.expectedAccountSid !== undefined && message.start.accountSid !== options.security.expectedAccountSid) {
|
|
15862
|
+
ws.close(4403, "invalid Twilio stream start");
|
|
15863
|
+
return;
|
|
15864
|
+
}
|
|
15865
|
+
startedSockets.add(key);
|
|
15866
|
+
} else if (message.event !== "connected" && !startedSockets.has(key)) {
|
|
15867
|
+
ws.close(4400, "Twilio stream must start before media");
|
|
15868
|
+
return;
|
|
15869
|
+
}
|
|
15790
15870
|
let bridge = bridges.get(key);
|
|
15791
15871
|
if (!bridge) {
|
|
15792
15872
|
bridge = createTwilioMediaStreamBridge({
|