@absolutejs/voice 0.0.22-beta.663 → 0.0.22-beta.664
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 +125 -45
- 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
|
};
|
|
@@ -26604,7 +26684,7 @@ var createVoiceRedisPlivoWebhookNonceStore = (options = {}) => {
|
|
|
26604
26684
|
};
|
|
26605
26685
|
};
|
|
26606
26686
|
var buildPlivoVoiceSetupStatus = async (options, input) => {
|
|
26607
|
-
const origin =
|
|
26687
|
+
const origin = resolveRequestOrigin(input.request);
|
|
26608
26688
|
const stream = await resolvePlivoStreamUrl(options, input);
|
|
26609
26689
|
const answer = joinUrlPath2(origin, input.answerPath);
|
|
26610
26690
|
const webhook = joinUrlPath2(origin, input.webhookPath);
|
|
@@ -26856,7 +26936,7 @@ import { Database as Database4 } from "bun:sqlite";
|
|
|
26856
26936
|
import { Elysia as Elysia39 } from "elysia";
|
|
26857
26937
|
var escapeXml4 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
26858
26938
|
var joinUrlPath3 = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
26859
|
-
var
|
|
26939
|
+
var resolveRequestOrigin2 = (request) => {
|
|
26860
26940
|
const url = new URL(request.url);
|
|
26861
26941
|
const forwardedHost = request.headers.get("x-forwarded-host");
|
|
26862
26942
|
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
@@ -26878,7 +26958,7 @@ var resolveTelnyxStreamUrl = async (options, input) => {
|
|
|
26878
26958
|
if (typeof options.texml?.streamUrl === "string") {
|
|
26879
26959
|
return options.texml.streamUrl;
|
|
26880
26960
|
}
|
|
26881
|
-
const origin =
|
|
26961
|
+
const origin = resolveRequestOrigin2(input.request);
|
|
26882
26962
|
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
26883
26963
|
return `${wsOrigin}${input.streamPath}`;
|
|
26884
26964
|
};
|
|
@@ -27218,7 +27298,7 @@ var createVoiceTelnyxWebhookVerifier = (options) => async (input) => {
|
|
|
27218
27298
|
return verification;
|
|
27219
27299
|
};
|
|
27220
27300
|
var buildTelnyxVoiceSetupStatus = async (options, input) => {
|
|
27221
|
-
const origin =
|
|
27301
|
+
const origin = resolveRequestOrigin2(input.request);
|
|
27222
27302
|
const stream = await resolveTelnyxStreamUrl(options, input);
|
|
27223
27303
|
const texml = joinUrlPath3(origin, input.texmlPath);
|
|
27224
27304
|
const webhook = joinUrlPath3(origin, input.webhookPath);
|
|
@@ -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({
|