@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.
- package/dist/client/opsStatusWidget.d.ts +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +187 -0
- package/dist/telephony/twilio.d.ts +129 -0
- package/dist/testing/index.js +862 -7
- package/package.json +1 -1
|
@@ -29,7 +29,7 @@ export type VoiceOpsStatusWidgetOptions = VoiceAppKitStatusClientOptions & {
|
|
|
29
29
|
includeLinks?: boolean;
|
|
30
30
|
title?: string;
|
|
31
31
|
};
|
|
32
|
-
export declare const getVoiceOpsStatusLabel: (report?: VoiceAppKitStatusReport | null, error?: string | null) => "Passing" | "
|
|
32
|
+
export declare const getVoiceOpsStatusLabel: (report?: VoiceAppKitStatusReport | null, error?: string | null) => "Passing" | "Needs attention" | "Unavailable" | "Checking";
|
|
33
33
|
export declare const createVoiceOpsStatusViewModel: (snapshot: VoiceAppKitStatusSnapshot, options?: VoiceOpsStatusWidgetOptions) => VoiceOpsStatusViewModel;
|
|
34
34
|
export declare const renderVoiceOpsStatusHTML: (snapshot: VoiceAppKitStatusSnapshot, options?: VoiceOpsStatusWidgetOptions) => string;
|
|
35
35
|
export declare const getVoiceOpsStatusCSS: () => string;
|
package/dist/index.d.ts
CHANGED
|
@@ -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("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
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("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
15231
|
+
var getWebhookVerificationUrl = (webhook, input) => {
|
|
15232
|
+
if (!webhook?.verificationUrl) {
|
|
15233
|
+
return;
|
|
15234
|
+
}
|
|
15235
|
+
if (typeof webhook.verificationUrl === "function") {
|
|
15236
|
+
return webhook.verificationUrl(input);
|
|
15237
|
+
}
|
|
15238
|
+
return webhook.verificationUrl;
|
|
15239
|
+
};
|
|
15240
|
+
var buildTwilioVoiceSetupStatus = async (options, input) => {
|
|
15241
|
+
const origin = resolveRequestOrigin(input.request);
|
|
15242
|
+
const stream = await resolveTwilioStreamUrl(options, input);
|
|
15243
|
+
const twiml = joinUrlPath(origin, input.twimlPath);
|
|
15244
|
+
const webhook = joinUrlPath(origin, input.webhookPath);
|
|
15245
|
+
const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
|
|
15246
|
+
const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
|
|
15247
|
+
const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
|
|
15248
|
+
const warnings = [
|
|
15249
|
+
...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
|
|
15250
|
+
...signingConfigured ? [] : ["Webhook signature verification is not configured."],
|
|
15251
|
+
...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
|
|
15252
|
+
];
|
|
15253
|
+
return {
|
|
15254
|
+
generatedAt: Date.now(),
|
|
15255
|
+
missing,
|
|
15256
|
+
provider: "twilio",
|
|
15257
|
+
ready: missing.length === 0 && signingConfigured && warnings.length === 0,
|
|
15258
|
+
signing: {
|
|
15259
|
+
configured: signingConfigured,
|
|
15260
|
+
mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
|
|
15261
|
+
verificationUrl
|
|
15262
|
+
},
|
|
15263
|
+
urls: {
|
|
15264
|
+
stream,
|
|
15265
|
+
twiml,
|
|
15266
|
+
webhook
|
|
15267
|
+
},
|
|
15268
|
+
warnings
|
|
15269
|
+
};
|
|
15270
|
+
};
|
|
15271
|
+
var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
15272
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
|
|
15273
|
+
<h1>${escapeHtml17(title)}</h1>
|
|
15274
|
+
<p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
|
|
15275
|
+
<section>
|
|
15276
|
+
<h2>URLs</h2>
|
|
15277
|
+
<ul>
|
|
15278
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml17(status.urls.twiml)}</code></li>
|
|
15279
|
+
<li><strong>Media stream:</strong> <code>${escapeHtml17(status.urls.stream)}</code></li>
|
|
15280
|
+
<li><strong>Status webhook:</strong> <code>${escapeHtml17(status.urls.webhook)}</code></li>
|
|
15281
|
+
</ul>
|
|
15282
|
+
</section>
|
|
15283
|
+
<section>
|
|
15284
|
+
<h2>Signing</h2>
|
|
15285
|
+
<p>Mode: <code>${status.signing.mode}</code></p>
|
|
15286
|
+
${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml17(status.signing.verificationUrl)}</code></p>` : ""}
|
|
15287
|
+
</section>
|
|
15288
|
+
${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml17(name)}</code></li>`).join("")}</ul></section>` : ""}
|
|
15289
|
+
${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml17(warning)}</li>`).join("")}</ul></section>` : ""}
|
|
15290
|
+
</main>`;
|
|
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 {};
|
package/dist/testing/index.js
CHANGED
|
@@ -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) =>
|
|
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
|
|
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("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
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("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
8570
|
+
var getWebhookVerificationUrl = (webhook, input) => {
|
|
8571
|
+
if (!webhook?.verificationUrl) {
|
|
8572
|
+
return;
|
|
8573
|
+
}
|
|
8574
|
+
if (typeof webhook.verificationUrl === "function") {
|
|
8575
|
+
return webhook.verificationUrl(input);
|
|
8576
|
+
}
|
|
8577
|
+
return webhook.verificationUrl;
|
|
8578
|
+
};
|
|
8579
|
+
var buildTwilioVoiceSetupStatus = async (options, input) => {
|
|
8580
|
+
const origin = resolveRequestOrigin(input.request);
|
|
8581
|
+
const stream = await resolveTwilioStreamUrl(options, input);
|
|
8582
|
+
const twiml = joinUrlPath(origin, input.twimlPath);
|
|
8583
|
+
const webhook = joinUrlPath(origin, input.webhookPath);
|
|
8584
|
+
const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
|
|
8585
|
+
const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
|
|
8586
|
+
const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
|
|
8587
|
+
const warnings = [
|
|
8588
|
+
...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
|
|
8589
|
+
...signingConfigured ? [] : ["Webhook signature verification is not configured."],
|
|
8590
|
+
...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
|
|
8591
|
+
];
|
|
8592
|
+
return {
|
|
8593
|
+
generatedAt: Date.now(),
|
|
8594
|
+
missing,
|
|
8595
|
+
provider: "twilio",
|
|
8596
|
+
ready: missing.length === 0 && signingConfigured && warnings.length === 0,
|
|
8597
|
+
signing: {
|
|
8598
|
+
configured: signingConfigured,
|
|
8599
|
+
mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
|
|
8600
|
+
verificationUrl
|
|
8601
|
+
},
|
|
8602
|
+
urls: {
|
|
8603
|
+
stream,
|
|
8604
|
+
twiml,
|
|
8605
|
+
webhook
|
|
8606
|
+
},
|
|
8607
|
+
warnings
|
|
8608
|
+
};
|
|
8609
|
+
};
|
|
8610
|
+
var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
8611
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
|
|
8612
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
8613
|
+
<p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
|
|
8614
|
+
<section>
|
|
8615
|
+
<h2>URLs</h2>
|
|
8616
|
+
<ul>
|
|
8617
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(status.urls.twiml)}</code></li>
|
|
8618
|
+
<li><strong>Media stream:</strong> <code>${escapeHtml2(status.urls.stream)}</code></li>
|
|
8619
|
+
<li><strong>Status webhook:</strong> <code>${escapeHtml2(status.urls.webhook)}</code></li>
|
|
8620
|
+
</ul>
|
|
8621
|
+
</section>
|
|
8622
|
+
<section>
|
|
8623
|
+
<h2>Signing</h2>
|
|
8624
|
+
<p>Mode: <code>${status.signing.mode}</code></p>
|
|
8625
|
+
${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml2(status.signing.verificationUrl)}</code></p>` : ""}
|
|
8626
|
+
</section>
|
|
8627
|
+
${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml2(name)}</code></li>`).join("")}</ul></section>` : ""}
|
|
8628
|
+
${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml2(warning)}</li>`).join("")}</ul></section>` : ""}
|
|
8629
|
+
</main>`;
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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 = {
|