@absolutejs/voice 0.0.22-beta.75 → 0.0.22-beta.77
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/index.d.ts +3 -1
- package/dist/index.js +224 -2
- package/dist/telephony/contract.d.ts +61 -0
- package/dist/telephony/twilio.d.ts +17 -14
- package/dist/testing/index.js +144 -2
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -81,7 +81,9 @@ 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
|
|
84
|
+
export { evaluateVoiceTelephonyContract } from './telephony/contract';
|
|
85
|
+
export type { TwilioInboundMessage, TwilioMediaStreamBridge, TwilioMediaStreamBridgeOptions, TwilioMediaStreamSocket, TwilioOutboundClearMessage, TwilioOutboundMarkMessage, TwilioOutboundMediaMessage, TwilioOutboundMessage, TwilioVoiceRouteParameters, TwilioVoiceResponseOptions, TwilioVoiceSmokeCheck, TwilioVoiceSmokeOptions, TwilioVoiceSmokeReport, TwilioVoiceSetupOptions, TwilioVoiceSetupStatus, TwilioVoiceRoutesOptions } from './telephony/twilio';
|
|
86
|
+
export type { VoiceTelephonyContractIssue, VoiceTelephonyContractOptions, VoiceTelephonyContractReport, VoiceTelephonyContractRequirement, VoiceTelephonyProvider, VoiceTelephonySetupStatus, VoiceTelephonySmokeCheck, VoiceTelephonySmokeReport } from './telephony/contract';
|
|
85
87
|
export { shapeTelephonyAssistantText } from './telephony/response';
|
|
86
88
|
export type { TelephonyResponseShapeMode, TelephonyResponseShapeOptions } from './telephony/response';
|
|
87
89
|
export * from './types';
|
package/dist/index.js
CHANGED
|
@@ -15288,6 +15288,104 @@ ${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml17(st
|
|
|
15288
15288
|
${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml17(name)}</code></li>`).join("")}</ul></section>` : ""}
|
|
15289
15289
|
${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml17(warning)}</li>`).join("")}</ul></section>` : ""}
|
|
15290
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
|
+
};
|
|
15291
15389
|
var normalizeOnTurn2 = (handler) => {
|
|
15292
15390
|
if (handler.length > 1) {
|
|
15293
15391
|
const directHandler = handler;
|
|
@@ -15669,6 +15767,7 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
15669
15767
|
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
15670
15768
|
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
15671
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";
|
|
15672
15771
|
const bridges = new WeakMap;
|
|
15673
15772
|
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
15674
15773
|
const app = new Elysia18({
|
|
@@ -15742,9 +15841,30 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
15742
15841
|
provider: "twilio"
|
|
15743
15842
|
}));
|
|
15744
15843
|
if (!setupPath) {
|
|
15745
|
-
|
|
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
|
+
});
|
|
15746
15866
|
}
|
|
15747
|
-
|
|
15867
|
+
const withSetup = app.get(setupPath, async ({ query, request }) => {
|
|
15748
15868
|
const status = await buildTwilioVoiceSetupStatus(options, {
|
|
15749
15869
|
query,
|
|
15750
15870
|
request,
|
|
@@ -15761,6 +15881,107 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
15761
15881
|
}
|
|
15762
15882
|
return status;
|
|
15763
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
|
+
});
|
|
15906
|
+
};
|
|
15907
|
+
// src/telephony/contract.ts
|
|
15908
|
+
var DEFAULT_REQUIREMENTS = [
|
|
15909
|
+
"stream-url",
|
|
15910
|
+
"wss-stream",
|
|
15911
|
+
"webhook-url",
|
|
15912
|
+
"signed-webhook",
|
|
15913
|
+
"smoke-pass"
|
|
15914
|
+
];
|
|
15915
|
+
var hasFailingSmokeCheck = (smoke) => smoke?.checks.some((check) => check.status === "fail") ?? false;
|
|
15916
|
+
var evaluateVoiceTelephonyContract = (input) => {
|
|
15917
|
+
const requirements = input.options?.requirements ?? DEFAULT_REQUIREMENTS;
|
|
15918
|
+
const issues = [];
|
|
15919
|
+
const hasRequirement = (requirement) => requirements.includes(requirement);
|
|
15920
|
+
if (hasRequirement("stream-url") && !input.setup.urls.stream) {
|
|
15921
|
+
issues.push({
|
|
15922
|
+
message: "Missing media stream URL.",
|
|
15923
|
+
requirement: "stream-url",
|
|
15924
|
+
severity: "error"
|
|
15925
|
+
});
|
|
15926
|
+
}
|
|
15927
|
+
if (hasRequirement("wss-stream") && !input.setup.urls.stream.startsWith("wss://")) {
|
|
15928
|
+
issues.push({
|
|
15929
|
+
message: "Media stream URL must use wss://.",
|
|
15930
|
+
requirement: "wss-stream",
|
|
15931
|
+
severity: "error"
|
|
15932
|
+
});
|
|
15933
|
+
}
|
|
15934
|
+
if (hasRequirement("webhook-url") && !input.setup.urls.webhook) {
|
|
15935
|
+
issues.push({
|
|
15936
|
+
message: "Missing carrier webhook URL.",
|
|
15937
|
+
requirement: "webhook-url",
|
|
15938
|
+
severity: "error"
|
|
15939
|
+
});
|
|
15940
|
+
}
|
|
15941
|
+
if (hasRequirement("signed-webhook") && !input.setup.signing.configured) {
|
|
15942
|
+
issues.push({
|
|
15943
|
+
message: "Carrier webhook signature verification is not configured.",
|
|
15944
|
+
requirement: "signed-webhook",
|
|
15945
|
+
severity: "error"
|
|
15946
|
+
});
|
|
15947
|
+
}
|
|
15948
|
+
if (hasRequirement("smoke-pass")) {
|
|
15949
|
+
if (!input.smoke) {
|
|
15950
|
+
issues.push({
|
|
15951
|
+
message: "Missing telephony smoke test report.",
|
|
15952
|
+
requirement: "smoke-pass",
|
|
15953
|
+
severity: "error"
|
|
15954
|
+
});
|
|
15955
|
+
} else if (!input.smoke.pass || hasFailingSmokeCheck(input.smoke)) {
|
|
15956
|
+
issues.push({
|
|
15957
|
+
message: "Telephony smoke test did not pass.",
|
|
15958
|
+
requirement: "smoke-pass",
|
|
15959
|
+
severity: "error"
|
|
15960
|
+
});
|
|
15961
|
+
}
|
|
15962
|
+
}
|
|
15963
|
+
for (const warning of input.setup.warnings) {
|
|
15964
|
+
issues.push({
|
|
15965
|
+
message: warning,
|
|
15966
|
+
requirement: "stream-url",
|
|
15967
|
+
severity: "warning"
|
|
15968
|
+
});
|
|
15969
|
+
}
|
|
15970
|
+
for (const name of input.setup.missing) {
|
|
15971
|
+
issues.push({
|
|
15972
|
+
message: `${name} is missing.`,
|
|
15973
|
+
requirement: "webhook-url",
|
|
15974
|
+
severity: "error"
|
|
15975
|
+
});
|
|
15976
|
+
}
|
|
15977
|
+
return {
|
|
15978
|
+
issues,
|
|
15979
|
+
pass: issues.every((issue) => issue.severity !== "error"),
|
|
15980
|
+
provider: input.setup.provider,
|
|
15981
|
+
requirements,
|
|
15982
|
+
setup: input.setup,
|
|
15983
|
+
smoke: input.smoke
|
|
15984
|
+
};
|
|
15764
15985
|
};
|
|
15765
15986
|
// src/telephony/response.ts
|
|
15766
15987
|
var normalizeWhitespace = (value) => value.replace(/\s+/g, " ").trim();
|
|
@@ -15904,6 +16125,7 @@ export {
|
|
|
15904
16125
|
failVoiceOpsTask,
|
|
15905
16126
|
exportVoiceTrace,
|
|
15906
16127
|
evaluateVoiceTrace,
|
|
16128
|
+
evaluateVoiceTelephonyContract,
|
|
15907
16129
|
evaluateVoiceQuality,
|
|
15908
16130
|
encodeTwilioMulawBase64,
|
|
15909
16131
|
deliverVoiceTraceEventsToSinks,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type VoiceTelephonyProvider = 'generic' | 'plivo' | 'telnyx' | 'twilio';
|
|
2
|
+
export type VoiceTelephonySetupStatus<TProvider extends VoiceTelephonyProvider = VoiceTelephonyProvider> = {
|
|
3
|
+
generatedAt: number;
|
|
4
|
+
missing: string[];
|
|
5
|
+
provider: TProvider;
|
|
6
|
+
ready: boolean;
|
|
7
|
+
signing: {
|
|
8
|
+
configured: boolean;
|
|
9
|
+
mode: 'custom' | 'none' | 'provider-signature' | 'twilio-signature';
|
|
10
|
+
verificationUrl?: string;
|
|
11
|
+
};
|
|
12
|
+
urls: {
|
|
13
|
+
stream: string;
|
|
14
|
+
twiml?: string;
|
|
15
|
+
webhook: string;
|
|
16
|
+
};
|
|
17
|
+
warnings: string[];
|
|
18
|
+
};
|
|
19
|
+
export type VoiceTelephonySmokeCheck = {
|
|
20
|
+
details?: Record<string, unknown>;
|
|
21
|
+
message?: string;
|
|
22
|
+
name: string;
|
|
23
|
+
status: 'fail' | 'pass' | 'warn';
|
|
24
|
+
};
|
|
25
|
+
export type VoiceTelephonySmokeReport<TProvider extends VoiceTelephonyProvider = VoiceTelephonyProvider> = {
|
|
26
|
+
checks: VoiceTelephonySmokeCheck[];
|
|
27
|
+
generatedAt: number;
|
|
28
|
+
pass: boolean;
|
|
29
|
+
provider: TProvider;
|
|
30
|
+
setup: VoiceTelephonySetupStatus<TProvider>;
|
|
31
|
+
twiml?: {
|
|
32
|
+
status: number;
|
|
33
|
+
streamUrl?: string;
|
|
34
|
+
};
|
|
35
|
+
webhook?: {
|
|
36
|
+
body?: unknown;
|
|
37
|
+
status: number;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
export type VoiceTelephonyContractRequirement = 'signed-webhook' | 'smoke-pass' | 'stream-url' | 'webhook-url' | 'wss-stream';
|
|
41
|
+
export type VoiceTelephonyContractIssue = {
|
|
42
|
+
requirement: VoiceTelephonyContractRequirement;
|
|
43
|
+
severity: 'error' | 'warning';
|
|
44
|
+
message: string;
|
|
45
|
+
};
|
|
46
|
+
export type VoiceTelephonyContractReport<TProvider extends VoiceTelephonyProvider = VoiceTelephonyProvider> = {
|
|
47
|
+
issues: VoiceTelephonyContractIssue[];
|
|
48
|
+
pass: boolean;
|
|
49
|
+
provider: TProvider;
|
|
50
|
+
requirements: VoiceTelephonyContractRequirement[];
|
|
51
|
+
setup: VoiceTelephonySetupStatus<TProvider>;
|
|
52
|
+
smoke?: VoiceTelephonySmokeReport<TProvider>;
|
|
53
|
+
};
|
|
54
|
+
export type VoiceTelephonyContractOptions = {
|
|
55
|
+
requirements?: VoiceTelephonyContractRequirement[];
|
|
56
|
+
};
|
|
57
|
+
export declare const evaluateVoiceTelephonyContract: <TProvider extends VoiceTelephonyProvider>(input: {
|
|
58
|
+
options?: VoiceTelephonyContractOptions;
|
|
59
|
+
setup: VoiceTelephonySetupStatus<TProvider>;
|
|
60
|
+
smoke?: VoiceTelephonySmokeReport<TProvider>;
|
|
61
|
+
}) => VoiceTelephonyContractReport<TProvider>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Elysia } from 'elysia';
|
|
2
|
+
import type { VoiceTelephonySetupStatus, VoiceTelephonySmokeCheck, VoiceTelephonySmokeReport } from './contract';
|
|
2
3
|
import { type VoiceTelephonyOutcomePolicy, type VoiceTelephonyWebhookRoutesOptions } from '../telephonyOutcome';
|
|
3
4
|
import { type VoiceCallReviewArtifact, type VoiceCallReviewConfig } from '../testing/review';
|
|
4
5
|
import type { AudioFormat, VoiceLogger, VoicePluginConfig, VoiceSessionRecord, VoiceServerMessage } from '../types';
|
|
@@ -113,31 +114,33 @@ export type TwilioVoiceRouteParameters = Record<string, string | number | boolea
|
|
|
113
114
|
query: Record<string, unknown>;
|
|
114
115
|
request: Request;
|
|
115
116
|
}) => Promise<Record<string, string | number | boolean | undefined>> | Record<string, string | number | boolean | undefined>);
|
|
116
|
-
export type TwilioVoiceSetupStatus = {
|
|
117
|
-
|
|
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;
|
|
117
|
+
export type TwilioVoiceSetupStatus = VoiceTelephonySetupStatus<'twilio'> & {
|
|
118
|
+
urls: VoiceTelephonySetupStatus<'twilio'>['urls'] & {
|
|
128
119
|
twiml: string;
|
|
129
|
-
webhook: string;
|
|
130
120
|
};
|
|
131
|
-
warnings: string[];
|
|
132
121
|
};
|
|
133
122
|
export type TwilioVoiceSetupOptions = {
|
|
134
123
|
path?: false | string;
|
|
135
124
|
requiredEnv?: Record<string, string | undefined>;
|
|
136
125
|
title?: string;
|
|
137
126
|
};
|
|
127
|
+
export type TwilioVoiceSmokeCheck = VoiceTelephonySmokeCheck;
|
|
128
|
+
export type TwilioVoiceSmokeReport = VoiceTelephonySmokeReport<'twilio'> & {
|
|
129
|
+
setup: TwilioVoiceSetupStatus;
|
|
130
|
+
};
|
|
131
|
+
export type TwilioVoiceSmokeOptions = {
|
|
132
|
+
callSid?: string;
|
|
133
|
+
path?: false | string;
|
|
134
|
+
scenarioId?: string;
|
|
135
|
+
sessionId?: string;
|
|
136
|
+
sipCode?: number;
|
|
137
|
+
status?: string;
|
|
138
|
+
title?: string;
|
|
139
|
+
};
|
|
138
140
|
export type TwilioVoiceRoutesOptions<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = TwilioMediaStreamBridgeOptions<TContext, TSession, TResult> & {
|
|
139
141
|
name?: string;
|
|
140
142
|
outcomePolicy?: VoiceTelephonyOutcomePolicy;
|
|
143
|
+
smoke?: TwilioVoiceSmokeOptions;
|
|
141
144
|
setup?: TwilioVoiceSetupOptions;
|
|
142
145
|
streamPath?: string;
|
|
143
146
|
twiml?: {
|
package/dist/testing/index.js
CHANGED
|
@@ -8627,6 +8627,104 @@ ${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml2(sta
|
|
|
8627
8627
|
${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml2(name)}</code></li>`).join("")}</ul></section>` : ""}
|
|
8628
8628
|
${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml2(warning)}</li>`).join("")}</ul></section>` : ""}
|
|
8629
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
|
+
};
|
|
8630
8728
|
var normalizeOnTurn = (handler) => {
|
|
8631
8729
|
if (handler.length > 1) {
|
|
8632
8730
|
const directHandler = handler;
|
|
@@ -9008,6 +9106,7 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
9008
9106
|
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
9009
9107
|
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
9010
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";
|
|
9011
9110
|
const bridges = new WeakMap;
|
|
9012
9111
|
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
9013
9112
|
const app = new Elysia2({
|
|
@@ -9081,9 +9180,30 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
9081
9180
|
provider: "twilio"
|
|
9082
9181
|
}));
|
|
9083
9182
|
if (!setupPath) {
|
|
9084
|
-
|
|
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
|
+
});
|
|
9085
9205
|
}
|
|
9086
|
-
|
|
9206
|
+
const withSetup = app.get(setupPath, async ({ query, request }) => {
|
|
9087
9207
|
const status = await buildTwilioVoiceSetupStatus(options, {
|
|
9088
9208
|
query,
|
|
9089
9209
|
request,
|
|
@@ -9100,6 +9220,28 @@ var createTwilioVoiceRoutes = (options) => {
|
|
|
9100
9220
|
}
|
|
9101
9221
|
return status;
|
|
9102
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
|
+
});
|
|
9103
9245
|
};
|
|
9104
9246
|
|
|
9105
9247
|
// src/testing/telephony.ts
|