@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.
@@ -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" | "Unavailable" | "Checking" | "Needs attention";
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("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
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("&amp;", "&");
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
- return new Elysia18({
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;
@@ -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("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
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("&amp;", "&");
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
- return new Elysia2({
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/voice",
3
- "version": "0.0.22-beta.74",
3
+ "version": "0.0.22-beta.76",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",