@absolutejs/voice 0.0.22-beta.12 → 0.0.22-beta.121
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/README.md +411 -3
- package/dist/agent.d.ts +2 -0
- package/dist/angular/index.d.ts +9 -0
- package/dist/angular/index.js +1278 -44
- package/dist/angular/voice-app-kit-status.service.d.ts +12 -0
- package/dist/angular/voice-campaign-dialer-proof.service.d.ts +14 -0
- package/dist/angular/voice-ops-status.component.d.ts +15 -0
- package/dist/angular/voice-provider-capabilities.service.d.ts +12 -0
- package/dist/angular/voice-provider-status.service.d.ts +12 -0
- package/dist/angular/voice-routing-status.service.d.ts +11 -0
- package/dist/angular/voice-stream.service.d.ts +2 -0
- package/dist/angular/voice-trace-timeline.service.d.ts +12 -0
- package/dist/angular/voice-turn-latency.service.d.ts +13 -0
- package/dist/angular/voice-turn-quality.service.d.ts +12 -0
- package/dist/angular/voice-workflow-status.service.d.ts +12 -0
- package/dist/appKit.d.ts +100 -0
- package/dist/assistantHealth.d.ts +81 -0
- package/dist/bargeInRoutes.d.ts +56 -0
- package/dist/campaign.d.ts +610 -0
- package/dist/campaignDialers.d.ts +90 -0
- package/dist/client/actions.d.ts +22 -0
- package/dist/client/appKitStatus.d.ts +19 -0
- package/dist/client/bargeInMonitor.d.ts +7 -0
- package/dist/client/campaignDialerProof.d.ts +23 -0
- package/dist/client/connection.d.ts +3 -0
- package/dist/client/duplex.d.ts +1 -1
- package/dist/client/htmxBootstrap.js +587 -13
- package/dist/client/index.d.ts +40 -0
- package/dist/client/index.js +2028 -8
- package/dist/client/liveTurnLatency.d.ts +41 -0
- package/dist/client/opsStatusWidget.d.ts +40 -0
- package/dist/client/providerCapabilities.d.ts +19 -0
- package/dist/client/providerCapabilitiesWidget.d.ts +32 -0
- package/dist/client/providerSimulationControls.d.ts +33 -0
- package/dist/client/providerSimulationControlsWidget.d.ts +20 -0
- package/dist/client/providerStatus.d.ts +19 -0
- package/dist/client/providerStatusWidget.d.ts +32 -0
- package/dist/client/routingStatus.d.ts +19 -0
- package/dist/client/routingStatusWidget.d.ts +28 -0
- package/dist/client/traceTimeline.d.ts +19 -0
- package/dist/client/traceTimelineWidget.d.ts +32 -0
- package/dist/client/turnLatency.d.ts +22 -0
- package/dist/client/turnLatencyWidget.d.ts +33 -0
- package/dist/client/turnQuality.d.ts +19 -0
- package/dist/client/turnQualityWidget.d.ts +32 -0
- package/dist/client/workflowStatus.d.ts +19 -0
- package/dist/diagnosticsRoutes.d.ts +44 -0
- package/dist/evalRoutes.d.ts +213 -0
- package/dist/fileStore.d.ts +3 -0
- package/dist/handoff.d.ts +54 -0
- package/dist/handoffHealth.d.ts +94 -0
- package/dist/index.d.ts +77 -8
- package/dist/index.js +12658 -3061
- package/dist/liveLatency.d.ts +78 -0
- package/dist/modelAdapters.d.ts +41 -2
- package/dist/openaiTTS.d.ts +18 -0
- package/dist/opsConsoleRoutes.d.ts +77 -0
- package/dist/opsWebhook.d.ts +126 -0
- package/dist/outcomeContract.d.ts +112 -0
- package/dist/phoneAgent.d.ts +58 -0
- package/dist/postgresStore.d.ts +5 -0
- package/dist/productionReadiness.d.ts +121 -0
- package/dist/providerAdapters.d.ts +48 -0
- package/dist/providerCapabilities.d.ts +92 -0
- package/dist/providerHealth.d.ts +79 -0
- package/dist/qualityRoutes.d.ts +76 -0
- package/dist/queue.d.ts +61 -0
- package/dist/react/VoiceOpsStatus.d.ts +6 -0
- package/dist/react/VoiceProviderCapabilities.d.ts +6 -0
- package/dist/react/VoiceProviderSimulationControls.d.ts +5 -0
- package/dist/react/VoiceProviderStatus.d.ts +6 -0
- package/dist/react/VoiceRoutingStatus.d.ts +6 -0
- package/dist/react/VoiceTraceTimeline.d.ts +6 -0
- package/dist/react/VoiceTurnLatency.d.ts +6 -0
- package/dist/react/VoiceTurnQuality.d.ts +6 -0
- package/dist/react/index.d.ts +18 -0
- package/dist/react/index.js +2606 -12
- package/dist/react/useVoiceAppKitStatus.d.ts +8 -0
- package/dist/react/useVoiceCampaignDialerProof.d.ts +10 -0
- package/dist/react/useVoiceController.d.ts +2 -0
- package/dist/react/useVoiceProviderCapabilities.d.ts +8 -0
- package/dist/react/useVoiceProviderSimulationControls.d.ts +10 -0
- package/dist/react/useVoiceProviderStatus.d.ts +8 -0
- package/dist/react/useVoiceRoutingStatus.d.ts +8 -0
- package/dist/react/useVoiceStream.d.ts +2 -0
- package/dist/react/useVoiceTraceTimeline.d.ts +8 -0
- package/dist/react/useVoiceTurnLatency.d.ts +9 -0
- package/dist/react/useVoiceTurnQuality.d.ts +8 -0
- package/dist/react/useVoiceWorkflowStatus.d.ts +8 -0
- package/dist/resilienceRoutes.d.ts +142 -0
- package/dist/sessionReplay.d.ts +175 -0
- package/dist/simulationSuite.d.ts +120 -0
- package/dist/sqliteStore.d.ts +5 -0
- package/dist/svelte/createVoiceAppKitStatus.d.ts +8 -0
- package/dist/svelte/createVoiceCampaignDialerProof.d.ts +9 -0
- package/dist/svelte/createVoiceOpsStatus.d.ts +9 -0
- package/dist/svelte/createVoiceProviderCapabilities.d.ts +10 -0
- package/dist/svelte/createVoiceProviderSimulationControls.d.ts +11 -0
- package/dist/svelte/createVoiceProviderStatus.d.ts +10 -0
- package/dist/svelte/createVoiceRoutingStatus.d.ts +10 -0
- package/dist/svelte/createVoiceTraceTimeline.d.ts +10 -0
- package/dist/svelte/createVoiceTurnLatency.d.ts +11 -0
- package/dist/svelte/createVoiceTurnQuality.d.ts +10 -0
- package/dist/svelte/createVoiceWorkflowStatus.d.ts +8 -0
- package/dist/svelte/index.d.ts +11 -0
- package/dist/svelte/index.js +1849 -4
- package/dist/telephony/contract.d.ts +61 -0
- package/dist/telephony/matrix.d.ts +97 -0
- package/dist/telephony/plivo.d.ts +254 -0
- package/dist/telephony/telnyx.d.ts +247 -0
- package/dist/telephony/twilio.d.ts +132 -0
- package/dist/telephonyOutcome.d.ts +201 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +2640 -21
- package/dist/testing/ioProviderSimulator.d.ts +41 -0
- package/dist/testing/providerSimulator.d.ts +44 -0
- package/dist/toolContract.d.ts +130 -0
- package/dist/toolRuntime.d.ts +50 -0
- package/dist/trace.d.ts +1 -1
- package/dist/traceTimeline.d.ts +93 -0
- package/dist/turnLatency.d.ts +95 -0
- package/dist/turnQuality.d.ts +94 -0
- package/dist/types.d.ts +125 -2
- package/dist/vue/VoiceOpsStatus.d.ts +30 -0
- package/dist/vue/VoiceProviderCapabilities.d.ts +51 -0
- package/dist/vue/VoiceProviderSimulationControls.d.ts +88 -0
- package/dist/vue/VoiceProviderStatus.d.ts +51 -0
- package/dist/vue/VoiceRoutingStatus.d.ts +51 -0
- package/dist/vue/VoiceTurnLatency.d.ts +69 -0
- package/dist/vue/VoiceTurnQuality.d.ts +51 -0
- package/dist/vue/index.d.ts +17 -0
- package/dist/vue/index.js +2520 -29
- package/dist/vue/useVoiceAppKitStatus.d.ts +9 -0
- package/dist/vue/useVoiceCampaignDialerProof.d.ts +11 -0
- package/dist/vue/useVoiceController.d.ts +1 -1
- package/dist/vue/useVoiceProviderCapabilities.d.ts +9 -0
- package/dist/vue/useVoiceProviderSimulationControls.d.ts +24 -0
- package/dist/vue/useVoiceProviderStatus.d.ts +9 -0
- package/dist/vue/useVoiceRoutingStatus.d.ts +8 -0
- package/dist/vue/useVoiceStream.d.ts +3 -1
- package/dist/vue/useVoiceTraceTimeline.d.ts +9 -0
- package/dist/vue/useVoiceTurnLatency.d.ts +10 -0
- package/dist/vue/useVoiceTurnQuality.d.ts +9 -0
- package/dist/vue/useVoiceWorkflowStatus.d.ts +9 -0
- package/dist/workflowContract.d.ts +91 -0
- package/package.json +1 -1
package/dist/testing/index.js
CHANGED
|
@@ -2105,6 +2105,12 @@ var serverMessageToAction = (message) => {
|
|
|
2105
2105
|
sessionId: message.sessionId,
|
|
2106
2106
|
type: "complete"
|
|
2107
2107
|
};
|
|
2108
|
+
case "call_lifecycle":
|
|
2109
|
+
return {
|
|
2110
|
+
event: message.event,
|
|
2111
|
+
sessionId: message.sessionId,
|
|
2112
|
+
type: "call_lifecycle"
|
|
2113
|
+
};
|
|
2108
2114
|
case "error":
|
|
2109
2115
|
return {
|
|
2110
2116
|
message: normalizeErrorMessage(message.message),
|
|
@@ -2148,7 +2154,7 @@ var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
|
|
|
2148
2154
|
var noop = () => {};
|
|
2149
2155
|
var noopUnsubscribe = () => noop;
|
|
2150
2156
|
var NOOP_CONNECTION = {
|
|
2151
|
-
|
|
2157
|
+
callControl: noop,
|
|
2152
2158
|
close: noop,
|
|
2153
2159
|
endTurn: noop,
|
|
2154
2160
|
getReadyState: () => WS_CLOSED,
|
|
@@ -2156,6 +2162,7 @@ var NOOP_CONNECTION = {
|
|
|
2156
2162
|
getSessionId: () => "",
|
|
2157
2163
|
send: noop,
|
|
2158
2164
|
sendAudio: noop,
|
|
2165
|
+
start: () => {},
|
|
2159
2166
|
subscribe: noopUnsubscribe
|
|
2160
2167
|
};
|
|
2161
2168
|
var createSessionId = () => crypto.randomUUID();
|
|
@@ -2177,6 +2184,7 @@ var isVoiceServerMessage = (value) => {
|
|
|
2177
2184
|
switch (value.type) {
|
|
2178
2185
|
case "audio":
|
|
2179
2186
|
case "assistant":
|
|
2187
|
+
case "call_lifecycle":
|
|
2180
2188
|
case "complete":
|
|
2181
2189
|
case "error":
|
|
2182
2190
|
case "final":
|
|
@@ -2317,6 +2325,12 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2317
2325
|
const endTurn = () => {
|
|
2318
2326
|
send({ type: "end_turn" });
|
|
2319
2327
|
};
|
|
2328
|
+
const callControl = (message) => {
|
|
2329
|
+
send({
|
|
2330
|
+
...message,
|
|
2331
|
+
type: "call_control"
|
|
2332
|
+
});
|
|
2333
|
+
};
|
|
2320
2334
|
const close = () => {
|
|
2321
2335
|
clearTimers();
|
|
2322
2336
|
if (state.ws) {
|
|
@@ -2334,7 +2348,7 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2334
2348
|
};
|
|
2335
2349
|
connect();
|
|
2336
2350
|
return {
|
|
2337
|
-
|
|
2351
|
+
callControl,
|
|
2338
2352
|
close,
|
|
2339
2353
|
endTurn,
|
|
2340
2354
|
getReadyState: () => state.ws?.readyState ?? WS_CLOSED,
|
|
@@ -2342,6 +2356,7 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2342
2356
|
getSessionId: () => state.sessionId,
|
|
2343
2357
|
send,
|
|
2344
2358
|
sendAudio,
|
|
2359
|
+
start,
|
|
2345
2360
|
subscribe
|
|
2346
2361
|
};
|
|
2347
2362
|
};
|
|
@@ -2350,6 +2365,7 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2350
2365
|
var createInitialState2 = () => ({
|
|
2351
2366
|
assistantAudio: [],
|
|
2352
2367
|
assistantTexts: [],
|
|
2368
|
+
call: null,
|
|
2353
2369
|
error: null,
|
|
2354
2370
|
isConnected: false,
|
|
2355
2371
|
scenarioId: null,
|
|
@@ -2393,6 +2409,20 @@ var createVoiceStreamStore = () => {
|
|
|
2393
2409
|
status: "completed"
|
|
2394
2410
|
};
|
|
2395
2411
|
break;
|
|
2412
|
+
case "call_lifecycle":
|
|
2413
|
+
state = {
|
|
2414
|
+
...state,
|
|
2415
|
+
call: {
|
|
2416
|
+
...state.call,
|
|
2417
|
+
disposition: action.event.type === "end" ? action.event.disposition : state.call?.disposition,
|
|
2418
|
+
endedAt: action.event.type === "end" ? action.event.at : state.call?.endedAt,
|
|
2419
|
+
events: [...state.call?.events ?? [], action.event],
|
|
2420
|
+
lastEventAt: action.event.at,
|
|
2421
|
+
startedAt: state.call?.startedAt ?? action.event.at
|
|
2422
|
+
},
|
|
2423
|
+
sessionId: action.sessionId
|
|
2424
|
+
};
|
|
2425
|
+
break;
|
|
2396
2426
|
case "connected":
|
|
2397
2427
|
state = {
|
|
2398
2428
|
...state,
|
|
@@ -2479,6 +2509,9 @@ var createVoiceStream = (path, options = {}) => {
|
|
|
2479
2509
|
}
|
|
2480
2510
|
});
|
|
2481
2511
|
return {
|
|
2512
|
+
callControl(message) {
|
|
2513
|
+
connection.callControl(message);
|
|
2514
|
+
},
|
|
2482
2515
|
close() {
|
|
2483
2516
|
unsubscribeConnection();
|
|
2484
2517
|
connection.close();
|
|
@@ -2522,6 +2555,9 @@ var createVoiceStream = (path, options = {}) => {
|
|
|
2522
2555
|
get assistantAudio() {
|
|
2523
2556
|
return store.getSnapshot().assistantAudio;
|
|
2524
2557
|
},
|
|
2558
|
+
get call() {
|
|
2559
|
+
return store.getSnapshot().call;
|
|
2560
|
+
},
|
|
2525
2561
|
sendAudio(audio) {
|
|
2526
2562
|
connection.sendAudio(audio);
|
|
2527
2563
|
},
|
|
@@ -2854,6 +2890,7 @@ var resolveVoiceRuntimePreset = (name = "default") => {
|
|
|
2854
2890
|
var createInitialState3 = (stream) => ({
|
|
2855
2891
|
assistantAudio: [...stream.assistantAudio],
|
|
2856
2892
|
assistantTexts: [...stream.assistantTexts],
|
|
2893
|
+
call: stream.call,
|
|
2857
2894
|
error: stream.error,
|
|
2858
2895
|
isConnected: stream.isConnected,
|
|
2859
2896
|
isRecording: false,
|
|
@@ -2883,6 +2920,7 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2883
2920
|
...state,
|
|
2884
2921
|
assistantAudio: [...stream.assistantAudio],
|
|
2885
2922
|
assistantTexts: [...stream.assistantTexts],
|
|
2923
|
+
call: stream.call,
|
|
2886
2924
|
error: stream.error,
|
|
2887
2925
|
isConnected: stream.isConnected,
|
|
2888
2926
|
partial: stream.partial,
|
|
@@ -2910,7 +2948,13 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2910
2948
|
capture = createMicrophoneCapture({
|
|
2911
2949
|
channelCount: options.capture?.channelCount ?? preset.capture.channelCount,
|
|
2912
2950
|
onLevel: options.capture?.onLevel,
|
|
2913
|
-
onAudio: (audio) =>
|
|
2951
|
+
onAudio: (audio) => {
|
|
2952
|
+
if (options.capture?.onAudio) {
|
|
2953
|
+
options.capture.onAudio(audio, stream.sendAudio);
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
stream.sendAudio(audio);
|
|
2957
|
+
},
|
|
2914
2958
|
sampleRateHz: options.capture?.sampleRateHz ?? preset.capture.sampleRateHz
|
|
2915
2959
|
});
|
|
2916
2960
|
return capture;
|
|
@@ -2960,6 +3004,7 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2960
3004
|
bindHTMX(bindingOptions) {
|
|
2961
3005
|
return bindVoiceHTMX(stream, bindingOptions);
|
|
2962
3006
|
},
|
|
3007
|
+
callControl: (message) => stream.callControl(message),
|
|
2963
3008
|
close,
|
|
2964
3009
|
endTurn: () => stream.endTurn(),
|
|
2965
3010
|
get error() {
|
|
@@ -3012,6 +3057,9 @@ var createVoiceController = (path, options = {}) => {
|
|
|
3012
3057
|
},
|
|
3013
3058
|
get assistantAudio() {
|
|
3014
3059
|
return state.assistantAudio;
|
|
3060
|
+
},
|
|
3061
|
+
get call() {
|
|
3062
|
+
return state.call;
|
|
3015
3063
|
}
|
|
3016
3064
|
};
|
|
3017
3065
|
};
|
|
@@ -3021,11 +3069,26 @@ var DEFAULT_INTERRUPT_THRESHOLD = 0.08;
|
|
|
3021
3069
|
var shouldInterruptForLevel = (level, options = {}) => (options.enabled ?? true) && level >= (options.interruptThreshold ?? DEFAULT_INTERRUPT_THRESHOLD);
|
|
3022
3070
|
var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
3023
3071
|
let lastPartial = controller.partial;
|
|
3024
|
-
const interruptIfPlaying = () => {
|
|
3072
|
+
const interruptIfPlaying = (reason) => {
|
|
3025
3073
|
if (!player.isPlaying || options.enabled === false) {
|
|
3074
|
+
options.monitor?.recordSkipped({
|
|
3075
|
+
reason,
|
|
3076
|
+
sessionId: controller.sessionId
|
|
3077
|
+
});
|
|
3026
3078
|
return;
|
|
3027
3079
|
}
|
|
3028
|
-
|
|
3080
|
+
options.monitor?.recordRequested({
|
|
3081
|
+
reason,
|
|
3082
|
+
sessionId: controller.sessionId
|
|
3083
|
+
});
|
|
3084
|
+
player.interrupt().then(() => {
|
|
3085
|
+
options.monitor?.recordStopped({
|
|
3086
|
+
latencyMs: player.lastInterruptLatencyMs,
|
|
3087
|
+
playbackStopLatencyMs: player.lastPlaybackStopLatencyMs,
|
|
3088
|
+
reason,
|
|
3089
|
+
sessionId: controller.sessionId
|
|
3090
|
+
});
|
|
3091
|
+
});
|
|
3029
3092
|
};
|
|
3030
3093
|
const unsubscribe = controller.subscribe(() => {
|
|
3031
3094
|
if (options.interruptOnPartial === false) {
|
|
@@ -3033,7 +3096,7 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
|
3033
3096
|
return;
|
|
3034
3097
|
}
|
|
3035
3098
|
if (!lastPartial && controller.partial) {
|
|
3036
|
-
interruptIfPlaying();
|
|
3099
|
+
interruptIfPlaying("partial-transcript");
|
|
3037
3100
|
}
|
|
3038
3101
|
lastPartial = controller.partial;
|
|
3039
3102
|
});
|
|
@@ -3043,11 +3106,11 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
|
3043
3106
|
},
|
|
3044
3107
|
handleLevel: (level) => {
|
|
3045
3108
|
if (shouldInterruptForLevel(level, options)) {
|
|
3046
|
-
interruptIfPlaying();
|
|
3109
|
+
interruptIfPlaying("input-level");
|
|
3047
3110
|
}
|
|
3048
3111
|
},
|
|
3049
3112
|
sendAudio: (audio) => {
|
|
3050
|
-
interruptIfPlaying();
|
|
3113
|
+
interruptIfPlaying("manual-audio");
|
|
3051
3114
|
controller.sendAudio(audio);
|
|
3052
3115
|
}
|
|
3053
3116
|
};
|
|
@@ -3077,7 +3140,17 @@ var createVoiceDuplexController = (path, options = {}) => {
|
|
|
3077
3140
|
audioPlayer,
|
|
3078
3141
|
close,
|
|
3079
3142
|
interruptAssistant: async () => {
|
|
3143
|
+
options.bargeIn?.monitor?.recordRequested({
|
|
3144
|
+
reason: "manual-interrupt",
|
|
3145
|
+
sessionId: controller.sessionId
|
|
3146
|
+
});
|
|
3080
3147
|
await audioPlayer.interrupt();
|
|
3148
|
+
options.bargeIn?.monitor?.recordStopped({
|
|
3149
|
+
latencyMs: audioPlayer.lastInterruptLatencyMs,
|
|
3150
|
+
playbackStopLatencyMs: audioPlayer.lastPlaybackStopLatencyMs,
|
|
3151
|
+
reason: "manual-interrupt",
|
|
3152
|
+
sessionId: controller.sessionId
|
|
3153
|
+
});
|
|
3081
3154
|
},
|
|
3082
3155
|
sendAudio: (audio) => {
|
|
3083
3156
|
bargeInBinding?.sendAudio(audio);
|
|
@@ -3468,6 +3541,1004 @@ var loadVoiceTestFixtures = async (fixtureDirectory) => {
|
|
|
3468
3541
|
}
|
|
3469
3542
|
return fixtures;
|
|
3470
3543
|
};
|
|
3544
|
+
// src/testing/ioProviderSimulator.ts
|
|
3545
|
+
var defaultFailureMessage = (input) => `Simulated ${input.provider} ${input.kind.toUpperCase()} ${input.operation} failure.`;
|
|
3546
|
+
var resolveRecoveryElapsedMs = (value, provider) => {
|
|
3547
|
+
if (typeof value === "number") {
|
|
3548
|
+
return value;
|
|
3549
|
+
}
|
|
3550
|
+
return value?.[provider] ?? 25;
|
|
3551
|
+
};
|
|
3552
|
+
var createHealth = (input) => ({
|
|
3553
|
+
consecutiveFailures: input.status === "healthy" ? 0 : 1,
|
|
3554
|
+
lastFailureAt: input.status === "healthy" ? undefined : input.now,
|
|
3555
|
+
provider: input.provider,
|
|
3556
|
+
status: input.status,
|
|
3557
|
+
suppressedUntil: input.suppressedUntil
|
|
3558
|
+
});
|
|
3559
|
+
var resolveFallback = async (options, provider) => {
|
|
3560
|
+
const configured = typeof options.fallback === "function" ? await options.fallback(provider) : options.fallback;
|
|
3561
|
+
return (configured ?? options.providers).find((candidate) => candidate !== provider);
|
|
3562
|
+
};
|
|
3563
|
+
var createVoiceIOProviderFailureSimulator = (options) => {
|
|
3564
|
+
if (options.providers.length === 0) {
|
|
3565
|
+
throw new Error("At least one provider is required.");
|
|
3566
|
+
}
|
|
3567
|
+
const now = options.now ?? Date.now;
|
|
3568
|
+
const operation = options.operation ?? "open";
|
|
3569
|
+
const cooldownMs = Math.max(0, options.cooldownMs ?? 30000);
|
|
3570
|
+
const emit = async (event, input) => {
|
|
3571
|
+
await options.onProviderEvent?.(event, input);
|
|
3572
|
+
};
|
|
3573
|
+
const run = async (provider, mode) => {
|
|
3574
|
+
if (!options.providers.includes(provider)) {
|
|
3575
|
+
throw new Error(`${provider} is not configured for simulation.`);
|
|
3576
|
+
}
|
|
3577
|
+
const startedAt = now();
|
|
3578
|
+
const sessionId = options.sessionId?.({ mode, now: startedAt, provider }) ?? `${options.kind}-provider-sim-${startedAt}`;
|
|
3579
|
+
if (mode === "recovery") {
|
|
3580
|
+
await emit({
|
|
3581
|
+
at: startedAt,
|
|
3582
|
+
attempt: 0,
|
|
3583
|
+
elapsedMs: resolveRecoveryElapsedMs(options.recoveryElapsedMs, provider),
|
|
3584
|
+
kind: options.kind,
|
|
3585
|
+
latencyBudgetMs: options.latencyBudgets?.[provider],
|
|
3586
|
+
operation,
|
|
3587
|
+
provider,
|
|
3588
|
+
providerHealth: createHealth({
|
|
3589
|
+
now: startedAt,
|
|
3590
|
+
provider,
|
|
3591
|
+
status: "healthy"
|
|
3592
|
+
}),
|
|
3593
|
+
selectedProvider: provider,
|
|
3594
|
+
status: "success"
|
|
3595
|
+
}, { mode, provider, sessionId });
|
|
3596
|
+
return {
|
|
3597
|
+
mode,
|
|
3598
|
+
provider,
|
|
3599
|
+
sessionId,
|
|
3600
|
+
status: "simulated"
|
|
3601
|
+
};
|
|
3602
|
+
}
|
|
3603
|
+
const fallbackProvider = await resolveFallback(options, provider);
|
|
3604
|
+
const suppressedUntil = startedAt + cooldownMs;
|
|
3605
|
+
await emit({
|
|
3606
|
+
at: startedAt,
|
|
3607
|
+
attempt: 0,
|
|
3608
|
+
elapsedMs: options.failureElapsedMs ?? 10,
|
|
3609
|
+
error: (options.failureMessage ?? defaultFailureMessage)({
|
|
3610
|
+
kind: options.kind,
|
|
3611
|
+
operation,
|
|
3612
|
+
provider
|
|
3613
|
+
}),
|
|
3614
|
+
fallbackProvider,
|
|
3615
|
+
kind: options.kind,
|
|
3616
|
+
latencyBudgetMs: options.latencyBudgets?.[provider],
|
|
3617
|
+
operation,
|
|
3618
|
+
provider,
|
|
3619
|
+
providerHealth: createHealth({
|
|
3620
|
+
now: startedAt,
|
|
3621
|
+
provider,
|
|
3622
|
+
status: "suppressed",
|
|
3623
|
+
suppressedUntil
|
|
3624
|
+
}),
|
|
3625
|
+
selectedProvider: provider,
|
|
3626
|
+
status: "error",
|
|
3627
|
+
suppressedUntil
|
|
3628
|
+
}, { mode, provider, sessionId });
|
|
3629
|
+
if (fallbackProvider) {
|
|
3630
|
+
await emit({
|
|
3631
|
+
at: startedAt + 1,
|
|
3632
|
+
attempt: 1,
|
|
3633
|
+
elapsedMs: resolveRecoveryElapsedMs(options.recoveryElapsedMs, fallbackProvider),
|
|
3634
|
+
fallbackProvider,
|
|
3635
|
+
kind: options.kind,
|
|
3636
|
+
latencyBudgetMs: options.latencyBudgets?.[fallbackProvider],
|
|
3637
|
+
operation,
|
|
3638
|
+
provider: fallbackProvider,
|
|
3639
|
+
providerHealth: createHealth({
|
|
3640
|
+
now: startedAt + 1,
|
|
3641
|
+
provider: fallbackProvider,
|
|
3642
|
+
status: "healthy"
|
|
3643
|
+
}),
|
|
3644
|
+
selectedProvider: provider,
|
|
3645
|
+
status: "fallback"
|
|
3646
|
+
}, { mode, provider, sessionId });
|
|
3647
|
+
}
|
|
3648
|
+
return {
|
|
3649
|
+
fallbackProvider,
|
|
3650
|
+
mode,
|
|
3651
|
+
provider,
|
|
3652
|
+
sessionId,
|
|
3653
|
+
status: "simulated",
|
|
3654
|
+
suppressedUntil
|
|
3655
|
+
};
|
|
3656
|
+
};
|
|
3657
|
+
return {
|
|
3658
|
+
run
|
|
3659
|
+
};
|
|
3660
|
+
};
|
|
3661
|
+
// src/modelAdapters.ts
|
|
3662
|
+
var resolveVoiceProviderRoutingPolicyPreset = (preset, options = {}) => {
|
|
3663
|
+
switch (preset) {
|
|
3664
|
+
case "balanced":
|
|
3665
|
+
return {
|
|
3666
|
+
fallbackMode: "provider-error",
|
|
3667
|
+
strategy: "balanced",
|
|
3668
|
+
weights: {
|
|
3669
|
+
cost: 1,
|
|
3670
|
+
latencyMs: 0.005,
|
|
3671
|
+
priority: 1,
|
|
3672
|
+
quality: 10,
|
|
3673
|
+
...options.weights
|
|
3674
|
+
},
|
|
3675
|
+
...options
|
|
3676
|
+
};
|
|
3677
|
+
case "cost-cap":
|
|
3678
|
+
return {
|
|
3679
|
+
fallbackMode: "provider-error",
|
|
3680
|
+
strategy: "prefer-cheapest",
|
|
3681
|
+
...options
|
|
3682
|
+
};
|
|
3683
|
+
case "cost-first":
|
|
3684
|
+
return {
|
|
3685
|
+
fallbackMode: "provider-error",
|
|
3686
|
+
strategy: "prefer-cheapest",
|
|
3687
|
+
...options
|
|
3688
|
+
};
|
|
3689
|
+
case "latency-first":
|
|
3690
|
+
return {
|
|
3691
|
+
fallbackMode: "provider-error",
|
|
3692
|
+
strategy: "prefer-fastest",
|
|
3693
|
+
...options
|
|
3694
|
+
};
|
|
3695
|
+
case "quality-first":
|
|
3696
|
+
return {
|
|
3697
|
+
fallbackMode: "provider-error",
|
|
3698
|
+
strategy: "quality-first",
|
|
3699
|
+
...options
|
|
3700
|
+
};
|
|
3701
|
+
}
|
|
3702
|
+
};
|
|
3703
|
+
var OUTPUT_SCHEMA = {
|
|
3704
|
+
additionalProperties: false,
|
|
3705
|
+
properties: {
|
|
3706
|
+
assistantText: {
|
|
3707
|
+
type: "string"
|
|
3708
|
+
},
|
|
3709
|
+
complete: {
|
|
3710
|
+
type: "boolean"
|
|
3711
|
+
},
|
|
3712
|
+
escalate: {
|
|
3713
|
+
additionalProperties: false,
|
|
3714
|
+
properties: {
|
|
3715
|
+
metadata: {
|
|
3716
|
+
additionalProperties: true,
|
|
3717
|
+
type: "object"
|
|
3718
|
+
},
|
|
3719
|
+
reason: {
|
|
3720
|
+
type: "string"
|
|
3721
|
+
}
|
|
3722
|
+
},
|
|
3723
|
+
required: ["reason"],
|
|
3724
|
+
type: "object"
|
|
3725
|
+
},
|
|
3726
|
+
noAnswer: {
|
|
3727
|
+
additionalProperties: false,
|
|
3728
|
+
properties: {
|
|
3729
|
+
metadata: {
|
|
3730
|
+
additionalProperties: true,
|
|
3731
|
+
type: "object"
|
|
3732
|
+
}
|
|
3733
|
+
},
|
|
3734
|
+
type: "object"
|
|
3735
|
+
},
|
|
3736
|
+
result: {
|
|
3737
|
+
additionalProperties: true,
|
|
3738
|
+
type: "object"
|
|
3739
|
+
},
|
|
3740
|
+
transfer: {
|
|
3741
|
+
additionalProperties: false,
|
|
3742
|
+
properties: {
|
|
3743
|
+
metadata: {
|
|
3744
|
+
additionalProperties: true,
|
|
3745
|
+
type: "object"
|
|
3746
|
+
},
|
|
3747
|
+
reason: {
|
|
3748
|
+
type: "string"
|
|
3749
|
+
},
|
|
3750
|
+
target: {
|
|
3751
|
+
type: "string"
|
|
3752
|
+
}
|
|
3753
|
+
},
|
|
3754
|
+
required: ["target"],
|
|
3755
|
+
type: "object"
|
|
3756
|
+
},
|
|
3757
|
+
voicemail: {
|
|
3758
|
+
additionalProperties: false,
|
|
3759
|
+
properties: {
|
|
3760
|
+
metadata: {
|
|
3761
|
+
additionalProperties: true,
|
|
3762
|
+
type: "object"
|
|
3763
|
+
}
|
|
3764
|
+
},
|
|
3765
|
+
type: "object"
|
|
3766
|
+
}
|
|
3767
|
+
},
|
|
3768
|
+
type: "object"
|
|
3769
|
+
};
|
|
3770
|
+
var ROUTE_RESULT_INSTRUCTION = "Return only a JSON object with assistantText, complete, transfer, escalate, voicemail, noAnswer, and result when you are not calling tools. Only set transfer, escalate, voicemail, or noAnswer when the user explicitly asks for that lifecycle outcome or a tool result says that exact outcome. Do not infer voicemail from generic words like voice, voice app, or voice integration.";
|
|
3771
|
+
var stripJSONCodeFence = (value) => {
|
|
3772
|
+
const trimmed = value.trim();
|
|
3773
|
+
const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
3774
|
+
return match?.[1]?.trim() ?? value;
|
|
3775
|
+
};
|
|
3776
|
+
var parseJSON = (value) => {
|
|
3777
|
+
try {
|
|
3778
|
+
const parsed = JSON.parse(stripJSONCodeFence(value));
|
|
3779
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
3780
|
+
} catch {
|
|
3781
|
+
return {
|
|
3782
|
+
assistantText: value
|
|
3783
|
+
};
|
|
3784
|
+
}
|
|
3785
|
+
};
|
|
3786
|
+
var parseJSONValue = (value) => {
|
|
3787
|
+
try {
|
|
3788
|
+
return JSON.parse(value);
|
|
3789
|
+
} catch {
|
|
3790
|
+
return value;
|
|
3791
|
+
}
|
|
3792
|
+
};
|
|
3793
|
+
|
|
3794
|
+
class VoiceProviderTimeoutError extends Error {
|
|
3795
|
+
provider;
|
|
3796
|
+
timeoutMs;
|
|
3797
|
+
constructor(provider, timeoutMs) {
|
|
3798
|
+
super(`Voice provider ${provider} exceeded ${timeoutMs}ms latency budget.`);
|
|
3799
|
+
this.name = "VoiceProviderTimeoutError";
|
|
3800
|
+
this.provider = provider;
|
|
3801
|
+
this.timeoutMs = timeoutMs;
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
var getMessageToolCalls = (message) => {
|
|
3805
|
+
const toolCalls = message.metadata?.toolCalls;
|
|
3806
|
+
return Array.isArray(toolCalls) ? toolCalls.filter((toolCall) => toolCall && typeof toolCall === "object" && typeof toolCall.name === "string") : [];
|
|
3807
|
+
};
|
|
3808
|
+
var createHTTPError = (provider, response) => new Error(`${provider} voice assistant model failed: HTTP ${response.status}`);
|
|
3809
|
+
var sleep = (ms) => new Promise((resolve2) => {
|
|
3810
|
+
setTimeout(resolve2, ms);
|
|
3811
|
+
});
|
|
3812
|
+
var errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
3813
|
+
var defaultIsRateLimitError = (error) => /(\b429\b|rate limit|quota|too many requests)/i.test(errorMessage(error));
|
|
3814
|
+
var normalizeRouteOutput = (output) => {
|
|
3815
|
+
const result = {};
|
|
3816
|
+
if (typeof output.assistantText === "string") {
|
|
3817
|
+
result.assistantText = output.assistantText;
|
|
3818
|
+
}
|
|
3819
|
+
if (typeof output.complete === "boolean") {
|
|
3820
|
+
result.complete = output.complete;
|
|
3821
|
+
}
|
|
3822
|
+
if (output.result !== undefined) {
|
|
3823
|
+
result.result = output.result;
|
|
3824
|
+
}
|
|
3825
|
+
if (output.transfer && typeof output.transfer === "object") {
|
|
3826
|
+
const transfer = output.transfer;
|
|
3827
|
+
if (typeof transfer.target === "string") {
|
|
3828
|
+
result.transfer = {
|
|
3829
|
+
metadata: transfer.metadata && typeof transfer.metadata === "object" ? transfer.metadata : undefined,
|
|
3830
|
+
reason: typeof transfer.reason === "string" ? transfer.reason : undefined,
|
|
3831
|
+
target: transfer.target
|
|
3832
|
+
};
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
if (output.escalate && typeof output.escalate === "object") {
|
|
3836
|
+
const escalate = output.escalate;
|
|
3837
|
+
if (typeof escalate.reason === "string") {
|
|
3838
|
+
result.escalate = {
|
|
3839
|
+
metadata: escalate.metadata && typeof escalate.metadata === "object" ? escalate.metadata : undefined,
|
|
3840
|
+
reason: escalate.reason
|
|
3841
|
+
};
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
if (output.voicemail && typeof output.voicemail === "object") {
|
|
3845
|
+
const voicemail = output.voicemail;
|
|
3846
|
+
result.voicemail = {
|
|
3847
|
+
metadata: voicemail.metadata && typeof voicemail.metadata === "object" ? voicemail.metadata : undefined
|
|
3848
|
+
};
|
|
3849
|
+
}
|
|
3850
|
+
if (output.noAnswer && typeof output.noAnswer === "object") {
|
|
3851
|
+
const noAnswer = output.noAnswer;
|
|
3852
|
+
result.noAnswer = {
|
|
3853
|
+
metadata: noAnswer.metadata && typeof noAnswer.metadata === "object" ? noAnswer.metadata : undefined
|
|
3854
|
+
};
|
|
3855
|
+
}
|
|
3856
|
+
return result;
|
|
3857
|
+
};
|
|
3858
|
+
var createJSONVoiceAssistantModel = (options) => ({
|
|
3859
|
+
generate: async (input) => {
|
|
3860
|
+
const output = await options.generate(input);
|
|
3861
|
+
if ("assistantText" in output || "toolCalls" in output || "complete" in output || "transfer" in output || "escalate" in output) {
|
|
3862
|
+
return output;
|
|
3863
|
+
}
|
|
3864
|
+
return options.mapOutput?.(output) ?? normalizeRouteOutput(output);
|
|
3865
|
+
}
|
|
3866
|
+
});
|
|
3867
|
+
var createVoiceProviderRouter = (options) => {
|
|
3868
|
+
const providerIds = Object.keys(options.providers);
|
|
3869
|
+
const firstProvider = providerIds[0];
|
|
3870
|
+
const policy = typeof options.policy === "string" ? options.policy === "balanced" || options.policy === "cost-cap" || options.policy === "cost-first" || options.policy === "latency-first" || options.policy === "quality-first" ? resolveVoiceProviderRoutingPolicyPreset(options.policy) : {
|
|
3871
|
+
strategy: options.policy
|
|
3872
|
+
} : options.policy;
|
|
3873
|
+
const strategy = policy?.strategy ?? "prefer-selected";
|
|
3874
|
+
const fallbackMode = policy?.fallbackMode ?? options.fallbackMode ?? "provider-error";
|
|
3875
|
+
const healthOptions = typeof options.providerHealth === "object" ? options.providerHealth : options.providerHealth ? {} : undefined;
|
|
3876
|
+
const healthState = new Map;
|
|
3877
|
+
const now = () => healthOptions?.now?.() ?? Date.now();
|
|
3878
|
+
const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
|
|
3879
|
+
const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
|
|
3880
|
+
const rateLimitCooldownMs = Math.max(0, healthOptions?.rateLimitCooldownMs ?? 60000);
|
|
3881
|
+
const getProviderTimeoutMs = (provider) => {
|
|
3882
|
+
const timeoutMs = options.providerProfiles?.[provider]?.timeoutMs ?? options.timeoutMs;
|
|
3883
|
+
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
|
|
3884
|
+
};
|
|
3885
|
+
const getHealth = (provider) => {
|
|
3886
|
+
const existing = healthState.get(provider);
|
|
3887
|
+
if (existing) {
|
|
3888
|
+
return existing;
|
|
3889
|
+
}
|
|
3890
|
+
const next = {
|
|
3891
|
+
consecutiveFailures: 0,
|
|
3892
|
+
provider,
|
|
3893
|
+
status: "healthy"
|
|
3894
|
+
};
|
|
3895
|
+
healthState.set(provider, next);
|
|
3896
|
+
return next;
|
|
3897
|
+
};
|
|
3898
|
+
const cloneHealth = (provider) => {
|
|
3899
|
+
if (!healthOptions) {
|
|
3900
|
+
return;
|
|
3901
|
+
}
|
|
3902
|
+
return {
|
|
3903
|
+
...getHealth(provider)
|
|
3904
|
+
};
|
|
3905
|
+
};
|
|
3906
|
+
const getSuppressionRemainingMs = (provider) => {
|
|
3907
|
+
if (!healthOptions) {
|
|
3908
|
+
return;
|
|
3909
|
+
}
|
|
3910
|
+
const suppressedUntil = getHealth(provider).suppressedUntil;
|
|
3911
|
+
return typeof suppressedUntil === "number" ? Math.max(0, suppressedUntil - now()) : undefined;
|
|
3912
|
+
};
|
|
3913
|
+
const isSuppressed = (provider) => {
|
|
3914
|
+
if (!healthOptions) {
|
|
3915
|
+
return false;
|
|
3916
|
+
}
|
|
3917
|
+
const health = getHealth(provider);
|
|
3918
|
+
return typeof health.suppressedUntil === "number" && health.suppressedUntil > now();
|
|
3919
|
+
};
|
|
3920
|
+
const recordProviderSuccess = (provider) => {
|
|
3921
|
+
if (!healthOptions) {
|
|
3922
|
+
return;
|
|
3923
|
+
}
|
|
3924
|
+
const health = getHealth(provider);
|
|
3925
|
+
health.consecutiveFailures = 0;
|
|
3926
|
+
health.status = "healthy";
|
|
3927
|
+
health.suppressedUntil = undefined;
|
|
3928
|
+
return cloneHealth(provider);
|
|
3929
|
+
};
|
|
3930
|
+
const recordProviderError = (provider, isProviderError, rateLimited) => {
|
|
3931
|
+
if (!healthOptions || !isProviderError) {
|
|
3932
|
+
return cloneHealth(provider);
|
|
3933
|
+
}
|
|
3934
|
+
const currentTime = now();
|
|
3935
|
+
const health = getHealth(provider);
|
|
3936
|
+
health.consecutiveFailures += 1;
|
|
3937
|
+
health.lastFailureAt = currentTime;
|
|
3938
|
+
if (rateLimited) {
|
|
3939
|
+
health.lastRateLimitedAt = currentTime;
|
|
3940
|
+
}
|
|
3941
|
+
if (rateLimited || health.consecutiveFailures >= failureThreshold) {
|
|
3942
|
+
health.status = "suppressed";
|
|
3943
|
+
health.suppressedUntil = currentTime + (rateLimited ? rateLimitCooldownMs : cooldownMs);
|
|
3944
|
+
}
|
|
3945
|
+
return cloneHealth(provider);
|
|
3946
|
+
};
|
|
3947
|
+
const resolveAllowedProviders = async (input) => {
|
|
3948
|
+
const allowProviders = policy?.allowProviders ?? options.allowProviders;
|
|
3949
|
+
const allowed = typeof allowProviders === "function" ? await allowProviders(input) : allowProviders;
|
|
3950
|
+
return new Set(allowed ?? providerIds);
|
|
3951
|
+
};
|
|
3952
|
+
const passesBudgetFilters = (provider) => {
|
|
3953
|
+
const profile = options.providerProfiles?.[provider];
|
|
3954
|
+
if (typeof policy?.maxCost === "number" && typeof profile?.cost === "number" && profile.cost > policy.maxCost) {
|
|
3955
|
+
return false;
|
|
3956
|
+
}
|
|
3957
|
+
if (typeof policy?.maxLatencyMs === "number" && typeof profile?.latencyMs === "number" && profile.latencyMs > policy.maxLatencyMs) {
|
|
3958
|
+
return false;
|
|
3959
|
+
}
|
|
3960
|
+
if (typeof policy?.minQuality === "number" && typeof profile?.quality === "number" && profile.quality < policy.minQuality) {
|
|
3961
|
+
return false;
|
|
3962
|
+
}
|
|
3963
|
+
return true;
|
|
3964
|
+
};
|
|
3965
|
+
const getBalancedScore = (provider) => {
|
|
3966
|
+
const profile = options.providerProfiles?.[provider];
|
|
3967
|
+
if (policy?.scoreProvider) {
|
|
3968
|
+
return policy.scoreProvider(provider, profile);
|
|
3969
|
+
}
|
|
3970
|
+
const weights = policy?.weights ?? {};
|
|
3971
|
+
return (profile?.cost ?? Number.MAX_SAFE_INTEGER) * (weights.cost ?? 1) + (profile?.latencyMs ?? Number.MAX_SAFE_INTEGER) * (weights.latencyMs ?? 0.005) + (profile?.priority ?? 0) * (weights.priority ?? 1) - (profile?.quality ?? 0) * (weights.quality ?? 10);
|
|
3972
|
+
};
|
|
3973
|
+
const sortProviders = (providers) => {
|
|
3974
|
+
if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest" && strategy !== "quality-first" && strategy !== "balanced") {
|
|
3975
|
+
return providers;
|
|
3976
|
+
}
|
|
3977
|
+
return [...providers].sort((left, right) => {
|
|
3978
|
+
const leftProfile = options.providerProfiles?.[left];
|
|
3979
|
+
const rightProfile = options.providerProfiles?.[right];
|
|
3980
|
+
if (strategy === "quality-first") {
|
|
3981
|
+
return (rightProfile?.quality ?? Number.MIN_SAFE_INTEGER) - (leftProfile?.quality ?? Number.MIN_SAFE_INTEGER) || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.cost ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.cost ?? Number.MAX_SAFE_INTEGER);
|
|
3982
|
+
}
|
|
3983
|
+
if (strategy === "balanced") {
|
|
3984
|
+
return getBalancedScore(left) - getBalancedScore(right);
|
|
3985
|
+
}
|
|
3986
|
+
const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
3987
|
+
const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
3988
|
+
return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
|
|
3989
|
+
});
|
|
3990
|
+
};
|
|
3991
|
+
const resolveOrder = async (input) => {
|
|
3992
|
+
const selectedProvider = await options.selectProvider?.(input);
|
|
3993
|
+
const allowedProviders = await resolveAllowedProviders(input);
|
|
3994
|
+
const fallbackOrder = typeof options.fallback === "function" ? await options.fallback(input) : options.fallback;
|
|
3995
|
+
const allowedRankedProviders = sortProviders([
|
|
3996
|
+
...fallbackOrder ?? providerIds
|
|
3997
|
+
]).filter((provider) => allowedProviders.has(provider));
|
|
3998
|
+
const rankedProviders = allowedRankedProviders.filter(passesBudgetFilters);
|
|
3999
|
+
const healthyRankedProviders = healthOptions ? rankedProviders.filter((provider) => !isSuppressed(provider)) : rankedProviders;
|
|
4000
|
+
const candidateRankedProviders = healthyRankedProviders.length ? healthyRankedProviders : rankedProviders;
|
|
4001
|
+
const preferred = selectedProvider && allowedProviders.has(selectedProvider) && passesBudgetFilters(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
|
|
4002
|
+
const seen = new Set;
|
|
4003
|
+
const order = [];
|
|
4004
|
+
const candidates = strategy === "ordered" ? candidateRankedProviders : [
|
|
4005
|
+
preferred,
|
|
4006
|
+
...candidateRankedProviders,
|
|
4007
|
+
...providerIds.filter((provider) => !healthOptions || !isSuppressed(provider))
|
|
4008
|
+
];
|
|
4009
|
+
for (const provider of candidates) {
|
|
4010
|
+
if (!provider || seen.has(provider) || !allowedProviders.has(provider) || !options.providers[provider]) {
|
|
4011
|
+
continue;
|
|
4012
|
+
}
|
|
4013
|
+
seen.add(provider);
|
|
4014
|
+
order.push(provider);
|
|
4015
|
+
}
|
|
4016
|
+
return {
|
|
4017
|
+
order,
|
|
4018
|
+
selectedProvider: preferred
|
|
4019
|
+
};
|
|
4020
|
+
};
|
|
4021
|
+
const emit = async (event, input) => {
|
|
4022
|
+
await options.onProviderEvent?.(event, input);
|
|
4023
|
+
};
|
|
4024
|
+
const runProvider = async (provider, model, input) => {
|
|
4025
|
+
const timeoutMs = getProviderTimeoutMs(provider);
|
|
4026
|
+
if (!timeoutMs) {
|
|
4027
|
+
return model.generate(input);
|
|
4028
|
+
}
|
|
4029
|
+
let timeout;
|
|
4030
|
+
try {
|
|
4031
|
+
return await Promise.race([
|
|
4032
|
+
model.generate(input),
|
|
4033
|
+
new Promise((_, reject) => {
|
|
4034
|
+
timeout = setTimeout(() => reject(new VoiceProviderTimeoutError(provider, timeoutMs)), timeoutMs);
|
|
4035
|
+
})
|
|
4036
|
+
]);
|
|
4037
|
+
} finally {
|
|
4038
|
+
if (timeout) {
|
|
4039
|
+
clearTimeout(timeout);
|
|
4040
|
+
}
|
|
4041
|
+
}
|
|
4042
|
+
};
|
|
4043
|
+
return {
|
|
4044
|
+
generate: async (input) => {
|
|
4045
|
+
const { order, selectedProvider } = await resolveOrder(input);
|
|
4046
|
+
if (!selectedProvider || order.length === 0) {
|
|
4047
|
+
throw new Error("Voice provider router has no available providers.");
|
|
4048
|
+
}
|
|
4049
|
+
let lastError;
|
|
4050
|
+
for (const [index, provider] of order.entries()) {
|
|
4051
|
+
const model = options.providers[provider];
|
|
4052
|
+
if (!model) {
|
|
4053
|
+
continue;
|
|
4054
|
+
}
|
|
4055
|
+
const startedAt = Date.now();
|
|
4056
|
+
try {
|
|
4057
|
+
const output = await runProvider(provider, model, input);
|
|
4058
|
+
const providerHealth = recordProviderSuccess(provider);
|
|
4059
|
+
await emit({
|
|
4060
|
+
at: Date.now(),
|
|
4061
|
+
attempt: index + 1,
|
|
4062
|
+
elapsedMs: Date.now() - startedAt,
|
|
4063
|
+
fallbackProvider: provider === selectedProvider ? undefined : provider,
|
|
4064
|
+
latencyBudgetMs: getProviderTimeoutMs(provider),
|
|
4065
|
+
provider,
|
|
4066
|
+
providerHealth,
|
|
4067
|
+
recovered: provider !== selectedProvider,
|
|
4068
|
+
selectedProvider,
|
|
4069
|
+
status: provider === selectedProvider ? "success" : "fallback"
|
|
4070
|
+
}, input);
|
|
4071
|
+
return output;
|
|
4072
|
+
} catch (error) {
|
|
4073
|
+
lastError = error;
|
|
4074
|
+
const hasNextProvider = index < order.length - 1;
|
|
4075
|
+
const isProviderError = options.isProviderError?.(error, provider) ?? true;
|
|
4076
|
+
const timedOut = options.isTimeoutError?.(error, provider) ?? error instanceof VoiceProviderTimeoutError;
|
|
4077
|
+
const rateLimited = options.isRateLimitError?.(error, provider) ?? defaultIsRateLimitError(error);
|
|
4078
|
+
const shouldFallback = fallbackMode === "provider-error" ? isProviderError : fallbackMode === "rate-limit" ? isProviderError && rateLimited : false;
|
|
4079
|
+
const providerHealth = recordProviderError(provider, isProviderError, rateLimited);
|
|
4080
|
+
const nextProvider = hasNextProvider ? order[index + 1] : undefined;
|
|
4081
|
+
await emit({
|
|
4082
|
+
at: Date.now(),
|
|
4083
|
+
attempt: index + 1,
|
|
4084
|
+
elapsedMs: Date.now() - startedAt,
|
|
4085
|
+
error: errorMessage(error),
|
|
4086
|
+
fallbackProvider: shouldFallback ? nextProvider : undefined,
|
|
4087
|
+
latencyBudgetMs: getProviderTimeoutMs(provider),
|
|
4088
|
+
provider,
|
|
4089
|
+
providerHealth,
|
|
4090
|
+
rateLimited,
|
|
4091
|
+
selectedProvider,
|
|
4092
|
+
suppressionRemainingMs: getSuppressionRemainingMs(provider),
|
|
4093
|
+
suppressedUntil: providerHealth?.suppressedUntil,
|
|
4094
|
+
status: "error",
|
|
4095
|
+
timedOut
|
|
4096
|
+
}, input);
|
|
4097
|
+
if (!hasNextProvider || !shouldFallback) {
|
|
4098
|
+
throw error;
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
throw lastError ?? new Error("Voice provider router did not run a provider.");
|
|
4103
|
+
}
|
|
4104
|
+
};
|
|
4105
|
+
};
|
|
4106
|
+
var messageToOpenAIInput = (message) => {
|
|
4107
|
+
if (message.role === "tool") {
|
|
4108
|
+
return [
|
|
4109
|
+
{
|
|
4110
|
+
call_id: message.toolCallId ?? message.name ?? crypto.randomUUID(),
|
|
4111
|
+
output: message.content,
|
|
4112
|
+
type: "function_call_output"
|
|
4113
|
+
}
|
|
4114
|
+
];
|
|
4115
|
+
}
|
|
4116
|
+
const toolCalls = getMessageToolCalls(message);
|
|
4117
|
+
if (message.role === "assistant" && toolCalls.length) {
|
|
4118
|
+
return toolCalls.map((toolCall) => ({
|
|
4119
|
+
arguments: JSON.stringify(toolCall.args),
|
|
4120
|
+
call_id: toolCall.id ?? crypto.randomUUID(),
|
|
4121
|
+
name: toolCall.name,
|
|
4122
|
+
type: "function_call"
|
|
4123
|
+
}));
|
|
4124
|
+
}
|
|
4125
|
+
return [
|
|
4126
|
+
{
|
|
4127
|
+
content: message.content,
|
|
4128
|
+
role: message.role === "system" ? "developer" : message.role
|
|
4129
|
+
}
|
|
4130
|
+
];
|
|
4131
|
+
};
|
|
4132
|
+
var messagesToOpenAIInput = (messages) => messages.flatMap(messageToOpenAIInput);
|
|
4133
|
+
var messageToAnthropicMessage = (message) => {
|
|
4134
|
+
if (message.role === "system") {
|
|
4135
|
+
return;
|
|
4136
|
+
}
|
|
4137
|
+
if (message.role === "tool") {
|
|
4138
|
+
if (!message.toolCallId) {
|
|
4139
|
+
return {
|
|
4140
|
+
content: `Tool result from ${message.name ?? "tool"}: ${message.content}`,
|
|
4141
|
+
role: "user"
|
|
4142
|
+
};
|
|
4143
|
+
}
|
|
4144
|
+
return {
|
|
4145
|
+
content: [
|
|
4146
|
+
{
|
|
4147
|
+
content: message.content,
|
|
4148
|
+
tool_use_id: message.toolCallId,
|
|
4149
|
+
type: "tool_result"
|
|
4150
|
+
}
|
|
4151
|
+
],
|
|
4152
|
+
role: "user"
|
|
4153
|
+
};
|
|
4154
|
+
}
|
|
4155
|
+
const toolCalls = getMessageToolCalls(message);
|
|
4156
|
+
if (message.role === "assistant" && toolCalls.length) {
|
|
4157
|
+
return {
|
|
4158
|
+
content: [
|
|
4159
|
+
...message.content ? [
|
|
4160
|
+
{
|
|
4161
|
+
text: message.content,
|
|
4162
|
+
type: "text"
|
|
4163
|
+
}
|
|
4164
|
+
] : [],
|
|
4165
|
+
...toolCalls.map((toolCall) => ({
|
|
4166
|
+
id: toolCall.id ?? crypto.randomUUID(),
|
|
4167
|
+
input: toolCall.args,
|
|
4168
|
+
name: toolCall.name,
|
|
4169
|
+
type: "tool_use"
|
|
4170
|
+
}))
|
|
4171
|
+
],
|
|
4172
|
+
role: "assistant"
|
|
4173
|
+
};
|
|
4174
|
+
}
|
|
4175
|
+
return {
|
|
4176
|
+
content: message.content,
|
|
4177
|
+
role: message.role
|
|
4178
|
+
};
|
|
4179
|
+
};
|
|
4180
|
+
var toGeminiSchema = (schema) => {
|
|
4181
|
+
const next = {};
|
|
4182
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
4183
|
+
if (key === "additionalProperties") {
|
|
4184
|
+
continue;
|
|
4185
|
+
}
|
|
4186
|
+
if (key === "type" && typeof value === "string") {
|
|
4187
|
+
next[key] = value.toUpperCase();
|
|
4188
|
+
continue;
|
|
4189
|
+
}
|
|
4190
|
+
if (Array.isArray(value)) {
|
|
4191
|
+
next[key] = value.map((item) => item && typeof item === "object" ? toGeminiSchema(item) : item);
|
|
4192
|
+
continue;
|
|
4193
|
+
}
|
|
4194
|
+
if (value && typeof value === "object") {
|
|
4195
|
+
next[key] = toGeminiSchema(value);
|
|
4196
|
+
continue;
|
|
4197
|
+
}
|
|
4198
|
+
next[key] = value;
|
|
4199
|
+
}
|
|
4200
|
+
return next;
|
|
4201
|
+
};
|
|
4202
|
+
var messageToGeminiContent = (message) => {
|
|
4203
|
+
if (message.role === "system") {
|
|
4204
|
+
return;
|
|
4205
|
+
}
|
|
4206
|
+
if (message.role === "tool") {
|
|
4207
|
+
return {
|
|
4208
|
+
parts: [
|
|
4209
|
+
{
|
|
4210
|
+
functionResponse: {
|
|
4211
|
+
id: message.toolCallId,
|
|
4212
|
+
name: message.name ?? "tool",
|
|
4213
|
+
response: {
|
|
4214
|
+
result: parseJSONValue(message.content)
|
|
4215
|
+
}
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4218
|
+
],
|
|
4219
|
+
role: "user"
|
|
4220
|
+
};
|
|
4221
|
+
}
|
|
4222
|
+
const toolCalls = getMessageToolCalls(message);
|
|
4223
|
+
if (message.role === "assistant" && toolCalls.length) {
|
|
4224
|
+
return {
|
|
4225
|
+
parts: [
|
|
4226
|
+
...message.content ? [
|
|
4227
|
+
{
|
|
4228
|
+
text: message.content
|
|
4229
|
+
}
|
|
4230
|
+
] : [],
|
|
4231
|
+
...toolCalls.map((toolCall) => ({
|
|
4232
|
+
functionCall: {
|
|
4233
|
+
args: toolCall.args,
|
|
4234
|
+
id: toolCall.id,
|
|
4235
|
+
name: toolCall.name
|
|
4236
|
+
}
|
|
4237
|
+
}))
|
|
4238
|
+
],
|
|
4239
|
+
role: "model"
|
|
4240
|
+
};
|
|
4241
|
+
}
|
|
4242
|
+
return {
|
|
4243
|
+
parts: [
|
|
4244
|
+
{
|
|
4245
|
+
text: message.content
|
|
4246
|
+
}
|
|
4247
|
+
],
|
|
4248
|
+
role: message.role === "assistant" ? "model" : "user"
|
|
4249
|
+
};
|
|
4250
|
+
};
|
|
4251
|
+
var extractText = (response) => {
|
|
4252
|
+
if (typeof response.output_text === "string") {
|
|
4253
|
+
return response.output_text;
|
|
4254
|
+
}
|
|
4255
|
+
const output = Array.isArray(response.output) ? response.output : [];
|
|
4256
|
+
for (const item of output) {
|
|
4257
|
+
if (!item || typeof item !== "object") {
|
|
4258
|
+
continue;
|
|
4259
|
+
}
|
|
4260
|
+
const record = item;
|
|
4261
|
+
const content = Array.isArray(record.content) ? record.content : [];
|
|
4262
|
+
for (const contentItem of content) {
|
|
4263
|
+
if (!contentItem || typeof contentItem !== "object") {
|
|
4264
|
+
continue;
|
|
4265
|
+
}
|
|
4266
|
+
const contentRecord = contentItem;
|
|
4267
|
+
if (typeof contentRecord.text === "string") {
|
|
4268
|
+
return contentRecord.text;
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
4272
|
+
return "";
|
|
4273
|
+
};
|
|
4274
|
+
var extractToolCalls = (response) => {
|
|
4275
|
+
const output = Array.isArray(response.output) ? response.output : [];
|
|
4276
|
+
const toolCalls = [];
|
|
4277
|
+
for (const item of output) {
|
|
4278
|
+
if (!item || typeof item !== "object") {
|
|
4279
|
+
continue;
|
|
4280
|
+
}
|
|
4281
|
+
const record = item;
|
|
4282
|
+
if (record.type !== "function_call" || typeof record.name !== "string") {
|
|
4283
|
+
continue;
|
|
4284
|
+
}
|
|
4285
|
+
const args = typeof record.arguments === "string" ? parseJSON(record.arguments) : {};
|
|
4286
|
+
toolCalls.push({
|
|
4287
|
+
args,
|
|
4288
|
+
id: typeof record.call_id === "string" ? record.call_id : typeof record.id === "string" ? record.id : undefined,
|
|
4289
|
+
name: record.name
|
|
4290
|
+
});
|
|
4291
|
+
}
|
|
4292
|
+
return toolCalls;
|
|
4293
|
+
};
|
|
4294
|
+
var createOpenAIVoiceAssistantModel = (options) => {
|
|
4295
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
4296
|
+
const baseUrl = options.baseUrl ?? "https://api.openai.com/v1";
|
|
4297
|
+
const model = options.model ?? "gpt-4.1-mini";
|
|
4298
|
+
return {
|
|
4299
|
+
generate: async (input) => {
|
|
4300
|
+
const response = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/responses`, {
|
|
4301
|
+
body: JSON.stringify({
|
|
4302
|
+
input: messagesToOpenAIInput(input.messages),
|
|
4303
|
+
instructions: [
|
|
4304
|
+
input.system,
|
|
4305
|
+
"Return a JSON object with assistantText, complete, transfer, escalate, voicemail, noAnswer, and result when you are not calling tools."
|
|
4306
|
+
].filter(Boolean).join(`
|
|
4307
|
+
|
|
4308
|
+
`),
|
|
4309
|
+
max_output_tokens: options.maxOutputTokens,
|
|
4310
|
+
model,
|
|
4311
|
+
temperature: options.temperature,
|
|
4312
|
+
text: {
|
|
4313
|
+
format: {
|
|
4314
|
+
name: "voice_route_result",
|
|
4315
|
+
schema: OUTPUT_SCHEMA,
|
|
4316
|
+
strict: false,
|
|
4317
|
+
type: "json_schema"
|
|
4318
|
+
}
|
|
4319
|
+
},
|
|
4320
|
+
tool_choice: input.tools.length ? "auto" : "none",
|
|
4321
|
+
tools: input.tools.map((tool) => ({
|
|
4322
|
+
description: tool.description,
|
|
4323
|
+
name: tool.name,
|
|
4324
|
+
parameters: tool.parameters ?? {
|
|
4325
|
+
additionalProperties: true,
|
|
4326
|
+
type: "object"
|
|
4327
|
+
},
|
|
4328
|
+
strict: false,
|
|
4329
|
+
type: "function"
|
|
4330
|
+
}))
|
|
4331
|
+
}),
|
|
4332
|
+
headers: {
|
|
4333
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
4334
|
+
"content-type": "application/json"
|
|
4335
|
+
},
|
|
4336
|
+
method: "POST"
|
|
4337
|
+
});
|
|
4338
|
+
if (!response.ok) {
|
|
4339
|
+
throw createHTTPError("OpenAI", response);
|
|
4340
|
+
}
|
|
4341
|
+
const body = await response.json();
|
|
4342
|
+
if (body.usage && typeof body.usage === "object") {
|
|
4343
|
+
await options.onUsage?.(body.usage);
|
|
4344
|
+
}
|
|
4345
|
+
const toolCalls = extractToolCalls(body);
|
|
4346
|
+
if (toolCalls.length) {
|
|
4347
|
+
return {
|
|
4348
|
+
toolCalls
|
|
4349
|
+
};
|
|
4350
|
+
}
|
|
4351
|
+
return normalizeRouteOutput(parseJSON(extractText(body)));
|
|
4352
|
+
}
|
|
4353
|
+
};
|
|
4354
|
+
};
|
|
4355
|
+
var extractAnthropicText = (response) => {
|
|
4356
|
+
const content = Array.isArray(response.content) ? response.content : [];
|
|
4357
|
+
return content.map((item) => item && typeof item === "object" && item.type === "text" && typeof item.text === "string" ? item.text : "").filter(Boolean).join(`
|
|
4358
|
+
`);
|
|
4359
|
+
};
|
|
4360
|
+
var extractAnthropicToolCalls = (response) => {
|
|
4361
|
+
const content = Array.isArray(response.content) ? response.content : [];
|
|
4362
|
+
const toolCalls = [];
|
|
4363
|
+
for (const item of content) {
|
|
4364
|
+
if (!item || typeof item !== "object") {
|
|
4365
|
+
continue;
|
|
4366
|
+
}
|
|
4367
|
+
const record = item;
|
|
4368
|
+
if (record.type !== "tool_use" || typeof record.name !== "string") {
|
|
4369
|
+
continue;
|
|
4370
|
+
}
|
|
4371
|
+
toolCalls.push({
|
|
4372
|
+
args: record.input && typeof record.input === "object" ? record.input : {},
|
|
4373
|
+
id: typeof record.id === "string" ? record.id : undefined,
|
|
4374
|
+
name: record.name
|
|
4375
|
+
});
|
|
4376
|
+
}
|
|
4377
|
+
return toolCalls;
|
|
4378
|
+
};
|
|
4379
|
+
var createAnthropicVoiceAssistantModel = (options) => {
|
|
4380
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
4381
|
+
const baseUrl = options.baseUrl ?? "https://api.anthropic.com/v1";
|
|
4382
|
+
const model = options.model ?? "claude-sonnet-4-5";
|
|
4383
|
+
return {
|
|
4384
|
+
generate: async (input) => {
|
|
4385
|
+
const response = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/messages`, {
|
|
4386
|
+
body: JSON.stringify({
|
|
4387
|
+
max_tokens: options.maxOutputTokens ?? 1024,
|
|
4388
|
+
messages: input.messages.map(messageToAnthropicMessage).filter(Boolean),
|
|
4389
|
+
model,
|
|
4390
|
+
system: [input.system, ROUTE_RESULT_INSTRUCTION].filter(Boolean).join(`
|
|
4391
|
+
|
|
4392
|
+
`),
|
|
4393
|
+
temperature: options.temperature,
|
|
4394
|
+
tool_choice: input.tools.length ? { type: "auto" } : { type: "none" },
|
|
4395
|
+
tools: input.tools.map((tool) => ({
|
|
4396
|
+
description: tool.description,
|
|
4397
|
+
input_schema: tool.parameters ?? {
|
|
4398
|
+
additionalProperties: true,
|
|
4399
|
+
type: "object"
|
|
4400
|
+
},
|
|
4401
|
+
name: tool.name
|
|
4402
|
+
}))
|
|
4403
|
+
}),
|
|
4404
|
+
headers: {
|
|
4405
|
+
"anthropic-version": options.version ?? "2023-06-01",
|
|
4406
|
+
"content-type": "application/json",
|
|
4407
|
+
"x-api-key": options.apiKey
|
|
4408
|
+
},
|
|
4409
|
+
method: "POST"
|
|
4410
|
+
});
|
|
4411
|
+
if (!response.ok) {
|
|
4412
|
+
throw createHTTPError("Anthropic", response);
|
|
4413
|
+
}
|
|
4414
|
+
const body = await response.json();
|
|
4415
|
+
if (body.usage && typeof body.usage === "object") {
|
|
4416
|
+
await options.onUsage?.(body.usage);
|
|
4417
|
+
}
|
|
4418
|
+
const toolCalls = extractAnthropicToolCalls(body);
|
|
4419
|
+
if (toolCalls.length) {
|
|
4420
|
+
return {
|
|
4421
|
+
assistantText: extractAnthropicText(body) || undefined,
|
|
4422
|
+
toolCalls
|
|
4423
|
+
};
|
|
4424
|
+
}
|
|
4425
|
+
return normalizeRouteOutput(parseJSON(extractAnthropicText(body)));
|
|
4426
|
+
}
|
|
4427
|
+
};
|
|
4428
|
+
};
|
|
4429
|
+
var extractGeminiCandidateParts = (response) => {
|
|
4430
|
+
const candidates = Array.isArray(response.candidates) ? response.candidates : [];
|
|
4431
|
+
const first = candidates[0];
|
|
4432
|
+
if (!first || typeof first !== "object") {
|
|
4433
|
+
return [];
|
|
4434
|
+
}
|
|
4435
|
+
const content = first.content;
|
|
4436
|
+
if (!content || typeof content !== "object") {
|
|
4437
|
+
return [];
|
|
4438
|
+
}
|
|
4439
|
+
const parts = content.parts;
|
|
4440
|
+
return Array.isArray(parts) ? parts : [];
|
|
4441
|
+
};
|
|
4442
|
+
var extractGeminiText = (response) => extractGeminiCandidateParts(response).map((part) => part && typeof part === "object" && typeof part.text === "string" ? part.text : "").filter(Boolean).join(`
|
|
4443
|
+
`);
|
|
4444
|
+
var extractGeminiToolCalls = (response) => {
|
|
4445
|
+
const toolCalls = [];
|
|
4446
|
+
for (const part of extractGeminiCandidateParts(response)) {
|
|
4447
|
+
if (!part || typeof part !== "object") {
|
|
4448
|
+
continue;
|
|
4449
|
+
}
|
|
4450
|
+
const functionCall = part.functionCall;
|
|
4451
|
+
if (!functionCall || typeof functionCall !== "object") {
|
|
4452
|
+
continue;
|
|
4453
|
+
}
|
|
4454
|
+
const record = functionCall;
|
|
4455
|
+
if (typeof record.name !== "string") {
|
|
4456
|
+
continue;
|
|
4457
|
+
}
|
|
4458
|
+
toolCalls.push({
|
|
4459
|
+
args: record.args && typeof record.args === "object" ? record.args : {},
|
|
4460
|
+
id: typeof record.id === "string" ? record.id : undefined,
|
|
4461
|
+
name: record.name
|
|
4462
|
+
});
|
|
4463
|
+
}
|
|
4464
|
+
return toolCalls;
|
|
4465
|
+
};
|
|
4466
|
+
var createGeminiVoiceAssistantModel = (options) => {
|
|
4467
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
4468
|
+
const baseUrl = options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
|
|
4469
|
+
const model = options.model ?? "gemini-2.5-flash";
|
|
4470
|
+
const maxRetries = Math.max(0, options.maxRetries ?? 2);
|
|
4471
|
+
return {
|
|
4472
|
+
generate: async (input) => {
|
|
4473
|
+
const endpoint = `${baseUrl.replace(/\/$/, "")}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(options.apiKey)}`;
|
|
4474
|
+
let response;
|
|
4475
|
+
for (let attempt = 0;attempt <= maxRetries; attempt += 1) {
|
|
4476
|
+
response = await fetchImpl(endpoint, {
|
|
4477
|
+
body: JSON.stringify({
|
|
4478
|
+
contents: input.messages.map(messageToGeminiContent).filter(Boolean),
|
|
4479
|
+
generationConfig: {
|
|
4480
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
4481
|
+
...input.tools.length ? {} : {
|
|
4482
|
+
responseMimeType: "application/json",
|
|
4483
|
+
responseSchema: toGeminiSchema(OUTPUT_SCHEMA)
|
|
4484
|
+
},
|
|
4485
|
+
temperature: options.temperature
|
|
4486
|
+
},
|
|
4487
|
+
systemInstruction: {
|
|
4488
|
+
parts: [
|
|
4489
|
+
{
|
|
4490
|
+
text: [input.system, ROUTE_RESULT_INSTRUCTION].filter(Boolean).join(`
|
|
4491
|
+
|
|
4492
|
+
`)
|
|
4493
|
+
}
|
|
4494
|
+
]
|
|
4495
|
+
},
|
|
4496
|
+
tools: input.tools.length ? [
|
|
4497
|
+
{
|
|
4498
|
+
functionDeclarations: input.tools.map((tool) => ({
|
|
4499
|
+
description: tool.description,
|
|
4500
|
+
name: tool.name,
|
|
4501
|
+
parameters: toGeminiSchema(tool.parameters ?? {
|
|
4502
|
+
additionalProperties: true,
|
|
4503
|
+
type: "object"
|
|
4504
|
+
})
|
|
4505
|
+
}))
|
|
4506
|
+
}
|
|
4507
|
+
] : undefined
|
|
4508
|
+
}),
|
|
4509
|
+
headers: {
|
|
4510
|
+
"content-type": "application/json"
|
|
4511
|
+
},
|
|
4512
|
+
method: "POST"
|
|
4513
|
+
});
|
|
4514
|
+
if (response.ok || response.status !== 429 && response.status < 500 || attempt === maxRetries) {
|
|
4515
|
+
break;
|
|
4516
|
+
}
|
|
4517
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
4518
|
+
await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt);
|
|
4519
|
+
}
|
|
4520
|
+
if (!response) {
|
|
4521
|
+
throw new Error("Gemini voice assistant model failed: no response");
|
|
4522
|
+
}
|
|
4523
|
+
if (!response.ok) {
|
|
4524
|
+
throw createHTTPError("Gemini", response);
|
|
4525
|
+
}
|
|
4526
|
+
const body = await response.json();
|
|
4527
|
+
if (body.usageMetadata && typeof body.usageMetadata === "object") {
|
|
4528
|
+
await options.onUsage?.(body.usageMetadata);
|
|
4529
|
+
}
|
|
4530
|
+
const toolCalls = extractGeminiToolCalls(body);
|
|
4531
|
+
if (toolCalls.length) {
|
|
4532
|
+
return {
|
|
4533
|
+
assistantText: extractGeminiText(body) || undefined,
|
|
4534
|
+
toolCalls
|
|
4535
|
+
};
|
|
4536
|
+
}
|
|
4537
|
+
return normalizeRouteOutput(parseJSON(extractGeminiText(body)));
|
|
4538
|
+
}
|
|
4539
|
+
};
|
|
4540
|
+
};
|
|
4541
|
+
|
|
3471
4542
|
// src/store.ts
|
|
3472
4543
|
var createId = () => crypto.randomUUID();
|
|
3473
4544
|
var createVoiceSessionRecord = (id, scenarioId) => ({
|
|
@@ -3508,6 +4579,118 @@ var toVoiceSessionSummary = (session) => ({
|
|
|
3508
4579
|
turnCount: session.turns.length
|
|
3509
4580
|
});
|
|
3510
4581
|
|
|
4582
|
+
// src/testing/providerSimulator.ts
|
|
4583
|
+
var getContextQuery = (context) => context.query;
|
|
4584
|
+
var titleCaseProvider = (provider) => provider.split(/[-_\s]+/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ");
|
|
4585
|
+
var resolveRequestedProvider = (context, providers) => {
|
|
4586
|
+
const provider = getContextQuery(context).provider;
|
|
4587
|
+
return providers.includes(provider) ? provider : providers[0];
|
|
4588
|
+
};
|
|
4589
|
+
var createVoiceProviderFailureSimulator = (options) => {
|
|
4590
|
+
if (options.providers.length === 0) {
|
|
4591
|
+
throw new Error("At least one provider is required.");
|
|
4592
|
+
}
|
|
4593
|
+
const providerModels = Object.fromEntries(options.providers.map((provider) => [
|
|
4594
|
+
provider,
|
|
4595
|
+
{
|
|
4596
|
+
generate: async (input) => {
|
|
4597
|
+
const query = getContextQuery(input.context);
|
|
4598
|
+
if (provider === query.simulateFailureProvider) {
|
|
4599
|
+
const label = options.providerLabel?.(provider) ?? titleCaseProvider(provider);
|
|
4600
|
+
throw new Error(`${label} voice assistant model failed: HTTP 429`);
|
|
4601
|
+
}
|
|
4602
|
+
if (options.response) {
|
|
4603
|
+
return options.response({
|
|
4604
|
+
...input,
|
|
4605
|
+
mode: query.recoverProvider === provider ? "recovery" : "failure",
|
|
4606
|
+
provider
|
|
4607
|
+
});
|
|
4608
|
+
}
|
|
4609
|
+
return {
|
|
4610
|
+
assistantText: `Simulated ${provider} provider recovered.`
|
|
4611
|
+
};
|
|
4612
|
+
}
|
|
4613
|
+
}
|
|
4614
|
+
]));
|
|
4615
|
+
const router = createVoiceProviderRouter({
|
|
4616
|
+
allowProviders: async (input) => {
|
|
4617
|
+
const recoverProvider = getContextQuery(input.context).recoverProvider;
|
|
4618
|
+
if (recoverProvider) {
|
|
4619
|
+
return [recoverProvider];
|
|
4620
|
+
}
|
|
4621
|
+
if (typeof options.allowProviders === "function") {
|
|
4622
|
+
return options.allowProviders(input);
|
|
4623
|
+
}
|
|
4624
|
+
return options.allowProviders ?? options.providers;
|
|
4625
|
+
},
|
|
4626
|
+
fallback: async (input) => {
|
|
4627
|
+
const selectedProvider = resolveRequestedProvider(input.context, options.providers);
|
|
4628
|
+
if (typeof options.fallback === "function") {
|
|
4629
|
+
return options.fallback(selectedProvider, input);
|
|
4630
|
+
}
|
|
4631
|
+
return options.fallback ?? options.providers.filter((provider) => provider !== selectedProvider);
|
|
4632
|
+
},
|
|
4633
|
+
fallbackMode: "provider-error",
|
|
4634
|
+
isProviderError: options.isProviderError,
|
|
4635
|
+
isRateLimitError: options.isRateLimitError,
|
|
4636
|
+
onProviderEvent: options.onProviderEvent,
|
|
4637
|
+
policy: "prefer-selected",
|
|
4638
|
+
providerHealth: options.providerHealth ?? {
|
|
4639
|
+
cooldownMs: 30000,
|
|
4640
|
+
failureThreshold: 1,
|
|
4641
|
+
rateLimitCooldownMs: 120000
|
|
4642
|
+
},
|
|
4643
|
+
providers: providerModels,
|
|
4644
|
+
selectProvider: ({ context }) => resolveRequestedProvider(context, options.providers)
|
|
4645
|
+
});
|
|
4646
|
+
const run = async (provider, mode) => {
|
|
4647
|
+
const now = Date.now();
|
|
4648
|
+
const session = createVoiceSessionRecord(`provider-sim-${now}`, "provider-simulation");
|
|
4649
|
+
const turn = {
|
|
4650
|
+
committedAt: now,
|
|
4651
|
+
id: `provider-sim-turn-${now}`,
|
|
4652
|
+
text: mode === "failure" ? `Simulate ${provider} provider failure.` : `Simulate ${provider} provider recovery.`,
|
|
4653
|
+
transcripts: []
|
|
4654
|
+
};
|
|
4655
|
+
const context = {
|
|
4656
|
+
query: {
|
|
4657
|
+
provider,
|
|
4658
|
+
...mode === "recovery" ? { recoverProvider: provider } : {},
|
|
4659
|
+
...mode === "failure" ? { simulateFailureProvider: provider } : {}
|
|
4660
|
+
}
|
|
4661
|
+
};
|
|
4662
|
+
const result = await router.generate({
|
|
4663
|
+
agentId: "provider-simulator",
|
|
4664
|
+
context,
|
|
4665
|
+
messages: [
|
|
4666
|
+
{
|
|
4667
|
+
content: turn.text,
|
|
4668
|
+
role: "user"
|
|
4669
|
+
}
|
|
4670
|
+
],
|
|
4671
|
+
session,
|
|
4672
|
+
system: "Simulate provider routing without calling external APIs.",
|
|
4673
|
+
tools: [],
|
|
4674
|
+
turn
|
|
4675
|
+
});
|
|
4676
|
+
return {
|
|
4677
|
+
mode,
|
|
4678
|
+
provider,
|
|
4679
|
+
replayHref: options.replayHref === false ? undefined : typeof options.replayHref === "function" ? options.replayHref({
|
|
4680
|
+
provider,
|
|
4681
|
+
sessionId: session.id,
|
|
4682
|
+
turnId: turn.id
|
|
4683
|
+
}) : `${options.replayHref ?? "/api/voice-sessions"}/${encodeURIComponent(session.id)}/replay/htmx`,
|
|
4684
|
+
result,
|
|
4685
|
+
sessionId: session.id,
|
|
4686
|
+
status: "simulated",
|
|
4687
|
+
turnId: turn.id
|
|
4688
|
+
};
|
|
4689
|
+
};
|
|
4690
|
+
return {
|
|
4691
|
+
run
|
|
4692
|
+
};
|
|
4693
|
+
};
|
|
3511
4694
|
// src/memoryStore.ts
|
|
3512
4695
|
var createVoiceMemoryStore = () => {
|
|
3513
4696
|
const sessions = new Map;
|
|
@@ -3531,7 +4714,290 @@ var createVoiceMemoryStore = () => {
|
|
|
3531
4714
|
};
|
|
3532
4715
|
|
|
3533
4716
|
// src/session.ts
|
|
3534
|
-
import { Buffer } from "buffer";
|
|
4717
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
4718
|
+
|
|
4719
|
+
// src/handoff.ts
|
|
4720
|
+
var toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
4721
|
+
var signHandoffBody = async (input) => {
|
|
4722
|
+
const encoder = new TextEncoder;
|
|
4723
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(input.secret), {
|
|
4724
|
+
hash: "SHA-256",
|
|
4725
|
+
name: "HMAC"
|
|
4726
|
+
}, false, ["sign"]);
|
|
4727
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(`${input.timestamp}.${input.body}`));
|
|
4728
|
+
return `sha256=${toHex(new Uint8Array(signature))}`;
|
|
4729
|
+
};
|
|
4730
|
+
var toErrorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
4731
|
+
var createSkippedDelivery = (adapter) => ({
|
|
4732
|
+
adapterId: adapter.id,
|
|
4733
|
+
adapterKind: adapter.kind,
|
|
4734
|
+
status: "skipped"
|
|
4735
|
+
});
|
|
4736
|
+
var aggregateHandoffStatus = (deliveries) => {
|
|
4737
|
+
const statuses = Object.values(deliveries).map((delivery) => delivery.status);
|
|
4738
|
+
if (statuses.some((status) => status === "failed")) {
|
|
4739
|
+
return "failed";
|
|
4740
|
+
}
|
|
4741
|
+
if (statuses.some((status) => status === "delivered")) {
|
|
4742
|
+
return "delivered";
|
|
4743
|
+
}
|
|
4744
|
+
return "skipped";
|
|
4745
|
+
};
|
|
4746
|
+
var createHandoffDeliveryId = (input) => [
|
|
4747
|
+
"voice-handoff",
|
|
4748
|
+
input.sessionId,
|
|
4749
|
+
input.action,
|
|
4750
|
+
Date.now(),
|
|
4751
|
+
crypto.randomUUID()
|
|
4752
|
+
].join(":");
|
|
4753
|
+
var resolveHandoffDeliveryError = (deliveries) => Object.values(deliveries).map((delivery) => delivery.error).find(Boolean);
|
|
4754
|
+
var defaultWebhookBody = (input) => ({
|
|
4755
|
+
action: input.action,
|
|
4756
|
+
metadata: input.metadata,
|
|
4757
|
+
reason: input.reason,
|
|
4758
|
+
result: input.result,
|
|
4759
|
+
session: {
|
|
4760
|
+
id: input.session.id,
|
|
4761
|
+
scenarioId: input.session.scenarioId,
|
|
4762
|
+
status: input.session.status
|
|
4763
|
+
},
|
|
4764
|
+
source: "absolutejs-voice",
|
|
4765
|
+
target: input.target
|
|
4766
|
+
});
|
|
4767
|
+
var deliverVoiceHandoff = async (input) => {
|
|
4768
|
+
if (!input.config || input.config.adapters.length === 0) {
|
|
4769
|
+
return;
|
|
4770
|
+
}
|
|
4771
|
+
const deliveries = {};
|
|
4772
|
+
for (const adapter of input.config.adapters) {
|
|
4773
|
+
if (adapter.actions && !adapter.actions.includes(input.handoff.action)) {
|
|
4774
|
+
deliveries[adapter.id] = createSkippedDelivery(adapter);
|
|
4775
|
+
continue;
|
|
4776
|
+
}
|
|
4777
|
+
try {
|
|
4778
|
+
const result = await adapter.handoff(input.handoff);
|
|
4779
|
+
deliveries[adapter.id] = {
|
|
4780
|
+
...result,
|
|
4781
|
+
adapterId: adapter.id,
|
|
4782
|
+
adapterKind: adapter.kind
|
|
4783
|
+
};
|
|
4784
|
+
} catch (error) {
|
|
4785
|
+
deliveries[adapter.id] = {
|
|
4786
|
+
adapterId: adapter.id,
|
|
4787
|
+
adapterKind: adapter.kind,
|
|
4788
|
+
error: toErrorMessage(error),
|
|
4789
|
+
status: "failed"
|
|
4790
|
+
};
|
|
4791
|
+
if (input.config.failMode === "throw") {
|
|
4792
|
+
throw error;
|
|
4793
|
+
}
|
|
4794
|
+
}
|
|
4795
|
+
}
|
|
4796
|
+
return {
|
|
4797
|
+
action: input.handoff.action,
|
|
4798
|
+
deliveries,
|
|
4799
|
+
status: aggregateHandoffStatus(deliveries)
|
|
4800
|
+
};
|
|
4801
|
+
};
|
|
4802
|
+
var createVoiceHandoffDeliveryRecord = (input) => {
|
|
4803
|
+
const now = Date.now();
|
|
4804
|
+
return {
|
|
4805
|
+
action: input.action,
|
|
4806
|
+
context: input.context,
|
|
4807
|
+
createdAt: now,
|
|
4808
|
+
deliveryAttempts: 0,
|
|
4809
|
+
deliveryStatus: "pending",
|
|
4810
|
+
id: input.id ?? createHandoffDeliveryId({
|
|
4811
|
+
action: input.action,
|
|
4812
|
+
sessionId: input.session.id
|
|
4813
|
+
}),
|
|
4814
|
+
metadata: input.metadata,
|
|
4815
|
+
reason: input.reason,
|
|
4816
|
+
result: input.result,
|
|
4817
|
+
session: input.session,
|
|
4818
|
+
sessionId: input.session.id,
|
|
4819
|
+
target: input.target,
|
|
4820
|
+
updatedAt: now
|
|
4821
|
+
};
|
|
4822
|
+
};
|
|
4823
|
+
var applyVoiceHandoffDeliveryResult = (delivery, result) => ({
|
|
4824
|
+
...delivery,
|
|
4825
|
+
deliveredAt: result.status === "delivered" || result.status === "skipped" ? Date.now() : delivery.deliveredAt,
|
|
4826
|
+
deliveries: result.deliveries,
|
|
4827
|
+
deliveryAttempts: (delivery.deliveryAttempts ?? 0) + 1,
|
|
4828
|
+
deliveryError: result.status === "failed" ? resolveHandoffDeliveryError(result.deliveries) : undefined,
|
|
4829
|
+
deliveryStatus: result.status,
|
|
4830
|
+
updatedAt: Date.now()
|
|
4831
|
+
});
|
|
4832
|
+
var deliverVoiceHandoffDelivery = async (options) => {
|
|
4833
|
+
const result = await deliverVoiceHandoff({
|
|
4834
|
+
config: {
|
|
4835
|
+
adapters: options.adapters,
|
|
4836
|
+
failMode: options.failMode
|
|
4837
|
+
},
|
|
4838
|
+
handoff: {
|
|
4839
|
+
action: options.delivery.action,
|
|
4840
|
+
api: options.api,
|
|
4841
|
+
context: options.delivery.context,
|
|
4842
|
+
metadata: options.delivery.metadata,
|
|
4843
|
+
reason: options.delivery.reason,
|
|
4844
|
+
result: options.delivery.result,
|
|
4845
|
+
session: options.delivery.session,
|
|
4846
|
+
target: options.delivery.target
|
|
4847
|
+
}
|
|
4848
|
+
});
|
|
4849
|
+
return result ? applyVoiceHandoffDeliveryResult(options.delivery, result) : {
|
|
4850
|
+
...options.delivery,
|
|
4851
|
+
deliveryAttempts: (options.delivery.deliveryAttempts ?? 0) + 1,
|
|
4852
|
+
deliveryStatus: "skipped",
|
|
4853
|
+
updatedAt: Date.now()
|
|
4854
|
+
};
|
|
4855
|
+
};
|
|
4856
|
+
var createVoiceMemoryHandoffDeliveryStore = () => {
|
|
4857
|
+
const deliveries = new Map;
|
|
4858
|
+
return {
|
|
4859
|
+
get: async (id) => deliveries.get(id),
|
|
4860
|
+
list: async () => [...deliveries.values()].sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)),
|
|
4861
|
+
remove: async (id) => {
|
|
4862
|
+
deliveries.delete(id);
|
|
4863
|
+
},
|
|
4864
|
+
set: async (id, delivery) => {
|
|
4865
|
+
deliveries.set(id, delivery);
|
|
4866
|
+
}
|
|
4867
|
+
};
|
|
4868
|
+
};
|
|
4869
|
+
var createVoiceWebhookHandoffAdapter = (options) => ({
|
|
4870
|
+
actions: options.actions,
|
|
4871
|
+
handoff: async (input) => {
|
|
4872
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
4873
|
+
if (typeof fetchImpl !== "function") {
|
|
4874
|
+
return {
|
|
4875
|
+
deliveredTo: options.url,
|
|
4876
|
+
error: "Handoff delivery failed: fetch is not available in this runtime.",
|
|
4877
|
+
status: "failed"
|
|
4878
|
+
};
|
|
4879
|
+
}
|
|
4880
|
+
const body = JSON.stringify(await options.body?.(input) ?? defaultWebhookBody(input));
|
|
4881
|
+
const headers = {
|
|
4882
|
+
"content-type": "application/json",
|
|
4883
|
+
...options.headers
|
|
4884
|
+
};
|
|
4885
|
+
if (options.signingSecret) {
|
|
4886
|
+
const timestamp = String(Date.now());
|
|
4887
|
+
headers["x-absolutejs-timestamp"] = timestamp;
|
|
4888
|
+
headers["x-absolutejs-signature"] = await signHandoffBody({
|
|
4889
|
+
body,
|
|
4890
|
+
secret: options.signingSecret,
|
|
4891
|
+
timestamp
|
|
4892
|
+
});
|
|
4893
|
+
}
|
|
4894
|
+
const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
|
|
4895
|
+
const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
|
|
4896
|
+
try {
|
|
4897
|
+
const response = await fetchImpl(options.url, {
|
|
4898
|
+
body,
|
|
4899
|
+
headers,
|
|
4900
|
+
method: options.method ?? "POST",
|
|
4901
|
+
signal: controller?.signal
|
|
4902
|
+
});
|
|
4903
|
+
if (!response.ok) {
|
|
4904
|
+
return {
|
|
4905
|
+
deliveredTo: options.url,
|
|
4906
|
+
error: `Handoff delivery failed with response ${response.status}.`,
|
|
4907
|
+
status: "failed"
|
|
4908
|
+
};
|
|
4909
|
+
}
|
|
4910
|
+
return {
|
|
4911
|
+
deliveredAt: Date.now(),
|
|
4912
|
+
deliveredTo: options.url,
|
|
4913
|
+
status: "delivered"
|
|
4914
|
+
};
|
|
4915
|
+
} finally {
|
|
4916
|
+
if (timeout) {
|
|
4917
|
+
clearTimeout(timeout);
|
|
4918
|
+
}
|
|
4919
|
+
}
|
|
4920
|
+
},
|
|
4921
|
+
id: options.id,
|
|
4922
|
+
kind: options.kind ?? "webhook"
|
|
4923
|
+
});
|
|
4924
|
+
var escapeXml = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
4925
|
+
var defaultTwilioTransferTwiML = (input) => {
|
|
4926
|
+
if (!input.target) {
|
|
4927
|
+
return "<Response><Hangup /></Response>";
|
|
4928
|
+
}
|
|
4929
|
+
return `<Response><Dial>${escapeXml(input.target)}</Dial></Response>`;
|
|
4930
|
+
};
|
|
4931
|
+
var resolveTwilioCallSid = async (resolver, input) => {
|
|
4932
|
+
if (typeof resolver === "function") {
|
|
4933
|
+
return resolver(input);
|
|
4934
|
+
}
|
|
4935
|
+
if (typeof resolver === "string" && resolver.length > 0) {
|
|
4936
|
+
return resolver;
|
|
4937
|
+
}
|
|
4938
|
+
const metadataSid = typeof input.metadata?.callSid === "string" ? input.metadata.callSid : undefined;
|
|
4939
|
+
const sessionMetadata = input.session.metadata && typeof input.session.metadata === "object" ? input.session.metadata : undefined;
|
|
4940
|
+
const sessionSid = typeof sessionMetadata?.callSid === "string" ? sessionMetadata.callSid : undefined;
|
|
4941
|
+
return metadataSid ?? sessionSid;
|
|
4942
|
+
};
|
|
4943
|
+
var createVoiceTwilioRedirectHandoffAdapter = (options) => ({
|
|
4944
|
+
actions: options.actions ?? ["transfer"],
|
|
4945
|
+
handoff: async (input) => {
|
|
4946
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
4947
|
+
const callSid = await resolveTwilioCallSid(options.callSid, input);
|
|
4948
|
+
if (!callSid) {
|
|
4949
|
+
return {
|
|
4950
|
+
error: "Twilio handoff requires a callSid.",
|
|
4951
|
+
status: "failed"
|
|
4952
|
+
};
|
|
4953
|
+
}
|
|
4954
|
+
if (typeof fetchImpl !== "function") {
|
|
4955
|
+
return {
|
|
4956
|
+
error: "Twilio handoff failed: fetch is not available in this runtime.",
|
|
4957
|
+
status: "failed"
|
|
4958
|
+
};
|
|
4959
|
+
}
|
|
4960
|
+
const url = `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(options.accountSid)}/Calls/${encodeURIComponent(callSid)}.json`;
|
|
4961
|
+
const body = new URLSearchParams({
|
|
4962
|
+
Twiml: await (options.buildTwiML?.(input) ?? defaultTwilioTransferTwiML(input))
|
|
4963
|
+
});
|
|
4964
|
+
const auth = btoa(`${options.accountSid}:${options.authToken}`);
|
|
4965
|
+
const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
|
|
4966
|
+
const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
|
|
4967
|
+
try {
|
|
4968
|
+
const response = await fetchImpl(url, {
|
|
4969
|
+
body,
|
|
4970
|
+
headers: {
|
|
4971
|
+
authorization: `Basic ${auth}`,
|
|
4972
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
4973
|
+
},
|
|
4974
|
+
method: "POST",
|
|
4975
|
+
signal: controller?.signal
|
|
4976
|
+
});
|
|
4977
|
+
if (!response.ok) {
|
|
4978
|
+
return {
|
|
4979
|
+
deliveredTo: url,
|
|
4980
|
+
error: `Twilio handoff failed with response ${response.status}.`,
|
|
4981
|
+
status: "failed"
|
|
4982
|
+
};
|
|
4983
|
+
}
|
|
4984
|
+
return {
|
|
4985
|
+
deliveredAt: Date.now(),
|
|
4986
|
+
deliveredTo: url,
|
|
4987
|
+
metadata: {
|
|
4988
|
+
callSid
|
|
4989
|
+
},
|
|
4990
|
+
status: "delivered"
|
|
4991
|
+
};
|
|
4992
|
+
} finally {
|
|
4993
|
+
if (timeout) {
|
|
4994
|
+
clearTimeout(timeout);
|
|
4995
|
+
}
|
|
4996
|
+
}
|
|
4997
|
+
},
|
|
4998
|
+
id: options.id ?? "twilio-redirect",
|
|
4999
|
+
kind: "twilio-redirect"
|
|
5000
|
+
});
|
|
3535
5001
|
|
|
3536
5002
|
// src/logger.ts
|
|
3537
5003
|
var noop2 = () => {};
|
|
@@ -3579,7 +5045,7 @@ var createEmptyCurrentTurn = () => ({
|
|
|
3579
5045
|
transcripts: []
|
|
3580
5046
|
});
|
|
3581
5047
|
var cloneTranscript = (transcript) => ({ ...transcript });
|
|
3582
|
-
var encodeBase64 = (chunk) =>
|
|
5048
|
+
var encodeBase64 = (chunk) => Buffer2.from(chunk).toString("base64");
|
|
3583
5049
|
var countWords2 = (text) => text.trim().split(/\s+/).filter(Boolean).length;
|
|
3584
5050
|
var normalizeText2 = (text) => text.trim().replace(/\s+/g, " ");
|
|
3585
5051
|
var getAudioChunkDurationMs = (chunk) => chunk.byteLength / (DEFAULT_FORMAT.sampleRateHz * DEFAULT_FORMAT.channels * 2) * 1000;
|
|
@@ -3725,6 +5191,7 @@ var pushCallLifecycleEvent = (session, input) => {
|
|
|
3725
5191
|
}
|
|
3726
5192
|
return lifecycle;
|
|
3727
5193
|
};
|
|
5194
|
+
var getLatestCallLifecycleEvent = (session) => session.call?.events.at(-1);
|
|
3728
5195
|
var createVoiceSession = (options) => {
|
|
3729
5196
|
const logger = resolveLogger(options.logger);
|
|
3730
5197
|
const reconnect = {
|
|
@@ -3749,7 +5216,7 @@ var createVoiceSession = (options) => {
|
|
|
3749
5216
|
} : undefined;
|
|
3750
5217
|
const appendTrace = async (input) => {
|
|
3751
5218
|
await options.trace?.append({
|
|
3752
|
-
at: Date.now(),
|
|
5219
|
+
at: input.at ?? Date.now(),
|
|
3753
5220
|
metadata: input.metadata,
|
|
3754
5221
|
payload: input.payload,
|
|
3755
5222
|
scenarioId: input.session?.scenarioId ?? options.scenarioId,
|
|
@@ -3758,6 +5225,13 @@ var createVoiceSession = (options) => {
|
|
|
3758
5225
|
type: input.type
|
|
3759
5226
|
});
|
|
3760
5227
|
};
|
|
5228
|
+
const appendTurnLatencyStage = async (input) => appendTrace({
|
|
5229
|
+
at: input.at,
|
|
5230
|
+
payload: { stage: input.stage },
|
|
5231
|
+
session: input.session,
|
|
5232
|
+
turnId: input.turnId,
|
|
5233
|
+
type: "turn_latency.stage"
|
|
5234
|
+
});
|
|
3761
5235
|
const phraseHints = options.phraseHints ?? [];
|
|
3762
5236
|
const lexicon = options.lexicon ?? [];
|
|
3763
5237
|
let socket = options.socket;
|
|
@@ -3825,6 +5299,64 @@ var createVoiceSession = (options) => {
|
|
|
3825
5299
|
});
|
|
3826
5300
|
}
|
|
3827
5301
|
};
|
|
5302
|
+
const sendCallLifecycle = async (session) => {
|
|
5303
|
+
const event = getLatestCallLifecycleEvent(session);
|
|
5304
|
+
if (!event) {
|
|
5305
|
+
return;
|
|
5306
|
+
}
|
|
5307
|
+
await send({
|
|
5308
|
+
event,
|
|
5309
|
+
sessionId: options.id,
|
|
5310
|
+
type: "call_lifecycle"
|
|
5311
|
+
});
|
|
5312
|
+
};
|
|
5313
|
+
const runHandoff = async (input) => {
|
|
5314
|
+
const queuedDelivery = options.handoff?.deliveryQueue ? createVoiceHandoffDeliveryRecord({
|
|
5315
|
+
action: input.action,
|
|
5316
|
+
context: options.context,
|
|
5317
|
+
metadata: input.metadata,
|
|
5318
|
+
reason: input.reason,
|
|
5319
|
+
result: input.result,
|
|
5320
|
+
session: input.session,
|
|
5321
|
+
target: input.target
|
|
5322
|
+
}) : undefined;
|
|
5323
|
+
if (queuedDelivery) {
|
|
5324
|
+
await options.handoff?.deliveryQueue?.set(queuedDelivery.id, queuedDelivery);
|
|
5325
|
+
}
|
|
5326
|
+
if (options.handoff?.enqueueOnly) {
|
|
5327
|
+
return;
|
|
5328
|
+
}
|
|
5329
|
+
const result = await deliverVoiceHandoff({
|
|
5330
|
+
config: options.handoff,
|
|
5331
|
+
handoff: {
|
|
5332
|
+
action: input.action,
|
|
5333
|
+
api,
|
|
5334
|
+
context: options.context,
|
|
5335
|
+
metadata: input.metadata,
|
|
5336
|
+
reason: input.reason,
|
|
5337
|
+
result: input.result,
|
|
5338
|
+
session: input.session,
|
|
5339
|
+
target: input.target
|
|
5340
|
+
}
|
|
5341
|
+
});
|
|
5342
|
+
if (!result) {
|
|
5343
|
+
return;
|
|
5344
|
+
}
|
|
5345
|
+
if (queuedDelivery) {
|
|
5346
|
+
const updatedDelivery = applyVoiceHandoffDeliveryResult(queuedDelivery, result);
|
|
5347
|
+
await options.handoff?.deliveryQueue?.set(updatedDelivery.id, updatedDelivery);
|
|
5348
|
+
}
|
|
5349
|
+
await appendTrace({
|
|
5350
|
+
metadata: input.metadata,
|
|
5351
|
+
payload: {
|
|
5352
|
+
...result,
|
|
5353
|
+
reason: input.reason,
|
|
5354
|
+
target: input.target
|
|
5355
|
+
},
|
|
5356
|
+
session: input.session,
|
|
5357
|
+
type: "call.handoff"
|
|
5358
|
+
});
|
|
5359
|
+
};
|
|
3828
5360
|
const readSession = async () => options.store.getOrCreate(options.id);
|
|
3829
5361
|
const writeSession = async (mutate) => {
|
|
3830
5362
|
const session = await options.store.getOrCreate(options.id);
|
|
@@ -4015,6 +5547,7 @@ var createVoiceSession = (options) => {
|
|
|
4015
5547
|
await appendTrace({
|
|
4016
5548
|
payload: {
|
|
4017
5549
|
disposition,
|
|
5550
|
+
metadata: input.metadata,
|
|
4018
5551
|
reason: input.reason,
|
|
4019
5552
|
target: input.target,
|
|
4020
5553
|
type: "end"
|
|
@@ -4022,6 +5555,7 @@ var createVoiceSession = (options) => {
|
|
|
4022
5555
|
session,
|
|
4023
5556
|
type: "call.lifecycle"
|
|
4024
5557
|
});
|
|
5558
|
+
await sendCallLifecycle(session);
|
|
4025
5559
|
await send({
|
|
4026
5560
|
sessionId: options.id,
|
|
4027
5561
|
type: "complete"
|
|
@@ -4101,6 +5635,15 @@ var createVoiceSession = (options) => {
|
|
|
4101
5635
|
session,
|
|
4102
5636
|
type: "call.lifecycle"
|
|
4103
5637
|
});
|
|
5638
|
+
await sendCallLifecycle(session);
|
|
5639
|
+
await runHandoff({
|
|
5640
|
+
action: "transfer",
|
|
5641
|
+
metadata: input.metadata,
|
|
5642
|
+
reason: input.reason,
|
|
5643
|
+
result: input.result,
|
|
5644
|
+
session,
|
|
5645
|
+
target: input.target
|
|
5646
|
+
});
|
|
4104
5647
|
await completeInternal(input.result, {
|
|
4105
5648
|
disposition: "transferred",
|
|
4106
5649
|
invokeOnComplete: false,
|
|
@@ -4126,6 +5669,14 @@ var createVoiceSession = (options) => {
|
|
|
4126
5669
|
session,
|
|
4127
5670
|
type: "call.lifecycle"
|
|
4128
5671
|
});
|
|
5672
|
+
await sendCallLifecycle(session);
|
|
5673
|
+
await runHandoff({
|
|
5674
|
+
action: "escalate",
|
|
5675
|
+
metadata: input.metadata,
|
|
5676
|
+
reason: input.reason,
|
|
5677
|
+
result: input.result,
|
|
5678
|
+
session
|
|
5679
|
+
});
|
|
4129
5680
|
await completeInternal(input.result, {
|
|
4130
5681
|
disposition: "escalated",
|
|
4131
5682
|
invokeOnComplete: false,
|
|
@@ -4148,6 +5699,13 @@ var createVoiceSession = (options) => {
|
|
|
4148
5699
|
session,
|
|
4149
5700
|
type: "call.lifecycle"
|
|
4150
5701
|
});
|
|
5702
|
+
await sendCallLifecycle(session);
|
|
5703
|
+
await runHandoff({
|
|
5704
|
+
action: "no-answer",
|
|
5705
|
+
metadata: input?.metadata,
|
|
5706
|
+
result: input?.result,
|
|
5707
|
+
session
|
|
5708
|
+
});
|
|
4151
5709
|
await completeInternal(input?.result, {
|
|
4152
5710
|
disposition: "no-answer",
|
|
4153
5711
|
invokeOnComplete: false,
|
|
@@ -4169,6 +5727,13 @@ var createVoiceSession = (options) => {
|
|
|
4169
5727
|
session,
|
|
4170
5728
|
type: "call.lifecycle"
|
|
4171
5729
|
});
|
|
5730
|
+
await sendCallLifecycle(session);
|
|
5731
|
+
await runHandoff({
|
|
5732
|
+
action: "voicemail",
|
|
5733
|
+
metadata: input?.metadata,
|
|
5734
|
+
result: input?.result,
|
|
5735
|
+
session
|
|
5736
|
+
});
|
|
4172
5737
|
await completeInternal(input?.result, {
|
|
4173
5738
|
disposition: "voicemail",
|
|
4174
5739
|
invokeOnComplete: false,
|
|
@@ -4617,6 +6182,13 @@ var createVoiceSession = (options) => {
|
|
|
4617
6182
|
turnId: activeTTSTurnId,
|
|
4618
6183
|
type: "audio"
|
|
4619
6184
|
});
|
|
6185
|
+
if (activeTTSTurnId) {
|
|
6186
|
+
await appendTurnLatencyStage({
|
|
6187
|
+
at: receivedAt,
|
|
6188
|
+
stage: "assistant_audio_received",
|
|
6189
|
+
turnId: activeTTSTurnId
|
|
6190
|
+
});
|
|
6191
|
+
}
|
|
4620
6192
|
});
|
|
4621
6193
|
});
|
|
4622
6194
|
openedSession.on("error", (event) => {
|
|
@@ -4675,6 +6247,7 @@ var createVoiceSession = (options) => {
|
|
|
4675
6247
|
voicemail: committedOutput?.voicemail
|
|
4676
6248
|
};
|
|
4677
6249
|
if (output?.assistantText) {
|
|
6250
|
+
const assistantTextStartedAt = Date.now();
|
|
4678
6251
|
await writeSession((currentSession) => {
|
|
4679
6252
|
setTurnResult(currentSession, turn.id, {
|
|
4680
6253
|
assistantText: output.assistantText
|
|
@@ -4685,6 +6258,12 @@ var createVoiceSession = (options) => {
|
|
|
4685
6258
|
turnId: turn.id,
|
|
4686
6259
|
type: "assistant"
|
|
4687
6260
|
});
|
|
6261
|
+
await appendTurnLatencyStage({
|
|
6262
|
+
at: assistantTextStartedAt,
|
|
6263
|
+
session,
|
|
6264
|
+
stage: "assistant_text_started",
|
|
6265
|
+
turnId: turn.id
|
|
6266
|
+
});
|
|
4688
6267
|
await appendTrace({
|
|
4689
6268
|
payload: {
|
|
4690
6269
|
text: output.assistantText,
|
|
@@ -4699,7 +6278,18 @@ var createVoiceSession = (options) => {
|
|
|
4699
6278
|
if (activeTTSSession) {
|
|
4700
6279
|
const ttsStartedAt = Date.now();
|
|
4701
6280
|
activeTTSTurnId = turn.id;
|
|
6281
|
+
await appendTurnLatencyStage({
|
|
6282
|
+
at: ttsStartedAt,
|
|
6283
|
+
session,
|
|
6284
|
+
stage: "tts_send_started",
|
|
6285
|
+
turnId: turn.id
|
|
6286
|
+
});
|
|
4702
6287
|
await activeTTSSession.send(output.assistantText);
|
|
6288
|
+
await appendTurnLatencyStage({
|
|
6289
|
+
session,
|
|
6290
|
+
stage: "tts_send_completed",
|
|
6291
|
+
turnId: turn.id
|
|
6292
|
+
});
|
|
4703
6293
|
await appendTrace({
|
|
4704
6294
|
payload: {
|
|
4705
6295
|
elapsedMs: Date.now() - ttsStartedAt,
|
|
@@ -4896,6 +6486,30 @@ var createVoiceSession = (options) => {
|
|
|
4896
6486
|
turnId: turn.id,
|
|
4897
6487
|
type: "turn.cost"
|
|
4898
6488
|
});
|
|
6489
|
+
const firstTranscriptAt = turn.transcripts.map((transcript) => transcript.endedAtMs ?? transcript.startedAtMs).filter((value) => typeof value === "number").sort((left, right) => left - right)[0];
|
|
6490
|
+
const finalTranscriptAt = turn.transcripts.filter((transcript) => transcript.isFinal).map((transcript) => transcript.endedAtMs ?? transcript.startedAtMs).filter((value) => typeof value === "number").sort((left, right) => left - right)[0];
|
|
6491
|
+
if (firstTranscriptAt !== undefined) {
|
|
6492
|
+
await appendTurnLatencyStage({
|
|
6493
|
+
at: firstTranscriptAt,
|
|
6494
|
+
session: updatedSession,
|
|
6495
|
+
stage: "speech_detected",
|
|
6496
|
+
turnId: turn.id
|
|
6497
|
+
});
|
|
6498
|
+
}
|
|
6499
|
+
if (finalTranscriptAt !== undefined) {
|
|
6500
|
+
await appendTurnLatencyStage({
|
|
6501
|
+
at: finalTranscriptAt,
|
|
6502
|
+
session: updatedSession,
|
|
6503
|
+
stage: "final_transcript",
|
|
6504
|
+
turnId: turn.id
|
|
6505
|
+
});
|
|
6506
|
+
}
|
|
6507
|
+
await appendTurnLatencyStage({
|
|
6508
|
+
at: turn.committedAt,
|
|
6509
|
+
session: updatedSession,
|
|
6510
|
+
stage: "turn_committed",
|
|
6511
|
+
turnId: turn.id
|
|
6512
|
+
});
|
|
4899
6513
|
await send({
|
|
4900
6514
|
turn,
|
|
4901
6515
|
type: "turn"
|
|
@@ -4955,6 +6569,7 @@ var createVoiceSession = (options) => {
|
|
|
4955
6569
|
session,
|
|
4956
6570
|
type: "call.lifecycle"
|
|
4957
6571
|
});
|
|
6572
|
+
await sendCallLifecycle(session);
|
|
4958
6573
|
}
|
|
4959
6574
|
await send({
|
|
4960
6575
|
sessionId: options.id,
|
|
@@ -5545,7 +7160,7 @@ var createVoiceCallReviewFromLiveTelephonyReport = (report, options = {}) => {
|
|
|
5545
7160
|
}
|
|
5546
7161
|
};
|
|
5547
7162
|
};
|
|
5548
|
-
var
|
|
7163
|
+
var toErrorMessage2 = (error) => {
|
|
5549
7164
|
if (typeof error === "string" && error.trim().length > 0) {
|
|
5550
7165
|
return error;
|
|
5551
7166
|
}
|
|
@@ -5632,7 +7247,7 @@ var createVoiceCallReviewRecorder = (options = {}) => {
|
|
|
5632
7247
|
};
|
|
5633
7248
|
},
|
|
5634
7249
|
recordError: (error) => {
|
|
5635
|
-
const message =
|
|
7250
|
+
const message = toErrorMessage2(error);
|
|
5636
7251
|
errors.push(message);
|
|
5637
7252
|
push("turn", "error", {
|
|
5638
7253
|
reason: message
|
|
@@ -6338,10 +7953,870 @@ var runVoiceSessionBenchmarkSeries = async (input) => {
|
|
|
6338
7953
|
});
|
|
6339
7954
|
};
|
|
6340
7955
|
// src/telephony/twilio.ts
|
|
6341
|
-
import { Buffer as
|
|
7956
|
+
import { Buffer as Buffer3 } from "buffer";
|
|
7957
|
+
import { Elysia as Elysia2 } from "elysia";
|
|
7958
|
+
|
|
7959
|
+
// src/telephonyOutcome.ts
|
|
7960
|
+
import { Elysia } from "elysia";
|
|
7961
|
+
var DEFAULT_COMPLETED_STATUSES = [
|
|
7962
|
+
"answered",
|
|
7963
|
+
"completed",
|
|
7964
|
+
"complete",
|
|
7965
|
+
"connected",
|
|
7966
|
+
"in-progress",
|
|
7967
|
+
"live"
|
|
7968
|
+
];
|
|
7969
|
+
var DEFAULT_NO_ANSWER_STATUSES = [
|
|
7970
|
+
"busy",
|
|
7971
|
+
"canceled",
|
|
7972
|
+
"cancelled",
|
|
7973
|
+
"failed",
|
|
7974
|
+
"no-answer",
|
|
7975
|
+
"no_answer",
|
|
7976
|
+
"not-answered",
|
|
7977
|
+
"ring-no-answer",
|
|
7978
|
+
"timeout",
|
|
7979
|
+
"unanswered"
|
|
7980
|
+
];
|
|
7981
|
+
var DEFAULT_VOICEMAIL_STATUSES = [
|
|
7982
|
+
"answering-machine",
|
|
7983
|
+
"machine",
|
|
7984
|
+
"voicemail",
|
|
7985
|
+
"voice-mail"
|
|
7986
|
+
];
|
|
7987
|
+
var DEFAULT_TRANSFER_STATUSES = ["bridged", "forwarded", "transferred"];
|
|
7988
|
+
var DEFAULT_ESCALATION_STATUSES = ["escalated", "human-required", "operator"];
|
|
7989
|
+
var DEFAULT_FAILED_STATUSES = ["busy", "failed", "no-answer"];
|
|
7990
|
+
var DEFAULT_MACHINE_VOICEMAIL_VALUES = [
|
|
7991
|
+
"answering-machine",
|
|
7992
|
+
"fax",
|
|
7993
|
+
"machine",
|
|
7994
|
+
"machine-end-beep",
|
|
7995
|
+
"machine-end-other",
|
|
7996
|
+
"machine-start",
|
|
7997
|
+
"voicemail"
|
|
7998
|
+
];
|
|
7999
|
+
var DEFAULT_NO_ANSWER_SIP_CODES = [408, 480, 486, 487, 603];
|
|
8000
|
+
var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
8001
|
+
|
|
8002
|
+
class VoiceTelephonyWebhookVerificationError extends Error {
|
|
8003
|
+
result;
|
|
8004
|
+
constructor(result) {
|
|
8005
|
+
super(result.ok ? "telephony webhook verified" : result.reason);
|
|
8006
|
+
this.name = "VoiceTelephonyWebhookVerificationError";
|
|
8007
|
+
this.result = result;
|
|
8008
|
+
}
|
|
8009
|
+
}
|
|
8010
|
+
var createMemoryVoiceTelephonyWebhookIdempotencyStore = () => {
|
|
8011
|
+
const decisions = new Map;
|
|
8012
|
+
return {
|
|
8013
|
+
get: (key) => decisions.get(key),
|
|
8014
|
+
set: (key, decision) => {
|
|
8015
|
+
decisions.set(key, decision);
|
|
8016
|
+
}
|
|
8017
|
+
};
|
|
8018
|
+
};
|
|
8019
|
+
var normalizeToken = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/\s+/g, "-").replace(/_+/g, "-") : undefined;
|
|
8020
|
+
var firstString = (source, keys) => {
|
|
8021
|
+
for (const key of keys) {
|
|
8022
|
+
const value = source[key];
|
|
8023
|
+
if (typeof value === "string" && value.trim()) {
|
|
8024
|
+
return value.trim();
|
|
8025
|
+
}
|
|
8026
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
8027
|
+
return String(value);
|
|
8028
|
+
}
|
|
8029
|
+
}
|
|
8030
|
+
};
|
|
8031
|
+
var firstNumber = (source, keys) => {
|
|
8032
|
+
for (const key of keys) {
|
|
8033
|
+
const value = source[key];
|
|
8034
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
8035
|
+
return value;
|
|
8036
|
+
}
|
|
8037
|
+
if (typeof value === "string" && value.trim()) {
|
|
8038
|
+
const parsed = Number(value);
|
|
8039
|
+
if (Number.isFinite(parsed)) {
|
|
8040
|
+
return parsed;
|
|
8041
|
+
}
|
|
8042
|
+
}
|
|
8043
|
+
}
|
|
8044
|
+
};
|
|
8045
|
+
var parseMaybeJSON = (value) => {
|
|
8046
|
+
try {
|
|
8047
|
+
return JSON.parse(value);
|
|
8048
|
+
} catch {
|
|
8049
|
+
return;
|
|
8050
|
+
}
|
|
8051
|
+
};
|
|
8052
|
+
var flattenPayload = (value) => {
|
|
8053
|
+
if (!isRecord(value)) {
|
|
8054
|
+
return {};
|
|
8055
|
+
}
|
|
8056
|
+
const data = isRecord(value.data) ? value.data : undefined;
|
|
8057
|
+
const payload = isRecord(value.payload) ? value.payload : undefined;
|
|
8058
|
+
const event = isRecord(value.event) ? value.event : undefined;
|
|
8059
|
+
return {
|
|
8060
|
+
...value,
|
|
8061
|
+
...payload,
|
|
8062
|
+
...event,
|
|
8063
|
+
...data,
|
|
8064
|
+
...isRecord(data?.payload) ? data.payload : undefined
|
|
8065
|
+
};
|
|
8066
|
+
};
|
|
8067
|
+
var toBase64 = (bytes) => Buffer.from(new Uint8Array(bytes)).toString("base64");
|
|
8068
|
+
var timingSafeEqual = (left, right) => {
|
|
8069
|
+
const encoder = new TextEncoder;
|
|
8070
|
+
const leftBytes = encoder.encode(left);
|
|
8071
|
+
const rightBytes = encoder.encode(right);
|
|
8072
|
+
if (leftBytes.length !== rightBytes.length) {
|
|
8073
|
+
return false;
|
|
8074
|
+
}
|
|
8075
|
+
let diff = 0;
|
|
8076
|
+
for (let index = 0;index < leftBytes.length; index += 1) {
|
|
8077
|
+
diff |= leftBytes[index] ^ rightBytes[index];
|
|
8078
|
+
}
|
|
8079
|
+
return diff === 0;
|
|
8080
|
+
};
|
|
8081
|
+
var signHmacSHA1Base64 = async (secret, payload) => {
|
|
8082
|
+
const encoder = new TextEncoder;
|
|
8083
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
|
|
8084
|
+
hash: "SHA-1",
|
|
8085
|
+
name: "HMAC"
|
|
8086
|
+
}, false, ["sign"]);
|
|
8087
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
8088
|
+
return toBase64(signature);
|
|
8089
|
+
};
|
|
8090
|
+
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("");
|
|
8091
|
+
var normalizeList = (values, fallback) => new Set((values ?? fallback).map(normalizeToken).filter(Boolean));
|
|
8092
|
+
var metadataValue = (metadata, keys) => {
|
|
8093
|
+
for (const key of keys) {
|
|
8094
|
+
const value = metadata?.[key];
|
|
8095
|
+
if (typeof value === "string" && value.trim()) {
|
|
8096
|
+
return value.trim();
|
|
8097
|
+
}
|
|
8098
|
+
}
|
|
8099
|
+
};
|
|
8100
|
+
var resolveTransferTarget = (event, policy) => {
|
|
8101
|
+
if (typeof event.target === "string" && event.target.trim()) {
|
|
8102
|
+
return event.target.trim();
|
|
8103
|
+
}
|
|
8104
|
+
const metadataTarget = metadataValue(event.metadata, [
|
|
8105
|
+
"transferTarget",
|
|
8106
|
+
"target",
|
|
8107
|
+
"queue",
|
|
8108
|
+
"department"
|
|
8109
|
+
]);
|
|
8110
|
+
if (metadataTarget) {
|
|
8111
|
+
return metadataTarget;
|
|
8112
|
+
}
|
|
8113
|
+
if (typeof policy.transferTarget === "function") {
|
|
8114
|
+
const target = policy.transferTarget(event);
|
|
8115
|
+
return typeof target === "string" && target.trim() ? target.trim() : undefined;
|
|
8116
|
+
}
|
|
8117
|
+
return typeof policy.transferTarget === "string" && policy.transferTarget.trim() ? policy.transferTarget.trim() : undefined;
|
|
8118
|
+
};
|
|
8119
|
+
var mergeMetadata = (event, policy) => ({
|
|
8120
|
+
...policy.includeProviderPayload ? {
|
|
8121
|
+
answeredBy: event.answeredBy,
|
|
8122
|
+
durationMs: event.durationMs,
|
|
8123
|
+
provider: event.provider,
|
|
8124
|
+
reason: event.reason,
|
|
8125
|
+
sipCode: event.sipCode,
|
|
8126
|
+
status: event.status
|
|
8127
|
+
} : undefined,
|
|
8128
|
+
...policy.metadata,
|
|
8129
|
+
...event.metadata
|
|
8130
|
+
});
|
|
8131
|
+
var withDecisionDefaults = (decision, input) => {
|
|
8132
|
+
if (typeof decision === "string") {
|
|
8133
|
+
return buildDecision(decision, input);
|
|
8134
|
+
}
|
|
8135
|
+
return {
|
|
8136
|
+
...buildDecision(decision.action, input),
|
|
8137
|
+
...decision,
|
|
8138
|
+
confidence: decision.confidence ?? "high",
|
|
8139
|
+
metadata: {
|
|
8140
|
+
...mergeMetadata(input.event, input.policy),
|
|
8141
|
+
...decision.metadata
|
|
8142
|
+
},
|
|
8143
|
+
source: decision.source ?? input.source,
|
|
8144
|
+
target: decision.target ?? (decision.action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined)
|
|
8145
|
+
};
|
|
8146
|
+
};
|
|
8147
|
+
var dispositionForAction = (action) => {
|
|
8148
|
+
switch (action) {
|
|
8149
|
+
case "complete":
|
|
8150
|
+
return "completed";
|
|
8151
|
+
case "escalate":
|
|
8152
|
+
return "escalated";
|
|
8153
|
+
case "no-answer":
|
|
8154
|
+
return "no-answer";
|
|
8155
|
+
case "transfer":
|
|
8156
|
+
return "transferred";
|
|
8157
|
+
case "voicemail":
|
|
8158
|
+
return "voicemail";
|
|
8159
|
+
default:
|
|
8160
|
+
return;
|
|
8161
|
+
}
|
|
8162
|
+
};
|
|
8163
|
+
var buildDecision = (action, input) => ({
|
|
8164
|
+
action,
|
|
8165
|
+
confidence: action === "ignore" ? "low" : "high",
|
|
8166
|
+
disposition: dispositionForAction(action),
|
|
8167
|
+
metadata: mergeMetadata(input.event, input.policy),
|
|
8168
|
+
reason: input.event.reason,
|
|
8169
|
+
source: input.source,
|
|
8170
|
+
target: action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined
|
|
8171
|
+
});
|
|
8172
|
+
var createVoiceTelephonyOutcomePolicy = (policy = {}) => ({
|
|
8173
|
+
completedStatuses: policy.completedStatuses ?? DEFAULT_COMPLETED_STATUSES,
|
|
8174
|
+
escalationStatuses: policy.escalationStatuses ?? DEFAULT_ESCALATION_STATUSES,
|
|
8175
|
+
failedAsNoAnswer: policy.failedAsNoAnswer ?? true,
|
|
8176
|
+
failedStatuses: policy.failedStatuses ?? DEFAULT_FAILED_STATUSES,
|
|
8177
|
+
includeProviderPayload: policy.includeProviderPayload ?? true,
|
|
8178
|
+
machineDetectionVoicemailValues: policy.machineDetectionVoicemailValues ?? DEFAULT_MACHINE_VOICEMAIL_VALUES,
|
|
8179
|
+
metadata: policy.metadata,
|
|
8180
|
+
minAnsweredDurationMs: policy.minAnsweredDurationMs,
|
|
8181
|
+
noAnswerOnZeroDuration: policy.noAnswerOnZeroDuration ?? true,
|
|
8182
|
+
noAnswerSipCodes: policy.noAnswerSipCodes ?? DEFAULT_NO_ANSWER_SIP_CODES,
|
|
8183
|
+
noAnswerStatuses: policy.noAnswerStatuses ?? DEFAULT_NO_ANSWER_STATUSES,
|
|
8184
|
+
statusMap: policy.statusMap,
|
|
8185
|
+
transferStatuses: policy.transferStatuses ?? DEFAULT_TRANSFER_STATUSES,
|
|
8186
|
+
transferTarget: policy.transferTarget,
|
|
8187
|
+
voicemailStatuses: policy.voicemailStatuses ?? DEFAULT_VOICEMAIL_STATUSES
|
|
8188
|
+
});
|
|
8189
|
+
var resolveVoiceTelephonyOutcome = (event, policyInput = {}) => {
|
|
8190
|
+
const policy = createVoiceTelephonyOutcomePolicy(policyInput);
|
|
8191
|
+
const status = normalizeToken(event.status);
|
|
8192
|
+
const provider = normalizeToken(event.provider);
|
|
8193
|
+
const answeredBy = normalizeToken(event.answeredBy);
|
|
8194
|
+
const target = resolveTransferTarget(event, policy);
|
|
8195
|
+
if (status) {
|
|
8196
|
+
const mapped = policy.statusMap?.[status] ?? (provider ? policy.statusMap?.[`${provider}:${status}`] : undefined);
|
|
8197
|
+
if (mapped) {
|
|
8198
|
+
return withDecisionDefaults(mapped, {
|
|
8199
|
+
event,
|
|
8200
|
+
policy,
|
|
8201
|
+
source: "policy"
|
|
8202
|
+
});
|
|
8203
|
+
}
|
|
8204
|
+
}
|
|
8205
|
+
if (answeredBy && normalizeList(policy.machineDetectionVoicemailValues, []).has(answeredBy)) {
|
|
8206
|
+
return buildDecision("voicemail", { event, policy, source: "answered-by" });
|
|
8207
|
+
}
|
|
8208
|
+
if (typeof event.sipCode === "number" && policy.noAnswerSipCodes.includes(event.sipCode)) {
|
|
8209
|
+
return buildDecision("no-answer", { event, policy, source: "sip" });
|
|
8210
|
+
}
|
|
8211
|
+
if (target && status && normalizeList(policy.transferStatuses, []).has(status)) {
|
|
8212
|
+
return buildDecision("transfer", { event, policy, source: "status" });
|
|
8213
|
+
}
|
|
8214
|
+
if (status && normalizeList(policy.voicemailStatuses, []).has(status)) {
|
|
8215
|
+
return buildDecision("voicemail", { event, policy, source: "status" });
|
|
8216
|
+
}
|
|
8217
|
+
if (status && normalizeList(policy.escalationStatuses, []).has(status)) {
|
|
8218
|
+
return buildDecision("escalate", { event, policy, source: "status" });
|
|
8219
|
+
}
|
|
8220
|
+
if (status && (policy.failedAsNoAnswer ? normalizeList(policy.noAnswerStatuses, []).has(status) || normalizeList(policy.failedStatuses, []).has(status) : normalizeList(policy.noAnswerStatuses, []).has(status))) {
|
|
8221
|
+
return buildDecision("no-answer", { event, policy, source: "status" });
|
|
8222
|
+
}
|
|
8223
|
+
if (policy.noAnswerOnZeroDuration && typeof event.durationMs === "number" && event.durationMs <= 0) {
|
|
8224
|
+
return buildDecision("no-answer", { event, policy, source: "duration" });
|
|
8225
|
+
}
|
|
8226
|
+
if (typeof policy.minAnsweredDurationMs === "number" && typeof event.durationMs === "number" && event.durationMs < policy.minAnsweredDurationMs) {
|
|
8227
|
+
return {
|
|
8228
|
+
...buildDecision("no-answer", { event, policy, source: "duration" }),
|
|
8229
|
+
confidence: "medium"
|
|
8230
|
+
};
|
|
8231
|
+
}
|
|
8232
|
+
if (status && normalizeList(policy.completedStatuses, []).has(status)) {
|
|
8233
|
+
return buildDecision("complete", { event, policy, source: "status" });
|
|
8234
|
+
}
|
|
8235
|
+
if (target) {
|
|
8236
|
+
return {
|
|
8237
|
+
...buildDecision("transfer", { event, policy, source: "explicit-target" }),
|
|
8238
|
+
confidence: "medium"
|
|
8239
|
+
};
|
|
8240
|
+
}
|
|
8241
|
+
return buildDecision("ignore", { event, policy, source: "status" });
|
|
8242
|
+
};
|
|
8243
|
+
var voiceTelephonyOutcomeToRouteResult = (decision, result) => {
|
|
8244
|
+
switch (decision.action) {
|
|
8245
|
+
case "complete":
|
|
8246
|
+
return { complete: true, result };
|
|
8247
|
+
case "escalate":
|
|
8248
|
+
return {
|
|
8249
|
+
escalate: {
|
|
8250
|
+
metadata: decision.metadata,
|
|
8251
|
+
reason: decision.reason ?? "telephony-escalation"
|
|
8252
|
+
},
|
|
8253
|
+
result
|
|
8254
|
+
};
|
|
8255
|
+
case "no-answer":
|
|
8256
|
+
return {
|
|
8257
|
+
noAnswer: {
|
|
8258
|
+
metadata: decision.metadata
|
|
8259
|
+
},
|
|
8260
|
+
result
|
|
8261
|
+
};
|
|
8262
|
+
case "transfer":
|
|
8263
|
+
if (!decision.target) {
|
|
8264
|
+
return { result };
|
|
8265
|
+
}
|
|
8266
|
+
return {
|
|
8267
|
+
result,
|
|
8268
|
+
transfer: {
|
|
8269
|
+
metadata: decision.metadata,
|
|
8270
|
+
reason: decision.reason,
|
|
8271
|
+
target: decision.target
|
|
8272
|
+
}
|
|
8273
|
+
};
|
|
8274
|
+
case "voicemail":
|
|
8275
|
+
return {
|
|
8276
|
+
result,
|
|
8277
|
+
voicemail: {
|
|
8278
|
+
metadata: decision.metadata
|
|
8279
|
+
}
|
|
8280
|
+
};
|
|
8281
|
+
default:
|
|
8282
|
+
return { result };
|
|
8283
|
+
}
|
|
8284
|
+
};
|
|
8285
|
+
var applyVoiceTelephonyOutcome = async (api, decision, result) => {
|
|
8286
|
+
switch (decision.action) {
|
|
8287
|
+
case "complete":
|
|
8288
|
+
await api.complete(result);
|
|
8289
|
+
break;
|
|
8290
|
+
case "escalate":
|
|
8291
|
+
await api.escalate({
|
|
8292
|
+
metadata: decision.metadata,
|
|
8293
|
+
reason: decision.reason ?? "telephony-escalation",
|
|
8294
|
+
result
|
|
8295
|
+
});
|
|
8296
|
+
break;
|
|
8297
|
+
case "no-answer":
|
|
8298
|
+
await api.markNoAnswer({
|
|
8299
|
+
metadata: decision.metadata,
|
|
8300
|
+
result
|
|
8301
|
+
});
|
|
8302
|
+
break;
|
|
8303
|
+
case "transfer":
|
|
8304
|
+
if (!decision.target) {
|
|
8305
|
+
return;
|
|
8306
|
+
}
|
|
8307
|
+
await api.transfer({
|
|
8308
|
+
metadata: decision.metadata,
|
|
8309
|
+
reason: decision.reason,
|
|
8310
|
+
result,
|
|
8311
|
+
target: decision.target
|
|
8312
|
+
});
|
|
8313
|
+
break;
|
|
8314
|
+
case "voicemail":
|
|
8315
|
+
await api.markVoicemail({
|
|
8316
|
+
metadata: decision.metadata,
|
|
8317
|
+
result
|
|
8318
|
+
});
|
|
8319
|
+
break;
|
|
8320
|
+
default:
|
|
8321
|
+
break;
|
|
8322
|
+
}
|
|
8323
|
+
};
|
|
8324
|
+
var parseRequestBodyText = (input) => {
|
|
8325
|
+
const { contentType, text } = input;
|
|
8326
|
+
if (!text) {
|
|
8327
|
+
return {};
|
|
8328
|
+
}
|
|
8329
|
+
if (contentType.includes("application/json")) {
|
|
8330
|
+
return parseMaybeJSON(text) ?? {};
|
|
8331
|
+
}
|
|
8332
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
8333
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
8334
|
+
}
|
|
8335
|
+
return parseMaybeJSON(text) ?? Object.fromEntries(new URLSearchParams(text));
|
|
8336
|
+
};
|
|
8337
|
+
var readRequestBody = async (request) => {
|
|
8338
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
8339
|
+
const text = await request.text();
|
|
8340
|
+
return {
|
|
8341
|
+
body: parseRequestBodyText({ contentType, text }),
|
|
8342
|
+
rawBody: text
|
|
8343
|
+
};
|
|
8344
|
+
};
|
|
8345
|
+
var signVoiceTwilioWebhook = async (input) => signHmacSHA1Base64(input.authToken, `${input.url}${sortedParamsForSignature(input.body ?? {})}`);
|
|
8346
|
+
var verifyVoiceTwilioWebhookSignature = async (input) => {
|
|
8347
|
+
if (!input.authToken) {
|
|
8348
|
+
return { ok: false, reason: "missing-secret" };
|
|
8349
|
+
}
|
|
8350
|
+
const signature = input.headers.get("x-twilio-signature");
|
|
8351
|
+
if (!signature) {
|
|
8352
|
+
return { ok: false, reason: "missing-signature" };
|
|
8353
|
+
}
|
|
8354
|
+
const expected = await signVoiceTwilioWebhook({
|
|
8355
|
+
authToken: input.authToken,
|
|
8356
|
+
body: input.body,
|
|
8357
|
+
url: input.url
|
|
8358
|
+
});
|
|
8359
|
+
return timingSafeEqual(signature, expected) ? { ok: true } : { ok: false, reason: "invalid-signature" };
|
|
8360
|
+
};
|
|
8361
|
+
var resolveVerificationUrl = (option, input) => typeof option === "function" ? option(input) : option ?? input.request.url;
|
|
8362
|
+
var verifyVoiceTelephonyWebhook = async (input) => {
|
|
8363
|
+
if (input.options.verify) {
|
|
8364
|
+
return input.options.verify({
|
|
8365
|
+
body: input.body,
|
|
8366
|
+
headers: input.request.headers,
|
|
8367
|
+
provider: input.provider,
|
|
8368
|
+
query: input.query,
|
|
8369
|
+
rawBody: input.rawBody,
|
|
8370
|
+
request: input.request
|
|
8371
|
+
});
|
|
8372
|
+
}
|
|
8373
|
+
if (!input.options.signingSecret) {
|
|
8374
|
+
return input.options.requireVerification ? { ok: false, reason: "missing-secret" } : { ok: true };
|
|
8375
|
+
}
|
|
8376
|
+
if (input.provider !== "twilio") {
|
|
8377
|
+
return { ok: false, reason: "unsupported-provider" };
|
|
8378
|
+
}
|
|
8379
|
+
return verifyVoiceTwilioWebhookSignature({
|
|
8380
|
+
authToken: input.options.signingSecret,
|
|
8381
|
+
body: input.body,
|
|
8382
|
+
headers: input.request.headers,
|
|
8383
|
+
url: resolveVerificationUrl(input.options.verificationUrl, {
|
|
8384
|
+
query: input.query,
|
|
8385
|
+
request: input.request
|
|
8386
|
+
})
|
|
8387
|
+
});
|
|
8388
|
+
};
|
|
8389
|
+
var durationMsFromSeconds = (value) => typeof value === "number" ? value * 1000 : undefined;
|
|
8390
|
+
var parseVoiceTelephonyWebhookEvent = (input) => {
|
|
8391
|
+
const payload = flattenPayload(input.body);
|
|
8392
|
+
const provider = firstString(payload, ["provider", "Provider"]) ?? input.provider;
|
|
8393
|
+
const status = firstString(payload, [
|
|
8394
|
+
"CallStatus",
|
|
8395
|
+
"call_status",
|
|
8396
|
+
"callStatus",
|
|
8397
|
+
"DialCallStatus",
|
|
8398
|
+
"dial_call_status",
|
|
8399
|
+
"status",
|
|
8400
|
+
"event_type",
|
|
8401
|
+
"type"
|
|
8402
|
+
]);
|
|
8403
|
+
const durationMs = firstNumber(payload, ["durationMs", "duration_ms"]) ?? durationMsFromSeconds(firstNumber(payload, [
|
|
8404
|
+
"CallDuration",
|
|
8405
|
+
"call_duration",
|
|
8406
|
+
"callDuration",
|
|
8407
|
+
"DialCallDuration",
|
|
8408
|
+
"dial_call_duration",
|
|
8409
|
+
"duration"
|
|
8410
|
+
]));
|
|
8411
|
+
const sipCode = firstNumber(payload, [
|
|
8412
|
+
"SipResponseCode",
|
|
8413
|
+
"sip_response_code",
|
|
8414
|
+
"sipCode",
|
|
8415
|
+
"sip_code",
|
|
8416
|
+
"hangupCauseCode"
|
|
8417
|
+
]);
|
|
8418
|
+
const from = firstString(payload, ["From", "from", "caller_id", "callerId"]);
|
|
8419
|
+
const to = firstString(payload, ["To", "to", "called_number", "calledNumber"]);
|
|
8420
|
+
const target = firstString(payload, [
|
|
8421
|
+
"transferTarget",
|
|
8422
|
+
"TransferTarget",
|
|
8423
|
+
"target",
|
|
8424
|
+
"queue",
|
|
8425
|
+
"department"
|
|
8426
|
+
]);
|
|
8427
|
+
return {
|
|
8428
|
+
answeredBy: firstString(payload, [
|
|
8429
|
+
"AnsweredBy",
|
|
8430
|
+
"answered_by",
|
|
8431
|
+
"answeredBy",
|
|
8432
|
+
"machineDetection",
|
|
8433
|
+
"machine_detection"
|
|
8434
|
+
]),
|
|
8435
|
+
durationMs,
|
|
8436
|
+
from,
|
|
8437
|
+
metadata: {
|
|
8438
|
+
...input.query,
|
|
8439
|
+
...payload
|
|
8440
|
+
},
|
|
8441
|
+
provider,
|
|
8442
|
+
reason: firstString(payload, [
|
|
8443
|
+
"Reason",
|
|
8444
|
+
"reason",
|
|
8445
|
+
"HangupCause",
|
|
8446
|
+
"hangup_cause",
|
|
8447
|
+
"hangupCause"
|
|
8448
|
+
]),
|
|
8449
|
+
sipCode,
|
|
8450
|
+
status,
|
|
8451
|
+
target,
|
|
8452
|
+
to
|
|
8453
|
+
};
|
|
8454
|
+
};
|
|
8455
|
+
var defaultSessionId = (input) => {
|
|
8456
|
+
const payload = flattenPayload(input.body);
|
|
8457
|
+
const metadataSessionId = input.event.metadata?.sessionId;
|
|
8458
|
+
return firstString(input.query, ["sessionId", "session_id"]) ?? firstString(payload, [
|
|
8459
|
+
"sessionId",
|
|
8460
|
+
"session_id",
|
|
8461
|
+
"SessionId",
|
|
8462
|
+
"CallSid",
|
|
8463
|
+
"call_sid",
|
|
8464
|
+
"callSid",
|
|
8465
|
+
"CallUUID",
|
|
8466
|
+
"call_uuid",
|
|
8467
|
+
"callControlId",
|
|
8468
|
+
"call_control_id"
|
|
8469
|
+
]) ?? (typeof metadataSessionId === "string" ? metadataSessionId : undefined);
|
|
8470
|
+
};
|
|
8471
|
+
var defaultIdempotencyKey = (input) => {
|
|
8472
|
+
const payload = flattenPayload(input.body);
|
|
8473
|
+
const eventId = firstString(payload, [
|
|
8474
|
+
"id",
|
|
8475
|
+
"event_id",
|
|
8476
|
+
"eventId",
|
|
8477
|
+
"EventSid",
|
|
8478
|
+
"event_sid",
|
|
8479
|
+
"MessageSid",
|
|
8480
|
+
"message_sid",
|
|
8481
|
+
"CallSid",
|
|
8482
|
+
"call_sid",
|
|
8483
|
+
"CallUUID",
|
|
8484
|
+
"call_uuid",
|
|
8485
|
+
"callControlId",
|
|
8486
|
+
"call_control_id"
|
|
8487
|
+
]);
|
|
8488
|
+
const status = normalizeToken(input.event.status) ?? "unknown";
|
|
8489
|
+
if (eventId) {
|
|
8490
|
+
return `${input.provider}:${eventId}:${status}`;
|
|
8491
|
+
}
|
|
8492
|
+
if (input.sessionId) {
|
|
8493
|
+
return `${input.provider}:${input.sessionId}:${status}`;
|
|
8494
|
+
}
|
|
8495
|
+
};
|
|
8496
|
+
var createVoiceTelephonyWebhookHandler = (options = {}) => async (input) => {
|
|
8497
|
+
const provider = options.provider ?? "generic";
|
|
8498
|
+
const query = input.query ?? {};
|
|
8499
|
+
const { body, rawBody } = await readRequestBody(input.request);
|
|
8500
|
+
const verification = await verifyVoiceTelephonyWebhook({
|
|
8501
|
+
body,
|
|
8502
|
+
options,
|
|
8503
|
+
provider,
|
|
8504
|
+
query,
|
|
8505
|
+
rawBody,
|
|
8506
|
+
request: input.request
|
|
8507
|
+
});
|
|
8508
|
+
if (!verification.ok) {
|
|
8509
|
+
throw new VoiceTelephonyWebhookVerificationError(verification);
|
|
8510
|
+
}
|
|
8511
|
+
const event = options.parse ? await options.parse({
|
|
8512
|
+
body,
|
|
8513
|
+
headers: input.request.headers,
|
|
8514
|
+
provider,
|
|
8515
|
+
query,
|
|
8516
|
+
request: input.request
|
|
8517
|
+
}) : parseVoiceTelephonyWebhookEvent({
|
|
8518
|
+
body,
|
|
8519
|
+
headers: input.request.headers,
|
|
8520
|
+
provider,
|
|
8521
|
+
query,
|
|
8522
|
+
request: input.request
|
|
8523
|
+
});
|
|
8524
|
+
const sessionId = await (options.resolveSessionId?.({
|
|
8525
|
+
body,
|
|
8526
|
+
event,
|
|
8527
|
+
query,
|
|
8528
|
+
request: input.request
|
|
8529
|
+
}) ?? defaultSessionId({ body, event, query }));
|
|
8530
|
+
const idempotencyEnabled = options.idempotency?.enabled !== false;
|
|
8531
|
+
const idempotencyKey = idempotencyEnabled ? await (options.idempotency?.key?.({
|
|
8532
|
+
body,
|
|
8533
|
+
event,
|
|
8534
|
+
provider,
|
|
8535
|
+
query,
|
|
8536
|
+
request: input.request,
|
|
8537
|
+
sessionId
|
|
8538
|
+
}) ?? defaultIdempotencyKey({ body, event, provider, sessionId })) : undefined;
|
|
8539
|
+
const idempotencyStore = options.idempotency?.store;
|
|
8540
|
+
if (idempotencyKey && idempotencyStore) {
|
|
8541
|
+
const existing = await idempotencyStore.get(idempotencyKey);
|
|
8542
|
+
if (existing) {
|
|
8543
|
+
const duplicateDecision = {
|
|
8544
|
+
...existing,
|
|
8545
|
+
duplicate: true
|
|
8546
|
+
};
|
|
8547
|
+
await options.onDecision?.({
|
|
8548
|
+
...duplicateDecision,
|
|
8549
|
+
context: options.context,
|
|
8550
|
+
request: input.request
|
|
8551
|
+
});
|
|
8552
|
+
return duplicateDecision;
|
|
8553
|
+
}
|
|
8554
|
+
}
|
|
8555
|
+
const decision = resolveVoiceTelephonyOutcome(event, options.policy);
|
|
8556
|
+
const resultResolver = options.result;
|
|
8557
|
+
const result = typeof resultResolver === "function" ? await resultResolver({
|
|
8558
|
+
decision,
|
|
8559
|
+
event,
|
|
8560
|
+
sessionId
|
|
8561
|
+
}) : resultResolver;
|
|
8562
|
+
const routeResult = voiceTelephonyOutcomeToRouteResult(decision, result);
|
|
8563
|
+
const shouldApply = typeof options.apply === "function" ? options.apply({
|
|
8564
|
+
applied: false,
|
|
8565
|
+
decision,
|
|
8566
|
+
event,
|
|
8567
|
+
routeResult,
|
|
8568
|
+
sessionId
|
|
8569
|
+
}) : options.apply === true;
|
|
8570
|
+
let applied = false;
|
|
8571
|
+
if (shouldApply && decision.action !== "ignore" && options.getSessionHandle) {
|
|
8572
|
+
const api = await options.getSessionHandle({
|
|
8573
|
+
context: options.context,
|
|
8574
|
+
decision,
|
|
8575
|
+
event,
|
|
8576
|
+
request: input.request,
|
|
8577
|
+
sessionId
|
|
8578
|
+
});
|
|
8579
|
+
if (api) {
|
|
8580
|
+
await applyVoiceTelephonyOutcome(api, decision, result);
|
|
8581
|
+
applied = true;
|
|
8582
|
+
}
|
|
8583
|
+
}
|
|
8584
|
+
const webhookDecision = {
|
|
8585
|
+
applied,
|
|
8586
|
+
decision,
|
|
8587
|
+
event,
|
|
8588
|
+
idempotencyKey,
|
|
8589
|
+
routeResult,
|
|
8590
|
+
sessionId
|
|
8591
|
+
};
|
|
8592
|
+
if (idempotencyKey && idempotencyStore) {
|
|
8593
|
+
const now = Date.now();
|
|
8594
|
+
await idempotencyStore.set(idempotencyKey, {
|
|
8595
|
+
...webhookDecision,
|
|
8596
|
+
createdAt: now,
|
|
8597
|
+
updatedAt: now
|
|
8598
|
+
});
|
|
8599
|
+
}
|
|
8600
|
+
await options.onDecision?.({
|
|
8601
|
+
...webhookDecision,
|
|
8602
|
+
context: options.context,
|
|
8603
|
+
request: input.request
|
|
8604
|
+
});
|
|
8605
|
+
return webhookDecision;
|
|
8606
|
+
};
|
|
8607
|
+
var createVoiceTelephonyWebhookRoutes = (options = {}) => {
|
|
8608
|
+
const path = options.path ?? "/api/voice/telephony/webhook";
|
|
8609
|
+
const handler = createVoiceTelephonyWebhookHandler(options);
|
|
8610
|
+
return new Elysia({
|
|
8611
|
+
name: options.name ?? "absolutejs-voice-telephony-webhooks"
|
|
8612
|
+
}).post(path, async ({ query, request }) => {
|
|
8613
|
+
try {
|
|
8614
|
+
return await handler({ query, request });
|
|
8615
|
+
} catch (error) {
|
|
8616
|
+
if (error instanceof VoiceTelephonyWebhookVerificationError) {
|
|
8617
|
+
return new Response(JSON.stringify({ verification: error.result }), {
|
|
8618
|
+
headers: {
|
|
8619
|
+
"content-type": "application/json"
|
|
8620
|
+
},
|
|
8621
|
+
status: 401
|
|
8622
|
+
});
|
|
8623
|
+
}
|
|
8624
|
+
throw error;
|
|
8625
|
+
}
|
|
8626
|
+
}, {
|
|
8627
|
+
parse: "none"
|
|
8628
|
+
});
|
|
8629
|
+
};
|
|
8630
|
+
|
|
8631
|
+
// src/telephony/twilio.ts
|
|
6342
8632
|
var TWILIO_MULAW_SAMPLE_RATE = 8000;
|
|
6343
8633
|
var VOICE_PCM_SAMPLE_RATE = 16000;
|
|
6344
|
-
var
|
|
8634
|
+
var escapeXml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
8635
|
+
var resolveRequestOrigin = (request) => {
|
|
8636
|
+
const url = new URL(request.url);
|
|
8637
|
+
const forwardedHost = request.headers.get("x-forwarded-host");
|
|
8638
|
+
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
8639
|
+
const host = forwardedHost ?? request.headers.get("host") ?? url.host;
|
|
8640
|
+
const protocol = forwardedProto ?? url.protocol.replace(":", "");
|
|
8641
|
+
return `${protocol}://${host}`;
|
|
8642
|
+
};
|
|
8643
|
+
var resolveTwilioStreamUrl = async (options, input) => {
|
|
8644
|
+
if (typeof options.twiml?.streamUrl === "function") {
|
|
8645
|
+
return options.twiml.streamUrl(input);
|
|
8646
|
+
}
|
|
8647
|
+
if (typeof options.twiml?.streamUrl === "string") {
|
|
8648
|
+
return options.twiml.streamUrl;
|
|
8649
|
+
}
|
|
8650
|
+
const origin = resolveRequestOrigin(input.request);
|
|
8651
|
+
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
8652
|
+
return `${wsOrigin}${input.streamPath}`;
|
|
8653
|
+
};
|
|
8654
|
+
var resolveTwilioStreamParameters = async (parameters, input) => {
|
|
8655
|
+
if (typeof parameters === "function") {
|
|
8656
|
+
return parameters(input);
|
|
8657
|
+
}
|
|
8658
|
+
return parameters;
|
|
8659
|
+
};
|
|
8660
|
+
var joinUrlPath = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
8661
|
+
var escapeHtml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
8662
|
+
var getWebhookVerificationUrl = (webhook, input) => {
|
|
8663
|
+
if (!webhook?.verificationUrl) {
|
|
8664
|
+
return;
|
|
8665
|
+
}
|
|
8666
|
+
if (typeof webhook.verificationUrl === "function") {
|
|
8667
|
+
return webhook.verificationUrl(input);
|
|
8668
|
+
}
|
|
8669
|
+
return webhook.verificationUrl;
|
|
8670
|
+
};
|
|
8671
|
+
var buildTwilioVoiceSetupStatus = async (options, input) => {
|
|
8672
|
+
const origin = resolveRequestOrigin(input.request);
|
|
8673
|
+
const stream = await resolveTwilioStreamUrl(options, input);
|
|
8674
|
+
const twiml = joinUrlPath(origin, input.twimlPath);
|
|
8675
|
+
const webhook = joinUrlPath(origin, input.webhookPath);
|
|
8676
|
+
const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
|
|
8677
|
+
const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
|
|
8678
|
+
const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
|
|
8679
|
+
const warnings = [
|
|
8680
|
+
...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
|
|
8681
|
+
...signingConfigured ? [] : ["Webhook signature verification is not configured."],
|
|
8682
|
+
...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
|
|
8683
|
+
];
|
|
8684
|
+
return {
|
|
8685
|
+
generatedAt: Date.now(),
|
|
8686
|
+
missing,
|
|
8687
|
+
provider: "twilio",
|
|
8688
|
+
ready: missing.length === 0 && signingConfigured && warnings.length === 0,
|
|
8689
|
+
signing: {
|
|
8690
|
+
configured: signingConfigured,
|
|
8691
|
+
mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
|
|
8692
|
+
verificationUrl
|
|
8693
|
+
},
|
|
8694
|
+
urls: {
|
|
8695
|
+
stream,
|
|
8696
|
+
twiml,
|
|
8697
|
+
webhook
|
|
8698
|
+
},
|
|
8699
|
+
warnings
|
|
8700
|
+
};
|
|
8701
|
+
};
|
|
8702
|
+
var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
8703
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
|
|
8704
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
8705
|
+
<p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
|
|
8706
|
+
<section>
|
|
8707
|
+
<h2>URLs</h2>
|
|
8708
|
+
<ul>
|
|
8709
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(status.urls.twiml)}</code></li>
|
|
8710
|
+
<li><strong>Media stream:</strong> <code>${escapeHtml2(status.urls.stream)}</code></li>
|
|
8711
|
+
<li><strong>Status webhook:</strong> <code>${escapeHtml2(status.urls.webhook)}</code></li>
|
|
8712
|
+
</ul>
|
|
8713
|
+
</section>
|
|
8714
|
+
<section>
|
|
8715
|
+
<h2>Signing</h2>
|
|
8716
|
+
<p>Mode: <code>${status.signing.mode}</code></p>
|
|
8717
|
+
${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml2(status.signing.verificationUrl)}</code></p>` : ""}
|
|
8718
|
+
</section>
|
|
8719
|
+
${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml2(name)}</code></li>`).join("")}</ul></section>` : ""}
|
|
8720
|
+
${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml2(warning)}</li>`).join("")}</ul></section>` : ""}
|
|
8721
|
+
</main>`;
|
|
8722
|
+
var extractTwilioStreamUrl = (twiml) => twiml.match(/<Stream\b[^>]*\surl="([^"]+)"/i)?.[1]?.replaceAll("&", "&");
|
|
8723
|
+
var createSmokeCheck = (name, status, message, details) => ({
|
|
8724
|
+
details,
|
|
8725
|
+
message,
|
|
8726
|
+
name,
|
|
8727
|
+
status
|
|
8728
|
+
});
|
|
8729
|
+
var renderTwilioVoiceSmokeHTML = (report, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
8730
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio smoke test</p>
|
|
8731
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
8732
|
+
<p><strong>Status:</strong> ${report.pass ? "Pass" : "Fail"}</p>
|
|
8733
|
+
<section>
|
|
8734
|
+
<h2>Checks</h2>
|
|
8735
|
+
<ul>
|
|
8736
|
+
${report.checks.map((check) => `<li><strong>${escapeHtml2(check.name)}</strong>: ${escapeHtml2(check.status)}${check.message ? ` - ${escapeHtml2(check.message)}` : ""}</li>`).join("")}
|
|
8737
|
+
</ul>
|
|
8738
|
+
</section>
|
|
8739
|
+
<section>
|
|
8740
|
+
<h2>Observed URLs</h2>
|
|
8741
|
+
<ul>
|
|
8742
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(report.setup.urls.twiml)}</code></li>
|
|
8743
|
+
<li><strong>Stream:</strong> <code>${escapeHtml2(report.twiml?.streamUrl ?? report.setup.urls.stream)}</code></li>
|
|
8744
|
+
<li><strong>Webhook:</strong> <code>${escapeHtml2(report.setup.urls.webhook)}</code></li>
|
|
8745
|
+
</ul>
|
|
8746
|
+
</section>
|
|
8747
|
+
</main>`;
|
|
8748
|
+
var runTwilioVoiceSmokeTest = async (input) => {
|
|
8749
|
+
const setup = await buildTwilioVoiceSetupStatus(input.options, input);
|
|
8750
|
+
const checks = [];
|
|
8751
|
+
const twimlUrl = new URL(setup.urls.twiml);
|
|
8752
|
+
twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
|
|
8753
|
+
twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
|
|
8754
|
+
const twimlResponse = await input.app.handle(new Request(twimlUrl, {
|
|
8755
|
+
headers: input.request.headers
|
|
8756
|
+
}));
|
|
8757
|
+
const twiml = await twimlResponse.text();
|
|
8758
|
+
const streamUrl = extractTwilioStreamUrl(twiml);
|
|
8759
|
+
checks.push(createSmokeCheck("twiml", twimlResponse.ok && Boolean(streamUrl) ? "pass" : "fail", streamUrl ? "TwiML includes a media stream URL." : 'TwiML is missing <Stream url="...">.', {
|
|
8760
|
+
status: twimlResponse.status,
|
|
8761
|
+
streamUrl
|
|
8762
|
+
}));
|
|
8763
|
+
checks.push(createSmokeCheck("stream-url", streamUrl?.startsWith("wss://") ? "pass" : "fail", streamUrl?.startsWith("wss://") ? "Media stream URL uses wss://." : "Media stream URL should use wss:// for Twilio.", {
|
|
8764
|
+
streamUrl
|
|
8765
|
+
}));
|
|
8766
|
+
const webhookBody = {
|
|
8767
|
+
CallSid: input.options.smoke?.callSid ?? "CA_SMOKE_TEST",
|
|
8768
|
+
CallStatus: input.options.smoke?.status ?? "busy",
|
|
8769
|
+
SipResponseCode: String(input.options.smoke?.sipCode ?? 486)
|
|
8770
|
+
};
|
|
8771
|
+
const webhookHeaders = new Headers({
|
|
8772
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
8773
|
+
});
|
|
8774
|
+
const verificationUrl = setup.signing.verificationUrl ?? setup.urls.webhook;
|
|
8775
|
+
if (input.options.webhook?.signingSecret) {
|
|
8776
|
+
webhookHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
|
|
8777
|
+
authToken: input.options.webhook.signingSecret,
|
|
8778
|
+
body: webhookBody,
|
|
8779
|
+
url: verificationUrl
|
|
8780
|
+
}));
|
|
8781
|
+
}
|
|
8782
|
+
const webhookResponse = await input.app.handle(new Request(setup.urls.webhook, {
|
|
8783
|
+
body: new URLSearchParams(webhookBody),
|
|
8784
|
+
headers: webhookHeaders,
|
|
8785
|
+
method: "POST"
|
|
8786
|
+
}));
|
|
8787
|
+
const webhookText = await webhookResponse.text();
|
|
8788
|
+
const webhookPayload = (() => {
|
|
8789
|
+
try {
|
|
8790
|
+
return JSON.parse(webhookText);
|
|
8791
|
+
} catch {
|
|
8792
|
+
return webhookText;
|
|
8793
|
+
}
|
|
8794
|
+
})();
|
|
8795
|
+
checks.push(createSmokeCheck("webhook", webhookResponse.ok ? "pass" : "fail", webhookResponse.ok ? "Synthetic Twilio status callback was accepted." : "Synthetic Twilio status callback failed.", {
|
|
8796
|
+
status: webhookResponse.status
|
|
8797
|
+
}));
|
|
8798
|
+
for (const warning of setup.warnings) {
|
|
8799
|
+
checks.push(createSmokeCheck("setup-warning", "warn", warning));
|
|
8800
|
+
}
|
|
8801
|
+
for (const name of setup.missing) {
|
|
8802
|
+
checks.push(createSmokeCheck("missing-env", "fail", `${name} is missing.`));
|
|
8803
|
+
}
|
|
8804
|
+
return {
|
|
8805
|
+
checks,
|
|
8806
|
+
generatedAt: Date.now(),
|
|
8807
|
+
pass: checks.every((check) => check.status !== "fail"),
|
|
8808
|
+
provider: "twilio",
|
|
8809
|
+
setup,
|
|
8810
|
+
twiml: {
|
|
8811
|
+
status: twimlResponse.status,
|
|
8812
|
+
streamUrl
|
|
8813
|
+
},
|
|
8814
|
+
webhook: {
|
|
8815
|
+
body: webhookPayload,
|
|
8816
|
+
status: webhookResponse.status
|
|
8817
|
+
}
|
|
8818
|
+
};
|
|
8819
|
+
};
|
|
6345
8820
|
var normalizeOnTurn = (handler) => {
|
|
6346
8821
|
if (handler.length > 1) {
|
|
6347
8822
|
const directHandler = handler;
|
|
@@ -6443,7 +8918,7 @@ var bytesToInt16Array = (bytes) => {
|
|
|
6443
8918
|
return output;
|
|
6444
8919
|
};
|
|
6445
8920
|
var decodeTwilioMulawBase64 = (payload) => {
|
|
6446
|
-
const bytes = Uint8Array.from(
|
|
8921
|
+
const bytes = Uint8Array.from(Buffer3.from(payload, "base64"));
|
|
6447
8922
|
const samples = new Int16Array(bytes.length);
|
|
6448
8923
|
for (let index = 0;index < bytes.length; index += 1) {
|
|
6449
8924
|
samples[index] = decodeMulawSample(bytes[index] ?? 0);
|
|
@@ -6455,7 +8930,7 @@ var encodeTwilioMulawBase64 = (samples) => {
|
|
|
6455
8930
|
for (let index = 0;index < samples.length; index += 1) {
|
|
6456
8931
|
bytes[index] = encodeMulawSample(samples[index] ?? 0);
|
|
6457
8932
|
}
|
|
6458
|
-
return
|
|
8933
|
+
return Buffer3.from(bytes).toString("base64");
|
|
6459
8934
|
};
|
|
6460
8935
|
var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
|
|
6461
8936
|
const narrowband = decodeTwilioMulawBase64(payload);
|
|
@@ -6464,7 +8939,7 @@ var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
|
|
|
6464
8939
|
};
|
|
6465
8940
|
var transcodePCMToTwilioOutboundPayload = (chunk, format) => {
|
|
6466
8941
|
if (format.container === "raw" && format.encoding === "mulaw" && format.channels === 1 && format.sampleRateHz === TWILIO_MULAW_SAMPLE_RATE) {
|
|
6467
|
-
return
|
|
8942
|
+
return Buffer3.from(chunk).toString("base64");
|
|
6468
8943
|
}
|
|
6469
8944
|
if (format.encoding !== "pcm_s16le") {
|
|
6470
8945
|
throw new Error(`Unsupported outbound telephony audio format: ${format.container}/${format.encoding}`);
|
|
@@ -6505,7 +8980,7 @@ var createTwilioSocketAdapter = (socket, getState) => ({
|
|
|
6505
8980
|
return;
|
|
6506
8981
|
}
|
|
6507
8982
|
if (message.type === "audio") {
|
|
6508
|
-
const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(
|
|
8983
|
+
const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer3.from(message.chunkBase64, "base64")), message.format);
|
|
6509
8984
|
state.hasOutboundAudioSinceLastInbound = true;
|
|
6510
8985
|
state.reviewRecorder?.recordTwilioOutbound({
|
|
6511
8986
|
bytes: payload.length,
|
|
@@ -6537,8 +9012,8 @@ var createTwilioSocketAdapter = (socket, getState) => ({
|
|
|
6537
9012
|
}
|
|
6538
9013
|
});
|
|
6539
9014
|
var createTwilioVoiceResponse = (options) => {
|
|
6540
|
-
const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${
|
|
6541
|
-
return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${
|
|
9015
|
+
const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml2(name)}" value="${escapeXml2(String(value))}" />`).join("");
|
|
9016
|
+
return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${escapeXml2(options.streamUrl)}"${options.track ? ` track="${escapeXml2(options.track)}"` : ""}${options.streamName ? ` name="${escapeXml2(options.streamName)}"` : ""}>${parameters}</Stream></Connect></Response>`;
|
|
6542
9017
|
};
|
|
6543
9018
|
var createTwilioMediaStreamBridge = (socket, options) => {
|
|
6544
9019
|
const runtimePreset = resolveVoiceRuntimePreset(options.preset);
|
|
@@ -6718,6 +9193,148 @@ var createTwilioMediaStreamBridge = (socket, options) => {
|
|
|
6718
9193
|
}
|
|
6719
9194
|
};
|
|
6720
9195
|
};
|
|
9196
|
+
var createTwilioVoiceRoutes = (options) => {
|
|
9197
|
+
const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
|
|
9198
|
+
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
9199
|
+
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
9200
|
+
const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
|
|
9201
|
+
const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
|
|
9202
|
+
const bridges = new WeakMap;
|
|
9203
|
+
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
9204
|
+
const app = new Elysia2({
|
|
9205
|
+
name: options.name ?? "absolutejs-voice-twilio"
|
|
9206
|
+
}).get(twimlPath, async ({ query, request }) => {
|
|
9207
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
9208
|
+
query,
|
|
9209
|
+
request,
|
|
9210
|
+
streamPath
|
|
9211
|
+
});
|
|
9212
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
9213
|
+
query,
|
|
9214
|
+
request
|
|
9215
|
+
});
|
|
9216
|
+
return new Response(createTwilioVoiceResponse({
|
|
9217
|
+
parameters,
|
|
9218
|
+
streamName: options.twiml?.streamName,
|
|
9219
|
+
streamUrl,
|
|
9220
|
+
track: options.twiml?.track
|
|
9221
|
+
}), {
|
|
9222
|
+
headers: {
|
|
9223
|
+
"content-type": "text/xml; charset=utf-8"
|
|
9224
|
+
}
|
|
9225
|
+
});
|
|
9226
|
+
}).post(twimlPath, async ({ query, request }) => {
|
|
9227
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
9228
|
+
query,
|
|
9229
|
+
request,
|
|
9230
|
+
streamPath
|
|
9231
|
+
});
|
|
9232
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
9233
|
+
query,
|
|
9234
|
+
request
|
|
9235
|
+
});
|
|
9236
|
+
return new Response(createTwilioVoiceResponse({
|
|
9237
|
+
parameters,
|
|
9238
|
+
streamName: options.twiml?.streamName,
|
|
9239
|
+
streamUrl,
|
|
9240
|
+
track: options.twiml?.track
|
|
9241
|
+
}), {
|
|
9242
|
+
headers: {
|
|
9243
|
+
"content-type": "text/xml; charset=utf-8"
|
|
9244
|
+
}
|
|
9245
|
+
});
|
|
9246
|
+
}).ws(streamPath, {
|
|
9247
|
+
close: async (ws, _code, reason) => {
|
|
9248
|
+
const bridge = bridges.get(ws);
|
|
9249
|
+
bridges.delete(ws);
|
|
9250
|
+
await bridge?.close(reason);
|
|
9251
|
+
},
|
|
9252
|
+
message: async (ws, raw) => {
|
|
9253
|
+
let bridge = bridges.get(ws);
|
|
9254
|
+
if (!bridge) {
|
|
9255
|
+
bridge = createTwilioMediaStreamBridge({
|
|
9256
|
+
close: (code, reason) => {
|
|
9257
|
+
ws.close(code, reason);
|
|
9258
|
+
},
|
|
9259
|
+
send: (data) => {
|
|
9260
|
+
ws.send(data);
|
|
9261
|
+
}
|
|
9262
|
+
}, options);
|
|
9263
|
+
bridges.set(ws, bridge);
|
|
9264
|
+
}
|
|
9265
|
+
await bridge.handleMessage(raw);
|
|
9266
|
+
}
|
|
9267
|
+
}).use(createVoiceTelephonyWebhookRoutes({
|
|
9268
|
+
...options.webhook ?? {},
|
|
9269
|
+
context: options.context,
|
|
9270
|
+
path: webhookPath,
|
|
9271
|
+
policy: webhookPolicy,
|
|
9272
|
+
provider: "twilio"
|
|
9273
|
+
}));
|
|
9274
|
+
if (!setupPath) {
|
|
9275
|
+
if (!smokePath) {
|
|
9276
|
+
return app;
|
|
9277
|
+
}
|
|
9278
|
+
return app.get(smokePath, async ({ query, request }) => {
|
|
9279
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
9280
|
+
app,
|
|
9281
|
+
options,
|
|
9282
|
+
query,
|
|
9283
|
+
request,
|
|
9284
|
+
streamPath,
|
|
9285
|
+
twimlPath,
|
|
9286
|
+
webhookPath
|
|
9287
|
+
});
|
|
9288
|
+
if (query.format === "html") {
|
|
9289
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
9290
|
+
headers: {
|
|
9291
|
+
"content-type": "text/html; charset=utf-8"
|
|
9292
|
+
}
|
|
9293
|
+
});
|
|
9294
|
+
}
|
|
9295
|
+
return report;
|
|
9296
|
+
});
|
|
9297
|
+
}
|
|
9298
|
+
const withSetup = app.get(setupPath, async ({ query, request }) => {
|
|
9299
|
+
const status = await buildTwilioVoiceSetupStatus(options, {
|
|
9300
|
+
query,
|
|
9301
|
+
request,
|
|
9302
|
+
streamPath,
|
|
9303
|
+
twimlPath,
|
|
9304
|
+
webhookPath
|
|
9305
|
+
});
|
|
9306
|
+
if (query.format === "html") {
|
|
9307
|
+
return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
|
|
9308
|
+
headers: {
|
|
9309
|
+
"content-type": "text/html; charset=utf-8"
|
|
9310
|
+
}
|
|
9311
|
+
});
|
|
9312
|
+
}
|
|
9313
|
+
return status;
|
|
9314
|
+
});
|
|
9315
|
+
if (!smokePath) {
|
|
9316
|
+
return withSetup;
|
|
9317
|
+
}
|
|
9318
|
+
return withSetup.get(smokePath, async ({ query, request }) => {
|
|
9319
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
9320
|
+
app,
|
|
9321
|
+
options,
|
|
9322
|
+
query,
|
|
9323
|
+
request,
|
|
9324
|
+
streamPath,
|
|
9325
|
+
twimlPath,
|
|
9326
|
+
webhookPath
|
|
9327
|
+
});
|
|
9328
|
+
if (query.format === "html") {
|
|
9329
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
9330
|
+
headers: {
|
|
9331
|
+
"content-type": "text/html; charset=utf-8"
|
|
9332
|
+
}
|
|
9333
|
+
});
|
|
9334
|
+
}
|
|
9335
|
+
return report;
|
|
9336
|
+
});
|
|
9337
|
+
};
|
|
6721
9338
|
|
|
6722
9339
|
// src/testing/telephony.ts
|
|
6723
9340
|
var DEFAULT_PCM16_FORMAT = {
|
|
@@ -7208,6 +9825,8 @@ export {
|
|
|
7208
9825
|
getDefaultVoiceDuplexBenchmarkScenarios,
|
|
7209
9826
|
getDefaultTTSBenchmarkFixtures,
|
|
7210
9827
|
evaluateSTTBenchmarkAcceptance,
|
|
9828
|
+
createVoiceProviderFailureSimulator,
|
|
9829
|
+
createVoiceIOProviderFailureSimulator,
|
|
7211
9830
|
createVoiceCallReviewRecorder,
|
|
7212
9831
|
createVoiceCallReviewFromLiveTelephonyReport,
|
|
7213
9832
|
createTelephonyVoiceTestFixtures,
|