@absolutejs/voice 0.0.22-beta.74 → 0.0.22-beta.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/opsStatusWidget.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +226 -1
- package/dist/telephony/twilio.d.ts +54 -0
- package/dist/testing/index.js +226 -1
- package/package.json +1 -1
|
@@ -29,7 +29,7 @@ export type VoiceOpsStatusWidgetOptions = VoiceAppKitStatusClientOptions & {
|
|
|
29
29
|
includeLinks?: boolean;
|
|
30
30
|
title?: string;
|
|
31
31
|
};
|
|
32
|
-
export declare const getVoiceOpsStatusLabel: (report?: VoiceAppKitStatusReport | null, error?: string | null) => "Passing" | "
|
|
32
|
+
export declare const getVoiceOpsStatusLabel: (report?: VoiceAppKitStatusReport | null, error?: string | null) => "Passing" | "Needs attention" | "Unavailable" | "Checking";
|
|
33
33
|
export declare const createVoiceOpsStatusViewModel: (snapshot: VoiceAppKitStatusSnapshot, options?: VoiceOpsStatusWidgetOptions) => VoiceOpsStatusViewModel;
|
|
34
34
|
export declare const renderVoiceOpsStatusHTML: (snapshot: VoiceAppKitStatusSnapshot, options?: VoiceOpsStatusWidgetOptions) => string;
|
|
35
35
|
export declare const getVoiceOpsStatusCSS: () => string;
|
package/dist/index.d.ts
CHANGED
|
@@ -81,7 +81,7 @@ export type { VoiceS3ReviewStoreClient, VoiceS3ReviewStoreFile, VoiceS3ReviewSto
|
|
|
81
81
|
export type { VoiceSQLiteRuntimeStorage, VoiceSQLiteStoreOptions } from './sqliteStore';
|
|
82
82
|
export type { StoredVoiceIntegrationEvent, StoredVoiceExternalObjectMap, StoredVoiceOpsTask, VoiceExternalObjectMap, VoiceExternalObjectMapStore, VoiceOpsTaskAgeBucket, VoiceOpsTaskAnalyticsOptions, VoiceOpsTaskAnalyticsSummary, VoiceOpsTaskAssignmentRule, VoiceOpsTaskAssignmentRuleCondition, VoiceOpsTaskAssignmentRules, VoiceOpsTaskAssigneeAnalytics, VoiceOpsDispositionTaskPolicies, VoiceOpsSLABreachPolicy, VoiceIntegrationDeliveryStatus, VoiceIntegrationEvent, VoiceIntegrationEventStore, VoiceIntegrationSinkDelivery, VoiceIntegrationEventType, VoiceIntegrationWebhookConfig, VoiceOpsTask, VoiceOpsTaskHistoryEntry, VoiceOpsTaskKind, VoiceOpsTaskPolicy, VoiceOpsTaskPriority, VoiceOpsTaskStatus, VoiceOpsTaskStore, VoiceOpsTaskSummary, VoiceOpsTaskWorkerAnalytics } from './ops';
|
|
83
83
|
export { createTwilioMediaStreamBridge, createTwilioVoiceRoutes, createTwilioVoiceResponse, decodeTwilioMulawBase64, encodeTwilioMulawBase64, transcodePCMToTwilioOutboundPayload, transcodeTwilioInboundPayloadToPCM16 } from './telephony/twilio';
|
|
84
|
-
export type { TwilioInboundMessage, TwilioMediaStreamBridge, TwilioMediaStreamBridgeOptions, TwilioMediaStreamSocket, TwilioOutboundClearMessage, TwilioOutboundMarkMessage, TwilioOutboundMediaMessage, TwilioOutboundMessage, TwilioVoiceRouteParameters, TwilioVoiceResponseOptions, TwilioVoiceRoutesOptions } from './telephony/twilio';
|
|
84
|
+
export type { TwilioInboundMessage, TwilioMediaStreamBridge, TwilioMediaStreamBridgeOptions, TwilioMediaStreamSocket, TwilioOutboundClearMessage, TwilioOutboundMarkMessage, TwilioOutboundMediaMessage, TwilioOutboundMessage, TwilioVoiceRouteParameters, TwilioVoiceResponseOptions, TwilioVoiceSmokeCheck, TwilioVoiceSmokeOptions, TwilioVoiceSmokeReport, TwilioVoiceSetupOptions, TwilioVoiceSetupStatus, TwilioVoiceRoutesOptions } from './telephony/twilio';
|
|
85
85
|
export { shapeTelephonyAssistantText } from './telephony/response';
|
|
86
86
|
export type { TelephonyResponseShapeMode, TelephonyResponseShapeOptions } from './telephony/response';
|
|
87
87
|
export * from './types';
|
package/dist/index.js
CHANGED
|
@@ -15226,6 +15226,166 @@ var resolveTwilioStreamParameters = async (parameters, input) => {
|
|
|
15226
15226
|
}
|
|
15227
15227
|
return parameters;
|
|
15228
15228
|
};
|
|
15229
|
+
var joinUrlPath = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
15230
|
+
var escapeHtml17 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
15231
|
+
var getWebhookVerificationUrl = (webhook, input) => {
|
|
15232
|
+
if (!webhook?.verificationUrl) {
|
|
15233
|
+
return;
|
|
15234
|
+
}
|
|
15235
|
+
if (typeof webhook.verificationUrl === "function") {
|
|
15236
|
+
return webhook.verificationUrl(input);
|
|
15237
|
+
}
|
|
15238
|
+
return webhook.verificationUrl;
|
|
15239
|
+
};
|
|
15240
|
+
var buildTwilioVoiceSetupStatus = async (options, input) => {
|
|
15241
|
+
const origin = resolveRequestOrigin(input.request);
|
|
15242
|
+
const stream = await resolveTwilioStreamUrl(options, input);
|
|
15243
|
+
const twiml = joinUrlPath(origin, input.twimlPath);
|
|
15244
|
+
const webhook = joinUrlPath(origin, input.webhookPath);
|
|
15245
|
+
const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
|
|
15246
|
+
const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
|
|
15247
|
+
const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
|
|
15248
|
+
const warnings = [
|
|
15249
|
+
...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
|
|
15250
|
+
...signingConfigured ? [] : ["Webhook signature verification is not configured."],
|
|
15251
|
+
...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
|
|
15252
|
+
];
|
|
15253
|
+
return {
|
|
15254
|
+
generatedAt: Date.now(),
|
|
15255
|
+
missing,
|
|
15256
|
+
provider: "twilio",
|
|
15257
|
+
ready: missing.length === 0 && signingConfigured && warnings.length === 0,
|
|
15258
|
+
signing: {
|
|
15259
|
+
configured: signingConfigured,
|
|
15260
|
+
mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
|
|
15261
|
+
verificationUrl
|
|
15262
|
+
},
|
|
15263
|
+
urls: {
|
|
15264
|
+
stream,
|
|
15265
|
+
twiml,
|
|
15266
|
+
webhook
|
|
15267
|
+
},
|
|
15268
|
+
warnings
|
|
15269
|
+
};
|
|
15270
|
+
};
|
|
15271
|
+
var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
15272
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
|
|
15273
|
+
<h1>${escapeHtml17(title)}</h1>
|
|
15274
|
+
<p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
|
|
15275
|
+
<section>
|
|
15276
|
+
<h2>URLs</h2>
|
|
15277
|
+
<ul>
|
|
15278
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml17(status.urls.twiml)}</code></li>
|
|
15279
|
+
<li><strong>Media stream:</strong> <code>${escapeHtml17(status.urls.stream)}</code></li>
|
|
15280
|
+
<li><strong>Status webhook:</strong> <code>${escapeHtml17(status.urls.webhook)}</code></li>
|
|
15281
|
+
</ul>
|
|
15282
|
+
</section>
|
|
15283
|
+
<section>
|
|
15284
|
+
<h2>Signing</h2>
|
|
15285
|
+
<p>Mode: <code>${status.signing.mode}</code></p>
|
|
15286
|
+
${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml17(status.signing.verificationUrl)}</code></p>` : ""}
|
|
15287
|
+
</section>
|
|
15288
|
+
${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml17(name)}</code></li>`).join("")}</ul></section>` : ""}
|
|
15289
|
+
${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml17(warning)}</li>`).join("")}</ul></section>` : ""}
|
|
15290
|
+
</main>`;
|
|
15291
|
+
var extractTwilioStreamUrl = (twiml) => twiml.match(/<Stream\b[^>]*\surl="([^"]+)"/i)?.[1]?.replaceAll("&", "&");
|
|
15292
|
+
var createSmokeCheck = (name, status, message, details) => ({
|
|
15293
|
+
details,
|
|
15294
|
+
message,
|
|
15295
|
+
name,
|
|
15296
|
+
status
|
|
15297
|
+
});
|
|
15298
|
+
var renderTwilioVoiceSmokeHTML = (report, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
15299
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio smoke test</p>
|
|
15300
|
+
<h1>${escapeHtml17(title)}</h1>
|
|
15301
|
+
<p><strong>Status:</strong> ${report.pass ? "Pass" : "Fail"}</p>
|
|
15302
|
+
<section>
|
|
15303
|
+
<h2>Checks</h2>
|
|
15304
|
+
<ul>
|
|
15305
|
+
${report.checks.map((check) => `<li><strong>${escapeHtml17(check.name)}</strong>: ${escapeHtml17(check.status)}${check.message ? ` - ${escapeHtml17(check.message)}` : ""}</li>`).join("")}
|
|
15306
|
+
</ul>
|
|
15307
|
+
</section>
|
|
15308
|
+
<section>
|
|
15309
|
+
<h2>Observed URLs</h2>
|
|
15310
|
+
<ul>
|
|
15311
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml17(report.setup.urls.twiml)}</code></li>
|
|
15312
|
+
<li><strong>Stream:</strong> <code>${escapeHtml17(report.twiml?.streamUrl ?? report.setup.urls.stream)}</code></li>
|
|
15313
|
+
<li><strong>Webhook:</strong> <code>${escapeHtml17(report.setup.urls.webhook)}</code></li>
|
|
15314
|
+
</ul>
|
|
15315
|
+
</section>
|
|
15316
|
+
</main>`;
|
|
15317
|
+
var runTwilioVoiceSmokeTest = async (input) => {
|
|
15318
|
+
const setup = await buildTwilioVoiceSetupStatus(input.options, input);
|
|
15319
|
+
const checks = [];
|
|
15320
|
+
const twimlUrl = new URL(setup.urls.twiml);
|
|
15321
|
+
twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
|
|
15322
|
+
twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
|
|
15323
|
+
const twimlResponse = await input.app.handle(new Request(twimlUrl, {
|
|
15324
|
+
headers: input.request.headers
|
|
15325
|
+
}));
|
|
15326
|
+
const twiml = await twimlResponse.text();
|
|
15327
|
+
const streamUrl = extractTwilioStreamUrl(twiml);
|
|
15328
|
+
checks.push(createSmokeCheck("twiml", twimlResponse.ok && Boolean(streamUrl) ? "pass" : "fail", streamUrl ? "TwiML includes a media stream URL." : 'TwiML is missing <Stream url="...">.', {
|
|
15329
|
+
status: twimlResponse.status,
|
|
15330
|
+
streamUrl
|
|
15331
|
+
}));
|
|
15332
|
+
checks.push(createSmokeCheck("stream-url", streamUrl?.startsWith("wss://") ? "pass" : "fail", streamUrl?.startsWith("wss://") ? "Media stream URL uses wss://." : "Media stream URL should use wss:// for Twilio.", {
|
|
15333
|
+
streamUrl
|
|
15334
|
+
}));
|
|
15335
|
+
const webhookBody = {
|
|
15336
|
+
CallSid: input.options.smoke?.callSid ?? "CA_SMOKE_TEST",
|
|
15337
|
+
CallStatus: input.options.smoke?.status ?? "busy",
|
|
15338
|
+
SipResponseCode: String(input.options.smoke?.sipCode ?? 486)
|
|
15339
|
+
};
|
|
15340
|
+
const webhookHeaders = new Headers({
|
|
15341
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
15342
|
+
});
|
|
15343
|
+
const verificationUrl = setup.signing.verificationUrl ?? setup.urls.webhook;
|
|
15344
|
+
if (input.options.webhook?.signingSecret) {
|
|
15345
|
+
webhookHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
|
|
15346
|
+
authToken: input.options.webhook.signingSecret,
|
|
15347
|
+
body: webhookBody,
|
|
15348
|
+
url: verificationUrl
|
|
15349
|
+
}));
|
|
15350
|
+
}
|
|
15351
|
+
const webhookResponse = await input.app.handle(new Request(setup.urls.webhook, {
|
|
15352
|
+
body: new URLSearchParams(webhookBody),
|
|
15353
|
+
headers: webhookHeaders,
|
|
15354
|
+
method: "POST"
|
|
15355
|
+
}));
|
|
15356
|
+
const webhookText = await webhookResponse.text();
|
|
15357
|
+
const webhookPayload = (() => {
|
|
15358
|
+
try {
|
|
15359
|
+
return JSON.parse(webhookText);
|
|
15360
|
+
} catch {
|
|
15361
|
+
return webhookText;
|
|
15362
|
+
}
|
|
15363
|
+
})();
|
|
15364
|
+
checks.push(createSmokeCheck("webhook", webhookResponse.ok ? "pass" : "fail", webhookResponse.ok ? "Synthetic Twilio status callback was accepted." : "Synthetic Twilio status callback failed.", {
|
|
15365
|
+
status: webhookResponse.status
|
|
15366
|
+
}));
|
|
15367
|
+
for (const warning of setup.warnings) {
|
|
15368
|
+
checks.push(createSmokeCheck("setup-warning", "warn", warning));
|
|
15369
|
+
}
|
|
15370
|
+
for (const name of setup.missing) {
|
|
15371
|
+
checks.push(createSmokeCheck("missing-env", "fail", `${name} is missing.`));
|
|
15372
|
+
}
|
|
15373
|
+
return {
|
|
15374
|
+
checks,
|
|
15375
|
+
generatedAt: Date.now(),
|
|
15376
|
+
pass: checks.every((check) => check.status !== "fail"),
|
|
15377
|
+
provider: "twilio",
|
|
15378
|
+
setup,
|
|
15379
|
+
twiml: {
|
|
15380
|
+
status: twimlResponse.status,
|
|
15381
|
+
streamUrl
|
|
15382
|
+
},
|
|
15383
|
+
webhook: {
|
|
15384
|
+
body: webhookPayload,
|
|
15385
|
+
status: webhookResponse.status
|
|
15386
|
+
}
|
|
15387
|
+
};
|
|
15388
|
+
};
|
|
15229
15389
|
var normalizeOnTurn2 = (handler) => {
|
|
15230
15390
|
if (handler.length > 1) {
|
|
15231
15391
|
const directHandler = handler;
|
|
@@ -15606,9 +15766,11 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
15606
15766
|
const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
|
|
15607
15767
|
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
15608
15768
|
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
15769
|
+
const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
|
|
15770
|
+
const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
|
|
15609
15771
|
const bridges = new WeakMap;
|
|
15610
15772
|
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
15611
|
-
|
|
15773
|
+
const app = new Elysia18({
|
|
15612
15774
|
name: options.name ?? "absolutejs-voice-twilio"
|
|
15613
15775
|
}).get(twimlPath, async ({ query, request }) => {
|
|
15614
15776
|
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
@@ -15678,6 +15840,69 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
15678
15840
|
policy: webhookPolicy,
|
|
15679
15841
|
provider: "twilio"
|
|
15680
15842
|
}));
|
|
15843
|
+
if (!setupPath) {
|
|
15844
|
+
if (!smokePath) {
|
|
15845
|
+
return app;
|
|
15846
|
+
}
|
|
15847
|
+
return app.get(smokePath, async ({ query, request }) => {
|
|
15848
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
15849
|
+
app,
|
|
15850
|
+
options,
|
|
15851
|
+
query,
|
|
15852
|
+
request,
|
|
15853
|
+
streamPath,
|
|
15854
|
+
twimlPath,
|
|
15855
|
+
webhookPath
|
|
15856
|
+
});
|
|
15857
|
+
if (query.format === "html") {
|
|
15858
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
15859
|
+
headers: {
|
|
15860
|
+
"content-type": "text/html; charset=utf-8"
|
|
15861
|
+
}
|
|
15862
|
+
});
|
|
15863
|
+
}
|
|
15864
|
+
return report;
|
|
15865
|
+
});
|
|
15866
|
+
}
|
|
15867
|
+
const withSetup = app.get(setupPath, async ({ query, request }) => {
|
|
15868
|
+
const status = await buildTwilioVoiceSetupStatus(options, {
|
|
15869
|
+
query,
|
|
15870
|
+
request,
|
|
15871
|
+
streamPath,
|
|
15872
|
+
twimlPath,
|
|
15873
|
+
webhookPath
|
|
15874
|
+
});
|
|
15875
|
+
if (query.format === "html") {
|
|
15876
|
+
return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
|
|
15877
|
+
headers: {
|
|
15878
|
+
"content-type": "text/html; charset=utf-8"
|
|
15879
|
+
}
|
|
15880
|
+
});
|
|
15881
|
+
}
|
|
15882
|
+
return status;
|
|
15883
|
+
});
|
|
15884
|
+
if (!smokePath) {
|
|
15885
|
+
return withSetup;
|
|
15886
|
+
}
|
|
15887
|
+
return withSetup.get(smokePath, async ({ query, request }) => {
|
|
15888
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
15889
|
+
app,
|
|
15890
|
+
options,
|
|
15891
|
+
query,
|
|
15892
|
+
request,
|
|
15893
|
+
streamPath,
|
|
15894
|
+
twimlPath,
|
|
15895
|
+
webhookPath
|
|
15896
|
+
});
|
|
15897
|
+
if (query.format === "html") {
|
|
15898
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
15899
|
+
headers: {
|
|
15900
|
+
"content-type": "text/html; charset=utf-8"
|
|
15901
|
+
}
|
|
15902
|
+
});
|
|
15903
|
+
}
|
|
15904
|
+
return report;
|
|
15905
|
+
});
|
|
15681
15906
|
};
|
|
15682
15907
|
// src/telephony/response.ts
|
|
15683
15908
|
var normalizeWhitespace = (value) => value.replace(/\s+/g, " ").trim();
|
|
@@ -113,9 +113,63 @@ export type TwilioVoiceRouteParameters = Record<string, string | number | boolea
|
|
|
113
113
|
query: Record<string, unknown>;
|
|
114
114
|
request: Request;
|
|
115
115
|
}) => Promise<Record<string, string | number | boolean | undefined>> | Record<string, string | number | boolean | undefined>);
|
|
116
|
+
export type TwilioVoiceSetupStatus = {
|
|
117
|
+
generatedAt: number;
|
|
118
|
+
missing: string[];
|
|
119
|
+
provider: 'twilio';
|
|
120
|
+
ready: boolean;
|
|
121
|
+
signing: {
|
|
122
|
+
configured: boolean;
|
|
123
|
+
mode: 'custom' | 'none' | 'twilio-signature';
|
|
124
|
+
verificationUrl?: string;
|
|
125
|
+
};
|
|
126
|
+
urls: {
|
|
127
|
+
stream: string;
|
|
128
|
+
twiml: string;
|
|
129
|
+
webhook: string;
|
|
130
|
+
};
|
|
131
|
+
warnings: string[];
|
|
132
|
+
};
|
|
133
|
+
export type TwilioVoiceSetupOptions = {
|
|
134
|
+
path?: false | string;
|
|
135
|
+
requiredEnv?: Record<string, string | undefined>;
|
|
136
|
+
title?: string;
|
|
137
|
+
};
|
|
138
|
+
export type TwilioVoiceSmokeCheck = {
|
|
139
|
+
details?: Record<string, unknown>;
|
|
140
|
+
message?: string;
|
|
141
|
+
name: string;
|
|
142
|
+
status: 'fail' | 'pass' | 'warn';
|
|
143
|
+
};
|
|
144
|
+
export type TwilioVoiceSmokeReport = {
|
|
145
|
+
checks: TwilioVoiceSmokeCheck[];
|
|
146
|
+
generatedAt: number;
|
|
147
|
+
pass: boolean;
|
|
148
|
+
provider: 'twilio';
|
|
149
|
+
setup: TwilioVoiceSetupStatus;
|
|
150
|
+
twiml?: {
|
|
151
|
+
status: number;
|
|
152
|
+
streamUrl?: string;
|
|
153
|
+
};
|
|
154
|
+
webhook?: {
|
|
155
|
+
body?: unknown;
|
|
156
|
+
status: number;
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
export type TwilioVoiceSmokeOptions = {
|
|
160
|
+
callSid?: string;
|
|
161
|
+
path?: false | string;
|
|
162
|
+
scenarioId?: string;
|
|
163
|
+
sessionId?: string;
|
|
164
|
+
sipCode?: number;
|
|
165
|
+
status?: string;
|
|
166
|
+
title?: string;
|
|
167
|
+
};
|
|
116
168
|
export type TwilioVoiceRoutesOptions<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = TwilioMediaStreamBridgeOptions<TContext, TSession, TResult> & {
|
|
117
169
|
name?: string;
|
|
118
170
|
outcomePolicy?: VoiceTelephonyOutcomePolicy;
|
|
171
|
+
smoke?: TwilioVoiceSmokeOptions;
|
|
172
|
+
setup?: TwilioVoiceSetupOptions;
|
|
119
173
|
streamPath?: string;
|
|
120
174
|
twiml?: {
|
|
121
175
|
parameters?: TwilioVoiceRouteParameters;
|
package/dist/testing/index.js
CHANGED
|
@@ -8565,6 +8565,166 @@ var resolveTwilioStreamParameters = async (parameters, input) => {
|
|
|
8565
8565
|
}
|
|
8566
8566
|
return parameters;
|
|
8567
8567
|
};
|
|
8568
|
+
var joinUrlPath = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
8569
|
+
var escapeHtml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
8570
|
+
var getWebhookVerificationUrl = (webhook, input) => {
|
|
8571
|
+
if (!webhook?.verificationUrl) {
|
|
8572
|
+
return;
|
|
8573
|
+
}
|
|
8574
|
+
if (typeof webhook.verificationUrl === "function") {
|
|
8575
|
+
return webhook.verificationUrl(input);
|
|
8576
|
+
}
|
|
8577
|
+
return webhook.verificationUrl;
|
|
8578
|
+
};
|
|
8579
|
+
var buildTwilioVoiceSetupStatus = async (options, input) => {
|
|
8580
|
+
const origin = resolveRequestOrigin(input.request);
|
|
8581
|
+
const stream = await resolveTwilioStreamUrl(options, input);
|
|
8582
|
+
const twiml = joinUrlPath(origin, input.twimlPath);
|
|
8583
|
+
const webhook = joinUrlPath(origin, input.webhookPath);
|
|
8584
|
+
const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
|
|
8585
|
+
const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
|
|
8586
|
+
const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
|
|
8587
|
+
const warnings = [
|
|
8588
|
+
...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
|
|
8589
|
+
...signingConfigured ? [] : ["Webhook signature verification is not configured."],
|
|
8590
|
+
...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
|
|
8591
|
+
];
|
|
8592
|
+
return {
|
|
8593
|
+
generatedAt: Date.now(),
|
|
8594
|
+
missing,
|
|
8595
|
+
provider: "twilio",
|
|
8596
|
+
ready: missing.length === 0 && signingConfigured && warnings.length === 0,
|
|
8597
|
+
signing: {
|
|
8598
|
+
configured: signingConfigured,
|
|
8599
|
+
mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
|
|
8600
|
+
verificationUrl
|
|
8601
|
+
},
|
|
8602
|
+
urls: {
|
|
8603
|
+
stream,
|
|
8604
|
+
twiml,
|
|
8605
|
+
webhook
|
|
8606
|
+
},
|
|
8607
|
+
warnings
|
|
8608
|
+
};
|
|
8609
|
+
};
|
|
8610
|
+
var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
8611
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
|
|
8612
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
8613
|
+
<p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
|
|
8614
|
+
<section>
|
|
8615
|
+
<h2>URLs</h2>
|
|
8616
|
+
<ul>
|
|
8617
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(status.urls.twiml)}</code></li>
|
|
8618
|
+
<li><strong>Media stream:</strong> <code>${escapeHtml2(status.urls.stream)}</code></li>
|
|
8619
|
+
<li><strong>Status webhook:</strong> <code>${escapeHtml2(status.urls.webhook)}</code></li>
|
|
8620
|
+
</ul>
|
|
8621
|
+
</section>
|
|
8622
|
+
<section>
|
|
8623
|
+
<h2>Signing</h2>
|
|
8624
|
+
<p>Mode: <code>${status.signing.mode}</code></p>
|
|
8625
|
+
${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml2(status.signing.verificationUrl)}</code></p>` : ""}
|
|
8626
|
+
</section>
|
|
8627
|
+
${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml2(name)}</code></li>`).join("")}</ul></section>` : ""}
|
|
8628
|
+
${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml2(warning)}</li>`).join("")}</ul></section>` : ""}
|
|
8629
|
+
</main>`;
|
|
8630
|
+
var extractTwilioStreamUrl = (twiml) => twiml.match(/<Stream\b[^>]*\surl="([^"]+)"/i)?.[1]?.replaceAll("&", "&");
|
|
8631
|
+
var createSmokeCheck = (name, status, message, details) => ({
|
|
8632
|
+
details,
|
|
8633
|
+
message,
|
|
8634
|
+
name,
|
|
8635
|
+
status
|
|
8636
|
+
});
|
|
8637
|
+
var renderTwilioVoiceSmokeHTML = (report, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
8638
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio smoke test</p>
|
|
8639
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
8640
|
+
<p><strong>Status:</strong> ${report.pass ? "Pass" : "Fail"}</p>
|
|
8641
|
+
<section>
|
|
8642
|
+
<h2>Checks</h2>
|
|
8643
|
+
<ul>
|
|
8644
|
+
${report.checks.map((check) => `<li><strong>${escapeHtml2(check.name)}</strong>: ${escapeHtml2(check.status)}${check.message ? ` - ${escapeHtml2(check.message)}` : ""}</li>`).join("")}
|
|
8645
|
+
</ul>
|
|
8646
|
+
</section>
|
|
8647
|
+
<section>
|
|
8648
|
+
<h2>Observed URLs</h2>
|
|
8649
|
+
<ul>
|
|
8650
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(report.setup.urls.twiml)}</code></li>
|
|
8651
|
+
<li><strong>Stream:</strong> <code>${escapeHtml2(report.twiml?.streamUrl ?? report.setup.urls.stream)}</code></li>
|
|
8652
|
+
<li><strong>Webhook:</strong> <code>${escapeHtml2(report.setup.urls.webhook)}</code></li>
|
|
8653
|
+
</ul>
|
|
8654
|
+
</section>
|
|
8655
|
+
</main>`;
|
|
8656
|
+
var runTwilioVoiceSmokeTest = async (input) => {
|
|
8657
|
+
const setup = await buildTwilioVoiceSetupStatus(input.options, input);
|
|
8658
|
+
const checks = [];
|
|
8659
|
+
const twimlUrl = new URL(setup.urls.twiml);
|
|
8660
|
+
twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
|
|
8661
|
+
twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
|
|
8662
|
+
const twimlResponse = await input.app.handle(new Request(twimlUrl, {
|
|
8663
|
+
headers: input.request.headers
|
|
8664
|
+
}));
|
|
8665
|
+
const twiml = await twimlResponse.text();
|
|
8666
|
+
const streamUrl = extractTwilioStreamUrl(twiml);
|
|
8667
|
+
checks.push(createSmokeCheck("twiml", twimlResponse.ok && Boolean(streamUrl) ? "pass" : "fail", streamUrl ? "TwiML includes a media stream URL." : 'TwiML is missing <Stream url="...">.', {
|
|
8668
|
+
status: twimlResponse.status,
|
|
8669
|
+
streamUrl
|
|
8670
|
+
}));
|
|
8671
|
+
checks.push(createSmokeCheck("stream-url", streamUrl?.startsWith("wss://") ? "pass" : "fail", streamUrl?.startsWith("wss://") ? "Media stream URL uses wss://." : "Media stream URL should use wss:// for Twilio.", {
|
|
8672
|
+
streamUrl
|
|
8673
|
+
}));
|
|
8674
|
+
const webhookBody = {
|
|
8675
|
+
CallSid: input.options.smoke?.callSid ?? "CA_SMOKE_TEST",
|
|
8676
|
+
CallStatus: input.options.smoke?.status ?? "busy",
|
|
8677
|
+
SipResponseCode: String(input.options.smoke?.sipCode ?? 486)
|
|
8678
|
+
};
|
|
8679
|
+
const webhookHeaders = new Headers({
|
|
8680
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
8681
|
+
});
|
|
8682
|
+
const verificationUrl = setup.signing.verificationUrl ?? setup.urls.webhook;
|
|
8683
|
+
if (input.options.webhook?.signingSecret) {
|
|
8684
|
+
webhookHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
|
|
8685
|
+
authToken: input.options.webhook.signingSecret,
|
|
8686
|
+
body: webhookBody,
|
|
8687
|
+
url: verificationUrl
|
|
8688
|
+
}));
|
|
8689
|
+
}
|
|
8690
|
+
const webhookResponse = await input.app.handle(new Request(setup.urls.webhook, {
|
|
8691
|
+
body: new URLSearchParams(webhookBody),
|
|
8692
|
+
headers: webhookHeaders,
|
|
8693
|
+
method: "POST"
|
|
8694
|
+
}));
|
|
8695
|
+
const webhookText = await webhookResponse.text();
|
|
8696
|
+
const webhookPayload = (() => {
|
|
8697
|
+
try {
|
|
8698
|
+
return JSON.parse(webhookText);
|
|
8699
|
+
} catch {
|
|
8700
|
+
return webhookText;
|
|
8701
|
+
}
|
|
8702
|
+
})();
|
|
8703
|
+
checks.push(createSmokeCheck("webhook", webhookResponse.ok ? "pass" : "fail", webhookResponse.ok ? "Synthetic Twilio status callback was accepted." : "Synthetic Twilio status callback failed.", {
|
|
8704
|
+
status: webhookResponse.status
|
|
8705
|
+
}));
|
|
8706
|
+
for (const warning of setup.warnings) {
|
|
8707
|
+
checks.push(createSmokeCheck("setup-warning", "warn", warning));
|
|
8708
|
+
}
|
|
8709
|
+
for (const name of setup.missing) {
|
|
8710
|
+
checks.push(createSmokeCheck("missing-env", "fail", `${name} is missing.`));
|
|
8711
|
+
}
|
|
8712
|
+
return {
|
|
8713
|
+
checks,
|
|
8714
|
+
generatedAt: Date.now(),
|
|
8715
|
+
pass: checks.every((check) => check.status !== "fail"),
|
|
8716
|
+
provider: "twilio",
|
|
8717
|
+
setup,
|
|
8718
|
+
twiml: {
|
|
8719
|
+
status: twimlResponse.status,
|
|
8720
|
+
streamUrl
|
|
8721
|
+
},
|
|
8722
|
+
webhook: {
|
|
8723
|
+
body: webhookPayload,
|
|
8724
|
+
status: webhookResponse.status
|
|
8725
|
+
}
|
|
8726
|
+
};
|
|
8727
|
+
};
|
|
8568
8728
|
var normalizeOnTurn = (handler) => {
|
|
8569
8729
|
if (handler.length > 1) {
|
|
8570
8730
|
const directHandler = handler;
|
|
@@ -8945,9 +9105,11 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
8945
9105
|
const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
|
|
8946
9106
|
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
8947
9107
|
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
9108
|
+
const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
|
|
9109
|
+
const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
|
|
8948
9110
|
const bridges = new WeakMap;
|
|
8949
9111
|
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
8950
|
-
|
|
9112
|
+
const app = new Elysia2({
|
|
8951
9113
|
name: options.name ?? "absolutejs-voice-twilio"
|
|
8952
9114
|
}).get(twimlPath, async ({ query, request }) => {
|
|
8953
9115
|
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
@@ -9017,6 +9179,69 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
9017
9179
|
policy: webhookPolicy,
|
|
9018
9180
|
provider: "twilio"
|
|
9019
9181
|
}));
|
|
9182
|
+
if (!setupPath) {
|
|
9183
|
+
if (!smokePath) {
|
|
9184
|
+
return app;
|
|
9185
|
+
}
|
|
9186
|
+
return app.get(smokePath, async ({ query, request }) => {
|
|
9187
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
9188
|
+
app,
|
|
9189
|
+
options,
|
|
9190
|
+
query,
|
|
9191
|
+
request,
|
|
9192
|
+
streamPath,
|
|
9193
|
+
twimlPath,
|
|
9194
|
+
webhookPath
|
|
9195
|
+
});
|
|
9196
|
+
if (query.format === "html") {
|
|
9197
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
9198
|
+
headers: {
|
|
9199
|
+
"content-type": "text/html; charset=utf-8"
|
|
9200
|
+
}
|
|
9201
|
+
});
|
|
9202
|
+
}
|
|
9203
|
+
return report;
|
|
9204
|
+
});
|
|
9205
|
+
}
|
|
9206
|
+
const withSetup = app.get(setupPath, async ({ query, request }) => {
|
|
9207
|
+
const status = await buildTwilioVoiceSetupStatus(options, {
|
|
9208
|
+
query,
|
|
9209
|
+
request,
|
|
9210
|
+
streamPath,
|
|
9211
|
+
twimlPath,
|
|
9212
|
+
webhookPath
|
|
9213
|
+
});
|
|
9214
|
+
if (query.format === "html") {
|
|
9215
|
+
return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
|
|
9216
|
+
headers: {
|
|
9217
|
+
"content-type": "text/html; charset=utf-8"
|
|
9218
|
+
}
|
|
9219
|
+
});
|
|
9220
|
+
}
|
|
9221
|
+
return status;
|
|
9222
|
+
});
|
|
9223
|
+
if (!smokePath) {
|
|
9224
|
+
return withSetup;
|
|
9225
|
+
}
|
|
9226
|
+
return withSetup.get(smokePath, async ({ query, request }) => {
|
|
9227
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
9228
|
+
app,
|
|
9229
|
+
options,
|
|
9230
|
+
query,
|
|
9231
|
+
request,
|
|
9232
|
+
streamPath,
|
|
9233
|
+
twimlPath,
|
|
9234
|
+
webhookPath
|
|
9235
|
+
});
|
|
9236
|
+
if (query.format === "html") {
|
|
9237
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
9238
|
+
headers: {
|
|
9239
|
+
"content-type": "text/html; charset=utf-8"
|
|
9240
|
+
}
|
|
9241
|
+
});
|
|
9242
|
+
}
|
|
9243
|
+
return report;
|
|
9244
|
+
});
|
|
9020
9245
|
};
|
|
9021
9246
|
|
|
9022
9247
|
// src/testing/telephony.ts
|