@absolutejs/voice 0.0.22-beta.73 → 0.0.22-beta.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -1
- package/dist/index.js +104 -0
- package/dist/telephony/twilio.d.ts +106 -0
- package/dist/testing/index.js +779 -7
- package/package.json +1 -1
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, 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,35 @@ 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
|
+
};
|
|
15203
15229
|
var normalizeOnTurn2 = (handler) => {
|
|
15204
15230
|
if (handler.length > 1) {
|
|
15205
15231
|
const directHandler = handler;
|
|
@@ -15576,6 +15602,83 @@ var createTwilioMediaStreamBridge = (socket, options) => {
|
|
|
15576
15602
|
}
|
|
15577
15603
|
};
|
|
15578
15604
|
};
|
|
15605
|
+
var createTwilioVoiceRoutes = (options) => {
|
|
15606
|
+
const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
|
|
15607
|
+
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
15608
|
+
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
15609
|
+
const bridges = new WeakMap;
|
|
15610
|
+
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
15611
|
+
return new Elysia18({
|
|
15612
|
+
name: options.name ?? "absolutejs-voice-twilio"
|
|
15613
|
+
}).get(twimlPath, async ({ query, request }) => {
|
|
15614
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
15615
|
+
query,
|
|
15616
|
+
request,
|
|
15617
|
+
streamPath
|
|
15618
|
+
});
|
|
15619
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
15620
|
+
query,
|
|
15621
|
+
request
|
|
15622
|
+
});
|
|
15623
|
+
return new Response(createTwilioVoiceResponse({
|
|
15624
|
+
parameters,
|
|
15625
|
+
streamName: options.twiml?.streamName,
|
|
15626
|
+
streamUrl,
|
|
15627
|
+
track: options.twiml?.track
|
|
15628
|
+
}), {
|
|
15629
|
+
headers: {
|
|
15630
|
+
"content-type": "text/xml; charset=utf-8"
|
|
15631
|
+
}
|
|
15632
|
+
});
|
|
15633
|
+
}).post(twimlPath, async ({ query, request }) => {
|
|
15634
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
15635
|
+
query,
|
|
15636
|
+
request,
|
|
15637
|
+
streamPath
|
|
15638
|
+
});
|
|
15639
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
15640
|
+
query,
|
|
15641
|
+
request
|
|
15642
|
+
});
|
|
15643
|
+
return new Response(createTwilioVoiceResponse({
|
|
15644
|
+
parameters,
|
|
15645
|
+
streamName: options.twiml?.streamName,
|
|
15646
|
+
streamUrl,
|
|
15647
|
+
track: options.twiml?.track
|
|
15648
|
+
}), {
|
|
15649
|
+
headers: {
|
|
15650
|
+
"content-type": "text/xml; charset=utf-8"
|
|
15651
|
+
}
|
|
15652
|
+
});
|
|
15653
|
+
}).ws(streamPath, {
|
|
15654
|
+
close: async (ws, _code, reason) => {
|
|
15655
|
+
const bridge = bridges.get(ws);
|
|
15656
|
+
bridges.delete(ws);
|
|
15657
|
+
await bridge?.close(reason);
|
|
15658
|
+
},
|
|
15659
|
+
message: async (ws, raw) => {
|
|
15660
|
+
let bridge = bridges.get(ws);
|
|
15661
|
+
if (!bridge) {
|
|
15662
|
+
bridge = createTwilioMediaStreamBridge({
|
|
15663
|
+
close: (code, reason) => {
|
|
15664
|
+
ws.close(code, reason);
|
|
15665
|
+
},
|
|
15666
|
+
send: (data) => {
|
|
15667
|
+
ws.send(data);
|
|
15668
|
+
}
|
|
15669
|
+
}, options);
|
|
15670
|
+
bridges.set(ws, bridge);
|
|
15671
|
+
}
|
|
15672
|
+
await bridge.handleMessage(raw);
|
|
15673
|
+
}
|
|
15674
|
+
}).use(createVoiceTelephonyWebhookRoutes({
|
|
15675
|
+
...options.webhook ?? {},
|
|
15676
|
+
context: options.context,
|
|
15677
|
+
path: webhookPath,
|
|
15678
|
+
policy: webhookPolicy,
|
|
15679
|
+
provider: "twilio"
|
|
15680
|
+
}));
|
|
15681
|
+
};
|
|
15579
15682
|
// src/telephony/response.ts
|
|
15580
15683
|
var normalizeWhitespace = (value) => value.replace(/\s+/g, " ").trim();
|
|
15581
15684
|
var DEFAULT_MAX_WORDS = 12;
|
|
@@ -15871,6 +15974,7 @@ export {
|
|
|
15871
15974
|
createVoiceAgentTool,
|
|
15872
15975
|
createVoiceAgentSquad,
|
|
15873
15976
|
createVoiceAgent,
|
|
15977
|
+
createTwilioVoiceRoutes,
|
|
15874
15978
|
createTwilioVoiceResponse,
|
|
15875
15979
|
createTwilioMediaStreamBridge,
|
|
15876
15980
|
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,114 @@ 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 TwilioVoiceRoutesOptions<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = TwilioMediaStreamBridgeOptions<TContext, TSession, TResult> & {
|
|
117
|
+
name?: string;
|
|
118
|
+
outcomePolicy?: VoiceTelephonyOutcomePolicy;
|
|
119
|
+
streamPath?: string;
|
|
120
|
+
twiml?: {
|
|
121
|
+
parameters?: TwilioVoiceRouteParameters;
|
|
122
|
+
path?: string;
|
|
123
|
+
streamName?: string;
|
|
124
|
+
streamUrl?: string | ((input: {
|
|
125
|
+
query: Record<string, unknown>;
|
|
126
|
+
request: Request;
|
|
127
|
+
streamPath: string;
|
|
128
|
+
}) => Promise<string> | string);
|
|
129
|
+
track?: TwilioVoiceResponseOptions['track'];
|
|
130
|
+
};
|
|
131
|
+
webhook?: Omit<VoiceTelephonyWebhookRoutesOptions<TContext, TSession, TResult>, 'context' | 'path' | 'policy' | 'provider'> & {
|
|
132
|
+
path?: string;
|
|
133
|
+
policy?: VoiceTelephonyOutcomePolicy;
|
|
134
|
+
};
|
|
135
|
+
};
|
|
110
136
|
export declare const decodeTwilioMulawBase64: (payload: string) => Int16Array<ArrayBuffer>;
|
|
111
137
|
export declare const encodeTwilioMulawBase64: (samples: Int16Array) => string;
|
|
112
138
|
export declare const transcodeTwilioInboundPayloadToPCM16: (payload: string) => Uint8Array<ArrayBuffer>;
|
|
113
139
|
export declare const transcodePCMToTwilioOutboundPayload: (chunk: Uint8Array, format: AudioFormat) => string;
|
|
114
140
|
export declare const createTwilioVoiceResponse: (options: TwilioVoiceResponseOptions) => string;
|
|
115
141
|
export declare const createTwilioMediaStreamBridge: <TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown>(socket: TwilioMediaStreamSocket, options: TwilioMediaStreamBridgeOptions<TContext, TSession, TResult>) => TwilioMediaStreamBridge;
|
|
142
|
+
export declare const createTwilioVoiceRoutes: <TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown>(options: TwilioVoiceRoutesOptions<TContext, TSession, TResult>) => Elysia<"", {
|
|
143
|
+
decorator: {};
|
|
144
|
+
store: {};
|
|
145
|
+
derive: {};
|
|
146
|
+
resolve: {};
|
|
147
|
+
}, {
|
|
148
|
+
typebox: {};
|
|
149
|
+
error: {};
|
|
150
|
+
}, {
|
|
151
|
+
schema: {};
|
|
152
|
+
standaloneSchema: {};
|
|
153
|
+
macro: {};
|
|
154
|
+
macroFn: {};
|
|
155
|
+
parser: {};
|
|
156
|
+
response: {};
|
|
157
|
+
}, {
|
|
158
|
+
[x: string]: {
|
|
159
|
+
get: {
|
|
160
|
+
body: unknown;
|
|
161
|
+
params: {};
|
|
162
|
+
query: unknown;
|
|
163
|
+
headers: unknown;
|
|
164
|
+
response: {
|
|
165
|
+
200: Response;
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
} & {
|
|
170
|
+
[x: string]: {
|
|
171
|
+
post: {
|
|
172
|
+
body: unknown;
|
|
173
|
+
params: {};
|
|
174
|
+
query: unknown;
|
|
175
|
+
headers: unknown;
|
|
176
|
+
response: {
|
|
177
|
+
200: Response;
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
} & {
|
|
182
|
+
[x: string]: {
|
|
183
|
+
subscribe: {
|
|
184
|
+
body: unknown;
|
|
185
|
+
params: {};
|
|
186
|
+
query: unknown;
|
|
187
|
+
headers: unknown;
|
|
188
|
+
response: {};
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
} & {
|
|
192
|
+
[x: string]: {
|
|
193
|
+
post: {
|
|
194
|
+
body: unknown;
|
|
195
|
+
params: {};
|
|
196
|
+
query: unknown;
|
|
197
|
+
headers: unknown;
|
|
198
|
+
response: {
|
|
199
|
+
200: Response | import("..").VoiceTelephonyWebhookDecision<TResult>;
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
}, {
|
|
204
|
+
derive: {};
|
|
205
|
+
resolve: {};
|
|
206
|
+
schema: {};
|
|
207
|
+
standaloneSchema: {};
|
|
208
|
+
response: {};
|
|
209
|
+
}, {
|
|
210
|
+
derive: {};
|
|
211
|
+
resolve: {};
|
|
212
|
+
schema: {};
|
|
213
|
+
standaloneSchema: {};
|
|
214
|
+
response: {};
|
|
215
|
+
} & {
|
|
216
|
+
derive: {};
|
|
217
|
+
resolve: {};
|
|
218
|
+
schema: {};
|
|
219
|
+
standaloneSchema: {};
|
|
220
|
+
response: {};
|
|
221
|
+
}>;
|
|
116
222
|
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,705 @@ 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
|
+
};
|
|
7873
8568
|
var normalizeOnTurn = (handler) => {
|
|
7874
8569
|
if (handler.length > 1) {
|
|
7875
8570
|
const directHandler = handler;
|
|
@@ -7971,7 +8666,7 @@ var bytesToInt16Array = (bytes) => {
|
|
|
7971
8666
|
return output;
|
|
7972
8667
|
};
|
|
7973
8668
|
var decodeTwilioMulawBase64 = (payload) => {
|
|
7974
|
-
const bytes = Uint8Array.from(
|
|
8669
|
+
const bytes = Uint8Array.from(Buffer3.from(payload, "base64"));
|
|
7975
8670
|
const samples = new Int16Array(bytes.length);
|
|
7976
8671
|
for (let index = 0;index < bytes.length; index += 1) {
|
|
7977
8672
|
samples[index] = decodeMulawSample(bytes[index] ?? 0);
|
|
@@ -7983,7 +8678,7 @@ var encodeTwilioMulawBase64 = (samples) => {
|
|
|
7983
8678
|
for (let index = 0;index < samples.length; index += 1) {
|
|
7984
8679
|
bytes[index] = encodeMulawSample(samples[index] ?? 0);
|
|
7985
8680
|
}
|
|
7986
|
-
return
|
|
8681
|
+
return Buffer3.from(bytes).toString("base64");
|
|
7987
8682
|
};
|
|
7988
8683
|
var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
|
|
7989
8684
|
const narrowband = decodeTwilioMulawBase64(payload);
|
|
@@ -7992,7 +8687,7 @@ var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
|
|
|
7992
8687
|
};
|
|
7993
8688
|
var transcodePCMToTwilioOutboundPayload = (chunk, format) => {
|
|
7994
8689
|
if (format.container === "raw" && format.encoding === "mulaw" && format.channels === 1 && format.sampleRateHz === TWILIO_MULAW_SAMPLE_RATE) {
|
|
7995
|
-
return
|
|
8690
|
+
return Buffer3.from(chunk).toString("base64");
|
|
7996
8691
|
}
|
|
7997
8692
|
if (format.encoding !== "pcm_s16le") {
|
|
7998
8693
|
throw new Error(`Unsupported outbound telephony audio format: ${format.container}/${format.encoding}`);
|
|
@@ -8033,7 +8728,7 @@ var createTwilioSocketAdapter = (socket, getState) => ({
|
|
|
8033
8728
|
return;
|
|
8034
8729
|
}
|
|
8035
8730
|
if (message.type === "audio") {
|
|
8036
|
-
const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(
|
|
8731
|
+
const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer3.from(message.chunkBase64, "base64")), message.format);
|
|
8037
8732
|
state.hasOutboundAudioSinceLastInbound = true;
|
|
8038
8733
|
state.reviewRecorder?.recordTwilioOutbound({
|
|
8039
8734
|
bytes: payload.length,
|
|
@@ -8246,6 +8941,83 @@ var createTwilioMediaStreamBridge = (socket, options) => {
|
|
|
8246
8941
|
}
|
|
8247
8942
|
};
|
|
8248
8943
|
};
|
|
8944
|
+
var createTwilioVoiceRoutes = (options) => {
|
|
8945
|
+
const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
|
|
8946
|
+
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
8947
|
+
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
8948
|
+
const bridges = new WeakMap;
|
|
8949
|
+
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
8950
|
+
return new Elysia2({
|
|
8951
|
+
name: options.name ?? "absolutejs-voice-twilio"
|
|
8952
|
+
}).get(twimlPath, async ({ query, request }) => {
|
|
8953
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
8954
|
+
query,
|
|
8955
|
+
request,
|
|
8956
|
+
streamPath
|
|
8957
|
+
});
|
|
8958
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
8959
|
+
query,
|
|
8960
|
+
request
|
|
8961
|
+
});
|
|
8962
|
+
return new Response(createTwilioVoiceResponse({
|
|
8963
|
+
parameters,
|
|
8964
|
+
streamName: options.twiml?.streamName,
|
|
8965
|
+
streamUrl,
|
|
8966
|
+
track: options.twiml?.track
|
|
8967
|
+
}), {
|
|
8968
|
+
headers: {
|
|
8969
|
+
"content-type": "text/xml; charset=utf-8"
|
|
8970
|
+
}
|
|
8971
|
+
});
|
|
8972
|
+
}).post(twimlPath, async ({ query, request }) => {
|
|
8973
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
8974
|
+
query,
|
|
8975
|
+
request,
|
|
8976
|
+
streamPath
|
|
8977
|
+
});
|
|
8978
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
8979
|
+
query,
|
|
8980
|
+
request
|
|
8981
|
+
});
|
|
8982
|
+
return new Response(createTwilioVoiceResponse({
|
|
8983
|
+
parameters,
|
|
8984
|
+
streamName: options.twiml?.streamName,
|
|
8985
|
+
streamUrl,
|
|
8986
|
+
track: options.twiml?.track
|
|
8987
|
+
}), {
|
|
8988
|
+
headers: {
|
|
8989
|
+
"content-type": "text/xml; charset=utf-8"
|
|
8990
|
+
}
|
|
8991
|
+
});
|
|
8992
|
+
}).ws(streamPath, {
|
|
8993
|
+
close: async (ws, _code, reason) => {
|
|
8994
|
+
const bridge = bridges.get(ws);
|
|
8995
|
+
bridges.delete(ws);
|
|
8996
|
+
await bridge?.close(reason);
|
|
8997
|
+
},
|
|
8998
|
+
message: async (ws, raw) => {
|
|
8999
|
+
let bridge = bridges.get(ws);
|
|
9000
|
+
if (!bridge) {
|
|
9001
|
+
bridge = createTwilioMediaStreamBridge({
|
|
9002
|
+
close: (code, reason) => {
|
|
9003
|
+
ws.close(code, reason);
|
|
9004
|
+
},
|
|
9005
|
+
send: (data) => {
|
|
9006
|
+
ws.send(data);
|
|
9007
|
+
}
|
|
9008
|
+
}, options);
|
|
9009
|
+
bridges.set(ws, bridge);
|
|
9010
|
+
}
|
|
9011
|
+
await bridge.handleMessage(raw);
|
|
9012
|
+
}
|
|
9013
|
+
}).use(createVoiceTelephonyWebhookRoutes({
|
|
9014
|
+
...options.webhook ?? {},
|
|
9015
|
+
context: options.context,
|
|
9016
|
+
path: webhookPath,
|
|
9017
|
+
policy: webhookPolicy,
|
|
9018
|
+
provider: "twilio"
|
|
9019
|
+
}));
|
|
9020
|
+
};
|
|
8249
9021
|
|
|
8250
9022
|
// src/testing/telephony.ts
|
|
8251
9023
|
var DEFAULT_PCM16_FORMAT = {
|