@absolutejs/voice 0.0.22-beta.24 → 0.0.22-beta.241
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 +3112 -236
- package/dist/agent.d.ts +62 -0
- package/dist/agentSquadContract.d.ts +69 -0
- package/dist/angular/index.d.ts +14 -0
- package/dist/angular/index.js +3266 -1097
- package/dist/angular/voice-agent-squad-status.service.d.ts +12 -0
- package/dist/angular/voice-campaign-dialer-proof.service.d.ts +14 -0
- package/dist/angular/voice-controller.service.d.ts +1 -0
- package/dist/angular/voice-delivery-runtime.component.d.ts +17 -0
- package/dist/angular/voice-delivery-runtime.service.d.ts +16 -0
- package/dist/angular/voice-live-ops.service.d.ts +11 -0
- package/dist/angular/voice-ops-action-center.service.d.ts +13 -0
- package/dist/angular/voice-ops-status.component.d.ts +15 -0
- package/dist/angular/voice-ops-status.service.d.ts +12 -0
- package/dist/angular/voice-platform-coverage.service.d.ts +12 -0
- package/dist/angular/voice-provider-capabilities.service.d.ts +12 -0
- package/dist/angular/voice-provider-contracts.service.d.ts +12 -0
- package/dist/angular/voice-routing-status.service.d.ts +11 -0
- package/dist/angular/voice-stream.service.d.ts +3 -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/audit.d.ts +128 -0
- package/dist/auditDeliveryRoutes.d.ts +85 -0
- package/dist/auditExport.d.ts +34 -0
- package/dist/auditRoutes.d.ts +66 -0
- package/dist/auditSinks.d.ts +151 -0
- package/dist/bargeInRoutes.d.ts +56 -0
- package/dist/campaign.d.ts +746 -0
- package/dist/campaignDialers.d.ts +90 -0
- package/dist/client/actions.d.ts +105 -0
- package/dist/client/agentSquadStatus.d.ts +37 -0
- package/dist/client/agentSquadStatusWidget.d.ts +24 -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/deliveryRuntime.d.ts +34 -0
- package/dist/client/deliveryRuntimeWidget.d.ts +37 -0
- package/dist/client/duplex.d.ts +1 -1
- package/dist/client/htmxBootstrap.js +747 -15
- package/dist/client/index.d.ts +64 -0
- package/dist/client/index.js +4594 -21
- package/dist/client/liveOps.d.ts +22 -0
- package/dist/client/liveOpsWidget.d.ts +23 -0
- package/dist/client/liveTurnLatency.d.ts +41 -0
- package/dist/client/opsActionCenter.d.ts +54 -0
- package/dist/client/opsActionCenterWidget.d.ts +29 -0
- package/dist/client/opsActionHistory.d.ts +19 -0
- package/dist/client/opsActionHistoryWidget.d.ts +11 -0
- package/dist/client/opsStatus.d.ts +19 -0
- package/dist/client/opsStatusWidget.d.ts +40 -0
- package/dist/client/platformCoverage.d.ts +19 -0
- package/dist/client/platformCoverageWidget.d.ts +37 -0
- package/dist/client/providerCapabilities.d.ts +19 -0
- package/dist/client/providerCapabilitiesWidget.d.ts +32 -0
- package/dist/client/providerContracts.d.ts +19 -0
- package/dist/client/providerContractsWidget.d.ts +37 -0
- package/dist/client/providerSimulationControls.d.ts +33 -0
- package/dist/client/providerSimulationControlsWidget.d.ts +20 -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 +36 -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/dataControl.d.ts +140 -0
- package/dist/deliveryRuntime.d.ts +158 -0
- package/dist/deliverySinkRoutes.d.ts +117 -0
- package/dist/demoReadyRoutes.d.ts +98 -0
- package/dist/diagnosticsRoutes.d.ts +44 -0
- package/dist/evalRoutes.d.ts +219 -0
- package/dist/fileStore.d.ts +14 -2
- package/dist/handoff.d.ts +54 -0
- package/dist/handoffHealth.d.ts +94 -0
- package/dist/incidentBundle.d.ts +116 -0
- package/dist/index.d.ts +130 -13
- package/dist/index.js +23751 -5201
- package/dist/latencySlo.d.ts +56 -0
- package/dist/liveLatency.d.ts +78 -0
- package/dist/liveOps.d.ts +122 -0
- package/dist/modelAdapters.d.ts +23 -2
- package/dist/observabilityExport.d.ts +428 -0
- package/dist/openaiRealtime.d.ts +27 -0
- package/dist/openaiTTS.d.ts +18 -0
- package/dist/operationsRecord.d.ts +158 -0
- package/dist/opsActionAuditRoutes.d.ts +99 -0
- package/dist/opsConsoleRoutes.d.ts +80 -0
- package/dist/opsRecovery.d.ts +137 -0
- package/dist/opsStatus.d.ts +76 -0
- package/dist/opsStatusRoutes.d.ts +33 -0
- package/dist/opsWebhook.d.ts +126 -0
- package/dist/outcomeContract.d.ts +115 -0
- package/dist/phoneAgent.d.ts +76 -0
- package/dist/phoneAgentProductionSmoke.d.ts +115 -0
- package/dist/platformCoverage.d.ts +73 -0
- package/dist/postgresStore.d.ts +13 -2
- package/dist/productionReadiness.d.ts +466 -0
- package/dist/proofTrends.d.ts +64 -0
- package/dist/providerAdapters.d.ts +48 -0
- package/dist/providerCapabilities.d.ts +92 -0
- package/dist/providerHealth.d.ts +1 -0
- package/dist/providerRoutingContract.d.ts +38 -0
- package/dist/providerSlo.d.ts +114 -0
- package/dist/providerStackRecommendations.d.ts +145 -0
- package/dist/qualityRoutes.d.ts +76 -0
- package/dist/queue.d.ts +61 -0
- package/dist/react/VoiceAgentSquadStatus.d.ts +5 -0
- package/dist/react/VoiceDeliveryRuntime.d.ts +7 -0
- package/dist/react/VoiceOpsActionCenter.d.ts +5 -0
- package/dist/react/VoiceOpsStatus.d.ts +6 -0
- package/dist/react/VoicePlatformCoverage.d.ts +6 -0
- package/dist/react/VoiceProviderCapabilities.d.ts +6 -0
- package/dist/react/VoiceProviderContracts.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 +28 -0
- package/dist/react/index.js +4359 -33
- package/dist/react/useVoiceAgentSquadStatus.d.ts +8 -0
- package/dist/react/useVoiceCampaignDialerProof.d.ts +10 -0
- package/dist/react/useVoiceController.d.ts +3 -0
- package/dist/react/useVoiceDeliveryRuntime.d.ts +13 -0
- package/dist/react/useVoiceLiveOps.d.ts +9 -0
- package/dist/react/useVoiceOpsActionCenter.d.ts +11 -0
- package/dist/react/useVoiceOpsStatus.d.ts +8 -0
- package/dist/react/useVoicePlatformCoverage.d.ts +8 -0
- package/dist/react/useVoiceProviderCapabilities.d.ts +8 -0
- package/dist/react/useVoiceProviderContracts.d.ts +8 -0
- package/dist/react/useVoiceProviderSimulationControls.d.ts +10 -0
- package/dist/react/useVoiceRoutingStatus.d.ts +8 -0
- package/dist/react/useVoiceStream.d.ts +3 -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/readinessProfiles.d.ts +37 -0
- package/dist/reconnectContract.d.ts +87 -0
- package/dist/resilienceRoutes.d.ts +143 -0
- package/dist/sessionReplay.d.ts +12 -0
- package/dist/simulationSuite.d.ts +121 -0
- package/dist/sqliteStore.d.ts +13 -2
- package/dist/svelte/createVoiceAgentSquadStatus.d.ts +9 -0
- package/dist/svelte/createVoiceCampaignDialerProof.d.ts +9 -0
- package/dist/svelte/createVoiceDeliveryRuntime.d.ts +11 -0
- package/dist/svelte/createVoiceLiveOps.d.ts +13 -0
- package/dist/svelte/createVoiceOpsActionCenter.d.ts +10 -0
- package/dist/svelte/createVoiceOpsStatus.d.ts +9 -0
- package/dist/svelte/createVoicePlatformCoverage.d.ts +7 -0
- package/dist/svelte/createVoiceProviderCapabilities.d.ts +10 -0
- package/dist/svelte/createVoiceProviderContracts.d.ts +10 -0
- package/dist/svelte/createVoiceProviderSimulationControls.d.ts +11 -0
- package/dist/svelte/createVoiceProviderStatus.d.ts +4 -2
- 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 +15 -0
- package/dist/svelte/index.js +4664 -439
- 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 +135 -2
- package/dist/telephonyOutcome.d.ts +201 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +2024 -69
- package/dist/testing/ioProviderSimulator.d.ts +41 -0
- package/dist/toolContract.d.ts +133 -0
- package/dist/toolRuntime.d.ts +50 -0
- package/dist/trace.d.ts +19 -1
- package/dist/traceDeliveryRoutes.d.ts +86 -0
- package/dist/traceTimeline.d.ts +97 -0
- package/dist/turnLatency.d.ts +95 -0
- package/dist/turnQuality.d.ts +94 -0
- package/dist/types.d.ts +180 -4
- package/dist/vue/VoiceDeliveryRuntime.d.ts +30 -0
- package/dist/vue/VoiceOpsActionCenter.d.ts +13 -0
- package/dist/vue/VoiceOpsStatus.d.ts +30 -0
- package/dist/vue/VoicePlatformCoverage.d.ts +23 -0
- package/dist/vue/VoiceProviderCapabilities.d.ts +51 -0
- package/dist/vue/VoiceProviderContracts.d.ts +21 -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 +26 -0
- package/dist/vue/index.js +4136 -57
- package/dist/vue/useVoiceAgentSquadStatus.d.ts +9 -0
- package/dist/vue/useVoiceCampaignDialerProof.d.ts +11 -0
- package/dist/vue/useVoiceController.d.ts +2 -1
- package/dist/vue/useVoiceDeliveryRuntime.d.ts +13 -0
- package/dist/vue/useVoiceLiveOps.d.ts +9 -0
- package/dist/vue/useVoiceOpsActionCenter.d.ts +11 -0
- package/dist/vue/useVoiceOpsStatus.d.ts +9 -0
- package/dist/vue/useVoicePlatformCoverage.d.ts +9 -0
- package/dist/vue/useVoiceProviderCapabilities.d.ts +9 -0
- package/dist/vue/useVoiceProviderContracts.d.ts +9 -0
- package/dist/vue/useVoiceProviderSimulationControls.d.ts +24 -0
- package/dist/vue/useVoiceProviderStatus.d.ts +1 -1
- package/dist/vue/useVoiceRoutingStatus.d.ts +8 -0
- package/dist/vue/useVoiceStream.d.ts +4 -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,17 @@ var serverMessageToAction = (message) => {
|
|
|
2105
2105
|
sessionId: message.sessionId,
|
|
2106
2106
|
type: "complete"
|
|
2107
2107
|
};
|
|
2108
|
+
case "connection":
|
|
2109
|
+
return {
|
|
2110
|
+
reconnect: message.reconnect,
|
|
2111
|
+
type: "connection"
|
|
2112
|
+
};
|
|
2113
|
+
case "call_lifecycle":
|
|
2114
|
+
return {
|
|
2115
|
+
event: message.event,
|
|
2116
|
+
sessionId: message.sessionId,
|
|
2117
|
+
type: "call_lifecycle"
|
|
2118
|
+
};
|
|
2108
2119
|
case "error":
|
|
2109
2120
|
return {
|
|
2110
2121
|
message: normalizeErrorMessage(message.message),
|
|
@@ -2120,6 +2131,17 @@ var serverMessageToAction = (message) => {
|
|
|
2120
2131
|
transcript: message.transcript,
|
|
2121
2132
|
type: "partial"
|
|
2122
2133
|
};
|
|
2134
|
+
case "replay":
|
|
2135
|
+
return {
|
|
2136
|
+
assistantTexts: message.assistantTexts,
|
|
2137
|
+
call: message.call,
|
|
2138
|
+
partial: message.partial,
|
|
2139
|
+
scenarioId: message.scenarioId,
|
|
2140
|
+
sessionId: message.sessionId,
|
|
2141
|
+
status: message.status,
|
|
2142
|
+
turns: message.turns,
|
|
2143
|
+
type: "replay"
|
|
2144
|
+
};
|
|
2123
2145
|
case "session":
|
|
2124
2146
|
return {
|
|
2125
2147
|
sessionId: message.sessionId,
|
|
@@ -2148,7 +2170,7 @@ var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
|
|
|
2148
2170
|
var noop = () => {};
|
|
2149
2171
|
var noopUnsubscribe = () => noop;
|
|
2150
2172
|
var NOOP_CONNECTION = {
|
|
2151
|
-
|
|
2173
|
+
callControl: noop,
|
|
2152
2174
|
close: noop,
|
|
2153
2175
|
endTurn: noop,
|
|
2154
2176
|
getReadyState: () => WS_CLOSED,
|
|
@@ -2156,6 +2178,7 @@ var NOOP_CONNECTION = {
|
|
|
2156
2178
|
getSessionId: () => "",
|
|
2157
2179
|
send: noop,
|
|
2158
2180
|
sendAudio: noop,
|
|
2181
|
+
start: () => {},
|
|
2159
2182
|
subscribe: noopUnsubscribe
|
|
2160
2183
|
};
|
|
2161
2184
|
var createSessionId = () => crypto.randomUUID();
|
|
@@ -2177,11 +2200,14 @@ var isVoiceServerMessage = (value) => {
|
|
|
2177
2200
|
switch (value.type) {
|
|
2178
2201
|
case "audio":
|
|
2179
2202
|
case "assistant":
|
|
2203
|
+
case "call_lifecycle":
|
|
2180
2204
|
case "complete":
|
|
2205
|
+
case "connection":
|
|
2181
2206
|
case "error":
|
|
2182
2207
|
case "final":
|
|
2183
2208
|
case "partial":
|
|
2184
2209
|
case "pong":
|
|
2210
|
+
case "replay":
|
|
2185
2211
|
case "session":
|
|
2186
2212
|
case "turn":
|
|
2187
2213
|
return true;
|
|
@@ -2218,6 +2244,9 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2218
2244
|
sessionId: options.sessionId ?? createSessionId(),
|
|
2219
2245
|
ws: null
|
|
2220
2246
|
};
|
|
2247
|
+
const emitConnection = (reconnect) => {
|
|
2248
|
+
listeners.forEach((listener) => listener(reconnect));
|
|
2249
|
+
};
|
|
2221
2250
|
const clearTimers = () => {
|
|
2222
2251
|
if (state.pingInterval) {
|
|
2223
2252
|
clearInterval(state.pingInterval);
|
|
@@ -2240,9 +2269,28 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2240
2269
|
}
|
|
2241
2270
|
};
|
|
2242
2271
|
const scheduleReconnect = () => {
|
|
2272
|
+
const nextAttemptAt = Date.now() + RECONNECT_DELAY_MS;
|
|
2243
2273
|
state.reconnectAttempts += 1;
|
|
2274
|
+
emitConnection({
|
|
2275
|
+
reconnect: {
|
|
2276
|
+
attempts: state.reconnectAttempts,
|
|
2277
|
+
lastDisconnectAt: Date.now(),
|
|
2278
|
+
maxAttempts: maxReconnectAttempts,
|
|
2279
|
+
nextAttemptAt,
|
|
2280
|
+
status: "reconnecting"
|
|
2281
|
+
},
|
|
2282
|
+
type: "connection"
|
|
2283
|
+
});
|
|
2244
2284
|
state.reconnectTimeout = setTimeout(() => {
|
|
2245
2285
|
if (state.reconnectAttempts > maxReconnectAttempts) {
|
|
2286
|
+
emitConnection({
|
|
2287
|
+
reconnect: {
|
|
2288
|
+
attempts: state.reconnectAttempts,
|
|
2289
|
+
maxAttempts: maxReconnectAttempts,
|
|
2290
|
+
status: "exhausted"
|
|
2291
|
+
},
|
|
2292
|
+
type: "connection"
|
|
2293
|
+
});
|
|
2246
2294
|
return;
|
|
2247
2295
|
}
|
|
2248
2296
|
connect();
|
|
@@ -2252,9 +2300,21 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2252
2300
|
const ws = new WebSocket(buildWsUrl(path, state.sessionId, state.scenarioId));
|
|
2253
2301
|
ws.binaryType = "arraybuffer";
|
|
2254
2302
|
ws.onopen = () => {
|
|
2303
|
+
const wasReconnecting = state.reconnectAttempts > 0;
|
|
2255
2304
|
state.isConnected = true;
|
|
2256
|
-
state.reconnectAttempts = 0;
|
|
2257
2305
|
flushPendingMessages();
|
|
2306
|
+
if (wasReconnecting) {
|
|
2307
|
+
emitConnection({
|
|
2308
|
+
reconnect: {
|
|
2309
|
+
attempts: state.reconnectAttempts,
|
|
2310
|
+
lastResumedAt: Date.now(),
|
|
2311
|
+
maxAttempts: maxReconnectAttempts,
|
|
2312
|
+
status: "resumed"
|
|
2313
|
+
},
|
|
2314
|
+
type: "connection"
|
|
2315
|
+
});
|
|
2316
|
+
state.reconnectAttempts = 0;
|
|
2317
|
+
}
|
|
2258
2318
|
listeners.forEach((listener) => listener({
|
|
2259
2319
|
scenarioId: state.scenarioId ?? undefined,
|
|
2260
2320
|
sessionId: state.sessionId,
|
|
@@ -2284,6 +2344,16 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2284
2344
|
const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && state.reconnectAttempts < maxReconnectAttempts;
|
|
2285
2345
|
if (reconnectable) {
|
|
2286
2346
|
scheduleReconnect();
|
|
2347
|
+
} else if (shouldReconnect && event.code !== WS_NORMAL_CLOSURE) {
|
|
2348
|
+
emitConnection({
|
|
2349
|
+
reconnect: {
|
|
2350
|
+
attempts: state.reconnectAttempts,
|
|
2351
|
+
lastDisconnectAt: Date.now(),
|
|
2352
|
+
maxAttempts: maxReconnectAttempts,
|
|
2353
|
+
status: "exhausted"
|
|
2354
|
+
},
|
|
2355
|
+
type: "connection"
|
|
2356
|
+
});
|
|
2287
2357
|
}
|
|
2288
2358
|
};
|
|
2289
2359
|
state.ws = ws;
|
|
@@ -2317,6 +2387,12 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2317
2387
|
const endTurn = () => {
|
|
2318
2388
|
send({ type: "end_turn" });
|
|
2319
2389
|
};
|
|
2390
|
+
const callControl = (message) => {
|
|
2391
|
+
send({
|
|
2392
|
+
...message,
|
|
2393
|
+
type: "call_control"
|
|
2394
|
+
});
|
|
2395
|
+
};
|
|
2320
2396
|
const close = () => {
|
|
2321
2397
|
clearTimers();
|
|
2322
2398
|
if (state.ws) {
|
|
@@ -2334,7 +2410,7 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2334
2410
|
};
|
|
2335
2411
|
connect();
|
|
2336
2412
|
return {
|
|
2337
|
-
|
|
2413
|
+
callControl,
|
|
2338
2414
|
close,
|
|
2339
2415
|
endTurn,
|
|
2340
2416
|
getReadyState: () => state.ws?.readyState ?? WS_CLOSED,
|
|
@@ -2342,18 +2418,26 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2342
2418
|
getSessionId: () => state.sessionId,
|
|
2343
2419
|
send,
|
|
2344
2420
|
sendAudio,
|
|
2421
|
+
start,
|
|
2345
2422
|
subscribe
|
|
2346
2423
|
};
|
|
2347
2424
|
};
|
|
2348
2425
|
|
|
2349
2426
|
// src/client/store.ts
|
|
2427
|
+
var createInitialReconnectState = () => ({
|
|
2428
|
+
attempts: 0,
|
|
2429
|
+
maxAttempts: 0,
|
|
2430
|
+
status: "idle"
|
|
2431
|
+
});
|
|
2350
2432
|
var createInitialState2 = () => ({
|
|
2351
2433
|
assistantAudio: [],
|
|
2352
2434
|
assistantTexts: [],
|
|
2435
|
+
call: null,
|
|
2353
2436
|
error: null,
|
|
2354
2437
|
isConnected: false,
|
|
2355
2438
|
scenarioId: null,
|
|
2356
2439
|
partial: "",
|
|
2440
|
+
reconnect: createInitialReconnectState(),
|
|
2357
2441
|
sessionId: null,
|
|
2358
2442
|
status: "idle",
|
|
2359
2443
|
turns: []
|
|
@@ -2393,10 +2477,36 @@ var createVoiceStreamStore = () => {
|
|
|
2393
2477
|
status: "completed"
|
|
2394
2478
|
};
|
|
2395
2479
|
break;
|
|
2480
|
+
case "call_lifecycle":
|
|
2481
|
+
state = {
|
|
2482
|
+
...state,
|
|
2483
|
+
call: {
|
|
2484
|
+
...state.call,
|
|
2485
|
+
disposition: action.event.type === "end" ? action.event.disposition : state.call?.disposition,
|
|
2486
|
+
endedAt: action.event.type === "end" ? action.event.at : state.call?.endedAt,
|
|
2487
|
+
events: [...state.call?.events ?? [], action.event],
|
|
2488
|
+
lastEventAt: action.event.at,
|
|
2489
|
+
startedAt: state.call?.startedAt ?? action.event.at
|
|
2490
|
+
},
|
|
2491
|
+
sessionId: action.sessionId
|
|
2492
|
+
};
|
|
2493
|
+
break;
|
|
2396
2494
|
case "connected":
|
|
2397
2495
|
state = {
|
|
2398
2496
|
...state,
|
|
2399
|
-
isConnected: true
|
|
2497
|
+
isConnected: true,
|
|
2498
|
+
reconnect: state.reconnect.status === "reconnecting" ? {
|
|
2499
|
+
...state.reconnect,
|
|
2500
|
+
lastResumedAt: Date.now(),
|
|
2501
|
+
nextAttemptAt: undefined,
|
|
2502
|
+
status: "resumed"
|
|
2503
|
+
} : state.reconnect
|
|
2504
|
+
};
|
|
2505
|
+
break;
|
|
2506
|
+
case "connection":
|
|
2507
|
+
state = {
|
|
2508
|
+
...state,
|
|
2509
|
+
reconnect: action.reconnect
|
|
2400
2510
|
};
|
|
2401
2511
|
break;
|
|
2402
2512
|
case "disconnected":
|
|
@@ -2424,6 +2534,26 @@ var createVoiceStreamStore = () => {
|
|
|
2424
2534
|
partial: action.transcript.text
|
|
2425
2535
|
};
|
|
2426
2536
|
break;
|
|
2537
|
+
case "replay":
|
|
2538
|
+
state = {
|
|
2539
|
+
...state,
|
|
2540
|
+
assistantTexts: [...action.assistantTexts],
|
|
2541
|
+
call: action.call ?? null,
|
|
2542
|
+
error: null,
|
|
2543
|
+
isConnected: action.status === "active",
|
|
2544
|
+
partial: action.partial,
|
|
2545
|
+
reconnect: state.reconnect.status === "reconnecting" ? {
|
|
2546
|
+
...state.reconnect,
|
|
2547
|
+
lastResumedAt: Date.now(),
|
|
2548
|
+
nextAttemptAt: undefined,
|
|
2549
|
+
status: "resumed"
|
|
2550
|
+
} : state.reconnect,
|
|
2551
|
+
scenarioId: action.scenarioId ?? state.scenarioId,
|
|
2552
|
+
sessionId: action.sessionId,
|
|
2553
|
+
status: action.status,
|
|
2554
|
+
turns: [...action.turns]
|
|
2555
|
+
};
|
|
2556
|
+
break;
|
|
2427
2557
|
case "session":
|
|
2428
2558
|
state = {
|
|
2429
2559
|
...state,
|
|
@@ -2471,14 +2601,41 @@ var createVoiceStream = (path, options = {}) => {
|
|
|
2471
2601
|
const notify = () => {
|
|
2472
2602
|
subscribers.forEach((subscriber) => subscriber());
|
|
2473
2603
|
};
|
|
2604
|
+
const reportReconnect = () => {
|
|
2605
|
+
if (!options.reconnectReportPath || typeof fetch === "undefined") {
|
|
2606
|
+
return;
|
|
2607
|
+
}
|
|
2608
|
+
const snapshot = store.getSnapshot();
|
|
2609
|
+
const body = JSON.stringify({
|
|
2610
|
+
at: Date.now(),
|
|
2611
|
+
reconnect: snapshot.reconnect,
|
|
2612
|
+
scenarioId: snapshot.scenarioId,
|
|
2613
|
+
sessionId: connection.getSessionId(),
|
|
2614
|
+
turnIds: snapshot.turns.map((turn) => turn.id)
|
|
2615
|
+
});
|
|
2616
|
+
fetch(options.reconnectReportPath, {
|
|
2617
|
+
body,
|
|
2618
|
+
headers: {
|
|
2619
|
+
"Content-Type": "application/json"
|
|
2620
|
+
},
|
|
2621
|
+
keepalive: true,
|
|
2622
|
+
method: "POST"
|
|
2623
|
+
}).catch(() => {});
|
|
2624
|
+
};
|
|
2474
2625
|
const unsubscribeConnection = connection.subscribe((message) => {
|
|
2475
2626
|
const action = serverMessageToAction(message);
|
|
2476
2627
|
if (action) {
|
|
2477
2628
|
store.dispatch(action);
|
|
2629
|
+
if (message.type === "connection") {
|
|
2630
|
+
reportReconnect();
|
|
2631
|
+
}
|
|
2478
2632
|
notify();
|
|
2479
2633
|
}
|
|
2480
2634
|
});
|
|
2481
2635
|
return {
|
|
2636
|
+
callControl(message) {
|
|
2637
|
+
connection.callControl(message);
|
|
2638
|
+
},
|
|
2482
2639
|
close() {
|
|
2483
2640
|
unsubscribeConnection();
|
|
2484
2641
|
connection.close();
|
|
@@ -2507,6 +2664,9 @@ var createVoiceStream = (path, options = {}) => {
|
|
|
2507
2664
|
get partial() {
|
|
2508
2665
|
return store.getSnapshot().partial;
|
|
2509
2666
|
},
|
|
2667
|
+
get reconnect() {
|
|
2668
|
+
return store.getSnapshot().reconnect;
|
|
2669
|
+
},
|
|
2510
2670
|
get sessionId() {
|
|
2511
2671
|
return connection.getSessionId();
|
|
2512
2672
|
},
|
|
@@ -2522,6 +2682,9 @@ var createVoiceStream = (path, options = {}) => {
|
|
|
2522
2682
|
get assistantAudio() {
|
|
2523
2683
|
return store.getSnapshot().assistantAudio;
|
|
2524
2684
|
},
|
|
2685
|
+
get call() {
|
|
2686
|
+
return store.getSnapshot().call;
|
|
2687
|
+
},
|
|
2525
2688
|
sendAudio(audio) {
|
|
2526
2689
|
connection.sendAudio(audio);
|
|
2527
2690
|
},
|
|
@@ -2854,10 +3017,12 @@ var resolveVoiceRuntimePreset = (name = "default") => {
|
|
|
2854
3017
|
var createInitialState3 = (stream) => ({
|
|
2855
3018
|
assistantAudio: [...stream.assistantAudio],
|
|
2856
3019
|
assistantTexts: [...stream.assistantTexts],
|
|
3020
|
+
call: stream.call,
|
|
2857
3021
|
error: stream.error,
|
|
2858
3022
|
isConnected: stream.isConnected,
|
|
2859
3023
|
isRecording: false,
|
|
2860
3024
|
partial: stream.partial,
|
|
3025
|
+
reconnect: stream.reconnect,
|
|
2861
3026
|
recordingError: null,
|
|
2862
3027
|
sessionId: stream.sessionId,
|
|
2863
3028
|
scenarioId: stream.scenarioId,
|
|
@@ -2883,9 +3048,11 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2883
3048
|
...state,
|
|
2884
3049
|
assistantAudio: [...stream.assistantAudio],
|
|
2885
3050
|
assistantTexts: [...stream.assistantTexts],
|
|
3051
|
+
call: stream.call,
|
|
2886
3052
|
error: stream.error,
|
|
2887
3053
|
isConnected: stream.isConnected,
|
|
2888
3054
|
partial: stream.partial,
|
|
3055
|
+
reconnect: stream.reconnect,
|
|
2889
3056
|
sessionId: stream.sessionId,
|
|
2890
3057
|
scenarioId: stream.scenarioId,
|
|
2891
3058
|
status: stream.status,
|
|
@@ -2910,7 +3077,13 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2910
3077
|
capture = createMicrophoneCapture({
|
|
2911
3078
|
channelCount: options.capture?.channelCount ?? preset.capture.channelCount,
|
|
2912
3079
|
onLevel: options.capture?.onLevel,
|
|
2913
|
-
onAudio: (audio) =>
|
|
3080
|
+
onAudio: (audio) => {
|
|
3081
|
+
if (options.capture?.onAudio) {
|
|
3082
|
+
options.capture.onAudio(audio, stream.sendAudio);
|
|
3083
|
+
return;
|
|
3084
|
+
}
|
|
3085
|
+
stream.sendAudio(audio);
|
|
3086
|
+
},
|
|
2914
3087
|
sampleRateHz: options.capture?.sampleRateHz ?? preset.capture.sampleRateHz
|
|
2915
3088
|
});
|
|
2916
3089
|
return capture;
|
|
@@ -2960,6 +3133,7 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2960
3133
|
bindHTMX(bindingOptions) {
|
|
2961
3134
|
return bindVoiceHTMX(stream, bindingOptions);
|
|
2962
3135
|
},
|
|
3136
|
+
callControl: (message) => stream.callControl(message),
|
|
2963
3137
|
close,
|
|
2964
3138
|
endTurn: () => stream.endTurn(),
|
|
2965
3139
|
get error() {
|
|
@@ -2979,6 +3153,9 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2979
3153
|
get recordingError() {
|
|
2980
3154
|
return state.recordingError;
|
|
2981
3155
|
},
|
|
3156
|
+
get reconnect() {
|
|
3157
|
+
return state.reconnect;
|
|
3158
|
+
},
|
|
2982
3159
|
sendAudio: (audio) => stream.sendAudio(audio),
|
|
2983
3160
|
get sessionId() {
|
|
2984
3161
|
return state.sessionId;
|
|
@@ -3012,6 +3189,9 @@ var createVoiceController = (path, options = {}) => {
|
|
|
3012
3189
|
},
|
|
3013
3190
|
get assistantAudio() {
|
|
3014
3191
|
return state.assistantAudio;
|
|
3192
|
+
},
|
|
3193
|
+
get call() {
|
|
3194
|
+
return state.call;
|
|
3015
3195
|
}
|
|
3016
3196
|
};
|
|
3017
3197
|
};
|
|
@@ -3021,11 +3201,26 @@ var DEFAULT_INTERRUPT_THRESHOLD = 0.08;
|
|
|
3021
3201
|
var shouldInterruptForLevel = (level, options = {}) => (options.enabled ?? true) && level >= (options.interruptThreshold ?? DEFAULT_INTERRUPT_THRESHOLD);
|
|
3022
3202
|
var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
3023
3203
|
let lastPartial = controller.partial;
|
|
3024
|
-
const interruptIfPlaying = () => {
|
|
3204
|
+
const interruptIfPlaying = (reason) => {
|
|
3025
3205
|
if (!player.isPlaying || options.enabled === false) {
|
|
3206
|
+
options.monitor?.recordSkipped({
|
|
3207
|
+
reason,
|
|
3208
|
+
sessionId: controller.sessionId
|
|
3209
|
+
});
|
|
3026
3210
|
return;
|
|
3027
3211
|
}
|
|
3028
|
-
|
|
3212
|
+
options.monitor?.recordRequested({
|
|
3213
|
+
reason,
|
|
3214
|
+
sessionId: controller.sessionId
|
|
3215
|
+
});
|
|
3216
|
+
player.interrupt().then(() => {
|
|
3217
|
+
options.monitor?.recordStopped({
|
|
3218
|
+
latencyMs: player.lastInterruptLatencyMs,
|
|
3219
|
+
playbackStopLatencyMs: player.lastPlaybackStopLatencyMs,
|
|
3220
|
+
reason,
|
|
3221
|
+
sessionId: controller.sessionId
|
|
3222
|
+
});
|
|
3223
|
+
});
|
|
3029
3224
|
};
|
|
3030
3225
|
const unsubscribe = controller.subscribe(() => {
|
|
3031
3226
|
if (options.interruptOnPartial === false) {
|
|
@@ -3033,7 +3228,7 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
|
3033
3228
|
return;
|
|
3034
3229
|
}
|
|
3035
3230
|
if (!lastPartial && controller.partial) {
|
|
3036
|
-
interruptIfPlaying();
|
|
3231
|
+
interruptIfPlaying("partial-transcript");
|
|
3037
3232
|
}
|
|
3038
3233
|
lastPartial = controller.partial;
|
|
3039
3234
|
});
|
|
@@ -3043,11 +3238,11 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
|
3043
3238
|
},
|
|
3044
3239
|
handleLevel: (level) => {
|
|
3045
3240
|
if (shouldInterruptForLevel(level, options)) {
|
|
3046
|
-
interruptIfPlaying();
|
|
3241
|
+
interruptIfPlaying("input-level");
|
|
3047
3242
|
}
|
|
3048
3243
|
},
|
|
3049
3244
|
sendAudio: (audio) => {
|
|
3050
|
-
interruptIfPlaying();
|
|
3245
|
+
interruptIfPlaying("manual-audio");
|
|
3051
3246
|
controller.sendAudio(audio);
|
|
3052
3247
|
}
|
|
3053
3248
|
};
|
|
@@ -3077,7 +3272,17 @@ var createVoiceDuplexController = (path, options = {}) => {
|
|
|
3077
3272
|
audioPlayer,
|
|
3078
3273
|
close,
|
|
3079
3274
|
interruptAssistant: async () => {
|
|
3275
|
+
options.bargeIn?.monitor?.recordRequested({
|
|
3276
|
+
reason: "manual-interrupt",
|
|
3277
|
+
sessionId: controller.sessionId
|
|
3278
|
+
});
|
|
3080
3279
|
await audioPlayer.interrupt();
|
|
3280
|
+
options.bargeIn?.monitor?.recordStopped({
|
|
3281
|
+
latencyMs: audioPlayer.lastInterruptLatencyMs,
|
|
3282
|
+
playbackStopLatencyMs: audioPlayer.lastPlaybackStopLatencyMs,
|
|
3283
|
+
reason: "manual-interrupt",
|
|
3284
|
+
sessionId: controller.sessionId
|
|
3285
|
+
});
|
|
3081
3286
|
},
|
|
3082
3287
|
sendAudio: (audio) => {
|
|
3083
3288
|
bargeInBinding?.sendAudio(audio);
|
|
@@ -3468,7 +3673,165 @@ var loadVoiceTestFixtures = async (fixtureDirectory) => {
|
|
|
3468
3673
|
}
|
|
3469
3674
|
return fixtures;
|
|
3470
3675
|
};
|
|
3676
|
+
// src/testing/ioProviderSimulator.ts
|
|
3677
|
+
var defaultFailureMessage = (input) => `Simulated ${input.provider} ${input.kind.toUpperCase()} ${input.operation} failure.`;
|
|
3678
|
+
var resolveRecoveryElapsedMs = (value, provider) => {
|
|
3679
|
+
if (typeof value === "number") {
|
|
3680
|
+
return value;
|
|
3681
|
+
}
|
|
3682
|
+
return value?.[provider] ?? 25;
|
|
3683
|
+
};
|
|
3684
|
+
var createHealth = (input) => ({
|
|
3685
|
+
consecutiveFailures: input.status === "healthy" ? 0 : 1,
|
|
3686
|
+
lastFailureAt: input.status === "healthy" ? undefined : input.now,
|
|
3687
|
+
provider: input.provider,
|
|
3688
|
+
status: input.status,
|
|
3689
|
+
suppressedUntil: input.suppressedUntil
|
|
3690
|
+
});
|
|
3691
|
+
var resolveFallback = async (options, provider) => {
|
|
3692
|
+
const configured = typeof options.fallback === "function" ? await options.fallback(provider) : options.fallback;
|
|
3693
|
+
return (configured ?? options.providers).find((candidate) => candidate !== provider);
|
|
3694
|
+
};
|
|
3695
|
+
var createVoiceIOProviderFailureSimulator = (options) => {
|
|
3696
|
+
if (options.providers.length === 0) {
|
|
3697
|
+
throw new Error("At least one provider is required.");
|
|
3698
|
+
}
|
|
3699
|
+
const now = options.now ?? Date.now;
|
|
3700
|
+
const operation = options.operation ?? "open";
|
|
3701
|
+
const cooldownMs = Math.max(0, options.cooldownMs ?? 30000);
|
|
3702
|
+
const emit = async (event, input) => {
|
|
3703
|
+
await options.onProviderEvent?.(event, input);
|
|
3704
|
+
};
|
|
3705
|
+
const run = async (provider, mode) => {
|
|
3706
|
+
if (!options.providers.includes(provider)) {
|
|
3707
|
+
throw new Error(`${provider} is not configured for simulation.`);
|
|
3708
|
+
}
|
|
3709
|
+
const startedAt = now();
|
|
3710
|
+
const sessionId = options.sessionId?.({ mode, now: startedAt, provider }) ?? `${options.kind}-provider-sim-${startedAt}`;
|
|
3711
|
+
if (mode === "recovery") {
|
|
3712
|
+
await emit({
|
|
3713
|
+
at: startedAt,
|
|
3714
|
+
attempt: 0,
|
|
3715
|
+
elapsedMs: resolveRecoveryElapsedMs(options.recoveryElapsedMs, provider),
|
|
3716
|
+
kind: options.kind,
|
|
3717
|
+
latencyBudgetMs: options.latencyBudgets?.[provider],
|
|
3718
|
+
operation,
|
|
3719
|
+
provider,
|
|
3720
|
+
providerHealth: createHealth({
|
|
3721
|
+
now: startedAt,
|
|
3722
|
+
provider,
|
|
3723
|
+
status: "healthy"
|
|
3724
|
+
}),
|
|
3725
|
+
selectedProvider: provider,
|
|
3726
|
+
status: "success"
|
|
3727
|
+
}, { mode, provider, sessionId });
|
|
3728
|
+
return {
|
|
3729
|
+
mode,
|
|
3730
|
+
provider,
|
|
3731
|
+
sessionId,
|
|
3732
|
+
status: "simulated"
|
|
3733
|
+
};
|
|
3734
|
+
}
|
|
3735
|
+
const fallbackProvider = await resolveFallback(options, provider);
|
|
3736
|
+
const suppressedUntil = startedAt + cooldownMs;
|
|
3737
|
+
await emit({
|
|
3738
|
+
at: startedAt,
|
|
3739
|
+
attempt: 0,
|
|
3740
|
+
elapsedMs: options.failureElapsedMs ?? 10,
|
|
3741
|
+
error: (options.failureMessage ?? defaultFailureMessage)({
|
|
3742
|
+
kind: options.kind,
|
|
3743
|
+
operation,
|
|
3744
|
+
provider
|
|
3745
|
+
}),
|
|
3746
|
+
fallbackProvider,
|
|
3747
|
+
kind: options.kind,
|
|
3748
|
+
latencyBudgetMs: options.latencyBudgets?.[provider],
|
|
3749
|
+
operation,
|
|
3750
|
+
provider,
|
|
3751
|
+
providerHealth: createHealth({
|
|
3752
|
+
now: startedAt,
|
|
3753
|
+
provider,
|
|
3754
|
+
status: "suppressed",
|
|
3755
|
+
suppressedUntil
|
|
3756
|
+
}),
|
|
3757
|
+
selectedProvider: provider,
|
|
3758
|
+
status: "error",
|
|
3759
|
+
suppressedUntil
|
|
3760
|
+
}, { mode, provider, sessionId });
|
|
3761
|
+
if (fallbackProvider) {
|
|
3762
|
+
await emit({
|
|
3763
|
+
at: startedAt + 1,
|
|
3764
|
+
attempt: 1,
|
|
3765
|
+
elapsedMs: resolveRecoveryElapsedMs(options.recoveryElapsedMs, fallbackProvider),
|
|
3766
|
+
fallbackProvider,
|
|
3767
|
+
kind: options.kind,
|
|
3768
|
+
latencyBudgetMs: options.latencyBudgets?.[fallbackProvider],
|
|
3769
|
+
operation,
|
|
3770
|
+
provider: fallbackProvider,
|
|
3771
|
+
providerHealth: createHealth({
|
|
3772
|
+
now: startedAt + 1,
|
|
3773
|
+
provider: fallbackProvider,
|
|
3774
|
+
status: "healthy"
|
|
3775
|
+
}),
|
|
3776
|
+
selectedProvider: provider,
|
|
3777
|
+
status: "fallback"
|
|
3778
|
+
}, { mode, provider, sessionId });
|
|
3779
|
+
}
|
|
3780
|
+
return {
|
|
3781
|
+
fallbackProvider,
|
|
3782
|
+
mode,
|
|
3783
|
+
provider,
|
|
3784
|
+
sessionId,
|
|
3785
|
+
status: "simulated",
|
|
3786
|
+
suppressedUntil
|
|
3787
|
+
};
|
|
3788
|
+
};
|
|
3789
|
+
return {
|
|
3790
|
+
run
|
|
3791
|
+
};
|
|
3792
|
+
};
|
|
3471
3793
|
// src/modelAdapters.ts
|
|
3794
|
+
var resolveVoiceProviderRoutingPolicyPreset = (preset, options = {}) => {
|
|
3795
|
+
switch (preset) {
|
|
3796
|
+
case "balanced":
|
|
3797
|
+
return {
|
|
3798
|
+
fallbackMode: "provider-error",
|
|
3799
|
+
strategy: "balanced",
|
|
3800
|
+
weights: {
|
|
3801
|
+
cost: 1,
|
|
3802
|
+
latencyMs: 0.005,
|
|
3803
|
+
priority: 1,
|
|
3804
|
+
quality: 10,
|
|
3805
|
+
...options.weights
|
|
3806
|
+
},
|
|
3807
|
+
...options
|
|
3808
|
+
};
|
|
3809
|
+
case "cost-cap":
|
|
3810
|
+
return {
|
|
3811
|
+
fallbackMode: "provider-error",
|
|
3812
|
+
strategy: "prefer-cheapest",
|
|
3813
|
+
...options
|
|
3814
|
+
};
|
|
3815
|
+
case "cost-first":
|
|
3816
|
+
return {
|
|
3817
|
+
fallbackMode: "provider-error",
|
|
3818
|
+
strategy: "prefer-cheapest",
|
|
3819
|
+
...options
|
|
3820
|
+
};
|
|
3821
|
+
case "latency-first":
|
|
3822
|
+
return {
|
|
3823
|
+
fallbackMode: "provider-error",
|
|
3824
|
+
strategy: "prefer-fastest",
|
|
3825
|
+
...options
|
|
3826
|
+
};
|
|
3827
|
+
case "quality-first":
|
|
3828
|
+
return {
|
|
3829
|
+
fallbackMode: "provider-error",
|
|
3830
|
+
strategy: "quality-first",
|
|
3831
|
+
...options
|
|
3832
|
+
};
|
|
3833
|
+
}
|
|
3834
|
+
};
|
|
3472
3835
|
var OUTPUT_SCHEMA = {
|
|
3473
3836
|
additionalProperties: false,
|
|
3474
3837
|
properties: {
|
|
@@ -3559,6 +3922,17 @@ var parseJSONValue = (value) => {
|
|
|
3559
3922
|
return value;
|
|
3560
3923
|
}
|
|
3561
3924
|
};
|
|
3925
|
+
|
|
3926
|
+
class VoiceProviderTimeoutError extends Error {
|
|
3927
|
+
provider;
|
|
3928
|
+
timeoutMs;
|
|
3929
|
+
constructor(provider, timeoutMs) {
|
|
3930
|
+
super(`Voice provider ${provider} exceeded ${timeoutMs}ms latency budget.`);
|
|
3931
|
+
this.name = "VoiceProviderTimeoutError";
|
|
3932
|
+
this.provider = provider;
|
|
3933
|
+
this.timeoutMs = timeoutMs;
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3562
3936
|
var getMessageToolCalls = (message) => {
|
|
3563
3937
|
const toolCalls = message.metadata?.toolCalls;
|
|
3564
3938
|
return Array.isArray(toolCalls) ? toolCalls.filter((toolCall) => toolCall && typeof toolCall === "object" && typeof toolCall.name === "string") : [];
|
|
@@ -3625,7 +3999,7 @@ var createJSONVoiceAssistantModel = (options) => ({
|
|
|
3625
3999
|
var createVoiceProviderRouter = (options) => {
|
|
3626
4000
|
const providerIds = Object.keys(options.providers);
|
|
3627
4001
|
const firstProvider = providerIds[0];
|
|
3628
|
-
const policy = typeof options.policy === "string" ? {
|
|
4002
|
+
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) : {
|
|
3629
4003
|
strategy: options.policy
|
|
3630
4004
|
} : options.policy;
|
|
3631
4005
|
const strategy = policy?.strategy ?? "prefer-selected";
|
|
@@ -3636,6 +4010,10 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3636
4010
|
const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
|
|
3637
4011
|
const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
|
|
3638
4012
|
const rateLimitCooldownMs = Math.max(0, healthOptions?.rateLimitCooldownMs ?? 60000);
|
|
4013
|
+
const getProviderTimeoutMs = (provider) => {
|
|
4014
|
+
const timeoutMs = options.providerProfiles?.[provider]?.timeoutMs ?? options.timeoutMs;
|
|
4015
|
+
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
|
|
4016
|
+
};
|
|
3639
4017
|
const getHealth = (provider) => {
|
|
3640
4018
|
const existing = healthState.get(provider);
|
|
3641
4019
|
if (existing) {
|
|
@@ -3703,13 +4081,40 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3703
4081
|
const allowed = typeof allowProviders === "function" ? await allowProviders(input) : allowProviders;
|
|
3704
4082
|
return new Set(allowed ?? providerIds);
|
|
3705
4083
|
};
|
|
4084
|
+
const passesBudgetFilters = (provider) => {
|
|
4085
|
+
const profile = options.providerProfiles?.[provider];
|
|
4086
|
+
if (typeof policy?.maxCost === "number" && typeof profile?.cost === "number" && profile.cost > policy.maxCost) {
|
|
4087
|
+
return false;
|
|
4088
|
+
}
|
|
4089
|
+
if (typeof policy?.maxLatencyMs === "number" && typeof profile?.latencyMs === "number" && profile.latencyMs > policy.maxLatencyMs) {
|
|
4090
|
+
return false;
|
|
4091
|
+
}
|
|
4092
|
+
if (typeof policy?.minQuality === "number" && typeof profile?.quality === "number" && profile.quality < policy.minQuality) {
|
|
4093
|
+
return false;
|
|
4094
|
+
}
|
|
4095
|
+
return true;
|
|
4096
|
+
};
|
|
4097
|
+
const getBalancedScore = (provider) => {
|
|
4098
|
+
const profile = options.providerProfiles?.[provider];
|
|
4099
|
+
if (policy?.scoreProvider) {
|
|
4100
|
+
return policy.scoreProvider(provider, profile);
|
|
4101
|
+
}
|
|
4102
|
+
const weights = policy?.weights ?? {};
|
|
4103
|
+
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);
|
|
4104
|
+
};
|
|
3706
4105
|
const sortProviders = (providers) => {
|
|
3707
|
-
if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest") {
|
|
4106
|
+
if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest" && strategy !== "quality-first" && strategy !== "balanced") {
|
|
3708
4107
|
return providers;
|
|
3709
4108
|
}
|
|
3710
4109
|
return [...providers].sort((left, right) => {
|
|
3711
4110
|
const leftProfile = options.providerProfiles?.[left];
|
|
3712
4111
|
const rightProfile = options.providerProfiles?.[right];
|
|
4112
|
+
if (strategy === "quality-first") {
|
|
4113
|
+
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);
|
|
4114
|
+
}
|
|
4115
|
+
if (strategy === "balanced") {
|
|
4116
|
+
return getBalancedScore(left) - getBalancedScore(right);
|
|
4117
|
+
}
|
|
3713
4118
|
const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
3714
4119
|
const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
3715
4120
|
return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
|
|
@@ -3719,12 +4124,13 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3719
4124
|
const selectedProvider = await options.selectProvider?.(input);
|
|
3720
4125
|
const allowedProviders = await resolveAllowedProviders(input);
|
|
3721
4126
|
const fallbackOrder = typeof options.fallback === "function" ? await options.fallback(input) : options.fallback;
|
|
3722
|
-
const
|
|
4127
|
+
const allowedRankedProviders = sortProviders([
|
|
3723
4128
|
...fallbackOrder ?? providerIds
|
|
3724
4129
|
]).filter((provider) => allowedProviders.has(provider));
|
|
4130
|
+
const rankedProviders = allowedRankedProviders.filter(passesBudgetFilters);
|
|
3725
4131
|
const healthyRankedProviders = healthOptions ? rankedProviders.filter((provider) => !isSuppressed(provider)) : rankedProviders;
|
|
3726
4132
|
const candidateRankedProviders = healthyRankedProviders.length ? healthyRankedProviders : rankedProviders;
|
|
3727
|
-
const preferred = selectedProvider && allowedProviders.has(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
|
|
4133
|
+
const preferred = selectedProvider && allowedProviders.has(selectedProvider) && passesBudgetFilters(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
|
|
3728
4134
|
const seen = new Set;
|
|
3729
4135
|
const order = [];
|
|
3730
4136
|
const candidates = strategy === "ordered" ? candidateRankedProviders : [
|
|
@@ -3747,6 +4153,25 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3747
4153
|
const emit = async (event, input) => {
|
|
3748
4154
|
await options.onProviderEvent?.(event, input);
|
|
3749
4155
|
};
|
|
4156
|
+
const runProvider = async (provider, model, input) => {
|
|
4157
|
+
const timeoutMs = getProviderTimeoutMs(provider);
|
|
4158
|
+
if (!timeoutMs) {
|
|
4159
|
+
return model.generate(input);
|
|
4160
|
+
}
|
|
4161
|
+
let timeout;
|
|
4162
|
+
try {
|
|
4163
|
+
return await Promise.race([
|
|
4164
|
+
model.generate(input),
|
|
4165
|
+
new Promise((_, reject) => {
|
|
4166
|
+
timeout = setTimeout(() => reject(new VoiceProviderTimeoutError(provider, timeoutMs)), timeoutMs);
|
|
4167
|
+
})
|
|
4168
|
+
]);
|
|
4169
|
+
} finally {
|
|
4170
|
+
if (timeout) {
|
|
4171
|
+
clearTimeout(timeout);
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4174
|
+
};
|
|
3750
4175
|
return {
|
|
3751
4176
|
generate: async (input) => {
|
|
3752
4177
|
const { order, selectedProvider } = await resolveOrder(input);
|
|
@@ -3761,12 +4186,14 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3761
4186
|
}
|
|
3762
4187
|
const startedAt = Date.now();
|
|
3763
4188
|
try {
|
|
3764
|
-
const output = await model
|
|
4189
|
+
const output = await runProvider(provider, model, input);
|
|
3765
4190
|
const providerHealth = recordProviderSuccess(provider);
|
|
3766
4191
|
await emit({
|
|
3767
4192
|
at: Date.now(),
|
|
4193
|
+
attempt: index + 1,
|
|
3768
4194
|
elapsedMs: Date.now() - startedAt,
|
|
3769
4195
|
fallbackProvider: provider === selectedProvider ? undefined : provider,
|
|
4196
|
+
latencyBudgetMs: getProviderTimeoutMs(provider),
|
|
3770
4197
|
provider,
|
|
3771
4198
|
providerHealth,
|
|
3772
4199
|
recovered: provider !== selectedProvider,
|
|
@@ -3778,22 +4205,26 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3778
4205
|
lastError = error;
|
|
3779
4206
|
const hasNextProvider = index < order.length - 1;
|
|
3780
4207
|
const isProviderError = options.isProviderError?.(error, provider) ?? true;
|
|
4208
|
+
const timedOut = options.isTimeoutError?.(error, provider) ?? error instanceof VoiceProviderTimeoutError;
|
|
3781
4209
|
const rateLimited = options.isRateLimitError?.(error, provider) ?? defaultIsRateLimitError(error);
|
|
3782
4210
|
const shouldFallback = fallbackMode === "provider-error" ? isProviderError : fallbackMode === "rate-limit" ? isProviderError && rateLimited : false;
|
|
3783
4211
|
const providerHealth = recordProviderError(provider, isProviderError, rateLimited);
|
|
3784
4212
|
const nextProvider = hasNextProvider ? order[index + 1] : undefined;
|
|
3785
4213
|
await emit({
|
|
3786
4214
|
at: Date.now(),
|
|
4215
|
+
attempt: index + 1,
|
|
3787
4216
|
elapsedMs: Date.now() - startedAt,
|
|
3788
4217
|
error: errorMessage(error),
|
|
3789
4218
|
fallbackProvider: shouldFallback ? nextProvider : undefined,
|
|
4219
|
+
latencyBudgetMs: getProviderTimeoutMs(provider),
|
|
3790
4220
|
provider,
|
|
3791
4221
|
providerHealth,
|
|
3792
4222
|
rateLimited,
|
|
3793
4223
|
selectedProvider,
|
|
3794
4224
|
suppressionRemainingMs: getSuppressionRemainingMs(provider),
|
|
3795
4225
|
suppressedUntil: providerHealth?.suppressedUntil,
|
|
3796
|
-
status: "error"
|
|
4226
|
+
status: "error",
|
|
4227
|
+
timedOut
|
|
3797
4228
|
}, input);
|
|
3798
4229
|
if (!hasNextProvider || !shouldFallback) {
|
|
3799
4230
|
throw error;
|
|
@@ -4415,7 +4846,290 @@ var createVoiceMemoryStore = () => {
|
|
|
4415
4846
|
};
|
|
4416
4847
|
|
|
4417
4848
|
// src/session.ts
|
|
4418
|
-
import { Buffer } from "buffer";
|
|
4849
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
4850
|
+
|
|
4851
|
+
// src/handoff.ts
|
|
4852
|
+
var toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
4853
|
+
var signHandoffBody = async (input) => {
|
|
4854
|
+
const encoder = new TextEncoder;
|
|
4855
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(input.secret), {
|
|
4856
|
+
hash: "SHA-256",
|
|
4857
|
+
name: "HMAC"
|
|
4858
|
+
}, false, ["sign"]);
|
|
4859
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(`${input.timestamp}.${input.body}`));
|
|
4860
|
+
return `sha256=${toHex(new Uint8Array(signature))}`;
|
|
4861
|
+
};
|
|
4862
|
+
var toErrorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
4863
|
+
var createSkippedDelivery = (adapter) => ({
|
|
4864
|
+
adapterId: adapter.id,
|
|
4865
|
+
adapterKind: adapter.kind,
|
|
4866
|
+
status: "skipped"
|
|
4867
|
+
});
|
|
4868
|
+
var aggregateHandoffStatus = (deliveries) => {
|
|
4869
|
+
const statuses = Object.values(deliveries).map((delivery) => delivery.status);
|
|
4870
|
+
if (statuses.some((status) => status === "failed")) {
|
|
4871
|
+
return "failed";
|
|
4872
|
+
}
|
|
4873
|
+
if (statuses.some((status) => status === "delivered")) {
|
|
4874
|
+
return "delivered";
|
|
4875
|
+
}
|
|
4876
|
+
return "skipped";
|
|
4877
|
+
};
|
|
4878
|
+
var createHandoffDeliveryId = (input) => [
|
|
4879
|
+
"voice-handoff",
|
|
4880
|
+
input.sessionId,
|
|
4881
|
+
input.action,
|
|
4882
|
+
Date.now(),
|
|
4883
|
+
crypto.randomUUID()
|
|
4884
|
+
].join(":");
|
|
4885
|
+
var resolveHandoffDeliveryError = (deliveries) => Object.values(deliveries).map((delivery) => delivery.error).find(Boolean);
|
|
4886
|
+
var defaultWebhookBody = (input) => ({
|
|
4887
|
+
action: input.action,
|
|
4888
|
+
metadata: input.metadata,
|
|
4889
|
+
reason: input.reason,
|
|
4890
|
+
result: input.result,
|
|
4891
|
+
session: {
|
|
4892
|
+
id: input.session.id,
|
|
4893
|
+
scenarioId: input.session.scenarioId,
|
|
4894
|
+
status: input.session.status
|
|
4895
|
+
},
|
|
4896
|
+
source: "absolutejs-voice",
|
|
4897
|
+
target: input.target
|
|
4898
|
+
});
|
|
4899
|
+
var deliverVoiceHandoff = async (input) => {
|
|
4900
|
+
if (!input.config || input.config.adapters.length === 0) {
|
|
4901
|
+
return;
|
|
4902
|
+
}
|
|
4903
|
+
const deliveries = {};
|
|
4904
|
+
for (const adapter of input.config.adapters) {
|
|
4905
|
+
if (adapter.actions && !adapter.actions.includes(input.handoff.action)) {
|
|
4906
|
+
deliveries[adapter.id] = createSkippedDelivery(adapter);
|
|
4907
|
+
continue;
|
|
4908
|
+
}
|
|
4909
|
+
try {
|
|
4910
|
+
const result = await adapter.handoff(input.handoff);
|
|
4911
|
+
deliveries[adapter.id] = {
|
|
4912
|
+
...result,
|
|
4913
|
+
adapterId: adapter.id,
|
|
4914
|
+
adapterKind: adapter.kind
|
|
4915
|
+
};
|
|
4916
|
+
} catch (error) {
|
|
4917
|
+
deliveries[adapter.id] = {
|
|
4918
|
+
adapterId: adapter.id,
|
|
4919
|
+
adapterKind: adapter.kind,
|
|
4920
|
+
error: toErrorMessage(error),
|
|
4921
|
+
status: "failed"
|
|
4922
|
+
};
|
|
4923
|
+
if (input.config.failMode === "throw") {
|
|
4924
|
+
throw error;
|
|
4925
|
+
}
|
|
4926
|
+
}
|
|
4927
|
+
}
|
|
4928
|
+
return {
|
|
4929
|
+
action: input.handoff.action,
|
|
4930
|
+
deliveries,
|
|
4931
|
+
status: aggregateHandoffStatus(deliveries)
|
|
4932
|
+
};
|
|
4933
|
+
};
|
|
4934
|
+
var createVoiceHandoffDeliveryRecord = (input) => {
|
|
4935
|
+
const now = Date.now();
|
|
4936
|
+
return {
|
|
4937
|
+
action: input.action,
|
|
4938
|
+
context: input.context,
|
|
4939
|
+
createdAt: now,
|
|
4940
|
+
deliveryAttempts: 0,
|
|
4941
|
+
deliveryStatus: "pending",
|
|
4942
|
+
id: input.id ?? createHandoffDeliveryId({
|
|
4943
|
+
action: input.action,
|
|
4944
|
+
sessionId: input.session.id
|
|
4945
|
+
}),
|
|
4946
|
+
metadata: input.metadata,
|
|
4947
|
+
reason: input.reason,
|
|
4948
|
+
result: input.result,
|
|
4949
|
+
session: input.session,
|
|
4950
|
+
sessionId: input.session.id,
|
|
4951
|
+
target: input.target,
|
|
4952
|
+
updatedAt: now
|
|
4953
|
+
};
|
|
4954
|
+
};
|
|
4955
|
+
var applyVoiceHandoffDeliveryResult = (delivery, result) => ({
|
|
4956
|
+
...delivery,
|
|
4957
|
+
deliveredAt: result.status === "delivered" || result.status === "skipped" ? Date.now() : delivery.deliveredAt,
|
|
4958
|
+
deliveries: result.deliveries,
|
|
4959
|
+
deliveryAttempts: (delivery.deliveryAttempts ?? 0) + 1,
|
|
4960
|
+
deliveryError: result.status === "failed" ? resolveHandoffDeliveryError(result.deliveries) : undefined,
|
|
4961
|
+
deliveryStatus: result.status,
|
|
4962
|
+
updatedAt: Date.now()
|
|
4963
|
+
});
|
|
4964
|
+
var deliverVoiceHandoffDelivery = async (options) => {
|
|
4965
|
+
const result = await deliverVoiceHandoff({
|
|
4966
|
+
config: {
|
|
4967
|
+
adapters: options.adapters,
|
|
4968
|
+
failMode: options.failMode
|
|
4969
|
+
},
|
|
4970
|
+
handoff: {
|
|
4971
|
+
action: options.delivery.action,
|
|
4972
|
+
api: options.api,
|
|
4973
|
+
context: options.delivery.context,
|
|
4974
|
+
metadata: options.delivery.metadata,
|
|
4975
|
+
reason: options.delivery.reason,
|
|
4976
|
+
result: options.delivery.result,
|
|
4977
|
+
session: options.delivery.session,
|
|
4978
|
+
target: options.delivery.target
|
|
4979
|
+
}
|
|
4980
|
+
});
|
|
4981
|
+
return result ? applyVoiceHandoffDeliveryResult(options.delivery, result) : {
|
|
4982
|
+
...options.delivery,
|
|
4983
|
+
deliveryAttempts: (options.delivery.deliveryAttempts ?? 0) + 1,
|
|
4984
|
+
deliveryStatus: "skipped",
|
|
4985
|
+
updatedAt: Date.now()
|
|
4986
|
+
};
|
|
4987
|
+
};
|
|
4988
|
+
var createVoiceMemoryHandoffDeliveryStore = () => {
|
|
4989
|
+
const deliveries = new Map;
|
|
4990
|
+
return {
|
|
4991
|
+
get: async (id) => deliveries.get(id),
|
|
4992
|
+
list: async () => [...deliveries.values()].sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)),
|
|
4993
|
+
remove: async (id) => {
|
|
4994
|
+
deliveries.delete(id);
|
|
4995
|
+
},
|
|
4996
|
+
set: async (id, delivery) => {
|
|
4997
|
+
deliveries.set(id, delivery);
|
|
4998
|
+
}
|
|
4999
|
+
};
|
|
5000
|
+
};
|
|
5001
|
+
var createVoiceWebhookHandoffAdapter = (options) => ({
|
|
5002
|
+
actions: options.actions,
|
|
5003
|
+
handoff: async (input) => {
|
|
5004
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
5005
|
+
if (typeof fetchImpl !== "function") {
|
|
5006
|
+
return {
|
|
5007
|
+
deliveredTo: options.url,
|
|
5008
|
+
error: "Handoff delivery failed: fetch is not available in this runtime.",
|
|
5009
|
+
status: "failed"
|
|
5010
|
+
};
|
|
5011
|
+
}
|
|
5012
|
+
const body = JSON.stringify(await options.body?.(input) ?? defaultWebhookBody(input));
|
|
5013
|
+
const headers = {
|
|
5014
|
+
"content-type": "application/json",
|
|
5015
|
+
...options.headers
|
|
5016
|
+
};
|
|
5017
|
+
if (options.signingSecret) {
|
|
5018
|
+
const timestamp = String(Date.now());
|
|
5019
|
+
headers["x-absolutejs-timestamp"] = timestamp;
|
|
5020
|
+
headers["x-absolutejs-signature"] = await signHandoffBody({
|
|
5021
|
+
body,
|
|
5022
|
+
secret: options.signingSecret,
|
|
5023
|
+
timestamp
|
|
5024
|
+
});
|
|
5025
|
+
}
|
|
5026
|
+
const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
|
|
5027
|
+
const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
|
|
5028
|
+
try {
|
|
5029
|
+
const response = await fetchImpl(options.url, {
|
|
5030
|
+
body,
|
|
5031
|
+
headers,
|
|
5032
|
+
method: options.method ?? "POST",
|
|
5033
|
+
signal: controller?.signal
|
|
5034
|
+
});
|
|
5035
|
+
if (!response.ok) {
|
|
5036
|
+
return {
|
|
5037
|
+
deliveredTo: options.url,
|
|
5038
|
+
error: `Handoff delivery failed with response ${response.status}.`,
|
|
5039
|
+
status: "failed"
|
|
5040
|
+
};
|
|
5041
|
+
}
|
|
5042
|
+
return {
|
|
5043
|
+
deliveredAt: Date.now(),
|
|
5044
|
+
deliveredTo: options.url,
|
|
5045
|
+
status: "delivered"
|
|
5046
|
+
};
|
|
5047
|
+
} finally {
|
|
5048
|
+
if (timeout) {
|
|
5049
|
+
clearTimeout(timeout);
|
|
5050
|
+
}
|
|
5051
|
+
}
|
|
5052
|
+
},
|
|
5053
|
+
id: options.id,
|
|
5054
|
+
kind: options.kind ?? "webhook"
|
|
5055
|
+
});
|
|
5056
|
+
var escapeXml = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
5057
|
+
var defaultTwilioTransferTwiML = (input) => {
|
|
5058
|
+
if (!input.target) {
|
|
5059
|
+
return "<Response><Hangup /></Response>";
|
|
5060
|
+
}
|
|
5061
|
+
return `<Response><Dial>${escapeXml(input.target)}</Dial></Response>`;
|
|
5062
|
+
};
|
|
5063
|
+
var resolveTwilioCallSid = async (resolver, input) => {
|
|
5064
|
+
if (typeof resolver === "function") {
|
|
5065
|
+
return resolver(input);
|
|
5066
|
+
}
|
|
5067
|
+
if (typeof resolver === "string" && resolver.length > 0) {
|
|
5068
|
+
return resolver;
|
|
5069
|
+
}
|
|
5070
|
+
const metadataSid = typeof input.metadata?.callSid === "string" ? input.metadata.callSid : undefined;
|
|
5071
|
+
const sessionMetadata = input.session.metadata && typeof input.session.metadata === "object" ? input.session.metadata : undefined;
|
|
5072
|
+
const sessionSid = typeof sessionMetadata?.callSid === "string" ? sessionMetadata.callSid : undefined;
|
|
5073
|
+
return metadataSid ?? sessionSid;
|
|
5074
|
+
};
|
|
5075
|
+
var createVoiceTwilioRedirectHandoffAdapter = (options) => ({
|
|
5076
|
+
actions: options.actions ?? ["transfer"],
|
|
5077
|
+
handoff: async (input) => {
|
|
5078
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
5079
|
+
const callSid = await resolveTwilioCallSid(options.callSid, input);
|
|
5080
|
+
if (!callSid) {
|
|
5081
|
+
return {
|
|
5082
|
+
error: "Twilio handoff requires a callSid.",
|
|
5083
|
+
status: "failed"
|
|
5084
|
+
};
|
|
5085
|
+
}
|
|
5086
|
+
if (typeof fetchImpl !== "function") {
|
|
5087
|
+
return {
|
|
5088
|
+
error: "Twilio handoff failed: fetch is not available in this runtime.",
|
|
5089
|
+
status: "failed"
|
|
5090
|
+
};
|
|
5091
|
+
}
|
|
5092
|
+
const url = `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(options.accountSid)}/Calls/${encodeURIComponent(callSid)}.json`;
|
|
5093
|
+
const body = new URLSearchParams({
|
|
5094
|
+
Twiml: await (options.buildTwiML?.(input) ?? defaultTwilioTransferTwiML(input))
|
|
5095
|
+
});
|
|
5096
|
+
const auth = btoa(`${options.accountSid}:${options.authToken}`);
|
|
5097
|
+
const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
|
|
5098
|
+
const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
|
|
5099
|
+
try {
|
|
5100
|
+
const response = await fetchImpl(url, {
|
|
5101
|
+
body,
|
|
5102
|
+
headers: {
|
|
5103
|
+
authorization: `Basic ${auth}`,
|
|
5104
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
5105
|
+
},
|
|
5106
|
+
method: "POST",
|
|
5107
|
+
signal: controller?.signal
|
|
5108
|
+
});
|
|
5109
|
+
if (!response.ok) {
|
|
5110
|
+
return {
|
|
5111
|
+
deliveredTo: url,
|
|
5112
|
+
error: `Twilio handoff failed with response ${response.status}.`,
|
|
5113
|
+
status: "failed"
|
|
5114
|
+
};
|
|
5115
|
+
}
|
|
5116
|
+
return {
|
|
5117
|
+
deliveredAt: Date.now(),
|
|
5118
|
+
deliveredTo: url,
|
|
5119
|
+
metadata: {
|
|
5120
|
+
callSid
|
|
5121
|
+
},
|
|
5122
|
+
status: "delivered"
|
|
5123
|
+
};
|
|
5124
|
+
} finally {
|
|
5125
|
+
if (timeout) {
|
|
5126
|
+
clearTimeout(timeout);
|
|
5127
|
+
}
|
|
5128
|
+
}
|
|
5129
|
+
},
|
|
5130
|
+
id: options.id ?? "twilio-redirect",
|
|
5131
|
+
kind: "twilio-redirect"
|
|
5132
|
+
});
|
|
4419
5133
|
|
|
4420
5134
|
// src/logger.ts
|
|
4421
5135
|
var noop2 = () => {};
|
|
@@ -4451,6 +5165,12 @@ var DEFAULT_FORMAT = {
|
|
|
4451
5165
|
encoding: "pcm_s16le",
|
|
4452
5166
|
sampleRateHz: 16000
|
|
4453
5167
|
};
|
|
5168
|
+
var DEFAULT_REALTIME_FORMAT = {
|
|
5169
|
+
channels: 1,
|
|
5170
|
+
container: "raw",
|
|
5171
|
+
encoding: "pcm_s16le",
|
|
5172
|
+
sampleRateHz: 24000
|
|
5173
|
+
};
|
|
4454
5174
|
var toError = (value) => value instanceof Error ? value : new Error(String(value));
|
|
4455
5175
|
var createEmptyCurrentTurn = () => ({
|
|
4456
5176
|
finalText: "",
|
|
@@ -4463,7 +5183,7 @@ var createEmptyCurrentTurn = () => ({
|
|
|
4463
5183
|
transcripts: []
|
|
4464
5184
|
});
|
|
4465
5185
|
var cloneTranscript = (transcript) => ({ ...transcript });
|
|
4466
|
-
var encodeBase64 = (chunk) =>
|
|
5186
|
+
var encodeBase64 = (chunk) => Buffer2.from(chunk).toString("base64");
|
|
4467
5187
|
var countWords2 = (text) => text.trim().split(/\s+/).filter(Boolean).length;
|
|
4468
5188
|
var normalizeText2 = (text) => text.trim().replace(/\s+/g, " ");
|
|
4469
5189
|
var getAudioChunkDurationMs = (chunk) => chunk.byteLength / (DEFAULT_FORMAT.sampleRateHz * DEFAULT_FORMAT.channels * 2) * 1000;
|
|
@@ -4609,6 +5329,7 @@ var pushCallLifecycleEvent = (session, input) => {
|
|
|
4609
5329
|
}
|
|
4610
5330
|
return lifecycle;
|
|
4611
5331
|
};
|
|
5332
|
+
var getLatestCallLifecycleEvent = (session) => session.call?.events.at(-1);
|
|
4612
5333
|
var createVoiceSession = (options) => {
|
|
4613
5334
|
const logger = resolveLogger(options.logger);
|
|
4614
5335
|
const reconnect = {
|
|
@@ -4633,7 +5354,7 @@ var createVoiceSession = (options) => {
|
|
|
4633
5354
|
} : undefined;
|
|
4634
5355
|
const appendTrace = async (input) => {
|
|
4635
5356
|
await options.trace?.append({
|
|
4636
|
-
at: Date.now(),
|
|
5357
|
+
at: input.at ?? Date.now(),
|
|
4637
5358
|
metadata: input.metadata,
|
|
4638
5359
|
payload: input.payload,
|
|
4639
5360
|
scenarioId: input.session?.scenarioId ?? options.scenarioId,
|
|
@@ -4642,6 +5363,13 @@ var createVoiceSession = (options) => {
|
|
|
4642
5363
|
type: input.type
|
|
4643
5364
|
});
|
|
4644
5365
|
};
|
|
5366
|
+
const appendTurnLatencyStage = async (input) => appendTrace({
|
|
5367
|
+
at: input.at,
|
|
5368
|
+
payload: { stage: input.stage },
|
|
5369
|
+
session: input.session,
|
|
5370
|
+
turnId: input.turnId,
|
|
5371
|
+
type: "turn_latency.stage"
|
|
5372
|
+
});
|
|
4645
5373
|
const phraseHints = options.phraseHints ?? [];
|
|
4646
5374
|
const lexicon = options.lexicon ?? [];
|
|
4647
5375
|
let socket = options.socket;
|
|
@@ -4709,35 +5437,105 @@ var createVoiceSession = (options) => {
|
|
|
4709
5437
|
});
|
|
4710
5438
|
}
|
|
4711
5439
|
};
|
|
4712
|
-
const
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
mutate(session);
|
|
4716
|
-
await options.store.set(options.id, session);
|
|
4717
|
-
return session;
|
|
4718
|
-
};
|
|
4719
|
-
const runSerial = (phase, operation) => {
|
|
4720
|
-
const result = operationQueue.then(async () => {
|
|
4721
|
-
logger.debug("voice session operation", {
|
|
4722
|
-
phase,
|
|
4723
|
-
sessionId: options.id
|
|
4724
|
-
});
|
|
4725
|
-
return await operation();
|
|
4726
|
-
});
|
|
4727
|
-
operationQueue = result.then(() => {
|
|
4728
|
-
return;
|
|
4729
|
-
}, () => {
|
|
5440
|
+
const sendCallLifecycle = async (session) => {
|
|
5441
|
+
const event = getLatestCallLifecycleEvent(session);
|
|
5442
|
+
if (!event) {
|
|
4730
5443
|
return;
|
|
5444
|
+
}
|
|
5445
|
+
await send({
|
|
5446
|
+
event,
|
|
5447
|
+
sessionId: options.id,
|
|
5448
|
+
type: "call_lifecycle"
|
|
4731
5449
|
});
|
|
4732
|
-
return result;
|
|
4733
5450
|
};
|
|
4734
|
-
const
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
5451
|
+
const sendReplay = async (session) => {
|
|
5452
|
+
await send({
|
|
5453
|
+
assistantTexts: session.turns.flatMap((turn) => turn.assistantText ? [turn.assistantText] : []),
|
|
5454
|
+
call: session.call,
|
|
5455
|
+
partial: session.currentTurn.partialText,
|
|
5456
|
+
scenarioId: session.scenarioId,
|
|
5457
|
+
sessionId: options.id,
|
|
5458
|
+
status: session.status,
|
|
5459
|
+
turns: session.turns,
|
|
5460
|
+
type: "replay"
|
|
5461
|
+
});
|
|
5462
|
+
};
|
|
5463
|
+
const runHandoff = async (input) => {
|
|
5464
|
+
const queuedDelivery = options.handoff?.deliveryQueue ? createVoiceHandoffDeliveryRecord({
|
|
5465
|
+
action: input.action,
|
|
5466
|
+
context: options.context,
|
|
5467
|
+
metadata: input.metadata,
|
|
5468
|
+
reason: input.reason,
|
|
5469
|
+
result: input.result,
|
|
5470
|
+
session: input.session,
|
|
5471
|
+
target: input.target
|
|
5472
|
+
}) : undefined;
|
|
5473
|
+
if (queuedDelivery) {
|
|
5474
|
+
await options.handoff?.deliveryQueue?.set(queuedDelivery.id, queuedDelivery);
|
|
5475
|
+
}
|
|
5476
|
+
if (options.handoff?.enqueueOnly) {
|
|
5477
|
+
return;
|
|
5478
|
+
}
|
|
5479
|
+
const result = await deliverVoiceHandoff({
|
|
5480
|
+
config: options.handoff,
|
|
5481
|
+
handoff: {
|
|
5482
|
+
action: input.action,
|
|
5483
|
+
api,
|
|
5484
|
+
context: options.context,
|
|
5485
|
+
metadata: input.metadata,
|
|
5486
|
+
reason: input.reason,
|
|
5487
|
+
result: input.result,
|
|
5488
|
+
session: input.session,
|
|
5489
|
+
target: input.target
|
|
5490
|
+
}
|
|
5491
|
+
});
|
|
5492
|
+
if (!result) {
|
|
5493
|
+
return;
|
|
5494
|
+
}
|
|
5495
|
+
if (queuedDelivery) {
|
|
5496
|
+
const updatedDelivery = applyVoiceHandoffDeliveryResult(queuedDelivery, result);
|
|
5497
|
+
await options.handoff?.deliveryQueue?.set(updatedDelivery.id, updatedDelivery);
|
|
5498
|
+
}
|
|
5499
|
+
await appendTrace({
|
|
5500
|
+
metadata: input.metadata,
|
|
5501
|
+
payload: {
|
|
5502
|
+
...result,
|
|
5503
|
+
reason: input.reason,
|
|
5504
|
+
target: input.target
|
|
5505
|
+
},
|
|
5506
|
+
session: input.session,
|
|
5507
|
+
type: "call.handoff"
|
|
5508
|
+
});
|
|
5509
|
+
};
|
|
5510
|
+
const readSession = async () => options.store.getOrCreate(options.id);
|
|
5511
|
+
const writeSession = async (mutate) => {
|
|
5512
|
+
const session = await options.store.getOrCreate(options.id);
|
|
5513
|
+
mutate(session);
|
|
5514
|
+
await options.store.set(options.id, session);
|
|
5515
|
+
return session;
|
|
5516
|
+
};
|
|
5517
|
+
const runSerial = (phase, operation) => {
|
|
5518
|
+
const result = operationQueue.then(async () => {
|
|
5519
|
+
logger.debug("voice session operation", {
|
|
5520
|
+
phase,
|
|
5521
|
+
sessionId: options.id
|
|
5522
|
+
});
|
|
5523
|
+
return await operation();
|
|
5524
|
+
});
|
|
5525
|
+
operationQueue = result.then(() => {
|
|
5526
|
+
return;
|
|
5527
|
+
}, () => {
|
|
5528
|
+
return;
|
|
5529
|
+
});
|
|
5530
|
+
return result;
|
|
5531
|
+
};
|
|
5532
|
+
const closeAdapter = async (reason) => {
|
|
5533
|
+
if (!sttSession) {
|
|
5534
|
+
return;
|
|
5535
|
+
}
|
|
5536
|
+
const activeSession = sttSession;
|
|
5537
|
+
sttSession = null;
|
|
5538
|
+
activeAdapterGeneration = 0;
|
|
4741
5539
|
try {
|
|
4742
5540
|
await activeSession.close(reason);
|
|
4743
5541
|
} catch (error) {
|
|
@@ -4765,6 +5563,23 @@ var createVoiceSession = (options) => {
|
|
|
4765
5563
|
});
|
|
4766
5564
|
}
|
|
4767
5565
|
};
|
|
5566
|
+
const sendAssistantAudio = async (chunk, input) => {
|
|
5567
|
+
const normalizedChunk = chunk instanceof Uint8Array ? new Uint8Array(chunk) : chunk instanceof ArrayBuffer ? new Uint8Array(chunk.slice(0)) : new Uint8Array(chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength));
|
|
5568
|
+
await send({
|
|
5569
|
+
chunkBase64: encodeBase64(normalizedChunk),
|
|
5570
|
+
format: input.format,
|
|
5571
|
+
receivedAt: input.receivedAt,
|
|
5572
|
+
turnId: activeTTSTurnId,
|
|
5573
|
+
type: "audio"
|
|
5574
|
+
});
|
|
5575
|
+
if (activeTTSTurnId) {
|
|
5576
|
+
await appendTurnLatencyStage({
|
|
5577
|
+
at: input.receivedAt,
|
|
5578
|
+
stage: "assistant_audio_received",
|
|
5579
|
+
turnId: activeTTSTurnId
|
|
5580
|
+
});
|
|
5581
|
+
}
|
|
5582
|
+
};
|
|
4768
5583
|
const scheduleTurnCommit = (delayMs, reason, reset = true) => {
|
|
4769
5584
|
if (!reset && silenceTimer) {
|
|
4770
5585
|
return;
|
|
@@ -4899,6 +5714,7 @@ var createVoiceSession = (options) => {
|
|
|
4899
5714
|
await appendTrace({
|
|
4900
5715
|
payload: {
|
|
4901
5716
|
disposition,
|
|
5717
|
+
metadata: input.metadata,
|
|
4902
5718
|
reason: input.reason,
|
|
4903
5719
|
target: input.target,
|
|
4904
5720
|
type: "end"
|
|
@@ -4906,6 +5722,7 @@ var createVoiceSession = (options) => {
|
|
|
4906
5722
|
session,
|
|
4907
5723
|
type: "call.lifecycle"
|
|
4908
5724
|
});
|
|
5725
|
+
await sendCallLifecycle(session);
|
|
4909
5726
|
await send({
|
|
4910
5727
|
sessionId: options.id,
|
|
4911
5728
|
type: "complete"
|
|
@@ -4985,6 +5802,15 @@ var createVoiceSession = (options) => {
|
|
|
4985
5802
|
session,
|
|
4986
5803
|
type: "call.lifecycle"
|
|
4987
5804
|
});
|
|
5805
|
+
await sendCallLifecycle(session);
|
|
5806
|
+
await runHandoff({
|
|
5807
|
+
action: "transfer",
|
|
5808
|
+
metadata: input.metadata,
|
|
5809
|
+
reason: input.reason,
|
|
5810
|
+
result: input.result,
|
|
5811
|
+
session,
|
|
5812
|
+
target: input.target
|
|
5813
|
+
});
|
|
4988
5814
|
await completeInternal(input.result, {
|
|
4989
5815
|
disposition: "transferred",
|
|
4990
5816
|
invokeOnComplete: false,
|
|
@@ -5010,6 +5836,14 @@ var createVoiceSession = (options) => {
|
|
|
5010
5836
|
session,
|
|
5011
5837
|
type: "call.lifecycle"
|
|
5012
5838
|
});
|
|
5839
|
+
await sendCallLifecycle(session);
|
|
5840
|
+
await runHandoff({
|
|
5841
|
+
action: "escalate",
|
|
5842
|
+
metadata: input.metadata,
|
|
5843
|
+
reason: input.reason,
|
|
5844
|
+
result: input.result,
|
|
5845
|
+
session
|
|
5846
|
+
});
|
|
5013
5847
|
await completeInternal(input.result, {
|
|
5014
5848
|
disposition: "escalated",
|
|
5015
5849
|
invokeOnComplete: false,
|
|
@@ -5032,6 +5866,13 @@ var createVoiceSession = (options) => {
|
|
|
5032
5866
|
session,
|
|
5033
5867
|
type: "call.lifecycle"
|
|
5034
5868
|
});
|
|
5869
|
+
await sendCallLifecycle(session);
|
|
5870
|
+
await runHandoff({
|
|
5871
|
+
action: "no-answer",
|
|
5872
|
+
metadata: input?.metadata,
|
|
5873
|
+
result: input?.result,
|
|
5874
|
+
session
|
|
5875
|
+
});
|
|
5035
5876
|
await completeInternal(input?.result, {
|
|
5036
5877
|
disposition: "no-answer",
|
|
5037
5878
|
invokeOnComplete: false,
|
|
@@ -5053,6 +5894,13 @@ var createVoiceSession = (options) => {
|
|
|
5053
5894
|
session,
|
|
5054
5895
|
type: "call.lifecycle"
|
|
5055
5896
|
});
|
|
5897
|
+
await sendCallLifecycle(session);
|
|
5898
|
+
await runHandoff({
|
|
5899
|
+
action: "voicemail",
|
|
5900
|
+
metadata: input?.metadata,
|
|
5901
|
+
result: input?.result,
|
|
5902
|
+
session
|
|
5903
|
+
});
|
|
5056
5904
|
await completeInternal(input?.result, {
|
|
5057
5905
|
disposition: "voicemail",
|
|
5058
5906
|
invokeOnComplete: false,
|
|
@@ -5433,8 +6281,12 @@ var createVoiceSession = (options) => {
|
|
|
5433
6281
|
if (sttSession) {
|
|
5434
6282
|
return sttSession;
|
|
5435
6283
|
}
|
|
5436
|
-
const
|
|
5437
|
-
|
|
6284
|
+
const inputAdapter = options.realtime ?? options.stt;
|
|
6285
|
+
if (!inputAdapter) {
|
|
6286
|
+
throw new Error("Voice session requires either an stt or realtime adapter.");
|
|
6287
|
+
}
|
|
6288
|
+
const openedSession = await inputAdapter.open({
|
|
6289
|
+
format: options.realtime ? options.realtimeInputFormat ?? DEFAULT_REALTIME_FORMAT : DEFAULT_FORMAT,
|
|
5438
6290
|
languageStrategy: options.languageStrategy,
|
|
5439
6291
|
lexicon,
|
|
5440
6292
|
phraseHints,
|
|
@@ -5469,6 +6321,16 @@ var createVoiceSession = (options) => {
|
|
|
5469
6321
|
openedSession.on("close", (event) => {
|
|
5470
6322
|
runAdapterEvent("adapter.close", () => handleClose(event));
|
|
5471
6323
|
});
|
|
6324
|
+
if (options.realtime) {
|
|
6325
|
+
openedSession.on("audio", ({ chunk, format, receivedAt }) => {
|
|
6326
|
+
runAdapterEvent("adapter.audio", async () => {
|
|
6327
|
+
await sendAssistantAudio(chunk, {
|
|
6328
|
+
format,
|
|
6329
|
+
receivedAt
|
|
6330
|
+
});
|
|
6331
|
+
});
|
|
6332
|
+
});
|
|
6333
|
+
}
|
|
5472
6334
|
return openedSession;
|
|
5473
6335
|
};
|
|
5474
6336
|
const ensureTTSSession = async () => {
|
|
@@ -5493,13 +6355,9 @@ var createVoiceSession = (options) => {
|
|
|
5493
6355
|
if (ttsSession !== openedSession) {
|
|
5494
6356
|
return;
|
|
5495
6357
|
}
|
|
5496
|
-
|
|
5497
|
-
await send({
|
|
5498
|
-
chunkBase64: encodeBase64(normalizedChunk),
|
|
6358
|
+
await sendAssistantAudio(chunk, {
|
|
5499
6359
|
format,
|
|
5500
|
-
receivedAt
|
|
5501
|
-
turnId: activeTTSTurnId,
|
|
5502
|
-
type: "audio"
|
|
6360
|
+
receivedAt
|
|
5503
6361
|
});
|
|
5504
6362
|
});
|
|
5505
6363
|
});
|
|
@@ -5543,9 +6401,32 @@ var createVoiceSession = (options) => {
|
|
|
5543
6401
|
});
|
|
5544
6402
|
};
|
|
5545
6403
|
const completeTurn = async (session, turn) => {
|
|
6404
|
+
const liveOpsControl = await options.liveOps?.getControl(options.id);
|
|
6405
|
+
if (liveOpsControl?.assistantPaused || liveOpsControl?.operatorTakeover) {
|
|
6406
|
+
await appendTrace({
|
|
6407
|
+
metadata: {
|
|
6408
|
+
source: "voice-live-ops"
|
|
6409
|
+
},
|
|
6410
|
+
payload: {
|
|
6411
|
+
action: "turn.skipped",
|
|
6412
|
+
control: liveOpsControl,
|
|
6413
|
+
reason: liveOpsControl.operatorTakeover ? "operator-takeover" : "assistant-paused",
|
|
6414
|
+
status: "skipped"
|
|
6415
|
+
},
|
|
6416
|
+
session,
|
|
6417
|
+
turnId: turn.id,
|
|
6418
|
+
type: "operator.action"
|
|
6419
|
+
});
|
|
6420
|
+
return;
|
|
6421
|
+
}
|
|
6422
|
+
const injectedInstruction = liveOpsControl?.injectedInstruction?.trim();
|
|
5546
6423
|
const committedOutput = await options.route.onTurn({
|
|
5547
6424
|
api,
|
|
5548
6425
|
context: options.context,
|
|
6426
|
+
liveOps: liveOpsControl ? {
|
|
6427
|
+
control: liveOpsControl,
|
|
6428
|
+
injectedInstruction
|
|
6429
|
+
} : undefined,
|
|
5549
6430
|
session,
|
|
5550
6431
|
turn
|
|
5551
6432
|
});
|
|
@@ -5559,6 +6440,7 @@ var createVoiceSession = (options) => {
|
|
|
5559
6440
|
voicemail: committedOutput?.voicemail
|
|
5560
6441
|
};
|
|
5561
6442
|
if (output?.assistantText) {
|
|
6443
|
+
const assistantTextStartedAt = Date.now();
|
|
5562
6444
|
await writeSession((currentSession) => {
|
|
5563
6445
|
setTurnResult(currentSession, turn.id, {
|
|
5564
6446
|
assistantText: output.assistantText
|
|
@@ -5569,10 +6451,17 @@ var createVoiceSession = (options) => {
|
|
|
5569
6451
|
turnId: turn.id,
|
|
5570
6452
|
type: "assistant"
|
|
5571
6453
|
});
|
|
6454
|
+
await appendTurnLatencyStage({
|
|
6455
|
+
at: assistantTextStartedAt,
|
|
6456
|
+
session,
|
|
6457
|
+
stage: "assistant_text_started",
|
|
6458
|
+
turnId: turn.id
|
|
6459
|
+
});
|
|
5572
6460
|
await appendTrace({
|
|
5573
6461
|
payload: {
|
|
5574
6462
|
text: output.assistantText,
|
|
5575
|
-
ttsConfigured: Boolean(options.tts)
|
|
6463
|
+
ttsConfigured: Boolean(options.tts),
|
|
6464
|
+
realtimeConfigured: Boolean(options.realtime)
|
|
5576
6465
|
},
|
|
5577
6466
|
session,
|
|
5578
6467
|
turnId: turn.id,
|
|
@@ -5583,7 +6472,18 @@ var createVoiceSession = (options) => {
|
|
|
5583
6472
|
if (activeTTSSession) {
|
|
5584
6473
|
const ttsStartedAt = Date.now();
|
|
5585
6474
|
activeTTSTurnId = turn.id;
|
|
6475
|
+
await appendTurnLatencyStage({
|
|
6476
|
+
at: ttsStartedAt,
|
|
6477
|
+
session,
|
|
6478
|
+
stage: "tts_send_started",
|
|
6479
|
+
turnId: turn.id
|
|
6480
|
+
});
|
|
5586
6481
|
await activeTTSSession.send(output.assistantText);
|
|
6482
|
+
await appendTurnLatencyStage({
|
|
6483
|
+
session,
|
|
6484
|
+
stage: "tts_send_completed",
|
|
6485
|
+
turnId: turn.id
|
|
6486
|
+
});
|
|
5587
6487
|
await appendTrace({
|
|
5588
6488
|
payload: {
|
|
5589
6489
|
elapsedMs: Date.now() - ttsStartedAt,
|
|
@@ -5593,9 +6493,35 @@ var createVoiceSession = (options) => {
|
|
|
5593
6493
|
turnId: turn.id,
|
|
5594
6494
|
type: "turn.assistant"
|
|
5595
6495
|
});
|
|
6496
|
+
} else if (options.realtime) {
|
|
6497
|
+
const activeRealtimeSession = await ensureAdapter();
|
|
6498
|
+
const realtimeStartedAt = Date.now();
|
|
6499
|
+
activeTTSTurnId = turn.id;
|
|
6500
|
+
await appendTurnLatencyStage({
|
|
6501
|
+
at: realtimeStartedAt,
|
|
6502
|
+
session,
|
|
6503
|
+
stage: "tts_send_started",
|
|
6504
|
+
turnId: turn.id
|
|
6505
|
+
});
|
|
6506
|
+
await activeRealtimeSession.send(output.assistantText);
|
|
6507
|
+
await appendTurnLatencyStage({
|
|
6508
|
+
session,
|
|
6509
|
+
stage: "tts_send_completed",
|
|
6510
|
+
turnId: turn.id
|
|
6511
|
+
});
|
|
6512
|
+
await appendTrace({
|
|
6513
|
+
payload: {
|
|
6514
|
+
elapsedMs: Date.now() - realtimeStartedAt,
|
|
6515
|
+
mode: "realtime",
|
|
6516
|
+
status: "sent"
|
|
6517
|
+
},
|
|
6518
|
+
session,
|
|
6519
|
+
turnId: turn.id,
|
|
6520
|
+
type: "turn.assistant"
|
|
6521
|
+
});
|
|
5596
6522
|
}
|
|
5597
6523
|
} catch (error) {
|
|
5598
|
-
logger.warn("voice
|
|
6524
|
+
logger.warn("voice assistant audio send failed", {
|
|
5599
6525
|
error: toError(error).message,
|
|
5600
6526
|
sessionId: options.id,
|
|
5601
6527
|
turnId: turn.id
|
|
@@ -5603,7 +6529,7 @@ var createVoiceSession = (options) => {
|
|
|
5603
6529
|
await appendTrace({
|
|
5604
6530
|
payload: {
|
|
5605
6531
|
error: toError(error).message,
|
|
5606
|
-
status: "tts-send-failed"
|
|
6532
|
+
status: options.realtime ? "realtime-send-failed" : "tts-send-failed"
|
|
5607
6533
|
},
|
|
5608
6534
|
session,
|
|
5609
6535
|
turnId: turn.id,
|
|
@@ -5780,11 +6706,35 @@ var createVoiceSession = (options) => {
|
|
|
5780
6706
|
turnId: turn.id,
|
|
5781
6707
|
type: "turn.cost"
|
|
5782
6708
|
});
|
|
6709
|
+
const firstTranscriptAt = turn.transcripts.map((transcript) => transcript.endedAtMs ?? transcript.startedAtMs).filter((value) => typeof value === "number").sort((left, right) => left - right)[0];
|
|
6710
|
+
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];
|
|
6711
|
+
if (firstTranscriptAt !== undefined) {
|
|
6712
|
+
await appendTurnLatencyStage({
|
|
6713
|
+
at: firstTranscriptAt,
|
|
6714
|
+
session: updatedSession,
|
|
6715
|
+
stage: "speech_detected",
|
|
6716
|
+
turnId: turn.id
|
|
6717
|
+
});
|
|
6718
|
+
}
|
|
6719
|
+
if (finalTranscriptAt !== undefined) {
|
|
6720
|
+
await appendTurnLatencyStage({
|
|
6721
|
+
at: finalTranscriptAt,
|
|
6722
|
+
session: updatedSession,
|
|
6723
|
+
stage: "final_transcript",
|
|
6724
|
+
turnId: turn.id
|
|
6725
|
+
});
|
|
6726
|
+
}
|
|
6727
|
+
await appendTurnLatencyStage({
|
|
6728
|
+
at: turn.committedAt,
|
|
6729
|
+
session: updatedSession,
|
|
6730
|
+
stage: "turn_committed",
|
|
6731
|
+
turnId: turn.id
|
|
6732
|
+
});
|
|
5783
6733
|
await send({
|
|
5784
6734
|
turn,
|
|
5785
6735
|
type: "turn"
|
|
5786
6736
|
});
|
|
5787
|
-
if (options.sttLifecycle === "turn-scoped") {
|
|
6737
|
+
if (options.stt && options.sttLifecycle === "turn-scoped") {
|
|
5788
6738
|
await closeAdapter("turn-commit");
|
|
5789
6739
|
}
|
|
5790
6740
|
await completeTurn(updatedSession, turn);
|
|
@@ -5839,6 +6789,7 @@ var createVoiceSession = (options) => {
|
|
|
5839
6789
|
session,
|
|
5840
6790
|
type: "call.lifecycle"
|
|
5841
6791
|
});
|
|
6792
|
+
await sendCallLifecycle(session);
|
|
5842
6793
|
}
|
|
5843
6794
|
await send({
|
|
5844
6795
|
sessionId: options.id,
|
|
@@ -5846,6 +6797,7 @@ var createVoiceSession = (options) => {
|
|
|
5846
6797
|
scenarioId: session.scenarioId,
|
|
5847
6798
|
type: "session"
|
|
5848
6799
|
});
|
|
6800
|
+
await sendReplay(session);
|
|
5849
6801
|
if (shouldFireOnSession) {
|
|
5850
6802
|
await options.route.onCallStart?.({
|
|
5851
6803
|
api,
|
|
@@ -6429,7 +7381,7 @@ var createVoiceCallReviewFromLiveTelephonyReport = (report, options = {}) => {
|
|
|
6429
7381
|
}
|
|
6430
7382
|
};
|
|
6431
7383
|
};
|
|
6432
|
-
var
|
|
7384
|
+
var toErrorMessage2 = (error) => {
|
|
6433
7385
|
if (typeof error === "string" && error.trim().length > 0) {
|
|
6434
7386
|
return error;
|
|
6435
7387
|
}
|
|
@@ -6516,7 +7468,7 @@ var createVoiceCallReviewRecorder = (options = {}) => {
|
|
|
6516
7468
|
};
|
|
6517
7469
|
},
|
|
6518
7470
|
recordError: (error) => {
|
|
6519
|
-
const message =
|
|
7471
|
+
const message = toErrorMessage2(error);
|
|
6520
7472
|
errors.push(message);
|
|
6521
7473
|
push("turn", "error", {
|
|
6522
7474
|
reason: message
|
|
@@ -7222,10 +8174,870 @@ var runVoiceSessionBenchmarkSeries = async (input) => {
|
|
|
7222
8174
|
});
|
|
7223
8175
|
};
|
|
7224
8176
|
// src/telephony/twilio.ts
|
|
7225
|
-
import { Buffer as
|
|
8177
|
+
import { Buffer as Buffer3 } from "buffer";
|
|
8178
|
+
import { Elysia as Elysia2 } from "elysia";
|
|
8179
|
+
|
|
8180
|
+
// src/telephonyOutcome.ts
|
|
8181
|
+
import { Elysia } from "elysia";
|
|
8182
|
+
var DEFAULT_COMPLETED_STATUSES = [
|
|
8183
|
+
"answered",
|
|
8184
|
+
"completed",
|
|
8185
|
+
"complete",
|
|
8186
|
+
"connected",
|
|
8187
|
+
"in-progress",
|
|
8188
|
+
"live"
|
|
8189
|
+
];
|
|
8190
|
+
var DEFAULT_NO_ANSWER_STATUSES = [
|
|
8191
|
+
"busy",
|
|
8192
|
+
"canceled",
|
|
8193
|
+
"cancelled",
|
|
8194
|
+
"failed",
|
|
8195
|
+
"no-answer",
|
|
8196
|
+
"no_answer",
|
|
8197
|
+
"not-answered",
|
|
8198
|
+
"ring-no-answer",
|
|
8199
|
+
"timeout",
|
|
8200
|
+
"unanswered"
|
|
8201
|
+
];
|
|
8202
|
+
var DEFAULT_VOICEMAIL_STATUSES = [
|
|
8203
|
+
"answering-machine",
|
|
8204
|
+
"machine",
|
|
8205
|
+
"voicemail",
|
|
8206
|
+
"voice-mail"
|
|
8207
|
+
];
|
|
8208
|
+
var DEFAULT_TRANSFER_STATUSES = ["bridged", "forwarded", "transferred"];
|
|
8209
|
+
var DEFAULT_ESCALATION_STATUSES = ["escalated", "human-required", "operator"];
|
|
8210
|
+
var DEFAULT_FAILED_STATUSES = ["busy", "failed", "no-answer"];
|
|
8211
|
+
var DEFAULT_MACHINE_VOICEMAIL_VALUES = [
|
|
8212
|
+
"answering-machine",
|
|
8213
|
+
"fax",
|
|
8214
|
+
"machine",
|
|
8215
|
+
"machine-end-beep",
|
|
8216
|
+
"machine-end-other",
|
|
8217
|
+
"machine-start",
|
|
8218
|
+
"voicemail"
|
|
8219
|
+
];
|
|
8220
|
+
var DEFAULT_NO_ANSWER_SIP_CODES = [408, 480, 486, 487, 603];
|
|
8221
|
+
var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
8222
|
+
|
|
8223
|
+
class VoiceTelephonyWebhookVerificationError extends Error {
|
|
8224
|
+
result;
|
|
8225
|
+
constructor(result) {
|
|
8226
|
+
super(result.ok ? "telephony webhook verified" : result.reason);
|
|
8227
|
+
this.name = "VoiceTelephonyWebhookVerificationError";
|
|
8228
|
+
this.result = result;
|
|
8229
|
+
}
|
|
8230
|
+
}
|
|
8231
|
+
var createMemoryVoiceTelephonyWebhookIdempotencyStore = () => {
|
|
8232
|
+
const decisions = new Map;
|
|
8233
|
+
return {
|
|
8234
|
+
get: (key) => decisions.get(key),
|
|
8235
|
+
set: (key, decision) => {
|
|
8236
|
+
decisions.set(key, decision);
|
|
8237
|
+
}
|
|
8238
|
+
};
|
|
8239
|
+
};
|
|
8240
|
+
var normalizeToken = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/\s+/g, "-").replace(/_+/g, "-") : undefined;
|
|
8241
|
+
var firstString = (source, keys) => {
|
|
8242
|
+
for (const key of keys) {
|
|
8243
|
+
const value = source[key];
|
|
8244
|
+
if (typeof value === "string" && value.trim()) {
|
|
8245
|
+
return value.trim();
|
|
8246
|
+
}
|
|
8247
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
8248
|
+
return String(value);
|
|
8249
|
+
}
|
|
8250
|
+
}
|
|
8251
|
+
};
|
|
8252
|
+
var firstNumber = (source, keys) => {
|
|
8253
|
+
for (const key of keys) {
|
|
8254
|
+
const value = source[key];
|
|
8255
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
8256
|
+
return value;
|
|
8257
|
+
}
|
|
8258
|
+
if (typeof value === "string" && value.trim()) {
|
|
8259
|
+
const parsed = Number(value);
|
|
8260
|
+
if (Number.isFinite(parsed)) {
|
|
8261
|
+
return parsed;
|
|
8262
|
+
}
|
|
8263
|
+
}
|
|
8264
|
+
}
|
|
8265
|
+
};
|
|
8266
|
+
var parseMaybeJSON = (value) => {
|
|
8267
|
+
try {
|
|
8268
|
+
return JSON.parse(value);
|
|
8269
|
+
} catch {
|
|
8270
|
+
return;
|
|
8271
|
+
}
|
|
8272
|
+
};
|
|
8273
|
+
var flattenPayload = (value) => {
|
|
8274
|
+
if (!isRecord(value)) {
|
|
8275
|
+
return {};
|
|
8276
|
+
}
|
|
8277
|
+
const data = isRecord(value.data) ? value.data : undefined;
|
|
8278
|
+
const payload = isRecord(value.payload) ? value.payload : undefined;
|
|
8279
|
+
const event = isRecord(value.event) ? value.event : undefined;
|
|
8280
|
+
return {
|
|
8281
|
+
...value,
|
|
8282
|
+
...payload,
|
|
8283
|
+
...event,
|
|
8284
|
+
...data,
|
|
8285
|
+
...isRecord(data?.payload) ? data.payload : undefined
|
|
8286
|
+
};
|
|
8287
|
+
};
|
|
8288
|
+
var toBase64 = (bytes) => Buffer.from(new Uint8Array(bytes)).toString("base64");
|
|
8289
|
+
var timingSafeEqual = (left, right) => {
|
|
8290
|
+
const encoder = new TextEncoder;
|
|
8291
|
+
const leftBytes = encoder.encode(left);
|
|
8292
|
+
const rightBytes = encoder.encode(right);
|
|
8293
|
+
if (leftBytes.length !== rightBytes.length) {
|
|
8294
|
+
return false;
|
|
8295
|
+
}
|
|
8296
|
+
let diff = 0;
|
|
8297
|
+
for (let index = 0;index < leftBytes.length; index += 1) {
|
|
8298
|
+
diff |= leftBytes[index] ^ rightBytes[index];
|
|
8299
|
+
}
|
|
8300
|
+
return diff === 0;
|
|
8301
|
+
};
|
|
8302
|
+
var signHmacSHA1Base64 = async (secret, payload) => {
|
|
8303
|
+
const encoder = new TextEncoder;
|
|
8304
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
|
|
8305
|
+
hash: "SHA-1",
|
|
8306
|
+
name: "HMAC"
|
|
8307
|
+
}, false, ["sign"]);
|
|
8308
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
8309
|
+
return toBase64(signature);
|
|
8310
|
+
};
|
|
8311
|
+
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("");
|
|
8312
|
+
var normalizeList = (values, fallback) => new Set((values ?? fallback).map(normalizeToken).filter(Boolean));
|
|
8313
|
+
var metadataValue = (metadata, keys) => {
|
|
8314
|
+
for (const key of keys) {
|
|
8315
|
+
const value = metadata?.[key];
|
|
8316
|
+
if (typeof value === "string" && value.trim()) {
|
|
8317
|
+
return value.trim();
|
|
8318
|
+
}
|
|
8319
|
+
}
|
|
8320
|
+
};
|
|
8321
|
+
var resolveTransferTarget = (event, policy) => {
|
|
8322
|
+
if (typeof event.target === "string" && event.target.trim()) {
|
|
8323
|
+
return event.target.trim();
|
|
8324
|
+
}
|
|
8325
|
+
const metadataTarget = metadataValue(event.metadata, [
|
|
8326
|
+
"transferTarget",
|
|
8327
|
+
"target",
|
|
8328
|
+
"queue",
|
|
8329
|
+
"department"
|
|
8330
|
+
]);
|
|
8331
|
+
if (metadataTarget) {
|
|
8332
|
+
return metadataTarget;
|
|
8333
|
+
}
|
|
8334
|
+
if (typeof policy.transferTarget === "function") {
|
|
8335
|
+
const target = policy.transferTarget(event);
|
|
8336
|
+
return typeof target === "string" && target.trim() ? target.trim() : undefined;
|
|
8337
|
+
}
|
|
8338
|
+
return typeof policy.transferTarget === "string" && policy.transferTarget.trim() ? policy.transferTarget.trim() : undefined;
|
|
8339
|
+
};
|
|
8340
|
+
var mergeMetadata = (event, policy) => ({
|
|
8341
|
+
...policy.includeProviderPayload ? {
|
|
8342
|
+
answeredBy: event.answeredBy,
|
|
8343
|
+
durationMs: event.durationMs,
|
|
8344
|
+
provider: event.provider,
|
|
8345
|
+
reason: event.reason,
|
|
8346
|
+
sipCode: event.sipCode,
|
|
8347
|
+
status: event.status
|
|
8348
|
+
} : undefined,
|
|
8349
|
+
...policy.metadata,
|
|
8350
|
+
...event.metadata
|
|
8351
|
+
});
|
|
8352
|
+
var withDecisionDefaults = (decision, input) => {
|
|
8353
|
+
if (typeof decision === "string") {
|
|
8354
|
+
return buildDecision(decision, input);
|
|
8355
|
+
}
|
|
8356
|
+
return {
|
|
8357
|
+
...buildDecision(decision.action, input),
|
|
8358
|
+
...decision,
|
|
8359
|
+
confidence: decision.confidence ?? "high",
|
|
8360
|
+
metadata: {
|
|
8361
|
+
...mergeMetadata(input.event, input.policy),
|
|
8362
|
+
...decision.metadata
|
|
8363
|
+
},
|
|
8364
|
+
source: decision.source ?? input.source,
|
|
8365
|
+
target: decision.target ?? (decision.action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined)
|
|
8366
|
+
};
|
|
8367
|
+
};
|
|
8368
|
+
var dispositionForAction = (action) => {
|
|
8369
|
+
switch (action) {
|
|
8370
|
+
case "complete":
|
|
8371
|
+
return "completed";
|
|
8372
|
+
case "escalate":
|
|
8373
|
+
return "escalated";
|
|
8374
|
+
case "no-answer":
|
|
8375
|
+
return "no-answer";
|
|
8376
|
+
case "transfer":
|
|
8377
|
+
return "transferred";
|
|
8378
|
+
case "voicemail":
|
|
8379
|
+
return "voicemail";
|
|
8380
|
+
default:
|
|
8381
|
+
return;
|
|
8382
|
+
}
|
|
8383
|
+
};
|
|
8384
|
+
var buildDecision = (action, input) => ({
|
|
8385
|
+
action,
|
|
8386
|
+
confidence: action === "ignore" ? "low" : "high",
|
|
8387
|
+
disposition: dispositionForAction(action),
|
|
8388
|
+
metadata: mergeMetadata(input.event, input.policy),
|
|
8389
|
+
reason: input.event.reason,
|
|
8390
|
+
source: input.source,
|
|
8391
|
+
target: action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined
|
|
8392
|
+
});
|
|
8393
|
+
var createVoiceTelephonyOutcomePolicy = (policy = {}) => ({
|
|
8394
|
+
completedStatuses: policy.completedStatuses ?? DEFAULT_COMPLETED_STATUSES,
|
|
8395
|
+
escalationStatuses: policy.escalationStatuses ?? DEFAULT_ESCALATION_STATUSES,
|
|
8396
|
+
failedAsNoAnswer: policy.failedAsNoAnswer ?? true,
|
|
8397
|
+
failedStatuses: policy.failedStatuses ?? DEFAULT_FAILED_STATUSES,
|
|
8398
|
+
includeProviderPayload: policy.includeProviderPayload ?? true,
|
|
8399
|
+
machineDetectionVoicemailValues: policy.machineDetectionVoicemailValues ?? DEFAULT_MACHINE_VOICEMAIL_VALUES,
|
|
8400
|
+
metadata: policy.metadata,
|
|
8401
|
+
minAnsweredDurationMs: policy.minAnsweredDurationMs,
|
|
8402
|
+
noAnswerOnZeroDuration: policy.noAnswerOnZeroDuration ?? true,
|
|
8403
|
+
noAnswerSipCodes: policy.noAnswerSipCodes ?? DEFAULT_NO_ANSWER_SIP_CODES,
|
|
8404
|
+
noAnswerStatuses: policy.noAnswerStatuses ?? DEFAULT_NO_ANSWER_STATUSES,
|
|
8405
|
+
statusMap: policy.statusMap,
|
|
8406
|
+
transferStatuses: policy.transferStatuses ?? DEFAULT_TRANSFER_STATUSES,
|
|
8407
|
+
transferTarget: policy.transferTarget,
|
|
8408
|
+
voicemailStatuses: policy.voicemailStatuses ?? DEFAULT_VOICEMAIL_STATUSES
|
|
8409
|
+
});
|
|
8410
|
+
var resolveVoiceTelephonyOutcome = (event, policyInput = {}) => {
|
|
8411
|
+
const policy = createVoiceTelephonyOutcomePolicy(policyInput);
|
|
8412
|
+
const status = normalizeToken(event.status);
|
|
8413
|
+
const provider = normalizeToken(event.provider);
|
|
8414
|
+
const answeredBy = normalizeToken(event.answeredBy);
|
|
8415
|
+
const target = resolveTransferTarget(event, policy);
|
|
8416
|
+
if (status) {
|
|
8417
|
+
const mapped = policy.statusMap?.[status] ?? (provider ? policy.statusMap?.[`${provider}:${status}`] : undefined);
|
|
8418
|
+
if (mapped) {
|
|
8419
|
+
return withDecisionDefaults(mapped, {
|
|
8420
|
+
event,
|
|
8421
|
+
policy,
|
|
8422
|
+
source: "policy"
|
|
8423
|
+
});
|
|
8424
|
+
}
|
|
8425
|
+
}
|
|
8426
|
+
if (answeredBy && normalizeList(policy.machineDetectionVoicemailValues, []).has(answeredBy)) {
|
|
8427
|
+
return buildDecision("voicemail", { event, policy, source: "answered-by" });
|
|
8428
|
+
}
|
|
8429
|
+
if (typeof event.sipCode === "number" && policy.noAnswerSipCodes.includes(event.sipCode)) {
|
|
8430
|
+
return buildDecision("no-answer", { event, policy, source: "sip" });
|
|
8431
|
+
}
|
|
8432
|
+
if (target && status && normalizeList(policy.transferStatuses, []).has(status)) {
|
|
8433
|
+
return buildDecision("transfer", { event, policy, source: "status" });
|
|
8434
|
+
}
|
|
8435
|
+
if (status && normalizeList(policy.voicemailStatuses, []).has(status)) {
|
|
8436
|
+
return buildDecision("voicemail", { event, policy, source: "status" });
|
|
8437
|
+
}
|
|
8438
|
+
if (status && normalizeList(policy.escalationStatuses, []).has(status)) {
|
|
8439
|
+
return buildDecision("escalate", { event, policy, source: "status" });
|
|
8440
|
+
}
|
|
8441
|
+
if (status && (policy.failedAsNoAnswer ? normalizeList(policy.noAnswerStatuses, []).has(status) || normalizeList(policy.failedStatuses, []).has(status) : normalizeList(policy.noAnswerStatuses, []).has(status))) {
|
|
8442
|
+
return buildDecision("no-answer", { event, policy, source: "status" });
|
|
8443
|
+
}
|
|
8444
|
+
if (policy.noAnswerOnZeroDuration && typeof event.durationMs === "number" && event.durationMs <= 0) {
|
|
8445
|
+
return buildDecision("no-answer", { event, policy, source: "duration" });
|
|
8446
|
+
}
|
|
8447
|
+
if (typeof policy.minAnsweredDurationMs === "number" && typeof event.durationMs === "number" && event.durationMs < policy.minAnsweredDurationMs) {
|
|
8448
|
+
return {
|
|
8449
|
+
...buildDecision("no-answer", { event, policy, source: "duration" }),
|
|
8450
|
+
confidence: "medium"
|
|
8451
|
+
};
|
|
8452
|
+
}
|
|
8453
|
+
if (status && normalizeList(policy.completedStatuses, []).has(status)) {
|
|
8454
|
+
return buildDecision("complete", { event, policy, source: "status" });
|
|
8455
|
+
}
|
|
8456
|
+
if (target) {
|
|
8457
|
+
return {
|
|
8458
|
+
...buildDecision("transfer", { event, policy, source: "explicit-target" }),
|
|
8459
|
+
confidence: "medium"
|
|
8460
|
+
};
|
|
8461
|
+
}
|
|
8462
|
+
return buildDecision("ignore", { event, policy, source: "status" });
|
|
8463
|
+
};
|
|
8464
|
+
var voiceTelephonyOutcomeToRouteResult = (decision, result) => {
|
|
8465
|
+
switch (decision.action) {
|
|
8466
|
+
case "complete":
|
|
8467
|
+
return { complete: true, result };
|
|
8468
|
+
case "escalate":
|
|
8469
|
+
return {
|
|
8470
|
+
escalate: {
|
|
8471
|
+
metadata: decision.metadata,
|
|
8472
|
+
reason: decision.reason ?? "telephony-escalation"
|
|
8473
|
+
},
|
|
8474
|
+
result
|
|
8475
|
+
};
|
|
8476
|
+
case "no-answer":
|
|
8477
|
+
return {
|
|
8478
|
+
noAnswer: {
|
|
8479
|
+
metadata: decision.metadata
|
|
8480
|
+
},
|
|
8481
|
+
result
|
|
8482
|
+
};
|
|
8483
|
+
case "transfer":
|
|
8484
|
+
if (!decision.target) {
|
|
8485
|
+
return { result };
|
|
8486
|
+
}
|
|
8487
|
+
return {
|
|
8488
|
+
result,
|
|
8489
|
+
transfer: {
|
|
8490
|
+
metadata: decision.metadata,
|
|
8491
|
+
reason: decision.reason,
|
|
8492
|
+
target: decision.target
|
|
8493
|
+
}
|
|
8494
|
+
};
|
|
8495
|
+
case "voicemail":
|
|
8496
|
+
return {
|
|
8497
|
+
result,
|
|
8498
|
+
voicemail: {
|
|
8499
|
+
metadata: decision.metadata
|
|
8500
|
+
}
|
|
8501
|
+
};
|
|
8502
|
+
default:
|
|
8503
|
+
return { result };
|
|
8504
|
+
}
|
|
8505
|
+
};
|
|
8506
|
+
var applyVoiceTelephonyOutcome = async (api, decision, result) => {
|
|
8507
|
+
switch (decision.action) {
|
|
8508
|
+
case "complete":
|
|
8509
|
+
await api.complete(result);
|
|
8510
|
+
break;
|
|
8511
|
+
case "escalate":
|
|
8512
|
+
await api.escalate({
|
|
8513
|
+
metadata: decision.metadata,
|
|
8514
|
+
reason: decision.reason ?? "telephony-escalation",
|
|
8515
|
+
result
|
|
8516
|
+
});
|
|
8517
|
+
break;
|
|
8518
|
+
case "no-answer":
|
|
8519
|
+
await api.markNoAnswer({
|
|
8520
|
+
metadata: decision.metadata,
|
|
8521
|
+
result
|
|
8522
|
+
});
|
|
8523
|
+
break;
|
|
8524
|
+
case "transfer":
|
|
8525
|
+
if (!decision.target) {
|
|
8526
|
+
return;
|
|
8527
|
+
}
|
|
8528
|
+
await api.transfer({
|
|
8529
|
+
metadata: decision.metadata,
|
|
8530
|
+
reason: decision.reason,
|
|
8531
|
+
result,
|
|
8532
|
+
target: decision.target
|
|
8533
|
+
});
|
|
8534
|
+
break;
|
|
8535
|
+
case "voicemail":
|
|
8536
|
+
await api.markVoicemail({
|
|
8537
|
+
metadata: decision.metadata,
|
|
8538
|
+
result
|
|
8539
|
+
});
|
|
8540
|
+
break;
|
|
8541
|
+
default:
|
|
8542
|
+
break;
|
|
8543
|
+
}
|
|
8544
|
+
};
|
|
8545
|
+
var parseRequestBodyText = (input) => {
|
|
8546
|
+
const { contentType, text } = input;
|
|
8547
|
+
if (!text) {
|
|
8548
|
+
return {};
|
|
8549
|
+
}
|
|
8550
|
+
if (contentType.includes("application/json")) {
|
|
8551
|
+
return parseMaybeJSON(text) ?? {};
|
|
8552
|
+
}
|
|
8553
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
8554
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
8555
|
+
}
|
|
8556
|
+
return parseMaybeJSON(text) ?? Object.fromEntries(new URLSearchParams(text));
|
|
8557
|
+
};
|
|
8558
|
+
var readRequestBody = async (request) => {
|
|
8559
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
8560
|
+
const text = await request.text();
|
|
8561
|
+
return {
|
|
8562
|
+
body: parseRequestBodyText({ contentType, text }),
|
|
8563
|
+
rawBody: text
|
|
8564
|
+
};
|
|
8565
|
+
};
|
|
8566
|
+
var signVoiceTwilioWebhook = async (input) => signHmacSHA1Base64(input.authToken, `${input.url}${sortedParamsForSignature(input.body ?? {})}`);
|
|
8567
|
+
var verifyVoiceTwilioWebhookSignature = async (input) => {
|
|
8568
|
+
if (!input.authToken) {
|
|
8569
|
+
return { ok: false, reason: "missing-secret" };
|
|
8570
|
+
}
|
|
8571
|
+
const signature = input.headers.get("x-twilio-signature");
|
|
8572
|
+
if (!signature) {
|
|
8573
|
+
return { ok: false, reason: "missing-signature" };
|
|
8574
|
+
}
|
|
8575
|
+
const expected = await signVoiceTwilioWebhook({
|
|
8576
|
+
authToken: input.authToken,
|
|
8577
|
+
body: input.body,
|
|
8578
|
+
url: input.url
|
|
8579
|
+
});
|
|
8580
|
+
return timingSafeEqual(signature, expected) ? { ok: true } : { ok: false, reason: "invalid-signature" };
|
|
8581
|
+
};
|
|
8582
|
+
var resolveVerificationUrl = (option, input) => typeof option === "function" ? option(input) : option ?? input.request.url;
|
|
8583
|
+
var verifyVoiceTelephonyWebhook = async (input) => {
|
|
8584
|
+
if (input.options.verify) {
|
|
8585
|
+
return input.options.verify({
|
|
8586
|
+
body: input.body,
|
|
8587
|
+
headers: input.request.headers,
|
|
8588
|
+
provider: input.provider,
|
|
8589
|
+
query: input.query,
|
|
8590
|
+
rawBody: input.rawBody,
|
|
8591
|
+
request: input.request
|
|
8592
|
+
});
|
|
8593
|
+
}
|
|
8594
|
+
if (!input.options.signingSecret) {
|
|
8595
|
+
return input.options.requireVerification ? { ok: false, reason: "missing-secret" } : { ok: true };
|
|
8596
|
+
}
|
|
8597
|
+
if (input.provider !== "twilio") {
|
|
8598
|
+
return { ok: false, reason: "unsupported-provider" };
|
|
8599
|
+
}
|
|
8600
|
+
return verifyVoiceTwilioWebhookSignature({
|
|
8601
|
+
authToken: input.options.signingSecret,
|
|
8602
|
+
body: input.body,
|
|
8603
|
+
headers: input.request.headers,
|
|
8604
|
+
url: resolveVerificationUrl(input.options.verificationUrl, {
|
|
8605
|
+
query: input.query,
|
|
8606
|
+
request: input.request
|
|
8607
|
+
})
|
|
8608
|
+
});
|
|
8609
|
+
};
|
|
8610
|
+
var durationMsFromSeconds = (value) => typeof value === "number" ? value * 1000 : undefined;
|
|
8611
|
+
var parseVoiceTelephonyWebhookEvent = (input) => {
|
|
8612
|
+
const payload = flattenPayload(input.body);
|
|
8613
|
+
const provider = firstString(payload, ["provider", "Provider"]) ?? input.provider;
|
|
8614
|
+
const status = firstString(payload, [
|
|
8615
|
+
"CallStatus",
|
|
8616
|
+
"call_status",
|
|
8617
|
+
"callStatus",
|
|
8618
|
+
"DialCallStatus",
|
|
8619
|
+
"dial_call_status",
|
|
8620
|
+
"status",
|
|
8621
|
+
"event_type",
|
|
8622
|
+
"type"
|
|
8623
|
+
]);
|
|
8624
|
+
const durationMs = firstNumber(payload, ["durationMs", "duration_ms"]) ?? durationMsFromSeconds(firstNumber(payload, [
|
|
8625
|
+
"CallDuration",
|
|
8626
|
+
"call_duration",
|
|
8627
|
+
"callDuration",
|
|
8628
|
+
"DialCallDuration",
|
|
8629
|
+
"dial_call_duration",
|
|
8630
|
+
"duration"
|
|
8631
|
+
]));
|
|
8632
|
+
const sipCode = firstNumber(payload, [
|
|
8633
|
+
"SipResponseCode",
|
|
8634
|
+
"sip_response_code",
|
|
8635
|
+
"sipCode",
|
|
8636
|
+
"sip_code",
|
|
8637
|
+
"hangupCauseCode"
|
|
8638
|
+
]);
|
|
8639
|
+
const from = firstString(payload, ["From", "from", "caller_id", "callerId"]);
|
|
8640
|
+
const to = firstString(payload, ["To", "to", "called_number", "calledNumber"]);
|
|
8641
|
+
const target = firstString(payload, [
|
|
8642
|
+
"transferTarget",
|
|
8643
|
+
"TransferTarget",
|
|
8644
|
+
"target",
|
|
8645
|
+
"queue",
|
|
8646
|
+
"department"
|
|
8647
|
+
]);
|
|
8648
|
+
return {
|
|
8649
|
+
answeredBy: firstString(payload, [
|
|
8650
|
+
"AnsweredBy",
|
|
8651
|
+
"answered_by",
|
|
8652
|
+
"answeredBy",
|
|
8653
|
+
"machineDetection",
|
|
8654
|
+
"machine_detection"
|
|
8655
|
+
]),
|
|
8656
|
+
durationMs,
|
|
8657
|
+
from,
|
|
8658
|
+
metadata: {
|
|
8659
|
+
...input.query,
|
|
8660
|
+
...payload
|
|
8661
|
+
},
|
|
8662
|
+
provider,
|
|
8663
|
+
reason: firstString(payload, [
|
|
8664
|
+
"Reason",
|
|
8665
|
+
"reason",
|
|
8666
|
+
"HangupCause",
|
|
8667
|
+
"hangup_cause",
|
|
8668
|
+
"hangupCause"
|
|
8669
|
+
]),
|
|
8670
|
+
sipCode,
|
|
8671
|
+
status,
|
|
8672
|
+
target,
|
|
8673
|
+
to
|
|
8674
|
+
};
|
|
8675
|
+
};
|
|
8676
|
+
var defaultSessionId = (input) => {
|
|
8677
|
+
const payload = flattenPayload(input.body);
|
|
8678
|
+
const metadataSessionId = input.event.metadata?.sessionId;
|
|
8679
|
+
return firstString(input.query, ["sessionId", "session_id"]) ?? firstString(payload, [
|
|
8680
|
+
"sessionId",
|
|
8681
|
+
"session_id",
|
|
8682
|
+
"SessionId",
|
|
8683
|
+
"CallSid",
|
|
8684
|
+
"call_sid",
|
|
8685
|
+
"callSid",
|
|
8686
|
+
"CallUUID",
|
|
8687
|
+
"call_uuid",
|
|
8688
|
+
"callControlId",
|
|
8689
|
+
"call_control_id"
|
|
8690
|
+
]) ?? (typeof metadataSessionId === "string" ? metadataSessionId : undefined);
|
|
8691
|
+
};
|
|
8692
|
+
var defaultIdempotencyKey = (input) => {
|
|
8693
|
+
const payload = flattenPayload(input.body);
|
|
8694
|
+
const eventId = firstString(payload, [
|
|
8695
|
+
"id",
|
|
8696
|
+
"event_id",
|
|
8697
|
+
"eventId",
|
|
8698
|
+
"EventSid",
|
|
8699
|
+
"event_sid",
|
|
8700
|
+
"MessageSid",
|
|
8701
|
+
"message_sid",
|
|
8702
|
+
"CallSid",
|
|
8703
|
+
"call_sid",
|
|
8704
|
+
"CallUUID",
|
|
8705
|
+
"call_uuid",
|
|
8706
|
+
"callControlId",
|
|
8707
|
+
"call_control_id"
|
|
8708
|
+
]);
|
|
8709
|
+
const status = normalizeToken(input.event.status) ?? "unknown";
|
|
8710
|
+
if (eventId) {
|
|
8711
|
+
return `${input.provider}:${eventId}:${status}`;
|
|
8712
|
+
}
|
|
8713
|
+
if (input.sessionId) {
|
|
8714
|
+
return `${input.provider}:${input.sessionId}:${status}`;
|
|
8715
|
+
}
|
|
8716
|
+
};
|
|
8717
|
+
var createVoiceTelephonyWebhookHandler = (options = {}) => async (input) => {
|
|
8718
|
+
const provider = options.provider ?? "generic";
|
|
8719
|
+
const query = input.query ?? {};
|
|
8720
|
+
const { body, rawBody } = await readRequestBody(input.request);
|
|
8721
|
+
const verification = await verifyVoiceTelephonyWebhook({
|
|
8722
|
+
body,
|
|
8723
|
+
options,
|
|
8724
|
+
provider,
|
|
8725
|
+
query,
|
|
8726
|
+
rawBody,
|
|
8727
|
+
request: input.request
|
|
8728
|
+
});
|
|
8729
|
+
if (!verification.ok) {
|
|
8730
|
+
throw new VoiceTelephonyWebhookVerificationError(verification);
|
|
8731
|
+
}
|
|
8732
|
+
const event = options.parse ? await options.parse({
|
|
8733
|
+
body,
|
|
8734
|
+
headers: input.request.headers,
|
|
8735
|
+
provider,
|
|
8736
|
+
query,
|
|
8737
|
+
request: input.request
|
|
8738
|
+
}) : parseVoiceTelephonyWebhookEvent({
|
|
8739
|
+
body,
|
|
8740
|
+
headers: input.request.headers,
|
|
8741
|
+
provider,
|
|
8742
|
+
query,
|
|
8743
|
+
request: input.request
|
|
8744
|
+
});
|
|
8745
|
+
const sessionId = await (options.resolveSessionId?.({
|
|
8746
|
+
body,
|
|
8747
|
+
event,
|
|
8748
|
+
query,
|
|
8749
|
+
request: input.request
|
|
8750
|
+
}) ?? defaultSessionId({ body, event, query }));
|
|
8751
|
+
const idempotencyEnabled = options.idempotency?.enabled !== false;
|
|
8752
|
+
const idempotencyKey = idempotencyEnabled ? await (options.idempotency?.key?.({
|
|
8753
|
+
body,
|
|
8754
|
+
event,
|
|
8755
|
+
provider,
|
|
8756
|
+
query,
|
|
8757
|
+
request: input.request,
|
|
8758
|
+
sessionId
|
|
8759
|
+
}) ?? defaultIdempotencyKey({ body, event, provider, sessionId })) : undefined;
|
|
8760
|
+
const idempotencyStore = options.idempotency?.store;
|
|
8761
|
+
if (idempotencyKey && idempotencyStore) {
|
|
8762
|
+
const existing = await idempotencyStore.get(idempotencyKey);
|
|
8763
|
+
if (existing) {
|
|
8764
|
+
const duplicateDecision = {
|
|
8765
|
+
...existing,
|
|
8766
|
+
duplicate: true
|
|
8767
|
+
};
|
|
8768
|
+
await options.onDecision?.({
|
|
8769
|
+
...duplicateDecision,
|
|
8770
|
+
context: options.context,
|
|
8771
|
+
request: input.request
|
|
8772
|
+
});
|
|
8773
|
+
return duplicateDecision;
|
|
8774
|
+
}
|
|
8775
|
+
}
|
|
8776
|
+
const decision = resolveVoiceTelephonyOutcome(event, options.policy);
|
|
8777
|
+
const resultResolver = options.result;
|
|
8778
|
+
const result = typeof resultResolver === "function" ? await resultResolver({
|
|
8779
|
+
decision,
|
|
8780
|
+
event,
|
|
8781
|
+
sessionId
|
|
8782
|
+
}) : resultResolver;
|
|
8783
|
+
const routeResult = voiceTelephonyOutcomeToRouteResult(decision, result);
|
|
8784
|
+
const shouldApply = typeof options.apply === "function" ? options.apply({
|
|
8785
|
+
applied: false,
|
|
8786
|
+
decision,
|
|
8787
|
+
event,
|
|
8788
|
+
routeResult,
|
|
8789
|
+
sessionId
|
|
8790
|
+
}) : options.apply === true;
|
|
8791
|
+
let applied = false;
|
|
8792
|
+
if (shouldApply && decision.action !== "ignore" && options.getSessionHandle) {
|
|
8793
|
+
const api = await options.getSessionHandle({
|
|
8794
|
+
context: options.context,
|
|
8795
|
+
decision,
|
|
8796
|
+
event,
|
|
8797
|
+
request: input.request,
|
|
8798
|
+
sessionId
|
|
8799
|
+
});
|
|
8800
|
+
if (api) {
|
|
8801
|
+
await applyVoiceTelephonyOutcome(api, decision, result);
|
|
8802
|
+
applied = true;
|
|
8803
|
+
}
|
|
8804
|
+
}
|
|
8805
|
+
const webhookDecision = {
|
|
8806
|
+
applied,
|
|
8807
|
+
decision,
|
|
8808
|
+
event,
|
|
8809
|
+
idempotencyKey,
|
|
8810
|
+
routeResult,
|
|
8811
|
+
sessionId
|
|
8812
|
+
};
|
|
8813
|
+
if (idempotencyKey && idempotencyStore) {
|
|
8814
|
+
const now = Date.now();
|
|
8815
|
+
await idempotencyStore.set(idempotencyKey, {
|
|
8816
|
+
...webhookDecision,
|
|
8817
|
+
createdAt: now,
|
|
8818
|
+
updatedAt: now
|
|
8819
|
+
});
|
|
8820
|
+
}
|
|
8821
|
+
await options.onDecision?.({
|
|
8822
|
+
...webhookDecision,
|
|
8823
|
+
context: options.context,
|
|
8824
|
+
request: input.request
|
|
8825
|
+
});
|
|
8826
|
+
return webhookDecision;
|
|
8827
|
+
};
|
|
8828
|
+
var createVoiceTelephonyWebhookRoutes = (options = {}) => {
|
|
8829
|
+
const path = options.path ?? "/api/voice/telephony/webhook";
|
|
8830
|
+
const handler = createVoiceTelephonyWebhookHandler(options);
|
|
8831
|
+
return new Elysia({
|
|
8832
|
+
name: options.name ?? "absolutejs-voice-telephony-webhooks"
|
|
8833
|
+
}).post(path, async ({ query, request }) => {
|
|
8834
|
+
try {
|
|
8835
|
+
return await handler({ query, request });
|
|
8836
|
+
} catch (error) {
|
|
8837
|
+
if (error instanceof VoiceTelephonyWebhookVerificationError) {
|
|
8838
|
+
return new Response(JSON.stringify({ verification: error.result }), {
|
|
8839
|
+
headers: {
|
|
8840
|
+
"content-type": "application/json"
|
|
8841
|
+
},
|
|
8842
|
+
status: 401
|
|
8843
|
+
});
|
|
8844
|
+
}
|
|
8845
|
+
throw error;
|
|
8846
|
+
}
|
|
8847
|
+
}, {
|
|
8848
|
+
parse: "none"
|
|
8849
|
+
});
|
|
8850
|
+
};
|
|
8851
|
+
|
|
8852
|
+
// src/telephony/twilio.ts
|
|
7226
8853
|
var TWILIO_MULAW_SAMPLE_RATE = 8000;
|
|
7227
8854
|
var VOICE_PCM_SAMPLE_RATE = 16000;
|
|
7228
|
-
var
|
|
8855
|
+
var escapeXml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
8856
|
+
var resolveRequestOrigin = (request) => {
|
|
8857
|
+
const url = new URL(request.url);
|
|
8858
|
+
const forwardedHost = request.headers.get("x-forwarded-host");
|
|
8859
|
+
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
8860
|
+
const host = forwardedHost ?? request.headers.get("host") ?? url.host;
|
|
8861
|
+
const protocol = forwardedProto ?? url.protocol.replace(":", "");
|
|
8862
|
+
return `${protocol}://${host}`;
|
|
8863
|
+
};
|
|
8864
|
+
var resolveTwilioStreamUrl = async (options, input) => {
|
|
8865
|
+
if (typeof options.twiml?.streamUrl === "function") {
|
|
8866
|
+
return options.twiml.streamUrl(input);
|
|
8867
|
+
}
|
|
8868
|
+
if (typeof options.twiml?.streamUrl === "string") {
|
|
8869
|
+
return options.twiml.streamUrl;
|
|
8870
|
+
}
|
|
8871
|
+
const origin = resolveRequestOrigin(input.request);
|
|
8872
|
+
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
8873
|
+
return `${wsOrigin}${input.streamPath}`;
|
|
8874
|
+
};
|
|
8875
|
+
var resolveTwilioStreamParameters = async (parameters, input) => {
|
|
8876
|
+
if (typeof parameters === "function") {
|
|
8877
|
+
return parameters(input);
|
|
8878
|
+
}
|
|
8879
|
+
return parameters;
|
|
8880
|
+
};
|
|
8881
|
+
var joinUrlPath = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
8882
|
+
var escapeHtml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
8883
|
+
var getWebhookVerificationUrl = (webhook, input) => {
|
|
8884
|
+
if (!webhook?.verificationUrl) {
|
|
8885
|
+
return;
|
|
8886
|
+
}
|
|
8887
|
+
if (typeof webhook.verificationUrl === "function") {
|
|
8888
|
+
return webhook.verificationUrl(input);
|
|
8889
|
+
}
|
|
8890
|
+
return webhook.verificationUrl;
|
|
8891
|
+
};
|
|
8892
|
+
var buildTwilioVoiceSetupStatus = async (options, input) => {
|
|
8893
|
+
const origin = resolveRequestOrigin(input.request);
|
|
8894
|
+
const stream = await resolveTwilioStreamUrl(options, input);
|
|
8895
|
+
const twiml = joinUrlPath(origin, input.twimlPath);
|
|
8896
|
+
const webhook = joinUrlPath(origin, input.webhookPath);
|
|
8897
|
+
const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
|
|
8898
|
+
const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
|
|
8899
|
+
const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
|
|
8900
|
+
const warnings = [
|
|
8901
|
+
...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
|
|
8902
|
+
...signingConfigured ? [] : ["Webhook signature verification is not configured."],
|
|
8903
|
+
...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
|
|
8904
|
+
];
|
|
8905
|
+
return {
|
|
8906
|
+
generatedAt: Date.now(),
|
|
8907
|
+
missing,
|
|
8908
|
+
provider: "twilio",
|
|
8909
|
+
ready: missing.length === 0 && signingConfigured && warnings.length === 0,
|
|
8910
|
+
signing: {
|
|
8911
|
+
configured: signingConfigured,
|
|
8912
|
+
mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
|
|
8913
|
+
verificationUrl
|
|
8914
|
+
},
|
|
8915
|
+
urls: {
|
|
8916
|
+
stream,
|
|
8917
|
+
twiml,
|
|
8918
|
+
webhook
|
|
8919
|
+
},
|
|
8920
|
+
warnings
|
|
8921
|
+
};
|
|
8922
|
+
};
|
|
8923
|
+
var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
8924
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
|
|
8925
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
8926
|
+
<p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
|
|
8927
|
+
<section>
|
|
8928
|
+
<h2>URLs</h2>
|
|
8929
|
+
<ul>
|
|
8930
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(status.urls.twiml)}</code></li>
|
|
8931
|
+
<li><strong>Media stream:</strong> <code>${escapeHtml2(status.urls.stream)}</code></li>
|
|
8932
|
+
<li><strong>Status webhook:</strong> <code>${escapeHtml2(status.urls.webhook)}</code></li>
|
|
8933
|
+
</ul>
|
|
8934
|
+
</section>
|
|
8935
|
+
<section>
|
|
8936
|
+
<h2>Signing</h2>
|
|
8937
|
+
<p>Mode: <code>${status.signing.mode}</code></p>
|
|
8938
|
+
${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml2(status.signing.verificationUrl)}</code></p>` : ""}
|
|
8939
|
+
</section>
|
|
8940
|
+
${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml2(name)}</code></li>`).join("")}</ul></section>` : ""}
|
|
8941
|
+
${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml2(warning)}</li>`).join("")}</ul></section>` : ""}
|
|
8942
|
+
</main>`;
|
|
8943
|
+
var extractTwilioStreamUrl = (twiml) => twiml.match(/<Stream\b[^>]*\surl="([^"]+)"/i)?.[1]?.replaceAll("&", "&");
|
|
8944
|
+
var createSmokeCheck = (name, status, message, details) => ({
|
|
8945
|
+
details,
|
|
8946
|
+
message,
|
|
8947
|
+
name,
|
|
8948
|
+
status
|
|
8949
|
+
});
|
|
8950
|
+
var renderTwilioVoiceSmokeHTML = (report, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
8951
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio smoke test</p>
|
|
8952
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
8953
|
+
<p><strong>Status:</strong> ${report.pass ? "Pass" : "Fail"}</p>
|
|
8954
|
+
<section>
|
|
8955
|
+
<h2>Checks</h2>
|
|
8956
|
+
<ul>
|
|
8957
|
+
${report.checks.map((check) => `<li><strong>${escapeHtml2(check.name)}</strong>: ${escapeHtml2(check.status)}${check.message ? ` - ${escapeHtml2(check.message)}` : ""}</li>`).join("")}
|
|
8958
|
+
</ul>
|
|
8959
|
+
</section>
|
|
8960
|
+
<section>
|
|
8961
|
+
<h2>Observed URLs</h2>
|
|
8962
|
+
<ul>
|
|
8963
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(report.setup.urls.twiml)}</code></li>
|
|
8964
|
+
<li><strong>Stream:</strong> <code>${escapeHtml2(report.twiml?.streamUrl ?? report.setup.urls.stream)}</code></li>
|
|
8965
|
+
<li><strong>Webhook:</strong> <code>${escapeHtml2(report.setup.urls.webhook)}</code></li>
|
|
8966
|
+
</ul>
|
|
8967
|
+
</section>
|
|
8968
|
+
</main>`;
|
|
8969
|
+
var runTwilioVoiceSmokeTest = async (input) => {
|
|
8970
|
+
const setup = await buildTwilioVoiceSetupStatus(input.options, input);
|
|
8971
|
+
const checks = [];
|
|
8972
|
+
const twimlUrl = new URL(setup.urls.twiml);
|
|
8973
|
+
twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
|
|
8974
|
+
twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
|
|
8975
|
+
const twimlResponse = await input.app.handle(new Request(twimlUrl, {
|
|
8976
|
+
headers: input.request.headers
|
|
8977
|
+
}));
|
|
8978
|
+
const twiml = await twimlResponse.text();
|
|
8979
|
+
const streamUrl = extractTwilioStreamUrl(twiml);
|
|
8980
|
+
checks.push(createSmokeCheck("twiml", twimlResponse.ok && Boolean(streamUrl) ? "pass" : "fail", streamUrl ? "TwiML includes a media stream URL." : 'TwiML is missing <Stream url="...">.', {
|
|
8981
|
+
status: twimlResponse.status,
|
|
8982
|
+
streamUrl
|
|
8983
|
+
}));
|
|
8984
|
+
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.", {
|
|
8985
|
+
streamUrl
|
|
8986
|
+
}));
|
|
8987
|
+
const webhookBody = {
|
|
8988
|
+
CallSid: input.options.smoke?.callSid ?? "CA_SMOKE_TEST",
|
|
8989
|
+
CallStatus: input.options.smoke?.status ?? "busy",
|
|
8990
|
+
SipResponseCode: String(input.options.smoke?.sipCode ?? 486)
|
|
8991
|
+
};
|
|
8992
|
+
const webhookHeaders = new Headers({
|
|
8993
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
8994
|
+
});
|
|
8995
|
+
const verificationUrl = setup.signing.verificationUrl ?? setup.urls.webhook;
|
|
8996
|
+
if (input.options.webhook?.signingSecret) {
|
|
8997
|
+
webhookHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
|
|
8998
|
+
authToken: input.options.webhook.signingSecret,
|
|
8999
|
+
body: webhookBody,
|
|
9000
|
+
url: verificationUrl
|
|
9001
|
+
}));
|
|
9002
|
+
}
|
|
9003
|
+
const webhookResponse = await input.app.handle(new Request(setup.urls.webhook, {
|
|
9004
|
+
body: new URLSearchParams(webhookBody),
|
|
9005
|
+
headers: webhookHeaders,
|
|
9006
|
+
method: "POST"
|
|
9007
|
+
}));
|
|
9008
|
+
const webhookText = await webhookResponse.text();
|
|
9009
|
+
const webhookPayload = (() => {
|
|
9010
|
+
try {
|
|
9011
|
+
return JSON.parse(webhookText);
|
|
9012
|
+
} catch {
|
|
9013
|
+
return webhookText;
|
|
9014
|
+
}
|
|
9015
|
+
})();
|
|
9016
|
+
checks.push(createSmokeCheck("webhook", webhookResponse.ok ? "pass" : "fail", webhookResponse.ok ? "Synthetic Twilio status callback was accepted." : "Synthetic Twilio status callback failed.", {
|
|
9017
|
+
status: webhookResponse.status
|
|
9018
|
+
}));
|
|
9019
|
+
for (const warning of setup.warnings) {
|
|
9020
|
+
checks.push(createSmokeCheck("setup-warning", "warn", warning));
|
|
9021
|
+
}
|
|
9022
|
+
for (const name of setup.missing) {
|
|
9023
|
+
checks.push(createSmokeCheck("missing-env", "fail", `${name} is missing.`));
|
|
9024
|
+
}
|
|
9025
|
+
return {
|
|
9026
|
+
checks,
|
|
9027
|
+
generatedAt: Date.now(),
|
|
9028
|
+
pass: checks.every((check) => check.status !== "fail"),
|
|
9029
|
+
provider: "twilio",
|
|
9030
|
+
setup,
|
|
9031
|
+
twiml: {
|
|
9032
|
+
status: twimlResponse.status,
|
|
9033
|
+
streamUrl
|
|
9034
|
+
},
|
|
9035
|
+
webhook: {
|
|
9036
|
+
body: webhookPayload,
|
|
9037
|
+
status: webhookResponse.status
|
|
9038
|
+
}
|
|
9039
|
+
};
|
|
9040
|
+
};
|
|
7229
9041
|
var normalizeOnTurn = (handler) => {
|
|
7230
9042
|
if (handler.length > 1) {
|
|
7231
9043
|
const directHandler = handler;
|
|
@@ -7327,7 +9139,7 @@ var bytesToInt16Array = (bytes) => {
|
|
|
7327
9139
|
return output;
|
|
7328
9140
|
};
|
|
7329
9141
|
var decodeTwilioMulawBase64 = (payload) => {
|
|
7330
|
-
const bytes = Uint8Array.from(
|
|
9142
|
+
const bytes = Uint8Array.from(Buffer3.from(payload, "base64"));
|
|
7331
9143
|
const samples = new Int16Array(bytes.length);
|
|
7332
9144
|
for (let index = 0;index < bytes.length; index += 1) {
|
|
7333
9145
|
samples[index] = decodeMulawSample(bytes[index] ?? 0);
|
|
@@ -7339,7 +9151,7 @@ var encodeTwilioMulawBase64 = (samples) => {
|
|
|
7339
9151
|
for (let index = 0;index < samples.length; index += 1) {
|
|
7340
9152
|
bytes[index] = encodeMulawSample(samples[index] ?? 0);
|
|
7341
9153
|
}
|
|
7342
|
-
return
|
|
9154
|
+
return Buffer3.from(bytes).toString("base64");
|
|
7343
9155
|
};
|
|
7344
9156
|
var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
|
|
7345
9157
|
const narrowband = decodeTwilioMulawBase64(payload);
|
|
@@ -7348,7 +9160,7 @@ var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
|
|
|
7348
9160
|
};
|
|
7349
9161
|
var transcodePCMToTwilioOutboundPayload = (chunk, format) => {
|
|
7350
9162
|
if (format.container === "raw" && format.encoding === "mulaw" && format.channels === 1 && format.sampleRateHz === TWILIO_MULAW_SAMPLE_RATE) {
|
|
7351
|
-
return
|
|
9163
|
+
return Buffer3.from(chunk).toString("base64");
|
|
7352
9164
|
}
|
|
7353
9165
|
if (format.encoding !== "pcm_s16le") {
|
|
7354
9166
|
throw new Error(`Unsupported outbound telephony audio format: ${format.container}/${format.encoding}`);
|
|
@@ -7389,7 +9201,7 @@ var createTwilioSocketAdapter = (socket, getState) => ({
|
|
|
7389
9201
|
return;
|
|
7390
9202
|
}
|
|
7391
9203
|
if (message.type === "audio") {
|
|
7392
|
-
const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(
|
|
9204
|
+
const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer3.from(message.chunkBase64, "base64")), message.format);
|
|
7393
9205
|
state.hasOutboundAudioSinceLastInbound = true;
|
|
7394
9206
|
state.reviewRecorder?.recordTwilioOutbound({
|
|
7395
9207
|
bytes: payload.length,
|
|
@@ -7421,8 +9233,8 @@ var createTwilioSocketAdapter = (socket, getState) => ({
|
|
|
7421
9233
|
}
|
|
7422
9234
|
});
|
|
7423
9235
|
var createTwilioVoiceResponse = (options) => {
|
|
7424
|
-
const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${
|
|
7425
|
-
return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${
|
|
9236
|
+
const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml2(name)}" value="${escapeXml2(String(value))}" />`).join("");
|
|
9237
|
+
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>`;
|
|
7426
9238
|
};
|
|
7427
9239
|
var createTwilioMediaStreamBridge = (socket, options) => {
|
|
7428
9240
|
const runtimePreset = resolveVoiceRuntimePreset(options.preset);
|
|
@@ -7602,6 +9414,148 @@ var createTwilioMediaStreamBridge = (socket, options) => {
|
|
|
7602
9414
|
}
|
|
7603
9415
|
};
|
|
7604
9416
|
};
|
|
9417
|
+
var createTwilioVoiceRoutes = (options) => {
|
|
9418
|
+
const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
|
|
9419
|
+
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
9420
|
+
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
9421
|
+
const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
|
|
9422
|
+
const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
|
|
9423
|
+
const bridges = new WeakMap;
|
|
9424
|
+
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
9425
|
+
const app = new Elysia2({
|
|
9426
|
+
name: options.name ?? "absolutejs-voice-twilio"
|
|
9427
|
+
}).get(twimlPath, async ({ query, request }) => {
|
|
9428
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
9429
|
+
query,
|
|
9430
|
+
request,
|
|
9431
|
+
streamPath
|
|
9432
|
+
});
|
|
9433
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
9434
|
+
query,
|
|
9435
|
+
request
|
|
9436
|
+
});
|
|
9437
|
+
return new Response(createTwilioVoiceResponse({
|
|
9438
|
+
parameters,
|
|
9439
|
+
streamName: options.twiml?.streamName,
|
|
9440
|
+
streamUrl,
|
|
9441
|
+
track: options.twiml?.track
|
|
9442
|
+
}), {
|
|
9443
|
+
headers: {
|
|
9444
|
+
"content-type": "text/xml; charset=utf-8"
|
|
9445
|
+
}
|
|
9446
|
+
});
|
|
9447
|
+
}).post(twimlPath, async ({ query, request }) => {
|
|
9448
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
9449
|
+
query,
|
|
9450
|
+
request,
|
|
9451
|
+
streamPath
|
|
9452
|
+
});
|
|
9453
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
9454
|
+
query,
|
|
9455
|
+
request
|
|
9456
|
+
});
|
|
9457
|
+
return new Response(createTwilioVoiceResponse({
|
|
9458
|
+
parameters,
|
|
9459
|
+
streamName: options.twiml?.streamName,
|
|
9460
|
+
streamUrl,
|
|
9461
|
+
track: options.twiml?.track
|
|
9462
|
+
}), {
|
|
9463
|
+
headers: {
|
|
9464
|
+
"content-type": "text/xml; charset=utf-8"
|
|
9465
|
+
}
|
|
9466
|
+
});
|
|
9467
|
+
}).ws(streamPath, {
|
|
9468
|
+
close: async (ws, _code, reason) => {
|
|
9469
|
+
const bridge = bridges.get(ws);
|
|
9470
|
+
bridges.delete(ws);
|
|
9471
|
+
await bridge?.close(reason);
|
|
9472
|
+
},
|
|
9473
|
+
message: async (ws, raw) => {
|
|
9474
|
+
let bridge = bridges.get(ws);
|
|
9475
|
+
if (!bridge) {
|
|
9476
|
+
bridge = createTwilioMediaStreamBridge({
|
|
9477
|
+
close: (code, reason) => {
|
|
9478
|
+
ws.close(code, reason);
|
|
9479
|
+
},
|
|
9480
|
+
send: (data) => {
|
|
9481
|
+
ws.send(data);
|
|
9482
|
+
}
|
|
9483
|
+
}, options);
|
|
9484
|
+
bridges.set(ws, bridge);
|
|
9485
|
+
}
|
|
9486
|
+
await bridge.handleMessage(raw);
|
|
9487
|
+
}
|
|
9488
|
+
}).use(createVoiceTelephonyWebhookRoutes({
|
|
9489
|
+
...options.webhook ?? {},
|
|
9490
|
+
context: options.context,
|
|
9491
|
+
path: webhookPath,
|
|
9492
|
+
policy: webhookPolicy,
|
|
9493
|
+
provider: "twilio"
|
|
9494
|
+
}));
|
|
9495
|
+
if (!setupPath) {
|
|
9496
|
+
if (!smokePath) {
|
|
9497
|
+
return app;
|
|
9498
|
+
}
|
|
9499
|
+
return app.get(smokePath, async ({ query, request }) => {
|
|
9500
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
9501
|
+
app,
|
|
9502
|
+
options,
|
|
9503
|
+
query,
|
|
9504
|
+
request,
|
|
9505
|
+
streamPath,
|
|
9506
|
+
twimlPath,
|
|
9507
|
+
webhookPath
|
|
9508
|
+
});
|
|
9509
|
+
if (query.format === "html") {
|
|
9510
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
9511
|
+
headers: {
|
|
9512
|
+
"content-type": "text/html; charset=utf-8"
|
|
9513
|
+
}
|
|
9514
|
+
});
|
|
9515
|
+
}
|
|
9516
|
+
return report;
|
|
9517
|
+
});
|
|
9518
|
+
}
|
|
9519
|
+
const withSetup = app.get(setupPath, async ({ query, request }) => {
|
|
9520
|
+
const status = await buildTwilioVoiceSetupStatus(options, {
|
|
9521
|
+
query,
|
|
9522
|
+
request,
|
|
9523
|
+
streamPath,
|
|
9524
|
+
twimlPath,
|
|
9525
|
+
webhookPath
|
|
9526
|
+
});
|
|
9527
|
+
if (query.format === "html") {
|
|
9528
|
+
return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
|
|
9529
|
+
headers: {
|
|
9530
|
+
"content-type": "text/html; charset=utf-8"
|
|
9531
|
+
}
|
|
9532
|
+
});
|
|
9533
|
+
}
|
|
9534
|
+
return status;
|
|
9535
|
+
});
|
|
9536
|
+
if (!smokePath) {
|
|
9537
|
+
return withSetup;
|
|
9538
|
+
}
|
|
9539
|
+
return withSetup.get(smokePath, async ({ query, request }) => {
|
|
9540
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
9541
|
+
app,
|
|
9542
|
+
options,
|
|
9543
|
+
query,
|
|
9544
|
+
request,
|
|
9545
|
+
streamPath,
|
|
9546
|
+
twimlPath,
|
|
9547
|
+
webhookPath
|
|
9548
|
+
});
|
|
9549
|
+
if (query.format === "html") {
|
|
9550
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
9551
|
+
headers: {
|
|
9552
|
+
"content-type": "text/html; charset=utf-8"
|
|
9553
|
+
}
|
|
9554
|
+
});
|
|
9555
|
+
}
|
|
9556
|
+
return report;
|
|
9557
|
+
});
|
|
9558
|
+
};
|
|
7605
9559
|
|
|
7606
9560
|
// src/testing/telephony.ts
|
|
7607
9561
|
var DEFAULT_PCM16_FORMAT = {
|
|
@@ -7867,7 +9821,7 @@ var runVoiceTelephonyBenchmark = async (scenarios = getDefaultVoiceTelephonyBenc
|
|
|
7867
9821
|
};
|
|
7868
9822
|
};
|
|
7869
9823
|
// src/testing/tts.ts
|
|
7870
|
-
var
|
|
9824
|
+
var DEFAULT_REALTIME_FORMAT2 = {
|
|
7871
9825
|
channels: 1,
|
|
7872
9826
|
container: "raw",
|
|
7873
9827
|
encoding: "pcm_s16le",
|
|
@@ -7926,7 +9880,7 @@ var runTTSAdapterFixture = async (adapter, fixture, options = {}) => {
|
|
|
7926
9880
|
let audioDurationMs = 0;
|
|
7927
9881
|
let audioChunkCount = 0;
|
|
7928
9882
|
const session = adapter.kind === "realtime" ? await adapter.open({
|
|
7929
|
-
format: options.realtimeFormat ??
|
|
9883
|
+
format: options.realtimeFormat ?? DEFAULT_REALTIME_FORMAT2,
|
|
7930
9884
|
sessionId: `tts-benchmark:${fixture.id}`,
|
|
7931
9885
|
...openOptions ?? {}
|
|
7932
9886
|
}) : await adapter.open({
|
|
@@ -8093,6 +10047,7 @@ export {
|
|
|
8093
10047
|
getDefaultTTSBenchmarkFixtures,
|
|
8094
10048
|
evaluateSTTBenchmarkAcceptance,
|
|
8095
10049
|
createVoiceProviderFailureSimulator,
|
|
10050
|
+
createVoiceIOProviderFailureSimulator,
|
|
8096
10051
|
createVoiceCallReviewRecorder,
|
|
8097
10052
|
createVoiceCallReviewFromLiveTelephonyReport,
|
|
8098
10053
|
createTelephonyVoiceTestFixtures,
|