@absolutejs/voice 0.0.22-beta.73 → 0.0.22-beta.75

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
@@ -80,7 +80,8 @@ export type { VoiceOpsTaskLease, VoiceOpsTaskWorker, VoiceOpsTaskWorkerOptions,
80
80
  export type { VoiceS3ReviewStoreClient, VoiceS3ReviewStoreFile, VoiceS3ReviewStoreOptions } from './s3Store';
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
- export { createTwilioMediaStreamBridge, createTwilioVoiceResponse, decodeTwilioMulawBase64, encodeTwilioMulawBase64, transcodePCMToTwilioOutboundPayload, transcodeTwilioInboundPayloadToPCM16 } from './telephony/twilio';
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, TwilioVoiceSetupOptions, TwilioVoiceSetupStatus, TwilioVoiceRoutesOptions } from './telephony/twilio';
84
85
  export { shapeTelephonyAssistantText } from './telephony/response';
85
86
  export type { TelephonyResponseShapeMode, TelephonyResponseShapeOptions } from './telephony/response';
86
87
  export * from './types';
package/dist/index.js CHANGED
@@ -15197,9 +15197,97 @@ var createVoiceSTTRoutingCorrectionHandler = (mode = "generic") => {
15197
15197
  };
15198
15198
  // src/telephony/twilio.ts
15199
15199
  import { Buffer as Buffer3 } from "buffer";
15200
+ import { Elysia as Elysia18 } from "elysia";
15200
15201
  var TWILIO_MULAW_SAMPLE_RATE = 8000;
15201
15202
  var VOICE_PCM_SAMPLE_RATE = 16000;
15202
15203
  var escapeXml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
