@absolutejs/voice 0.0.22-beta.30 → 0.0.22-beta.300
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 +3232 -82
- package/dist/agent.d.ts +62 -0
- package/dist/agentSquadContract.d.ts +98 -0
- package/dist/angular/index.d.ts +16 -0
- package/dist/angular/index.js +3498 -1128
- 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-proof-trends.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-readiness-failures.service.d.ts +13 -0
- package/dist/angular/voice-routing-status.service.d.ts +11 -0
- package/dist/angular/voice-stream.service.d.ts +1 -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 +768 -0
- package/dist/campaignDialers.d.ts +111 -0
- package/dist/client/actions.d.ts +83 -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/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 +703 -13
- package/dist/client/index.d.ts +70 -0
- package/dist/client/index.js +5257 -19
- 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/proofTrends.d.ts +19 -0
- package/dist/client/proofTrendsWidget.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/readinessFailures.d.ts +19 -0
- package/dist/client/readinessFailuresWidget.d.ts +42 -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 +180 -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/guardrails.d.ts +128 -0
- package/dist/handoff.d.ts +15 -1
- package/dist/incidentBundle.d.ts +116 -0
- package/dist/index.d.ts +140 -15
- package/dist/index.js +27685 -5100
- package/dist/latencySlo.d.ts +56 -0
- package/dist/liveLatency.d.ts +78 -0
- package/dist/liveOps.d.ts +190 -0
- package/dist/modelAdapters.d.ts +60 -2
- package/dist/observabilityExport.d.ts +481 -0
- package/dist/openaiRealtime.d.ts +27 -0
- package/dist/openaiTTS.d.ts +18 -0
- package/dist/operationsRecord.d.ts +254 -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/outcomeContract.d.ts +146 -0
- package/dist/phoneAgent.d.ts +139 -0
- package/dist/phoneAgentProductionSmoke.d.ts +115 -0
- package/dist/platformCoverage.d.ts +91 -0
- package/dist/postCallAnalysis.d.ts +98 -0
- package/dist/postgresStore.d.ts +13 -2
- package/dist/productionReadiness.d.ts +559 -0
- package/dist/proofTrends.d.ts +133 -0
- package/dist/providerAdapters.d.ts +48 -0
- package/dist/providerCapabilities.d.ts +92 -0
- package/dist/providerDecisionTraces.d.ts +130 -0
- package/dist/providerHealth.d.ts +1 -0
- package/dist/providerOrchestration.d.ts +109 -0
- package/dist/providerRoutingContract.d.ts +71 -0
- package/dist/providerSlo.d.ts +142 -0
- package/dist/providerStackRecommendations.d.ts +187 -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/VoiceProofTrends.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/VoiceReadinessFailures.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 +32 -0
- package/dist/react/index.js +5059 -31
- package/dist/react/useVoiceAgentSquadStatus.d.ts +8 -0
- package/dist/react/useVoiceCampaignDialerProof.d.ts +10 -0
- package/dist/react/useVoiceController.d.ts +1 -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/useVoiceProofTrends.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/useVoiceReadinessFailures.d.ts +8 -0
- package/dist/react/useVoiceRoutingStatus.d.ts +8 -0
- package/dist/react/useVoiceStream.d.ts +1 -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 +38 -0
- package/dist/reconnectContract.d.ts +88 -0
- package/dist/resilienceRoutes.d.ts +143 -0
- package/dist/sessionReplay.d.ts +12 -0
- package/dist/simulationSuite.d.ts +143 -0
- package/dist/sloCalibration.d.ts +185 -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/createVoiceProofTrends.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/createVoiceReadinessFailures.d.ts +7 -0
- package/dist/svelte/createVoiceRoutingStatus.d.ts +10 -0
- package/dist/svelte/createVoiceTraceTimeline.d.ts +10 -0
- package/dist/svelte/createVoiceTurnLatency.d.ts +11 -0
- package/dist/svelte/createVoiceTurnQuality.d.ts +10 -0
- package/dist/svelte/createVoiceWorkflowStatus.d.ts +8 -0
- package/dist/svelte/index.d.ts +17 -0
- package/dist/svelte/index.js +4924 -420
- package/dist/telephony/contract.d.ts +61 -0
- package/dist/telephony/matrix.d.ts +97 -0
- package/dist/telephony/plivo.d.ts +303 -0
- package/dist/telephony/security.d.ts +182 -0
- package/dist/telephony/telnyx.d.ts +291 -0
- package/dist/telephony/twilio.d.ts +135 -2
- package/dist/telephonyOutcome.d.ts +273 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1861 -44
- package/dist/testing/ioProviderSimulator.d.ts +41 -0
- package/dist/toolContract.d.ts +161 -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 +127 -3
- package/dist/voiceMonitoring.d.ts +444 -0
- 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/VoiceProofTrends.d.ts +21 -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/VoiceReadinessFailures.d.ts +21 -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 +30 -0
- package/dist/vue/index.js +4828 -56
- 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/useVoiceProofTrends.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/useVoiceReadinessFailures.d.ts +775 -0
- package/dist/vue/useVoiceRoutingStatus.d.ts +8 -0
- package/dist/vue/useVoiceStream.d.ts +2 -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,11 @@ 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
|
+
};
|
|
2108
2113
|
case "call_lifecycle":
|
|
2109
2114
|
return {
|
|
2110
2115
|
event: message.event,
|
|
@@ -2126,6 +2131,17 @@ var serverMessageToAction = (message) => {
|
|
|
2126
2131
|
transcript: message.transcript,
|
|
2127
2132
|
type: "partial"
|
|
2128
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
|
+
};
|
|
2129
2145
|
case "session":
|
|
2130
2146
|
return {
|
|
2131
2147
|
sessionId: message.sessionId,
|
|
@@ -2186,10 +2202,12 @@ var isVoiceServerMessage = (value) => {
|
|
|
2186
2202
|
case "assistant":
|
|
2187
2203
|
case "call_lifecycle":
|
|
2188
2204
|
case "complete":
|
|
2205
|
+
case "connection":
|
|
2189
2206
|
case "error":
|
|
2190
2207
|
case "final":
|
|
2191
2208
|
case "partial":
|
|
2192
2209
|
case "pong":
|
|
2210
|
+
case "replay":
|
|
2193
2211
|
case "session":
|
|
2194
2212
|
case "turn":
|
|
2195
2213
|
return true;
|
|
@@ -2226,6 +2244,9 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2226
2244
|
sessionId: options.sessionId ?? createSessionId(),
|
|
2227
2245
|
ws: null
|
|
2228
2246
|
};
|
|
2247
|
+
const emitConnection = (reconnect) => {
|
|
2248
|
+
listeners.forEach((listener) => listener(reconnect));
|
|
2249
|
+
};
|
|
2229
2250
|
const clearTimers = () => {
|
|
2230
2251
|
if (state.pingInterval) {
|
|
2231
2252
|
clearInterval(state.pingInterval);
|
|
@@ -2248,9 +2269,28 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2248
2269
|
}
|
|
2249
2270
|
};
|
|
2250
2271
|
const scheduleReconnect = () => {
|
|
2272
|
+
const nextAttemptAt = Date.now() + RECONNECT_DELAY_MS;
|
|
2251
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
|
+
});
|
|
2252
2284
|
state.reconnectTimeout = setTimeout(() => {
|
|
2253
2285
|
if (state.reconnectAttempts > maxReconnectAttempts) {
|
|
2286
|
+
emitConnection({
|
|
2287
|
+
reconnect: {
|
|
2288
|
+
attempts: state.reconnectAttempts,
|
|
2289
|
+
maxAttempts: maxReconnectAttempts,
|
|
2290
|
+
status: "exhausted"
|
|
2291
|
+
},
|
|
2292
|
+
type: "connection"
|
|
2293
|
+
});
|
|
2254
2294
|
return;
|
|
2255
2295
|
}
|
|
2256
2296
|
connect();
|
|
@@ -2260,9 +2300,21 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2260
2300
|
const ws = new WebSocket(buildWsUrl(path, state.sessionId, state.scenarioId));
|
|
2261
2301
|
ws.binaryType = "arraybuffer";
|
|
2262
2302
|
ws.onopen = () => {
|
|
2303
|
+
const wasReconnecting = state.reconnectAttempts > 0;
|
|
2263
2304
|
state.isConnected = true;
|
|
2264
|
-
state.reconnectAttempts = 0;
|
|
2265
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
|
+
}
|
|
2266
2318
|
listeners.forEach((listener) => listener({
|
|
2267
2319
|
scenarioId: state.scenarioId ?? undefined,
|
|
2268
2320
|
sessionId: state.sessionId,
|
|
@@ -2292,6 +2344,16 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2292
2344
|
const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && state.reconnectAttempts < maxReconnectAttempts;
|
|
2293
2345
|
if (reconnectable) {
|
|
2294
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
|
+
});
|
|
2295
2357
|
}
|
|
2296
2358
|
};
|
|
2297
2359
|
state.ws = ws;
|
|
@@ -2362,6 +2424,11 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2362
2424
|
};
|
|
2363
2425
|
|
|
2364
2426
|
// src/client/store.ts
|
|
2427
|
+
var createInitialReconnectState = () => ({
|
|
2428
|
+
attempts: 0,
|
|
2429
|
+
maxAttempts: 0,
|
|
2430
|
+
status: "idle"
|
|
2431
|
+
});
|
|
2365
2432
|
var createInitialState2 = () => ({
|
|
2366
2433
|
assistantAudio: [],
|
|
2367
2434
|
assistantTexts: [],
|
|
@@ -2370,6 +2437,7 @@ var createInitialState2 = () => ({
|
|
|
2370
2437
|
isConnected: false,
|
|
2371
2438
|
scenarioId: null,
|
|
2372
2439
|
partial: "",
|
|
2440
|
+
reconnect: createInitialReconnectState(),
|
|
2373
2441
|
sessionId: null,
|
|
2374
2442
|
status: "idle",
|
|
2375
2443
|
turns: []
|
|
@@ -2426,7 +2494,19 @@ var createVoiceStreamStore = () => {
|
|
|
2426
2494
|
case "connected":
|
|
2427
2495
|
state = {
|
|
2428
2496
|
...state,
|
|
2429
|
-
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
|
|
2430
2510
|
};
|
|
2431
2511
|
break;
|
|
2432
2512
|
case "disconnected":
|
|
@@ -2454,6 +2534,26 @@ var createVoiceStreamStore = () => {
|
|
|
2454
2534
|
partial: action.transcript.text
|
|
2455
2535
|
};
|
|
2456
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;
|
|
2457
2557
|
case "session":
|
|
2458
2558
|
state = {
|
|
2459
2559
|
...state,
|
|
@@ -2501,10 +2601,34 @@ var createVoiceStream = (path, options = {}) => {
|
|
|
2501
2601
|
const notify = () => {
|
|
2502
2602
|
subscribers.forEach((subscriber) => subscriber());
|
|
2503
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
|
+
};
|
|
2504
2625
|
const unsubscribeConnection = connection.subscribe((message) => {
|
|
2505
2626
|
const action = serverMessageToAction(message);
|
|
2506
2627
|
if (action) {
|
|
2507
2628
|
store.dispatch(action);
|
|
2629
|
+
if (message.type === "connection") {
|
|
2630
|
+
reportReconnect();
|
|
2631
|
+
}
|
|
2508
2632
|
notify();
|
|
2509
2633
|
}
|
|
2510
2634
|
});
|
|
@@ -2540,6 +2664,9 @@ var createVoiceStream = (path, options = {}) => {
|
|
|
2540
2664
|
get partial() {
|
|
2541
2665
|
return store.getSnapshot().partial;
|
|
2542
2666
|
},
|
|
2667
|
+
get reconnect() {
|
|
2668
|
+
return store.getSnapshot().reconnect;
|
|
2669
|
+
},
|
|
2543
2670
|
get sessionId() {
|
|
2544
2671
|
return connection.getSessionId();
|
|
2545
2672
|
},
|
|
@@ -2895,6 +3022,7 @@ var createInitialState3 = (stream) => ({
|
|
|
2895
3022
|
isConnected: stream.isConnected,
|
|
2896
3023
|
isRecording: false,
|
|
2897
3024
|
partial: stream.partial,
|
|
3025
|
+
reconnect: stream.reconnect,
|
|
2898
3026
|
recordingError: null,
|
|
2899
3027
|
sessionId: stream.sessionId,
|
|
2900
3028
|
scenarioId: stream.scenarioId,
|
|
@@ -2924,6 +3052,7 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2924
3052
|
error: stream.error,
|
|
2925
3053
|
isConnected: stream.isConnected,
|
|
2926
3054
|
partial: stream.partial,
|
|
3055
|
+
reconnect: stream.reconnect,
|
|
2927
3056
|
sessionId: stream.sessionId,
|
|
2928
3057
|
scenarioId: stream.scenarioId,
|
|
2929
3058
|
status: stream.status,
|
|
@@ -2948,7 +3077,13 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2948
3077
|
capture = createMicrophoneCapture({
|
|
2949
3078
|
channelCount: options.capture?.channelCount ?? preset.capture.channelCount,
|
|
2950
3079
|
onLevel: options.capture?.onLevel,
|
|
2951
|
-
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
|
+
},
|
|
2952
3087
|
sampleRateHz: options.capture?.sampleRateHz ?? preset.capture.sampleRateHz
|
|
2953
3088
|
});
|
|
2954
3089
|
return capture;
|
|
@@ -3018,6 +3153,9 @@ var createVoiceController = (path, options = {}) => {
|
|
|
3018
3153
|
get recordingError() {
|
|
3019
3154
|
return state.recordingError;
|
|
3020
3155
|
},
|
|
3156
|
+
get reconnect() {
|
|
3157
|
+
return state.reconnect;
|
|
3158
|
+
},
|
|
3021
3159
|
sendAudio: (audio) => stream.sendAudio(audio),
|
|
3022
3160
|
get sessionId() {
|
|
3023
3161
|
return state.sessionId;
|
|
@@ -3063,11 +3201,26 @@ var DEFAULT_INTERRUPT_THRESHOLD = 0.08;
|
|
|
3063
3201
|
var shouldInterruptForLevel = (level, options = {}) => (options.enabled ?? true) && level >= (options.interruptThreshold ?? DEFAULT_INTERRUPT_THRESHOLD);
|
|
3064
3202
|
var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
3065
3203
|
let lastPartial = controller.partial;
|
|
3066
|
-
const interruptIfPlaying = () => {
|
|
3204
|
+
const interruptIfPlaying = (reason) => {
|
|
3067
3205
|
if (!player.isPlaying || options.enabled === false) {
|
|
3206
|
+
options.monitor?.recordSkipped({
|
|
3207
|
+
reason,
|
|
3208
|
+
sessionId: controller.sessionId
|
|
3209
|
+
});
|
|
3068
3210
|
return;
|
|
3069
3211
|
}
|
|
3070
|
-
|
|
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
|
+
});
|
|
3071
3224
|
};
|
|
3072
3225
|
const unsubscribe = controller.subscribe(() => {
|
|
3073
3226
|
if (options.interruptOnPartial === false) {
|
|
@@ -3075,7 +3228,7 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
|
3075
3228
|
return;
|
|
3076
3229
|
}
|
|
3077
3230
|
if (!lastPartial && controller.partial) {
|
|
3078
|
-
interruptIfPlaying();
|
|
3231
|
+
interruptIfPlaying("partial-transcript");
|
|
3079
3232
|
}
|
|
3080
3233
|
lastPartial = controller.partial;
|
|
3081
3234
|
});
|
|
@@ -3085,11 +3238,11 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
|
3085
3238
|
},
|
|
3086
3239
|
handleLevel: (level) => {
|
|
3087
3240
|
if (shouldInterruptForLevel(level, options)) {
|
|
3088
|
-
interruptIfPlaying();
|
|
3241
|
+
interruptIfPlaying("input-level");
|
|
3089
3242
|
}
|
|
3090
3243
|
},
|
|
3091
3244
|
sendAudio: (audio) => {
|
|
3092
|
-
interruptIfPlaying();
|
|
3245
|
+
interruptIfPlaying("manual-audio");
|
|
3093
3246
|
controller.sendAudio(audio);
|
|
3094
3247
|
}
|
|
3095
3248
|
};
|
|
@@ -3119,7 +3272,17 @@ var createVoiceDuplexController = (path, options = {}) => {
|
|
|
3119
3272
|
audioPlayer,
|
|
3120
3273
|
close,
|
|
3121
3274
|
interruptAssistant: async () => {
|
|
3275
|
+
options.bargeIn?.monitor?.recordRequested({
|
|
3276
|
+
reason: "manual-interrupt",
|
|
3277
|
+
sessionId: controller.sessionId
|
|
3278
|
+
});
|
|
3122
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
|
+
});
|
|
3123
3286
|
},
|
|
3124
3287
|
sendAudio: (audio) => {
|
|
3125
3288
|
bargeInBinding?.sendAudio(audio);
|
|
@@ -3510,7 +3673,235 @@ var loadVoiceTestFixtures = async (fixtureDirectory) => {
|
|
|
3510
3673
|
}
|
|
3511
3674
|
return fixtures;
|
|
3512
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
|
+
};
|
|
3513
3793
|
// src/modelAdapters.ts
|
|
3794
|
+
var isVoiceProviderRoutingPolicyPreset = (value) => value === "balanced" || value === "cost-cap" || value === "cost-first" || value === "latency-first" || value === "quality-first";
|
|
3795
|
+
var resolveVoiceProviderRoutingPolicyPreset = (preset, options = {}) => {
|
|
3796
|
+
switch (preset) {
|
|
3797
|
+
case "balanced":
|
|
3798
|
+
return {
|
|
3799
|
+
fallbackMode: "provider-error",
|
|
3800
|
+
strategy: "balanced",
|
|
3801
|
+
weights: {
|
|
3802
|
+
cost: 1,
|
|
3803
|
+
latencyMs: 0.005,
|
|
3804
|
+
priority: 1,
|
|
3805
|
+
quality: 10,
|
|
3806
|
+
...options.weights
|
|
3807
|
+
},
|
|
3808
|
+
...options
|
|
3809
|
+
};
|
|
3810
|
+
case "cost-cap":
|
|
3811
|
+
return {
|
|
3812
|
+
fallbackMode: "provider-error",
|
|
3813
|
+
strategy: "prefer-cheapest",
|
|
3814
|
+
...options
|
|
3815
|
+
};
|
|
3816
|
+
case "cost-first":
|
|
3817
|
+
return {
|
|
3818
|
+
fallbackMode: "provider-error",
|
|
3819
|
+
strategy: "prefer-cheapest",
|
|
3820
|
+
...options
|
|
3821
|
+
};
|
|
3822
|
+
case "latency-first":
|
|
3823
|
+
return {
|
|
3824
|
+
fallbackMode: "provider-error",
|
|
3825
|
+
strategy: "prefer-fastest",
|
|
3826
|
+
...options
|
|
3827
|
+
};
|
|
3828
|
+
case "quality-first":
|
|
3829
|
+
return {
|
|
3830
|
+
fallbackMode: "provider-error",
|
|
3831
|
+
strategy: "quality-first",
|
|
3832
|
+
...options
|
|
3833
|
+
};
|
|
3834
|
+
}
|
|
3835
|
+
};
|
|
3836
|
+
var resolveVoiceProviderRoutingPolicy = (policy) => {
|
|
3837
|
+
if (!policy) {
|
|
3838
|
+
return;
|
|
3839
|
+
}
|
|
3840
|
+
if (typeof policy === "string") {
|
|
3841
|
+
return isVoiceProviderRoutingPolicyPreset(policy) ? resolveVoiceProviderRoutingPolicyPreset(policy) : {
|
|
3842
|
+
strategy: policy
|
|
3843
|
+
};
|
|
3844
|
+
}
|
|
3845
|
+
return policy;
|
|
3846
|
+
};
|
|
3847
|
+
var mergeDefinedProviderPolicyFields = (base, surface) => {
|
|
3848
|
+
const next = {
|
|
3849
|
+
...base ?? {}
|
|
3850
|
+
};
|
|
3851
|
+
if (surface.allowProviders !== undefined) {
|
|
3852
|
+
next.allowProviders = surface.allowProviders;
|
|
3853
|
+
}
|
|
3854
|
+
if (surface.fallbackMode !== undefined) {
|
|
3855
|
+
next.fallbackMode = surface.fallbackMode;
|
|
3856
|
+
}
|
|
3857
|
+
if (surface.maxCost !== undefined) {
|
|
3858
|
+
next.maxCost = surface.maxCost;
|
|
3859
|
+
}
|
|
3860
|
+
if (surface.maxLatencyMs !== undefined) {
|
|
3861
|
+
next.maxLatencyMs = surface.maxLatencyMs;
|
|
3862
|
+
}
|
|
3863
|
+
if (surface.minQuality !== undefined) {
|
|
3864
|
+
next.minQuality = surface.minQuality;
|
|
3865
|
+
}
|
|
3866
|
+
if (surface.strategy !== undefined) {
|
|
3867
|
+
next.strategy = surface.strategy;
|
|
3868
|
+
}
|
|
3869
|
+
if (surface.weights !== undefined) {
|
|
3870
|
+
next.weights = {
|
|
3871
|
+
...base?.weights ?? {},
|
|
3872
|
+
...surface.weights
|
|
3873
|
+
};
|
|
3874
|
+
}
|
|
3875
|
+
return next;
|
|
3876
|
+
};
|
|
3877
|
+
var createVoiceProviderOrchestrationProfile = (options) => {
|
|
3878
|
+
const surfaceNames = Object.keys(options.surfaces);
|
|
3879
|
+
const defaultSurface = options.defaultSurface ?? surfaceNames[0];
|
|
3880
|
+
if (!defaultSurface || !options.surfaces[defaultSurface]) {
|
|
3881
|
+
throw new Error("Voice provider orchestration profile has no surfaces.");
|
|
3882
|
+
}
|
|
3883
|
+
return {
|
|
3884
|
+
defaultSurface,
|
|
3885
|
+
id: options.id,
|
|
3886
|
+
resolve: (surface = defaultSurface) => {
|
|
3887
|
+
const config = options.surfaces[surface];
|
|
3888
|
+
if (!config) {
|
|
3889
|
+
throw new Error(`Voice provider orchestration profile ${options.id} has no surface "${surface}".`);
|
|
3890
|
+
}
|
|
3891
|
+
const policy = mergeDefinedProviderPolicyFields(resolveVoiceProviderRoutingPolicy(config.policy), config);
|
|
3892
|
+
return {
|
|
3893
|
+
allowProviders: config.allowProviders,
|
|
3894
|
+
fallback: config.fallback,
|
|
3895
|
+
fallbackMode: config.fallbackMode,
|
|
3896
|
+
policy,
|
|
3897
|
+
providerHealth: config.providerHealth,
|
|
3898
|
+
providerProfiles: config.providerProfiles,
|
|
3899
|
+
timeoutMs: config.timeoutMs
|
|
3900
|
+
};
|
|
3901
|
+
},
|
|
3902
|
+
surfaces: options.surfaces
|
|
3903
|
+
};
|
|
3904
|
+
};
|
|
3514
3905
|
var OUTPUT_SCHEMA = {
|
|
3515
3906
|
additionalProperties: false,
|
|
3516
3907
|
properties: {
|
|
@@ -3601,6 +3992,17 @@ var parseJSONValue = (value) => {
|
|
|
3601
3992
|
return value;
|
|
3602
3993
|
}
|
|
3603
3994
|
};
|
|
3995
|
+
|
|
3996
|
+
class VoiceProviderTimeoutError extends Error {
|
|
3997
|
+
provider;
|
|
3998
|
+
timeoutMs;
|
|
3999
|
+
constructor(provider, timeoutMs) {
|
|
4000
|
+
super(`Voice provider ${provider} exceeded ${timeoutMs}ms latency budget.`);
|
|
4001
|
+
this.name = "VoiceProviderTimeoutError";
|
|
4002
|
+
this.provider = provider;
|
|
4003
|
+
this.timeoutMs = timeoutMs;
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
3604
4006
|
var getMessageToolCalls = (message) => {
|
|
3605
4007
|
const toolCalls = message.metadata?.toolCalls;
|
|
3606
4008
|
return Array.isArray(toolCalls) ? toolCalls.filter((toolCall) => toolCall && typeof toolCall === "object" && typeof toolCall.name === "string") : [];
|
|
@@ -3667,17 +4069,25 @@ var createJSONVoiceAssistantModel = (options) => ({
|
|
|
3667
4069
|
var createVoiceProviderRouter = (options) => {
|
|
3668
4070
|
const providerIds = Object.keys(options.providers);
|
|
3669
4071
|
const firstProvider = providerIds[0];
|
|
3670
|
-
const
|
|
3671
|
-
|
|
3672
|
-
} : options.policy;
|
|
4072
|
+
const orchestrationSurface = options.orchestrationProfile?.resolve(options.orchestrationSurface);
|
|
4073
|
+
const policy = resolveVoiceProviderRoutingPolicy(options.policy) ?? resolveVoiceProviderRoutingPolicy(orchestrationSurface?.policy);
|
|
3673
4074
|
const strategy = policy?.strategy ?? "prefer-selected";
|
|
3674
|
-
const fallbackMode = policy?.fallbackMode ?? options.fallbackMode ?? "provider-error";
|
|
3675
|
-
const
|
|
4075
|
+
const fallbackMode = policy?.fallbackMode ?? options.fallbackMode ?? orchestrationSurface?.fallbackMode ?? "provider-error";
|
|
4076
|
+
const providerProfiles = {
|
|
4077
|
+
...orchestrationSurface?.providerProfiles ?? {},
|
|
4078
|
+
...options.providerProfiles ?? {}
|
|
4079
|
+
};
|
|
4080
|
+
const providerHealthOption = options.providerHealth ?? orchestrationSurface?.providerHealth;
|
|
4081
|
+
const healthOptions = typeof providerHealthOption === "object" ? providerHealthOption : providerHealthOption ? {} : undefined;
|
|
3676
4082
|
const healthState = new Map;
|
|
3677
4083
|
const now = () => healthOptions?.now?.() ?? Date.now();
|
|
3678
4084
|
const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
|
|
3679
4085
|
const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
|
|
3680
4086
|
const rateLimitCooldownMs = Math.max(0, healthOptions?.rateLimitCooldownMs ?? 60000);
|
|
4087
|
+
const getProviderTimeoutMs = (provider) => {
|
|
4088
|
+
const timeoutMs = providerProfiles[provider]?.timeoutMs ?? options.timeoutMs ?? orchestrationSurface?.timeoutMs;
|
|
4089
|
+
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
|
|
4090
|
+
};
|
|
3681
4091
|
const getHealth = (provider) => {
|
|
3682
4092
|
const existing = healthState.get(provider);
|
|
3683
4093
|
if (existing) {
|
|
@@ -3741,17 +4151,44 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3741
4151
|
return cloneHealth(provider);
|
|
3742
4152
|
};
|
|
3743
4153
|
const resolveAllowedProviders = async (input) => {
|
|
3744
|
-
const allowProviders = policy?.allowProviders ?? options.allowProviders;
|
|
4154
|
+
const allowProviders = policy?.allowProviders ?? options.allowProviders ?? orchestrationSurface?.allowProviders;
|
|
3745
4155
|
const allowed = typeof allowProviders === "function" ? await allowProviders(input) : allowProviders;
|
|
3746
4156
|
return new Set(allowed ?? providerIds);
|
|
3747
4157
|
};
|
|
4158
|
+
const passesBudgetFilters = (provider) => {
|
|
4159
|
+
const profile = providerProfiles[provider];
|
|
4160
|
+
if (typeof policy?.maxCost === "number" && typeof profile?.cost === "number" && profile.cost > policy.maxCost) {
|
|
4161
|
+
return false;
|
|
4162
|
+
}
|
|
4163
|
+
if (typeof policy?.maxLatencyMs === "number" && typeof profile?.latencyMs === "number" && profile.latencyMs > policy.maxLatencyMs) {
|
|
4164
|
+
return false;
|
|
4165
|
+
}
|
|
4166
|
+
if (typeof policy?.minQuality === "number" && typeof profile?.quality === "number" && profile.quality < policy.minQuality) {
|
|
4167
|
+
return false;
|
|
4168
|
+
}
|
|
4169
|
+
return true;
|
|
4170
|
+
};
|
|
4171
|
+
const getBalancedScore = (provider) => {
|
|
4172
|
+
const profile = providerProfiles[provider];
|
|
4173
|
+
if (policy?.scoreProvider) {
|
|
4174
|
+
return policy.scoreProvider(provider, profile);
|
|
4175
|
+
}
|
|
4176
|
+
const weights = policy?.weights ?? {};
|
|
4177
|
+
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);
|
|
4178
|
+
};
|
|
3748
4179
|
const sortProviders = (providers) => {
|
|
3749
|
-
if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest") {
|
|
4180
|
+
if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest" && strategy !== "quality-first" && strategy !== "balanced") {
|
|
3750
4181
|
return providers;
|
|
3751
4182
|
}
|
|
3752
4183
|
return [...providers].sort((left, right) => {
|
|
3753
|
-
const leftProfile =
|
|
3754
|
-
const rightProfile =
|
|
4184
|
+
const leftProfile = providerProfiles[left];
|
|
4185
|
+
const rightProfile = providerProfiles[right];
|
|
4186
|
+
if (strategy === "quality-first") {
|
|
4187
|
+
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);
|
|
4188
|
+
}
|
|
4189
|
+
if (strategy === "balanced") {
|
|
4190
|
+
return getBalancedScore(left) - getBalancedScore(right);
|
|
4191
|
+
}
|
|
3755
4192
|
const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
3756
4193
|
const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
3757
4194
|
return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
|
|
@@ -3760,13 +4197,15 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3760
4197
|
const resolveOrder = async (input) => {
|
|
3761
4198
|
const selectedProvider = await options.selectProvider?.(input);
|
|
3762
4199
|
const allowedProviders = await resolveAllowedProviders(input);
|
|
3763
|
-
const
|
|
3764
|
-
const
|
|
4200
|
+
const fallbackSource = options.fallback ?? orchestrationSurface?.fallback;
|
|
4201
|
+
const fallbackOrder = typeof fallbackSource === "function" ? await fallbackSource(input) : fallbackSource;
|
|
4202
|
+
const allowedRankedProviders = sortProviders([
|
|
3765
4203
|
...fallbackOrder ?? providerIds
|
|
3766
4204
|
]).filter((provider) => allowedProviders.has(provider));
|
|
4205
|
+
const rankedProviders = allowedRankedProviders.filter(passesBudgetFilters);
|
|
3767
4206
|
const healthyRankedProviders = healthOptions ? rankedProviders.filter((provider) => !isSuppressed(provider)) : rankedProviders;
|
|
3768
4207
|
const candidateRankedProviders = healthyRankedProviders.length ? healthyRankedProviders : rankedProviders;
|
|
3769
|
-
const preferred = selectedProvider && allowedProviders.has(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
|
|
4208
|
+
const preferred = selectedProvider && allowedProviders.has(selectedProvider) && passesBudgetFilters(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
|
|
3770
4209
|
const seen = new Set;
|
|
3771
4210
|
const order = [];
|
|
3772
4211
|
const candidates = strategy === "ordered" ? candidateRankedProviders : [
|
|
@@ -3789,6 +4228,25 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3789
4228
|
const emit = async (event, input) => {
|
|
3790
4229
|
await options.onProviderEvent?.(event, input);
|
|
3791
4230
|
};
|
|
4231
|
+
const runProvider = async (provider, model, input) => {
|
|
4232
|
+
const timeoutMs = getProviderTimeoutMs(provider);
|
|
4233
|
+
if (!timeoutMs) {
|
|
4234
|
+
return model.generate(input);
|
|
4235
|
+
}
|
|
4236
|
+
let timeout;
|
|
4237
|
+
try {
|
|
4238
|
+
return await Promise.race([
|
|
4239
|
+
model.generate(input),
|
|
4240
|
+
new Promise((_, reject) => {
|
|
4241
|
+
timeout = setTimeout(() => reject(new VoiceProviderTimeoutError(provider, timeoutMs)), timeoutMs);
|
|
4242
|
+
})
|
|
4243
|
+
]);
|
|
4244
|
+
} finally {
|
|
4245
|
+
if (timeout) {
|
|
4246
|
+
clearTimeout(timeout);
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
};
|
|
3792
4250
|
return {
|
|
3793
4251
|
generate: async (input) => {
|
|
3794
4252
|
const { order, selectedProvider } = await resolveOrder(input);
|
|
@@ -3803,12 +4261,14 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3803
4261
|
}
|
|
3804
4262
|
const startedAt = Date.now();
|
|
3805
4263
|
try {
|
|
3806
|
-
const output = await model
|
|
4264
|
+
const output = await runProvider(provider, model, input);
|
|
3807
4265
|
const providerHealth = recordProviderSuccess(provider);
|
|
3808
4266
|
await emit({
|
|
3809
4267
|
at: Date.now(),
|
|
4268
|
+
attempt: index + 1,
|
|
3810
4269
|
elapsedMs: Date.now() - startedAt,
|
|
3811
4270
|
fallbackProvider: provider === selectedProvider ? undefined : provider,
|
|
4271
|
+
latencyBudgetMs: getProviderTimeoutMs(provider),
|
|
3812
4272
|
provider,
|
|
3813
4273
|
providerHealth,
|
|
3814
4274
|
recovered: provider !== selectedProvider,
|
|
@@ -3820,22 +4280,26 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3820
4280
|
lastError = error;
|
|
3821
4281
|
const hasNextProvider = index < order.length - 1;
|
|
3822
4282
|
const isProviderError = options.isProviderError?.(error, provider) ?? true;
|
|
4283
|
+
const timedOut = options.isTimeoutError?.(error, provider) ?? error instanceof VoiceProviderTimeoutError;
|
|
3823
4284
|
const rateLimited = options.isRateLimitError?.(error, provider) ?? defaultIsRateLimitError(error);
|
|
3824
4285
|
const shouldFallback = fallbackMode === "provider-error" ? isProviderError : fallbackMode === "rate-limit" ? isProviderError && rateLimited : false;
|
|
3825
4286
|
const providerHealth = recordProviderError(provider, isProviderError, rateLimited);
|
|
3826
4287
|
const nextProvider = hasNextProvider ? order[index + 1] : undefined;
|
|
3827
4288
|
await emit({
|
|
3828
4289
|
at: Date.now(),
|
|
4290
|
+
attempt: index + 1,
|
|
3829
4291
|
elapsedMs: Date.now() - startedAt,
|
|
3830
4292
|
error: errorMessage(error),
|
|
3831
4293
|
fallbackProvider: shouldFallback ? nextProvider : undefined,
|
|
4294
|
+
latencyBudgetMs: getProviderTimeoutMs(provider),
|
|
3832
4295
|
provider,
|
|
3833
4296
|
providerHealth,
|
|
3834
4297
|
rateLimited,
|
|
3835
4298
|
selectedProvider,
|
|
3836
4299
|
suppressionRemainingMs: getSuppressionRemainingMs(provider),
|
|
3837
4300
|
suppressedUntil: providerHealth?.suppressedUntil,
|
|
3838
|
-
status: "error"
|
|
4301
|
+
status: "error",
|
|
4302
|
+
timedOut
|
|
3839
4303
|
}, input);
|
|
3840
4304
|
if (!hasNextProvider || !shouldFallback) {
|
|
3841
4305
|
throw error;
|
|
@@ -4457,7 +4921,7 @@ var createVoiceMemoryStore = () => {
|
|
|
4457
4921
|
};
|
|
4458
4922
|
|
|
4459
4923
|
// src/session.ts
|
|
4460
|
-
import { Buffer } from "buffer";
|
|
4924
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
4461
4925
|
|
|
4462
4926
|
// src/handoff.ts
|
|
4463
4927
|
var toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
@@ -4486,6 +4950,14 @@ var aggregateHandoffStatus = (deliveries) => {
|
|
|
4486
4950
|
}
|
|
4487
4951
|
return "skipped";
|
|
4488
4952
|
};
|
|
4953
|
+
var createHandoffDeliveryId = (input) => [
|
|
4954
|
+
"voice-handoff",
|
|
4955
|
+
input.sessionId,
|
|
4956
|
+
input.action,
|
|
4957
|
+
Date.now(),
|
|
4958
|
+
crypto.randomUUID()
|
|
4959
|
+
].join(":");
|
|
4960
|
+
var resolveHandoffDeliveryError = (deliveries) => Object.values(deliveries).map((delivery) => delivery.error).find(Boolean);
|
|
4489
4961
|
var defaultWebhookBody = (input) => ({
|
|
4490
4962
|
action: input.action,
|
|
4491
4963
|
metadata: input.metadata,
|
|
@@ -4534,6 +5006,73 @@ var deliverVoiceHandoff = async (input) => {
|
|
|
4534
5006
|
status: aggregateHandoffStatus(deliveries)
|
|
4535
5007
|
};
|
|
4536
5008
|
};
|
|
5009
|
+
var createVoiceHandoffDeliveryRecord = (input) => {
|
|
5010
|
+
const now = Date.now();
|
|
5011
|
+
return {
|
|
5012
|
+
action: input.action,
|
|
5013
|
+
context: input.context,
|
|
5014
|
+
createdAt: now,
|
|
5015
|
+
deliveryAttempts: 0,
|
|
5016
|
+
deliveryStatus: "pending",
|
|
5017
|
+
id: input.id ?? createHandoffDeliveryId({
|
|
5018
|
+
action: input.action,
|
|
5019
|
+
sessionId: input.session.id
|
|
5020
|
+
}),
|
|
5021
|
+
metadata: input.metadata,
|
|
5022
|
+
reason: input.reason,
|
|
5023
|
+
result: input.result,
|
|
5024
|
+
session: input.session,
|
|
5025
|
+
sessionId: input.session.id,
|
|
5026
|
+
target: input.target,
|
|
5027
|
+
updatedAt: now
|
|
5028
|
+
};
|
|
5029
|
+
};
|
|
5030
|
+
var applyVoiceHandoffDeliveryResult = (delivery, result) => ({
|
|
5031
|
+
...delivery,
|
|
5032
|
+
deliveredAt: result.status === "delivered" || result.status === "skipped" ? Date.now() : delivery.deliveredAt,
|
|
5033
|
+
deliveries: result.deliveries,
|
|
5034
|
+
deliveryAttempts: (delivery.deliveryAttempts ?? 0) + 1,
|
|
5035
|
+
deliveryError: result.status === "failed" ? resolveHandoffDeliveryError(result.deliveries) : undefined,
|
|
5036
|
+
deliveryStatus: result.status,
|
|
5037
|
+
updatedAt: Date.now()
|
|
5038
|
+
});
|
|
5039
|
+
var deliverVoiceHandoffDelivery = async (options) => {
|
|
5040
|
+
const result = await deliverVoiceHandoff({
|
|
5041
|
+
config: {
|
|
5042
|
+
adapters: options.adapters,
|
|
5043
|
+
failMode: options.failMode
|
|
5044
|
+
},
|
|
5045
|
+
handoff: {
|
|
5046
|
+
action: options.delivery.action,
|
|
5047
|
+
api: options.api,
|
|
5048
|
+
context: options.delivery.context,
|
|
5049
|
+
metadata: options.delivery.metadata,
|
|
5050
|
+
reason: options.delivery.reason,
|
|
5051
|
+
result: options.delivery.result,
|
|
5052
|
+
session: options.delivery.session,
|
|
5053
|
+
target: options.delivery.target
|
|
5054
|
+
}
|
|
5055
|
+
});
|
|
5056
|
+
return result ? applyVoiceHandoffDeliveryResult(options.delivery, result) : {
|
|
5057
|
+
...options.delivery,
|
|
5058
|
+
deliveryAttempts: (options.delivery.deliveryAttempts ?? 0) + 1,
|
|
5059
|
+
deliveryStatus: "skipped",
|
|
5060
|
+
updatedAt: Date.now()
|
|
5061
|
+
};
|
|
5062
|
+
};
|
|
5063
|
+
var createVoiceMemoryHandoffDeliveryStore = () => {
|
|
5064
|
+
const deliveries = new Map;
|
|
5065
|
+
return {
|
|
5066
|
+
get: async (id) => deliveries.get(id),
|
|
5067
|
+
list: async () => [...deliveries.values()].sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)),
|
|
5068
|
+
remove: async (id) => {
|
|
5069
|
+
deliveries.delete(id);
|
|
5070
|
+
},
|
|
5071
|
+
set: async (id, delivery) => {
|
|
5072
|
+
deliveries.set(id, delivery);
|
|
5073
|
+
}
|
|
5074
|
+
};
|
|
5075
|
+
};
|
|
4537
5076
|
var createVoiceWebhookHandoffAdapter = (options) => ({
|
|
4538
5077
|
actions: options.actions,
|
|
4539
5078
|
handoff: async (input) => {
|
|
@@ -4701,6 +5240,12 @@ var DEFAULT_FORMAT = {
|
|
|
4701
5240
|
encoding: "pcm_s16le",
|
|
4702
5241
|
sampleRateHz: 16000
|
|
4703
5242
|
};
|
|
5243
|
+
var DEFAULT_REALTIME_FORMAT = {
|
|
5244
|
+
channels: 1,
|
|
5245
|
+
container: "raw",
|
|
5246
|
+
encoding: "pcm_s16le",
|
|
5247
|
+
sampleRateHz: 24000
|
|
5248
|
+
};
|
|
4704
5249
|
var toError = (value) => value instanceof Error ? value : new Error(String(value));
|
|
4705
5250
|
var createEmptyCurrentTurn = () => ({
|
|
4706
5251
|
finalText: "",
|
|
@@ -4713,7 +5258,7 @@ var createEmptyCurrentTurn = () => ({
|
|
|
4713
5258
|
transcripts: []
|
|
4714
5259
|
});
|
|
4715
5260
|
var cloneTranscript = (transcript) => ({ ...transcript });
|
|
4716
|
-
var encodeBase64 = (chunk) =>
|
|
5261
|
+
var encodeBase64 = (chunk) => Buffer2.from(chunk).toString("base64");
|
|
4717
5262
|
var countWords2 = (text) => text.trim().split(/\s+/).filter(Boolean).length;
|
|
4718
5263
|
var normalizeText2 = (text) => text.trim().replace(/\s+/g, " ");
|
|
4719
5264
|
var getAudioChunkDurationMs = (chunk) => chunk.byteLength / (DEFAULT_FORMAT.sampleRateHz * DEFAULT_FORMAT.channels * 2) * 1000;
|
|
@@ -4884,7 +5429,7 @@ var createVoiceSession = (options) => {
|
|
|
4884
5429
|
} : undefined;
|
|
4885
5430
|
const appendTrace = async (input) => {
|
|
4886
5431
|
await options.trace?.append({
|
|
4887
|
-
at: Date.now(),
|
|
5432
|
+
at: input.at ?? Date.now(),
|
|
4888
5433
|
metadata: input.metadata,
|
|
4889
5434
|
payload: input.payload,
|
|
4890
5435
|
scenarioId: input.session?.scenarioId ?? options.scenarioId,
|
|
@@ -4893,6 +5438,13 @@ var createVoiceSession = (options) => {
|
|
|
4893
5438
|
type: input.type
|
|
4894
5439
|
});
|
|
4895
5440
|
};
|
|
5441
|
+
const appendTurnLatencyStage = async (input) => appendTrace({
|
|
5442
|
+
at: input.at,
|
|
5443
|
+
payload: { stage: input.stage },
|
|
5444
|
+
session: input.session,
|
|
5445
|
+
turnId: input.turnId,
|
|
5446
|
+
type: "turn_latency.stage"
|
|
5447
|
+
});
|
|
4896
5448
|
const phraseHints = options.phraseHints ?? [];
|
|
4897
5449
|
const lexicon = options.lexicon ?? [];
|
|
4898
5450
|
let socket = options.socket;
|
|
@@ -4971,7 +5523,34 @@ var createVoiceSession = (options) => {
|
|
|
4971
5523
|
type: "call_lifecycle"
|
|
4972
5524
|
});
|
|
4973
5525
|
};
|
|
5526
|
+
const sendReplay = async (session) => {
|
|
5527
|
+
await send({
|
|
5528
|
+
assistantTexts: session.turns.flatMap((turn) => turn.assistantText ? [turn.assistantText] : []),
|
|
5529
|
+
call: session.call,
|
|
5530
|
+
partial: session.currentTurn.partialText,
|
|
5531
|
+
scenarioId: session.scenarioId,
|
|
5532
|
+
sessionId: options.id,
|
|
5533
|
+
status: session.status,
|
|
5534
|
+
turns: session.turns,
|
|
5535
|
+
type: "replay"
|
|
5536
|
+
});
|
|
5537
|
+
};
|
|
4974
5538
|
const runHandoff = async (input) => {
|
|
5539
|
+
const queuedDelivery = options.handoff?.deliveryQueue ? createVoiceHandoffDeliveryRecord({
|
|
5540
|
+
action: input.action,
|
|
5541
|
+
context: options.context,
|
|
5542
|
+
metadata: input.metadata,
|
|
5543
|
+
reason: input.reason,
|
|
5544
|
+
result: input.result,
|
|
5545
|
+
session: input.session,
|
|
5546
|
+
target: input.target
|
|
5547
|
+
}) : undefined;
|
|
5548
|
+
if (queuedDelivery) {
|
|
5549
|
+
await options.handoff?.deliveryQueue?.set(queuedDelivery.id, queuedDelivery);
|
|
5550
|
+
}
|
|
5551
|
+
if (options.handoff?.enqueueOnly) {
|
|
5552
|
+
return;
|
|
5553
|
+
}
|
|
4975
5554
|
const result = await deliverVoiceHandoff({
|
|
4976
5555
|
config: options.handoff,
|
|
4977
5556
|
handoff: {
|
|
@@ -4988,6 +5567,10 @@ var createVoiceSession = (options) => {
|
|
|
4988
5567
|
if (!result) {
|
|
4989
5568
|
return;
|
|
4990
5569
|
}
|
|
5570
|
+
if (queuedDelivery) {
|
|
5571
|
+
const updatedDelivery = applyVoiceHandoffDeliveryResult(queuedDelivery, result);
|
|
5572
|
+
await options.handoff?.deliveryQueue?.set(updatedDelivery.id, updatedDelivery);
|
|
5573
|
+
}
|
|
4991
5574
|
await appendTrace({
|
|
4992
5575
|
metadata: input.metadata,
|
|
4993
5576
|
payload: {
|
|
@@ -5055,6 +5638,23 @@ var createVoiceSession = (options) => {
|
|
|
5055
5638
|
});
|
|
5056
5639
|
}
|
|
5057
5640
|
};
|
|
5641
|
+
const sendAssistantAudio = async (chunk, input) => {
|
|
5642
|
+
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));
|
|
5643
|
+
await send({
|
|
5644
|
+
chunkBase64: encodeBase64(normalizedChunk),
|
|
5645
|
+
format: input.format,
|
|
5646
|
+
receivedAt: input.receivedAt,
|
|
5647
|
+
turnId: activeTTSTurnId,
|
|
5648
|
+
type: "audio"
|
|
5649
|
+
});
|
|
5650
|
+
if (activeTTSTurnId) {
|
|
5651
|
+
await appendTurnLatencyStage({
|
|
5652
|
+
at: input.receivedAt,
|
|
5653
|
+
stage: "assistant_audio_received",
|
|
5654
|
+
turnId: activeTTSTurnId
|
|
5655
|
+
});
|
|
5656
|
+
}
|
|
5657
|
+
};
|
|
5058
5658
|
const scheduleTurnCommit = (delayMs, reason, reset = true) => {
|
|
5059
5659
|
if (!reset && silenceTimer) {
|
|
5060
5660
|
return;
|
|
@@ -5756,8 +6356,12 @@ var createVoiceSession = (options) => {
|
|
|
5756
6356
|
if (sttSession) {
|
|
5757
6357
|
return sttSession;
|
|
5758
6358
|
}
|
|
5759
|
-
const
|
|
5760
|
-
|
|
6359
|
+
const inputAdapter = options.realtime ?? options.stt;
|
|
6360
|
+
if (!inputAdapter) {
|
|
6361
|
+
throw new Error("Voice session requires either an stt or realtime adapter.");
|
|
6362
|
+
}
|
|
6363
|
+
const openedSession = await inputAdapter.open({
|
|
6364
|
+
format: options.realtime ? options.realtimeInputFormat ?? DEFAULT_REALTIME_FORMAT : DEFAULT_FORMAT,
|
|
5761
6365
|
languageStrategy: options.languageStrategy,
|
|
5762
6366
|
lexicon,
|
|
5763
6367
|
phraseHints,
|
|
@@ -5792,6 +6396,16 @@ var createVoiceSession = (options) => {
|
|
|
5792
6396
|
openedSession.on("close", (event) => {
|
|
5793
6397
|
runAdapterEvent("adapter.close", () => handleClose(event));
|
|
5794
6398
|
});
|
|
6399
|
+
if (options.realtime) {
|
|
6400
|
+
openedSession.on("audio", ({ chunk, format, receivedAt }) => {
|
|
6401
|
+
runAdapterEvent("adapter.audio", async () => {
|
|
6402
|
+
await sendAssistantAudio(chunk, {
|
|
6403
|
+
format,
|
|
6404
|
+
receivedAt
|
|
6405
|
+
});
|
|
6406
|
+
});
|
|
6407
|
+
});
|
|
6408
|
+
}
|
|
5795
6409
|
return openedSession;
|
|
5796
6410
|
};
|
|
5797
6411
|
const ensureTTSSession = async () => {
|
|
@@ -5816,13 +6430,9 @@ var createVoiceSession = (options) => {
|
|
|
5816
6430
|
if (ttsSession !== openedSession) {
|
|
5817
6431
|
return;
|
|
5818
6432
|
}
|
|
5819
|
-
|
|
5820
|
-
await send({
|
|
5821
|
-
chunkBase64: encodeBase64(normalizedChunk),
|
|
6433
|
+
await sendAssistantAudio(chunk, {
|
|
5822
6434
|
format,
|
|
5823
|
-
receivedAt
|
|
5824
|
-
turnId: activeTTSTurnId,
|
|
5825
|
-
type: "audio"
|
|
6435
|
+
receivedAt
|
|
5826
6436
|
});
|
|
5827
6437
|
});
|
|
5828
6438
|
});
|
|
@@ -5866,9 +6476,32 @@ var createVoiceSession = (options) => {
|
|
|
5866
6476
|
});
|
|
5867
6477
|
};
|
|
5868
6478
|
const completeTurn = async (session, turn) => {
|
|
6479
|
+
const liveOpsControl = await options.liveOps?.getControl(options.id);
|
|
6480
|
+
if (liveOpsControl?.assistantPaused || liveOpsControl?.operatorTakeover) {
|
|
6481
|
+
await appendTrace({
|
|
6482
|
+
metadata: {
|
|
6483
|
+
source: "voice-live-ops"
|
|
6484
|
+
},
|
|
6485
|
+
payload: {
|
|
6486
|
+
action: "turn.skipped",
|
|
6487
|
+
control: liveOpsControl,
|
|
6488
|
+
reason: liveOpsControl.operatorTakeover ? "operator-takeover" : "assistant-paused",
|
|
6489
|
+
status: "skipped"
|
|
6490
|
+
},
|
|
6491
|
+
session,
|
|
6492
|
+
turnId: turn.id,
|
|
6493
|
+
type: "operator.action"
|
|
6494
|
+
});
|
|
6495
|
+
return;
|
|
6496
|
+
}
|
|
6497
|
+
const injectedInstruction = liveOpsControl?.injectedInstruction?.trim();
|
|
5869
6498
|
const committedOutput = await options.route.onTurn({
|
|
5870
6499
|
api,
|
|
5871
6500
|
context: options.context,
|
|
6501
|
+
liveOps: liveOpsControl ? {
|
|
6502
|
+
control: liveOpsControl,
|
|
6503
|
+
injectedInstruction
|
|
6504
|
+
} : undefined,
|
|
5872
6505
|
session,
|
|
5873
6506
|
turn
|
|
5874
6507
|
});
|
|
@@ -5882,6 +6515,7 @@ var createVoiceSession = (options) => {
|
|
|
5882
6515
|
voicemail: committedOutput?.voicemail
|
|
5883
6516
|
};
|
|
5884
6517
|
if (output?.assistantText) {
|
|
6518
|
+
const assistantTextStartedAt = Date.now();
|
|
5885
6519
|
await writeSession((currentSession) => {
|
|
5886
6520
|
setTurnResult(currentSession, turn.id, {
|
|
5887
6521
|
assistantText: output.assistantText
|
|
@@ -5892,10 +6526,17 @@ var createVoiceSession = (options) => {
|
|
|
5892
6526
|
turnId: turn.id,
|
|
5893
6527
|
type: "assistant"
|
|
5894
6528
|
});
|
|
6529
|
+
await appendTurnLatencyStage({
|
|
6530
|
+
at: assistantTextStartedAt,
|
|
6531
|
+
session,
|
|
6532
|
+
stage: "assistant_text_started",
|
|
6533
|
+
turnId: turn.id
|
|
6534
|
+
});
|
|
5895
6535
|
await appendTrace({
|
|
5896
6536
|
payload: {
|
|
5897
6537
|
text: output.assistantText,
|
|
5898
|
-
ttsConfigured: Boolean(options.tts)
|
|
6538
|
+
ttsConfigured: Boolean(options.tts),
|
|
6539
|
+
realtimeConfigured: Boolean(options.realtime)
|
|
5899
6540
|
},
|
|
5900
6541
|
session,
|
|
5901
6542
|
turnId: turn.id,
|
|
@@ -5906,7 +6547,18 @@ var createVoiceSession = (options) => {
|
|
|
5906
6547
|
if (activeTTSSession) {
|
|
5907
6548
|
const ttsStartedAt = Date.now();
|
|
5908
6549
|
activeTTSTurnId = turn.id;
|
|
6550
|
+
await appendTurnLatencyStage({
|
|
6551
|
+
at: ttsStartedAt,
|
|
6552
|
+
session,
|
|
6553
|
+
stage: "tts_send_started",
|
|
6554
|
+
turnId: turn.id
|
|
6555
|
+
});
|
|
5909
6556
|
await activeTTSSession.send(output.assistantText);
|
|
6557
|
+
await appendTurnLatencyStage({
|
|
6558
|
+
session,
|
|
6559
|
+
stage: "tts_send_completed",
|
|
6560
|
+
turnId: turn.id
|
|
6561
|
+
});
|
|
5910
6562
|
await appendTrace({
|
|
5911
6563
|
payload: {
|
|
5912
6564
|
elapsedMs: Date.now() - ttsStartedAt,
|
|
@@ -5916,9 +6568,35 @@ var createVoiceSession = (options) => {
|
|
|
5916
6568
|
turnId: turn.id,
|
|
5917
6569
|
type: "turn.assistant"
|
|
5918
6570
|
});
|
|
6571
|
+
} else if (options.realtime) {
|
|
6572
|
+
const activeRealtimeSession = await ensureAdapter();
|
|
6573
|
+
const realtimeStartedAt = Date.now();
|
|
6574
|
+
activeTTSTurnId = turn.id;
|
|
6575
|
+
await appendTurnLatencyStage({
|
|
6576
|
+
at: realtimeStartedAt,
|
|
6577
|
+
session,
|
|
6578
|
+
stage: "tts_send_started",
|
|
6579
|
+
turnId: turn.id
|
|
6580
|
+
});
|
|
6581
|
+
await activeRealtimeSession.send(output.assistantText);
|
|
6582
|
+
await appendTurnLatencyStage({
|
|
6583
|
+
session,
|
|
6584
|
+
stage: "tts_send_completed",
|
|
6585
|
+
turnId: turn.id
|
|
6586
|
+
});
|
|
6587
|
+
await appendTrace({
|
|
6588
|
+
payload: {
|
|
6589
|
+
elapsedMs: Date.now() - realtimeStartedAt,
|
|
6590
|
+
mode: "realtime",
|
|
6591
|
+
status: "sent"
|
|
6592
|
+
},
|
|
6593
|
+
session,
|
|
6594
|
+
turnId: turn.id,
|
|
6595
|
+
type: "turn.assistant"
|
|
6596
|
+
});
|
|
5919
6597
|
}
|
|
5920
6598
|
} catch (error) {
|
|
5921
|
-
logger.warn("voice
|
|
6599
|
+
logger.warn("voice assistant audio send failed", {
|
|
5922
6600
|
error: toError(error).message,
|
|
5923
6601
|
sessionId: options.id,
|
|
5924
6602
|
turnId: turn.id
|
|
@@ -5926,7 +6604,7 @@ var createVoiceSession = (options) => {
|
|
|
5926
6604
|
await appendTrace({
|
|
5927
6605
|
payload: {
|
|
5928
6606
|
error: toError(error).message,
|
|
5929
|
-
status: "tts-send-failed"
|
|
6607
|
+
status: options.realtime ? "realtime-send-failed" : "tts-send-failed"
|
|
5930
6608
|
},
|
|
5931
6609
|
session,
|
|
5932
6610
|
turnId: turn.id,
|
|
@@ -6103,11 +6781,35 @@ var createVoiceSession = (options) => {
|
|
|
6103
6781
|
turnId: turn.id,
|
|
6104
6782
|
type: "turn.cost"
|
|
6105
6783
|
});
|
|
6784
|
+
const firstTranscriptAt = turn.transcripts.map((transcript) => transcript.endedAtMs ?? transcript.startedAtMs).filter((value) => typeof value === "number").sort((left, right) => left - right)[0];
|
|
6785
|
+
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];
|
|
6786
|
+
if (firstTranscriptAt !== undefined) {
|
|
6787
|
+
await appendTurnLatencyStage({
|
|
6788
|
+
at: firstTranscriptAt,
|
|
6789
|
+
session: updatedSession,
|
|
6790
|
+
stage: "speech_detected",
|
|
6791
|
+
turnId: turn.id
|
|
6792
|
+
});
|
|
6793
|
+
}
|
|
6794
|
+
if (finalTranscriptAt !== undefined) {
|
|
6795
|
+
await appendTurnLatencyStage({
|
|
6796
|
+
at: finalTranscriptAt,
|
|
6797
|
+
session: updatedSession,
|
|
6798
|
+
stage: "final_transcript",
|
|
6799
|
+
turnId: turn.id
|
|
6800
|
+
});
|
|
6801
|
+
}
|
|
6802
|
+
await appendTurnLatencyStage({
|
|
6803
|
+
at: turn.committedAt,
|
|
6804
|
+
session: updatedSession,
|
|
6805
|
+
stage: "turn_committed",
|
|
6806
|
+
turnId: turn.id
|
|
6807
|
+
});
|
|
6106
6808
|
await send({
|
|
6107
6809
|
turn,
|
|
6108
6810
|
type: "turn"
|
|
6109
6811
|
});
|
|
6110
|
-
if (options.sttLifecycle === "turn-scoped") {
|
|
6812
|
+
if (options.stt && options.sttLifecycle === "turn-scoped") {
|
|
6111
6813
|
await closeAdapter("turn-commit");
|
|
6112
6814
|
}
|
|
6113
6815
|
await completeTurn(updatedSession, turn);
|
|
@@ -6170,6 +6872,7 @@ var createVoiceSession = (options) => {
|
|
|
6170
6872
|
scenarioId: session.scenarioId,
|
|
6171
6873
|
type: "session"
|
|
6172
6874
|
});
|
|
6875
|
+
await sendReplay(session);
|
|
6173
6876
|
if (shouldFireOnSession) {
|
|
6174
6877
|
await options.route.onCallStart?.({
|
|
6175
6878
|
api,
|
|
@@ -7546,10 +8249,981 @@ var runVoiceSessionBenchmarkSeries = async (input) => {
|
|
|
7546
8249
|
});
|
|
7547
8250
|
};
|
|
7548
8251
|
// src/telephony/twilio.ts
|
|
7549
|
-
import { Buffer as
|
|
8252
|
+
import { Buffer as Buffer3 } from "buffer";
|
|
8253
|
+
import { Elysia as Elysia2 } from "elysia";
|
|
8254
|
+
|
|
8255
|
+
// src/telephonyOutcome.ts
|
|
8256
|
+
import { Elysia } from "elysia";
|
|
8257
|
+
var DEFAULT_COMPLETED_STATUSES = [
|
|
8258
|
+
"answered",
|
|
8259
|
+
"completed",
|
|
8260
|
+
"complete",
|
|
8261
|
+
"connected",
|
|
8262
|
+
"in-progress",
|
|
8263
|
+
"live"
|
|
8264
|
+
];
|
|
8265
|
+
var DEFAULT_NO_ANSWER_STATUSES = [
|
|
8266
|
+
"busy",
|
|
8267
|
+
"canceled",
|
|
8268
|
+
"cancelled",
|
|
8269
|
+
"failed",
|
|
8270
|
+
"no-answer",
|
|
8271
|
+
"no_answer",
|
|
8272
|
+
"not-answered",
|
|
8273
|
+
"ring-no-answer",
|
|
8274
|
+
"timeout",
|
|
8275
|
+
"unanswered"
|
|
8276
|
+
];
|
|
8277
|
+
var DEFAULT_VOICEMAIL_STATUSES = [
|
|
8278
|
+
"answering-machine",
|
|
8279
|
+
"machine",
|
|
8280
|
+
"voicemail",
|
|
8281
|
+
"voice-mail"
|
|
8282
|
+
];
|
|
8283
|
+
var DEFAULT_TRANSFER_STATUSES = ["bridged", "forwarded", "transferred"];
|
|
8284
|
+
var DEFAULT_ESCALATION_STATUSES = ["escalated", "human-required", "operator"];
|
|
8285
|
+
var DEFAULT_FAILED_STATUSES = ["busy", "failed", "no-answer"];
|
|
8286
|
+
var DEFAULT_MACHINE_VOICEMAIL_VALUES = [
|
|
8287
|
+
"answering-machine",
|
|
8288
|
+
"fax",
|
|
8289
|
+
"machine",
|
|
8290
|
+
"machine-end-beep",
|
|
8291
|
+
"machine-end-other",
|
|
8292
|
+
"machine-start",
|
|
8293
|
+
"voicemail"
|
|
8294
|
+
];
|
|
8295
|
+
var DEFAULT_NO_ANSWER_SIP_CODES = [408, 480, 486, 487, 603];
|
|
8296
|
+
var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
8297
|
+
var uniqueSorted = (values) => Array.from(new Set(values)).sort();
|
|
8298
|
+
var findMissing = (values, required) => {
|
|
8299
|
+
if (!required?.length) {
|
|
8300
|
+
return [];
|
|
8301
|
+
}
|
|
8302
|
+
const valueSet = new Set(values);
|
|
8303
|
+
return required.filter((value) => !valueSet.has(value));
|
|
8304
|
+
};
|
|
8305
|
+
|
|
8306
|
+
class VoiceTelephonyWebhookVerificationError extends Error {
|
|
8307
|
+
result;
|
|
8308
|
+
constructor(result) {
|
|
8309
|
+
super(result.ok ? "telephony webhook verified" : result.reason);
|
|
8310
|
+
this.name = "VoiceTelephonyWebhookVerificationError";
|
|
8311
|
+
this.result = result;
|
|
8312
|
+
}
|
|
8313
|
+
}
|
|
8314
|
+
var createMemoryVoiceTelephonyWebhookIdempotencyStore = () => {
|
|
8315
|
+
const decisions = new Map;
|
|
8316
|
+
return {
|
|
8317
|
+
get: (key) => decisions.get(key),
|
|
8318
|
+
set: (key, decision) => {
|
|
8319
|
+
decisions.set(key, decision);
|
|
8320
|
+
}
|
|
8321
|
+
};
|
|
8322
|
+
};
|
|
8323
|
+
var isTelephonyWebhookProvider = (value) => value === "generic" || value === "plivo" || value === "telnyx" || value === "twilio";
|
|
8324
|
+
var isTelephonyOutcomeAction = (value) => value === "complete" || value === "escalate" || value === "ignore" || value === "no-answer" || value === "transfer" || value === "voicemail";
|
|
8325
|
+
var isCallDisposition = (value) => value === "completed" || value === "escalated" || value === "failed" || value === "no-answer" || value === "transferred" || value === "voicemail";
|
|
8326
|
+
var evaluateVoiceTelephonyWebhookNormalizationEvidence = (input = {}) => {
|
|
8327
|
+
const issues = [];
|
|
8328
|
+
const decisions = input.decisions ?? [];
|
|
8329
|
+
const verificationAttempts = input.verificationAttempts ?? [];
|
|
8330
|
+
const actions = uniqueSorted(decisions.map((decision) => decision.decision?.action ?? decision.action).filter(isTelephonyOutcomeAction));
|
|
8331
|
+
const dispositions = uniqueSorted(decisions.map((decision) => decision.decision?.disposition ?? decision.disposition).filter(isCallDisposition));
|
|
8332
|
+
const providers = uniqueSorted(decisions.map((decision) => decision.provider ?? decision.event?.provider).filter(isTelephonyWebhookProvider));
|
|
8333
|
+
const sources = uniqueSorted(decisions.map((decision) => decision.decision?.source ?? decision.source).filter((source) => typeof source === "string"));
|
|
8334
|
+
const applied = decisions.filter((decision) => decision.applied === true).length;
|
|
8335
|
+
const duplicateDecisions = decisions.filter((decision) => decision.duplicate === true);
|
|
8336
|
+
const duplicateProviders = uniqueSorted(duplicateDecisions.map((decision) => decision.provider ?? decision.event?.provider).filter(isTelephonyWebhookProvider));
|
|
8337
|
+
const duplicateIdempotencyKeys = new Set(duplicateDecisions.map((decision) => decision.idempotencyKey).filter((key) => typeof key === "string" && key.length > 0)).size;
|
|
8338
|
+
const duplicateCampaignOutcomesApplied = duplicateDecisions.filter((decision) => isRecord(decision.campaignOutcome) && decision.campaignOutcome.applied === true).length;
|
|
8339
|
+
const duplicateOutcomeReasons = uniqueSorted(duplicateDecisions.map((decision) => isRecord(decision.campaignOutcome) ? decision.campaignOutcome.reason : undefined).filter((reason) => typeof reason === "string"));
|
|
8340
|
+
const routeResults = decisions.filter((decision) => isRecord(decision.routeResult)).length;
|
|
8341
|
+
const missingSessionIds = decisions.filter((decision) => !decision.sessionId).length;
|
|
8342
|
+
const rejectedVerificationAttempts = verificationAttempts.filter((attempt) => attempt.rejected === true || attempt.status === 401 || attempt.verification?.ok === false && attempt.verification.reason === "invalid-signature");
|
|
8343
|
+
const rejectedVerificationProviders = uniqueSorted(rejectedVerificationAttempts.map((attempt) => attempt.provider).filter(isTelephonyWebhookProvider));
|
|
8344
|
+
const replayRejectedVerificationAttempts = rejectedVerificationAttempts.filter((attempt) => attempt.replayRejected === true);
|
|
8345
|
+
const replayRejectedVerificationProviders = uniqueSorted(replayRejectedVerificationAttempts.map((attempt) => attempt.provider).filter(isTelephonyWebhookProvider));
|
|
8346
|
+
const rejectedVerificationSideEffects = rejectedVerificationAttempts.reduce((total, attempt) => total + Math.max(0, attempt.sideEffects ?? 0), 0);
|
|
8347
|
+
if (input.minDecisions !== undefined && decisions.length < input.minDecisions) {
|
|
8348
|
+
issues.push(`Expected at least ${String(input.minDecisions)} telephony webhook decision(s), found ${String(decisions.length)}.`);
|
|
8349
|
+
}
|
|
8350
|
+
if (input.minApplied !== undefined && applied < input.minApplied) {
|
|
8351
|
+
issues.push(`Expected at least ${String(input.minApplied)} applied telephony webhook decision(s), found ${String(applied)}.`);
|
|
8352
|
+
}
|
|
8353
|
+
if (input.minDuplicates !== undefined && duplicateDecisions.length < input.minDuplicates) {
|
|
8354
|
+
issues.push(`Expected at least ${String(input.minDuplicates)} duplicate telephony webhook decision(s), found ${String(duplicateDecisions.length)}.`);
|
|
8355
|
+
}
|
|
8356
|
+
if (input.minDuplicateIdempotencyKeys !== undefined && duplicateIdempotencyKeys < input.minDuplicateIdempotencyKeys) {
|
|
8357
|
+
issues.push(`Expected at least ${String(input.minDuplicateIdempotencyKeys)} duplicate telephony webhook idempotency key(s), found ${String(duplicateIdempotencyKeys)}.`);
|
|
8358
|
+
}
|
|
8359
|
+
if (input.maxDuplicateCampaignOutcomesApplied !== undefined && duplicateCampaignOutcomesApplied > input.maxDuplicateCampaignOutcomesApplied) {
|
|
8360
|
+
issues.push(`Expected at most ${String(input.maxDuplicateCampaignOutcomesApplied)} duplicate telephony webhook campaign outcome application(s), found ${String(duplicateCampaignOutcomesApplied)}.`);
|
|
8361
|
+
}
|
|
8362
|
+
if (input.minRejectedVerificationAttempts !== undefined && rejectedVerificationAttempts.length < input.minRejectedVerificationAttempts) {
|
|
8363
|
+
issues.push(`Expected at least ${String(input.minRejectedVerificationAttempts)} rejected telephony webhook verification attempt(s), found ${String(rejectedVerificationAttempts.length)}.`);
|
|
8364
|
+
}
|
|
8365
|
+
if (input.maxRejectedVerificationSideEffects !== undefined && rejectedVerificationSideEffects > input.maxRejectedVerificationSideEffects) {
|
|
8366
|
+
issues.push(`Expected at most ${String(input.maxRejectedVerificationSideEffects)} rejected telephony webhook side effect(s), found ${String(rejectedVerificationSideEffects)}.`);
|
|
8367
|
+
}
|
|
8368
|
+
if (input.minReplayRejectedVerificationAttempts !== undefined && replayRejectedVerificationAttempts.length < input.minReplayRejectedVerificationAttempts) {
|
|
8369
|
+
issues.push(`Expected at least ${String(input.minReplayRejectedVerificationAttempts)} replay-rejected telephony webhook verification attempt(s), found ${String(replayRejectedVerificationAttempts.length)}.`);
|
|
8370
|
+
}
|
|
8371
|
+
if (input.maxMissingSessionIds !== undefined && missingSessionIds > input.maxMissingSessionIds) {
|
|
8372
|
+
issues.push(`Expected at most ${String(input.maxMissingSessionIds)} telephony webhook decision(s) without sessionId, found ${String(missingSessionIds)}.`);
|
|
8373
|
+
}
|
|
8374
|
+
if (input.requireRouteResults && routeResults < decisions.length) {
|
|
8375
|
+
issues.push(`Expected every telephony webhook decision to include a route result, found ${String(routeResults)} of ${String(decisions.length)}.`);
|
|
8376
|
+
}
|
|
8377
|
+
for (const provider of findMissing(providers, input.requiredProviders)) {
|
|
8378
|
+
issues.push(`Missing telephony webhook provider: ${provider}.`);
|
|
8379
|
+
}
|
|
8380
|
+
for (const provider of findMissing(duplicateProviders, input.requiredDuplicateProviders)) {
|
|
8381
|
+
issues.push(`Missing duplicate telephony webhook provider: ${provider}.`);
|
|
8382
|
+
}
|
|
8383
|
+
for (const provider of findMissing(rejectedVerificationProviders, input.requiredRejectedVerificationProviders)) {
|
|
8384
|
+
issues.push(`Missing rejected telephony webhook verification provider: ${provider}.`);
|
|
8385
|
+
}
|
|
8386
|
+
for (const provider of findMissing(replayRejectedVerificationProviders, input.requiredReplayRejectedVerificationProviders)) {
|
|
8387
|
+
issues.push(`Missing replay-rejected telephony webhook verification provider: ${provider}.`);
|
|
8388
|
+
}
|
|
8389
|
+
for (const action of findMissing(actions, input.requiredActions)) {
|
|
8390
|
+
issues.push(`Missing telephony webhook action: ${action}.`);
|
|
8391
|
+
}
|
|
8392
|
+
for (const disposition of findMissing(dispositions, input.requiredDispositions)) {
|
|
8393
|
+
issues.push(`Missing telephony webhook disposition: ${disposition}.`);
|
|
8394
|
+
}
|
|
8395
|
+
return {
|
|
8396
|
+
actions,
|
|
8397
|
+
applied,
|
|
8398
|
+
decisions: decisions.length,
|
|
8399
|
+
dispositions,
|
|
8400
|
+
duplicateCampaignOutcomesApplied,
|
|
8401
|
+
duplicateIdempotencyKeys,
|
|
8402
|
+
duplicateOutcomeReasons,
|
|
8403
|
+
duplicateProviders,
|
|
8404
|
+
duplicates: duplicateDecisions.length,
|
|
8405
|
+
issues,
|
|
8406
|
+
missingSessionIds,
|
|
8407
|
+
ok: issues.length === 0,
|
|
8408
|
+
providers,
|
|
8409
|
+
rejectedVerificationAttempts: rejectedVerificationAttempts.length,
|
|
8410
|
+
rejectedVerificationProviders,
|
|
8411
|
+
rejectedVerificationSideEffects,
|
|
8412
|
+
replayRejectedVerificationAttempts: replayRejectedVerificationAttempts.length,
|
|
8413
|
+
replayRejectedVerificationProviders,
|
|
8414
|
+
routeResults,
|
|
8415
|
+
sources,
|
|
8416
|
+
verificationAttempts: verificationAttempts.length
|
|
8417
|
+
};
|
|
8418
|
+
};
|
|
8419
|
+
var assertVoiceTelephonyWebhookNormalizationEvidence = (input = {}) => {
|
|
8420
|
+
const assertion = evaluateVoiceTelephonyWebhookNormalizationEvidence(input);
|
|
8421
|
+
if (!assertion.ok) {
|
|
8422
|
+
throw new Error(`Voice telephony webhook normalization evidence assertion failed: ${assertion.issues.join(" ")}`);
|
|
8423
|
+
}
|
|
8424
|
+
return assertion;
|
|
8425
|
+
};
|
|
8426
|
+
var normalizeToken = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/\s+/g, "-").replace(/_+/g, "-") : undefined;
|
|
8427
|
+
var firstString = (source, keys) => {
|
|
8428
|
+
for (const key of keys) {
|
|
8429
|
+
const value = source[key];
|
|
8430
|
+
if (typeof value === "string" && value.trim()) {
|
|
8431
|
+
return value.trim();
|
|
8432
|
+
}
|
|
8433
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
8434
|
+
return String(value);
|
|
8435
|
+
}
|
|
8436
|
+
}
|
|
8437
|
+
};
|
|
8438
|
+
var firstNumber = (source, keys) => {
|
|
8439
|
+
for (const key of keys) {
|
|
8440
|
+
const value = source[key];
|
|
8441
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
8442
|
+
return value;
|
|
8443
|
+
}
|
|
8444
|
+
if (typeof value === "string" && value.trim()) {
|
|
8445
|
+
const parsed = Number(value);
|
|
8446
|
+
if (Number.isFinite(parsed)) {
|
|
8447
|
+
return parsed;
|
|
8448
|
+
}
|
|
8449
|
+
}
|
|
8450
|
+
}
|
|
8451
|
+
};
|
|
8452
|
+
var parseMaybeJSON = (value) => {
|
|
8453
|
+
try {
|
|
8454
|
+
return JSON.parse(value);
|
|
8455
|
+
} catch {
|
|
8456
|
+
return;
|
|
8457
|
+
}
|
|
8458
|
+
};
|
|
8459
|
+
var flattenPayload = (value) => {
|
|
8460
|
+
if (!isRecord(value)) {
|
|
8461
|
+
return {};
|
|
8462
|
+
}
|
|
8463
|
+
const data = isRecord(value.data) ? value.data : undefined;
|
|
8464
|
+
const payload = isRecord(value.payload) ? value.payload : undefined;
|
|
8465
|
+
const event = isRecord(value.event) ? value.event : undefined;
|
|
8466
|
+
return {
|
|
8467
|
+
...value,
|
|
8468
|
+
...payload,
|
|
8469
|
+
...event,
|
|
8470
|
+
...data,
|
|
8471
|
+
...isRecord(data?.payload) ? data.payload : undefined
|
|
8472
|
+
};
|
|
8473
|
+
};
|
|
8474
|
+
var toBase64 = (bytes) => Buffer.from(new Uint8Array(bytes)).toString("base64");
|
|
8475
|
+
var timingSafeEqual = (left, right) => {
|
|
8476
|
+
const encoder = new TextEncoder;
|
|
8477
|
+
const leftBytes = encoder.encode(left);
|
|
8478
|
+
const rightBytes = encoder.encode(right);
|
|
8479
|
+
if (leftBytes.length !== rightBytes.length) {
|
|
8480
|
+
return false;
|
|
8481
|
+
}
|
|
8482
|
+
let diff = 0;
|
|
8483
|
+
for (let index = 0;index < leftBytes.length; index += 1) {
|
|
8484
|
+
diff |= leftBytes[index] ^ rightBytes[index];
|
|
8485
|
+
}
|
|
8486
|
+
return diff === 0;
|
|
8487
|
+
};
|
|
8488
|
+
var signHmacSHA1Base64 = async (secret, payload) => {
|
|
8489
|
+
const encoder = new TextEncoder;
|
|
8490
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
|
|
8491
|
+
hash: "SHA-1",
|
|
8492
|
+
name: "HMAC"
|
|
8493
|
+
}, false, ["sign"]);
|
|
8494
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
8495
|
+
return toBase64(signature);
|
|
8496
|
+
};
|
|
8497
|
+
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("");
|
|
8498
|
+
var normalizeList = (values, fallback) => new Set((values ?? fallback).map(normalizeToken).filter(Boolean));
|
|
8499
|
+
var metadataValue = (metadata, keys) => {
|
|
8500
|
+
for (const key of keys) {
|
|
8501
|
+
const value = metadata?.[key];
|
|
8502
|
+
if (typeof value === "string" && value.trim()) {
|
|
8503
|
+
return value.trim();
|
|
8504
|
+
}
|
|
8505
|
+
}
|
|
8506
|
+
};
|
|
8507
|
+
var resolveTransferTarget = (event, policy) => {
|
|
8508
|
+
if (typeof event.target === "string" && event.target.trim()) {
|
|
8509
|
+
return event.target.trim();
|
|
8510
|
+
}
|
|
8511
|
+
const metadataTarget = metadataValue(event.metadata, [
|
|
8512
|
+
"transferTarget",
|
|
8513
|
+
"target",
|
|
8514
|
+
"queue",
|
|
8515
|
+
"department"
|
|
8516
|
+
]);
|
|
8517
|
+
if (metadataTarget) {
|
|
8518
|
+
return metadataTarget;
|
|
8519
|
+
}
|
|
8520
|
+
if (typeof policy.transferTarget === "function") {
|
|
8521
|
+
const target = policy.transferTarget(event);
|
|
8522
|
+
return typeof target === "string" && target.trim() ? target.trim() : undefined;
|
|
8523
|
+
}
|
|
8524
|
+
return typeof policy.transferTarget === "string" && policy.transferTarget.trim() ? policy.transferTarget.trim() : undefined;
|
|
8525
|
+
};
|
|
8526
|
+
var mergeMetadata = (event, policy) => ({
|
|
8527
|
+
...policy.includeProviderPayload ? {
|
|
8528
|
+
answeredBy: event.answeredBy,
|
|
8529
|
+
durationMs: event.durationMs,
|
|
8530
|
+
provider: event.provider,
|
|
8531
|
+
reason: event.reason,
|
|
8532
|
+
sipCode: event.sipCode,
|
|
8533
|
+
status: event.status
|
|
8534
|
+
} : undefined,
|
|
8535
|
+
...policy.metadata,
|
|
8536
|
+
...event.metadata
|
|
8537
|
+
});
|
|
8538
|
+
var withDecisionDefaults = (decision, input) => {
|
|
8539
|
+
if (typeof decision === "string") {
|
|
8540
|
+
return buildDecision(decision, input);
|
|
8541
|
+
}
|
|
8542
|
+
return {
|
|
8543
|
+
...buildDecision(decision.action, input),
|
|
8544
|
+
...decision,
|
|
8545
|
+
confidence: decision.confidence ?? "high",
|
|
8546
|
+
metadata: {
|
|
8547
|
+
...mergeMetadata(input.event, input.policy),
|
|
8548
|
+
...decision.metadata
|
|
8549
|
+
},
|
|
8550
|
+
source: decision.source ?? input.source,
|
|
8551
|
+
target: decision.target ?? (decision.action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined)
|
|
8552
|
+
};
|
|
8553
|
+
};
|
|
8554
|
+
var dispositionForAction = (action) => {
|
|
8555
|
+
switch (action) {
|
|
8556
|
+
case "complete":
|
|
8557
|
+
return "completed";
|
|
8558
|
+
case "escalate":
|
|
8559
|
+
return "escalated";
|
|
8560
|
+
case "no-answer":
|
|
8561
|
+
return "no-answer";
|
|
8562
|
+
case "transfer":
|
|
8563
|
+
return "transferred";
|
|
8564
|
+
case "voicemail":
|
|
8565
|
+
return "voicemail";
|
|
8566
|
+
default:
|
|
8567
|
+
return;
|
|
8568
|
+
}
|
|
8569
|
+
};
|
|
8570
|
+
var buildDecision = (action, input) => ({
|
|
8571
|
+
action,
|
|
8572
|
+
confidence: action === "ignore" ? "low" : "high",
|
|
8573
|
+
disposition: dispositionForAction(action),
|
|
8574
|
+
metadata: mergeMetadata(input.event, input.policy),
|
|
8575
|
+
reason: input.event.reason,
|
|
8576
|
+
source: input.source,
|
|
8577
|
+
target: action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined
|
|
8578
|
+
});
|
|
8579
|
+
var createVoiceTelephonyOutcomePolicy = (policy = {}) => ({
|
|
8580
|
+
completedStatuses: policy.completedStatuses ?? DEFAULT_COMPLETED_STATUSES,
|
|
8581
|
+
escalationStatuses: policy.escalationStatuses ?? DEFAULT_ESCALATION_STATUSES,
|
|
8582
|
+
failedAsNoAnswer: policy.failedAsNoAnswer ?? true,
|
|
8583
|
+
failedStatuses: policy.failedStatuses ?? DEFAULT_FAILED_STATUSES,
|
|
8584
|
+
includeProviderPayload: policy.includeProviderPayload ?? true,
|
|
8585
|
+
machineDetectionVoicemailValues: policy.machineDetectionVoicemailValues ?? DEFAULT_MACHINE_VOICEMAIL_VALUES,
|
|
8586
|
+
metadata: policy.metadata,
|
|
8587
|
+
minAnsweredDurationMs: policy.minAnsweredDurationMs,
|
|
8588
|
+
noAnswerOnZeroDuration: policy.noAnswerOnZeroDuration ?? true,
|
|
8589
|
+
noAnswerSipCodes: policy.noAnswerSipCodes ?? DEFAULT_NO_ANSWER_SIP_CODES,
|
|
8590
|
+
noAnswerStatuses: policy.noAnswerStatuses ?? DEFAULT_NO_ANSWER_STATUSES,
|
|
8591
|
+
statusMap: policy.statusMap,
|
|
8592
|
+
transferStatuses: policy.transferStatuses ?? DEFAULT_TRANSFER_STATUSES,
|
|
8593
|
+
transferTarget: policy.transferTarget,
|
|
8594
|
+
voicemailStatuses: policy.voicemailStatuses ?? DEFAULT_VOICEMAIL_STATUSES
|
|
8595
|
+
});
|
|
8596
|
+
var resolveVoiceTelephonyOutcome = (event, policyInput = {}) => {
|
|
8597
|
+
const policy = createVoiceTelephonyOutcomePolicy(policyInput);
|
|
8598
|
+
const status = normalizeToken(event.status);
|
|
8599
|
+
const provider = normalizeToken(event.provider);
|
|
8600
|
+
const answeredBy = normalizeToken(event.answeredBy);
|
|
8601
|
+
const target = resolveTransferTarget(event, policy);
|
|
8602
|
+
if (status) {
|
|
8603
|
+
const mapped = policy.statusMap?.[status] ?? (provider ? policy.statusMap?.[`${provider}:${status}`] : undefined);
|
|
8604
|
+
if (mapped) {
|
|
8605
|
+
return withDecisionDefaults(mapped, {
|
|
8606
|
+
event,
|
|
8607
|
+
policy,
|
|
8608
|
+
source: "policy"
|
|
8609
|
+
});
|
|
8610
|
+
}
|
|
8611
|
+
}
|
|
8612
|
+
if (answeredBy && normalizeList(policy.machineDetectionVoicemailValues, []).has(answeredBy)) {
|
|
8613
|
+
return buildDecision("voicemail", { event, policy, source: "answered-by" });
|
|
8614
|
+
}
|
|
8615
|
+
if (typeof event.sipCode === "number" && policy.noAnswerSipCodes.includes(event.sipCode)) {
|
|
8616
|
+
return buildDecision("no-answer", { event, policy, source: "sip" });
|
|
8617
|
+
}
|
|
8618
|
+
if (target && status && normalizeList(policy.transferStatuses, []).has(status)) {
|
|
8619
|
+
return buildDecision("transfer", { event, policy, source: "status" });
|
|
8620
|
+
}
|
|
8621
|
+
if (status && normalizeList(policy.voicemailStatuses, []).has(status)) {
|
|
8622
|
+
return buildDecision("voicemail", { event, policy, source: "status" });
|
|
8623
|
+
}
|
|
8624
|
+
if (status && normalizeList(policy.escalationStatuses, []).has(status)) {
|
|
8625
|
+
return buildDecision("escalate", { event, policy, source: "status" });
|
|
8626
|
+
}
|
|
8627
|
+
if (status && (policy.failedAsNoAnswer ? normalizeList(policy.noAnswerStatuses, []).has(status) || normalizeList(policy.failedStatuses, []).has(status) : normalizeList(policy.noAnswerStatuses, []).has(status))) {
|
|
8628
|
+
return buildDecision("no-answer", { event, policy, source: "status" });
|
|
8629
|
+
}
|
|
8630
|
+
if (policy.noAnswerOnZeroDuration && typeof event.durationMs === "number" && event.durationMs <= 0) {
|
|
8631
|
+
return buildDecision("no-answer", { event, policy, source: "duration" });
|
|
8632
|
+
}
|
|
8633
|
+
if (typeof policy.minAnsweredDurationMs === "number" && typeof event.durationMs === "number" && event.durationMs < policy.minAnsweredDurationMs) {
|
|
8634
|
+
return {
|
|
8635
|
+
...buildDecision("no-answer", { event, policy, source: "duration" }),
|
|
8636
|
+
confidence: "medium"
|
|
8637
|
+
};
|
|
8638
|
+
}
|
|
8639
|
+
if (status && normalizeList(policy.completedStatuses, []).has(status)) {
|
|
8640
|
+
return buildDecision("complete", { event, policy, source: "status" });
|
|
8641
|
+
}
|
|
8642
|
+
if (target) {
|
|
8643
|
+
return {
|
|
8644
|
+
...buildDecision("transfer", { event, policy, source: "explicit-target" }),
|
|
8645
|
+
confidence: "medium"
|
|
8646
|
+
};
|
|
8647
|
+
}
|
|
8648
|
+
return buildDecision("ignore", { event, policy, source: "status" });
|
|
8649
|
+
};
|
|
8650
|
+
var voiceTelephonyOutcomeToRouteResult = (decision, result) => {
|
|
8651
|
+
switch (decision.action) {
|
|
8652
|
+
case "complete":
|
|
8653
|
+
return { complete: true, result };
|
|
8654
|
+
case "escalate":
|
|
8655
|
+
return {
|
|
8656
|
+
escalate: {
|
|
8657
|
+
metadata: decision.metadata,
|
|
8658
|
+
reason: decision.reason ?? "telephony-escalation"
|
|
8659
|
+
},
|
|
8660
|
+
result
|
|
8661
|
+
};
|
|
8662
|
+
case "no-answer":
|
|
8663
|
+
return {
|
|
8664
|
+
noAnswer: {
|
|
8665
|
+
metadata: decision.metadata
|
|
8666
|
+
},
|
|
8667
|
+
result
|
|
8668
|
+
};
|
|
8669
|
+
case "transfer":
|
|
8670
|
+
if (!decision.target) {
|
|
8671
|
+
return { result };
|
|
8672
|
+
}
|
|
8673
|
+
return {
|
|
8674
|
+
result,
|
|
8675
|
+
transfer: {
|
|
8676
|
+
metadata: decision.metadata,
|
|
8677
|
+
reason: decision.reason,
|
|
8678
|
+
target: decision.target
|
|
8679
|
+
}
|
|
8680
|
+
};
|
|
8681
|
+
case "voicemail":
|
|
8682
|
+
return {
|
|
8683
|
+
result,
|
|
8684
|
+
voicemail: {
|
|
8685
|
+
metadata: decision.metadata
|
|
8686
|
+
}
|
|
8687
|
+
};
|
|
8688
|
+
default:
|
|
8689
|
+
return { result };
|
|
8690
|
+
}
|
|
8691
|
+
};
|
|
8692
|
+
var applyVoiceTelephonyOutcome = async (api, decision, result) => {
|
|
8693
|
+
switch (decision.action) {
|
|
8694
|
+
case "complete":
|
|
8695
|
+
await api.complete(result);
|
|
8696
|
+
break;
|
|
8697
|
+
case "escalate":
|
|
8698
|
+
await api.escalate({
|
|
8699
|
+
metadata: decision.metadata,
|
|
8700
|
+
reason: decision.reason ?? "telephony-escalation",
|
|
8701
|
+
result
|
|
8702
|
+
});
|
|
8703
|
+
break;
|
|
8704
|
+
case "no-answer":
|
|
8705
|
+
await api.markNoAnswer({
|
|
8706
|
+
metadata: decision.metadata,
|
|
8707
|
+
result
|
|
8708
|
+
});
|
|
8709
|
+
break;
|
|
8710
|
+
case "transfer":
|
|
8711
|
+
if (!decision.target) {
|
|
8712
|
+
return;
|
|
8713
|
+
}
|
|
8714
|
+
await api.transfer({
|
|
8715
|
+
metadata: decision.metadata,
|
|
8716
|
+
reason: decision.reason,
|
|
8717
|
+
result,
|
|
8718
|
+
target: decision.target
|
|
8719
|
+
});
|
|
8720
|
+
break;
|
|
8721
|
+
case "voicemail":
|
|
8722
|
+
await api.markVoicemail({
|
|
8723
|
+
metadata: decision.metadata,
|
|
8724
|
+
result
|
|
8725
|
+
});
|
|
8726
|
+
break;
|
|
8727
|
+
default:
|
|
8728
|
+
break;
|
|
8729
|
+
}
|
|
8730
|
+
};
|
|
8731
|
+
var parseRequestBodyText = (input) => {
|
|
8732
|
+
const { contentType, text } = input;
|
|
8733
|
+
if (!text) {
|
|
8734
|
+
return {};
|
|
8735
|
+
}
|
|
8736
|
+
if (contentType.includes("application/json")) {
|
|
8737
|
+
return parseMaybeJSON(text) ?? {};
|
|
8738
|
+
}
|
|
8739
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
8740
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
8741
|
+
}
|
|
8742
|
+
return parseMaybeJSON(text) ?? Object.fromEntries(new URLSearchParams(text));
|
|
8743
|
+
};
|
|
8744
|
+
var readRequestBody = async (request) => {
|
|
8745
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
8746
|
+
const text = await request.text();
|
|
8747
|
+
return {
|
|
8748
|
+
body: parseRequestBodyText({ contentType, text }),
|
|
8749
|
+
rawBody: text
|
|
8750
|
+
};
|
|
8751
|
+
};
|
|
8752
|
+
var signVoiceTwilioWebhook = async (input) => signHmacSHA1Base64(input.authToken, `${input.url}${sortedParamsForSignature(input.body ?? {})}`);
|
|
8753
|
+
var verifyVoiceTwilioWebhookSignature = async (input) => {
|
|
8754
|
+
if (!input.authToken) {
|
|
8755
|
+
return { ok: false, reason: "missing-secret" };
|
|
8756
|
+
}
|
|
8757
|
+
const signature = input.headers.get("x-twilio-signature");
|
|
8758
|
+
if (!signature) {
|
|
8759
|
+
return { ok: false, reason: "missing-signature" };
|
|
8760
|
+
}
|
|
8761
|
+
const expected = await signVoiceTwilioWebhook({
|
|
8762
|
+
authToken: input.authToken,
|
|
8763
|
+
body: input.body,
|
|
8764
|
+
url: input.url
|
|
8765
|
+
});
|
|
8766
|
+
return timingSafeEqual(signature, expected) ? { ok: true } : { ok: false, reason: "invalid-signature" };
|
|
8767
|
+
};
|
|
8768
|
+
var resolveVerificationUrl = (option, input) => typeof option === "function" ? option(input) : option ?? input.request.url;
|
|
8769
|
+
var verifyVoiceTelephonyWebhook = async (input) => {
|
|
8770
|
+
if (input.options.verify) {
|
|
8771
|
+
return input.options.verify({
|
|
8772
|
+
body: input.body,
|
|
8773
|
+
headers: input.request.headers,
|
|
8774
|
+
provider: input.provider,
|
|
8775
|
+
query: input.query,
|
|
8776
|
+
rawBody: input.rawBody,
|
|
8777
|
+
request: input.request
|
|
8778
|
+
});
|
|
8779
|
+
}
|
|
8780
|
+
if (!input.options.signingSecret) {
|
|
8781
|
+
return input.options.requireVerification ? { ok: false, reason: "missing-secret" } : { ok: true };
|
|
8782
|
+
}
|
|
8783
|
+
if (input.provider !== "twilio") {
|
|
8784
|
+
return { ok: false, reason: "unsupported-provider" };
|
|
8785
|
+
}
|
|
8786
|
+
return verifyVoiceTwilioWebhookSignature({
|
|
8787
|
+
authToken: input.options.signingSecret,
|
|
8788
|
+
body: input.body,
|
|
8789
|
+
headers: input.request.headers,
|
|
8790
|
+
url: resolveVerificationUrl(input.options.verificationUrl, {
|
|
8791
|
+
query: input.query,
|
|
8792
|
+
request: input.request
|
|
8793
|
+
})
|
|
8794
|
+
});
|
|
8795
|
+
};
|
|
8796
|
+
var durationMsFromSeconds = (value) => typeof value === "number" ? value * 1000 : undefined;
|
|
8797
|
+
var parseVoiceTelephonyWebhookEvent = (input) => {
|
|
8798
|
+
const payload = flattenPayload(input.body);
|
|
8799
|
+
const provider = firstString(payload, ["provider", "Provider"]) ?? input.provider;
|
|
8800
|
+
const status = firstString(payload, [
|
|
8801
|
+
"CallStatus",
|
|
8802
|
+
"call_status",
|
|
8803
|
+
"callStatus",
|
|
8804
|
+
"DialCallStatus",
|
|
8805
|
+
"dial_call_status",
|
|
8806
|
+
"status",
|
|
8807
|
+
"event_type",
|
|
8808
|
+
"type"
|
|
8809
|
+
]);
|
|
8810
|
+
const durationMs = firstNumber(payload, ["durationMs", "duration_ms"]) ?? durationMsFromSeconds(firstNumber(payload, [
|
|
8811
|
+
"CallDuration",
|
|
8812
|
+
"call_duration",
|
|
8813
|
+
"callDuration",
|
|
8814
|
+
"DialCallDuration",
|
|
8815
|
+
"dial_call_duration",
|
|
8816
|
+
"duration"
|
|
8817
|
+
]));
|
|
8818
|
+
const sipCode = firstNumber(payload, [
|
|
8819
|
+
"SipResponseCode",
|
|
8820
|
+
"sip_response_code",
|
|
8821
|
+
"sipCode",
|
|
8822
|
+
"sip_code",
|
|
8823
|
+
"hangupCauseCode"
|
|
8824
|
+
]);
|
|
8825
|
+
const from = firstString(payload, ["From", "from", "caller_id", "callerId"]);
|
|
8826
|
+
const to = firstString(payload, ["To", "to", "called_number", "calledNumber"]);
|
|
8827
|
+
const target = firstString(payload, [
|
|
8828
|
+
"transferTarget",
|
|
8829
|
+
"TransferTarget",
|
|
8830
|
+
"target",
|
|
8831
|
+
"queue",
|
|
8832
|
+
"department"
|
|
8833
|
+
]);
|
|
8834
|
+
return {
|
|
8835
|
+
answeredBy: firstString(payload, [
|
|
8836
|
+
"AnsweredBy",
|
|
8837
|
+
"answered_by",
|
|
8838
|
+
"answeredBy",
|
|
8839
|
+
"machineDetection",
|
|
8840
|
+
"machine_detection"
|
|
8841
|
+
]),
|
|
8842
|
+
durationMs,
|
|
8843
|
+
from,
|
|
8844
|
+
metadata: {
|
|
8845
|
+
...input.query,
|
|
8846
|
+
...payload
|
|
8847
|
+
},
|
|
8848
|
+
provider,
|
|
8849
|
+
reason: firstString(payload, [
|
|
8850
|
+
"Reason",
|
|
8851
|
+
"reason",
|
|
8852
|
+
"HangupCause",
|
|
8853
|
+
"hangup_cause",
|
|
8854
|
+
"hangupCause"
|
|
8855
|
+
]),
|
|
8856
|
+
sipCode,
|
|
8857
|
+
status,
|
|
8858
|
+
target,
|
|
8859
|
+
to
|
|
8860
|
+
};
|
|
8861
|
+
};
|
|
8862
|
+
var defaultSessionId = (input) => {
|
|
8863
|
+
const payload = flattenPayload(input.body);
|
|
8864
|
+
const metadataSessionId = input.event.metadata?.sessionId;
|
|
8865
|
+
return firstString(input.query, ["sessionId", "session_id"]) ?? firstString(payload, [
|
|
8866
|
+
"sessionId",
|
|
8867
|
+
"session_id",
|
|
8868
|
+
"SessionId",
|
|
8869
|
+
"CallSid",
|
|
8870
|
+
"call_sid",
|
|
8871
|
+
"callSid",
|
|
8872
|
+
"CallUUID",
|
|
8873
|
+
"call_uuid",
|
|
8874
|
+
"callControlId",
|
|
8875
|
+
"call_control_id"
|
|
8876
|
+
]) ?? (typeof metadataSessionId === "string" ? metadataSessionId : undefined);
|
|
8877
|
+
};
|
|
8878
|
+
var defaultIdempotencyKey = (input) => {
|
|
8879
|
+
const payload = flattenPayload(input.body);
|
|
8880
|
+
const eventId = firstString(payload, [
|
|
8881
|
+
"id",
|
|
8882
|
+
"event_id",
|
|
8883
|
+
"eventId",
|
|
8884
|
+
"EventSid",
|
|
8885
|
+
"event_sid",
|
|
8886
|
+
"MessageSid",
|
|
8887
|
+
"message_sid",
|
|
8888
|
+
"CallSid",
|
|
8889
|
+
"call_sid",
|
|
8890
|
+
"CallUUID",
|
|
8891
|
+
"call_uuid",
|
|
8892
|
+
"callControlId",
|
|
8893
|
+
"call_control_id"
|
|
8894
|
+
]);
|
|
8895
|
+
const status = normalizeToken(input.event.status) ?? "unknown";
|
|
8896
|
+
if (eventId) {
|
|
8897
|
+
return `${input.provider}:${eventId}:${status}`;
|
|
8898
|
+
}
|
|
8899
|
+
if (input.sessionId) {
|
|
8900
|
+
return `${input.provider}:${input.sessionId}:${status}`;
|
|
8901
|
+
}
|
|
8902
|
+
};
|
|
8903
|
+
var createVoiceTelephonyWebhookHandler = (options = {}) => async (input) => {
|
|
8904
|
+
const provider = options.provider ?? "generic";
|
|
8905
|
+
const query = input.query ?? {};
|
|
8906
|
+
const { body, rawBody } = await readRequestBody(input.request);
|
|
8907
|
+
const verification = await verifyVoiceTelephonyWebhook({
|
|
8908
|
+
body,
|
|
8909
|
+
options,
|
|
8910
|
+
provider,
|
|
8911
|
+
query,
|
|
8912
|
+
rawBody,
|
|
8913
|
+
request: input.request
|
|
8914
|
+
});
|
|
8915
|
+
if (!verification.ok) {
|
|
8916
|
+
throw new VoiceTelephonyWebhookVerificationError(verification);
|
|
8917
|
+
}
|
|
8918
|
+
const event = options.parse ? await options.parse({
|
|
8919
|
+
body,
|
|
8920
|
+
headers: input.request.headers,
|
|
8921
|
+
provider,
|
|
8922
|
+
query,
|
|
8923
|
+
request: input.request
|
|
8924
|
+
}) : parseVoiceTelephonyWebhookEvent({
|
|
8925
|
+
body,
|
|
8926
|
+
headers: input.request.headers,
|
|
8927
|
+
provider,
|
|
8928
|
+
query,
|
|
8929
|
+
request: input.request
|
|
8930
|
+
});
|
|
8931
|
+
const sessionId = await (options.resolveSessionId?.({
|
|
8932
|
+
body,
|
|
8933
|
+
event,
|
|
8934
|
+
query,
|
|
8935
|
+
request: input.request
|
|
8936
|
+
}) ?? defaultSessionId({ body, event, query }));
|
|
8937
|
+
const idempotencyEnabled = options.idempotency?.enabled !== false;
|
|
8938
|
+
const idempotencyKey = idempotencyEnabled ? await (options.idempotency?.key?.({
|
|
8939
|
+
body,
|
|
8940
|
+
event,
|
|
8941
|
+
provider,
|
|
8942
|
+
query,
|
|
8943
|
+
request: input.request,
|
|
8944
|
+
sessionId
|
|
8945
|
+
}) ?? defaultIdempotencyKey({ body, event, provider, sessionId })) : undefined;
|
|
8946
|
+
const idempotencyStore = options.idempotency?.store;
|
|
8947
|
+
if (idempotencyKey && idempotencyStore) {
|
|
8948
|
+
const existing = await idempotencyStore.get(idempotencyKey);
|
|
8949
|
+
if (existing) {
|
|
8950
|
+
const duplicateDecision = {
|
|
8951
|
+
...existing,
|
|
8952
|
+
duplicate: true
|
|
8953
|
+
};
|
|
8954
|
+
await options.onDecision?.({
|
|
8955
|
+
...duplicateDecision,
|
|
8956
|
+
context: options.context,
|
|
8957
|
+
request: input.request
|
|
8958
|
+
});
|
|
8959
|
+
return duplicateDecision;
|
|
8960
|
+
}
|
|
8961
|
+
}
|
|
8962
|
+
const decision = resolveVoiceTelephonyOutcome(event, options.policy);
|
|
8963
|
+
const resultResolver = options.result;
|
|
8964
|
+
const result = typeof resultResolver === "function" ? await resultResolver({
|
|
8965
|
+
decision,
|
|
8966
|
+
event,
|
|
8967
|
+
sessionId
|
|
8968
|
+
}) : resultResolver;
|
|
8969
|
+
const routeResult = voiceTelephonyOutcomeToRouteResult(decision, result);
|
|
8970
|
+
const shouldApply = typeof options.apply === "function" ? options.apply({
|
|
8971
|
+
applied: false,
|
|
8972
|
+
decision,
|
|
8973
|
+
event,
|
|
8974
|
+
routeResult,
|
|
8975
|
+
sessionId
|
|
8976
|
+
}) : options.apply === true;
|
|
8977
|
+
let applied = false;
|
|
8978
|
+
if (shouldApply && decision.action !== "ignore" && options.getSessionHandle) {
|
|
8979
|
+
const api = await options.getSessionHandle({
|
|
8980
|
+
context: options.context,
|
|
8981
|
+
decision,
|
|
8982
|
+
event,
|
|
8983
|
+
request: input.request,
|
|
8984
|
+
sessionId
|
|
8985
|
+
});
|
|
8986
|
+
if (api) {
|
|
8987
|
+
await applyVoiceTelephonyOutcome(api, decision, result);
|
|
8988
|
+
applied = true;
|
|
8989
|
+
}
|
|
8990
|
+
}
|
|
8991
|
+
const webhookDecision = {
|
|
8992
|
+
applied,
|
|
8993
|
+
decision,
|
|
8994
|
+
event,
|
|
8995
|
+
idempotencyKey,
|
|
8996
|
+
routeResult,
|
|
8997
|
+
sessionId
|
|
8998
|
+
};
|
|
8999
|
+
if (idempotencyKey && idempotencyStore) {
|
|
9000
|
+
const now = Date.now();
|
|
9001
|
+
await idempotencyStore.set(idempotencyKey, {
|
|
9002
|
+
...webhookDecision,
|
|
9003
|
+
createdAt: now,
|
|
9004
|
+
updatedAt: now
|
|
9005
|
+
});
|
|
9006
|
+
}
|
|
9007
|
+
await options.onDecision?.({
|
|
9008
|
+
...webhookDecision,
|
|
9009
|
+
context: options.context,
|
|
9010
|
+
request: input.request
|
|
9011
|
+
});
|
|
9012
|
+
return webhookDecision;
|
|
9013
|
+
};
|
|
9014
|
+
var createVoiceTelephonyWebhookRoutes = (options = {}) => {
|
|
9015
|
+
const path = options.path ?? "/api/voice/telephony/webhook";
|
|
9016
|
+
const handler = createVoiceTelephonyWebhookHandler(options);
|
|
9017
|
+
return new Elysia({
|
|
9018
|
+
name: options.name ?? "absolutejs-voice-telephony-webhooks"
|
|
9019
|
+
}).post(path, async ({ query, request }) => {
|
|
9020
|
+
try {
|
|
9021
|
+
return await handler({ query, request });
|
|
9022
|
+
} catch (error) {
|
|
9023
|
+
if (error instanceof VoiceTelephonyWebhookVerificationError) {
|
|
9024
|
+
return new Response(JSON.stringify({ verification: error.result }), {
|
|
9025
|
+
headers: {
|
|
9026
|
+
"content-type": "application/json"
|
|
9027
|
+
},
|
|
9028
|
+
status: 401
|
|
9029
|
+
});
|
|
9030
|
+
}
|
|
9031
|
+
throw error;
|
|
9032
|
+
}
|
|
9033
|
+
}, {
|
|
9034
|
+
parse: "none"
|
|
9035
|
+
});
|
|
9036
|
+
};
|
|
9037
|
+
|
|
9038
|
+
// src/telephony/twilio.ts
|
|
7550
9039
|
var TWILIO_MULAW_SAMPLE_RATE = 8000;
|
|
7551
9040
|
var VOICE_PCM_SAMPLE_RATE = 16000;
|
|
7552
9041
|
var escapeXml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
9042
|
+
var resolveRequestOrigin = (request) => {
|
|
9043
|
+
const url = new URL(request.url);
|
|
9044
|
+
const forwardedHost = request.headers.get("x-forwarded-host");
|
|
9045
|
+
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
9046
|
+
const host = forwardedHost ?? request.headers.get("host") ?? url.host;
|
|
9047
|
+
const protocol = forwardedProto ?? url.protocol.replace(":", "");
|
|
9048
|
+
return `${protocol}://${host}`;
|
|
9049
|
+
};
|
|
9050
|
+
var resolveTwilioStreamUrl = async (options, input) => {
|
|
9051
|
+
if (typeof options.twiml?.streamUrl === "function") {
|
|
9052
|
+
return options.twiml.streamUrl(input);
|
|
9053
|
+
}
|
|
9054
|
+
if (typeof options.twiml?.streamUrl === "string") {
|
|
9055
|
+
return options.twiml.streamUrl;
|
|
9056
|
+
}
|
|
9057
|
+
const origin = resolveRequestOrigin(input.request);
|
|
9058
|
+
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
9059
|
+
return `${wsOrigin}${input.streamPath}`;
|
|
9060
|
+
};
|
|
9061
|
+
var resolveTwilioStreamParameters = async (parameters, input) => {
|
|
9062
|
+
if (typeof parameters === "function") {
|
|
9063
|
+
return parameters(input);
|
|
9064
|
+
}
|
|
9065
|
+
return parameters;
|
|
9066
|
+
};
|
|
9067
|
+
var joinUrlPath = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
9068
|
+
var escapeHtml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
9069
|
+
var getWebhookVerificationUrl = (webhook, input) => {
|
|
9070
|
+
if (!webhook?.verificationUrl) {
|
|
9071
|
+
return;
|
|
9072
|
+
}
|
|
9073
|
+
if (typeof webhook.verificationUrl === "function") {
|
|
9074
|
+
return webhook.verificationUrl(input);
|
|
9075
|
+
}
|
|
9076
|
+
return webhook.verificationUrl;
|
|
9077
|
+
};
|
|
9078
|
+
var buildTwilioVoiceSetupStatus = async (options, input) => {
|
|
9079
|
+
const origin = resolveRequestOrigin(input.request);
|
|
9080
|
+
const stream = await resolveTwilioStreamUrl(options, input);
|
|
9081
|
+
const twiml = joinUrlPath(origin, input.twimlPath);
|
|
9082
|
+
const webhook = joinUrlPath(origin, input.webhookPath);
|
|
9083
|
+
const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
|
|
9084
|
+
const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
|
|
9085
|
+
const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
|
|
9086
|
+
const warnings = [
|
|
9087
|
+
...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
|
|
9088
|
+
...signingConfigured ? [] : ["Webhook signature verification is not configured."],
|
|
9089
|
+
...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
|
|
9090
|
+
];
|
|
9091
|
+
return {
|
|
9092
|
+
generatedAt: Date.now(),
|
|
9093
|
+
missing,
|
|
9094
|
+
provider: "twilio",
|
|
9095
|
+
ready: missing.length === 0 && signingConfigured && warnings.length === 0,
|
|
9096
|
+
signing: {
|
|
9097
|
+
configured: signingConfigured,
|
|
9098
|
+
mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
|
|
9099
|
+
verificationUrl
|
|
9100
|
+
},
|
|
9101
|
+
urls: {
|
|
9102
|
+
stream,
|
|
9103
|
+
twiml,
|
|
9104
|
+
webhook
|
|
9105
|
+
},
|
|
9106
|
+
warnings
|
|
9107
|
+
};
|
|
9108
|
+
};
|
|
9109
|
+
var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
9110
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
|
|
9111
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
9112
|
+
<p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
|
|
9113
|
+
<section>
|
|
9114
|
+
<h2>URLs</h2>
|
|
9115
|
+
<ul>
|
|
9116
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(status.urls.twiml)}</code></li>
|
|
9117
|
+
<li><strong>Media stream:</strong> <code>${escapeHtml2(status.urls.stream)}</code></li>
|
|
9118
|
+
<li><strong>Status webhook:</strong> <code>${escapeHtml2(status.urls.webhook)}</code></li>
|
|
9119
|
+
</ul>
|
|
9120
|
+
</section>
|
|
9121
|
+
<section>
|
|
9122
|
+
<h2>Signing</h2>
|
|
9123
|
+
<p>Mode: <code>${status.signing.mode}</code></p>
|
|
9124
|
+
${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml2(status.signing.verificationUrl)}</code></p>` : ""}
|
|
9125
|
+
</section>
|
|
9126
|
+
${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml2(name)}</code></li>`).join("")}</ul></section>` : ""}
|
|
9127
|
+
${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml2(warning)}</li>`).join("")}</ul></section>` : ""}
|
|
9128
|
+
</main>`;
|
|
9129
|
+
var extractTwilioStreamUrl = (twiml) => twiml.match(/<Stream\b[^>]*\surl="([^"]+)"/i)?.[1]?.replaceAll("&", "&");
|
|
9130
|
+
var createSmokeCheck = (name, status, message, details) => ({
|
|
9131
|
+
details,
|
|
9132
|
+
message,
|
|
9133
|
+
name,
|
|
9134
|
+
status
|
|
9135
|
+
});
|
|
9136
|
+
var renderTwilioVoiceSmokeHTML = (report, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
9137
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio smoke test</p>
|
|
9138
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
9139
|
+
<p><strong>Status:</strong> ${report.pass ? "Pass" : "Fail"}</p>
|
|
9140
|
+
<section>
|
|
9141
|
+
<h2>Checks</h2>
|
|
9142
|
+
<ul>
|
|
9143
|
+
${report.checks.map((check) => `<li><strong>${escapeHtml2(check.name)}</strong>: ${escapeHtml2(check.status)}${check.message ? ` - ${escapeHtml2(check.message)}` : ""}</li>`).join("")}
|
|
9144
|
+
</ul>
|
|
9145
|
+
</section>
|
|
9146
|
+
<section>
|
|
9147
|
+
<h2>Observed URLs</h2>
|
|
9148
|
+
<ul>
|
|
9149
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(report.setup.urls.twiml)}</code></li>
|
|
9150
|
+
<li><strong>Stream:</strong> <code>${escapeHtml2(report.twiml?.streamUrl ?? report.setup.urls.stream)}</code></li>
|
|
9151
|
+
<li><strong>Webhook:</strong> <code>${escapeHtml2(report.setup.urls.webhook)}</code></li>
|
|
9152
|
+
</ul>
|
|
9153
|
+
</section>
|
|
9154
|
+
</main>`;
|
|
9155
|
+
var runTwilioVoiceSmokeTest = async (input) => {
|
|
9156
|
+
const setup = await buildTwilioVoiceSetupStatus(input.options, input);
|
|
9157
|
+
const checks = [];
|
|
9158
|
+
const twimlUrl = new URL(setup.urls.twiml);
|
|
9159
|
+
twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
|
|
9160
|
+
twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
|
|
9161
|
+
const twimlResponse = await input.app.handle(new Request(twimlUrl, {
|
|
9162
|
+
headers: input.request.headers
|
|
9163
|
+
}));
|
|
9164
|
+
const twiml = await twimlResponse.text();
|
|
9165
|
+
const streamUrl = extractTwilioStreamUrl(twiml);
|
|
9166
|
+
checks.push(createSmokeCheck("twiml", twimlResponse.ok && Boolean(streamUrl) ? "pass" : "fail", streamUrl ? "TwiML includes a media stream URL." : 'TwiML is missing <Stream url="...">.', {
|
|
9167
|
+
status: twimlResponse.status,
|
|
9168
|
+
streamUrl
|
|
9169
|
+
}));
|
|
9170
|
+
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.", {
|
|
9171
|
+
streamUrl
|
|
9172
|
+
}));
|
|
9173
|
+
const webhookBody = {
|
|
9174
|
+
CallSid: input.options.smoke?.callSid ?? "CA_SMOKE_TEST",
|
|
9175
|
+
CallStatus: input.options.smoke?.status ?? "busy",
|
|
9176
|
+
SipResponseCode: String(input.options.smoke?.sipCode ?? 486)
|
|
9177
|
+
};
|
|
9178
|
+
const webhookHeaders = new Headers({
|
|
9179
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
9180
|
+
});
|
|
9181
|
+
const verificationUrl = setup.signing.verificationUrl ?? setup.urls.webhook;
|
|
9182
|
+
if (input.options.webhook?.signingSecret) {
|
|
9183
|
+
webhookHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
|
|
9184
|
+
authToken: input.options.webhook.signingSecret,
|
|
9185
|
+
body: webhookBody,
|
|
9186
|
+
url: verificationUrl
|
|
9187
|
+
}));
|
|
9188
|
+
}
|
|
9189
|
+
const webhookResponse = await input.app.handle(new Request(setup.urls.webhook, {
|
|
9190
|
+
body: new URLSearchParams(webhookBody),
|
|
9191
|
+
headers: webhookHeaders,
|
|
9192
|
+
method: "POST"
|
|
9193
|
+
}));
|
|
9194
|
+
const webhookText = await webhookResponse.text();
|
|
9195
|
+
const webhookPayload = (() => {
|
|
9196
|
+
try {
|
|
9197
|
+
return JSON.parse(webhookText);
|
|
9198
|
+
} catch {
|
|
9199
|
+
return webhookText;
|
|
9200
|
+
}
|
|
9201
|
+
})();
|
|
9202
|
+
checks.push(createSmokeCheck("webhook", webhookResponse.ok ? "pass" : "fail", webhookResponse.ok ? "Synthetic Twilio status callback was accepted." : "Synthetic Twilio status callback failed.", {
|
|
9203
|
+
status: webhookResponse.status
|
|
9204
|
+
}));
|
|
9205
|
+
for (const warning of setup.warnings) {
|
|
9206
|
+
checks.push(createSmokeCheck("setup-warning", "warn", warning));
|
|
9207
|
+
}
|
|
9208
|
+
for (const name of setup.missing) {
|
|
9209
|
+
checks.push(createSmokeCheck("missing-env", "fail", `${name} is missing.`));
|
|
9210
|
+
}
|
|
9211
|
+
return {
|
|
9212
|
+
checks,
|
|
9213
|
+
generatedAt: Date.now(),
|
|
9214
|
+
pass: checks.every((check) => check.status !== "fail"),
|
|
9215
|
+
provider: "twilio",
|
|
9216
|
+
setup,
|
|
9217
|
+
twiml: {
|
|
9218
|
+
status: twimlResponse.status,
|
|
9219
|
+
streamUrl
|
|
9220
|
+
},
|
|
9221
|
+
webhook: {
|
|
9222
|
+
body: webhookPayload,
|
|
9223
|
+
status: webhookResponse.status
|
|
9224
|
+
}
|
|
9225
|
+
};
|
|
9226
|
+
};
|
|
7553
9227
|
var normalizeOnTurn = (handler) => {
|
|
7554
9228
|
if (handler.length > 1) {
|
|
7555
9229
|
const directHandler = handler;
|
|
@@ -7651,7 +9325,7 @@ var bytesToInt16Array = (bytes) => {
|
|
|
7651
9325
|
return output;
|
|
7652
9326
|
};
|
|
7653
9327
|
var decodeTwilioMulawBase64 = (payload) => {
|
|
7654
|
-
const bytes = Uint8Array.from(
|
|
9328
|
+
const bytes = Uint8Array.from(Buffer3.from(payload, "base64"));
|
|
7655
9329
|
const samples = new Int16Array(bytes.length);
|
|
7656
9330
|
for (let index = 0;index < bytes.length; index += 1) {
|
|
7657
9331
|
samples[index] = decodeMulawSample(bytes[index] ?? 0);
|
|
@@ -7663,7 +9337,7 @@ var encodeTwilioMulawBase64 = (samples) => {
|
|
|
7663
9337
|
for (let index = 0;index < samples.length; index += 1) {
|
|
7664
9338
|
bytes[index] = encodeMulawSample(samples[index] ?? 0);
|
|
7665
9339
|
}
|
|
7666
|
-
return
|
|
9340
|
+
return Buffer3.from(bytes).toString("base64");
|
|
7667
9341
|
};
|
|
7668
9342
|
var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
|
|
7669
9343
|
const narrowband = decodeTwilioMulawBase64(payload);
|
|
@@ -7672,7 +9346,7 @@ var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
|
|
|
7672
9346
|
};
|
|
7673
9347
|
var transcodePCMToTwilioOutboundPayload = (chunk, format) => {
|
|
7674
9348
|
if (format.container === "raw" && format.encoding === "mulaw" && format.channels === 1 && format.sampleRateHz === TWILIO_MULAW_SAMPLE_RATE) {
|
|
7675
|
-
return
|
|
9349
|
+
return Buffer3.from(chunk).toString("base64");
|
|
7676
9350
|
}
|
|
7677
9351
|
if (format.encoding !== "pcm_s16le") {
|
|
7678
9352
|
throw new Error(`Unsupported outbound telephony audio format: ${format.container}/${format.encoding}`);
|
|
@@ -7713,7 +9387,7 @@ var createTwilioSocketAdapter = (socket, getState) => ({
|
|
|
7713
9387
|
return;
|
|
7714
9388
|
}
|
|
7715
9389
|
if (message.type === "audio") {
|
|
7716
|
-
const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(
|
|
9390
|
+
const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer3.from(message.chunkBase64, "base64")), message.format);
|
|
7717
9391
|
state.hasOutboundAudioSinceLastInbound = true;
|
|
7718
9392
|
state.reviewRecorder?.recordTwilioOutbound({
|
|
7719
9393
|
bytes: payload.length,
|
|
@@ -7926,6 +9600,148 @@ var createTwilioMediaStreamBridge = (socket, options) => {
|
|
|
7926
9600
|
}
|
|
7927
9601
|
};
|
|
7928
9602
|
};
|
|
9603
|
+
var createTwilioVoiceRoutes = (options) => {
|
|
9604
|
+
const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
|
|
9605
|
+
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
9606
|
+
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
9607
|
+
const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
|
|
9608
|
+
const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
|
|
9609
|
+
const bridges = new WeakMap;
|
|
9610
|
+
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
9611
|
+
const app = new Elysia2({
|
|
9612
|
+
name: options.name ?? "absolutejs-voice-twilio"
|
|
9613
|
+
}).get(twimlPath, async ({ query, request }) => {
|
|
9614
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
9615
|
+
query,
|
|
9616
|
+
request,
|
|
9617
|
+
streamPath
|
|
9618
|
+
});
|
|
9619
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
9620
|
+
query,
|
|
9621
|
+
request
|
|
9622
|
+
});
|
|
9623
|
+
return new Response(createTwilioVoiceResponse({
|
|
9624
|
+
parameters,
|
|
9625
|
+
streamName: options.twiml?.streamName,
|
|
9626
|
+
streamUrl,
|
|
9627
|
+
track: options.twiml?.track
|
|
9628
|
+
}), {
|
|
9629
|
+
headers: {
|
|
9630
|
+
"content-type": "text/xml; charset=utf-8"
|
|
9631
|
+
}
|
|
9632
|
+
});
|
|
9633
|
+
}).post(twimlPath, async ({ query, request }) => {
|
|
9634
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
9635
|
+
query,
|
|
9636
|
+
request,
|
|
9637
|
+
streamPath
|
|
9638
|
+
});
|
|
9639
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
9640
|
+
query,
|
|
9641
|
+
request
|
|
9642
|
+
});
|
|
9643
|
+
return new Response(createTwilioVoiceResponse({
|
|
9644
|
+
parameters,
|
|
9645
|
+
streamName: options.twiml?.streamName,
|
|
9646
|
+
streamUrl,
|
|
9647
|
+
track: options.twiml?.track
|
|
9648
|
+
}), {
|
|
9649
|
+
headers: {
|
|
9650
|
+
"content-type": "text/xml; charset=utf-8"
|
|
9651
|
+
}
|
|
9652
|
+
});
|
|
9653
|
+
}).ws(streamPath, {
|
|
9654
|
+
close: async (ws, _code, reason) => {
|
|
9655
|
+
const bridge = bridges.get(ws);
|
|
9656
|
+
bridges.delete(ws);
|
|
9657
|
+
await bridge?.close(reason);
|
|
9658
|
+
},
|
|
9659
|
+
message: async (ws, raw) => {
|
|
9660
|
+
let bridge = bridges.get(ws);
|
|
9661
|
+
if (!bridge) {
|
|
9662
|
+
bridge = createTwilioMediaStreamBridge({
|
|
9663
|
+
close: (code, reason) => {
|
|
9664
|
+
ws.close(code, reason);
|
|
9665
|
+
},
|
|
9666
|
+
send: (data) => {
|
|
9667
|
+
ws.send(data);
|
|
9668
|
+
}
|
|
9669
|
+
}, options);
|
|
9670
|
+
bridges.set(ws, bridge);
|
|
9671
|
+
}
|
|
9672
|
+
await bridge.handleMessage(raw);
|
|
9673
|
+
}
|
|
9674
|
+
}).use(createVoiceTelephonyWebhookRoutes({
|
|
9675
|
+
...options.webhook ?? {},
|
|
9676
|
+
context: options.context,
|
|
9677
|
+
path: webhookPath,
|
|
9678
|
+
policy: webhookPolicy,
|
|
9679
|
+
provider: "twilio"
|
|
9680
|
+
}));
|
|
9681
|
+
if (!setupPath) {
|
|
9682
|
+
if (!smokePath) {
|
|
9683
|
+
return app;
|
|
9684
|
+
}
|
|
9685
|
+
return app.get(smokePath, async ({ query, request }) => {
|
|
9686
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
9687
|
+
app,
|
|
9688
|
+
options,
|
|
9689
|
+
query,
|
|
9690
|
+
request,
|
|
9691
|
+
streamPath,
|
|
9692
|
+
twimlPath,
|
|
9693
|
+
webhookPath
|
|
9694
|
+
});
|
|
9695
|
+
if (query.format === "html") {
|
|
9696
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
9697
|
+
headers: {
|
|
9698
|
+
"content-type": "text/html; charset=utf-8"
|
|
9699
|
+
}
|
|
9700
|
+
});
|
|
9701
|
+
}
|
|
9702
|
+
return report;
|
|
9703
|
+
});
|
|
9704
|
+
}
|
|
9705
|
+
const withSetup = app.get(setupPath, async ({ query, request }) => {
|
|
9706
|
+
const status = await buildTwilioVoiceSetupStatus(options, {
|
|
9707
|
+
query,
|
|
9708
|
+
request,
|
|
9709
|
+
streamPath,
|
|
9710
|
+
twimlPath,
|
|
9711
|
+
webhookPath
|
|
9712
|
+
});
|
|
9713
|
+
if (query.format === "html") {
|
|
9714
|
+
return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
|
|
9715
|
+
headers: {
|
|
9716
|
+
"content-type": "text/html; charset=utf-8"
|
|
9717
|
+
}
|
|
9718
|
+
});
|
|
9719
|
+
}
|
|
9720
|
+
return status;
|
|
9721
|
+
});
|
|
9722
|
+
if (!smokePath) {
|
|
9723
|
+
return withSetup;
|
|
9724
|
+
}
|
|
9725
|
+
return withSetup.get(smokePath, async ({ query, request }) => {
|
|
9726
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
9727
|
+
app,
|
|
9728
|
+
options,
|
|
9729
|
+
query,
|
|
9730
|
+
request,
|
|
9731
|
+
streamPath,
|
|
9732
|
+
twimlPath,
|
|
9733
|
+
webhookPath
|
|
9734
|
+
});
|
|
9735
|
+
if (query.format === "html") {
|
|
9736
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
9737
|
+
headers: {
|
|
9738
|
+
"content-type": "text/html; charset=utf-8"
|
|
9739
|
+
}
|
|
9740
|
+
});
|
|
9741
|
+
}
|
|
9742
|
+
return report;
|
|
9743
|
+
});
|
|
9744
|
+
};
|
|
7929
9745
|
|
|
7930
9746
|
// src/testing/telephony.ts
|
|
7931
9747
|
var DEFAULT_PCM16_FORMAT = {
|
|
@@ -8191,7 +10007,7 @@ var runVoiceTelephonyBenchmark = async (scenarios = getDefaultVoiceTelephonyBenc
|
|
|
8191
10007
|
};
|
|
8192
10008
|
};
|
|
8193
10009
|
// src/testing/tts.ts
|
|
8194
|
-
var
|
|
10010
|
+
var DEFAULT_REALTIME_FORMAT2 = {
|
|
8195
10011
|
channels: 1,
|
|
8196
10012
|
container: "raw",
|
|
8197
10013
|
encoding: "pcm_s16le",
|
|
@@ -8250,7 +10066,7 @@ var runTTSAdapterFixture = async (adapter, fixture, options = {}) => {
|
|
|
8250
10066
|
let audioDurationMs = 0;
|
|
8251
10067
|
let audioChunkCount = 0;
|
|
8252
10068
|
const session = adapter.kind === "realtime" ? await adapter.open({
|
|
8253
|
-
format: options.realtimeFormat ??
|
|
10069
|
+
format: options.realtimeFormat ?? DEFAULT_REALTIME_FORMAT2,
|
|
8254
10070
|
sessionId: `tts-benchmark:${fixture.id}`,
|
|
8255
10071
|
...openOptions ?? {}
|
|
8256
10072
|
}) : await adapter.open({
|
|
@@ -8417,6 +10233,7 @@ export {
|
|
|
8417
10233
|
getDefaultTTSBenchmarkFixtures,
|
|
8418
10234
|
evaluateSTTBenchmarkAcceptance,
|
|
8419
10235
|
createVoiceProviderFailureSimulator,
|
|
10236
|
+
createVoiceIOProviderFailureSimulator,
|
|
8420
10237
|
createVoiceCallReviewRecorder,
|
|
8421
10238
|
createVoiceCallReviewFromLiveTelephonyReport,
|
|
8422
10239
|
createTelephonyVoiceTestFixtures,
|