@absolutejs/voice 0.0.22-beta.27 → 0.0.22-beta.270
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 +3234 -228
- package/dist/agent.d.ts +62 -0
- package/dist/agentSquadContract.d.ts +98 -0
- package/dist/angular/index.d.ts +15 -0
- package/dist/angular/index.js +3402 -1150
- 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-routing-status.service.d.ts +11 -0
- package/dist/angular/voice-stream.service.d.ts +3 -0
- package/dist/angular/voice-trace-timeline.service.d.ts +12 -0
- package/dist/angular/voice-turn-latency.service.d.ts +13 -0
- package/dist/angular/voice-turn-quality.service.d.ts +12 -0
- package/dist/angular/voice-workflow-status.service.d.ts +12 -0
- package/dist/audit.d.ts +128 -0
- package/dist/auditDeliveryRoutes.d.ts +85 -0
- package/dist/auditExport.d.ts +34 -0
- package/dist/auditRoutes.d.ts +66 -0
- package/dist/auditSinks.d.ts +151 -0
- package/dist/bargeInRoutes.d.ts +56 -0
- package/dist/campaign.d.ts +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 +66 -0
- package/dist/client/index.js +5073 -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/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 +54 -0
- package/dist/handoffHealth.d.ts +94 -0
- package/dist/incidentBundle.d.ts +116 -0
- package/dist/index.d.ts +132 -13
- package/dist/index.js +25725 -5035
- 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 +23 -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 +210 -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 +484 -0
- package/dist/proofTrends.d.ts +133 -0
- package/dist/providerAdapters.d.ts +48 -0
- package/dist/providerCapabilities.d.ts +92 -0
- package/dist/providerHealth.d.ts +1 -0
- package/dist/providerRoutingContract.d.ts +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/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 +30 -0
- package/dist/react/index.js +4773 -31
- package/dist/react/useVoiceAgentSquadStatus.d.ts +8 -0
- package/dist/react/useVoiceCampaignDialerProof.d.ts +10 -0
- package/dist/react/useVoiceController.d.ts +2 -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/useVoiceRoutingStatus.d.ts +8 -0
- package/dist/react/useVoiceStream.d.ts +2 -0
- package/dist/react/useVoiceTraceTimeline.d.ts +8 -0
- package/dist/react/useVoiceTurnLatency.d.ts +9 -0
- package/dist/react/useVoiceTurnQuality.d.ts +8 -0
- package/dist/react/useVoiceWorkflowStatus.d.ts +8 -0
- package/dist/readinessProfiles.d.ts +37 -0
- package/dist/reconnectContract.d.ts +87 -0
- package/dist/resilienceRoutes.d.ts +143 -0
- package/dist/sessionReplay.d.ts +12 -0
- package/dist/simulationSuite.d.ts +143 -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/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 +16 -0
- package/dist/svelte/index.js +4838 -420
- package/dist/telephony/contract.d.ts +61 -0
- package/dist/telephony/matrix.d.ts +97 -0
- package/dist/telephony/plivo.d.ts +274 -0
- package/dist/telephony/telnyx.d.ts +247 -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 +2047 -42
- 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 +158 -3
- 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/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 +28 -0
- package/dist/vue/index.js +4553 -55
- 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/useVoiceRoutingStatus.d.ts +8 -0
- package/dist/vue/useVoiceStream.d.ts +4 -1
- package/dist/vue/useVoiceTraceTimeline.d.ts +9 -0
- package/dist/vue/useVoiceTurnLatency.d.ts +10 -0
- package/dist/vue/useVoiceTurnQuality.d.ts +9 -0
- package/dist/vue/useVoiceWorkflowStatus.d.ts +9 -0
- package/dist/workflowContract.d.ts +91 -0
- package/package.json +1 -1
package/dist/testing/index.js
CHANGED
|
@@ -2105,6 +2105,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,165 @@ 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 resolveVoiceProviderRoutingPolicyPreset = (preset, options = {}) => {
|
|
3795
|
+
switch (preset) {
|
|
3796
|
+
case "balanced":
|
|
3797
|
+
return {
|
|
3798
|
+
fallbackMode: "provider-error",
|
|
3799
|
+
strategy: "balanced",
|
|
3800
|
+
weights: {
|
|
3801
|
+
cost: 1,
|
|
3802
|
+
latencyMs: 0.005,
|
|
3803
|
+
priority: 1,
|
|
3804
|
+
quality: 10,
|
|
3805
|
+
...options.weights
|
|
3806
|
+
},
|
|
3807
|
+
...options
|
|
3808
|
+
};
|
|
3809
|
+
case "cost-cap":
|
|
3810
|
+
return {
|
|
3811
|
+
fallbackMode: "provider-error",
|
|
3812
|
+
strategy: "prefer-cheapest",
|
|
3813
|
+
...options
|
|
3814
|
+
};
|
|
3815
|
+
case "cost-first":
|
|
3816
|
+
return {
|
|
3817
|
+
fallbackMode: "provider-error",
|
|
3818
|
+
strategy: "prefer-cheapest",
|
|
3819
|
+
...options
|
|
3820
|
+
};
|
|
3821
|
+
case "latency-first":
|
|
3822
|
+
return {
|
|
3823
|
+
fallbackMode: "provider-error",
|
|
3824
|
+
strategy: "prefer-fastest",
|
|
3825
|
+
...options
|
|
3826
|
+
};
|
|
3827
|
+
case "quality-first":
|
|
3828
|
+
return {
|
|
3829
|
+
fallbackMode: "provider-error",
|
|
3830
|
+
strategy: "quality-first",
|
|
3831
|
+
...options
|
|
3832
|
+
};
|
|
3833
|
+
}
|
|
3834
|
+
};
|
|
3514
3835
|
var OUTPUT_SCHEMA = {
|
|
3515
3836
|
additionalProperties: false,
|
|
3516
3837
|
properties: {
|
|
@@ -3601,6 +3922,17 @@ var parseJSONValue = (value) => {
|
|
|
3601
3922
|
return value;
|
|
3602
3923
|
}
|
|
3603
3924
|
};
|
|
3925
|
+
|
|
3926
|
+
class VoiceProviderTimeoutError extends Error {
|
|
3927
|
+
provider;
|
|
3928
|
+
timeoutMs;
|
|
3929
|
+
constructor(provider, timeoutMs) {
|
|
3930
|
+
super(`Voice provider ${provider} exceeded ${timeoutMs}ms latency budget.`);
|
|
3931
|
+
this.name = "VoiceProviderTimeoutError";
|
|
3932
|
+
this.provider = provider;
|
|
3933
|
+
this.timeoutMs = timeoutMs;
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3604
3936
|
var getMessageToolCalls = (message) => {
|
|
3605
3937
|
const toolCalls = message.metadata?.toolCalls;
|
|
3606
3938
|
return Array.isArray(toolCalls) ? toolCalls.filter((toolCall) => toolCall && typeof toolCall === "object" && typeof toolCall.name === "string") : [];
|
|
@@ -3667,7 +3999,7 @@ var createJSONVoiceAssistantModel = (options) => ({
|
|
|
3667
3999
|
var createVoiceProviderRouter = (options) => {
|
|
3668
4000
|
const providerIds = Object.keys(options.providers);
|
|
3669
4001
|
const firstProvider = providerIds[0];
|
|
3670
|
-
const policy = typeof options.policy === "string" ? {
|
|
4002
|
+
const policy = typeof options.policy === "string" ? options.policy === "balanced" || options.policy === "cost-cap" || options.policy === "cost-first" || options.policy === "latency-first" || options.policy === "quality-first" ? resolveVoiceProviderRoutingPolicyPreset(options.policy) : {
|
|
3671
4003
|
strategy: options.policy
|
|
3672
4004
|
} : options.policy;
|
|
3673
4005
|
const strategy = policy?.strategy ?? "prefer-selected";
|
|
@@ -3678,6 +4010,10 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3678
4010
|
const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
|
|
3679
4011
|
const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
|
|
3680
4012
|
const rateLimitCooldownMs = Math.max(0, healthOptions?.rateLimitCooldownMs ?? 60000);
|
|
4013
|
+
const getProviderTimeoutMs = (provider) => {
|
|
4014
|
+
const timeoutMs = options.providerProfiles?.[provider]?.timeoutMs ?? options.timeoutMs;
|
|
4015
|
+
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
|
|
4016
|
+
};
|
|
3681
4017
|
const getHealth = (provider) => {
|
|
3682
4018
|
const existing = healthState.get(provider);
|
|
3683
4019
|
if (existing) {
|
|
@@ -3745,13 +4081,40 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3745
4081
|
const allowed = typeof allowProviders === "function" ? await allowProviders(input) : allowProviders;
|
|
3746
4082
|
return new Set(allowed ?? providerIds);
|
|
3747
4083
|
};
|
|
4084
|
+
const passesBudgetFilters = (provider) => {
|
|
4085
|
+
const profile = options.providerProfiles?.[provider];
|
|
4086
|
+
if (typeof policy?.maxCost === "number" && typeof profile?.cost === "number" && profile.cost > policy.maxCost) {
|
|
4087
|
+
return false;
|
|
4088
|
+
}
|
|
4089
|
+
if (typeof policy?.maxLatencyMs === "number" && typeof profile?.latencyMs === "number" && profile.latencyMs > policy.maxLatencyMs) {
|
|
4090
|
+
return false;
|
|
4091
|
+
}
|
|
4092
|
+
if (typeof policy?.minQuality === "number" && typeof profile?.quality === "number" && profile.quality < policy.minQuality) {
|
|
4093
|
+
return false;
|
|
4094
|
+
}
|
|
4095
|
+
return true;
|
|
4096
|
+
};
|
|
4097
|
+
const getBalancedScore = (provider) => {
|
|
4098
|
+
const profile = options.providerProfiles?.[provider];
|
|
4099
|
+
if (policy?.scoreProvider) {
|
|
4100
|
+
return policy.scoreProvider(provider, profile);
|
|
4101
|
+
}
|
|
4102
|
+
const weights = policy?.weights ?? {};
|
|
4103
|
+
return (profile?.cost ?? Number.MAX_SAFE_INTEGER) * (weights.cost ?? 1) + (profile?.latencyMs ?? Number.MAX_SAFE_INTEGER) * (weights.latencyMs ?? 0.005) + (profile?.priority ?? 0) * (weights.priority ?? 1) - (profile?.quality ?? 0) * (weights.quality ?? 10);
|
|
4104
|
+
};
|
|
3748
4105
|
const sortProviders = (providers) => {
|
|
3749
|
-
if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest") {
|
|
4106
|
+
if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest" && strategy !== "quality-first" && strategy !== "balanced") {
|
|
3750
4107
|
return providers;
|
|
3751
4108
|
}
|
|
3752
4109
|
return [...providers].sort((left, right) => {
|
|
3753
4110
|
const leftProfile = options.providerProfiles?.[left];
|
|
3754
4111
|
const rightProfile = options.providerProfiles?.[right];
|
|
4112
|
+
if (strategy === "quality-first") {
|
|
4113
|
+
return (rightProfile?.quality ?? Number.MIN_SAFE_INTEGER) - (leftProfile?.quality ?? Number.MIN_SAFE_INTEGER) || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.cost ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.cost ?? Number.MAX_SAFE_INTEGER);
|
|
4114
|
+
}
|
|
4115
|
+
if (strategy === "balanced") {
|
|
4116
|
+
return getBalancedScore(left) - getBalancedScore(right);
|
|
4117
|
+
}
|
|
3755
4118
|
const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
3756
4119
|
const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
3757
4120
|
return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
|
|
@@ -3761,12 +4124,13 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3761
4124
|
const selectedProvider = await options.selectProvider?.(input);
|
|
3762
4125
|
const allowedProviders = await resolveAllowedProviders(input);
|
|
3763
4126
|
const fallbackOrder = typeof options.fallback === "function" ? await options.fallback(input) : options.fallback;
|
|
3764
|
-
const
|
|
4127
|
+
const allowedRankedProviders = sortProviders([
|
|
3765
4128
|
...fallbackOrder ?? providerIds
|
|
3766
4129
|
]).filter((provider) => allowedProviders.has(provider));
|
|
4130
|
+
const rankedProviders = allowedRankedProviders.filter(passesBudgetFilters);
|
|
3767
4131
|
const healthyRankedProviders = healthOptions ? rankedProviders.filter((provider) => !isSuppressed(provider)) : rankedProviders;
|
|
3768
4132
|
const candidateRankedProviders = healthyRankedProviders.length ? healthyRankedProviders : rankedProviders;
|
|
3769
|
-
const preferred = selectedProvider && allowedProviders.has(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
|
|
4133
|
+
const preferred = selectedProvider && allowedProviders.has(selectedProvider) && passesBudgetFilters(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
|
|
3770
4134
|
const seen = new Set;
|
|
3771
4135
|
const order = [];
|
|
3772
4136
|
const candidates = strategy === "ordered" ? candidateRankedProviders : [
|
|
@@ -3789,6 +4153,25 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3789
4153
|
const emit = async (event, input) => {
|
|
3790
4154
|
await options.onProviderEvent?.(event, input);
|
|
3791
4155
|
};
|
|
4156
|
+
const runProvider = async (provider, model, input) => {
|
|
4157
|
+
const timeoutMs = getProviderTimeoutMs(provider);
|
|
4158
|
+
if (!timeoutMs) {
|
|
4159
|
+
return model.generate(input);
|
|
4160
|
+
}
|
|
4161
|
+
let timeout;
|
|
4162
|
+
try {
|
|
4163
|
+
return await Promise.race([
|
|
4164
|
+
model.generate(input),
|
|
4165
|
+
new Promise((_, reject) => {
|
|
4166
|
+
timeout = setTimeout(() => reject(new VoiceProviderTimeoutError(provider, timeoutMs)), timeoutMs);
|
|
4167
|
+
})
|
|
4168
|
+
]);
|
|
4169
|
+
} finally {
|
|
4170
|
+
if (timeout) {
|
|
4171
|
+
clearTimeout(timeout);
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4174
|
+
};
|
|
3792
4175
|
return {
|
|
3793
4176
|
generate: async (input) => {
|
|
3794
4177
|
const { order, selectedProvider } = await resolveOrder(input);
|
|
@@ -3803,12 +4186,14 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3803
4186
|
}
|
|
3804
4187
|
const startedAt = Date.now();
|
|
3805
4188
|
try {
|
|
3806
|
-
const output = await model
|
|
4189
|
+
const output = await runProvider(provider, model, input);
|
|
3807
4190
|
const providerHealth = recordProviderSuccess(provider);
|
|
3808
4191
|
await emit({
|
|
3809
4192
|
at: Date.now(),
|
|
4193
|
+
attempt: index + 1,
|
|
3810
4194
|
elapsedMs: Date.now() - startedAt,
|
|
3811
4195
|
fallbackProvider: provider === selectedProvider ? undefined : provider,
|
|
4196
|
+
latencyBudgetMs: getProviderTimeoutMs(provider),
|
|
3812
4197
|
provider,
|
|
3813
4198
|
providerHealth,
|
|
3814
4199
|
recovered: provider !== selectedProvider,
|
|
@@ -3820,22 +4205,26 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3820
4205
|
lastError = error;
|
|
3821
4206
|
const hasNextProvider = index < order.length - 1;
|
|
3822
4207
|
const isProviderError = options.isProviderError?.(error, provider) ?? true;
|
|
4208
|
+
const timedOut = options.isTimeoutError?.(error, provider) ?? error instanceof VoiceProviderTimeoutError;
|
|
3823
4209
|
const rateLimited = options.isRateLimitError?.(error, provider) ?? defaultIsRateLimitError(error);
|
|
3824
4210
|
const shouldFallback = fallbackMode === "provider-error" ? isProviderError : fallbackMode === "rate-limit" ? isProviderError && rateLimited : false;
|
|
3825
4211
|
const providerHealth = recordProviderError(provider, isProviderError, rateLimited);
|
|
3826
4212
|
const nextProvider = hasNextProvider ? order[index + 1] : undefined;
|
|
3827
4213
|
await emit({
|
|
3828
4214
|
at: Date.now(),
|
|
4215
|
+
attempt: index + 1,
|
|
3829
4216
|
elapsedMs: Date.now() - startedAt,
|
|
3830
4217
|
error: errorMessage(error),
|
|
3831
4218
|
fallbackProvider: shouldFallback ? nextProvider : undefined,
|
|
4219
|
+
latencyBudgetMs: getProviderTimeoutMs(provider),
|
|
3832
4220
|
provider,
|
|
3833
4221
|
providerHealth,
|
|
3834
4222
|
rateLimited,
|
|
3835
4223
|
selectedProvider,
|
|
3836
4224
|
suppressionRemainingMs: getSuppressionRemainingMs(provider),
|
|
3837
4225
|
suppressedUntil: providerHealth?.suppressedUntil,
|
|
3838
|
-
status: "error"
|
|
4226
|
+
status: "error",
|
|
4227
|
+
timedOut
|
|
3839
4228
|
}, input);
|
|
3840
4229
|
if (!hasNextProvider || !shouldFallback) {
|
|
3841
4230
|
throw error;
|
|
@@ -4457,7 +4846,290 @@ var createVoiceMemoryStore = () => {
|
|
|
4457
4846
|
};
|
|
4458
4847
|
|
|
4459
4848
|
// src/session.ts
|
|
4460
|
-
import { Buffer } from "buffer";
|
|
4849
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
4850
|
+
|
|
4851
|
+
// src/handoff.ts
|
|
4852
|
+
var toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
4853
|
+
var signHandoffBody = async (input) => {
|
|
4854
|
+
const encoder = new TextEncoder;
|
|
4855
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(input.secret), {
|
|
4856
|
+
hash: "SHA-256",
|
|
4857
|
+
name: "HMAC"
|
|
4858
|
+
}, false, ["sign"]);
|
|
4859
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(`${input.timestamp}.${input.body}`));
|
|
4860
|
+
return `sha256=${toHex(new Uint8Array(signature))}`;
|
|
4861
|
+
};
|
|
4862
|
+
var toErrorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
4863
|
+
var createSkippedDelivery = (adapter) => ({
|
|
4864
|
+
adapterId: adapter.id,
|
|
4865
|
+
adapterKind: adapter.kind,
|
|
4866
|
+
status: "skipped"
|
|
4867
|
+
});
|
|
4868
|
+
var aggregateHandoffStatus = (deliveries) => {
|
|
4869
|
+
const statuses = Object.values(deliveries).map((delivery) => delivery.status);
|
|
4870
|
+
if (statuses.some((status) => status === "failed")) {
|
|
4871
|
+
return "failed";
|
|
4872
|
+
}
|
|
4873
|
+
if (statuses.some((status) => status === "delivered")) {
|
|
4874
|
+
return "delivered";
|
|
4875
|
+
}
|
|
4876
|
+
return "skipped";
|
|
4877
|
+
};
|
|
4878
|
+
var createHandoffDeliveryId = (input) => [
|
|
4879
|
+
"voice-handoff",
|
|
4880
|
+
input.sessionId,
|
|
4881
|
+
input.action,
|
|
4882
|
+
Date.now(),
|
|
4883
|
+
crypto.randomUUID()
|
|
4884
|
+
].join(":");
|
|
4885
|
+
var resolveHandoffDeliveryError = (deliveries) => Object.values(deliveries).map((delivery) => delivery.error).find(Boolean);
|
|
4886
|
+
var defaultWebhookBody = (input) => ({
|
|
4887
|
+
action: input.action,
|
|
4888
|
+
metadata: input.metadata,
|
|
4889
|
+
reason: input.reason,
|
|
4890
|
+
result: input.result,
|
|
4891
|
+
session: {
|
|
4892
|
+
id: input.session.id,
|
|
4893
|
+
scenarioId: input.session.scenarioId,
|
|
4894
|
+
status: input.session.status
|
|
4895
|
+
},
|
|
4896
|
+
source: "absolutejs-voice",
|
|
4897
|
+
target: input.target
|
|
4898
|
+
});
|
|
4899
|
+
var deliverVoiceHandoff = async (input) => {
|
|
4900
|
+
if (!input.config || input.config.adapters.length === 0) {
|
|
4901
|
+
return;
|
|
4902
|
+
}
|
|
4903
|
+
const deliveries = {};
|
|
4904
|
+
for (const adapter of input.config.adapters) {
|
|
4905
|
+
if (adapter.actions && !adapter.actions.includes(input.handoff.action)) {
|
|
4906
|
+
deliveries[adapter.id] = createSkippedDelivery(adapter);
|
|
4907
|
+
continue;
|
|
4908
|
+
}
|
|
4909
|
+
try {
|
|
4910
|
+
const result = await adapter.handoff(input.handoff);
|
|
4911
|
+
deliveries[adapter.id] = {
|
|
4912
|
+
...result,
|
|
4913
|
+
adapterId: adapter.id,
|
|
4914
|
+
adapterKind: adapter.kind
|
|
4915
|
+
};
|
|
4916
|
+
} catch (error) {
|
|
4917
|
+
deliveries[adapter.id] = {
|
|
4918
|
+
adapterId: adapter.id,
|
|
4919
|
+
adapterKind: adapter.kind,
|
|
4920
|
+
error: toErrorMessage(error),
|
|
4921
|
+
status: "failed"
|
|
4922
|
+
};
|
|
4923
|
+
if (input.config.failMode === "throw") {
|
|
4924
|
+
throw error;
|
|
4925
|
+
}
|
|
4926
|
+
}
|
|
4927
|
+
}
|
|
4928
|
+
return {
|
|
4929
|
+
action: input.handoff.action,
|
|
4930
|
+
deliveries,
|
|
4931
|
+
status: aggregateHandoffStatus(deliveries)
|
|
4932
|
+
};
|
|
4933
|
+
};
|
|
4934
|
+
var createVoiceHandoffDeliveryRecord = (input) => {
|
|
4935
|
+
const now = Date.now();
|
|
4936
|
+
return {
|
|
4937
|
+
action: input.action,
|
|
4938
|
+
context: input.context,
|
|
4939
|
+
createdAt: now,
|
|
4940
|
+
deliveryAttempts: 0,
|
|
4941
|
+
deliveryStatus: "pending",
|
|
4942
|
+
id: input.id ?? createHandoffDeliveryId({
|
|
4943
|
+
action: input.action,
|
|
4944
|
+
sessionId: input.session.id
|
|
4945
|
+
}),
|
|
4946
|
+
metadata: input.metadata,
|
|
4947
|
+
reason: input.reason,
|
|
4948
|
+
result: input.result,
|
|
4949
|
+
session: input.session,
|
|
4950
|
+
sessionId: input.session.id,
|
|
4951
|
+
target: input.target,
|
|
4952
|
+
updatedAt: now
|
|
4953
|
+
};
|
|
4954
|
+
};
|
|
4955
|
+
var applyVoiceHandoffDeliveryResult = (delivery, result) => ({
|
|
4956
|
+
...delivery,
|
|
4957
|
+
deliveredAt: result.status === "delivered" || result.status === "skipped" ? Date.now() : delivery.deliveredAt,
|
|
4958
|
+
deliveries: result.deliveries,
|
|
4959
|
+
deliveryAttempts: (delivery.deliveryAttempts ?? 0) + 1,
|
|
4960
|
+
deliveryError: result.status === "failed" ? resolveHandoffDeliveryError(result.deliveries) : undefined,
|
|
4961
|
+
deliveryStatus: result.status,
|
|
4962
|
+
updatedAt: Date.now()
|
|
4963
|
+
});
|
|
4964
|
+
var deliverVoiceHandoffDelivery = async (options) => {
|
|
4965
|
+
const result = await deliverVoiceHandoff({
|
|
4966
|
+
config: {
|
|
4967
|
+
adapters: options.adapters,
|
|
4968
|
+
failMode: options.failMode
|
|
4969
|
+
},
|
|
4970
|
+
handoff: {
|
|
4971
|
+
action: options.delivery.action,
|
|
4972
|
+
api: options.api,
|
|
4973
|
+
context: options.delivery.context,
|
|
4974
|
+
metadata: options.delivery.metadata,
|
|
4975
|
+
reason: options.delivery.reason,
|
|
4976
|
+
result: options.delivery.result,
|
|
4977
|
+
session: options.delivery.session,
|
|
4978
|
+
target: options.delivery.target
|
|
4979
|
+
}
|
|
4980
|
+
});
|
|
4981
|
+
return result ? applyVoiceHandoffDeliveryResult(options.delivery, result) : {
|
|
4982
|
+
...options.delivery,
|
|
4983
|
+
deliveryAttempts: (options.delivery.deliveryAttempts ?? 0) + 1,
|
|
4984
|
+
deliveryStatus: "skipped",
|
|
4985
|
+
updatedAt: Date.now()
|
|
4986
|
+
};
|
|
4987
|
+
};
|
|
4988
|
+
var createVoiceMemoryHandoffDeliveryStore = () => {
|
|
4989
|
+
const deliveries = new Map;
|
|
4990
|
+
return {
|
|
4991
|
+
get: async (id) => deliveries.get(id),
|
|
4992
|
+
list: async () => [...deliveries.values()].sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)),
|
|
4993
|
+
remove: async (id) => {
|
|
4994
|
+
deliveries.delete(id);
|
|
4995
|
+
},
|
|
4996
|
+
set: async (id, delivery) => {
|
|
4997
|
+
deliveries.set(id, delivery);
|
|
4998
|
+
}
|
|
4999
|
+
};
|
|
5000
|
+
};
|
|
5001
|
+
var createVoiceWebhookHandoffAdapter = (options) => ({
|
|
5002
|
+
actions: options.actions,
|
|
5003
|
+
handoff: async (input) => {
|
|
5004
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
5005
|
+
if (typeof fetchImpl !== "function") {
|
|
5006
|
+
return {
|
|
5007
|
+
deliveredTo: options.url,
|
|
5008
|
+
error: "Handoff delivery failed: fetch is not available in this runtime.",
|
|
5009
|
+
status: "failed"
|
|
5010
|
+
};
|
|
5011
|
+
}
|
|
5012
|
+
const body = JSON.stringify(await options.body?.(input) ?? defaultWebhookBody(input));
|
|
5013
|
+
const headers = {
|
|
5014
|
+
"content-type": "application/json",
|
|
5015
|
+
...options.headers
|
|
5016
|
+
};
|
|
5017
|
+
if (options.signingSecret) {
|
|
5018
|
+
const timestamp = String(Date.now());
|
|
5019
|
+
headers["x-absolutejs-timestamp"] = timestamp;
|
|
5020
|
+
headers["x-absolutejs-signature"] = await signHandoffBody({
|
|
5021
|
+
body,
|
|
5022
|
+
secret: options.signingSecret,
|
|
5023
|
+
timestamp
|
|
5024
|
+
});
|
|
5025
|
+
}
|
|
5026
|
+
const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
|
|
5027
|
+
const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
|
|
5028
|
+
try {
|
|
5029
|
+
const response = await fetchImpl(options.url, {
|
|
5030
|
+
body,
|
|
5031
|
+
headers,
|
|
5032
|
+
method: options.method ?? "POST",
|
|
5033
|
+
signal: controller?.signal
|
|
5034
|
+
});
|
|
5035
|
+
if (!response.ok) {
|
|
5036
|
+
return {
|
|
5037
|
+
deliveredTo: options.url,
|
|
5038
|
+
error: `Handoff delivery failed with response ${response.status}.`,
|
|
5039
|
+
status: "failed"
|
|
5040
|
+
};
|
|
5041
|
+
}
|
|
5042
|
+
return {
|
|
5043
|
+
deliveredAt: Date.now(),
|
|
5044
|
+
deliveredTo: options.url,
|
|
5045
|
+
status: "delivered"
|
|
5046
|
+
};
|
|
5047
|
+
} finally {
|
|
5048
|
+
if (timeout) {
|
|
5049
|
+
clearTimeout(timeout);
|
|
5050
|
+
}
|
|
5051
|
+
}
|
|
5052
|
+
},
|
|
5053
|
+
id: options.id,
|
|
5054
|
+
kind: options.kind ?? "webhook"
|
|
5055
|
+
});
|
|
5056
|
+
var escapeXml = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
5057
|
+
var defaultTwilioTransferTwiML = (input) => {
|
|
5058
|
+
if (!input.target) {
|
|
5059
|
+
return "<Response><Hangup /></Response>";
|
|
5060
|
+
}
|
|
5061
|
+
return `<Response><Dial>${escapeXml(input.target)}</Dial></Response>`;
|
|
5062
|
+
};
|
|
5063
|
+
var resolveTwilioCallSid = async (resolver, input) => {
|
|
5064
|
+
if (typeof resolver === "function") {
|
|
5065
|
+
return resolver(input);
|
|
5066
|
+
}
|
|
5067
|
+
if (typeof resolver === "string" && resolver.length > 0) {
|
|
5068
|
+
return resolver;
|
|
5069
|
+
}
|
|
5070
|
+
const metadataSid = typeof input.metadata?.callSid === "string" ? input.metadata.callSid : undefined;
|
|
5071
|
+
const sessionMetadata = input.session.metadata && typeof input.session.metadata === "object" ? input.session.metadata : undefined;
|
|
5072
|
+
const sessionSid = typeof sessionMetadata?.callSid === "string" ? sessionMetadata.callSid : undefined;
|
|
5073
|
+
return metadataSid ?? sessionSid;
|
|
5074
|
+
};
|
|
5075
|
+
var createVoiceTwilioRedirectHandoffAdapter = (options) => ({
|
|
5076
|
+
actions: options.actions ?? ["transfer"],
|
|
5077
|
+
handoff: async (input) => {
|
|
5078
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
5079
|
+
const callSid = await resolveTwilioCallSid(options.callSid, input);
|
|
5080
|
+
if (!callSid) {
|
|
5081
|
+
return {
|
|
5082
|
+
error: "Twilio handoff requires a callSid.",
|
|
5083
|
+
status: "failed"
|
|
5084
|
+
};
|
|
5085
|
+
}
|
|
5086
|
+
if (typeof fetchImpl !== "function") {
|
|
5087
|
+
return {
|
|
5088
|
+
error: "Twilio handoff failed: fetch is not available in this runtime.",
|
|
5089
|
+
status: "failed"
|
|
5090
|
+
};
|
|
5091
|
+
}
|
|
5092
|
+
const url = `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(options.accountSid)}/Calls/${encodeURIComponent(callSid)}.json`;
|
|
5093
|
+
const body = new URLSearchParams({
|
|
5094
|
+
Twiml: await (options.buildTwiML?.(input) ?? defaultTwilioTransferTwiML(input))
|
|
5095
|
+
});
|
|
5096
|
+
const auth = btoa(`${options.accountSid}:${options.authToken}`);
|
|
5097
|
+
const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
|
|
5098
|
+
const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
|
|
5099
|
+
try {
|
|
5100
|
+
const response = await fetchImpl(url, {
|
|
5101
|
+
body,
|
|
5102
|
+
headers: {
|
|
5103
|
+
authorization: `Basic ${auth}`,
|
|
5104
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
5105
|
+
},
|
|
5106
|
+
method: "POST",
|
|
5107
|
+
signal: controller?.signal
|
|
5108
|
+
});
|
|
5109
|
+
if (!response.ok) {
|
|
5110
|
+
return {
|
|
5111
|
+
deliveredTo: url,
|
|
5112
|
+
error: `Twilio handoff failed with response ${response.status}.`,
|
|
5113
|
+
status: "failed"
|
|
5114
|
+
};
|
|
5115
|
+
}
|
|
5116
|
+
return {
|
|
5117
|
+
deliveredAt: Date.now(),
|
|
5118
|
+
deliveredTo: url,
|
|
5119
|
+
metadata: {
|
|
5120
|
+
callSid
|
|
5121
|
+
},
|
|
5122
|
+
status: "delivered"
|
|
5123
|
+
};
|
|
5124
|
+
} finally {
|
|
5125
|
+
if (timeout) {
|
|
5126
|
+
clearTimeout(timeout);
|
|
5127
|
+
}
|
|
5128
|
+
}
|
|
5129
|
+
},
|
|
5130
|
+
id: options.id ?? "twilio-redirect",
|
|
5131
|
+
kind: "twilio-redirect"
|
|
5132
|
+
});
|
|
4461
5133
|
|
|
4462
5134
|
// src/logger.ts
|
|
4463
5135
|
var noop2 = () => {};
|
|
@@ -4493,6 +5165,12 @@ var DEFAULT_FORMAT = {
|
|
|
4493
5165
|
encoding: "pcm_s16le",
|
|
4494
5166
|
sampleRateHz: 16000
|
|
4495
5167
|
};
|
|
5168
|
+
var DEFAULT_REALTIME_FORMAT = {
|
|
5169
|
+
channels: 1,
|
|
5170
|
+
container: "raw",
|
|
5171
|
+
encoding: "pcm_s16le",
|
|
5172
|
+
sampleRateHz: 24000
|
|
5173
|
+
};
|
|
4496
5174
|
var toError = (value) => value instanceof Error ? value : new Error(String(value));
|
|
4497
5175
|
var createEmptyCurrentTurn = () => ({
|
|
4498
5176
|
finalText: "",
|
|
@@ -4505,7 +5183,7 @@ var createEmptyCurrentTurn = () => ({
|
|
|
4505
5183
|
transcripts: []
|
|
4506
5184
|
});
|
|
4507
5185
|
var cloneTranscript = (transcript) => ({ ...transcript });
|
|
4508
|
-
var encodeBase64 = (chunk) =>
|
|
5186
|
+
var encodeBase64 = (chunk) => Buffer2.from(chunk).toString("base64");
|
|
4509
5187
|
var countWords2 = (text) => text.trim().split(/\s+/).filter(Boolean).length;
|
|
4510
5188
|
var normalizeText2 = (text) => text.trim().replace(/\s+/g, " ");
|
|
4511
5189
|
var getAudioChunkDurationMs = (chunk) => chunk.byteLength / (DEFAULT_FORMAT.sampleRateHz * DEFAULT_FORMAT.channels * 2) * 1000;
|
|
@@ -4676,7 +5354,7 @@ var createVoiceSession = (options) => {
|
|
|
4676
5354
|
} : undefined;
|
|
4677
5355
|
const appendTrace = async (input) => {
|
|
4678
5356
|
await options.trace?.append({
|
|
4679
|
-
at: Date.now(),
|
|
5357
|
+
at: input.at ?? Date.now(),
|
|
4680
5358
|
metadata: input.metadata,
|
|
4681
5359
|
payload: input.payload,
|
|
4682
5360
|
scenarioId: input.session?.scenarioId ?? options.scenarioId,
|
|
@@ -4685,6 +5363,13 @@ var createVoiceSession = (options) => {
|
|
|
4685
5363
|
type: input.type
|
|
4686
5364
|
});
|
|
4687
5365
|
};
|
|
5366
|
+
const appendTurnLatencyStage = async (input) => appendTrace({
|
|
5367
|
+
at: input.at,
|
|
5368
|
+
payload: { stage: input.stage },
|
|
5369
|
+
session: input.session,
|
|
5370
|
+
turnId: input.turnId,
|
|
5371
|
+
type: "turn_latency.stage"
|
|
5372
|
+
});
|
|
4688
5373
|
const phraseHints = options.phraseHints ?? [];
|
|
4689
5374
|
const lexicon = options.lexicon ?? [];
|
|
4690
5375
|
let socket = options.socket;
|
|
@@ -4763,6 +5448,65 @@ var createVoiceSession = (options) => {
|
|
|
4763
5448
|
type: "call_lifecycle"
|
|
4764
5449
|
});
|
|
4765
5450
|
};
|
|
5451
|
+
const sendReplay = async (session) => {
|
|
5452
|
+
await send({
|
|
5453
|
+
assistantTexts: session.turns.flatMap((turn) => turn.assistantText ? [turn.assistantText] : []),
|
|
5454
|
+
call: session.call,
|
|
5455
|
+
partial: session.currentTurn.partialText,
|
|
5456
|
+
scenarioId: session.scenarioId,
|
|
5457
|
+
sessionId: options.id,
|
|
5458
|
+
status: session.status,
|
|
5459
|
+
turns: session.turns,
|
|
5460
|
+
type: "replay"
|
|
5461
|
+
});
|
|
5462
|
+
};
|
|
5463
|
+
const runHandoff = async (input) => {
|
|
5464
|
+
const queuedDelivery = options.handoff?.deliveryQueue ? createVoiceHandoffDeliveryRecord({
|
|
5465
|
+
action: input.action,
|
|
5466
|
+
context: options.context,
|
|
5467
|
+
metadata: input.metadata,
|
|
5468
|
+
reason: input.reason,
|
|
5469
|
+
result: input.result,
|
|
5470
|
+
session: input.session,
|
|
5471
|
+
target: input.target
|
|
5472
|
+
}) : undefined;
|
|
5473
|
+
if (queuedDelivery) {
|
|
5474
|
+
await options.handoff?.deliveryQueue?.set(queuedDelivery.id, queuedDelivery);
|
|
5475
|
+
}
|
|
5476
|
+
if (options.handoff?.enqueueOnly) {
|
|
5477
|
+
return;
|
|
5478
|
+
}
|
|
5479
|
+
const result = await deliverVoiceHandoff({
|
|
5480
|
+
config: options.handoff,
|
|
5481
|
+
handoff: {
|
|
5482
|
+
action: input.action,
|
|
5483
|
+
api,
|
|
5484
|
+
context: options.context,
|
|
5485
|
+
metadata: input.metadata,
|
|
5486
|
+
reason: input.reason,
|
|
5487
|
+
result: input.result,
|
|
5488
|
+
session: input.session,
|
|
5489
|
+
target: input.target
|
|
5490
|
+
}
|
|
5491
|
+
});
|
|
5492
|
+
if (!result) {
|
|
5493
|
+
return;
|
|
5494
|
+
}
|
|
5495
|
+
if (queuedDelivery) {
|
|
5496
|
+
const updatedDelivery = applyVoiceHandoffDeliveryResult(queuedDelivery, result);
|
|
5497
|
+
await options.handoff?.deliveryQueue?.set(updatedDelivery.id, updatedDelivery);
|
|
5498
|
+
}
|
|
5499
|
+
await appendTrace({
|
|
5500
|
+
metadata: input.metadata,
|
|
5501
|
+
payload: {
|
|
5502
|
+
...result,
|
|
5503
|
+
reason: input.reason,
|
|
5504
|
+
target: input.target
|
|
5505
|
+
},
|
|
5506
|
+
session: input.session,
|
|
5507
|
+
type: "call.handoff"
|
|
5508
|
+
});
|
|
5509
|
+
};
|
|
4766
5510
|
const readSession = async () => options.store.getOrCreate(options.id);
|
|
4767
5511
|
const writeSession = async (mutate) => {
|
|
4768
5512
|
const session = await options.store.getOrCreate(options.id);
|
|
@@ -4819,6 +5563,23 @@ var createVoiceSession = (options) => {
|
|
|
4819
5563
|
});
|
|
4820
5564
|
}
|
|
4821
5565
|
};
|
|
5566
|
+
const sendAssistantAudio = async (chunk, input) => {
|
|
5567
|
+
const normalizedChunk = chunk instanceof Uint8Array ? new Uint8Array(chunk) : chunk instanceof ArrayBuffer ? new Uint8Array(chunk.slice(0)) : new Uint8Array(chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength));
|
|
5568
|
+
await send({
|
|
5569
|
+
chunkBase64: encodeBase64(normalizedChunk),
|
|
5570
|
+
format: input.format,
|
|
5571
|
+
receivedAt: input.receivedAt,
|
|
5572
|
+
turnId: activeTTSTurnId,
|
|
5573
|
+
type: "audio"
|
|
5574
|
+
});
|
|
5575
|
+
if (activeTTSTurnId) {
|
|
5576
|
+
await appendTurnLatencyStage({
|
|
5577
|
+
at: input.receivedAt,
|
|
5578
|
+
stage: "assistant_audio_received",
|
|
5579
|
+
turnId: activeTTSTurnId
|
|
5580
|
+
});
|
|
5581
|
+
}
|
|
5582
|
+
};
|
|
4822
5583
|
const scheduleTurnCommit = (delayMs, reason, reset = true) => {
|
|
4823
5584
|
if (!reset && silenceTimer) {
|
|
4824
5585
|
return;
|
|
@@ -5042,6 +5803,14 @@ var createVoiceSession = (options) => {
|
|
|
5042
5803
|
type: "call.lifecycle"
|
|
5043
5804
|
});
|
|
5044
5805
|
await sendCallLifecycle(session);
|
|
5806
|
+
await runHandoff({
|
|
5807
|
+
action: "transfer",
|
|
5808
|
+
metadata: input.metadata,
|
|
5809
|
+
reason: input.reason,
|
|
5810
|
+
result: input.result,
|
|
5811
|
+
session,
|
|
5812
|
+
target: input.target
|
|
5813
|
+
});
|
|
5045
5814
|
await completeInternal(input.result, {
|
|
5046
5815
|
disposition: "transferred",
|
|
5047
5816
|
invokeOnComplete: false,
|
|
@@ -5068,6 +5837,13 @@ var createVoiceSession = (options) => {
|
|
|
5068
5837
|
type: "call.lifecycle"
|
|
5069
5838
|
});
|
|
5070
5839
|
await sendCallLifecycle(session);
|
|
5840
|
+
await runHandoff({
|
|
5841
|
+
action: "escalate",
|
|
5842
|
+
metadata: input.metadata,
|
|
5843
|
+
reason: input.reason,
|
|
5844
|
+
result: input.result,
|
|
5845
|
+
session
|
|
5846
|
+
});
|
|
5071
5847
|
await completeInternal(input.result, {
|
|
5072
5848
|
disposition: "escalated",
|
|
5073
5849
|
invokeOnComplete: false,
|
|
@@ -5091,7 +5867,13 @@ var createVoiceSession = (options) => {
|
|
|
5091
5867
|
type: "call.lifecycle"
|
|
5092
5868
|
});
|
|
5093
5869
|
await sendCallLifecycle(session);
|
|
5094
|
-
await
|
|
5870
|
+
await runHandoff({
|
|
5871
|
+
action: "no-answer",
|
|
5872
|
+
metadata: input?.metadata,
|
|
5873
|
+
result: input?.result,
|
|
5874
|
+
session
|
|
5875
|
+
});
|
|
5876
|
+
await completeInternal(input?.result, {
|
|
5095
5877
|
disposition: "no-answer",
|
|
5096
5878
|
invokeOnComplete: false,
|
|
5097
5879
|
metadata: input?.metadata
|
|
@@ -5113,6 +5895,12 @@ var createVoiceSession = (options) => {
|
|
|
5113
5895
|
type: "call.lifecycle"
|
|
5114
5896
|
});
|
|
5115
5897
|
await sendCallLifecycle(session);
|
|
5898
|
+
await runHandoff({
|
|
5899
|
+
action: "voicemail",
|
|
5900
|
+
metadata: input?.metadata,
|
|
5901
|
+
result: input?.result,
|
|
5902
|
+
session
|
|
5903
|
+
});
|
|
5116
5904
|
await completeInternal(input?.result, {
|
|
5117
5905
|
disposition: "voicemail",
|
|
5118
5906
|
invokeOnComplete: false,
|
|
@@ -5493,8 +6281,12 @@ var createVoiceSession = (options) => {
|
|
|
5493
6281
|
if (sttSession) {
|
|
5494
6282
|
return sttSession;
|
|
5495
6283
|
}
|
|
5496
|
-
const
|
|
5497
|
-
|
|
6284
|
+
const inputAdapter = options.realtime ?? options.stt;
|
|
6285
|
+
if (!inputAdapter) {
|
|
6286
|
+
throw new Error("Voice session requires either an stt or realtime adapter.");
|
|
6287
|
+
}
|
|
6288
|
+
const openedSession = await inputAdapter.open({
|
|
6289
|
+
format: options.realtime ? options.realtimeInputFormat ?? DEFAULT_REALTIME_FORMAT : DEFAULT_FORMAT,
|
|
5498
6290
|
languageStrategy: options.languageStrategy,
|
|
5499
6291
|
lexicon,
|
|
5500
6292
|
phraseHints,
|
|
@@ -5529,6 +6321,16 @@ var createVoiceSession = (options) => {
|
|
|
5529
6321
|
openedSession.on("close", (event) => {
|
|
5530
6322
|
runAdapterEvent("adapter.close", () => handleClose(event));
|
|
5531
6323
|
});
|
|
6324
|
+
if (options.realtime) {
|
|
6325
|
+
openedSession.on("audio", ({ chunk, format, receivedAt }) => {
|
|
6326
|
+
runAdapterEvent("adapter.audio", async () => {
|
|
6327
|
+
await sendAssistantAudio(chunk, {
|
|
6328
|
+
format,
|
|
6329
|
+
receivedAt
|
|
6330
|
+
});
|
|
6331
|
+
});
|
|
6332
|
+
});
|
|
6333
|
+
}
|
|
5532
6334
|
return openedSession;
|
|
5533
6335
|
};
|
|
5534
6336
|
const ensureTTSSession = async () => {
|
|
@@ -5553,13 +6355,9 @@ var createVoiceSession = (options) => {
|
|
|
5553
6355
|
if (ttsSession !== openedSession) {
|
|
5554
6356
|
return;
|
|
5555
6357
|
}
|
|
5556
|
-
|
|
5557
|
-
await send({
|
|
5558
|
-
chunkBase64: encodeBase64(normalizedChunk),
|
|
6358
|
+
await sendAssistantAudio(chunk, {
|
|
5559
6359
|
format,
|
|
5560
|
-
receivedAt
|
|
5561
|
-
turnId: activeTTSTurnId,
|
|
5562
|
-
type: "audio"
|
|
6360
|
+
receivedAt
|
|
5563
6361
|
});
|
|
5564
6362
|
});
|
|
5565
6363
|
});
|
|
@@ -5603,9 +6401,32 @@ var createVoiceSession = (options) => {
|
|
|
5603
6401
|
});
|
|
5604
6402
|
};
|
|
5605
6403
|
const completeTurn = async (session, turn) => {
|
|
6404
|
+
const liveOpsControl = await options.liveOps?.getControl(options.id);
|
|
6405
|
+
if (liveOpsControl?.assistantPaused || liveOpsControl?.operatorTakeover) {
|
|
6406
|
+
await appendTrace({
|
|
6407
|
+
metadata: {
|
|
6408
|
+
source: "voice-live-ops"
|
|
6409
|
+
},
|
|
6410
|
+
payload: {
|
|
6411
|
+
action: "turn.skipped",
|
|
6412
|
+
control: liveOpsControl,
|
|
6413
|
+
reason: liveOpsControl.operatorTakeover ? "operator-takeover" : "assistant-paused",
|
|
6414
|
+
status: "skipped"
|
|
6415
|
+
},
|
|
6416
|
+
session,
|
|
6417
|
+
turnId: turn.id,
|
|
6418
|
+
type: "operator.action"
|
|
6419
|
+
});
|
|
6420
|
+
return;
|
|
6421
|
+
}
|
|
6422
|
+
const injectedInstruction = liveOpsControl?.injectedInstruction?.trim();
|
|
5606
6423
|
const committedOutput = await options.route.onTurn({
|
|
5607
6424
|
api,
|
|
5608
6425
|
context: options.context,
|
|
6426
|
+
liveOps: liveOpsControl ? {
|
|
6427
|
+
control: liveOpsControl,
|
|
6428
|
+
injectedInstruction
|
|
6429
|
+
} : undefined,
|
|
5609
6430
|
session,
|
|
5610
6431
|
turn
|
|
5611
6432
|
});
|
|
@@ -5619,6 +6440,7 @@ var createVoiceSession = (options) => {
|
|
|
5619
6440
|
voicemail: committedOutput?.voicemail
|
|
5620
6441
|
};
|
|
5621
6442
|
if (output?.assistantText) {
|
|
6443
|
+
const assistantTextStartedAt = Date.now();
|
|
5622
6444
|
await writeSession((currentSession) => {
|
|
5623
6445
|
setTurnResult(currentSession, turn.id, {
|
|
5624
6446
|
assistantText: output.assistantText
|
|
@@ -5629,10 +6451,17 @@ var createVoiceSession = (options) => {
|
|
|
5629
6451
|
turnId: turn.id,
|
|
5630
6452
|
type: "assistant"
|
|
5631
6453
|
});
|
|
6454
|
+
await appendTurnLatencyStage({
|
|
6455
|
+
at: assistantTextStartedAt,
|
|
6456
|
+
session,
|
|
6457
|
+
stage: "assistant_text_started",
|
|
6458
|
+
turnId: turn.id
|
|
6459
|
+
});
|
|
5632
6460
|
await appendTrace({
|
|
5633
6461
|
payload: {
|
|
5634
6462
|
text: output.assistantText,
|
|
5635
|
-
ttsConfigured: Boolean(options.tts)
|
|
6463
|
+
ttsConfigured: Boolean(options.tts),
|
|
6464
|
+
realtimeConfigured: Boolean(options.realtime)
|
|
5636
6465
|
},
|
|
5637
6466
|
session,
|
|
5638
6467
|
turnId: turn.id,
|
|
@@ -5643,7 +6472,18 @@ var createVoiceSession = (options) => {
|
|
|
5643
6472
|
if (activeTTSSession) {
|
|
5644
6473
|
const ttsStartedAt = Date.now();
|
|
5645
6474
|
activeTTSTurnId = turn.id;
|
|
6475
|
+
await appendTurnLatencyStage({
|
|
6476
|
+
at: ttsStartedAt,
|
|
6477
|
+
session,
|
|
6478
|
+
stage: "tts_send_started",
|
|
6479
|
+
turnId: turn.id
|
|
6480
|
+
});
|
|
5646
6481
|
await activeTTSSession.send(output.assistantText);
|
|
6482
|
+
await appendTurnLatencyStage({
|
|
6483
|
+
session,
|
|
6484
|
+
stage: "tts_send_completed",
|
|
6485
|
+
turnId: turn.id
|
|
6486
|
+
});
|
|
5647
6487
|
await appendTrace({
|
|
5648
6488
|
payload: {
|
|
5649
6489
|
elapsedMs: Date.now() - ttsStartedAt,
|
|
@@ -5653,9 +6493,35 @@ var createVoiceSession = (options) => {
|
|
|
5653
6493
|
turnId: turn.id,
|
|
5654
6494
|
type: "turn.assistant"
|
|
5655
6495
|
});
|
|
6496
|
+
} else if (options.realtime) {
|
|
6497
|
+
const activeRealtimeSession = await ensureAdapter();
|
|
6498
|
+
const realtimeStartedAt = Date.now();
|
|
6499
|
+
activeTTSTurnId = turn.id;
|
|
6500
|
+
await appendTurnLatencyStage({
|
|
6501
|
+
at: realtimeStartedAt,
|
|
6502
|
+
session,
|
|
6503
|
+
stage: "tts_send_started",
|
|
6504
|
+
turnId: turn.id
|
|
6505
|
+
});
|
|
6506
|
+
await activeRealtimeSession.send(output.assistantText);
|
|
6507
|
+
await appendTurnLatencyStage({
|
|
6508
|
+
session,
|
|
6509
|
+
stage: "tts_send_completed",
|
|
6510
|
+
turnId: turn.id
|
|
6511
|
+
});
|
|
6512
|
+
await appendTrace({
|
|
6513
|
+
payload: {
|
|
6514
|
+
elapsedMs: Date.now() - realtimeStartedAt,
|
|
6515
|
+
mode: "realtime",
|
|
6516
|
+
status: "sent"
|
|
6517
|
+
},
|
|
6518
|
+
session,
|
|
6519
|
+
turnId: turn.id,
|
|
6520
|
+
type: "turn.assistant"
|
|
6521
|
+
});
|
|
5656
6522
|
}
|
|
5657
6523
|
} catch (error) {
|
|
5658
|
-
logger.warn("voice
|
|
6524
|
+
logger.warn("voice assistant audio send failed", {
|
|
5659
6525
|
error: toError(error).message,
|
|
5660
6526
|
sessionId: options.id,
|
|
5661
6527
|
turnId: turn.id
|
|
@@ -5663,7 +6529,7 @@ var createVoiceSession = (options) => {
|
|
|
5663
6529
|
await appendTrace({
|
|
5664
6530
|
payload: {
|
|
5665
6531
|
error: toError(error).message,
|
|
5666
|
-
status: "tts-send-failed"
|
|
6532
|
+
status: options.realtime ? "realtime-send-failed" : "tts-send-failed"
|
|
5667
6533
|
},
|
|
5668
6534
|
session,
|
|
5669
6535
|
turnId: turn.id,
|
|
@@ -5840,11 +6706,35 @@ var createVoiceSession = (options) => {
|
|
|
5840
6706
|
turnId: turn.id,
|
|
5841
6707
|
type: "turn.cost"
|
|
5842
6708
|
});
|
|
6709
|
+
const firstTranscriptAt = turn.transcripts.map((transcript) => transcript.endedAtMs ?? transcript.startedAtMs).filter((value) => typeof value === "number").sort((left, right) => left - right)[0];
|
|
6710
|
+
const finalTranscriptAt = turn.transcripts.filter((transcript) => transcript.isFinal).map((transcript) => transcript.endedAtMs ?? transcript.startedAtMs).filter((value) => typeof value === "number").sort((left, right) => left - right)[0];
|
|
6711
|
+
if (firstTranscriptAt !== undefined) {
|
|
6712
|
+
await appendTurnLatencyStage({
|
|
6713
|
+
at: firstTranscriptAt,
|
|
6714
|
+
session: updatedSession,
|
|
6715
|
+
stage: "speech_detected",
|
|
6716
|
+
turnId: turn.id
|
|
6717
|
+
});
|
|
6718
|
+
}
|
|
6719
|
+
if (finalTranscriptAt !== undefined) {
|
|
6720
|
+
await appendTurnLatencyStage({
|
|
6721
|
+
at: finalTranscriptAt,
|
|
6722
|
+
session: updatedSession,
|
|
6723
|
+
stage: "final_transcript",
|
|
6724
|
+
turnId: turn.id
|
|
6725
|
+
});
|
|
6726
|
+
}
|
|
6727
|
+
await appendTurnLatencyStage({
|
|
6728
|
+
at: turn.committedAt,
|
|
6729
|
+
session: updatedSession,
|
|
6730
|
+
stage: "turn_committed",
|
|
6731
|
+
turnId: turn.id
|
|
6732
|
+
});
|
|
5843
6733
|
await send({
|
|
5844
6734
|
turn,
|
|
5845
6735
|
type: "turn"
|
|
5846
6736
|
});
|
|
5847
|
-
if (options.sttLifecycle === "turn-scoped") {
|
|
6737
|
+
if (options.stt && options.sttLifecycle === "turn-scoped") {
|
|
5848
6738
|
await closeAdapter("turn-commit");
|
|
5849
6739
|
}
|
|
5850
6740
|
await completeTurn(updatedSession, turn);
|
|
@@ -5907,6 +6797,7 @@ var createVoiceSession = (options) => {
|
|
|
5907
6797
|
scenarioId: session.scenarioId,
|
|
5908
6798
|
type: "session"
|
|
5909
6799
|
});
|
|
6800
|
+
await sendReplay(session);
|
|
5910
6801
|
if (shouldFireOnSession) {
|
|
5911
6802
|
await options.route.onCallStart?.({
|
|
5912
6803
|
api,
|
|
@@ -6490,7 +7381,7 @@ var createVoiceCallReviewFromLiveTelephonyReport = (report, options = {}) => {
|
|
|
6490
7381
|
}
|
|
6491
7382
|
};
|
|
6492
7383
|
};
|
|
6493
|
-
var
|
|
7384
|
+
var toErrorMessage2 = (error) => {
|
|
6494
7385
|
if (typeof error === "string" && error.trim().length > 0) {
|
|
6495
7386
|
return error;
|
|
6496
7387
|
}
|
|
@@ -6577,7 +7468,7 @@ var createVoiceCallReviewRecorder = (options = {}) => {
|
|
|
6577
7468
|
};
|
|
6578
7469
|
},
|
|
6579
7470
|
recordError: (error) => {
|
|
6580
|
-
const message =
|
|
7471
|
+
const message = toErrorMessage2(error);
|
|
6581
7472
|
errors.push(message);
|
|
6582
7473
|
push("turn", "error", {
|
|
6583
7474
|
reason: message
|
|
@@ -7283,10 +8174,981 @@ var runVoiceSessionBenchmarkSeries = async (input) => {
|
|
|
7283
8174
|
});
|
|
7284
8175
|
};
|
|
7285
8176
|
// src/telephony/twilio.ts
|
|
7286
|
-
import { Buffer as
|
|
8177
|
+
import { Buffer as Buffer3 } from "buffer";
|
|
8178
|
+
import { Elysia as Elysia2 } from "elysia";
|
|
8179
|
+
|
|
8180
|
+
// src/telephonyOutcome.ts
|
|
8181
|
+
import { Elysia } from "elysia";
|
|
8182
|
+
var DEFAULT_COMPLETED_STATUSES = [
|
|
8183
|
+
"answered",
|
|
8184
|
+
"completed",
|
|
8185
|
+
"complete",
|
|
8186
|
+
"connected",
|
|
8187
|
+
"in-progress",
|
|
8188
|
+
"live"
|
|
8189
|
+
];
|
|
8190
|
+
var DEFAULT_NO_ANSWER_STATUSES = [
|
|
8191
|
+
"busy",
|
|
8192
|
+
"canceled",
|
|
8193
|
+
"cancelled",
|
|
8194
|
+
"failed",
|
|
8195
|
+
"no-answer",
|
|
8196
|
+
"no_answer",
|
|
8197
|
+
"not-answered",
|
|
8198
|
+
"ring-no-answer",
|
|
8199
|
+
"timeout",
|
|
8200
|
+
"unanswered"
|
|
8201
|
+
];
|
|
8202
|
+
var DEFAULT_VOICEMAIL_STATUSES = [
|
|
8203
|
+
"answering-machine",
|
|
8204
|
+
"machine",
|
|
8205
|
+
"voicemail",
|
|
8206
|
+
"voice-mail"
|
|
8207
|
+
];
|
|
8208
|
+
var DEFAULT_TRANSFER_STATUSES = ["bridged", "forwarded", "transferred"];
|
|
8209
|
+
var DEFAULT_ESCALATION_STATUSES = ["escalated", "human-required", "operator"];
|
|
8210
|
+
var DEFAULT_FAILED_STATUSES = ["busy", "failed", "no-answer"];
|
|
8211
|
+
var DEFAULT_MACHINE_VOICEMAIL_VALUES = [
|
|
8212
|
+
"answering-machine",
|
|
8213
|
+
"fax",
|
|
8214
|
+
"machine",
|
|
8215
|
+
"machine-end-beep",
|
|
8216
|
+
"machine-end-other",
|
|
8217
|
+
"machine-start",
|
|
8218
|
+
"voicemail"
|
|
8219
|
+
];
|
|
8220
|
+
var DEFAULT_NO_ANSWER_SIP_CODES = [408, 480, 486, 487, 603];
|
|
8221
|
+
var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
8222
|
+
var uniqueSorted = (values) => Array.from(new Set(values)).sort();
|
|
8223
|
+
var findMissing = (values, required) => {
|
|
8224
|
+
if (!required?.length) {
|
|
8225
|
+
return [];
|
|
8226
|
+
}
|
|
8227
|
+
const valueSet = new Set(values);
|
|
8228
|
+
return required.filter((value) => !valueSet.has(value));
|
|
8229
|
+
};
|
|
8230
|
+
|
|
8231
|
+
class VoiceTelephonyWebhookVerificationError extends Error {
|
|
8232
|
+
result;
|
|
8233
|
+
constructor(result) {
|
|
8234
|
+
super(result.ok ? "telephony webhook verified" : result.reason);
|
|
8235
|
+
this.name = "VoiceTelephonyWebhookVerificationError";
|
|
8236
|
+
this.result = result;
|
|
8237
|
+
}
|
|
8238
|
+
}
|
|
8239
|
+
var createMemoryVoiceTelephonyWebhookIdempotencyStore = () => {
|
|
8240
|
+
const decisions = new Map;
|
|
8241
|
+
return {
|
|
8242
|
+
get: (key) => decisions.get(key),
|
|
8243
|
+
set: (key, decision) => {
|
|
8244
|
+
decisions.set(key, decision);
|
|
8245
|
+
}
|
|
8246
|
+
};
|
|
8247
|
+
};
|
|
8248
|
+
var isTelephonyWebhookProvider = (value) => value === "generic" || value === "plivo" || value === "telnyx" || value === "twilio";
|
|
8249
|
+
var isTelephonyOutcomeAction = (value) => value === "complete" || value === "escalate" || value === "ignore" || value === "no-answer" || value === "transfer" || value === "voicemail";
|
|
8250
|
+
var isCallDisposition = (value) => value === "completed" || value === "escalated" || value === "failed" || value === "no-answer" || value === "transferred" || value === "voicemail";
|
|
8251
|
+
var evaluateVoiceTelephonyWebhookNormalizationEvidence = (input = {}) => {
|
|
8252
|
+
const issues = [];
|
|
8253
|
+
const decisions = input.decisions ?? [];
|
|
8254
|
+
const verificationAttempts = input.verificationAttempts ?? [];
|
|
8255
|
+
const actions = uniqueSorted(decisions.map((decision) => decision.decision?.action ?? decision.action).filter(isTelephonyOutcomeAction));
|
|
8256
|
+
const dispositions = uniqueSorted(decisions.map((decision) => decision.decision?.disposition ?? decision.disposition).filter(isCallDisposition));
|
|
8257
|
+
const providers = uniqueSorted(decisions.map((decision) => decision.provider ?? decision.event?.provider).filter(isTelephonyWebhookProvider));
|
|
8258
|
+
const sources = uniqueSorted(decisions.map((decision) => decision.decision?.source ?? decision.source).filter((source) => typeof source === "string"));
|
|
8259
|
+
const applied = decisions.filter((decision) => decision.applied === true).length;
|
|
8260
|
+
const duplicateDecisions = decisions.filter((decision) => decision.duplicate === true);
|
|
8261
|
+
const duplicateProviders = uniqueSorted(duplicateDecisions.map((decision) => decision.provider ?? decision.event?.provider).filter(isTelephonyWebhookProvider));
|
|
8262
|
+
const duplicateIdempotencyKeys = new Set(duplicateDecisions.map((decision) => decision.idempotencyKey).filter((key) => typeof key === "string" && key.length > 0)).size;
|
|
8263
|
+
const duplicateCampaignOutcomesApplied = duplicateDecisions.filter((decision) => isRecord(decision.campaignOutcome) && decision.campaignOutcome.applied === true).length;
|
|
8264
|
+
const duplicateOutcomeReasons = uniqueSorted(duplicateDecisions.map((decision) => isRecord(decision.campaignOutcome) ? decision.campaignOutcome.reason : undefined).filter((reason) => typeof reason === "string"));
|
|
8265
|
+
const routeResults = decisions.filter((decision) => isRecord(decision.routeResult)).length;
|
|
8266
|
+
const missingSessionIds = decisions.filter((decision) => !decision.sessionId).length;
|
|
8267
|
+
const rejectedVerificationAttempts = verificationAttempts.filter((attempt) => attempt.rejected === true || attempt.status === 401 || attempt.verification?.ok === false && attempt.verification.reason === "invalid-signature");
|
|
8268
|
+
const rejectedVerificationProviders = uniqueSorted(rejectedVerificationAttempts.map((attempt) => attempt.provider).filter(isTelephonyWebhookProvider));
|
|
8269
|
+
const replayRejectedVerificationAttempts = rejectedVerificationAttempts.filter((attempt) => attempt.replayRejected === true);
|
|
8270
|
+
const replayRejectedVerificationProviders = uniqueSorted(replayRejectedVerificationAttempts.map((attempt) => attempt.provider).filter(isTelephonyWebhookProvider));
|
|
8271
|
+
const rejectedVerificationSideEffects = rejectedVerificationAttempts.reduce((total, attempt) => total + Math.max(0, attempt.sideEffects ?? 0), 0);
|
|
8272
|
+
if (input.minDecisions !== undefined && decisions.length < input.minDecisions) {
|
|
8273
|
+
issues.push(`Expected at least ${String(input.minDecisions)} telephony webhook decision(s), found ${String(decisions.length)}.`);
|
|
8274
|
+
}
|
|
8275
|
+
if (input.minApplied !== undefined && applied < input.minApplied) {
|
|
8276
|
+
issues.push(`Expected at least ${String(input.minApplied)} applied telephony webhook decision(s), found ${String(applied)}.`);
|
|
8277
|
+
}
|
|
8278
|
+
if (input.minDuplicates !== undefined && duplicateDecisions.length < input.minDuplicates) {
|
|
8279
|
+
issues.push(`Expected at least ${String(input.minDuplicates)} duplicate telephony webhook decision(s), found ${String(duplicateDecisions.length)}.`);
|
|
8280
|
+
}
|
|
8281
|
+
if (input.minDuplicateIdempotencyKeys !== undefined && duplicateIdempotencyKeys < input.minDuplicateIdempotencyKeys) {
|
|
8282
|
+
issues.push(`Expected at least ${String(input.minDuplicateIdempotencyKeys)} duplicate telephony webhook idempotency key(s), found ${String(duplicateIdempotencyKeys)}.`);
|
|
8283
|
+
}
|
|
8284
|
+
if (input.maxDuplicateCampaignOutcomesApplied !== undefined && duplicateCampaignOutcomesApplied > input.maxDuplicateCampaignOutcomesApplied) {
|
|
8285
|
+
issues.push(`Expected at most ${String(input.maxDuplicateCampaignOutcomesApplied)} duplicate telephony webhook campaign outcome application(s), found ${String(duplicateCampaignOutcomesApplied)}.`);
|
|
8286
|
+
}
|
|
8287
|
+
if (input.minRejectedVerificationAttempts !== undefined && rejectedVerificationAttempts.length < input.minRejectedVerificationAttempts) {
|
|
8288
|
+
issues.push(`Expected at least ${String(input.minRejectedVerificationAttempts)} rejected telephony webhook verification attempt(s), found ${String(rejectedVerificationAttempts.length)}.`);
|
|
8289
|
+
}
|
|
8290
|
+
if (input.maxRejectedVerificationSideEffects !== undefined && rejectedVerificationSideEffects > input.maxRejectedVerificationSideEffects) {
|
|
8291
|
+
issues.push(`Expected at most ${String(input.maxRejectedVerificationSideEffects)} rejected telephony webhook side effect(s), found ${String(rejectedVerificationSideEffects)}.`);
|
|
8292
|
+
}
|
|
8293
|
+
if (input.minReplayRejectedVerificationAttempts !== undefined && replayRejectedVerificationAttempts.length < input.minReplayRejectedVerificationAttempts) {
|
|
8294
|
+
issues.push(`Expected at least ${String(input.minReplayRejectedVerificationAttempts)} replay-rejected telephony webhook verification attempt(s), found ${String(replayRejectedVerificationAttempts.length)}.`);
|
|
8295
|
+
}
|
|
8296
|
+
if (input.maxMissingSessionIds !== undefined && missingSessionIds > input.maxMissingSessionIds) {
|
|
8297
|
+
issues.push(`Expected at most ${String(input.maxMissingSessionIds)} telephony webhook decision(s) without sessionId, found ${String(missingSessionIds)}.`);
|
|
8298
|
+
}
|
|
8299
|
+
if (input.requireRouteResults && routeResults < decisions.length) {
|
|
8300
|
+
issues.push(`Expected every telephony webhook decision to include a route result, found ${String(routeResults)} of ${String(decisions.length)}.`);
|
|
8301
|
+
}
|
|
8302
|
+
for (const provider of findMissing(providers, input.requiredProviders)) {
|
|
8303
|
+
issues.push(`Missing telephony webhook provider: ${provider}.`);
|
|
8304
|
+
}
|
|
8305
|
+
for (const provider of findMissing(duplicateProviders, input.requiredDuplicateProviders)) {
|
|
8306
|
+
issues.push(`Missing duplicate telephony webhook provider: ${provider}.`);
|
|
8307
|
+
}
|
|
8308
|
+
for (const provider of findMissing(rejectedVerificationProviders, input.requiredRejectedVerificationProviders)) {
|
|
8309
|
+
issues.push(`Missing rejected telephony webhook verification provider: ${provider}.`);
|
|
8310
|
+
}
|
|
8311
|
+
for (const provider of findMissing(replayRejectedVerificationProviders, input.requiredReplayRejectedVerificationProviders)) {
|
|
8312
|
+
issues.push(`Missing replay-rejected telephony webhook verification provider: ${provider}.`);
|
|
8313
|
+
}
|
|
8314
|
+
for (const action of findMissing(actions, input.requiredActions)) {
|
|
8315
|
+
issues.push(`Missing telephony webhook action: ${action}.`);
|
|
8316
|
+
}
|
|
8317
|
+
for (const disposition of findMissing(dispositions, input.requiredDispositions)) {
|
|
8318
|
+
issues.push(`Missing telephony webhook disposition: ${disposition}.`);
|
|
8319
|
+
}
|
|
8320
|
+
return {
|
|
8321
|
+
actions,
|
|
8322
|
+
applied,
|
|
8323
|
+
decisions: decisions.length,
|
|
8324
|
+
dispositions,
|
|
8325
|
+
duplicateCampaignOutcomesApplied,
|
|
8326
|
+
duplicateIdempotencyKeys,
|
|
8327
|
+
duplicateOutcomeReasons,
|
|
8328
|
+
duplicateProviders,
|
|
8329
|
+
duplicates: duplicateDecisions.length,
|
|
8330
|
+
issues,
|
|
8331
|
+
missingSessionIds,
|
|
8332
|
+
ok: issues.length === 0,
|
|
8333
|
+
providers,
|
|
8334
|
+
rejectedVerificationAttempts: rejectedVerificationAttempts.length,
|
|
8335
|
+
rejectedVerificationProviders,
|
|
8336
|
+
rejectedVerificationSideEffects,
|
|
8337
|
+
replayRejectedVerificationAttempts: replayRejectedVerificationAttempts.length,
|
|
8338
|
+
replayRejectedVerificationProviders,
|
|
8339
|
+
routeResults,
|
|
8340
|
+
sources,
|
|
8341
|
+
verificationAttempts: verificationAttempts.length
|
|
8342
|
+
};
|
|
8343
|
+
};
|
|
8344
|
+
var assertVoiceTelephonyWebhookNormalizationEvidence = (input = {}) => {
|
|
8345
|
+
const assertion = evaluateVoiceTelephonyWebhookNormalizationEvidence(input);
|
|
8346
|
+
if (!assertion.ok) {
|
|
8347
|
+
throw new Error(`Voice telephony webhook normalization evidence assertion failed: ${assertion.issues.join(" ")}`);
|
|
8348
|
+
}
|
|
8349
|
+
return assertion;
|
|
8350
|
+
};
|
|
8351
|
+
var normalizeToken = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/\s+/g, "-").replace(/_+/g, "-") : undefined;
|
|
8352
|
+
var firstString = (source, keys) => {
|
|
8353
|
+
for (const key of keys) {
|
|
8354
|
+
const value = source[key];
|
|
8355
|
+
if (typeof value === "string" && value.trim()) {
|
|
8356
|
+
return value.trim();
|
|
8357
|
+
}
|
|
8358
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
8359
|
+
return String(value);
|
|
8360
|
+
}
|
|
8361
|
+
}
|
|
8362
|
+
};
|
|
8363
|
+
var firstNumber = (source, keys) => {
|
|
8364
|
+
for (const key of keys) {
|
|
8365
|
+
const value = source[key];
|
|
8366
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
8367
|
+
return value;
|
|
8368
|
+
}
|
|
8369
|
+
if (typeof value === "string" && value.trim()) {
|
|
8370
|
+
const parsed = Number(value);
|
|
8371
|
+
if (Number.isFinite(parsed)) {
|
|
8372
|
+
return parsed;
|
|
8373
|
+
}
|
|
8374
|
+
}
|
|
8375
|
+
}
|
|
8376
|
+
};
|
|
8377
|
+
var parseMaybeJSON = (value) => {
|
|
8378
|
+
try {
|
|
8379
|
+
return JSON.parse(value);
|
|
8380
|
+
} catch {
|
|
8381
|
+
return;
|
|
8382
|
+
}
|
|
8383
|
+
};
|
|
8384
|
+
var flattenPayload = (value) => {
|
|
8385
|
+
if (!isRecord(value)) {
|
|
8386
|
+
return {};
|
|
8387
|
+
}
|
|
8388
|
+
const data = isRecord(value.data) ? value.data : undefined;
|
|
8389
|
+
const payload = isRecord(value.payload) ? value.payload : undefined;
|
|
8390
|
+
const event = isRecord(value.event) ? value.event : undefined;
|
|
8391
|
+
return {
|
|
8392
|
+
...value,
|
|
8393
|
+
...payload,
|
|
8394
|
+
...event,
|
|
8395
|
+
...data,
|
|
8396
|
+
...isRecord(data?.payload) ? data.payload : undefined
|
|
8397
|
+
};
|
|
8398
|
+
};
|
|
8399
|
+
var toBase64 = (bytes) => Buffer.from(new Uint8Array(bytes)).toString("base64");
|
|
8400
|
+
var timingSafeEqual = (left, right) => {
|
|
8401
|
+
const encoder = new TextEncoder;
|
|
8402
|
+
const leftBytes = encoder.encode(left);
|
|
8403
|
+
const rightBytes = encoder.encode(right);
|
|
8404
|
+
if (leftBytes.length !== rightBytes.length) {
|
|
8405
|
+
return false;
|
|
8406
|
+
}
|
|
8407
|
+
let diff = 0;
|
|
8408
|
+
for (let index = 0;index < leftBytes.length; index += 1) {
|
|
8409
|
+
diff |= leftBytes[index] ^ rightBytes[index];
|
|
8410
|
+
}
|
|
8411
|
+
return diff === 0;
|
|
8412
|
+
};
|
|
8413
|
+
var signHmacSHA1Base64 = async (secret, payload) => {
|
|
8414
|
+
const encoder = new TextEncoder;
|
|
8415
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
|
|
8416
|
+
hash: "SHA-1",
|
|
8417
|
+
name: "HMAC"
|
|
8418
|
+
}, false, ["sign"]);
|
|
8419
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
8420
|
+
return toBase64(signature);
|
|
8421
|
+
};
|
|
8422
|
+
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("");
|
|
8423
|
+
var normalizeList = (values, fallback) => new Set((values ?? fallback).map(normalizeToken).filter(Boolean));
|
|
8424
|
+
var metadataValue = (metadata, keys) => {
|
|
8425
|
+
for (const key of keys) {
|
|
8426
|
+
const value = metadata?.[key];
|
|
8427
|
+
if (typeof value === "string" && value.trim()) {
|
|
8428
|
+
return value.trim();
|
|
8429
|
+
}
|
|
8430
|
+
}
|
|
8431
|
+
};
|
|
8432
|
+
var resolveTransferTarget = (event, policy) => {
|
|
8433
|
+
if (typeof event.target === "string" && event.target.trim()) {
|
|
8434
|
+
return event.target.trim();
|
|
8435
|
+
}
|
|
8436
|
+
const metadataTarget = metadataValue(event.metadata, [
|
|
8437
|
+
"transferTarget",
|
|
8438
|
+
"target",
|
|
8439
|
+
"queue",
|
|
8440
|
+
"department"
|
|
8441
|
+
]);
|
|
8442
|
+
if (metadataTarget) {
|
|
8443
|
+
return metadataTarget;
|
|
8444
|
+
}
|
|
8445
|
+
if (typeof policy.transferTarget === "function") {
|
|
8446
|
+
const target = policy.transferTarget(event);
|
|
8447
|
+
return typeof target === "string" && target.trim() ? target.trim() : undefined;
|
|
8448
|
+
}
|
|
8449
|
+
return typeof policy.transferTarget === "string" && policy.transferTarget.trim() ? policy.transferTarget.trim() : undefined;
|
|
8450
|
+
};
|
|
8451
|
+
var mergeMetadata = (event, policy) => ({
|
|
8452
|
+
...policy.includeProviderPayload ? {
|
|
8453
|
+
answeredBy: event.answeredBy,
|
|
8454
|
+
durationMs: event.durationMs,
|
|
8455
|
+
provider: event.provider,
|
|
8456
|
+
reason: event.reason,
|
|
8457
|
+
sipCode: event.sipCode,
|
|
8458
|
+
status: event.status
|
|
8459
|
+
} : undefined,
|
|
8460
|
+
...policy.metadata,
|
|
8461
|
+
...event.metadata
|
|
8462
|
+
});
|
|
8463
|
+
var withDecisionDefaults = (decision, input) => {
|
|
8464
|
+
if (typeof decision === "string") {
|
|
8465
|
+
return buildDecision(decision, input);
|
|
8466
|
+
}
|
|
8467
|
+
return {
|
|
8468
|
+
...buildDecision(decision.action, input),
|
|
8469
|
+
...decision,
|
|
8470
|
+
confidence: decision.confidence ?? "high",
|
|
8471
|
+
metadata: {
|
|
8472
|
+
...mergeMetadata(input.event, input.policy),
|
|
8473
|
+
...decision.metadata
|
|
8474
|
+
},
|
|
8475
|
+
source: decision.source ?? input.source,
|
|
8476
|
+
target: decision.target ?? (decision.action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined)
|
|
8477
|
+
};
|
|
8478
|
+
};
|
|
8479
|
+
var dispositionForAction = (action) => {
|
|
8480
|
+
switch (action) {
|
|
8481
|
+
case "complete":
|
|
8482
|
+
return "completed";
|
|
8483
|
+
case "escalate":
|
|
8484
|
+
return "escalated";
|
|
8485
|
+
case "no-answer":
|
|
8486
|
+
return "no-answer";
|
|
8487
|
+
case "transfer":
|
|
8488
|
+
return "transferred";
|
|
8489
|
+
case "voicemail":
|
|
8490
|
+
return "voicemail";
|
|
8491
|
+
default:
|
|
8492
|
+
return;
|
|
8493
|
+
}
|
|
8494
|
+
};
|
|
8495
|
+
var buildDecision = (action, input) => ({
|
|
8496
|
+
action,
|
|
8497
|
+
confidence: action === "ignore" ? "low" : "high",
|
|
8498
|
+
disposition: dispositionForAction(action),
|
|
8499
|
+
metadata: mergeMetadata(input.event, input.policy),
|
|
8500
|
+
reason: input.event.reason,
|
|
8501
|
+
source: input.source,
|
|
8502
|
+
target: action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined
|
|
8503
|
+
});
|
|
8504
|
+
var createVoiceTelephonyOutcomePolicy = (policy = {}) => ({
|
|
8505
|
+
completedStatuses: policy.completedStatuses ?? DEFAULT_COMPLETED_STATUSES,
|
|
8506
|
+
escalationStatuses: policy.escalationStatuses ?? DEFAULT_ESCALATION_STATUSES,
|
|
8507
|
+
failedAsNoAnswer: policy.failedAsNoAnswer ?? true,
|
|
8508
|
+
failedStatuses: policy.failedStatuses ?? DEFAULT_FAILED_STATUSES,
|
|
8509
|
+
includeProviderPayload: policy.includeProviderPayload ?? true,
|
|
8510
|
+
machineDetectionVoicemailValues: policy.machineDetectionVoicemailValues ?? DEFAULT_MACHINE_VOICEMAIL_VALUES,
|
|
8511
|
+
metadata: policy.metadata,
|
|
8512
|
+
minAnsweredDurationMs: policy.minAnsweredDurationMs,
|
|
8513
|
+
noAnswerOnZeroDuration: policy.noAnswerOnZeroDuration ?? true,
|
|
8514
|
+
noAnswerSipCodes: policy.noAnswerSipCodes ?? DEFAULT_NO_ANSWER_SIP_CODES,
|
|
8515
|
+
noAnswerStatuses: policy.noAnswerStatuses ?? DEFAULT_NO_ANSWER_STATUSES,
|
|
8516
|
+
statusMap: policy.statusMap,
|
|
8517
|
+
transferStatuses: policy.transferStatuses ?? DEFAULT_TRANSFER_STATUSES,
|
|
8518
|
+
transferTarget: policy.transferTarget,
|
|
8519
|
+
voicemailStatuses: policy.voicemailStatuses ?? DEFAULT_VOICEMAIL_STATUSES
|
|
8520
|
+
});
|
|
8521
|
+
var resolveVoiceTelephonyOutcome = (event, policyInput = {}) => {
|
|
8522
|
+
const policy = createVoiceTelephonyOutcomePolicy(policyInput);
|
|
8523
|
+
const status = normalizeToken(event.status);
|
|
8524
|
+
const provider = normalizeToken(event.provider);
|
|
8525
|
+
const answeredBy = normalizeToken(event.answeredBy);
|
|
8526
|
+
const target = resolveTransferTarget(event, policy);
|
|
8527
|
+
if (status) {
|
|
8528
|
+
const mapped = policy.statusMap?.[status] ?? (provider ? policy.statusMap?.[`${provider}:${status}`] : undefined);
|
|
8529
|
+
if (mapped) {
|
|
8530
|
+
return withDecisionDefaults(mapped, {
|
|
8531
|
+
event,
|
|
8532
|
+
policy,
|
|
8533
|
+
source: "policy"
|
|
8534
|
+
});
|
|
8535
|
+
}
|
|
8536
|
+
}
|
|
8537
|
+
if (answeredBy && normalizeList(policy.machineDetectionVoicemailValues, []).has(answeredBy)) {
|
|
8538
|
+
return buildDecision("voicemail", { event, policy, source: "answered-by" });
|
|
8539
|
+
}
|
|
8540
|
+
if (typeof event.sipCode === "number" && policy.noAnswerSipCodes.includes(event.sipCode)) {
|
|
8541
|
+
return buildDecision("no-answer", { event, policy, source: "sip" });
|
|
8542
|
+
}
|
|
8543
|
+
if (target && status && normalizeList(policy.transferStatuses, []).has(status)) {
|
|
8544
|
+
return buildDecision("transfer", { event, policy, source: "status" });
|
|
8545
|
+
}
|
|
8546
|
+
if (status && normalizeList(policy.voicemailStatuses, []).has(status)) {
|
|
8547
|
+
return buildDecision("voicemail", { event, policy, source: "status" });
|
|
8548
|
+
}
|
|
8549
|
+
if (status && normalizeList(policy.escalationStatuses, []).has(status)) {
|
|
8550
|
+
return buildDecision("escalate", { event, policy, source: "status" });
|
|
8551
|
+
}
|
|
8552
|
+
if (status && (policy.failedAsNoAnswer ? normalizeList(policy.noAnswerStatuses, []).has(status) || normalizeList(policy.failedStatuses, []).has(status) : normalizeList(policy.noAnswerStatuses, []).has(status))) {
|
|
8553
|
+
return buildDecision("no-answer", { event, policy, source: "status" });
|
|
8554
|
+
}
|
|
8555
|
+
if (policy.noAnswerOnZeroDuration && typeof event.durationMs === "number" && event.durationMs <= 0) {
|
|
8556
|
+
return buildDecision("no-answer", { event, policy, source: "duration" });
|
|
8557
|
+
}
|
|
8558
|
+
if (typeof policy.minAnsweredDurationMs === "number" && typeof event.durationMs === "number" && event.durationMs < policy.minAnsweredDurationMs) {
|
|
8559
|
+
return {
|
|
8560
|
+
...buildDecision("no-answer", { event, policy, source: "duration" }),
|
|
8561
|
+
confidence: "medium"
|
|
8562
|
+
};
|
|
8563
|
+
}
|
|
8564
|
+
if (status && normalizeList(policy.completedStatuses, []).has(status)) {
|
|
8565
|
+
return buildDecision("complete", { event, policy, source: "status" });
|
|
8566
|
+
}
|
|
8567
|
+
if (target) {
|
|
8568
|
+
return {
|
|
8569
|
+
...buildDecision("transfer", { event, policy, source: "explicit-target" }),
|
|
8570
|
+
confidence: "medium"
|
|
8571
|
+
};
|
|
8572
|
+
}
|
|
8573
|
+
return buildDecision("ignore", { event, policy, source: "status" });
|
|
8574
|
+
};
|
|
8575
|
+
var voiceTelephonyOutcomeToRouteResult = (decision, result) => {
|
|
8576
|
+
switch (decision.action) {
|
|
8577
|
+
case "complete":
|
|
8578
|
+
return { complete: true, result };
|
|
8579
|
+
case "escalate":
|
|
8580
|
+
return {
|
|
8581
|
+
escalate: {
|
|
8582
|
+
metadata: decision.metadata,
|
|
8583
|
+
reason: decision.reason ?? "telephony-escalation"
|
|
8584
|
+
},
|
|
8585
|
+
result
|
|
8586
|
+
};
|
|
8587
|
+
case "no-answer":
|
|
8588
|
+
return {
|
|
8589
|
+
noAnswer: {
|
|
8590
|
+
metadata: decision.metadata
|
|
8591
|
+
},
|
|
8592
|
+
result
|
|
8593
|
+
};
|
|
8594
|
+
case "transfer":
|
|
8595
|
+
if (!decision.target) {
|
|
8596
|
+
return { result };
|
|
8597
|
+
}
|
|
8598
|
+
return {
|
|
8599
|
+
result,
|
|
8600
|
+
transfer: {
|
|
8601
|
+
metadata: decision.metadata,
|
|
8602
|
+
reason: decision.reason,
|
|
8603
|
+
target: decision.target
|
|
8604
|
+
}
|
|
8605
|
+
};
|
|
8606
|
+
case "voicemail":
|
|
8607
|
+
return {
|
|
8608
|
+
result,
|
|
8609
|
+
voicemail: {
|
|
8610
|
+
metadata: decision.metadata
|
|
8611
|
+
}
|
|
8612
|
+
};
|
|
8613
|
+
default:
|
|
8614
|
+
return { result };
|
|
8615
|
+
}
|
|
8616
|
+
};
|
|
8617
|
+
var applyVoiceTelephonyOutcome = async (api, decision, result) => {
|
|
8618
|
+
switch (decision.action) {
|
|
8619
|
+
case "complete":
|
|
8620
|
+
await api.complete(result);
|
|
8621
|
+
break;
|
|
8622
|
+
case "escalate":
|
|
8623
|
+
await api.escalate({
|
|
8624
|
+
metadata: decision.metadata,
|
|
8625
|
+
reason: decision.reason ?? "telephony-escalation",
|
|
8626
|
+
result
|
|
8627
|
+
});
|
|
8628
|
+
break;
|
|
8629
|
+
case "no-answer":
|
|
8630
|
+
await api.markNoAnswer({
|
|
8631
|
+
metadata: decision.metadata,
|
|
8632
|
+
result
|
|
8633
|
+
});
|
|
8634
|
+
break;
|
|
8635
|
+
case "transfer":
|
|
8636
|
+
if (!decision.target) {
|
|
8637
|
+
return;
|
|
8638
|
+
}
|
|
8639
|
+
await api.transfer({
|
|
8640
|
+
metadata: decision.metadata,
|
|
8641
|
+
reason: decision.reason,
|
|
8642
|
+
result,
|
|
8643
|
+
target: decision.target
|
|
8644
|
+
});
|
|
8645
|
+
break;
|
|
8646
|
+
case "voicemail":
|
|
8647
|
+
await api.markVoicemail({
|
|
8648
|
+
metadata: decision.metadata,
|
|
8649
|
+
result
|
|
8650
|
+
});
|
|
8651
|
+
break;
|
|
8652
|
+
default:
|
|
8653
|
+
break;
|
|
8654
|
+
}
|
|
8655
|
+
};
|
|
8656
|
+
var parseRequestBodyText = (input) => {
|
|
8657
|
+
const { contentType, text } = input;
|
|
8658
|
+
if (!text) {
|
|
8659
|
+
return {};
|
|
8660
|
+
}
|
|
8661
|
+
if (contentType.includes("application/json")) {
|
|
8662
|
+
return parseMaybeJSON(text) ?? {};
|
|
8663
|
+
}
|
|
8664
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
8665
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
8666
|
+
}
|
|
8667
|
+
return parseMaybeJSON(text) ?? Object.fromEntries(new URLSearchParams(text));
|
|
8668
|
+
};
|
|
8669
|
+
var readRequestBody = async (request) => {
|
|
8670
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
8671
|
+
const text = await request.text();
|
|
8672
|
+
return {
|
|
8673
|
+
body: parseRequestBodyText({ contentType, text }),
|
|
8674
|
+
rawBody: text
|
|
8675
|
+
};
|
|
8676
|
+
};
|
|
8677
|
+
var signVoiceTwilioWebhook = async (input) => signHmacSHA1Base64(input.authToken, `${input.url}${sortedParamsForSignature(input.body ?? {})}`);
|
|
8678
|
+
var verifyVoiceTwilioWebhookSignature = async (input) => {
|
|
8679
|
+
if (!input.authToken) {
|
|
8680
|
+
return { ok: false, reason: "missing-secret" };
|
|
8681
|
+
}
|
|
8682
|
+
const signature = input.headers.get("x-twilio-signature");
|
|
8683
|
+
if (!signature) {
|
|
8684
|
+
return { ok: false, reason: "missing-signature" };
|
|
8685
|
+
}
|
|
8686
|
+
const expected = await signVoiceTwilioWebhook({
|
|
8687
|
+
authToken: input.authToken,
|
|
8688
|
+
body: input.body,
|
|
8689
|
+
url: input.url
|
|
8690
|
+
});
|
|
8691
|
+
return timingSafeEqual(signature, expected) ? { ok: true } : { ok: false, reason: "invalid-signature" };
|
|
8692
|
+
};
|
|
8693
|
+
var resolveVerificationUrl = (option, input) => typeof option === "function" ? option(input) : option ?? input.request.url;
|
|
8694
|
+
var verifyVoiceTelephonyWebhook = async (input) => {
|
|
8695
|
+
if (input.options.verify) {
|
|
8696
|
+
return input.options.verify({
|
|
8697
|
+
body: input.body,
|
|
8698
|
+
headers: input.request.headers,
|
|
8699
|
+
provider: input.provider,
|
|
8700
|
+
query: input.query,
|
|
8701
|
+
rawBody: input.rawBody,
|
|
8702
|
+
request: input.request
|
|
8703
|
+
});
|
|
8704
|
+
}
|
|
8705
|
+
if (!input.options.signingSecret) {
|
|
8706
|
+
return input.options.requireVerification ? { ok: false, reason: "missing-secret" } : { ok: true };
|
|
8707
|
+
}
|
|
8708
|
+
if (input.provider !== "twilio") {
|
|
8709
|
+
return { ok: false, reason: "unsupported-provider" };
|
|
8710
|
+
}
|
|
8711
|
+
return verifyVoiceTwilioWebhookSignature({
|
|
8712
|
+
authToken: input.options.signingSecret,
|
|
8713
|
+
body: input.body,
|
|
8714
|
+
headers: input.request.headers,
|
|
8715
|
+
url: resolveVerificationUrl(input.options.verificationUrl, {
|
|
8716
|
+
query: input.query,
|
|
8717
|
+
request: input.request
|
|
8718
|
+
})
|
|
8719
|
+
});
|
|
8720
|
+
};
|
|
8721
|
+
var durationMsFromSeconds = (value) => typeof value === "number" ? value * 1000 : undefined;
|
|
8722
|
+
var parseVoiceTelephonyWebhookEvent = (input) => {
|
|
8723
|
+
const payload = flattenPayload(input.body);
|
|
8724
|
+
const provider = firstString(payload, ["provider", "Provider"]) ?? input.provider;
|
|
8725
|
+
const status = firstString(payload, [
|
|
8726
|
+
"CallStatus",
|
|
8727
|
+
"call_status",
|
|
8728
|
+
"callStatus",
|
|
8729
|
+
"DialCallStatus",
|
|
8730
|
+
"dial_call_status",
|
|
8731
|
+
"status",
|
|
8732
|
+
"event_type",
|
|
8733
|
+
"type"
|
|
8734
|
+
]);
|
|
8735
|
+
const durationMs = firstNumber(payload, ["durationMs", "duration_ms"]) ?? durationMsFromSeconds(firstNumber(payload, [
|
|
8736
|
+
"CallDuration",
|
|
8737
|
+
"call_duration",
|
|
8738
|
+
"callDuration",
|
|
8739
|
+
"DialCallDuration",
|
|
8740
|
+
"dial_call_duration",
|
|
8741
|
+
"duration"
|
|
8742
|
+
]));
|
|
8743
|
+
const sipCode = firstNumber(payload, [
|
|
8744
|
+
"SipResponseCode",
|
|
8745
|
+
"sip_response_code",
|
|
8746
|
+
"sipCode",
|
|
8747
|
+
"sip_code",
|
|
8748
|
+
"hangupCauseCode"
|
|
8749
|
+
]);
|
|
8750
|
+
const from = firstString(payload, ["From", "from", "caller_id", "callerId"]);
|
|
8751
|
+
const to = firstString(payload, ["To", "to", "called_number", "calledNumber"]);
|
|
8752
|
+
const target = firstString(payload, [
|
|
8753
|
+
"transferTarget",
|
|
8754
|
+
"TransferTarget",
|
|
8755
|
+
"target",
|
|
8756
|
+
"queue",
|
|
8757
|
+
"department"
|
|
8758
|
+
]);
|
|
8759
|
+
return {
|
|
8760
|
+
answeredBy: firstString(payload, [
|
|
8761
|
+
"AnsweredBy",
|
|
8762
|
+
"answered_by",
|
|
8763
|
+
"answeredBy",
|
|
8764
|
+
"machineDetection",
|
|
8765
|
+
"machine_detection"
|
|
8766
|
+
]),
|
|
8767
|
+
durationMs,
|
|
8768
|
+
from,
|
|
8769
|
+
metadata: {
|
|
8770
|
+
...input.query,
|
|
8771
|
+
...payload
|
|
8772
|
+
},
|
|
8773
|
+
provider,
|
|
8774
|
+
reason: firstString(payload, [
|
|
8775
|
+
"Reason",
|
|
8776
|
+
"reason",
|
|
8777
|
+
"HangupCause",
|
|
8778
|
+
"hangup_cause",
|
|
8779
|
+
"hangupCause"
|
|
8780
|
+
]),
|
|
8781
|
+
sipCode,
|
|
8782
|
+
status,
|
|
8783
|
+
target,
|
|
8784
|
+
to
|
|
8785
|
+
};
|
|
8786
|
+
};
|
|
8787
|
+
var defaultSessionId = (input) => {
|
|
8788
|
+
const payload = flattenPayload(input.body);
|
|
8789
|
+
const metadataSessionId = input.event.metadata?.sessionId;
|
|
8790
|
+
return firstString(input.query, ["sessionId", "session_id"]) ?? firstString(payload, [
|
|
8791
|
+
"sessionId",
|
|
8792
|
+
"session_id",
|
|
8793
|
+
"SessionId",
|
|
8794
|
+
"CallSid",
|
|
8795
|
+
"call_sid",
|
|
8796
|
+
"callSid",
|
|
8797
|
+
"CallUUID",
|
|
8798
|
+
"call_uuid",
|
|
8799
|
+
"callControlId",
|
|
8800
|
+
"call_control_id"
|
|
8801
|
+
]) ?? (typeof metadataSessionId === "string" ? metadataSessionId : undefined);
|
|
8802
|
+
};
|
|
8803
|
+
var defaultIdempotencyKey = (input) => {
|
|
8804
|
+
const payload = flattenPayload(input.body);
|
|
8805
|
+
const eventId = firstString(payload, [
|
|
8806
|
+
"id",
|
|
8807
|
+
"event_id",
|
|
8808
|
+
"eventId",
|
|
8809
|
+
"EventSid",
|
|
8810
|
+
"event_sid",
|
|
8811
|
+
"MessageSid",
|
|
8812
|
+
"message_sid",
|
|
8813
|
+
"CallSid",
|
|
8814
|
+
"call_sid",
|
|
8815
|
+
"CallUUID",
|
|
8816
|
+
"call_uuid",
|
|
8817
|
+
"callControlId",
|
|
8818
|
+
"call_control_id"
|
|
8819
|
+
]);
|
|
8820
|
+
const status = normalizeToken(input.event.status) ?? "unknown";
|
|
8821
|
+
if (eventId) {
|
|
8822
|
+
return `${input.provider}:${eventId}:${status}`;
|
|
8823
|
+
}
|
|
8824
|
+
if (input.sessionId) {
|
|
8825
|
+
return `${input.provider}:${input.sessionId}:${status}`;
|
|
8826
|
+
}
|
|
8827
|
+
};
|
|
8828
|
+
var createVoiceTelephonyWebhookHandler = (options = {}) => async (input) => {
|
|
8829
|
+
const provider = options.provider ?? "generic";
|
|
8830
|
+
const query = input.query ?? {};
|
|
8831
|
+
const { body, rawBody } = await readRequestBody(input.request);
|
|
8832
|
+
const verification = await verifyVoiceTelephonyWebhook({
|
|
8833
|
+
body,
|
|
8834
|
+
options,
|
|
8835
|
+
provider,
|
|
8836
|
+
query,
|
|
8837
|
+
rawBody,
|
|
8838
|
+
request: input.request
|
|
8839
|
+
});
|
|
8840
|
+
if (!verification.ok) {
|
|
8841
|
+
throw new VoiceTelephonyWebhookVerificationError(verification);
|
|
8842
|
+
}
|
|
8843
|
+
const event = options.parse ? await options.parse({
|
|
8844
|
+
body,
|
|
8845
|
+
headers: input.request.headers,
|
|
8846
|
+
provider,
|
|
8847
|
+
query,
|
|
8848
|
+
request: input.request
|
|
8849
|
+
}) : parseVoiceTelephonyWebhookEvent({
|
|
8850
|
+
body,
|
|
8851
|
+
headers: input.request.headers,
|
|
8852
|
+
provider,
|
|
8853
|
+
query,
|
|
8854
|
+
request: input.request
|
|
8855
|
+
});
|
|
8856
|
+
const sessionId = await (options.resolveSessionId?.({
|
|
8857
|
+
body,
|
|
8858
|
+
event,
|
|
8859
|
+
query,
|
|
8860
|
+
request: input.request
|
|
8861
|
+
}) ?? defaultSessionId({ body, event, query }));
|
|
8862
|
+
const idempotencyEnabled = options.idempotency?.enabled !== false;
|
|
8863
|
+
const idempotencyKey = idempotencyEnabled ? await (options.idempotency?.key?.({
|
|
8864
|
+
body,
|
|
8865
|
+
event,
|
|
8866
|
+
provider,
|
|
8867
|
+
query,
|
|
8868
|
+
request: input.request,
|
|
8869
|
+
sessionId
|
|
8870
|
+
}) ?? defaultIdempotencyKey({ body, event, provider, sessionId })) : undefined;
|
|
8871
|
+
const idempotencyStore = options.idempotency?.store;
|
|
8872
|
+
if (idempotencyKey && idempotencyStore) {
|
|
8873
|
+
const existing = await idempotencyStore.get(idempotencyKey);
|
|
8874
|
+
if (existing) {
|
|
8875
|
+
const duplicateDecision = {
|
|
8876
|
+
...existing,
|
|
8877
|
+
duplicate: true
|
|
8878
|
+
};
|
|
8879
|
+
await options.onDecision?.({
|
|
8880
|
+
...duplicateDecision,
|
|
8881
|
+
context: options.context,
|
|
8882
|
+
request: input.request
|
|
8883
|
+
});
|
|
8884
|
+
return duplicateDecision;
|
|
8885
|
+
}
|
|
8886
|
+
}
|
|
8887
|
+
const decision = resolveVoiceTelephonyOutcome(event, options.policy);
|
|
8888
|
+
const resultResolver = options.result;
|
|
8889
|
+
const result = typeof resultResolver === "function" ? await resultResolver({
|
|
8890
|
+
decision,
|
|
8891
|
+
event,
|
|
8892
|
+
sessionId
|
|
8893
|
+
}) : resultResolver;
|
|
8894
|
+
const routeResult = voiceTelephonyOutcomeToRouteResult(decision, result);
|
|
8895
|
+
const shouldApply = typeof options.apply === "function" ? options.apply({
|
|
8896
|
+
applied: false,
|
|
8897
|
+
decision,
|
|
8898
|
+
event,
|
|
8899
|
+
routeResult,
|
|
8900
|
+
sessionId
|
|
8901
|
+
}) : options.apply === true;
|
|
8902
|
+
let applied = false;
|
|
8903
|
+
if (shouldApply && decision.action !== "ignore" && options.getSessionHandle) {
|
|
8904
|
+
const api = await options.getSessionHandle({
|
|
8905
|
+
context: options.context,
|
|
8906
|
+
decision,
|
|
8907
|
+
event,
|
|
8908
|
+
request: input.request,
|
|
8909
|
+
sessionId
|
|
8910
|
+
});
|
|
8911
|
+
if (api) {
|
|
8912
|
+
await applyVoiceTelephonyOutcome(api, decision, result);
|
|
8913
|
+
applied = true;
|
|
8914
|
+
}
|
|
8915
|
+
}
|
|
8916
|
+
const webhookDecision = {
|
|
8917
|
+
applied,
|
|
8918
|
+
decision,
|
|
8919
|
+
event,
|
|
8920
|
+
idempotencyKey,
|
|
8921
|
+
routeResult,
|
|
8922
|
+
sessionId
|
|
8923
|
+
};
|
|
8924
|
+
if (idempotencyKey && idempotencyStore) {
|
|
8925
|
+
const now = Date.now();
|
|
8926
|
+
await idempotencyStore.set(idempotencyKey, {
|
|
8927
|
+
...webhookDecision,
|
|
8928
|
+
createdAt: now,
|
|
8929
|
+
updatedAt: now
|
|
8930
|
+
});
|
|
8931
|
+
}
|
|
8932
|
+
await options.onDecision?.({
|
|
8933
|
+
...webhookDecision,
|
|
8934
|
+
context: options.context,
|
|
8935
|
+
request: input.request
|
|
8936
|
+
});
|
|
8937
|
+
return webhookDecision;
|
|
8938
|
+
};
|
|
8939
|
+
var createVoiceTelephonyWebhookRoutes = (options = {}) => {
|
|
8940
|
+
const path = options.path ?? "/api/voice/telephony/webhook";
|
|
8941
|
+
const handler = createVoiceTelephonyWebhookHandler(options);
|
|
8942
|
+
return new Elysia({
|
|
8943
|
+
name: options.name ?? "absolutejs-voice-telephony-webhooks"
|
|
8944
|
+
}).post(path, async ({ query, request }) => {
|
|
8945
|
+
try {
|
|
8946
|
+
return await handler({ query, request });
|
|
8947
|
+
} catch (error) {
|
|
8948
|
+
if (error instanceof VoiceTelephonyWebhookVerificationError) {
|
|
8949
|
+
return new Response(JSON.stringify({ verification: error.result }), {
|
|
8950
|
+
headers: {
|
|
8951
|
+
"content-type": "application/json"
|
|
8952
|
+
},
|
|
8953
|
+
status: 401
|
|
8954
|
+
});
|
|
8955
|
+
}
|
|
8956
|
+
throw error;
|
|
8957
|
+
}
|
|
8958
|
+
}, {
|
|
8959
|
+
parse: "none"
|
|
8960
|
+
});
|
|
8961
|
+
};
|
|
8962
|
+
|
|
8963
|
+
// src/telephony/twilio.ts
|
|
7287
8964
|
var TWILIO_MULAW_SAMPLE_RATE = 8000;
|
|
7288
8965
|
var VOICE_PCM_SAMPLE_RATE = 16000;
|
|
7289
|
-
var
|
|
8966
|
+
var escapeXml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
8967
|
+
var resolveRequestOrigin = (request) => {
|
|
8968
|
+
const url = new URL(request.url);
|
|
8969
|
+
const forwardedHost = request.headers.get("x-forwarded-host");
|
|
8970
|
+
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
8971
|
+
const host = forwardedHost ?? request.headers.get("host") ?? url.host;
|
|
8972
|
+
const protocol = forwardedProto ?? url.protocol.replace(":", "");
|
|
8973
|
+
return `${protocol}://${host}`;
|
|
8974
|
+
};
|
|
8975
|
+
var resolveTwilioStreamUrl = async (options, input) => {
|
|
8976
|
+
if (typeof options.twiml?.streamUrl === "function") {
|
|
8977
|
+
return options.twiml.streamUrl(input);
|
|
8978
|
+
}
|
|
8979
|
+
if (typeof options.twiml?.streamUrl === "string") {
|
|
8980
|
+
return options.twiml.streamUrl;
|
|
8981
|
+
}
|
|
8982
|
+
const origin = resolveRequestOrigin(input.request);
|
|
8983
|
+
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
8984
|
+
return `${wsOrigin}${input.streamPath}`;
|
|
8985
|
+
};
|
|
8986
|
+
var resolveTwilioStreamParameters = async (parameters, input) => {
|
|
8987
|
+
if (typeof parameters === "function") {
|
|
8988
|
+
return parameters(input);
|
|
8989
|
+
}
|
|
8990
|
+
return parameters;
|
|
8991
|
+
};
|
|
8992
|
+
var joinUrlPath = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
8993
|
+
var escapeHtml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
8994
|
+
var getWebhookVerificationUrl = (webhook, input) => {
|
|
8995
|
+
if (!webhook?.verificationUrl) {
|
|
8996
|
+
return;
|
|
8997
|
+
}
|
|
8998
|
+
if (typeof webhook.verificationUrl === "function") {
|
|
8999
|
+
return webhook.verificationUrl(input);
|
|
9000
|
+
}
|
|
9001
|
+
return webhook.verificationUrl;
|
|
9002
|
+
};
|
|
9003
|
+
var buildTwilioVoiceSetupStatus = async (options, input) => {
|
|
9004
|
+
const origin = resolveRequestOrigin(input.request);
|
|
9005
|
+
const stream = await resolveTwilioStreamUrl(options, input);
|
|
9006
|
+
const twiml = joinUrlPath(origin, input.twimlPath);
|
|
9007
|
+
const webhook = joinUrlPath(origin, input.webhookPath);
|
|
9008
|
+
const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
|
|
9009
|
+
const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
|
|
9010
|
+
const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
|
|
9011
|
+
const warnings = [
|
|
9012
|
+
...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
|
|
9013
|
+
...signingConfigured ? [] : ["Webhook signature verification is not configured."],
|
|
9014
|
+
...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
|
|
9015
|
+
];
|
|
9016
|
+
return {
|
|
9017
|
+
generatedAt: Date.now(),
|
|
9018
|
+
missing,
|
|
9019
|
+
provider: "twilio",
|
|
9020
|
+
ready: missing.length === 0 && signingConfigured && warnings.length === 0,
|
|
9021
|
+
signing: {
|
|
9022
|
+
configured: signingConfigured,
|
|
9023
|
+
mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
|
|
9024
|
+
verificationUrl
|
|
9025
|
+
},
|
|
9026
|
+
urls: {
|
|
9027
|
+
stream,
|
|
9028
|
+
twiml,
|
|
9029
|
+
webhook
|
|
9030
|
+
},
|
|
9031
|
+
warnings
|
|
9032
|
+
};
|
|
9033
|
+
};
|
|
9034
|
+
var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
9035
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
|
|
9036
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
9037
|
+
<p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
|
|
9038
|
+
<section>
|
|
9039
|
+
<h2>URLs</h2>
|
|
9040
|
+
<ul>
|
|
9041
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(status.urls.twiml)}</code></li>
|
|
9042
|
+
<li><strong>Media stream:</strong> <code>${escapeHtml2(status.urls.stream)}</code></li>
|
|
9043
|
+
<li><strong>Status webhook:</strong> <code>${escapeHtml2(status.urls.webhook)}</code></li>
|
|
9044
|
+
</ul>
|
|
9045
|
+
</section>
|
|
9046
|
+
<section>
|
|
9047
|
+
<h2>Signing</h2>
|
|
9048
|
+
<p>Mode: <code>${status.signing.mode}</code></p>
|
|
9049
|
+
${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml2(status.signing.verificationUrl)}</code></p>` : ""}
|
|
9050
|
+
</section>
|
|
9051
|
+
${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml2(name)}</code></li>`).join("")}</ul></section>` : ""}
|
|
9052
|
+
${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml2(warning)}</li>`).join("")}</ul></section>` : ""}
|
|
9053
|
+
</main>`;
|
|
9054
|
+
var extractTwilioStreamUrl = (twiml) => twiml.match(/<Stream\b[^>]*\surl="([^"]+)"/i)?.[1]?.replaceAll("&", "&");
|
|
9055
|
+
var createSmokeCheck = (name, status, message, details) => ({
|
|
9056
|
+
details,
|
|
9057
|
+
message,
|
|
9058
|
+
name,
|
|
9059
|
+
status
|
|
9060
|
+
});
|
|
9061
|
+
var renderTwilioVoiceSmokeHTML = (report, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
9062
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio smoke test</p>
|
|
9063
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
9064
|
+
<p><strong>Status:</strong> ${report.pass ? "Pass" : "Fail"}</p>
|
|
9065
|
+
<section>
|
|
9066
|
+
<h2>Checks</h2>
|
|
9067
|
+
<ul>
|
|
9068
|
+
${report.checks.map((check) => `<li><strong>${escapeHtml2(check.name)}</strong>: ${escapeHtml2(check.status)}${check.message ? ` - ${escapeHtml2(check.message)}` : ""}</li>`).join("")}
|
|
9069
|
+
</ul>
|
|
9070
|
+
</section>
|
|
9071
|
+
<section>
|
|
9072
|
+
<h2>Observed URLs</h2>
|
|
9073
|
+
<ul>
|
|
9074
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(report.setup.urls.twiml)}</code></li>
|
|
9075
|
+
<li><strong>Stream:</strong> <code>${escapeHtml2(report.twiml?.streamUrl ?? report.setup.urls.stream)}</code></li>
|
|
9076
|
+
<li><strong>Webhook:</strong> <code>${escapeHtml2(report.setup.urls.webhook)}</code></li>
|
|
9077
|
+
</ul>
|
|
9078
|
+
</section>
|
|
9079
|
+
</main>`;
|
|
9080
|
+
var runTwilioVoiceSmokeTest = async (input) => {
|
|
9081
|
+
const setup = await buildTwilioVoiceSetupStatus(input.options, input);
|
|
9082
|
+
const checks = [];
|
|
9083
|
+
const twimlUrl = new URL(setup.urls.twiml);
|
|
9084
|
+
twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
|
|
9085
|
+
twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
|
|
9086
|
+
const twimlResponse = await input.app.handle(new Request(twimlUrl, {
|
|
9087
|
+
headers: input.request.headers
|
|
9088
|
+
}));
|
|
9089
|
+
const twiml = await twimlResponse.text();
|
|
9090
|
+
const streamUrl = extractTwilioStreamUrl(twiml);
|
|
9091
|
+
checks.push(createSmokeCheck("twiml", twimlResponse.ok && Boolean(streamUrl) ? "pass" : "fail", streamUrl ? "TwiML includes a media stream URL." : 'TwiML is missing <Stream url="...">.', {
|
|
9092
|
+
status: twimlResponse.status,
|
|
9093
|
+
streamUrl
|
|
9094
|
+
}));
|
|
9095
|
+
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.", {
|
|
9096
|
+
streamUrl
|
|
9097
|
+
}));
|
|
9098
|
+
const webhookBody = {
|
|
9099
|
+
CallSid: input.options.smoke?.callSid ?? "CA_SMOKE_TEST",
|
|
9100
|
+
CallStatus: input.options.smoke?.status ?? "busy",
|
|
9101
|
+
SipResponseCode: String(input.options.smoke?.sipCode ?? 486)
|
|
9102
|
+
};
|
|
9103
|
+
const webhookHeaders = new Headers({
|
|
9104
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
9105
|
+
});
|
|
9106
|
+
const verificationUrl = setup.signing.verificationUrl ?? setup.urls.webhook;
|
|
9107
|
+
if (input.options.webhook?.signingSecret) {
|
|
9108
|
+
webhookHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
|
|
9109
|
+
authToken: input.options.webhook.signingSecret,
|
|
9110
|
+
body: webhookBody,
|
|
9111
|
+
url: verificationUrl
|
|
9112
|
+
}));
|
|
9113
|
+
}
|
|
9114
|
+
const webhookResponse = await input.app.handle(new Request(setup.urls.webhook, {
|
|
9115
|
+
body: new URLSearchParams(webhookBody),
|
|
9116
|
+
headers: webhookHeaders,
|
|
9117
|
+
method: "POST"
|
|
9118
|
+
}));
|
|
9119
|
+
const webhookText = await webhookResponse.text();
|
|
9120
|
+
const webhookPayload = (() => {
|
|
9121
|
+
try {
|
|
9122
|
+
return JSON.parse(webhookText);
|
|
9123
|
+
} catch {
|
|
9124
|
+
return webhookText;
|
|
9125
|
+
}
|
|
9126
|
+
})();
|
|
9127
|
+
checks.push(createSmokeCheck("webhook", webhookResponse.ok ? "pass" : "fail", webhookResponse.ok ? "Synthetic Twilio status callback was accepted." : "Synthetic Twilio status callback failed.", {
|
|
9128
|
+
status: webhookResponse.status
|
|
9129
|
+
}));
|
|
9130
|
+
for (const warning of setup.warnings) {
|
|
9131
|
+
checks.push(createSmokeCheck("setup-warning", "warn", warning));
|
|
9132
|
+
}
|
|
9133
|
+
for (const name of setup.missing) {
|
|
9134
|
+
checks.push(createSmokeCheck("missing-env", "fail", `${name} is missing.`));
|
|
9135
|
+
}
|
|
9136
|
+
return {
|
|
9137
|
+
checks,
|
|
9138
|
+
generatedAt: Date.now(),
|
|
9139
|
+
pass: checks.every((check) => check.status !== "fail"),
|
|
9140
|
+
provider: "twilio",
|
|
9141
|
+
setup,
|
|
9142
|
+
twiml: {
|
|
9143
|
+
status: twimlResponse.status,
|
|
9144
|
+
streamUrl
|
|
9145
|
+
},
|
|
9146
|
+
webhook: {
|
|
9147
|
+
body: webhookPayload,
|
|
9148
|
+
status: webhookResponse.status
|
|
9149
|
+
}
|
|
9150
|
+
};
|
|
9151
|
+
};
|
|
7290
9152
|
var normalizeOnTurn = (handler) => {
|
|
7291
9153
|
if (handler.length > 1) {
|
|
7292
9154
|
const directHandler = handler;
|
|
@@ -7388,7 +9250,7 @@ var bytesToInt16Array = (bytes) => {
|
|
|
7388
9250
|
return output;
|
|
7389
9251
|
};
|
|
7390
9252
|
var decodeTwilioMulawBase64 = (payload) => {
|
|
7391
|
-
const bytes = Uint8Array.from(
|
|
9253
|
+
const bytes = Uint8Array.from(Buffer3.from(payload, "base64"));
|
|
7392
9254
|
const samples = new Int16Array(bytes.length);
|
|
7393
9255
|
for (let index = 0;index < bytes.length; index += 1) {
|
|
7394
9256
|
samples[index] = decodeMulawSample(bytes[index] ?? 0);
|
|
@@ -7400,7 +9262,7 @@ var encodeTwilioMulawBase64 = (samples) => {
|
|
|
7400
9262
|
for (let index = 0;index < samples.length; index += 1) {
|
|
7401
9263
|
bytes[index] = encodeMulawSample(samples[index] ?? 0);
|
|
7402
9264
|
}
|
|
7403
|
-
return
|
|
9265
|
+
return Buffer3.from(bytes).toString("base64");
|
|
7404
9266
|
};
|
|
7405
9267
|
var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
|
|
7406
9268
|
const narrowband = decodeTwilioMulawBase64(payload);
|
|
@@ -7409,7 +9271,7 @@ var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
|
|
|
7409
9271
|
};
|
|
7410
9272
|
var transcodePCMToTwilioOutboundPayload = (chunk, format) => {
|
|
7411
9273
|
if (format.container === "raw" && format.encoding === "mulaw" && format.channels === 1 && format.sampleRateHz === TWILIO_MULAW_SAMPLE_RATE) {
|
|
7412
|
-
return
|
|
9274
|
+
return Buffer3.from(chunk).toString("base64");
|
|
7413
9275
|
}
|
|
7414
9276
|
if (format.encoding !== "pcm_s16le") {
|
|
7415
9277
|
throw new Error(`Unsupported outbound telephony audio format: ${format.container}/${format.encoding}`);
|
|
@@ -7450,7 +9312,7 @@ var createTwilioSocketAdapter = (socket, getState) => ({
|
|
|
7450
9312
|
return;
|
|
7451
9313
|
}
|
|
7452
9314
|
if (message.type === "audio") {
|
|
7453
|
-
const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(
|
|
9315
|
+
const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer3.from(message.chunkBase64, "base64")), message.format);
|
|
7454
9316
|
state.hasOutboundAudioSinceLastInbound = true;
|
|
7455
9317
|
state.reviewRecorder?.recordTwilioOutbound({
|
|
7456
9318
|
bytes: payload.length,
|
|
@@ -7482,8 +9344,8 @@ var createTwilioSocketAdapter = (socket, getState) => ({
|
|
|
7482
9344
|
}
|
|
7483
9345
|
});
|
|
7484
9346
|
var createTwilioVoiceResponse = (options) => {
|
|
7485
|
-
const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${
|
|
7486
|
-
return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${
|
|
9347
|
+
const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml2(name)}" value="${escapeXml2(String(value))}" />`).join("");
|
|
9348
|
+
return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${escapeXml2(options.streamUrl)}"${options.track ? ` track="${escapeXml2(options.track)}"` : ""}${options.streamName ? ` name="${escapeXml2(options.streamName)}"` : ""}>${parameters}</Stream></Connect></Response>`;
|
|
7487
9349
|
};
|
|
7488
9350
|
var createTwilioMediaStreamBridge = (socket, options) => {
|
|
7489
9351
|
const runtimePreset = resolveVoiceRuntimePreset(options.preset);
|
|
@@ -7663,6 +9525,148 @@ var createTwilioMediaStreamBridge = (socket, options) => {
|
|
|
7663
9525
|
}
|
|
7664
9526
|
};
|
|
7665
9527
|
};
|
|
9528
|
+
var createTwilioVoiceRoutes = (options) => {
|
|
9529
|
+
const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
|
|
9530
|
+
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
9531
|
+
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
9532
|
+
const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
|
|
9533
|
+
const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
|
|
9534
|
+
const bridges = new WeakMap;
|
|
9535
|
+
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
9536
|
+
const app = new Elysia2({
|
|
9537
|
+
name: options.name ?? "absolutejs-voice-twilio"
|
|
9538
|
+
}).get(twimlPath, async ({ query, request }) => {
|
|
9539
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
9540
|
+
query,
|
|
9541
|
+
request,
|
|
9542
|
+
streamPath
|
|
9543
|
+
});
|
|
9544
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
9545
|
+
query,
|
|
9546
|
+
request
|
|
9547
|
+
});
|
|
9548
|
+
return new Response(createTwilioVoiceResponse({
|
|
9549
|
+
parameters,
|
|
9550
|
+
streamName: options.twiml?.streamName,
|
|
9551
|
+
streamUrl,
|
|
9552
|
+
track: options.twiml?.track
|
|
9553
|
+
}), {
|
|
9554
|
+
headers: {
|
|
9555
|
+
"content-type": "text/xml; charset=utf-8"
|
|
9556
|
+
}
|
|
9557
|
+
});
|
|
9558
|
+
}).post(twimlPath, async ({ query, request }) => {
|
|
9559
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
9560
|
+
query,
|
|
9561
|
+
request,
|
|
9562
|
+
streamPath
|
|
9563
|
+
});
|
|
9564
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
9565
|
+
query,
|
|
9566
|
+
request
|
|
9567
|
+
});
|
|
9568
|
+
return new Response(createTwilioVoiceResponse({
|
|
9569
|
+
parameters,
|
|
9570
|
+
streamName: options.twiml?.streamName,
|
|
9571
|
+
streamUrl,
|
|
9572
|
+
track: options.twiml?.track
|
|
9573
|
+
}), {
|
|
9574
|
+
headers: {
|
|
9575
|
+
"content-type": "text/xml; charset=utf-8"
|
|
9576
|
+
}
|
|
9577
|
+
});
|
|
9578
|
+
}).ws(streamPath, {
|
|
9579
|
+
close: async (ws, _code, reason) => {
|
|
9580
|
+
const bridge = bridges.get(ws);
|
|
9581
|
+
bridges.delete(ws);
|
|
9582
|
+
await bridge?.close(reason);
|
|
9583
|
+
},
|
|
9584
|
+
message: async (ws, raw) => {
|
|
9585
|
+
let bridge = bridges.get(ws);
|
|
9586
|
+
if (!bridge) {
|
|
9587
|
+
bridge = createTwilioMediaStreamBridge({
|
|
9588
|
+
close: (code, reason) => {
|
|
9589
|
+
ws.close(code, reason);
|
|
9590
|
+
},
|
|
9591
|
+
send: (data) => {
|
|
9592
|
+
ws.send(data);
|
|
9593
|
+
}
|
|
9594
|
+
}, options);
|
|
9595
|
+
bridges.set(ws, bridge);
|
|
9596
|
+
}
|
|
9597
|
+
await bridge.handleMessage(raw);
|
|
9598
|
+
}
|
|
9599
|
+
}).use(createVoiceTelephonyWebhookRoutes({
|
|
9600
|
+
...options.webhook ?? {},
|
|
9601
|
+
context: options.context,
|
|
9602
|
+
path: webhookPath,
|
|
9603
|
+
policy: webhookPolicy,
|
|
9604
|
+
provider: "twilio"
|
|
9605
|
+
}));
|
|
9606
|
+
if (!setupPath) {
|
|
9607
|
+
if (!smokePath) {
|
|
9608
|
+
return app;
|
|
9609
|
+
}
|
|
9610
|
+
return app.get(smokePath, async ({ query, request }) => {
|
|
9611
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
9612
|
+
app,
|
|
9613
|
+
options,
|
|
9614
|
+
query,
|
|
9615
|
+
request,
|
|
9616
|
+
streamPath,
|
|
9617
|
+
twimlPath,
|
|
9618
|
+
webhookPath
|
|
9619
|
+
});
|
|
9620
|
+
if (query.format === "html") {
|
|
9621
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
9622
|
+
headers: {
|
|
9623
|
+
"content-type": "text/html; charset=utf-8"
|
|
9624
|
+
}
|
|
9625
|
+
});
|
|
9626
|
+
}
|
|
9627
|
+
return report;
|
|
9628
|
+
});
|
|
9629
|
+
}
|
|
9630
|
+
const withSetup = app.get(setupPath, async ({ query, request }) => {
|
|
9631
|
+
const status = await buildTwilioVoiceSetupStatus(options, {
|
|
9632
|
+
query,
|
|
9633
|
+
request,
|
|
9634
|
+
streamPath,
|
|
9635
|
+
twimlPath,
|
|
9636
|
+
webhookPath
|
|
9637
|
+
});
|
|
9638
|
+
if (query.format === "html") {
|
|
9639
|
+
return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
|
|
9640
|
+
headers: {
|
|
9641
|
+
"content-type": "text/html; charset=utf-8"
|
|
9642
|
+
}
|
|
9643
|
+
});
|
|
9644
|
+
}
|
|
9645
|
+
return status;
|
|
9646
|
+
});
|
|
9647
|
+
if (!smokePath) {
|
|
9648
|
+
return withSetup;
|
|
9649
|
+
}
|
|
9650
|
+
return withSetup.get(smokePath, async ({ query, request }) => {
|
|
9651
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
9652
|
+
app,
|
|
9653
|
+
options,
|
|
9654
|
+
query,
|
|
9655
|
+
request,
|
|
9656
|
+
streamPath,
|
|
9657
|
+
twimlPath,
|
|
9658
|
+
webhookPath
|
|
9659
|
+
});
|
|
9660
|
+
if (query.format === "html") {
|
|
9661
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
9662
|
+
headers: {
|
|
9663
|
+
"content-type": "text/html; charset=utf-8"
|
|
9664
|
+
}
|
|
9665
|
+
});
|
|
9666
|
+
}
|
|
9667
|
+
return report;
|
|
9668
|
+
});
|
|
9669
|
+
};
|
|
7666
9670
|
|
|
7667
9671
|
// src/testing/telephony.ts
|
|
7668
9672
|
var DEFAULT_PCM16_FORMAT = {
|
|
@@ -7928,7 +9932,7 @@ var runVoiceTelephonyBenchmark = async (scenarios = getDefaultVoiceTelephonyBenc
|
|
|
7928
9932
|
};
|
|
7929
9933
|
};
|
|
7930
9934
|
// src/testing/tts.ts
|
|
7931
|
-
var
|
|
9935
|
+
var DEFAULT_REALTIME_FORMAT2 = {
|
|
7932
9936
|
channels: 1,
|
|
7933
9937
|
container: "raw",
|
|
7934
9938
|
encoding: "pcm_s16le",
|
|
@@ -7987,7 +9991,7 @@ var runTTSAdapterFixture = async (adapter, fixture, options = {}) => {
|
|
|
7987
9991
|
let audioDurationMs = 0;
|
|
7988
9992
|
let audioChunkCount = 0;
|
|
7989
9993
|
const session = adapter.kind === "realtime" ? await adapter.open({
|
|
7990
|
-
format: options.realtimeFormat ??
|
|
9994
|
+
format: options.realtimeFormat ?? DEFAULT_REALTIME_FORMAT2,
|
|
7991
9995
|
sessionId: `tts-benchmark:${fixture.id}`,
|
|
7992
9996
|
...openOptions ?? {}
|
|
7993
9997
|
}) : await adapter.open({
|
|
@@ -8154,6 +10158,7 @@ export {
|
|
|
8154
10158
|
getDefaultTTSBenchmarkFixtures,
|
|
8155
10159
|
evaluateSTTBenchmarkAcceptance,
|
|
8156
10160
|
createVoiceProviderFailureSimulator,
|
|
10161
|
+
createVoiceIOProviderFailureSimulator,
|
|
8157
10162
|
createVoiceCallReviewRecorder,
|
|
8158
10163
|
createVoiceCallReviewFromLiveTelephonyReport,
|
|
8159
10164
|
createTelephonyVoiceTestFixtures,
|