@absolutejs/voice 0.0.22-beta.662 → 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/core/types.d.ts +7 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +138 -45
- package/dist/telephony/twilio.d.ts +11 -0
- package/dist/testing/index.js +120 -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/core/types.d.ts
CHANGED
|
@@ -905,6 +905,10 @@ export type VoicePluginConfig<TContext = unknown, TSession extends VoiceSessionR
|
|
|
905
905
|
}) => string | Promise<string>);
|
|
906
906
|
stuckCallClose?: {
|
|
907
907
|
afterMs: number;
|
|
908
|
+
/** Whether this fault-driven close invokes the route completion hook.
|
|
909
|
+
* Defaults to true for backward compatibility. Set false when an
|
|
910
|
+
* interrupted conversation must remain resumable. */
|
|
911
|
+
invokeOnComplete?: boolean;
|
|
908
912
|
line?: string | ((input: {
|
|
909
913
|
session: TSession;
|
|
910
914
|
}) => string | Promise<string>);
|
|
@@ -1091,6 +1095,9 @@ export type CreateVoiceSessionOptions<TContext = unknown, TSession extends Voice
|
|
|
1091
1095
|
* assistant's own speech, so STT recovery re-prompts can't defer it forever. */
|
|
1092
1096
|
stuckCallClose?: {
|
|
1093
1097
|
afterMs: number;
|
|
1098
|
+
/** Whether this fault-driven close invokes the route completion hook.
|
|
1099
|
+
* Defaults to true for backward compatibility. */
|
|
1100
|
+
invokeOnComplete?: boolean;
|
|
1094
1101
|
line?: string | ((input: {
|
|
1095
1102
|
session: TSession;
|
|
1096
1103
|
}) => string | Promise<string>);
|
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
|
@@ -4452,6 +4452,7 @@ var createVoiceSession = (options) => {
|
|
|
4452
4452
|
}
|
|
4453
4453
|
await completeInternal(undefined, {
|
|
4454
4454
|
disposition: "completed",
|
|
4455
|
+
invokeOnComplete: stuckCloseConfig?.invokeOnComplete,
|
|
4455
4456
|
reason: stuckCloseConfig?.reason ?? "stuck-call-close"
|
|
4456
4457
|
});
|
|
4457
4458
|
});
|
|
@@ -25345,13 +25346,20 @@ import { Elysia as Elysia37 } from "elysia";
|
|
|
25345
25346
|
var TWILIO_MULAW_SAMPLE_RATE = 8000;
|
|
25346
25347
|
var VOICE_PCM_SAMPLE_RATE = 16000;
|
|
25347
25348
|
var escapeXml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
25348
|
-
var
|
|
25349
|
-
|
|
25350
|
-
|
|
25351
|
-
|
|
25352
|
-
|
|
25353
|
-
|
|
25354
|
-
|
|
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;
|
|
25355
25363
|
};
|
|
25356
25364
|
var resolveTwilioStreamUrl = async (options, input) => {
|
|
25357
25365
|
if (typeof options.twiml?.streamUrl === "function") {
|
|
@@ -25360,10 +25368,61 @@ var resolveTwilioStreamUrl = async (options, input) => {
|
|
|
25360
25368
|
if (typeof options.twiml?.streamUrl === "string") {
|
|
25361
25369
|
return options.twiml.streamUrl;
|
|
25362
25370
|
}
|
|
25363
|
-
const origin =
|
|
25371
|
+
const origin = resolveTwilioPublicOrigin(options.security);
|
|
25364
25372
|
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
25365
25373
|
return `${wsOrigin}${input.streamPath}`;
|
|
25366
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
|
+
};
|
|
25367
25426
|
var resolveTwilioStreamParameters = async (parameters, input) => {
|
|
25368
25427
|
if (typeof parameters === "function") {
|
|
25369
25428
|
return parameters(input);
|
|
@@ -25381,7 +25440,7 @@ var getWebhookVerificationUrl = (webhook, input) => {
|
|
|
25381
25440
|
return webhook.verificationUrl;
|
|
25382
25441
|
};
|
|
25383
25442
|
var buildTwilioVoiceSetupStatus = async (options, input) => {
|
|
25384
|
-
const origin =
|
|
25443
|
+
const origin = resolveTwilioPublicOrigin(options.security);
|
|
25385
25444
|
const stream = await resolveTwilioStreamUrl(options, input);
|
|
25386
25445
|
const twiml = joinUrlPath(origin, input.twimlPath);
|
|
25387
25446
|
const webhook = joinUrlPath(origin, input.webhookPath);
|
|
@@ -25465,8 +25524,13 @@ var runTwilioVoiceSmokeTest = async (input) => {
|
|
|
25465
25524
|
const twimlUrl = new URL(setup.urls.twiml);
|
|
25466
25525
|
twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
|
|
25467
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
|
+
}));
|
|
25468
25532
|
const twimlResponse = await input.app.handle(new Request(twimlUrl, {
|
|
25469
|
-
headers:
|
|
25533
|
+
headers: twimlHeaders
|
|
25470
25534
|
}));
|
|
25471
25535
|
const twiml = await twimlResponse.text();
|
|
25472
25536
|
const streamUrl = extractTwilioStreamUrl(twiml);
|
|
@@ -26055,45 +26119,32 @@ var createTwilioVoiceResponse = (options) => {
|
|
|
26055
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>`;
|
|
26056
26120
|
};
|
|
26057
26121
|
var createTwilioVoiceRoutes = (options) => {
|
|
26122
|
+
resolveTwilioPublicOrigin(options.security);
|
|
26058
26123
|
const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
|
|
26059
26124
|
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
26060
26125
|
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
26061
26126
|
const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
|
|
26062
26127
|
const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
|
|
26063
26128
|
const bridges = new WeakMap;
|
|
26129
|
+
const admittedSockets = new WeakSet;
|
|
26130
|
+
const startedSockets = new WeakSet;
|
|
26064
26131
|
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
26065
|
-
const
|
|
26066
|
-
|
|
26067
|
-
|
|
26068
|
-
|
|
26069
|
-
|
|
26070
|
-
request,
|
|
26071
|
-
streamPath
|
|
26072
|
-
});
|
|
26073
|
-
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
26074
|
-
query,
|
|
26075
|
-
request
|
|
26076
|
-
});
|
|
26077
|
-
return new Response(createTwilioVoiceResponse({
|
|
26078
|
-
parameters,
|
|
26079
|
-
streamName: options.twiml?.streamName,
|
|
26080
|
-
streamUrl,
|
|
26081
|
-
track: options.twiml?.track
|
|
26082
|
-
}), {
|
|
26083
|
-
headers: {
|
|
26084
|
-
"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 });
|
|
26085
26137
|
}
|
|
26086
|
-
})
|
|
26087
|
-
|
|
26138
|
+
} catch (error) {
|
|
26139
|
+
if (error instanceof Response)
|
|
26140
|
+
return error;
|
|
26141
|
+
throw error;
|
|
26142
|
+
}
|
|
26088
26143
|
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
26089
|
-
|
|
26090
|
-
request,
|
|
26144
|
+
...input,
|
|
26091
26145
|
streamPath
|
|
26092
26146
|
});
|
|
26093
|
-
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters,
|
|
26094
|
-
query,
|
|
26095
|
-
request
|
|
26096
|
-
});
|
|
26147
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, input);
|
|
26097
26148
|
return new Response(createTwilioVoiceResponse({
|
|
26098
26149
|
parameters,
|
|
26099
26150
|
streamName: options.twiml?.streamName,
|
|
@@ -26104,15 +26155,45 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
26104
26155
|
"content-type": "text/xml; charset=utf-8"
|
|
26105
26156
|
}
|
|
26106
26157
|
});
|
|
26107
|
-
}
|
|
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, {
|
|
26108
26163
|
close: async (ws, _code, reason) => {
|
|
26109
26164
|
const key = ws.raw ?? ws;
|
|
26110
26165
|
const bridge = bridges.get(key);
|
|
26111
26166
|
bridges.delete(key);
|
|
26167
|
+
admittedSockets.delete(key);
|
|
26168
|
+
startedSockets.delete(key);
|
|
26112
26169
|
await bridge?.close(reason);
|
|
26113
26170
|
},
|
|
26114
26171
|
message: async (ws, raw) => {
|
|
26115
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
|
+
}
|
|
26116
26197
|
let bridge = bridges.get(key);
|
|
26117
26198
|
if (!bridge) {
|
|
26118
26199
|
bridge = createTwilioMediaStreamBridge({
|
|
@@ -26205,7 +26286,7 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
26205
26286
|
// src/telephony/plivo.ts
|
|
26206
26287
|
var escapeXml3 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
26207
26288
|
var joinUrlPath2 = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
26208
|
-
var
|
|
26289
|
+
var resolveRequestOrigin = (request) => {
|
|
26209
26290
|
const url = new URL(request.url);
|
|
26210
26291
|
const forwardedHost = request.headers.get("x-forwarded-host");
|
|
26211
26292
|
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
@@ -26234,7 +26315,7 @@ var resolvePlivoStreamUrl = async (options, input) => {
|
|
|
26234
26315
|
if (typeof options.answer?.streamUrl === "string") {
|
|
26235
26316
|
return options.answer.streamUrl;
|
|
26236
26317
|
}
|
|
26237
|
-
const origin =
|
|
26318
|
+
const origin = resolveRequestOrigin(input.request);
|
|
26238
26319
|
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
26239
26320
|
return `${wsOrigin}${input.streamPath}`;
|
|
26240
26321
|
};
|
|
@@ -26603,7 +26684,7 @@ var createVoiceRedisPlivoWebhookNonceStore = (options = {}) => {
|
|
|
26603
26684
|
};
|
|
26604
26685
|
};
|
|
26605
26686
|
var buildPlivoVoiceSetupStatus = async (options, input) => {
|
|
26606
|
-
const origin =
|
|
26687
|
+
const origin = resolveRequestOrigin(input.request);
|
|
26607
26688
|
const stream = await resolvePlivoStreamUrl(options, input);
|
|
26608
26689
|
const answer = joinUrlPath2(origin, input.answerPath);
|
|
26609
26690
|
const webhook = joinUrlPath2(origin, input.webhookPath);
|
|
@@ -26855,7 +26936,7 @@ import { Database as Database4 } from "bun:sqlite";
|
|
|
26855
26936
|
import { Elysia as Elysia39 } from "elysia";
|
|
26856
26937
|
var escapeXml4 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
26857
26938
|
var joinUrlPath3 = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
26858
|
-
var
|
|
26939
|
+
var resolveRequestOrigin2 = (request) => {
|
|
26859
26940
|
const url = new URL(request.url);
|
|
26860
26941
|
const forwardedHost = request.headers.get("x-forwarded-host");
|
|
26861
26942
|
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
@@ -26877,7 +26958,7 @@ var resolveTelnyxStreamUrl = async (options, input) => {
|
|
|
26877
26958
|
if (typeof options.texml?.streamUrl === "string") {
|
|
26878
26959
|
return options.texml.streamUrl;
|
|
26879
26960
|
}
|
|
26880
|
-
const origin =
|
|
26961
|
+
const origin = resolveRequestOrigin2(input.request);
|
|
26881
26962
|
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
26882
26963
|
return `${wsOrigin}${input.streamPath}`;
|
|
26883
26964
|
};
|
|
@@ -27217,7 +27298,7 @@ var createVoiceTelnyxWebhookVerifier = (options) => async (input) => {
|
|
|
27217
27298
|
return verification;
|
|
27218
27299
|
};
|
|
27219
27300
|
var buildTelnyxVoiceSetupStatus = async (options, input) => {
|
|
27220
|
-
const origin =
|
|
27301
|
+
const origin = resolveRequestOrigin2(input.request);
|
|
27221
27302
|
const stream = await resolveTelnyxStreamUrl(options, input);
|
|
27222
27303
|
const texml = joinUrlPath3(origin, input.texmlPath);
|
|
27223
27304
|
const webhook = joinUrlPath3(origin, input.webhookPath);
|
|
@@ -40713,6 +40794,18 @@ var voice = (config) => {
|
|
|
40713
40794
|
if (message.action === "resume") {
|
|
40714
40795
|
await current.resume();
|
|
40715
40796
|
}
|
|
40797
|
+
if (message.action === "pause" || message.action === "resume") {
|
|
40798
|
+
const snapshot = await current.snapshot();
|
|
40799
|
+
ws.send(JSON.stringify({
|
|
40800
|
+
pauseExpiresAt: snapshot.pause?.expiresAt,
|
|
40801
|
+
paused: Boolean(snapshot.pause),
|
|
40802
|
+
sessionId: snapshot.id,
|
|
40803
|
+
status: snapshot.status,
|
|
40804
|
+
sessionMetadata: snapshot.metadata,
|
|
40805
|
+
scenarioId: snapshot.scenarioId,
|
|
40806
|
+
type: "session"
|
|
40807
|
+
}));
|
|
40808
|
+
}
|
|
40716
40809
|
acknowledge(true);
|
|
40717
40810
|
} catch (error) {
|
|
40718
40811
|
acknowledge(false, error instanceof Error ? error.message : String(error));
|
|
@@ -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
|
@@ -7404,6 +7404,7 @@ var createVoiceSession = (options) => {
|
|
|
7404
7404
|
}
|
|
7405
7405
|
await completeInternal(undefined, {
|
|
7406
7406
|
disposition: "completed",
|
|
7407
|
+
invokeOnComplete: stuckCloseConfig?.invokeOnComplete,
|
|
7407
7408
|
reason: stuckCloseConfig?.reason ?? "stuck-call-close"
|
|
7408
7409
|
});
|
|
7409
7410
|
});
|
|
@@ -15018,13 +15019,20 @@ var createVoiceTelephonyWebhookRoutes = (options = {}) => {
|
|
|
15018
15019
|
var TWILIO_MULAW_SAMPLE_RATE = 8000;
|
|
15019
15020
|
var VOICE_PCM_SAMPLE_RATE = 16000;
|
|
15020
15021
|
var escapeXml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
15021
|
-
var
|
|
15022
|
-
|
|
15023
|
-
|
|
15024
|
-
|
|
15025
|
-
|
|
15026
|
-
|
|
15027
|
-
|
|
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;
|
|
15028
15036
|
};
|
|
15029
15037
|
var resolveTwilioStreamUrl = async (options, input) => {
|
|
15030
15038
|
if (typeof options.twiml?.streamUrl === "function") {
|
|
@@ -15033,10 +15041,61 @@ var resolveTwilioStreamUrl = async (options, input) => {
|
|
|
15033
15041
|
if (typeof options.twiml?.streamUrl === "string") {
|
|
15034
15042
|
return options.twiml.streamUrl;
|
|
15035
15043
|
}
|
|
15036
|
-
const origin =
|
|
15044
|
+
const origin = resolveTwilioPublicOrigin(options.security);
|
|
15037
15045
|
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
15038
15046
|
return `${wsOrigin}${input.streamPath}`;
|
|
15039
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
|
+
};
|
|
15040
15099
|
var resolveTwilioStreamParameters = async (parameters, input) => {
|
|
15041
15100
|
if (typeof parameters === "function") {
|
|
15042
15101
|
return parameters(input);
|
|
@@ -15054,7 +15113,7 @@ var getWebhookVerificationUrl = (webhook, input) => {
|
|
|
15054
15113
|
return webhook.verificationUrl;
|
|
15055
15114
|
};
|
|
15056
15115
|
var buildTwilioVoiceSetupStatus = async (options, input) => {
|
|
15057
|
-
const origin =
|
|
15116
|
+
const origin = resolveTwilioPublicOrigin(options.security);
|
|
15058
15117
|
const stream = await resolveTwilioStreamUrl(options, input);
|
|
15059
15118
|
const twiml = joinUrlPath(origin, input.twimlPath);
|
|
15060
15119
|
const webhook = joinUrlPath(origin, input.webhookPath);
|
|
@@ -15138,8 +15197,13 @@ var runTwilioVoiceSmokeTest = async (input) => {
|
|
|
15138
15197
|
const twimlUrl = new URL(setup.urls.twiml);
|
|
15139
15198
|
twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
|
|
15140
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
|
+
}));
|
|
15141
15205
|
const twimlResponse = await input.app.handle(new Request(twimlUrl, {
|
|
15142
|
-
headers:
|
|
15206
|
+
headers: twimlHeaders
|
|
15143
15207
|
}));
|
|
15144
15208
|
const twiml = await twimlResponse.text();
|
|
15145
15209
|
const streamUrl = extractTwilioStreamUrl(twiml);
|
|
@@ -15728,45 +15792,32 @@ var createTwilioVoiceResponse = (options) => {
|
|
|
15728
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>`;
|
|
15729
15793
|
};
|
|
15730
15794
|
var createTwilioVoiceRoutes = (options) => {
|
|
15795
|
+
resolveTwilioPublicOrigin(options.security);
|
|
15731
15796
|
const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
|
|
15732
15797
|
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
15733
15798
|
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
15734
15799
|
const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
|
|
15735
15800
|
const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
|
|
15736
15801
|
const bridges = new WeakMap;
|
|
15802
|
+
const admittedSockets = new WeakSet;
|
|
15803
|
+
const startedSockets = new WeakSet;
|
|
15737
15804
|
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
15738
|
-
const
|
|
15739
|
-
|
|
15740
|
-
|
|
15741
|
-
|
|
15742
|
-
|
|
15743
|
-
request,
|
|
15744
|
-
streamPath
|
|
15745
|
-
});
|
|
15746
|
-
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
15747
|
-
query,
|
|
15748
|
-
request
|
|
15749
|
-
});
|
|
15750
|
-
return new Response(createTwilioVoiceResponse({
|
|
15751
|
-
parameters,
|
|
15752
|
-
streamName: options.twiml?.streamName,
|
|
15753
|
-
streamUrl,
|
|
15754
|
-
track: options.twiml?.track
|
|
15755
|
-
}), {
|
|
15756
|
-
headers: {
|
|
15757
|
-
"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 });
|
|
15758
15810
|
}
|
|
15759
|
-
})
|
|
15760
|
-
|
|
15811
|
+
} catch (error) {
|
|
15812
|
+
if (error instanceof Response)
|
|
15813
|
+
return error;
|
|
15814
|
+
throw error;
|
|
15815
|
+
}
|
|
15761
15816
|
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
15762
|
-
|
|
15763
|
-
request,
|
|
15817
|
+
...input,
|
|
15764
15818
|
streamPath
|
|
15765
15819
|
});
|
|
15766
|
-
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters,
|
|
15767
|
-
query,
|
|
15768
|
-
request
|
|
15769
|
-
});
|
|
15820
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, input);
|
|
15770
15821
|
return new Response(createTwilioVoiceResponse({
|
|
15771
15822
|
parameters,
|
|
15772
15823
|
streamName: options.twiml?.streamName,
|
|
@@ -15777,15 +15828,45 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
15777
15828
|
"content-type": "text/xml; charset=utf-8"
|
|
15778
15829
|
}
|
|
15779
15830
|
});
|
|
15780
|
-
}
|
|
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, {
|
|
15781
15836
|
close: async (ws, _code, reason) => {
|
|
15782
15837
|
const key = ws.raw ?? ws;
|
|
15783
15838
|
const bridge = bridges.get(key);
|
|
15784
15839
|
bridges.delete(key);
|
|
15840
|
+
admittedSockets.delete(key);
|
|
15841
|
+
startedSockets.delete(key);
|
|
15785
15842
|
await bridge?.close(reason);
|
|
15786
15843
|
},
|
|
15787
15844
|
message: async (ws, raw) => {
|
|
15788
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
|
+
}
|
|
15789
15870
|
let bridge = bridges.get(key);
|
|
15790
15871
|
if (!bridge) {
|
|
15791
15872
|
bridge = createTwilioMediaStreamBridge({
|