15204
+ var resolveRequestOrigin = (request) => {
15205
+ const url = new URL(request.url);
15206
+ const forwardedHost = request.headers.get("x-forwarded-host");
15207
+ const forwardedProto = request.headers.get("x-forwarded-proto");
15208
+ const host = forwardedHost ?? request.headers.get("host") ?? url.host;
15209
+ const protocol = forwardedProto ?? url.protocol.replace(":", "");
15210
+ return `${protocol}://${host}`;
15211
+ };
15212
+ var resolveTwilioStreamUrl = async (options, input) => {
15213
+ if (typeof options.twiml?.streamUrl === "function") {
15214
+ return options.twiml.streamUrl(input);
15215
+ }
15216
+ if (typeof options.twiml?.streamUrl === "string") {
15217
+ return options.twiml.streamUrl;
15218
+ }
15219
+ const origin = resolveRequestOrigin(input.request);
15220
+ const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
15221
+ return `${wsOrigin}${input.streamPath}`;
15222
+ };
15223
+ var resolveTwilioStreamParameters = async (parameters, input) => {
15224
+ if (typeof parameters === "function") {
15225
+ return parameters(input);
15226
+ }
15227
+ return parameters;
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>`;
15203
15291
  var normalizeOnTurn2 = (handler) => {
15204
15292
  if (handler.length > 1) {
15205
15293
  const directHandler = handler;
@@ -15576,6 +15664,104 @@ var createTwilioMediaStreamBridge = (socket, options) => {
15576
15664
  }
15577
15665
  };
15578
15666
  };
15667
+ var createTwilioVoiceRoutes = (options) => {
15668
+ const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
15669
+ const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
15670
+ const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
15671
+ const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
15672
+ const bridges = new WeakMap;
15673
+ const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
15674
+ const app = new Elysia18({
15675
+ name: options.name ?? "absolutejs-voice-twilio"
15676
+ }).get(twimlPath, async ({ query, request }) => {
15677
+ const streamUrl = await resolveTwilioStreamUrl(options, {
15678
+ query,
15679
+ request,
15680
+ streamPath
15681
+ });
15682
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
15683
+ query,
15684
+ request
15685
+ });
15686
+ return new Response(createTwilioVoiceResponse({
15687
+ parameters,
15688
+ streamName: options.twiml?.streamName,
15689
+ streamUrl,
15690
+ track: options.twiml?.track
15691
+ }), {
15692
+ headers: {
15693
+ "content-type": "text/xml; charset=utf-8"
15694
+ }
15695
+ });
15696
+ }).post(twimlPath, async ({ query, request }) => {
15697
+ const streamUrl = await resolveTwilioStreamUrl(options, {
15698
+ query,
15699
+ request,
15700
+ streamPath
15701
+ });
15702
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
15703
+ query,
15704
+ request
15705
+ });
15706
+ return new Response(createTwilioVoiceResponse({
15707
+ parameters,
15708
+ streamName: options.twiml?.streamName,
15709
+ streamUrl,
15710
+ track: options.twiml?.track
15711
+ }), {
15712
+ headers: {
15713
+ "content-type": "text/xml; charset=utf-8"
15714
+ }
15715
+ });
15716
+ }).ws(streamPath, {
15717
+ close: async (ws, _code, reason) => {
15718
+ const bridge = bridges.get(ws);
15719
+ bridges.delete(ws);
15720
+ await bridge?.close(reason);
15721
+ },
15722
+ message: async (ws, raw) => {
15723
+ let bridge = bridges.get(ws);
15724
+ if (!bridge) {
15725
+ bridge = createTwilioMediaStreamBridge({
15726
+ close: (code, reason) => {
15727
+ ws.close(code, reason);
15728
+ },
15729
+ send: (data) => {
15730
+ ws.send(data);
15731
+ }
15732
+ }, options);
15733
+ bridges.set(ws, bridge);
15734
+ }
15735
+ await bridge.handleMessage(raw);
15736
+ }
15737
+ }).use(createVoiceTelephonyWebhookRoutes({
15738
+ ...options.webhook ?? {},
15739
+ context: options.context,
15740
+ path: webhookPath,
15741
+ policy: webhookPolicy,
15742
+ provider: "twilio"
15743
+ }));
15744
+ if (!setupPath) {
15745
+ return app;
15746
+ }
15747
+ return app.get(setupPath, async ({ query, request }) => {
15748
+ const status = await buildTwilioVoiceSetupStatus(options, {
15749
+ query,
15750
+ request,
15751
+ streamPath,
15752
+ twimlPath,
15753
+ webhookPath
15754
+ });
15755
+ if (query.format === "html") {
15756
+ return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
15757
+ headers: {
15758
+ "content-type": "text/html; charset=utf-8"
15759
+ }
15760
+ });
15761
+ }
15762
+ return status;
15763
+ });
15764
+ };
15579
15765
  // src/telephony/response.ts
15580
15766
  var normalizeWhitespace = (value) => value.replace(/\s+/g, " ").trim();
15581
15767
  var DEFAULT_MAX_WORDS = 12;
@@ -15871,6 +16057,7 @@ export {
15871
16057
  createVoiceAgentTool,
15872
16058
  createVoiceAgentSquad,
15873
16059
  createVoiceAgent,
16060
+ createTwilioVoiceRoutes,
15874
16061
  createTwilioVoiceResponse,
15875
16062
  createTwilioMediaStreamBridge,
15876
16063
  createStoredVoiceOpsTask,
@@ -1,3 +1,5 @@
1
+ import { Elysia } from 'elysia';
2
+ import { type VoiceTelephonyOutcomePolicy, type VoiceTelephonyWebhookRoutesOptions } from '../telephonyOutcome';
1
3
  import { type VoiceCallReviewArtifact, type VoiceCallReviewConfig } from '../testing/review';
2
4
  import type { AudioFormat, VoiceLogger, VoicePluginConfig, VoiceSessionRecord, VoiceServerMessage } from '../types';
3
5
  type TwilioMediaPayload = {
@@ -107,10 +109,137 @@ export type TwilioVoiceResponseOptions = {
107
109
  streamUrl: string;
108
110
  track?: 'both_tracks' | 'inbound_track' | 'outbound_track';
109
111
  };
112
+ export type TwilioVoiceRouteParameters = Record<string, string | number | boolean | undefined> | ((input: {
113
+ query: Record<string, unknown>;
114
+ request: Request;
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 TwilioVoiceRoutesOptions<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = TwilioMediaStreamBridgeOptions<TContext, TSession, TResult> & {
139
+ name?: string;
140
+ outcomePolicy?: VoiceTelephonyOutcomePolicy;
141
+ setup?: TwilioVoiceSetupOptions;
142
+ streamPath?: string;
143
+ twiml?: {
144
+ parameters?: TwilioVoiceRouteParameters;
145
+ path?: string;
146
+ streamName?: string;
147
+ streamUrl?: string | ((input: {
148
+ query: Record<string, unknown>;
149
+ request: Request;
150
+ streamPath: string;
151
+ }) => Promise<string> | string);
152
+ track?: TwilioVoiceResponseOptions['track'];
153
+ };
154
+ webhook?: Omit<VoiceTelephonyWebhookRoutesOptions<TContext, TSession, TResult>, 'context' | 'path' | 'policy' | 'provider'> & {
155
+ path?: string;
156
+ policy?: VoiceTelephonyOutcomePolicy;
157
+ };
158
+ };
110
159
  export declare const decodeTwilioMulawBase64: (payload: string) => Int16Array<ArrayBuffer>;
111
160
  export declare const encodeTwilioMulawBase64: (samples: Int16Array) => string;
112
161
  export declare const transcodeTwilioInboundPayloadToPCM16: (payload: string) => Uint8Array<ArrayBuffer>;
113
162
  export declare const transcodePCMToTwilioOutboundPayload: (chunk: Uint8Array, format: AudioFormat) => string;
114
163
  export declare const createTwilioVoiceResponse: (options: TwilioVoiceResponseOptions) => string;
115
164
  export declare const createTwilioMediaStreamBridge: <TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown>(socket: TwilioMediaStreamSocket, options: TwilioMediaStreamBridgeOptions<TContext, TSession, TResult>) => TwilioMediaStreamBridge;
165
+ export declare const createTwilioVoiceRoutes: <TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown>(options: TwilioVoiceRoutesOptions<TContext, TSession, TResult>) => Elysia<"", {
166
+ decorator: {};
167
+ store: {};
168
+ derive: {};
169
+ resolve: {};
170
+ }, {
171
+ typebox: {};
172
+ error: {};
173
+ }, {
174
+ schema: {};
175
+ standaloneSchema: {};
176
+ macro: {};
177
+ macroFn: {};
178
+ parser: {};
179
+ response: {};
180
+ }, {
181
+ [x: string]: {
182
+ get: {
183
+ body: unknown;
184
+ params: {};
185
+ query: unknown;
186
+ headers: unknown;
187
+ response: {
188
+ 200: Response;
189
+ };
190
+ };
191
+ };
192
+ } & {
193
+ [x: string]: {
194
+ post: {
195
+ body: unknown;
196
+ params: {};
197
+ query: unknown;
198
+ headers: unknown;
199
+ response: {
200
+ 200: Response;
201
+ };
202
+ };
203
+ };
204
+ } & {
205
+ [x: string]: {
206
+ subscribe: {
207
+ body: unknown;
208
+ params: {};
209
+ query: unknown;
210
+ headers: unknown;
211
+ response: {};
212
+ };
213
+ };
214
+ } & {
215
+ [x: string]: {
216
+ post: {
217
+ body: unknown;
218
+ params: {};
219
+ query: unknown;
220
+ headers: unknown;
221
+ response: {
222
+ 200: Response | import("..").VoiceTelephonyWebhookDecision<TResult>;
223
+ };
224
+ };
225
+ };
226
+ }, {
227
+ derive: {};
228
+ resolve: {};
229
+ schema: {};
230
+ standaloneSchema: {};
231
+ response: {};
232
+ }, {
233
+ derive: {};
234
+ resolve: {};
235
+ schema: {};
236
+ standaloneSchema: {};
237
+ response: {};
238
+ } & {
239
+ derive: {};
240
+ resolve: {};
241
+ schema: {};
242
+ standaloneSchema: {};
243
+ response: {};
244
+ }>;
116
245
  export {};
@@ -4683,7 +4683,7 @@ var createVoiceMemoryStore = () => {
4683
4683
  };
4684
4684
 
4685
4685
  // src/session.ts
4686
- import { Buffer } from "buffer";
4686
+ import { Buffer as Buffer2 } from "buffer";
4687
4687
 
4688
4688
  // src/handoff.ts
4689
4689
  var toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
@@ -5014,7 +5014,7 @@ var createEmptyCurrentTurn = () => ({
5014
5014
  transcripts: []
5015
5015
  });
5016
5016
  var cloneTranscript = (transcript) => ({ ...transcript });
5017
- var encodeBase64 = (chunk) => Buffer.from(chunk).toString("base64");
5017
+ var encodeBase64 = (chunk) => Buffer2.from(chunk).toString("base64");
5018
5018
  var countWords2 = (text) => text.trim().split(/\s+/).filter(Boolean).length;
5019
5019
  var normalizeText2 = (text) => text.trim().replace(/\s+/g, " ");
5020
5020
  var getAudioChunkDurationMs = (chunk) => chunk.byteLength / (DEFAULT_FORMAT.sampleRateHz * DEFAULT_FORMAT.channels * 2) * 1000;
@@ -7866,10 +7866,767 @@ var runVoiceSessionBenchmarkSeries = async (input) => {
7866
7866
  });
7867
7867
  };
7868
7868
  // src/telephony/twilio.ts
7869
- import { Buffer as Buffer2 } from "buffer";
7869
+ import { Buffer as Buffer3 } from "buffer";
7870
+ import { Elysia as Elysia2 } from "elysia";
7871
+
7872
+ // src/telephonyOutcome.ts
7873
+ import { Elysia } from "elysia";
7874
+ var DEFAULT_COMPLETED_STATUSES = [
7875
+ "answered",
7876
+ "completed",
7877
+ "complete",
7878
+ "connected",
7879
+ "in-progress",
7880
+ "live"
7881
+ ];
7882
+ var DEFAULT_NO_ANSWER_STATUSES = [
7883
+ "busy",
7884
+ "canceled",
7885
+ "cancelled",
7886
+ "failed",
7887
+ "no-answer",
7888
+ "no_answer",
7889
+ "not-answered",
7890
+ "ring-no-answer",
7891
+ "timeout",
7892
+ "unanswered"
7893
+ ];
7894
+ var DEFAULT_VOICEMAIL_STATUSES = [
7895
+ "answering-machine",
7896
+ "machine",
7897
+ "voicemail",
7898
+ "voice-mail"
7899
+ ];
7900
+ var DEFAULT_TRANSFER_STATUSES = ["bridged", "forwarded", "transferred"];
7901
+ var DEFAULT_ESCALATION_STATUSES = ["escalated", "human-required", "operator"];
7902
+ var DEFAULT_FAILED_STATUSES = ["busy", "failed", "no-answer"];
7903
+ var DEFAULT_MACHINE_VOICEMAIL_VALUES = [
7904
+ "answering-machine",
7905
+ "fax",
7906
+ "machine",
7907
+ "machine-end-beep",
7908
+ "machine-end-other",
7909
+ "machine-start",
7910
+ "voicemail"
7911
+ ];
7912
+ var DEFAULT_NO_ANSWER_SIP_CODES = [408, 480, 486, 487, 603];
7913
+ var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
7914
+
7915
+ class VoiceTelephonyWebhookVerificationError extends Error {
7916
+ result;
7917
+ constructor(result) {
7918
+ super(result.ok ? "telephony webhook verified" : result.reason);
7919
+ this.name = "VoiceTelephonyWebhookVerificationError";
7920
+ this.result = result;
7921
+ }
7922
+ }
7923
+ var createMemoryVoiceTelephonyWebhookIdempotencyStore = () => {
7924
+ const decisions = new Map;
7925
+ return {
7926
+ get: (key) => decisions.get(key),
7927
+ set: (key, decision) => {
7928
+ decisions.set(key, decision);
7929
+ }
7930
+ };
7931
+ };
7932
+ var normalizeToken = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/\s+/g, "-").replace(/_+/g, "-") : undefined;
7933
+ var firstString = (source, keys) => {
7934
+ for (const key of keys) {
7935
+ const value = source[key];
7936
+ if (typeof value === "string" && value.trim()) {
7937
+ return value.trim();
7938
+ }
7939
+ if (typeof value === "number" && Number.isFinite(value)) {
7940
+ return String(value);
7941
+ }
7942
+ }
7943
+ };
7944
+ var firstNumber = (source, keys) => {
7945
+ for (const key of keys) {
7946
+ const value = source[key];
7947
+ if (typeof value === "number" && Number.isFinite(value)) {
7948
+ return value;
7949
+ }
7950
+ if (typeof value === "string" && value.trim()) {
7951
+ const parsed = Number(value);
7952
+ if (Number.isFinite(parsed)) {
7953
+ return parsed;
7954
+ }
7955
+ }
7956
+ }
7957
+ };
7958
+ var parseMaybeJSON = (value) => {
7959
+ try {
7960
+ return JSON.parse(value);
7961
+ } catch {
7962
+ return;
7963
+ }
7964
+ };
7965
+ var flattenPayload = (value) => {
7966
+ if (!isRecord(value)) {
7967
+ return {};
7968
+ }
7969
+ const data = isRecord(value.data) ? value.data : undefined;
7970
+ const payload = isRecord(value.payload) ? value.payload : undefined;
7971
+ const event = isRecord(value.event) ? value.event : undefined;
7972
+ return {
7973
+ ...value,
7974
+ ...payload,
7975
+ ...event,
7976
+ ...data,
7977
+ ...isRecord(data?.payload) ? data.payload : undefined
7978
+ };
7979
+ };
7980
+ var toBase64 = (bytes) => Buffer.from(new Uint8Array(bytes)).toString("base64");
7981
+ var timingSafeEqual = (left, right) => {
7982
+ const encoder = new TextEncoder;
7983
+ const leftBytes = encoder.encode(left);
7984
+ const rightBytes = encoder.encode(right);
7985
+ if (leftBytes.length !== rightBytes.length) {
7986
+ return false;
7987
+ }
7988
+ let diff = 0;
7989
+ for (let index = 0;index < leftBytes.length; index += 1) {
7990
+ diff |= leftBytes[index] ^ rightBytes[index];
7991
+ }
7992
+ return diff === 0;
7993
+ };
7994
+ var signHmacSHA1Base64 = async (secret, payload) => {
7995
+ const encoder = new TextEncoder;
7996
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
7997
+ hash: "SHA-1",
7998
+ name: "HMAC"
7999
+ }, false, ["sign"]);
8000
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
8001
+ return toBase64(signature);
8002
+ };
8003
+ var sortedParamsForSignature = (body) => Object.entries(flattenPayload(body)).filter(([, value]) => value !== undefined && value !== null).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => `${key}${String(value)}`).join("");
8004
+ var normalizeList = (values, fallback) => new Set((values ?? fallback).map(normalizeToken).filter(Boolean));
8005
+ var metadataValue = (metadata, keys) => {
8006
+ for (const key of keys) {
8007
+ const value = metadata?.[key];
8008
+ if (typeof value === "string" && value.trim()) {
8009
+ return value.trim();
8010
+ }
8011
+ }
8012
+ };
8013
+ var resolveTransferTarget = (event, policy) => {
8014
+ if (typeof event.target === "string" && event.target.trim()) {
8015
+ return event.target.trim();
8016
+ }
8017
+ const metadataTarget = metadataValue(event.metadata, [
8018
+ "transferTarget",
8019
+ "target",
8020
+ "queue",
8021
+ "department"
8022
+ ]);
8023
+ if (metadataTarget) {
8024
+ return metadataTarget;
8025
+ }
8026
+ if (typeof policy.transferTarget === "function") {
8027
+ const target = policy.transferTarget(event);
8028
+ return typeof target === "string" && target.trim() ? target.trim() : undefined;
8029
+ }
8030
+ return typeof policy.transferTarget === "string" && policy.transferTarget.trim() ? policy.transferTarget.trim() : undefined;
8031
+ };
8032
+ var mergeMetadata = (event, policy) => ({
8033
+ ...policy.includeProviderPayload ? {
8034
+ answeredBy: event.answeredBy,
8035
+ durationMs: event.durationMs,
8036
+ provider: event.provider,
8037
+ reason: event.reason,
8038
+ sipCode: event.sipCode,
8039
+ status: event.status
8040
+ } : undefined,
8041
+ ...policy.metadata,
8042
+ ...event.metadata
8043
+ });
8044
+ var withDecisionDefaults = (decision, input) => {
8045
+ if (typeof decision === "string") {
8046
+ return buildDecision(decision, input);
8047
+ }
8048
+ return {
8049
+ ...buildDecision(decision.action, input),
8050
+ ...decision,
8051
+ confidence: decision.confidence ?? "high",
8052
+ metadata: {
8053
+ ...mergeMetadata(input.event, input.policy),
8054
+ ...decision.metadata
8055
+ },
8056
+ source: decision.source ?? input.source,
8057
+ target: decision.target ?? (decision.action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined)
8058
+ };
8059
+ };
8060
+ var dispositionForAction = (action) => {
8061
+ switch (action) {
8062
+ case "complete":
8063
+ return "completed";
8064
+ case "escalate":
8065
+ return "escalated";
8066
+ case "no-answer":
8067
+ return "no-answer";
8068
+ case "transfer":
8069
+ return "transferred";
8070
+ case "voicemail":
8071
+ return "voicemail";
8072
+ default:
8073
+ return;
8074
+ }
8075
+ };
8076
+ var buildDecision = (action, input) => ({
8077
+ action,
8078
+ confidence: action === "ignore" ? "low" : "high",
8079
+ disposition: dispositionForAction(action),
8080
+ metadata: mergeMetadata(input.event, input.policy),
8081
+ reason: input.event.reason,
8082
+ source: input.source,
8083
+ target: action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined
8084
+ });
8085
+ var createVoiceTelephonyOutcomePolicy = (policy = {}) => ({
8086
+ completedStatuses: policy.completedStatuses ?? DEFAULT_COMPLETED_STATUSES,
8087
+ escalationStatuses: policy.escalationStatuses ?? DEFAULT_ESCALATION_STATUSES,
8088
+ failedAsNoAnswer: policy.failedAsNoAnswer ?? true,
8089
+ failedStatuses: policy.failedStatuses ?? DEFAULT_FAILED_STATUSES,
8090
+ includeProviderPayload: policy.includeProviderPayload ?? true,
8091
+ machineDetectionVoicemailValues: policy.machineDetectionVoicemailValues ?? DEFAULT_MACHINE_VOICEMAIL_VALUES,
8092
+ metadata: policy.metadata,
8093
+ minAnsweredDurationMs: policy.minAnsweredDurationMs,
8094
+ noAnswerOnZeroDuration: policy.noAnswerOnZeroDuration ?? true,
8095
+ noAnswerSipCodes: policy.noAnswerSipCodes ?? DEFAULT_NO_ANSWER_SIP_CODES,
8096
+ noAnswerStatuses: policy.noAnswerStatuses ?? DEFAULT_NO_ANSWER_STATUSES,
8097
+ statusMap: policy.statusMap,
8098
+ transferStatuses: policy.transferStatuses ?? DEFAULT_TRANSFER_STATUSES,
8099
+ transferTarget: policy.transferTarget,
8100
+ voicemailStatuses: policy.voicemailStatuses ?? DEFAULT_VOICEMAIL_STATUSES
8101
+ });
8102
+ var resolveVoiceTelephonyOutcome = (event, policyInput = {}) => {
8103
+ const policy = createVoiceTelephonyOutcomePolicy(policyInput);
8104
+ const status = normalizeToken(event.status);
8105
+ const provider = normalizeToken(event.provider);
8106
+ const answeredBy = normalizeToken(event.answeredBy);
8107
+ const target = resolveTransferTarget(event, policy);
8108
+ if (status) {
8109
+ const mapped = policy.statusMap?.[status] ?? (provider ? policy.statusMap?.[`${provider}:${status}`] : undefined);
8110
+ if (mapped) {
8111
+ return withDecisionDefaults(mapped, {
8112
+ event,
8113
+ policy,
8114
+ source: "policy"
8115
+ });
8116
+ }
8117
+ }
8118
+ if (answeredBy && normalizeList(policy.machineDetectionVoicemailValues, []).has(answeredBy)) {
8119
+ return buildDecision("voicemail", { event, policy, source: "answered-by" });
8120
+ }
8121
+ if (typeof event.sipCode === "number" && policy.noAnswerSipCodes.includes(event.sipCode)) {
8122
+ return buildDecision("no-answer", { event, policy, source: "sip" });
8123
+ }
8124
+ if (target && status && normalizeList(policy.transferStatuses, []).has(status)) {
8125
+ return buildDecision("transfer", { event, policy, source: "status" });
8126
+ }
8127
+ if (status && normalizeList(policy.voicemailStatuses, []).has(status)) {
8128
+ return buildDecision("voicemail", { event, policy, source: "status" });
8129
+ }
8130
+ if (status && normalizeList(policy.escalationStatuses, []).has(status)) {
8131
+ return buildDecision("escalate", { event, policy, source: "status" });
8132
+ }
8133
+ if (status && (policy.failedAsNoAnswer ? normalizeList(policy.noAnswerStatuses, []).has(status) || normalizeList(policy.failedStatuses, []).has(status) : normalizeList(policy.noAnswerStatuses, []).has(status))) {
8134
+ return buildDecision("no-answer", { event, policy, source: "status" });
8135
+ }
8136
+ if (policy.noAnswerOnZeroDuration && typeof event.durationMs === "number" && event.durationMs <= 0) {
8137
+ return buildDecision("no-answer", { event, policy, source: "duration" });
8138
+ }
8139
+ if (typeof policy.minAnsweredDurationMs === "number" && typeof event.durationMs === "number" && event.durationMs < policy.minAnsweredDurationMs) {
8140
+ return {
8141
+ ...buildDecision("no-answer", { event, policy, source: "duration" }),
8142
+ confidence: "medium"
8143
+ };
8144
+ }
8145
+ if (status && normalizeList(policy.completedStatuses, []).has(status)) {
8146
+ return buildDecision("complete", { event, policy, source: "status" });
8147
+ }
8148
+ if (target) {
8149
+ return {
8150
+ ...buildDecision("transfer", { event, policy, source: "explicit-target" }),
8151
+ confidence: "medium"
8152
+ };
8153
+ }
8154
+ return buildDecision("ignore", { event, policy, source: "status" });
8155
+ };
8156
+ var voiceTelephonyOutcomeToRouteResult = (decision, result) => {
8157
+ switch (decision.action) {
8158
+ case "complete":
8159
+ return { complete: true, result };
8160
+ case "escalate":
8161
+ return {
8162
+ escalate: {
8163
+ metadata: decision.metadata,
8164
+ reason: decision.reason ?? "telephony-escalation"
8165
+ },
8166
+ result
8167
+ };
8168
+ case "no-answer":
8169
+ return {
8170
+ noAnswer: {
8171
+ metadata: decision.metadata
8172
+ },
8173
+ result
8174
+ };
8175
+ case "transfer":
8176
+ if (!decision.target) {
8177
+ return { result };
8178
+ }
8179
+ return {
8180
+ result,
8181
+ transfer: {
8182
+ metadata: decision.metadata,
8183
+ reason: decision.reason,
8184
+ target: decision.target
8185
+ }
8186
+ };
8187
+ case "voicemail":
8188
+ return {
8189
+ result,
8190
+ voicemail: {
8191
+ metadata: decision.metadata
8192
+ }
8193
+ };
8194
+ default:
8195
+ return { result };
8196
+ }
8197
+ };
8198
+ var applyVoiceTelephonyOutcome = async (api, decision, result) => {
8199
+ switch (decision.action) {
8200
+ case "complete":
8201
+ await api.complete(result);
8202
+ break;
8203
+ case "escalate":
8204
+ await api.escalate({
8205
+ metadata: decision.metadata,
8206
+ reason: decision.reason ?? "telephony-escalation",
8207
+ result
8208
+ });
8209
+ break;
8210
+ case "no-answer":
8211
+ await api.markNoAnswer({
8212
+ metadata: decision.metadata,
8213
+ result
8214
+ });
8215
+ break;
8216
+ case "transfer":
8217
+ if (!decision.target) {
8218
+ return;
8219
+ }
8220
+ await api.transfer({
8221
+ metadata: decision.metadata,
8222
+ reason: decision.reason,
8223
+ result,
8224
+ target: decision.target
8225
+ });
8226
+ break;
8227
+ case "voicemail":
8228
+ await api.markVoicemail({
8229
+ metadata: decision.metadata,
8230
+ result
8231
+ });
8232
+ break;
8233
+ default:
8234
+ break;
8235
+ }
8236
+ };
8237
+ var parseRequestBodyText = (input) => {
8238
+ const { contentType, text } = input;
8239
+ if (!text) {
8240
+ return {};
8241
+ }
8242
+ if (contentType.includes("application/json")) {
8243
+ return parseMaybeJSON(text) ?? {};
8244
+ }
8245
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
8246
+ return Object.fromEntries(new URLSearchParams(text));
8247
+ }
8248
+ return parseMaybeJSON(text) ?? Object.fromEntries(new URLSearchParams(text));
8249
+ };
8250
+ var readRequestBody = async (request) => {
8251
+ const contentType = request.headers.get("content-type") ?? "";
8252
+ const text = await request.text();
8253
+ return {
8254
+ body: parseRequestBodyText({ contentType, text }),
8255
+ rawBody: text
8256
+ };
8257
+ };
8258
+ var signVoiceTwilioWebhook = async (input) => signHmacSHA1Base64(input.authToken, `${input.url}${sortedParamsForSignature(input.body ?? {})}`);
8259
+ var verifyVoiceTwilioWebhookSignature = async (input) => {
8260
+ if (!input.authToken) {
8261
+ return { ok: false, reason: "missing-secret" };
8262
+ }
8263
+ const signature = input.headers.get("x-twilio-signature");
8264
+ if (!signature) {
8265
+ return { ok: false, reason: "missing-signature" };
8266
+ }
8267
+ const expected = await signVoiceTwilioWebhook({
8268
+ authToken: input.authToken,
8269
+ body: input.body,
8270
+ url: input.url
8271
+ });
8272
+ return timingSafeEqual(signature, expected) ? { ok: true } : { ok: false, reason: "invalid-signature" };
8273
+ };
8274
+ var resolveVerificationUrl = (option, input) => typeof option === "function" ? option(input) : option ?? input.request.url;
8275
+ var verifyVoiceTelephonyWebhook = async (input) => {
8276
+ if (input.options.verify) {
8277
+ return input.options.verify({
8278
+ body: input.body,
8279
+ headers: input.request.headers,
8280
+ provider: input.provider,
8281
+ query: input.query,
8282
+ rawBody: input.rawBody,
8283
+ request: input.request
8284
+ });
8285
+ }
8286
+ if (!input.options.signingSecret) {
8287
+ return input.options.requireVerification ? { ok: false, reason: "missing-secret" } : { ok: true };
8288
+ }
8289
+ if (input.provider !== "twilio") {
8290
+ return { ok: false, reason: "unsupported-provider" };
8291
+ }
8292
+ return verifyVoiceTwilioWebhookSignature({
8293
+ authToken: input.options.signingSecret,
8294
+ body: input.body,
8295
+ headers: input.request.headers,
8296
+ url: resolveVerificationUrl(input.options.verificationUrl, {
8297
+ query: input.query,
8298
+ request: input.request
8299
+ })
8300
+ });
8301
+ };
8302
+ var durationMsFromSeconds = (value) => typeof value === "number" ? value * 1000 : undefined;
8303
+ var parseVoiceTelephonyWebhookEvent = (input) => {
8304
+ const payload = flattenPayload(input.body);
8305
+ const provider = firstString(payload, ["provider", "Provider"]) ?? input.provider;
8306
+ const status = firstString(payload, [
8307
+ "CallStatus",
8308
+ "call_status",
8309
+ "callStatus",
8310
+ "DialCallStatus",
8311
+ "dial_call_status",
8312
+ "status",
8313
+ "event_type",
8314
+ "type"
8315
+ ]);
8316
+ const durationMs = firstNumber(payload, ["durationMs", "duration_ms"]) ?? durationMsFromSeconds(firstNumber(payload, [
8317
+ "CallDuration",
8318
+ "call_duration",
8319
+ "callDuration",
8320
+ "DialCallDuration",
8321
+ "dial_call_duration",
8322
+ "duration"
8323
+ ]));
8324
+ const sipCode = firstNumber(payload, [
8325
+ "SipResponseCode",
8326
+ "sip_response_code",
8327
+ "sipCode",
8328
+ "sip_code",
8329
+ "hangupCauseCode"
8330
+ ]);
8331
+ const from = firstString(payload, ["From", "from", "caller_id", "callerId"]);
8332
+ const to = firstString(payload, ["To", "to", "called_number", "calledNumber"]);
8333
+ const target = firstString(payload, [
8334
+ "transferTarget",
8335
+ "TransferTarget",
8336
+ "target",
8337
+ "queue",
8338
+ "department"
8339
+ ]);
8340
+ return {
8341
+ answeredBy: firstString(payload, [
8342
+ "AnsweredBy",
8343
+ "answered_by",
8344
+ "answeredBy",
8345
+ "machineDetection",
8346
+ "machine_detection"
8347
+ ]),
8348
+ durationMs,
8349
+ from,
8350
+ metadata: payload,
8351
+ provider,
8352
+ reason: firstString(payload, [
8353
+ "Reason",
8354
+ "reason",
8355
+ "HangupCause",
8356
+ "hangup_cause",
8357
+ "hangupCause"
8358
+ ]),
8359
+ sipCode,
8360
+ status,
8361
+ target,
8362
+ to
8363
+ };
8364
+ };
8365
+ var defaultSessionId = (input) => {
8366
+ const payload = flattenPayload(input.body);
8367
+ const metadataSessionId = input.event.metadata?.sessionId;
8368
+ return firstString(input.query, ["sessionId", "session_id"]) ?? firstString(payload, [
8369
+ "sessionId",
8370
+ "session_id",
8371
+ "SessionId",
8372
+ "CallSid",
8373
+ "call_sid",
8374
+ "callSid",
8375
+ "CallUUID",
8376
+ "call_uuid",
8377
+ "callControlId",
8378
+ "call_control_id"
8379
+ ]) ?? (typeof metadataSessionId === "string" ? metadataSessionId : undefined);
8380
+ };
8381
+ var defaultIdempotencyKey = (input) => {
8382
+ const payload = flattenPayload(input.body);
8383
+ const eventId = firstString(payload, [
8384
+ "id",
8385
+ "event_id",
8386
+ "eventId",
8387
+ "EventSid",
8388
+ "event_sid",
8389
+ "MessageSid",
8390
+ "message_sid",
8391
+ "CallSid",
8392
+ "call_sid",
8393
+ "CallUUID",
8394
+ "call_uuid",
8395
+ "callControlId",
8396
+ "call_control_id"
8397
+ ]);
8398
+ const status = normalizeToken(input.event.status) ?? "unknown";
8399
+ if (eventId) {
8400
+ return `${input.provider}:${eventId}:${status}`;
8401
+ }
8402
+ if (input.sessionId) {
8403
+ return `${input.provider}:${input.sessionId}:${status}`;
8404
+ }
8405
+ };
8406
+ var createVoiceTelephonyWebhookHandler = (options = {}) => async (input) => {
8407
+ const provider = options.provider ?? "generic";
8408
+ const query = input.query ?? {};
8409
+ const { body, rawBody } = await readRequestBody(input.request);
8410
+ const verification = await verifyVoiceTelephonyWebhook({
8411
+ body,
8412
+ options,
8413
+ provider,
8414
+ query,
8415
+ rawBody,
8416
+ request: input.request
8417
+ });
8418
+ if (!verification.ok) {
8419
+ throw new VoiceTelephonyWebhookVerificationError(verification);
8420
+ }
8421
+ const event = options.parse ? await options.parse({
8422
+ body,
8423
+ headers: input.request.headers,
8424
+ provider,
8425
+ query,
8426
+ request: input.request
8427
+ }) : parseVoiceTelephonyWebhookEvent({
8428
+ body,
8429
+ headers: input.request.headers,
8430
+ provider,
8431
+ query,
8432
+ request: input.request
8433
+ });
8434
+ const sessionId = await (options.resolveSessionId?.({
8435
+ body,
8436
+ event,
8437
+ query,
8438
+ request: input.request
8439
+ }) ?? defaultSessionId({ body, event, query }));
8440
+ const idempotencyEnabled = options.idempotency?.enabled !== false;
8441
+ const idempotencyKey = idempotencyEnabled ? await (options.idempotency?.key?.({
8442
+ body,
8443
+ event,
8444
+ provider,
8445
+ query,
8446
+ request: input.request,
8447
+ sessionId
8448
+ }) ?? defaultIdempotencyKey({ body, event, provider, sessionId })) : undefined;
8449
+ const idempotencyStore = options.idempotency?.store;
8450
+ if (idempotencyKey && idempotencyStore) {
8451
+ const existing = await idempotencyStore.get(idempotencyKey);
8452
+ if (existing) {
8453
+ const duplicateDecision = {
8454
+ ...existing,
8455
+ duplicate: true
8456
+ };
8457
+ await options.onDecision?.({
8458
+ ...duplicateDecision,
8459
+ context: options.context,
8460
+ request: input.request
8461
+ });
8462
+ return duplicateDecision;
8463
+ }
8464
+ }
8465
+ const decision = resolveVoiceTelephonyOutcome(event, options.policy);
8466
+ const resultResolver = options.result;
8467
+ const result = typeof resultResolver === "function" ? await resultResolver({
8468
+ decision,
8469
+ event,
8470
+ sessionId
8471
+ }) : resultResolver;
8472
+ const routeResult = voiceTelephonyOutcomeToRouteResult(decision, result);
8473
+ const shouldApply = typeof options.apply === "function" ? options.apply({
8474
+ applied: false,
8475
+ decision,
8476
+ event,
8477
+ routeResult,
8478
+ sessionId
8479
+ }) : options.apply === true;
8480
+ let applied = false;
8481
+ if (shouldApply && decision.action !== "ignore" && options.getSessionHandle) {
8482
+ const api = await options.getSessionHandle({
8483
+ context: options.context,
8484
+ decision,
8485
+ event,
8486
+ request: input.request,
8487
+ sessionId
8488
+ });
8489
+ if (api) {
8490
+ await applyVoiceTelephonyOutcome(api, decision, result);
8491
+ applied = true;
8492
+ }
8493
+ }
8494
+ const webhookDecision = {
8495
+ applied,
8496
+ decision,
8497
+ event,
8498
+ idempotencyKey,
8499
+ routeResult,
8500
+ sessionId
8501
+ };
8502
+ if (idempotencyKey && idempotencyStore) {
8503
+ const now = Date.now();
8504
+ await idempotencyStore.set(idempotencyKey, {
8505
+ ...webhookDecision,
8506
+ createdAt: now,
8507
+ updatedAt: now
8508
+ });
8509
+ }
8510
+ await options.onDecision?.({
8511
+ ...webhookDecision,
8512
+ context: options.context,
8513
+ request: input.request
8514
+ });
8515
+ return webhookDecision;
8516
+ };
8517
+ var createVoiceTelephonyWebhookRoutes = (options = {}) => {
8518
+ const path = options.path ?? "/api/voice/telephony/webhook";
8519
+ const handler = createVoiceTelephonyWebhookHandler(options);
8520
+ return new Elysia({
8521
+ name: options.name ?? "absolutejs-voice-telephony-webhooks"
8522
+ }).post(path, async ({ query, request }) => {
8523
+ try {
8524
+ return await handler({ query, request });
8525
+ } catch (error) {
8526
+ if (error instanceof VoiceTelephonyWebhookVerificationError) {
8527
+ return new Response(JSON.stringify({ verification: error.result }), {
8528
+ headers: {
8529
+ "content-type": "application/json"
8530
+ },
8531
+ status: 401
8532
+ });
8533
+ }
8534
+ throw error;
8535
+ }
8536
+ });
8537
+ };
8538
+
8539
+ // src/telephony/twilio.ts
7870
8540
  var TWILIO_MULAW_SAMPLE_RATE = 8000;
7871
8541
  var VOICE_PCM_SAMPLE_RATE = 16000;
7872
8542
  var escapeXml2 = (value) => value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&apos;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
8543
+ var resolveRequestOrigin = (request) => {
8544
+ const url = new URL(request.url);
8545
+ const forwardedHost = request.headers.get("x-forwarded-host");
8546
+ const forwardedProto = request.headers.get("x-forwarded-proto");
8547
+ const host = forwardedHost ?? request.headers.get("host") ?? url.host;
8548
+ const protocol = forwardedProto ?? url.protocol.replace(":", "");
8549
+ return `${protocol}://${host}`;
8550
+ };
8551
+ var resolveTwilioStreamUrl = async (options, input) => {
8552
+ if (typeof options.twiml?.streamUrl === "function") {
8553
+ return options.twiml.streamUrl(input);
8554
+ }
8555
+ if (typeof options.twiml?.streamUrl === "string") {
8556
+ return options.twiml.streamUrl;
8557
+ }
8558
+ const origin = resolveRequestOrigin(input.request);
8559
+ const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
8560
+ return `${wsOrigin}${input.streamPath}`;
8561
+ };
8562
+ var resolveTwilioStreamParameters = async (parameters, input) => {
8563
+ if (typeof parameters === "function") {
8564
+ return parameters(input);
8565
+ }
8566
+ return parameters;
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>`;
7873
8630
  var normalizeOnTurn = (handler) => {
7874
8631
  if (handler.length > 1) {
7875
8632
  const directHandler = handler;
@@ -7971,7 +8728,7 @@ var bytesToInt16Array = (bytes) => {
7971
8728
  return output;
7972
8729
  };
7973
8730
  var decodeTwilioMulawBase64 = (payload) => {
7974
- const bytes = Uint8Array.from(Buffer2.from(payload, "base64"));
8731
+ const bytes = Uint8Array.from(Buffer3.from(payload, "base64"));
7975
8732
  const samples = new Int16Array(bytes.length);
7976
8733
  for (let index = 0;index < bytes.length; index += 1) {
7977
8734
  samples[index] = decodeMulawSample(bytes[index] ?? 0);
@@ -7983,7 +8740,7 @@ var encodeTwilioMulawBase64 = (samples) => {
7983
8740
  for (let index = 0;index < samples.length; index += 1) {
7984
8741
  bytes[index] = encodeMulawSample(samples[index] ?? 0);
7985
8742
  }
7986
- return Buffer2.from(bytes).toString("base64");
8743
+ return Buffer3.from(bytes).toString("base64");
7987
8744
  };
7988
8745
  var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
7989
8746
  const narrowband = decodeTwilioMulawBase64(payload);
@@ -7992,7 +8749,7 @@ var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
7992
8749
  };
7993
8750
  var transcodePCMToTwilioOutboundPayload = (chunk, format) => {
7994
8751
  if (format.container === "raw" && format.encoding === "mulaw" && format.channels === 1 && format.sampleRateHz === TWILIO_MULAW_SAMPLE_RATE) {
7995
- return Buffer2.from(chunk).toString("base64");
8752
+ return Buffer3.from(chunk).toString("base64");
7996
8753
  }
7997
8754
  if (format.encoding !== "pcm_s16le") {
7998
8755
  throw new Error(`Unsupported outbound telephony audio format: ${format.container}/${format.encoding}`);
@@ -8033,7 +8790,7 @@ var createTwilioSocketAdapter = (socket, getState) => ({
8033
8790
  return;
8034
8791
  }
8035
8792
  if (message.type === "audio") {
8036
- const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer2.from(message.chunkBase64, "base64")), message.format);
8793
+ const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer3.from(message.chunkBase64, "base64")), message.format);
8037
8794
  state.hasOutboundAudioSinceLastInbound = true;
8038
8795
  state.reviewRecorder?.recordTwilioOutbound({
8039
8796
  bytes: payload.length,
@@ -8246,6 +9003,104 @@ var createTwilioMediaStreamBridge = (socket, options) => {
8246
9003
  }
8247
9004
  };
8248
9005
  };
9006
+ var createTwilioVoiceRoutes = (options) => {
9007
+ const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
9008
+ const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
9009
+ const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
9010
+ const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
9011
+ const bridges = new WeakMap;
9012
+ const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
9013
+ const app = new Elysia2({
9014
+ name: options.name ?? "absolutejs-voice-twilio"
9015
+ }).get(twimlPath, async ({ query, request }) => {
9016
+ const streamUrl = await resolveTwilioStreamUrl(options, {
9017
+ query,
9018
+ request,
9019
+ streamPath
9020
+ });
9021
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
9022
+ query,
9023
+ request
9024
+ });
9025
+ return new Response(createTwilioVoiceResponse({
9026
+ parameters,
9027
+ streamName: options.twiml?.streamName,
9028
+ streamUrl,
9029
+ track: options.twiml?.track
9030
+ }), {
9031
+ headers: {
9032
+ "content-type": "text/xml; charset=utf-8"
9033
+ }
9034
+ });
9035
+ }).post(twimlPath, async ({ query, request }) => {
9036
+ const streamUrl = await resolveTwilioStreamUrl(options, {
9037
+ query,
9038
+ request,
9039
+ streamPath
9040
+ });
9041
+ const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
9042
+ query,
9043
+ request
9044
+ });
9045
+ return new Response(createTwilioVoiceResponse({
9046
+ parameters,
9047
+ streamName: options.twiml?.streamName,
9048
+ streamUrl,
9049
+ track: options.twiml?.track
9050
+ }), {
9051
+ headers: {
9052
+ "content-type": "text/xml; charset=utf-8"
9053
+ }
9054
+ });
9055
+ }).ws(streamPath, {
9056
+ close: async (ws, _code, reason) => {
9057
+ const bridge = bridges.get(ws);
9058
+ bridges.delete(ws);
9059
+ await bridge?.close(reason);
9060
+ },
9061
+ message: async (ws, raw) => {
9062
+ let bridge = bridges.get(ws);
9063
+ if (!bridge) {
9064
+ bridge = createTwilioMediaStreamBridge({
9065
+ close: (code, reason) => {
9066
+ ws.close(code, reason);
9067
+ },
9068
+ send: (data) => {
9069
+ ws.send(data);
9070
+ }
9071
+ }, options);
9072
+ bridges.set(ws, bridge);
9073
+ }
9074
+ await bridge.handleMessage(raw);
9075
+ }
9076
+ }).use(createVoiceTelephonyWebhookRoutes({
9077
+ ...options.webhook ?? {},
9078
+ context: options.context,
9079
+ path: webhookPath,
9080
+ policy: webhookPolicy,
9081
+ provider: "twilio"
9082
+ }));
9083
+ if (!setupPath) {
9084
+ return app;
9085
+ }
9086
+ return app.get(setupPath, async ({ query, request }) => {
9087
+ const status = await buildTwilioVoiceSetupStatus(options, {
9088
+ query,
9089
+ request,
9090
+ streamPath,
9091
+ twimlPath,
9092
+ webhookPath
9093
+ });
9094
+ if (query.format === "html") {
9095
+ return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
9096
+ headers: {
9097
+ "content-type": "text/html; charset=utf-8"
9098
+ }
9099
+ });
9100
+ }
9101
+ return status;
9102
+ });
9103
+ };
8249
9104
 
8250
9105
  // src/testing/telephony.ts
8251
9106
  var DEFAULT_PCM16_FORMAT = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/voice",
3
- "version": "0.0.22-beta.73",
3
+ "version": "0.0.22-beta.75",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",