@absolutejs/voice 0.0.22-beta.32 → 0.0.22-beta.321
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 +3354 -55
- package/dist/agent.d.ts +62 -0
- package/dist/agentSquadContract.d.ts +98 -0
- package/dist/angular/index.d.ts +16 -0
- package/dist/angular/index.js +3911 -1128
- package/dist/angular/voice-agent-squad-status.service.d.ts +12 -0
- package/dist/angular/voice-campaign-dialer-proof.service.d.ts +14 -0
- package/dist/angular/voice-controller.service.d.ts +1 -0
- package/dist/angular/voice-delivery-runtime.component.d.ts +17 -0
- package/dist/angular/voice-delivery-runtime.service.d.ts +16 -0
- package/dist/angular/voice-live-ops.service.d.ts +11 -0
- package/dist/angular/voice-ops-action-center.service.d.ts +13 -0
- package/dist/angular/voice-ops-status.component.d.ts +15 -0
- package/dist/angular/voice-ops-status.service.d.ts +12 -0
- package/dist/angular/voice-platform-coverage.service.d.ts +12 -0
- package/dist/angular/voice-proof-trends.service.d.ts +12 -0
- package/dist/angular/voice-provider-capabilities.service.d.ts +12 -0
- package/dist/angular/voice-provider-contracts.service.d.ts +12 -0
- package/dist/angular/voice-readiness-failures.service.d.ts +13 -0
- package/dist/angular/voice-routing-status.service.d.ts +11 -0
- package/dist/angular/voice-stream.service.d.ts +1 -0
- package/dist/angular/voice-trace-timeline.service.d.ts +12 -0
- package/dist/angular/voice-turn-latency.service.d.ts +13 -0
- package/dist/angular/voice-turn-quality.service.d.ts +12 -0
- package/dist/angular/voice-workflow-status.service.d.ts +12 -0
- package/dist/audit.d.ts +128 -0
- package/dist/auditDeliveryRoutes.d.ts +85 -0
- package/dist/auditExport.d.ts +34 -0
- package/dist/auditRoutes.d.ts +66 -0
- package/dist/auditSinks.d.ts +151 -0
- package/dist/bargeInRoutes.d.ts +56 -0
- package/dist/browserMediaRoutes.d.ts +61 -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/browserMedia.d.ts +8 -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 +875 -14
- package/dist/client/index.d.ts +72 -0
- package/dist/client/index.js +5671 -19
- package/dist/client/liveOps.d.ts +22 -0
- package/dist/client/liveOpsWidget.d.ts +23 -0
- package/dist/client/liveTurnLatency.d.ts +41 -0
- package/dist/client/opsActionCenter.d.ts +54 -0
- package/dist/client/opsActionCenterWidget.d.ts +29 -0
- package/dist/client/opsActionHistory.d.ts +19 -0
- package/dist/client/opsActionHistoryWidget.d.ts +11 -0
- package/dist/client/opsStatus.d.ts +19 -0
- package/dist/client/opsStatusWidget.d.ts +40 -0
- package/dist/client/platformCoverage.d.ts +19 -0
- package/dist/client/platformCoverageWidget.d.ts +37 -0
- package/dist/client/proofTrends.d.ts +19 -0
- package/dist/client/proofTrendsWidget.d.ts +37 -0
- package/dist/client/providerCapabilities.d.ts +19 -0
- package/dist/client/providerCapabilitiesWidget.d.ts +32 -0
- package/dist/client/providerContracts.d.ts +19 -0
- package/dist/client/providerContractsWidget.d.ts +37 -0
- package/dist/client/providerSimulationControls.d.ts +33 -0
- package/dist/client/providerSimulationControlsWidget.d.ts +20 -0
- package/dist/client/providerStatusWidget.d.ts +32 -0
- package/dist/client/readinessFailures.d.ts +19 -0
- package/dist/client/readinessFailuresWidget.d.ts +42 -0
- package/dist/client/routingStatus.d.ts +19 -0
- package/dist/client/routingStatusWidget.d.ts +28 -0
- package/dist/client/traceTimeline.d.ts +19 -0
- package/dist/client/traceTimelineWidget.d.ts +36 -0
- package/dist/client/turnLatency.d.ts +22 -0
- package/dist/client/turnLatencyWidget.d.ts +33 -0
- package/dist/client/turnQuality.d.ts +19 -0
- package/dist/client/turnQualityWidget.d.ts +32 -0
- package/dist/client/workflowStatus.d.ts +19 -0
- package/dist/competitiveCoverage.d.ts +141 -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/incidentBundle.d.ts +116 -0
- package/dist/index.d.ts +146 -13
- package/dist/index.js +28615 -5228
- package/dist/latencySlo.d.ts +56 -0
- package/dist/liveLatency.d.ts +78 -0
- package/dist/liveOps.d.ts +190 -0
- package/dist/mediaPipelineRoutes.d.ts +117 -0
- package/dist/modelAdapters.d.ts +54 -2
- package/dist/observabilityExport.d.ts +481 -0
- package/dist/openaiTTS.d.ts +18 -0
- package/dist/operationsRecord.d.ts +254 -0
- package/dist/opsActionAuditRoutes.d.ts +99 -0
- package/dist/opsConsoleRoutes.d.ts +80 -0
- package/dist/opsRecovery.d.ts +137 -0
- package/dist/opsStatus.d.ts +76 -0
- package/dist/opsStatusRoutes.d.ts +33 -0
- package/dist/outcomeContract.d.ts +146 -0
- package/dist/phoneAgent.d.ts +139 -0
- package/dist/phoneAgentProductionSmoke.d.ts +115 -0
- package/dist/platformCoverage.d.ts +91 -0
- package/dist/postCallAnalysis.d.ts +98 -0
- package/dist/postgresStore.d.ts +13 -2
- package/dist/productionReadiness.d.ts +594 -0
- package/dist/proofTrends.d.ts +133 -0
- package/dist/providerAdapters.d.ts +48 -0
- package/dist/providerCapabilities.d.ts +92 -0
- package/dist/providerDecisionTraces.d.ts +130 -0
- package/dist/providerOrchestration.d.ts +109 -0
- package/dist/providerRoutingContract.d.ts +71 -0
- package/dist/providerSlo.d.ts +142 -0
- package/dist/providerStackRecommendations.d.ts +187 -0
- package/dist/qualityRoutes.d.ts +76 -0
- package/dist/queue.d.ts +9 -0
- package/dist/react/VoiceAgentSquadStatus.d.ts +5 -0
- package/dist/react/VoiceDeliveryRuntime.d.ts +7 -0
- package/dist/react/VoiceOpsActionCenter.d.ts +5 -0
- package/dist/react/VoiceOpsStatus.d.ts +6 -0
- package/dist/react/VoicePlatformCoverage.d.ts +6 -0
- package/dist/react/VoiceProofTrends.d.ts +6 -0
- package/dist/react/VoiceProviderCapabilities.d.ts +6 -0
- package/dist/react/VoiceProviderContracts.d.ts +6 -0
- package/dist/react/VoiceProviderSimulationControls.d.ts +5 -0
- package/dist/react/VoiceProviderStatus.d.ts +6 -0
- package/dist/react/VoiceReadinessFailures.d.ts +6 -0
- package/dist/react/VoiceRoutingStatus.d.ts +6 -0
- package/dist/react/VoiceTraceTimeline.d.ts +6 -0
- package/dist/react/VoiceTurnLatency.d.ts +6 -0
- package/dist/react/VoiceTurnQuality.d.ts +6 -0
- package/dist/react/index.d.ts +32 -0
- package/dist/react/index.js +5472 -31
- package/dist/react/useVoiceAgentSquadStatus.d.ts +8 -0
- package/dist/react/useVoiceCampaignDialerProof.d.ts +10 -0
- package/dist/react/useVoiceController.d.ts +1 -0
- package/dist/react/useVoiceDeliveryRuntime.d.ts +13 -0
- package/dist/react/useVoiceLiveOps.d.ts +9 -0
- package/dist/react/useVoiceOpsActionCenter.d.ts +11 -0
- package/dist/react/useVoiceOpsStatus.d.ts +8 -0
- package/dist/react/useVoicePlatformCoverage.d.ts +8 -0
- package/dist/react/useVoiceProofTrends.d.ts +8 -0
- package/dist/react/useVoiceProviderCapabilities.d.ts +8 -0
- package/dist/react/useVoiceProviderContracts.d.ts +8 -0
- package/dist/react/useVoiceProviderSimulationControls.d.ts +10 -0
- package/dist/react/useVoiceReadinessFailures.d.ts +8 -0
- package/dist/react/useVoiceRoutingStatus.d.ts +8 -0
- package/dist/react/useVoiceStream.d.ts +1 -0
- package/dist/react/useVoiceTraceTimeline.d.ts +8 -0
- package/dist/react/useVoiceTurnLatency.d.ts +9 -0
- package/dist/react/useVoiceTurnQuality.d.ts +8 -0
- package/dist/react/useVoiceWorkflowStatus.d.ts +8 -0
- package/dist/readinessProfiles.d.ts +38 -0
- package/dist/realtimeChannel.d.ts +136 -0
- package/dist/realtimeProviderContracts.d.ts +133 -0
- package/dist/reconnectContract.d.ts +88 -0
- package/dist/resilienceRoutes.d.ts +143 -0
- package/dist/sessionReplay.d.ts +12 -0
- package/dist/simulationSuite.d.ts +143 -0
- package/dist/sloCalibration.d.ts +185 -0
- package/dist/sqliteStore.d.ts +13 -2
- package/dist/svelte/createVoiceAgentSquadStatus.d.ts +9 -0
- package/dist/svelte/createVoiceCampaignDialerProof.d.ts +9 -0
- package/dist/svelte/createVoiceDeliveryRuntime.d.ts +11 -0
- package/dist/svelte/createVoiceLiveOps.d.ts +13 -0
- package/dist/svelte/createVoiceOpsActionCenter.d.ts +10 -0
- package/dist/svelte/createVoiceOpsStatus.d.ts +9 -0
- package/dist/svelte/createVoicePlatformCoverage.d.ts +7 -0
- package/dist/svelte/createVoiceProofTrends.d.ts +7 -0
- package/dist/svelte/createVoiceProviderCapabilities.d.ts +10 -0
- package/dist/svelte/createVoiceProviderContracts.d.ts +10 -0
- package/dist/svelte/createVoiceProviderSimulationControls.d.ts +11 -0
- package/dist/svelte/createVoiceProviderStatus.d.ts +4 -2
- package/dist/svelte/createVoiceReadinessFailures.d.ts +7 -0
- package/dist/svelte/createVoiceRoutingStatus.d.ts +10 -0
- package/dist/svelte/createVoiceTraceTimeline.d.ts +10 -0
- package/dist/svelte/createVoiceTurnLatency.d.ts +11 -0
- package/dist/svelte/createVoiceTurnQuality.d.ts +10 -0
- package/dist/svelte/createVoiceWorkflowStatus.d.ts +8 -0
- package/dist/svelte/index.d.ts +17 -0
- package/dist/svelte/index.js +5337 -420
- package/dist/telephony/contract.d.ts +61 -0
- package/dist/telephony/matrix.d.ts +97 -0
- package/dist/telephony/plivo.d.ts +303 -0
- package/dist/telephony/security.d.ts +182 -0
- package/dist/telephony/telnyx.d.ts +291 -0
- package/dist/telephony/twilio.d.ts +135 -2
- package/dist/telephonyOutcome.d.ts +273 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +2172 -76
- 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 +116 -3
- package/dist/voiceMonitoring.d.ts +444 -0
- package/dist/vue/VoiceDeliveryRuntime.d.ts +30 -0
- package/dist/vue/VoiceOpsActionCenter.d.ts +13 -0
- package/dist/vue/VoiceOpsStatus.d.ts +30 -0
- package/dist/vue/VoicePlatformCoverage.d.ts +23 -0
- package/dist/vue/VoiceProofTrends.d.ts +21 -0
- package/dist/vue/VoiceProviderCapabilities.d.ts +51 -0
- package/dist/vue/VoiceProviderContracts.d.ts +21 -0
- package/dist/vue/VoiceProviderSimulationControls.d.ts +88 -0
- package/dist/vue/VoiceProviderStatus.d.ts +51 -0
- package/dist/vue/VoiceReadinessFailures.d.ts +21 -0
- package/dist/vue/VoiceRoutingStatus.d.ts +51 -0
- package/dist/vue/VoiceTurnLatency.d.ts +69 -0
- package/dist/vue/VoiceTurnQuality.d.ts +51 -0
- package/dist/vue/index.d.ts +30 -0
- package/dist/vue/index.js +5241 -56
- package/dist/vue/useVoiceAgentSquadStatus.d.ts +9 -0
- package/dist/vue/useVoiceCampaignDialerProof.d.ts +11 -0
- package/dist/vue/useVoiceController.d.ts +2 -1
- package/dist/vue/useVoiceDeliveryRuntime.d.ts +13 -0
- package/dist/vue/useVoiceLiveOps.d.ts +9 -0
- package/dist/vue/useVoiceOpsActionCenter.d.ts +11 -0
- package/dist/vue/useVoiceOpsStatus.d.ts +9 -0
- package/dist/vue/useVoicePlatformCoverage.d.ts +9 -0
- package/dist/vue/useVoiceProofTrends.d.ts +9 -0
- package/dist/vue/useVoiceProviderCapabilities.d.ts +9 -0
- package/dist/vue/useVoiceProviderContracts.d.ts +9 -0
- package/dist/vue/useVoiceProviderSimulationControls.d.ts +24 -0
- package/dist/vue/useVoiceProviderStatus.d.ts +1 -1
- package/dist/vue/useVoiceReadinessFailures.d.ts +837 -0
- package/dist/vue/useVoiceRoutingStatus.d.ts +8 -0
- package/dist/vue/useVoiceStream.d.ts +2 -1
- package/dist/vue/useVoiceTraceTimeline.d.ts +9 -0
- package/dist/vue/useVoiceTurnLatency.d.ts +10 -0
- package/dist/vue/useVoiceTurnQuality.d.ts +9 -0
- package/dist/vue/useVoiceWorkflowStatus.d.ts +9 -0
- package/dist/workflowContract.d.ts +91 -0
- package/package.json +4 -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,
|
|
@@ -2143,6 +2159,412 @@ var serverMessageToAction = (message) => {
|
|
|
2143
2159
|
}
|
|
2144
2160
|
};
|
|
2145
2161
|
|
|
2162
|
+
// node_modules/@absolutejs/media/dist/index.js
|
|
2163
|
+
var formatLabel = (format) => `${format.container}/${format.encoding}/${String(format.sampleRateHz)}hz/${String(format.channels)}ch`;
|
|
2164
|
+
var formatMatches = (actual, expected) => actual.container === expected.container && actual.encoding === expected.encoding && actual.sampleRateHz === expected.sampleRateHz && actual.channels === expected.channels;
|
|
2165
|
+
var pushIssue = (issues, severity, code, message) => {
|
|
2166
|
+
issues.push({ code, message, severity });
|
|
2167
|
+
};
|
|
2168
|
+
var numericMetadata = (frame, key) => {
|
|
2169
|
+
const value = frame.metadata?.[key];
|
|
2170
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
2171
|
+
};
|
|
2172
|
+
var average3 = (values) => values.length === 0 ? undefined : values.reduce((total, value) => total + value, 0) / values.length;
|
|
2173
|
+
var max = (values) => values.length === 0 ? undefined : Math.max(...values);
|
|
2174
|
+
var min = (values) => values.length === 0 ? undefined : Math.min(...values);
|
|
2175
|
+
var numericStat = (stat, key) => {
|
|
2176
|
+
const value = stat[key];
|
|
2177
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
2178
|
+
};
|
|
2179
|
+
var booleanStat = (stat, key) => {
|
|
2180
|
+
const value = stat[key];
|
|
2181
|
+
return typeof value === "boolean" ? value : undefined;
|
|
2182
|
+
};
|
|
2183
|
+
var stringStat = (stat, key) => {
|
|
2184
|
+
const value = stat[key];
|
|
2185
|
+
return typeof value === "string" ? value : undefined;
|
|
2186
|
+
};
|
|
2187
|
+
var secondsToMs = (value) => value === undefined ? undefined : value * 1000;
|
|
2188
|
+
var normalizeWebRTCStat = (stat) => {
|
|
2189
|
+
const sample = {};
|
|
2190
|
+
for (const [key, value] of Object.entries(stat)) {
|
|
2191
|
+
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
2192
|
+
sample[key] = value;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
return sample;
|
|
2196
|
+
};
|
|
2197
|
+
var buildMediaResamplingPlan = (input) => {
|
|
2198
|
+
const required = !formatMatches(input.inputFormat, input.outputFormat);
|
|
2199
|
+
return {
|
|
2200
|
+
inputFormat: input.inputFormat,
|
|
2201
|
+
outputFormat: input.outputFormat,
|
|
2202
|
+
ratio: input.outputFormat.sampleRateHz / input.inputFormat.sampleRateHz,
|
|
2203
|
+
required,
|
|
2204
|
+
status: input.inputFormat.container === input.outputFormat.container && input.inputFormat.encoding === input.outputFormat.encoding && input.inputFormat.channels === input.outputFormat.channels ? "pass" : "warn"
|
|
2205
|
+
};
|
|
2206
|
+
};
|
|
2207
|
+
var speechProbability = (frame) => {
|
|
2208
|
+
if (frame.metadata?.isSpeech === true) {
|
|
2209
|
+
return 1;
|
|
2210
|
+
}
|
|
2211
|
+
if (frame.metadata?.isSpeech === false) {
|
|
2212
|
+
return 0;
|
|
2213
|
+
}
|
|
2214
|
+
for (const key of ["speechProbability", "voiceProbability", "rms", "energy"]) {
|
|
2215
|
+
const value = numericMetadata(frame, key);
|
|
2216
|
+
if (value !== undefined) {
|
|
2217
|
+
return value;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
return 0;
|
|
2221
|
+
};
|
|
2222
|
+
var buildMediaVadReport = (input = {}) => {
|
|
2223
|
+
const frames = (input.frames ?? []).filter((frame) => frame.kind === "input-audio");
|
|
2224
|
+
const speechStartThreshold = input.speechStartThreshold ?? 0.6;
|
|
2225
|
+
const speechEndThreshold = input.speechEndThreshold ?? 0.35;
|
|
2226
|
+
const minSpeechFrames = input.minSpeechFrames ?? 1;
|
|
2227
|
+
const maxSilenceFrames = input.maxSilenceFrames ?? 1;
|
|
2228
|
+
const segments = [];
|
|
2229
|
+
let activeFrames = [];
|
|
2230
|
+
let silenceFrames = 0;
|
|
2231
|
+
const closeSegment = () => {
|
|
2232
|
+
if (activeFrames.length < minSpeechFrames) {
|
|
2233
|
+
activeFrames = [];
|
|
2234
|
+
silenceFrames = 0;
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
const first = activeFrames[0];
|
|
2238
|
+
const last = activeFrames.at(-1);
|
|
2239
|
+
if (!first) {
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
segments.push({
|
|
2243
|
+
durationMs: first.at !== undefined && last?.at !== undefined ? last.at - first.at + (last.durationMs ?? 0) : undefined,
|
|
2244
|
+
endAt: last?.at !== undefined ? last.at + (last.durationMs ?? 0) : undefined,
|
|
2245
|
+
frameCount: activeFrames.length,
|
|
2246
|
+
segmentId: `vad:${String(segments.length + 1)}`,
|
|
2247
|
+
sessionId: first.sessionId,
|
|
2248
|
+
startAt: first.at,
|
|
2249
|
+
turnId: first.turnId
|
|
2250
|
+
});
|
|
2251
|
+
activeFrames = [];
|
|
2252
|
+
silenceFrames = 0;
|
|
2253
|
+
};
|
|
2254
|
+
for (const frame of frames) {
|
|
2255
|
+
const probability = speechProbability(frame);
|
|
2256
|
+
if (activeFrames.length === 0) {
|
|
2257
|
+
if (probability >= speechStartThreshold) {
|
|
2258
|
+
activeFrames.push(frame);
|
|
2259
|
+
}
|
|
2260
|
+
continue;
|
|
2261
|
+
}
|
|
2262
|
+
activeFrames.push(frame);
|
|
2263
|
+
if (probability <= speechEndThreshold) {
|
|
2264
|
+
silenceFrames += 1;
|
|
2265
|
+
} else {
|
|
2266
|
+
silenceFrames = 0;
|
|
2267
|
+
}
|
|
2268
|
+
if (silenceFrames > maxSilenceFrames) {
|
|
2269
|
+
closeSegment();
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
closeSegment();
|
|
2273
|
+
return {
|
|
2274
|
+
checkedAt: Date.now(),
|
|
2275
|
+
inputAudioFrames: frames.length,
|
|
2276
|
+
segments,
|
|
2277
|
+
status: frames.length === 0 ? "warn" : "pass"
|
|
2278
|
+
};
|
|
2279
|
+
};
|
|
2280
|
+
var buildMediaInterruptionReport = (input = {}) => {
|
|
2281
|
+
const issues = [];
|
|
2282
|
+
const interruptionFrames = (input.frames ?? []).filter((frame) => frame.kind === "interruption");
|
|
2283
|
+
const latenciesMs = interruptionFrames.map((frame) => frame.latencyMs).filter((latency) => typeof latency === "number");
|
|
2284
|
+
const maxInterruptionLatencyMs = input.maxInterruptionLatencyMs;
|
|
2285
|
+
if (interruptionFrames.length === 0) {
|
|
2286
|
+
pushIssue(issues, "warning", "media.interruption_missing", "No interruption frame was observed.");
|
|
2287
|
+
}
|
|
2288
|
+
if (maxInterruptionLatencyMs !== undefined && latenciesMs.some((latency) => latency > maxInterruptionLatencyMs)) {
|
|
2289
|
+
pushIssue(issues, "error", "media.interruption_latency", `Interruption latency exceeded ${String(maxInterruptionLatencyMs)}ms.`);
|
|
2290
|
+
}
|
|
2291
|
+
return {
|
|
2292
|
+
checkedAt: Date.now(),
|
|
2293
|
+
interruptionFrames: interruptionFrames.length,
|
|
2294
|
+
issues,
|
|
2295
|
+
latenciesMs,
|
|
2296
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass"
|
|
2297
|
+
};
|
|
2298
|
+
};
|
|
2299
|
+
var buildMediaQualityReport = (input = {}) => {
|
|
2300
|
+
const frames = [...input.frames ?? []].sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
|
|
2301
|
+
const audioFrames = frames.filter((frame) => frame.kind === "input-audio" || frame.kind === "assistant-audio");
|
|
2302
|
+
const inputAudioFrames = frames.filter((frame) => frame.kind === "input-audio");
|
|
2303
|
+
const assistantAudioFrames = frames.filter((frame) => frame.kind === "assistant-audio");
|
|
2304
|
+
const issues = [];
|
|
2305
|
+
const gapsMs = [];
|
|
2306
|
+
for (const [index, frame] of audioFrames.entries()) {
|
|
2307
|
+
const previous = audioFrames[index - 1];
|
|
2308
|
+
if (previous?.at === undefined || frame.at === undefined || previous.durationMs === undefined) {
|
|
2309
|
+
continue;
|
|
2310
|
+
}
|
|
2311
|
+
const gap = frame.at - (previous.at + previous.durationMs);
|
|
2312
|
+
if (gap > 0) {
|
|
2313
|
+
gapsMs.push(gap);
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
const jitterMs = audioFrames.map((frame) => numericMetadata(frame, "jitterMs")).filter((value) => value !== undefined).at(-1) ?? max(gapsMs);
|
|
2317
|
+
const first = audioFrames.find((frame) => frame.at !== undefined);
|
|
2318
|
+
const last = audioFrames.toReversed().find((frame) => frame.at !== undefined);
|
|
2319
|
+
const durationMs = first?.at !== undefined && last?.at !== undefined ? last.at - first.at + (last.durationMs ?? 0) : undefined;
|
|
2320
|
+
const expectedDurationMs = audioFrames.length > 0 ? audioFrames.reduce((total, frame) => total + (frame.durationMs ?? 0), 0) : undefined;
|
|
2321
|
+
const timestampDriftMs = durationMs !== undefined && expectedDurationMs !== undefined ? Math.max(0, durationMs - expectedDurationMs) : undefined;
|
|
2322
|
+
const speechScores = inputAudioFrames.map(speechProbability);
|
|
2323
|
+
const speechFrames = speechScores.filter((score) => score >= 0.6).length;
|
|
2324
|
+
const silenceFrames = speechScores.filter((score) => score <= 0.35).length;
|
|
2325
|
+
const unknownSpeechFrames = Math.max(0, inputAudioFrames.length - speechFrames - silenceFrames);
|
|
2326
|
+
const speechRatio = inputAudioFrames.length === 0 ? 0 : speechFrames / inputAudioFrames.length;
|
|
2327
|
+
const silenceRatio = inputAudioFrames.length === 0 ? 0 : silenceFrames / inputAudioFrames.length;
|
|
2328
|
+
const levels = audioFrames.map((frame) => numericMetadata(frame, "level") ?? numericMetadata(frame, "rms") ?? numericMetadata(frame, "energy")).filter((value) => value !== undefined);
|
|
2329
|
+
const backpressureEvents = input.transport?.backpressureEvents ?? 0;
|
|
2330
|
+
const maxGapMs = input.maxGapMs;
|
|
2331
|
+
if (maxGapMs !== undefined && gapsMs.some((gap) => gap > maxGapMs)) {
|
|
2332
|
+
pushIssue(issues, "warning", "media.quality_gap", `Observed media gap above ${String(maxGapMs)}ms.`);
|
|
2333
|
+
}
|
|
2334
|
+
if (input.maxJitterMs !== undefined && jitterMs !== undefined && jitterMs > input.maxJitterMs) {
|
|
2335
|
+
pushIssue(issues, "warning", "media.quality_jitter", `Observed jitter ${String(jitterMs)}ms above ${String(input.maxJitterMs)}ms.`);
|
|
2336
|
+
}
|
|
2337
|
+
if (input.maxTimestampDriftMs !== undefined && timestampDriftMs !== undefined && timestampDriftMs > input.maxTimestampDriftMs) {
|
|
2338
|
+
pushIssue(issues, "warning", "media.quality_timestamp_drift", `Observed timestamp drift ${String(timestampDriftMs)}ms above ${String(input.maxTimestampDriftMs)}ms.`);
|
|
2339
|
+
}
|
|
2340
|
+
if (input.minSpeechRatio !== undefined && inputAudioFrames.length > 0 && speechRatio < input.minSpeechRatio) {
|
|
2341
|
+
pushIssue(issues, "warning", "media.quality_speech_ratio", `Observed speech ratio ${String(speechRatio)} below ${String(input.minSpeechRatio)}.`);
|
|
2342
|
+
}
|
|
2343
|
+
if (input.maxBackpressureEvents !== undefined && backpressureEvents > input.maxBackpressureEvents) {
|
|
2344
|
+
pushIssue(issues, "warning", "media.quality_backpressure", `Observed ${String(backpressureEvents)} backpressure event(s), above ${String(input.maxBackpressureEvents)}.`);
|
|
2345
|
+
}
|
|
2346
|
+
return {
|
|
2347
|
+
assistantAudioFrames: assistantAudioFrames.length,
|
|
2348
|
+
backpressureEvents,
|
|
2349
|
+
checkedAt: Date.now(),
|
|
2350
|
+
durationMs,
|
|
2351
|
+
gapCount: gapsMs.length,
|
|
2352
|
+
gapsMs,
|
|
2353
|
+
inputAudioFrames: inputAudioFrames.length,
|
|
2354
|
+
issues,
|
|
2355
|
+
jitterMs,
|
|
2356
|
+
levelAverage: average3(levels),
|
|
2357
|
+
levelMax: max(levels),
|
|
2358
|
+
levelMin: min(levels),
|
|
2359
|
+
silenceFrames,
|
|
2360
|
+
silenceRatio,
|
|
2361
|
+
speechFrames,
|
|
2362
|
+
speechRatio,
|
|
2363
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
|
|
2364
|
+
timestampDriftMs,
|
|
2365
|
+
totalFrames: frames.length,
|
|
2366
|
+
unknownSpeechFrames
|
|
2367
|
+
};
|
|
2368
|
+
};
|
|
2369
|
+
var buildMediaWebRTCStatsReport = (input = {}) => {
|
|
2370
|
+
const stats = input.stats ?? [];
|
|
2371
|
+
const issues = [];
|
|
2372
|
+
const inbound = stats.filter((stat) => stat.type === "inbound-rtp" && stringStat(stat, "kind") !== "video");
|
|
2373
|
+
const outbound = stats.filter((stat) => stat.type === "outbound-rtp" && stringStat(stat, "kind") !== "video");
|
|
2374
|
+
const candidatePairs = stats.filter((stat) => stat.type === "candidate-pair");
|
|
2375
|
+
const audioTracks = stats.filter((stat) => (stat.type === "track" || stat.type === "media-source") && stringStat(stat, "kind") === "audio");
|
|
2376
|
+
const activeCandidatePairs = candidatePairs.filter((stat) => booleanStat(stat, "selected") === true || booleanStat(stat, "nominated") === true || stringStat(stat, "state") === "succeeded").length;
|
|
2377
|
+
const liveAudioTracks = audioTracks.filter((stat) => stringStat(stat, "readyState") !== "ended" && stringStat(stat, "trackState") !== "ended" && booleanStat(stat, "ended") !== true).length;
|
|
2378
|
+
const endedAudioTracks = audioTracks.filter((stat) => stringStat(stat, "readyState") === "ended" || stringStat(stat, "trackState") === "ended" || booleanStat(stat, "ended") === true).length;
|
|
2379
|
+
const inboundPackets = inbound.reduce((total, stat) => total + (numericStat(stat, "packetsReceived") ?? 0), 0);
|
|
2380
|
+
const outboundPackets = outbound.reduce((total, stat) => total + (numericStat(stat, "packetsSent") ?? 0), 0);
|
|
2381
|
+
const packetsLost = [...inbound, ...outbound].reduce((total, stat) => total + Math.max(0, numericStat(stat, "packetsLost") ?? 0), 0);
|
|
2382
|
+
const packetLossDenominator = inboundPackets + packetsLost;
|
|
2383
|
+
const packetLossRatio = packetLossDenominator === 0 ? 0 : packetsLost / packetLossDenominator;
|
|
2384
|
+
const bytesReceived = inbound.reduce((total, stat) => total + (numericStat(stat, "bytesReceived") ?? 0), 0);
|
|
2385
|
+
const bytesSent = outbound.reduce((total, stat) => total + (numericStat(stat, "bytesSent") ?? 0), 0);
|
|
2386
|
+
const roundTripTimeMs = max(candidatePairs.map((stat) => secondsToMs(numericStat(stat, "currentRoundTripTime") ?? numericStat(stat, "roundTripTime"))).filter((value) => value !== undefined));
|
|
2387
|
+
const jitterMs = max([...inbound, ...outbound].map((stat) => secondsToMs(numericStat(stat, "jitter"))).filter((value) => value !== undefined));
|
|
2388
|
+
const jitterBufferDelayMs = max(inbound.map((stat) => {
|
|
2389
|
+
const delay = numericStat(stat, "jitterBufferDelay");
|
|
2390
|
+
const emitted = numericStat(stat, "jitterBufferEmittedCount");
|
|
2391
|
+
return delay !== undefined && emitted !== undefined && emitted > 0 ? delay / emitted * 1000 : undefined;
|
|
2392
|
+
}).filter((value) => value !== undefined));
|
|
2393
|
+
const audioLevels = audioTracks.map((stat) => numericStat(stat, "audioLevel")).filter((value) => value !== undefined);
|
|
2394
|
+
if (input.requireConnectedCandidatePair && candidatePairs.length > 0 && activeCandidatePairs === 0) {
|
|
2395
|
+
pushIssue(issues, "error", "media.webrtc_candidate_pair_missing", "No active WebRTC candidate pair was observed.");
|
|
2396
|
+
}
|
|
2397
|
+
if (input.requireLiveAudioTrack && liveAudioTracks === 0) {
|
|
2398
|
+
pushIssue(issues, "error", "media.webrtc_audio_track_missing", "No live WebRTC audio track was observed.");
|
|
2399
|
+
}
|
|
2400
|
+
if (input.maxPacketLossRatio !== undefined && packetLossRatio > input.maxPacketLossRatio) {
|
|
2401
|
+
pushIssue(issues, "warning", "media.webrtc_packet_loss", `Observed WebRTC packet loss ratio ${String(packetLossRatio)} above ${String(input.maxPacketLossRatio)}.`);
|
|
2402
|
+
}
|
|
2403
|
+
if (input.maxRoundTripTimeMs !== undefined && roundTripTimeMs !== undefined && roundTripTimeMs > input.maxRoundTripTimeMs) {
|
|
2404
|
+
pushIssue(issues, "warning", "media.webrtc_round_trip_time", `Observed WebRTC RTT ${String(roundTripTimeMs)}ms above ${String(input.maxRoundTripTimeMs)}ms.`);
|
|
2405
|
+
}
|
|
2406
|
+
if (input.maxJitterMs !== undefined && jitterMs !== undefined && jitterMs > input.maxJitterMs) {
|
|
2407
|
+
pushIssue(issues, "warning", "media.webrtc_jitter", `Observed WebRTC jitter ${String(jitterMs)}ms above ${String(input.maxJitterMs)}ms.`);
|
|
2408
|
+
}
|
|
2409
|
+
return {
|
|
2410
|
+
activeCandidatePairs,
|
|
2411
|
+
audioLevelAverage: average3(audioLevels),
|
|
2412
|
+
bytesReceived,
|
|
2413
|
+
bytesSent,
|
|
2414
|
+
checkedAt: Date.now(),
|
|
2415
|
+
endedAudioTracks,
|
|
2416
|
+
inboundPackets,
|
|
2417
|
+
issues,
|
|
2418
|
+
jitterBufferDelayMs,
|
|
2419
|
+
jitterMs,
|
|
2420
|
+
liveAudioTracks,
|
|
2421
|
+
outboundPackets,
|
|
2422
|
+
packetLossRatio,
|
|
2423
|
+
packetsLost,
|
|
2424
|
+
roundTripTimeMs,
|
|
2425
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
|
|
2426
|
+
totalStats: stats.length
|
|
2427
|
+
};
|
|
2428
|
+
};
|
|
2429
|
+
var collectMediaWebRTCStats = async (input) => {
|
|
2430
|
+
const report = await input.peerConnection.getStats(input.selector ?? null);
|
|
2431
|
+
return [...report.values()].map(normalizeWebRTCStat);
|
|
2432
|
+
};
|
|
2433
|
+
var collectMediaWebRTCStatsReport = async (input) => {
|
|
2434
|
+
const stats = await collectMediaWebRTCStats(input);
|
|
2435
|
+
return buildMediaWebRTCStatsReport({
|
|
2436
|
+
...input,
|
|
2437
|
+
stats
|
|
2438
|
+
});
|
|
2439
|
+
};
|
|
2440
|
+
var buildMediaPipelineCalibrationReport = (input = {}) => {
|
|
2441
|
+
const frames = input.frames ?? [];
|
|
2442
|
+
const issues = [];
|
|
2443
|
+
const inputFrames = frames.filter((frame) => frame.kind === "input-audio");
|
|
2444
|
+
const assistantFrames = frames.filter((frame) => frame.kind === "assistant-audio");
|
|
2445
|
+
const turnCommitFrames = frames.filter((frame) => frame.kind === "turn-commit");
|
|
2446
|
+
const interruptionFrameRecords = frames.filter((frame) => frame.kind === "interruption");
|
|
2447
|
+
const traceLinkedFrames = frames.filter((frame) => frame.traceEventId).length;
|
|
2448
|
+
const backpressureFrames = frames.filter((frame) => Boolean(frame.metadata?.backpressure)).length;
|
|
2449
|
+
const audioLatencies = assistantFrames.map((frame) => frame.latencyMs).filter((latency) => typeof latency === "number");
|
|
2450
|
+
const firstAudioLatencyMs = audioLatencies.length > 0 ? Math.min(...audioLatencies) : undefined;
|
|
2451
|
+
const jitterValues = frames.map((frame) => numericMetadata(frame, "jitterMs")).filter((value) => value !== undefined);
|
|
2452
|
+
const jitterMs = jitterValues.length > 0 ? Math.max(...jitterValues) : undefined;
|
|
2453
|
+
const inputFormat = input.inputFormat ?? inputFrames.find((frame) => frame.format)?.format;
|
|
2454
|
+
const outputFormat = input.outputFormat ?? assistantFrames.find((frame) => frame.format)?.format;
|
|
2455
|
+
const resamplingRequired = Boolean(input.expectedInputFormat && inputFormat && inputFormat.sampleRateHz !== input.expectedInputFormat.sampleRateHz) || Boolean(input.expectedOutputFormat && outputFormat && outputFormat.sampleRateHz !== input.expectedOutputFormat.sampleRateHz);
|
|
2456
|
+
const resamplingTargetHz = resamplingRequired && input.expectedInputFormat ? input.expectedInputFormat.sampleRateHz : resamplingRequired ? input.expectedOutputFormat?.sampleRateHz : undefined;
|
|
2457
|
+
if (inputFrames.length === 0) {
|
|
2458
|
+
pushIssue(issues, "warning", "media.input_audio_missing", "No input audio frames were observed.");
|
|
2459
|
+
}
|
|
2460
|
+
if (assistantFrames.length === 0) {
|
|
2461
|
+
pushIssue(issues, "warning", "media.assistant_audio_missing", "No assistant audio frames were observed.");
|
|
2462
|
+
}
|
|
2463
|
+
if (input.expectedInputFormat && inputFormat && !formatMatches(inputFormat, input.expectedInputFormat)) {
|
|
2464
|
+
pushIssue(issues, inputFormat.sampleRateHz === input.expectedInputFormat.sampleRateHz ? "warning" : "error", "media.input_format_mismatch", `Input format ${formatLabel(inputFormat)} does not match expected ${formatLabel(input.expectedInputFormat)}.`);
|
|
2465
|
+
}
|
|
2466
|
+
if (input.expectedOutputFormat && outputFormat && !formatMatches(outputFormat, input.expectedOutputFormat)) {
|
|
2467
|
+
pushIssue(issues, outputFormat.sampleRateHz === input.expectedOutputFormat.sampleRateHz ? "warning" : "error", "media.output_format_mismatch", `Output format ${formatLabel(outputFormat)} does not match expected ${formatLabel(input.expectedOutputFormat)}.`);
|
|
2468
|
+
}
|
|
2469
|
+
if (firstAudioLatencyMs !== undefined && input.maxFirstAudioLatencyMs !== undefined && firstAudioLatencyMs > input.maxFirstAudioLatencyMs) {
|
|
2470
|
+
pushIssue(issues, "error", "media.first_audio_latency", `First audio latency ${String(firstAudioLatencyMs)}ms exceeds budget ${String(input.maxFirstAudioLatencyMs)}ms.`);
|
|
2471
|
+
}
|
|
2472
|
+
if (jitterMs !== undefined && input.maxJitterMs !== undefined && jitterMs > input.maxJitterMs) {
|
|
2473
|
+
pushIssue(issues, "warning", "media.jitter", `Media jitter ${String(jitterMs)}ms exceeds budget ${String(input.maxJitterMs)}ms.`);
|
|
2474
|
+
}
|
|
2475
|
+
if (input.maxBackpressureFrames !== undefined && backpressureFrames > input.maxBackpressureFrames) {
|
|
2476
|
+
pushIssue(issues, "warning", "media.backpressure", `Backpressure frame count ${String(backpressureFrames)} exceeds budget ${String(input.maxBackpressureFrames)}.`);
|
|
2477
|
+
}
|
|
2478
|
+
if (input.requireInterruptionFrame && interruptionFrameRecords.length === 0) {
|
|
2479
|
+
pushIssue(issues, "warning", "media.interruption_missing", "No interruption frame was observed.");
|
|
2480
|
+
}
|
|
2481
|
+
if (input.requireTraceEvidence && traceLinkedFrames === 0) {
|
|
2482
|
+
pushIssue(issues, "warning", "media.trace_evidence_missing", "No media frames were linked to trace evidence.");
|
|
2483
|
+
}
|
|
2484
|
+
return {
|
|
2485
|
+
assistantAudioFrames: assistantFrames.length,
|
|
2486
|
+
backpressureFrames,
|
|
2487
|
+
checkedAt: Date.now(),
|
|
2488
|
+
firstAudioLatencyMs,
|
|
2489
|
+
inputAudioFrames: inputFrames.length,
|
|
2490
|
+
inputFormat,
|
|
2491
|
+
interruptionFrames: interruptionFrameRecords.length,
|
|
2492
|
+
issues,
|
|
2493
|
+
jitterMs,
|
|
2494
|
+
outputFormat,
|
|
2495
|
+
resamplingRequired,
|
|
2496
|
+
resamplingTargetHz,
|
|
2497
|
+
status: issues.some((issue) => issue.severity === "error") ? "fail" : issues.length > 0 ? "warn" : "pass",
|
|
2498
|
+
surface: input.surface ?? "media-pipeline",
|
|
2499
|
+
traceLinkedFrames,
|
|
2500
|
+
turnCommitFrames: turnCommitFrames.length
|
|
2501
|
+
};
|
|
2502
|
+
};
|
|
2503
|
+
|
|
2504
|
+
// src/client/browserMedia.ts
|
|
2505
|
+
var DEFAULT_BROWSER_MEDIA_PATH = "/api/voice/browser-media";
|
|
2506
|
+
var DEFAULT_BROWSER_MEDIA_INTERVAL_MS = 5000;
|
|
2507
|
+
var resolvePeerConnection = async (options) => options.peerConnection ?? await options.getPeerConnection?.() ?? null;
|
|
2508
|
+
var postBrowserMediaReport = async (payload, options) => {
|
|
2509
|
+
const requestFetch = options.fetch ?? globalThis.fetch;
|
|
2510
|
+
if (!requestFetch) {
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
await requestFetch(options.path ?? DEFAULT_BROWSER_MEDIA_PATH, {
|
|
2514
|
+
body: JSON.stringify(payload),
|
|
2515
|
+
headers: {
|
|
2516
|
+
"Content-Type": "application/json"
|
|
2517
|
+
},
|
|
2518
|
+
keepalive: true,
|
|
2519
|
+
method: "POST"
|
|
2520
|
+
});
|
|
2521
|
+
};
|
|
2522
|
+
var createVoiceBrowserMediaReporter = (options) => {
|
|
2523
|
+
let interval = null;
|
|
2524
|
+
const reportOnce = async () => {
|
|
2525
|
+
const peerConnection = await resolvePeerConnection(options);
|
|
2526
|
+
if (!peerConnection) {
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
const report = await collectMediaWebRTCStatsReport({
|
|
2530
|
+
...options,
|
|
2531
|
+
peerConnection
|
|
2532
|
+
});
|
|
2533
|
+
const payload = {
|
|
2534
|
+
at: Date.now(),
|
|
2535
|
+
report,
|
|
2536
|
+
scenarioId: options.getScenarioId?.() ?? null,
|
|
2537
|
+
sessionId: options.getSessionId?.() ?? null
|
|
2538
|
+
};
|
|
2539
|
+
options.onReport?.(payload);
|
|
2540
|
+
await postBrowserMediaReport(payload, options);
|
|
2541
|
+
return payload;
|
|
2542
|
+
};
|
|
2543
|
+
const run = () => {
|
|
2544
|
+
reportOnce().catch((error) => {
|
|
2545
|
+
options.onError?.(error);
|
|
2546
|
+
});
|
|
2547
|
+
};
|
|
2548
|
+
const stop = () => {
|
|
2549
|
+
if (interval) {
|
|
2550
|
+
clearInterval(interval);
|
|
2551
|
+
interval = null;
|
|
2552
|
+
}
|
|
2553
|
+
};
|
|
2554
|
+
return {
|
|
2555
|
+
close: stop,
|
|
2556
|
+
reportOnce,
|
|
2557
|
+
start: () => {
|
|
2558
|
+
if (interval) {
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
run();
|
|
2562
|
+
interval = setInterval(run, options.intervalMs ?? DEFAULT_BROWSER_MEDIA_INTERVAL_MS);
|
|
2563
|
+
},
|
|
2564
|
+
stop
|
|
2565
|
+
};
|
|
2566
|
+
};
|
|
2567
|
+
|
|
2146
2568
|
// src/client/connection.ts
|
|
2147
2569
|
var WS_OPEN = 1;
|
|
2148
2570
|
var WS_CLOSED = 3;
|
|
@@ -2186,10 +2608,12 @@ var isVoiceServerMessage = (value) => {
|
|
|
2186
2608
|
case "assistant":
|
|
2187
2609
|
case "call_lifecycle":
|
|
2188
2610
|
case "complete":
|
|
2611
|
+
case "connection":
|
|
2189
2612
|
case "error":
|
|
2190
2613
|
case "final":
|
|
2191
2614
|
case "partial":
|
|
2192
2615
|
case "pong":
|
|
2616
|
+
case "replay":
|
|
2193
2617
|
case "session":
|
|
2194
2618
|
case "turn":
|
|
2195
2619
|
return true;
|
|
@@ -2226,6 +2650,9 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2226
2650
|
sessionId: options.sessionId ?? createSessionId(),
|
|
2227
2651
|
ws: null
|
|
2228
2652
|
};
|
|
2653
|
+
const emitConnection = (reconnect) => {
|
|
2654
|
+
listeners.forEach((listener) => listener(reconnect));
|
|
2655
|
+
};
|
|
2229
2656
|
const clearTimers = () => {
|
|
2230
2657
|
if (state.pingInterval) {
|
|
2231
2658
|
clearInterval(state.pingInterval);
|
|
@@ -2248,9 +2675,28 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2248
2675
|
}
|
|
2249
2676
|
};
|
|
2250
2677
|
const scheduleReconnect = () => {
|
|
2678
|
+
const nextAttemptAt = Date.now() + RECONNECT_DELAY_MS;
|
|
2251
2679
|
state.reconnectAttempts += 1;
|
|
2680
|
+
emitConnection({
|
|
2681
|
+
reconnect: {
|
|
2682
|
+
attempts: state.reconnectAttempts,
|
|
2683
|
+
lastDisconnectAt: Date.now(),
|
|
2684
|
+
maxAttempts: maxReconnectAttempts,
|
|
2685
|
+
nextAttemptAt,
|
|
2686
|
+
status: "reconnecting"
|
|
2687
|
+
},
|
|
2688
|
+
type: "connection"
|
|
2689
|
+
});
|
|
2252
2690
|
state.reconnectTimeout = setTimeout(() => {
|
|
2253
2691
|
if (state.reconnectAttempts > maxReconnectAttempts) {
|
|
2692
|
+
emitConnection({
|
|
2693
|
+
reconnect: {
|
|
2694
|
+
attempts: state.reconnectAttempts,
|
|
2695
|
+
maxAttempts: maxReconnectAttempts,
|
|
2696
|
+
status: "exhausted"
|
|
2697
|
+
},
|
|
2698
|
+
type: "connection"
|
|
2699
|
+
});
|
|
2254
2700
|
return;
|
|
2255
2701
|
}
|
|
2256
2702
|
connect();
|
|
@@ -2260,9 +2706,21 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2260
2706
|
const ws = new WebSocket(buildWsUrl(path, state.sessionId, state.scenarioId));
|
|
2261
2707
|
ws.binaryType = "arraybuffer";
|
|
2262
2708
|
ws.onopen = () => {
|
|
2709
|
+
const wasReconnecting = state.reconnectAttempts > 0;
|
|
2263
2710
|
state.isConnected = true;
|
|
2264
|
-
state.reconnectAttempts = 0;
|
|
2265
2711
|
flushPendingMessages();
|
|
2712
|
+
if (wasReconnecting) {
|
|
2713
|
+
emitConnection({
|
|
2714
|
+
reconnect: {
|
|
2715
|
+
attempts: state.reconnectAttempts,
|
|
2716
|
+
lastResumedAt: Date.now(),
|
|
2717
|
+
maxAttempts: maxReconnectAttempts,
|
|
2718
|
+
status: "resumed"
|
|
2719
|
+
},
|
|
2720
|
+
type: "connection"
|
|
2721
|
+
});
|
|
2722
|
+
state.reconnectAttempts = 0;
|
|
2723
|
+
}
|
|
2266
2724
|
listeners.forEach((listener) => listener({
|
|
2267
2725
|
scenarioId: state.scenarioId ?? undefined,
|
|
2268
2726
|
sessionId: state.sessionId,
|
|
@@ -2292,6 +2750,16 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2292
2750
|
const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && state.reconnectAttempts < maxReconnectAttempts;
|
|
2293
2751
|
if (reconnectable) {
|
|
2294
2752
|
scheduleReconnect();
|
|
2753
|
+
} else if (shouldReconnect && event.code !== WS_NORMAL_CLOSURE) {
|
|
2754
|
+
emitConnection({
|
|
2755
|
+
reconnect: {
|
|
2756
|
+
attempts: state.reconnectAttempts,
|
|
2757
|
+
lastDisconnectAt: Date.now(),
|
|
2758
|
+
maxAttempts: maxReconnectAttempts,
|
|
2759
|
+
status: "exhausted"
|
|
2760
|
+
},
|
|
2761
|
+
type: "connection"
|
|
2762
|
+
});
|
|
2295
2763
|
}
|
|
2296
2764
|
};
|
|
2297
2765
|
state.ws = ws;
|
|
@@ -2362,6 +2830,11 @@ var createVoiceConnection = (path, options = {}) => {
|
|
|
2362
2830
|
};
|
|
2363
2831
|
|
|
2364
2832
|
// src/client/store.ts
|
|
2833
|
+
var createInitialReconnectState = () => ({
|
|
2834
|
+
attempts: 0,
|
|
2835
|
+
maxAttempts: 0,
|
|
2836
|
+
status: "idle"
|
|
2837
|
+
});
|
|
2365
2838
|
var createInitialState2 = () => ({
|
|
2366
2839
|
assistantAudio: [],
|
|
2367
2840
|
assistantTexts: [],
|
|
@@ -2370,6 +2843,7 @@ var createInitialState2 = () => ({
|
|
|
2370
2843
|
isConnected: false,
|
|
2371
2844
|
scenarioId: null,
|
|
2372
2845
|
partial: "",
|
|
2846
|
+
reconnect: createInitialReconnectState(),
|
|
2373
2847
|
sessionId: null,
|
|
2374
2848
|
status: "idle",
|
|
2375
2849
|
turns: []
|
|
@@ -2426,7 +2900,19 @@ var createVoiceStreamStore = () => {
|
|
|
2426
2900
|
case "connected":
|
|
2427
2901
|
state = {
|
|
2428
2902
|
...state,
|
|
2429
|
-
isConnected: true
|
|
2903
|
+
isConnected: true,
|
|
2904
|
+
reconnect: state.reconnect.status === "reconnecting" ? {
|
|
2905
|
+
...state.reconnect,
|
|
2906
|
+
lastResumedAt: Date.now(),
|
|
2907
|
+
nextAttemptAt: undefined,
|
|
2908
|
+
status: "resumed"
|
|
2909
|
+
} : state.reconnect
|
|
2910
|
+
};
|
|
2911
|
+
break;
|
|
2912
|
+
case "connection":
|
|
2913
|
+
state = {
|
|
2914
|
+
...state,
|
|
2915
|
+
reconnect: action.reconnect
|
|
2430
2916
|
};
|
|
2431
2917
|
break;
|
|
2432
2918
|
case "disconnected":
|
|
@@ -2454,6 +2940,26 @@ var createVoiceStreamStore = () => {
|
|
|
2454
2940
|
partial: action.transcript.text
|
|
2455
2941
|
};
|
|
2456
2942
|
break;
|
|
2943
|
+
case "replay":
|
|
2944
|
+
state = {
|
|
2945
|
+
...state,
|
|
2946
|
+
assistantTexts: [...action.assistantTexts],
|
|
2947
|
+
call: action.call ?? null,
|
|
2948
|
+
error: null,
|
|
2949
|
+
isConnected: action.status === "active",
|
|
2950
|
+
partial: action.partial,
|
|
2951
|
+
reconnect: state.reconnect.status === "reconnecting" ? {
|
|
2952
|
+
...state.reconnect,
|
|
2953
|
+
lastResumedAt: Date.now(),
|
|
2954
|
+
nextAttemptAt: undefined,
|
|
2955
|
+
status: "resumed"
|
|
2956
|
+
} : state.reconnect,
|
|
2957
|
+
scenarioId: action.scenarioId ?? state.scenarioId,
|
|
2958
|
+
sessionId: action.sessionId,
|
|
2959
|
+
status: action.status,
|
|
2960
|
+
turns: [...action.turns]
|
|
2961
|
+
};
|
|
2962
|
+
break;
|
|
2457
2963
|
case "session":
|
|
2458
2964
|
state = {
|
|
2459
2965
|
...state,
|
|
@@ -2491,20 +2997,50 @@ var createVoiceStreamStore = () => {
|
|
|
2491
2997
|
var createVoiceStream = (path, options = {}) => {
|
|
2492
2998
|
const connection = createVoiceConnection(path, options);
|
|
2493
2999
|
const store = createVoiceStreamStore();
|
|
3000
|
+
const browserMediaReporter = options.browserMedia && typeof window !== "undefined" ? createVoiceBrowserMediaReporter({
|
|
3001
|
+
...options.browserMedia,
|
|
3002
|
+
getScenarioId: () => options.browserMedia ? options.browserMedia.getScenarioId?.() ?? connection.getScenarioId() : connection.getScenarioId(),
|
|
3003
|
+
getSessionId: () => options.browserMedia ? options.browserMedia.getSessionId?.() ?? connection.getSessionId() : connection.getSessionId()
|
|
3004
|
+
}) : null;
|
|
2494
3005
|
const subscribers = new Set;
|
|
2495
3006
|
const start = (input) => Promise.resolve().then(() => {
|
|
2496
3007
|
if (!input?.sessionId && !input?.scenarioId) {
|
|
2497
3008
|
return;
|
|
2498
3009
|
}
|
|
2499
3010
|
connection.start(input);
|
|
3011
|
+
browserMediaReporter?.start();
|
|
2500
3012
|
});
|
|
2501
3013
|
const notify = () => {
|
|
2502
3014
|
subscribers.forEach((subscriber) => subscriber());
|
|
2503
3015
|
};
|
|
3016
|
+
const reportReconnect = () => {
|
|
3017
|
+
if (!options.reconnectReportPath || typeof fetch === "undefined") {
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
const snapshot = store.getSnapshot();
|
|
3021
|
+
const body = JSON.stringify({
|
|
3022
|
+
at: Date.now(),
|
|
3023
|
+
reconnect: snapshot.reconnect,
|
|
3024
|
+
scenarioId: snapshot.scenarioId,
|
|
3025
|
+
sessionId: connection.getSessionId(),
|
|
3026
|
+
turnIds: snapshot.turns.map((turn) => turn.id)
|
|
3027
|
+
});
|
|
3028
|
+
fetch(options.reconnectReportPath, {
|
|
3029
|
+
body,
|
|
3030
|
+
headers: {
|
|
3031
|
+
"Content-Type": "application/json"
|
|
3032
|
+
},
|
|
3033
|
+
keepalive: true,
|
|
3034
|
+
method: "POST"
|
|
3035
|
+
}).catch(() => {});
|
|
3036
|
+
};
|
|
2504
3037
|
const unsubscribeConnection = connection.subscribe((message) => {
|
|
2505
3038
|
const action = serverMessageToAction(message);
|
|
2506
3039
|
if (action) {
|
|
2507
3040
|
store.dispatch(action);
|
|
3041
|
+
if (message.type === "connection") {
|
|
3042
|
+
reportReconnect();
|
|
3043
|
+
}
|
|
2508
3044
|
notify();
|
|
2509
3045
|
}
|
|
2510
3046
|
});
|
|
@@ -2514,6 +3050,7 @@ var createVoiceStream = (path, options = {}) => {
|
|
|
2514
3050
|
},
|
|
2515
3051
|
close() {
|
|
2516
3052
|
unsubscribeConnection();
|
|
3053
|
+
browserMediaReporter?.close();
|
|
2517
3054
|
connection.close();
|
|
2518
3055
|
store.dispatch({ type: "disconnected" });
|
|
2519
3056
|
notify();
|
|
@@ -2540,6 +3077,9 @@ var createVoiceStream = (path, options = {}) => {
|
|
|
2540
3077
|
get partial() {
|
|
2541
3078
|
return store.getSnapshot().partial;
|
|
2542
3079
|
},
|
|
3080
|
+
get reconnect() {
|
|
3081
|
+
return store.getSnapshot().reconnect;
|
|
3082
|
+
},
|
|
2543
3083
|
get sessionId() {
|
|
2544
3084
|
return connection.getSessionId();
|
|
2545
3085
|
},
|
|
@@ -2895,6 +3435,7 @@ var createInitialState3 = (stream) => ({
|
|
|
2895
3435
|
isConnected: stream.isConnected,
|
|
2896
3436
|
isRecording: false,
|
|
2897
3437
|
partial: stream.partial,
|
|
3438
|
+
reconnect: stream.reconnect,
|
|
2898
3439
|
recordingError: null,
|
|
2899
3440
|
sessionId: stream.sessionId,
|
|
2900
3441
|
scenarioId: stream.scenarioId,
|
|
@@ -2924,6 +3465,7 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2924
3465
|
error: stream.error,
|
|
2925
3466
|
isConnected: stream.isConnected,
|
|
2926
3467
|
partial: stream.partial,
|
|
3468
|
+
reconnect: stream.reconnect,
|
|
2927
3469
|
sessionId: stream.sessionId,
|
|
2928
3470
|
scenarioId: stream.scenarioId,
|
|
2929
3471
|
status: stream.status,
|
|
@@ -2948,7 +3490,13 @@ var createVoiceController = (path, options = {}) => {
|
|
|
2948
3490
|
capture = createMicrophoneCapture({
|
|
2949
3491
|
channelCount: options.capture?.channelCount ?? preset.capture.channelCount,
|
|
2950
3492
|
onLevel: options.capture?.onLevel,
|
|
2951
|
-
onAudio: (audio) =>
|
|
3493
|
+
onAudio: (audio) => {
|
|
3494
|
+
if (options.capture?.onAudio) {
|
|
3495
|
+
options.capture.onAudio(audio, stream.sendAudio);
|
|
3496
|
+
return;
|
|
3497
|
+
}
|
|
3498
|
+
stream.sendAudio(audio);
|
|
3499
|
+
},
|
|
2952
3500
|
sampleRateHz: options.capture?.sampleRateHz ?? preset.capture.sampleRateHz
|
|
2953
3501
|
});
|
|
2954
3502
|
return capture;
|
|
@@ -3018,6 +3566,9 @@ var createVoiceController = (path, options = {}) => {
|
|
|
3018
3566
|
get recordingError() {
|
|
3019
3567
|
return state.recordingError;
|
|
3020
3568
|
},
|
|
3569
|
+
get reconnect() {
|
|
3570
|
+
return state.reconnect;
|
|
3571
|
+
},
|
|
3021
3572
|
sendAudio: (audio) => stream.sendAudio(audio),
|
|
3022
3573
|
get sessionId() {
|
|
3023
3574
|
return state.sessionId;
|
|
@@ -3063,11 +3614,26 @@ var DEFAULT_INTERRUPT_THRESHOLD = 0.08;
|
|
|
3063
3614
|
var shouldInterruptForLevel = (level, options = {}) => (options.enabled ?? true) && level >= (options.interruptThreshold ?? DEFAULT_INTERRUPT_THRESHOLD);
|
|
3064
3615
|
var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
3065
3616
|
let lastPartial = controller.partial;
|
|
3066
|
-
const interruptIfPlaying = () => {
|
|
3617
|
+
const interruptIfPlaying = (reason) => {
|
|
3067
3618
|
if (!player.isPlaying || options.enabled === false) {
|
|
3619
|
+
options.monitor?.recordSkipped({
|
|
3620
|
+
reason,
|
|
3621
|
+
sessionId: controller.sessionId
|
|
3622
|
+
});
|
|
3068
3623
|
return;
|
|
3069
3624
|
}
|
|
3070
|
-
|
|
3625
|
+
options.monitor?.recordRequested({
|
|
3626
|
+
reason,
|
|
3627
|
+
sessionId: controller.sessionId
|
|
3628
|
+
});
|
|
3629
|
+
player.interrupt().then(() => {
|
|
3630
|
+
options.monitor?.recordStopped({
|
|
3631
|
+
latencyMs: player.lastInterruptLatencyMs,
|
|
3632
|
+
playbackStopLatencyMs: player.lastPlaybackStopLatencyMs,
|
|
3633
|
+
reason,
|
|
3634
|
+
sessionId: controller.sessionId
|
|
3635
|
+
});
|
|
3636
|
+
});
|
|
3071
3637
|
};
|
|
3072
3638
|
const unsubscribe = controller.subscribe(() => {
|
|
3073
3639
|
if (options.interruptOnPartial === false) {
|
|
@@ -3075,7 +3641,7 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
|
3075
3641
|
return;
|
|
3076
3642
|
}
|
|
3077
3643
|
if (!lastPartial && controller.partial) {
|
|
3078
|
-
interruptIfPlaying();
|
|
3644
|
+
interruptIfPlaying("partial-transcript");
|
|
3079
3645
|
}
|
|
3080
3646
|
lastPartial = controller.partial;
|
|
3081
3647
|
});
|
|
@@ -3085,11 +3651,11 @@ var bindVoiceBargeIn = (controller, player, options = {}) => {
|
|
|
3085
3651
|
},
|
|
3086
3652
|
handleLevel: (level) => {
|
|
3087
3653
|
if (shouldInterruptForLevel(level, options)) {
|
|
3088
|
-
interruptIfPlaying();
|
|
3654
|
+
interruptIfPlaying("input-level");
|
|
3089
3655
|
}
|
|
3090
3656
|
},
|
|
3091
3657
|
sendAudio: (audio) => {
|
|
3092
|
-
interruptIfPlaying();
|
|
3658
|
+
interruptIfPlaying("manual-audio");
|
|
3093
3659
|
controller.sendAudio(audio);
|
|
3094
3660
|
}
|
|
3095
3661
|
};
|
|
@@ -3119,7 +3685,17 @@ var createVoiceDuplexController = (path, options = {}) => {
|
|
|
3119
3685
|
audioPlayer,
|
|
3120
3686
|
close,
|
|
3121
3687
|
interruptAssistant: async () => {
|
|
3688
|
+
options.bargeIn?.monitor?.recordRequested({
|
|
3689
|
+
reason: "manual-interrupt",
|
|
3690
|
+
sessionId: controller.sessionId
|
|
3691
|
+
});
|
|
3122
3692
|
await audioPlayer.interrupt();
|
|
3693
|
+
options.bargeIn?.monitor?.recordStopped({
|
|
3694
|
+
latencyMs: audioPlayer.lastInterruptLatencyMs,
|
|
3695
|
+
playbackStopLatencyMs: audioPlayer.lastPlaybackStopLatencyMs,
|
|
3696
|
+
reason: "manual-interrupt",
|
|
3697
|
+
sessionId: controller.sessionId
|
|
3698
|
+
});
|
|
3123
3699
|
},
|
|
3124
3700
|
sendAudio: (audio) => {
|
|
3125
3701
|
bargeInBinding?.sendAudio(audio);
|
|
@@ -3510,7 +4086,235 @@ var loadVoiceTestFixtures = async (fixtureDirectory) => {
|
|
|
3510
4086
|
}
|
|
3511
4087
|
return fixtures;
|
|
3512
4088
|
};
|
|
4089
|
+
// src/testing/ioProviderSimulator.ts
|
|
4090
|
+
var defaultFailureMessage = (input) => `Simulated ${input.provider} ${input.kind.toUpperCase()} ${input.operation} failure.`;
|
|
4091
|
+
var resolveRecoveryElapsedMs = (value, provider) => {
|
|
4092
|
+
if (typeof value === "number") {
|
|
4093
|
+
return value;
|
|
4094
|
+
}
|
|
4095
|
+
return value?.[provider] ?? 25;
|
|
4096
|
+
};
|
|
4097
|
+
var createHealth = (input) => ({
|
|
4098
|
+
consecutiveFailures: input.status === "healthy" ? 0 : 1,
|
|
4099
|
+
lastFailureAt: input.status === "healthy" ? undefined : input.now,
|
|
4100
|
+
provider: input.provider,
|
|
4101
|
+
status: input.status,
|
|
4102
|
+
suppressedUntil: input.suppressedUntil
|
|
4103
|
+
});
|
|
4104
|
+
var resolveFallback = async (options, provider) => {
|
|
4105
|
+
const configured = typeof options.fallback === "function" ? await options.fallback(provider) : options.fallback;
|
|
4106
|
+
return (configured ?? options.providers).find((candidate) => candidate !== provider);
|
|
4107
|
+
};
|
|
4108
|
+
var createVoiceIOProviderFailureSimulator = (options) => {
|
|
4109
|
+
if (options.providers.length === 0) {
|
|
4110
|
+
throw new Error("At least one provider is required.");
|
|
4111
|
+
}
|
|
4112
|
+
const now = options.now ?? Date.now;
|
|
4113
|
+
const operation = options.operation ?? "open";
|
|
4114
|
+
const cooldownMs = Math.max(0, options.cooldownMs ?? 30000);
|
|
4115
|
+
const emit = async (event, input) => {
|
|
4116
|
+
await options.onProviderEvent?.(event, input);
|
|
4117
|
+
};
|
|
4118
|
+
const run = async (provider, mode) => {
|
|
4119
|
+
if (!options.providers.includes(provider)) {
|
|
4120
|
+
throw new Error(`${provider} is not configured for simulation.`);
|
|
4121
|
+
}
|
|
4122
|
+
const startedAt = now();
|
|
4123
|
+
const sessionId = options.sessionId?.({ mode, now: startedAt, provider }) ?? `${options.kind}-provider-sim-${startedAt}`;
|
|
4124
|
+
if (mode === "recovery") {
|
|
4125
|
+
await emit({
|
|
4126
|
+
at: startedAt,
|
|
4127
|
+
attempt: 0,
|
|
4128
|
+
elapsedMs: resolveRecoveryElapsedMs(options.recoveryElapsedMs, provider),
|
|
4129
|
+
kind: options.kind,
|
|
4130
|
+
latencyBudgetMs: options.latencyBudgets?.[provider],
|
|
4131
|
+
operation,
|
|
4132
|
+
provider,
|
|
4133
|
+
providerHealth: createHealth({
|
|
4134
|
+
now: startedAt,
|
|
4135
|
+
provider,
|
|
4136
|
+
status: "healthy"
|
|
4137
|
+
}),
|
|
4138
|
+
selectedProvider: provider,
|
|
4139
|
+
status: "success"
|
|
4140
|
+
}, { mode, provider, sessionId });
|
|
4141
|
+
return {
|
|
4142
|
+
mode,
|
|
4143
|
+
provider,
|
|
4144
|
+
sessionId,
|
|
4145
|
+
status: "simulated"
|
|
4146
|
+
};
|
|
4147
|
+
}
|
|
4148
|
+
const fallbackProvider = await resolveFallback(options, provider);
|
|
4149
|
+
const suppressedUntil = startedAt + cooldownMs;
|
|
4150
|
+
await emit({
|
|
4151
|
+
at: startedAt,
|
|
4152
|
+
attempt: 0,
|
|
4153
|
+
elapsedMs: options.failureElapsedMs ?? 10,
|
|
4154
|
+
error: (options.failureMessage ?? defaultFailureMessage)({
|
|
4155
|
+
kind: options.kind,
|
|
4156
|
+
operation,
|
|
4157
|
+
provider
|
|
4158
|
+
}),
|
|
4159
|
+
fallbackProvider,
|
|
4160
|
+
kind: options.kind,
|
|
4161
|
+
latencyBudgetMs: options.latencyBudgets?.[provider],
|
|
4162
|
+
operation,
|
|
4163
|
+
provider,
|
|
4164
|
+
providerHealth: createHealth({
|
|
4165
|
+
now: startedAt,
|
|
4166
|
+
provider,
|
|
4167
|
+
status: "suppressed",
|
|
4168
|
+
suppressedUntil
|
|
4169
|
+
}),
|
|
4170
|
+
selectedProvider: provider,
|
|
4171
|
+
status: "error",
|
|
4172
|
+
suppressedUntil
|
|
4173
|
+
}, { mode, provider, sessionId });
|
|
4174
|
+
if (fallbackProvider) {
|
|
4175
|
+
await emit({
|
|
4176
|
+
at: startedAt + 1,
|
|
4177
|
+
attempt: 1,
|
|
4178
|
+
elapsedMs: resolveRecoveryElapsedMs(options.recoveryElapsedMs, fallbackProvider),
|
|
4179
|
+
fallbackProvider,
|
|
4180
|
+
kind: options.kind,
|
|
4181
|
+
latencyBudgetMs: options.latencyBudgets?.[fallbackProvider],
|
|
4182
|
+
operation,
|
|
4183
|
+
provider: fallbackProvider,
|
|
4184
|
+
providerHealth: createHealth({
|
|
4185
|
+
now: startedAt + 1,
|
|
4186
|
+
provider: fallbackProvider,
|
|
4187
|
+
status: "healthy"
|
|
4188
|
+
}),
|
|
4189
|
+
selectedProvider: provider,
|
|
4190
|
+
status: "fallback"
|
|
4191
|
+
}, { mode, provider, sessionId });
|
|
4192
|
+
}
|
|
4193
|
+
return {
|
|
4194
|
+
fallbackProvider,
|
|
4195
|
+
mode,
|
|
4196
|
+
provider,
|
|
4197
|
+
sessionId,
|
|
4198
|
+
status: "simulated",
|
|
4199
|
+
suppressedUntil
|
|
4200
|
+
};
|
|
4201
|
+
};
|
|
4202
|
+
return {
|
|
4203
|
+
run
|
|
4204
|
+
};
|
|
4205
|
+
};
|
|
3513
4206
|
// src/modelAdapters.ts
|
|
4207
|
+
var isVoiceProviderRoutingPolicyPreset = (value) => value === "balanced" || value === "cost-cap" || value === "cost-first" || value === "latency-first" || value === "quality-first";
|
|
4208
|
+
var resolveVoiceProviderRoutingPolicyPreset = (preset, options = {}) => {
|
|
4209
|
+
switch (preset) {
|
|
4210
|
+
case "balanced":
|
|
4211
|
+
return {
|
|
4212
|
+
fallbackMode: "provider-error",
|
|
4213
|
+
strategy: "balanced",
|
|
4214
|
+
weights: {
|
|
4215
|
+
cost: 1,
|
|
4216
|
+
latencyMs: 0.005,
|
|
4217
|
+
priority: 1,
|
|
4218
|
+
quality: 10,
|
|
4219
|
+
...options.weights
|
|
4220
|
+
},
|
|
4221
|
+
...options
|
|
4222
|
+
};
|
|
4223
|
+
case "cost-cap":
|
|
4224
|
+
return {
|
|
4225
|
+
fallbackMode: "provider-error",
|
|
4226
|
+
strategy: "prefer-cheapest",
|
|
4227
|
+
...options
|
|
4228
|
+
};
|
|
4229
|
+
case "cost-first":
|
|
4230
|
+
return {
|
|
4231
|
+
fallbackMode: "provider-error",
|
|
4232
|
+
strategy: "prefer-cheapest",
|
|
4233
|
+
...options
|
|
4234
|
+
};
|
|
4235
|
+
case "latency-first":
|
|
4236
|
+
return {
|
|
4237
|
+
fallbackMode: "provider-error",
|
|
4238
|
+
strategy: "prefer-fastest",
|
|
4239
|
+
...options
|
|
4240
|
+
};
|
|
4241
|
+
case "quality-first":
|
|
4242
|
+
return {
|
|
4243
|
+
fallbackMode: "provider-error",
|
|
4244
|
+
strategy: "quality-first",
|
|
4245
|
+
...options
|
|
4246
|
+
};
|
|
4247
|
+
}
|
|
4248
|
+
};
|
|
4249
|
+
var resolveVoiceProviderRoutingPolicy = (policy) => {
|
|
4250
|
+
if (!policy) {
|
|
4251
|
+
return;
|
|
4252
|
+
}
|
|
4253
|
+
if (typeof policy === "string") {
|
|
4254
|
+
return isVoiceProviderRoutingPolicyPreset(policy) ? resolveVoiceProviderRoutingPolicyPreset(policy) : {
|
|
4255
|
+
strategy: policy
|
|
4256
|
+
};
|
|
4257
|
+
}
|
|
4258
|
+
return policy;
|
|
4259
|
+
};
|
|
4260
|
+
var mergeDefinedProviderPolicyFields = (base, surface) => {
|
|
4261
|
+
const next = {
|
|
4262
|
+
...base ?? {}
|
|
4263
|
+
};
|
|
4264
|
+
if (surface.allowProviders !== undefined) {
|
|
4265
|
+
next.allowProviders = surface.allowProviders;
|
|
4266
|
+
}
|
|
4267
|
+
if (surface.fallbackMode !== undefined) {
|
|
4268
|
+
next.fallbackMode = surface.fallbackMode;
|
|
4269
|
+
}
|
|
4270
|
+
if (surface.maxCost !== undefined) {
|
|
4271
|
+
next.maxCost = surface.maxCost;
|
|
4272
|
+
}
|
|
4273
|
+
if (surface.maxLatencyMs !== undefined) {
|
|
4274
|
+
next.maxLatencyMs = surface.maxLatencyMs;
|
|
4275
|
+
}
|
|
4276
|
+
if (surface.minQuality !== undefined) {
|
|
4277
|
+
next.minQuality = surface.minQuality;
|
|
4278
|
+
}
|
|
4279
|
+
if (surface.strategy !== undefined) {
|
|
4280
|
+
next.strategy = surface.strategy;
|
|
4281
|
+
}
|
|
4282
|
+
if (surface.weights !== undefined) {
|
|
4283
|
+
next.weights = {
|
|
4284
|
+
...base?.weights ?? {},
|
|
4285
|
+
...surface.weights
|
|
4286
|
+
};
|
|
4287
|
+
}
|
|
4288
|
+
return next;
|
|
4289
|
+
};
|
|
4290
|
+
var createVoiceProviderOrchestrationProfile = (options) => {
|
|
4291
|
+
const surfaceNames = Object.keys(options.surfaces);
|
|
4292
|
+
const defaultSurface = options.defaultSurface ?? surfaceNames[0];
|
|
4293
|
+
if (!defaultSurface || !options.surfaces[defaultSurface]) {
|
|
4294
|
+
throw new Error("Voice provider orchestration profile has no surfaces.");
|
|
4295
|
+
}
|
|
4296
|
+
return {
|
|
4297
|
+
defaultSurface,
|
|
4298
|
+
id: options.id,
|
|
4299
|
+
resolve: (surface = defaultSurface) => {
|
|
4300
|
+
const config = options.surfaces[surface];
|
|
4301
|
+
if (!config) {
|
|
4302
|
+
throw new Error(`Voice provider orchestration profile ${options.id} has no surface "${surface}".`);
|
|
4303
|
+
}
|
|
4304
|
+
const policy = mergeDefinedProviderPolicyFields(resolveVoiceProviderRoutingPolicy(config.policy), config);
|
|
4305
|
+
return {
|
|
4306
|
+
allowProviders: config.allowProviders,
|
|
4307
|
+
fallback: config.fallback,
|
|
4308
|
+
fallbackMode: config.fallbackMode,
|
|
4309
|
+
policy,
|
|
4310
|
+
providerHealth: config.providerHealth,
|
|
4311
|
+
providerProfiles: config.providerProfiles,
|
|
4312
|
+
timeoutMs: config.timeoutMs
|
|
4313
|
+
};
|
|
4314
|
+
},
|
|
4315
|
+
surfaces: options.surfaces
|
|
4316
|
+
};
|
|
4317
|
+
};
|
|
3514
4318
|
var OUTPUT_SCHEMA = {
|
|
3515
4319
|
additionalProperties: false,
|
|
3516
4320
|
properties: {
|
|
@@ -3678,19 +4482,23 @@ var createJSONVoiceAssistantModel = (options) => ({
|
|
|
3678
4482
|
var createVoiceProviderRouter = (options) => {
|
|
3679
4483
|
const providerIds = Object.keys(options.providers);
|
|
3680
4484
|
const firstProvider = providerIds[0];
|
|
3681
|
-
const
|
|
3682
|
-
|
|
3683
|
-
} : options.policy;
|
|
4485
|
+
const orchestrationSurface = options.orchestrationProfile?.resolve(options.orchestrationSurface);
|
|
4486
|
+
const policy = resolveVoiceProviderRoutingPolicy(options.policy) ?? resolveVoiceProviderRoutingPolicy(orchestrationSurface?.policy);
|
|
3684
4487
|
const strategy = policy?.strategy ?? "prefer-selected";
|
|
3685
|
-
const fallbackMode = policy?.fallbackMode ?? options.fallbackMode ?? "provider-error";
|
|
3686
|
-
const
|
|
4488
|
+
const fallbackMode = policy?.fallbackMode ?? options.fallbackMode ?? orchestrationSurface?.fallbackMode ?? "provider-error";
|
|
4489
|
+
const providerProfiles = {
|
|
4490
|
+
...orchestrationSurface?.providerProfiles ?? {},
|
|
4491
|
+
...options.providerProfiles ?? {}
|
|
4492
|
+
};
|
|
4493
|
+
const providerHealthOption = options.providerHealth ?? orchestrationSurface?.providerHealth;
|
|
4494
|
+
const healthOptions = typeof providerHealthOption === "object" ? providerHealthOption : providerHealthOption ? {} : undefined;
|
|
3687
4495
|
const healthState = new Map;
|
|
3688
4496
|
const now = () => healthOptions?.now?.() ?? Date.now();
|
|
3689
4497
|
const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
|
|
3690
4498
|
const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
|
|
3691
4499
|
const rateLimitCooldownMs = Math.max(0, healthOptions?.rateLimitCooldownMs ?? 60000);
|
|
3692
4500
|
const getProviderTimeoutMs = (provider) => {
|
|
3693
|
-
const timeoutMs =
|
|
4501
|
+
const timeoutMs = providerProfiles[provider]?.timeoutMs ?? options.timeoutMs ?? orchestrationSurface?.timeoutMs;
|
|
3694
4502
|
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
|
|
3695
4503
|
};
|
|
3696
4504
|
const getHealth = (provider) => {
|
|
@@ -3756,17 +4564,44 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3756
4564
|
return cloneHealth(provider);
|
|
3757
4565
|
};
|
|
3758
4566
|
const resolveAllowedProviders = async (input) => {
|
|
3759
|
-
const allowProviders = policy?.allowProviders ?? options.allowProviders;
|
|
4567
|
+
const allowProviders = policy?.allowProviders ?? options.allowProviders ?? orchestrationSurface?.allowProviders;
|
|
3760
4568
|
const allowed = typeof allowProviders === "function" ? await allowProviders(input) : allowProviders;
|
|
3761
4569
|
return new Set(allowed ?? providerIds);
|
|
3762
4570
|
};
|
|
4571
|
+
const passesBudgetFilters = (provider) => {
|
|
4572
|
+
const profile = providerProfiles[provider];
|
|
4573
|
+
if (typeof policy?.maxCost === "number" && typeof profile?.cost === "number" && profile.cost > policy.maxCost) {
|
|
4574
|
+
return false;
|
|
4575
|
+
}
|
|
4576
|
+
if (typeof policy?.maxLatencyMs === "number" && typeof profile?.latencyMs === "number" && profile.latencyMs > policy.maxLatencyMs) {
|
|
4577
|
+
return false;
|
|
4578
|
+
}
|
|
4579
|
+
if (typeof policy?.minQuality === "number" && typeof profile?.quality === "number" && profile.quality < policy.minQuality) {
|
|
4580
|
+
return false;
|
|
4581
|
+
}
|
|
4582
|
+
return true;
|
|
4583
|
+
};
|
|
4584
|
+
const getBalancedScore = (provider) => {
|
|
4585
|
+
const profile = providerProfiles[provider];
|
|
4586
|
+
if (policy?.scoreProvider) {
|
|
4587
|
+
return policy.scoreProvider(provider, profile);
|
|
4588
|
+
}
|
|
4589
|
+
const weights = policy?.weights ?? {};
|
|
4590
|
+
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);
|
|
4591
|
+
};
|
|
3763
4592
|
const sortProviders = (providers) => {
|
|
3764
|
-
if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest") {
|
|
4593
|
+
if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest" && strategy !== "quality-first" && strategy !== "balanced") {
|
|
3765
4594
|
return providers;
|
|
3766
4595
|
}
|
|
3767
4596
|
return [...providers].sort((left, right) => {
|
|
3768
|
-
const leftProfile =
|
|
3769
|
-
const rightProfile =
|
|
4597
|
+
const leftProfile = providerProfiles[left];
|
|
4598
|
+
const rightProfile = providerProfiles[right];
|
|
4599
|
+
if (strategy === "quality-first") {
|
|
4600
|
+
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);
|
|
4601
|
+
}
|
|
4602
|
+
if (strategy === "balanced") {
|
|
4603
|
+
return getBalancedScore(left) - getBalancedScore(right);
|
|
4604
|
+
}
|
|
3770
4605
|
const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
3771
4606
|
const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
3772
4607
|
return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
|
|
@@ -3775,13 +4610,15 @@ var createVoiceProviderRouter = (options) => {
|
|
|
3775
4610
|
const resolveOrder = async (input) => {
|
|
3776
4611
|
const selectedProvider = await options.selectProvider?.(input);
|
|
3777
4612
|
const allowedProviders = await resolveAllowedProviders(input);
|
|
3778
|
-
const
|
|
3779
|
-
const
|
|
4613
|
+
const fallbackSource = options.fallback ?? orchestrationSurface?.fallback;
|
|
4614
|
+
const fallbackOrder = typeof fallbackSource === "function" ? await fallbackSource(input) : fallbackSource;
|
|
4615
|
+
const allowedRankedProviders = sortProviders([
|
|
3780
4616
|
...fallbackOrder ?? providerIds
|
|
3781
4617
|
]).filter((provider) => allowedProviders.has(provider));
|
|
4618
|
+
const rankedProviders = allowedRankedProviders.filter(passesBudgetFilters);
|
|
3782
4619
|
const healthyRankedProviders = healthOptions ? rankedProviders.filter((provider) => !isSuppressed(provider)) : rankedProviders;
|
|
3783
4620
|
const candidateRankedProviders = healthyRankedProviders.length ? healthyRankedProviders : rankedProviders;
|
|
3784
|
-
const preferred = selectedProvider && allowedProviders.has(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
|
|
4621
|
+
const preferred = selectedProvider && allowedProviders.has(selectedProvider) && passesBudgetFilters(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
|
|
3785
4622
|
const seen = new Set;
|
|
3786
4623
|
const order = [];
|
|
3787
4624
|
const candidates = strategy === "ordered" ? candidateRankedProviders : [
|
|
@@ -4497,7 +5334,7 @@ var createVoiceMemoryStore = () => {
|
|
|
4497
5334
|
};
|
|
4498
5335
|
|
|
4499
5336
|
// src/session.ts
|
|
4500
|
-
import { Buffer } from "buffer";
|
|
5337
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
4501
5338
|
|
|
4502
5339
|
// src/handoff.ts
|
|
4503
5340
|
var toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
@@ -4816,6 +5653,12 @@ var DEFAULT_FORMAT = {
|
|
|
4816
5653
|
encoding: "pcm_s16le",
|
|
4817
5654
|
sampleRateHz: 16000
|
|
4818
5655
|
};
|
|
5656
|
+
var DEFAULT_REALTIME_FORMAT = {
|
|
5657
|
+
channels: 1,
|
|
5658
|
+
container: "raw",
|
|
5659
|
+
encoding: "pcm_s16le",
|
|
5660
|
+
sampleRateHz: 24000
|
|
5661
|
+
};
|
|
4819
5662
|
var toError = (value) => value instanceof Error ? value : new Error(String(value));
|
|
4820
5663
|
var createEmptyCurrentTurn = () => ({
|
|
4821
5664
|
finalText: "",
|
|
@@ -4828,7 +5671,7 @@ var createEmptyCurrentTurn = () => ({
|
|
|
4828
5671
|
transcripts: []
|
|
4829
5672
|
});
|
|
4830
5673
|
var cloneTranscript = (transcript) => ({ ...transcript });
|
|
4831
|
-
var encodeBase64 = (chunk) =>
|
|
5674
|
+
var encodeBase64 = (chunk) => Buffer2.from(chunk).toString("base64");
|
|
4832
5675
|
var countWords2 = (text) => text.trim().split(/\s+/).filter(Boolean).length;
|
|
4833
5676
|
var normalizeText2 = (text) => text.trim().replace(/\s+/g, " ");
|
|
4834
5677
|
var getAudioChunkDurationMs = (chunk) => chunk.byteLength / (DEFAULT_FORMAT.sampleRateHz * DEFAULT_FORMAT.channels * 2) * 1000;
|
|
@@ -4999,7 +5842,7 @@ var createVoiceSession = (options) => {
|
|
|
4999
5842
|
} : undefined;
|
|
5000
5843
|
const appendTrace = async (input) => {
|
|
5001
5844
|
await options.trace?.append({
|
|
5002
|
-
at: Date.now(),
|
|
5845
|
+
at: input.at ?? Date.now(),
|
|
5003
5846
|
metadata: input.metadata,
|
|
5004
5847
|
payload: input.payload,
|
|
5005
5848
|
scenarioId: input.session?.scenarioId ?? options.scenarioId,
|
|
@@ -5008,6 +5851,13 @@ var createVoiceSession = (options) => {
|
|
|
5008
5851
|
type: input.type
|
|
5009
5852
|
});
|
|
5010
5853
|
};
|
|
5854
|
+
const appendTurnLatencyStage = async (input) => appendTrace({
|
|
5855
|
+
at: input.at,
|
|
5856
|
+
payload: { stage: input.stage },
|
|
5857
|
+
session: input.session,
|
|
5858
|
+
turnId: input.turnId,
|
|
5859
|
+
type: "turn_latency.stage"
|
|
5860
|
+
});
|
|
5011
5861
|
const phraseHints = options.phraseHints ?? [];
|
|
5012
5862
|
const lexicon = options.lexicon ?? [];
|
|
5013
5863
|
let socket = options.socket;
|
|
@@ -5086,6 +5936,18 @@ var createVoiceSession = (options) => {
|
|
|
5086
5936
|
type: "call_lifecycle"
|
|
5087
5937
|
});
|
|
5088
5938
|
};
|
|
5939
|
+
const sendReplay = async (session) => {
|
|
5940
|
+
await send({
|
|
5941
|
+
assistantTexts: session.turns.flatMap((turn) => turn.assistantText ? [turn.assistantText] : []),
|
|
5942
|
+
call: session.call,
|
|
5943
|
+
partial: session.currentTurn.partialText,
|
|
5944
|
+
scenarioId: session.scenarioId,
|
|
5945
|
+
sessionId: options.id,
|
|
5946
|
+
status: session.status,
|
|
5947
|
+
turns: session.turns,
|
|
5948
|
+
type: "replay"
|
|
5949
|
+
});
|
|
5950
|
+
};
|
|
5089
5951
|
const runHandoff = async (input) => {
|
|
5090
5952
|
const queuedDelivery = options.handoff?.deliveryQueue ? createVoiceHandoffDeliveryRecord({
|
|
5091
5953
|
action: input.action,
|
|
@@ -5189,6 +6051,23 @@ var createVoiceSession = (options) => {
|
|
|
5189
6051
|
});
|
|
5190
6052
|
}
|
|
5191
6053
|
};
|
|
6054
|
+
const sendAssistantAudio = async (chunk, input) => {
|
|
6055
|
+
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));
|
|
6056
|
+
await send({
|
|
6057
|
+
chunkBase64: encodeBase64(normalizedChunk),
|
|
6058
|
+
format: input.format,
|
|
6059
|
+
receivedAt: input.receivedAt,
|
|
6060
|
+
turnId: activeTTSTurnId,
|
|
6061
|
+
type: "audio"
|
|
6062
|
+
});
|
|
6063
|
+
if (activeTTSTurnId) {
|
|
6064
|
+
await appendTurnLatencyStage({
|
|
6065
|
+
at: input.receivedAt,
|
|
6066
|
+
stage: "assistant_audio_received",
|
|
6067
|
+
turnId: activeTTSTurnId
|
|
6068
|
+
});
|
|
6069
|
+
}
|
|
6070
|
+
};
|
|
5192
6071
|
const scheduleTurnCommit = (delayMs, reason, reset = true) => {
|
|
5193
6072
|
if (!reset && silenceTimer) {
|
|
5194
6073
|
return;
|
|
@@ -5890,8 +6769,12 @@ var createVoiceSession = (options) => {
|
|
|
5890
6769
|
if (sttSession) {
|
|
5891
6770
|
return sttSession;
|
|
5892
6771
|
}
|
|
5893
|
-
const
|
|
5894
|
-
|
|
6772
|
+
const inputAdapter = options.realtime ?? options.stt;
|
|
6773
|
+
if (!inputAdapter) {
|
|
6774
|
+
throw new Error("Voice session requires either an stt or realtime adapter.");
|
|
6775
|
+
}
|
|
6776
|
+
const openedSession = await inputAdapter.open({
|
|
6777
|
+
format: options.realtime ? options.realtimeInputFormat ?? DEFAULT_REALTIME_FORMAT : DEFAULT_FORMAT,
|
|
5895
6778
|
languageStrategy: options.languageStrategy,
|
|
5896
6779
|
lexicon,
|
|
5897
6780
|
phraseHints,
|
|
@@ -5926,6 +6809,16 @@ var createVoiceSession = (options) => {
|
|
|
5926
6809
|
openedSession.on("close", (event) => {
|
|
5927
6810
|
runAdapterEvent("adapter.close", () => handleClose(event));
|
|
5928
6811
|
});
|
|
6812
|
+
if (options.realtime) {
|
|
6813
|
+
openedSession.on("audio", ({ chunk, format, receivedAt }) => {
|
|
6814
|
+
runAdapterEvent("adapter.audio", async () => {
|
|
6815
|
+
await sendAssistantAudio(chunk, {
|
|
6816
|
+
format,
|
|
6817
|
+
receivedAt
|
|
6818
|
+
});
|
|
6819
|
+
});
|
|
6820
|
+
});
|
|
6821
|
+
}
|
|
5929
6822
|
return openedSession;
|
|
5930
6823
|
};
|
|
5931
6824
|
const ensureTTSSession = async () => {
|
|
@@ -5950,13 +6843,9 @@ var createVoiceSession = (options) => {
|
|
|
5950
6843
|
if (ttsSession !== openedSession) {
|
|
5951
6844
|
return;
|
|
5952
6845
|
}
|
|
5953
|
-
|
|
5954
|
-
await send({
|
|
5955
|
-
chunkBase64: encodeBase64(normalizedChunk),
|
|
6846
|
+
await sendAssistantAudio(chunk, {
|
|
5956
6847
|
format,
|
|
5957
|
-
receivedAt
|
|
5958
|
-
turnId: activeTTSTurnId,
|
|
5959
|
-
type: "audio"
|
|
6848
|
+
receivedAt
|
|
5960
6849
|
});
|
|
5961
6850
|
});
|
|
5962
6851
|
});
|
|
@@ -6000,9 +6889,32 @@ var createVoiceSession = (options) => {
|
|
|
6000
6889
|
});
|
|
6001
6890
|
};
|
|
6002
6891
|
const completeTurn = async (session, turn) => {
|
|
6892
|
+
const liveOpsControl = await options.liveOps?.getControl(options.id);
|
|
6893
|
+
if (liveOpsControl?.assistantPaused || liveOpsControl?.operatorTakeover) {
|
|
6894
|
+
await appendTrace({
|
|
6895
|
+
metadata: {
|
|
6896
|
+
source: "voice-live-ops"
|
|
6897
|
+
},
|
|
6898
|
+
payload: {
|
|
6899
|
+
action: "turn.skipped",
|
|
6900
|
+
control: liveOpsControl,
|
|
6901
|
+
reason: liveOpsControl.operatorTakeover ? "operator-takeover" : "assistant-paused",
|
|
6902
|
+
status: "skipped"
|
|
6903
|
+
},
|
|
6904
|
+
session,
|
|
6905
|
+
turnId: turn.id,
|
|
6906
|
+
type: "operator.action"
|
|
6907
|
+
});
|
|
6908
|
+
return;
|
|
6909
|
+
}
|
|
6910
|
+
const injectedInstruction = liveOpsControl?.injectedInstruction?.trim();
|
|
6003
6911
|
const committedOutput = await options.route.onTurn({
|
|
6004
6912
|
api,
|
|
6005
6913
|
context: options.context,
|
|
6914
|
+
liveOps: liveOpsControl ? {
|
|
6915
|
+
control: liveOpsControl,
|
|
6916
|
+
injectedInstruction
|
|
6917
|
+
} : undefined,
|
|
6006
6918
|
session,
|
|
6007
6919
|
turn
|
|
6008
6920
|
});
|
|
@@ -6016,6 +6928,7 @@ var createVoiceSession = (options) => {
|
|
|
6016
6928
|
voicemail: committedOutput?.voicemail
|
|
6017
6929
|
};
|
|
6018
6930
|
if (output?.assistantText) {
|
|
6931
|
+
const assistantTextStartedAt = Date.now();
|
|
6019
6932
|
await writeSession((currentSession) => {
|
|
6020
6933
|
setTurnResult(currentSession, turn.id, {
|
|
6021
6934
|
assistantText: output.assistantText
|
|
@@ -6026,10 +6939,17 @@ var createVoiceSession = (options) => {
|
|
|
6026
6939
|
turnId: turn.id,
|
|
6027
6940
|
type: "assistant"
|
|
6028
6941
|
});
|
|
6942
|
+
await appendTurnLatencyStage({
|
|
6943
|
+
at: assistantTextStartedAt,
|
|
6944
|
+
session,
|
|
6945
|
+
stage: "assistant_text_started",
|
|
6946
|
+
turnId: turn.id
|
|
6947
|
+
});
|
|
6029
6948
|
await appendTrace({
|
|
6030
6949
|
payload: {
|
|
6031
6950
|
text: output.assistantText,
|
|
6032
|
-
ttsConfigured: Boolean(options.tts)
|
|
6951
|
+
ttsConfigured: Boolean(options.tts),
|
|
6952
|
+
realtimeConfigured: Boolean(options.realtime)
|
|
6033
6953
|
},
|
|
6034
6954
|
session,
|
|
6035
6955
|
turnId: turn.id,
|
|
@@ -6040,7 +6960,18 @@ var createVoiceSession = (options) => {
|
|
|
6040
6960
|
if (activeTTSSession) {
|
|
6041
6961
|
const ttsStartedAt = Date.now();
|
|
6042
6962
|
activeTTSTurnId = turn.id;
|
|
6963
|
+
await appendTurnLatencyStage({
|
|
6964
|
+
at: ttsStartedAt,
|
|
6965
|
+
session,
|
|
6966
|
+
stage: "tts_send_started",
|
|
6967
|
+
turnId: turn.id
|
|
6968
|
+
});
|
|
6043
6969
|
await activeTTSSession.send(output.assistantText);
|
|
6970
|
+
await appendTurnLatencyStage({
|
|
6971
|
+
session,
|
|
6972
|
+
stage: "tts_send_completed",
|
|
6973
|
+
turnId: turn.id
|
|
6974
|
+
});
|
|
6044
6975
|
await appendTrace({
|
|
6045
6976
|
payload: {
|
|
6046
6977
|
elapsedMs: Date.now() - ttsStartedAt,
|
|
@@ -6050,17 +6981,43 @@ var createVoiceSession = (options) => {
|
|
|
6050
6981
|
turnId: turn.id,
|
|
6051
6982
|
type: "turn.assistant"
|
|
6052
6983
|
});
|
|
6053
|
-
}
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
|
|
6058
|
-
|
|
6984
|
+
} else if (options.realtime) {
|
|
6985
|
+
const activeRealtimeSession = await ensureAdapter();
|
|
6986
|
+
const realtimeStartedAt = Date.now();
|
|
6987
|
+
activeTTSTurnId = turn.id;
|
|
6988
|
+
await appendTurnLatencyStage({
|
|
6989
|
+
at: realtimeStartedAt,
|
|
6990
|
+
session,
|
|
6991
|
+
stage: "tts_send_started",
|
|
6992
|
+
turnId: turn.id
|
|
6993
|
+
});
|
|
6994
|
+
await activeRealtimeSession.send(output.assistantText);
|
|
6995
|
+
await appendTurnLatencyStage({
|
|
6996
|
+
session,
|
|
6997
|
+
stage: "tts_send_completed",
|
|
6998
|
+
turnId: turn.id
|
|
6999
|
+
});
|
|
7000
|
+
await appendTrace({
|
|
7001
|
+
payload: {
|
|
7002
|
+
elapsedMs: Date.now() - realtimeStartedAt,
|
|
7003
|
+
mode: "realtime",
|
|
7004
|
+
status: "sent"
|
|
7005
|
+
},
|
|
7006
|
+
session,
|
|
7007
|
+
turnId: turn.id,
|
|
7008
|
+
type: "turn.assistant"
|
|
7009
|
+
});
|
|
7010
|
+
}
|
|
7011
|
+
} catch (error) {
|
|
7012
|
+
logger.warn("voice assistant audio send failed", {
|
|
7013
|
+
error: toError(error).message,
|
|
7014
|
+
sessionId: options.id,
|
|
7015
|
+
turnId: turn.id
|
|
6059
7016
|
});
|
|
6060
7017
|
await appendTrace({
|
|
6061
7018
|
payload: {
|
|
6062
7019
|
error: toError(error).message,
|
|
6063
|
-
status: "tts-send-failed"
|
|
7020
|
+
status: options.realtime ? "realtime-send-failed" : "tts-send-failed"
|
|
6064
7021
|
},
|
|
6065
7022
|
session,
|
|
6066
7023
|
turnId: turn.id,
|
|
@@ -6237,11 +7194,35 @@ var createVoiceSession = (options) => {
|
|
|
6237
7194
|
turnId: turn.id,
|
|
6238
7195
|
type: "turn.cost"
|
|
6239
7196
|
});
|
|
7197
|
+
const firstTranscriptAt = turn.transcripts.map((transcript) => transcript.endedAtMs ?? transcript.startedAtMs).filter((value) => typeof value === "number").sort((left, right) => left - right)[0];
|
|
7198
|
+
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];
|
|
7199
|
+
if (firstTranscriptAt !== undefined) {
|
|
7200
|
+
await appendTurnLatencyStage({
|
|
7201
|
+
at: firstTranscriptAt,
|
|
7202
|
+
session: updatedSession,
|
|
7203
|
+
stage: "speech_detected",
|
|
7204
|
+
turnId: turn.id
|
|
7205
|
+
});
|
|
7206
|
+
}
|
|
7207
|
+
if (finalTranscriptAt !== undefined) {
|
|
7208
|
+
await appendTurnLatencyStage({
|
|
7209
|
+
at: finalTranscriptAt,
|
|
7210
|
+
session: updatedSession,
|
|
7211
|
+
stage: "final_transcript",
|
|
7212
|
+
turnId: turn.id
|
|
7213
|
+
});
|
|
7214
|
+
}
|
|
7215
|
+
await appendTurnLatencyStage({
|
|
7216
|
+
at: turn.committedAt,
|
|
7217
|
+
session: updatedSession,
|
|
7218
|
+
stage: "turn_committed",
|
|
7219
|
+
turnId: turn.id
|
|
7220
|
+
});
|
|
6240
7221
|
await send({
|
|
6241
7222
|
turn,
|
|
6242
7223
|
type: "turn"
|
|
6243
7224
|
});
|
|
6244
|
-
if (options.sttLifecycle === "turn-scoped") {
|
|
7225
|
+
if (options.stt && options.sttLifecycle === "turn-scoped") {
|
|
6245
7226
|
await closeAdapter("turn-commit");
|
|
6246
7227
|
}
|
|
6247
7228
|
await completeTurn(updatedSession, turn);
|
|
@@ -6304,6 +7285,7 @@ var createVoiceSession = (options) => {
|
|
|
6304
7285
|
scenarioId: session.scenarioId,
|
|
6305
7286
|
type: "session"
|
|
6306
7287
|
});
|
|
7288
|
+
await sendReplay(session);
|
|
6307
7289
|
if (shouldFireOnSession) {
|
|
6308
7290
|
await options.route.onCallStart?.({
|
|
6309
7291
|
api,
|
|
@@ -7237,16 +8219,16 @@ var renderVoiceCallReviewHTML = (artifact) => {
|
|
|
7237
8219
|
</html>`;
|
|
7238
8220
|
};
|
|
7239
8221
|
// src/testing/sessionBenchmark.ts
|
|
7240
|
-
var
|
|
8222
|
+
var average4 = (values) => values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
|
|
7241
8223
|
var normalizeTurnText = (value) => value.toLowerCase().replace(/[^\p{L}\p{N}\s']/gu, " ").replace(/\s+/g, " ").trim();
|
|
7242
8224
|
var countPassedTurns = (turnResults) => turnResults.reduce((count, result) => count + (result.passes ? 1 : 0), 0);
|
|
7243
8225
|
var calculateTurnPassRate = (turnResults) => turnResults.length > 0 ? countPassedTurns(turnResults) / turnResults.length : 0;
|
|
7244
8226
|
var summarizeScenarioCosts = (turnResults) => {
|
|
7245
8227
|
const costEstimates = turnResults.map((turn) => turn.quality?.cost).filter((value) => value !== undefined);
|
|
7246
8228
|
return {
|
|
7247
|
-
averageRelativeCostUnits: roundMetric5(
|
|
7248
|
-
fallbackReplayAudioMs: roundMetric5(
|
|
7249
|
-
primaryAudioMs: roundMetric5(
|
|
8229
|
+
averageRelativeCostUnits: roundMetric5(average4(costEstimates.map((estimate) => estimate.estimatedRelativeCostUnits))),
|
|
8230
|
+
fallbackReplayAudioMs: roundMetric5(average4(costEstimates.map((estimate) => estimate.fallbackReplayAudioMs)), 2),
|
|
8231
|
+
primaryAudioMs: roundMetric5(average4(costEstimates.map((estimate) => estimate.primaryAudioMs)), 2)
|
|
7250
8232
|
};
|
|
7251
8233
|
};
|
|
7252
8234
|
var roundMetric5 = (value, digits = 4) => {
|
|
@@ -7565,13 +8547,13 @@ var summarizeVoiceSessionBenchmark = (adapterId, scenarios) => {
|
|
|
7565
8547
|
const turnAccuracies = scenarios.flatMap((scenario) => scenario.turnResults.map((turn) => turn.accuracy?.wordErrorRate).filter((value) => typeof value === "number"));
|
|
7566
8548
|
return {
|
|
7567
8549
|
adapterId,
|
|
7568
|
-
averageElapsedMs: roundMetric5(
|
|
7569
|
-
averageFallbackReplayAudioMs: roundMetric5(
|
|
7570
|
-
averagePrimaryAudioMs: roundMetric5(
|
|
7571
|
-
averageReconnectCount: roundMetric5(
|
|
7572
|
-
averageRelativeCostUnits: roundMetric5(
|
|
7573
|
-
averageTurnPassRate: roundMetric5(
|
|
7574
|
-
averageWordErrorRate: roundMetric5(
|
|
8550
|
+
averageElapsedMs: roundMetric5(average4(scenarios.map((scenario) => scenario.elapsedMs)), 2),
|
|
8551
|
+
averageFallbackReplayAudioMs: roundMetric5(average4(scenarios.map((scenario) => scenario.fallbackReplayAudioMs)), 2),
|
|
8552
|
+
averagePrimaryAudioMs: roundMetric5(average4(scenarios.map((scenario) => scenario.primaryAudioMs)), 2),
|
|
8553
|
+
averageReconnectCount: roundMetric5(average4(scenarios.map((scenario) => scenario.reconnectCount))),
|
|
8554
|
+
averageRelativeCostUnits: roundMetric5(average4(scenarios.map((scenario) => scenario.averageRelativeCostUnits))),
|
|
8555
|
+
averageTurnPassRate: roundMetric5(average4(scenarios.map((scenario) => scenario.turnPassRate))),
|
|
8556
|
+
averageWordErrorRate: roundMetric5(average4(turnAccuracies)),
|
|
7575
8557
|
duplicateTurnRate: roundMetric5(scenarios.length > 0 ? scenarios.filter((scenario) => scenario.duplicateTurnCount > 0).length / scenarios.length : 0),
|
|
7576
8558
|
passCount,
|
|
7577
8559
|
passRate: roundMetric5(scenarios.length > 0 ? passCount / scenarios.length : 0),
|
|
@@ -7597,13 +8579,13 @@ var summarizeVoiceSessionBenchmarkSeries = (input) => {
|
|
|
7597
8579
|
const passCount = results.filter((scenario) => scenario.passes).length;
|
|
7598
8580
|
const sample = results[0];
|
|
7599
8581
|
return {
|
|
7600
|
-
averageElapsedMs: roundMetric5(
|
|
7601
|
-
averageFallbackReplayAudioMs: roundMetric5(
|
|
7602
|
-
averagePrimaryAudioMs: roundMetric5(
|
|
7603
|
-
averageReconnectCount: roundMetric5(
|
|
7604
|
-
averageRelativeCostUnits: roundMetric5(
|
|
7605
|
-
averageTurnPassRate: roundMetric5(
|
|
7606
|
-
averageWordErrorRate: roundMetric5(
|
|
8582
|
+
averageElapsedMs: roundMetric5(average4(results.map((scenario) => scenario.elapsedMs)), 2),
|
|
8583
|
+
averageFallbackReplayAudioMs: roundMetric5(average4(results.map((scenario) => scenario.fallbackReplayAudioMs)), 2),
|
|
8584
|
+
averagePrimaryAudioMs: roundMetric5(average4(results.map((scenario) => scenario.primaryAudioMs)), 2),
|
|
8585
|
+
averageReconnectCount: roundMetric5(average4(results.map((scenario) => scenario.reconnectCount))),
|
|
8586
|
+
averageRelativeCostUnits: roundMetric5(average4(results.map((scenario) => scenario.averageRelativeCostUnits))),
|
|
8587
|
+
averageTurnPassRate: roundMetric5(average4(results.map((scenario) => scenario.turnPassRate))),
|
|
8588
|
+
averageWordErrorRate: roundMetric5(average4(wordErrorRates)),
|
|
7607
8589
|
bestWordErrorRate: roundMetric5(wordErrorRates.length > 0 ? Math.min(...wordErrorRates) : 0),
|
|
7608
8590
|
fixtureId,
|
|
7609
8591
|
passCount,
|
|
@@ -7626,18 +8608,18 @@ var summarizeVoiceSessionBenchmarkSeries = (input) => {
|
|
|
7626
8608
|
scenarios: scenarioAggregates,
|
|
7627
8609
|
summary: {
|
|
7628
8610
|
adapterId: input.adapterId,
|
|
7629
|
-
averageElapsedMs: roundMetric5(
|
|
7630
|
-
averageFallbackReplayAudioMs: roundMetric5(
|
|
7631
|
-
averagePassRate: roundMetric5(
|
|
7632
|
-
averagePrimaryAudioMs: roundMetric5(
|
|
7633
|
-
averageReconnectCount: roundMetric5(
|
|
7634
|
-
averageRelativeCostUnits: roundMetric5(
|
|
7635
|
-
averageTurnPassRate: roundMetric5(
|
|
7636
|
-
averageWordErrorRate: roundMetric5(
|
|
8611
|
+
averageElapsedMs: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.averageElapsedMs)), 2),
|
|
8612
|
+
averageFallbackReplayAudioMs: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.averageFallbackReplayAudioMs)), 2),
|
|
8613
|
+
averagePassRate: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.passRate))),
|
|
8614
|
+
averagePrimaryAudioMs: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.averagePrimaryAudioMs)), 2),
|
|
8615
|
+
averageReconnectCount: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.averageReconnectCount))),
|
|
8616
|
+
averageRelativeCostUnits: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.averageRelativeCostUnits))),
|
|
8617
|
+
averageTurnPassRate: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.averageTurnPassRate))),
|
|
8618
|
+
averageWordErrorRate: roundMetric5(average4(scenarioAggregates.map((scenario) => scenario.averageWordErrorRate))),
|
|
7637
8619
|
flakyScenarioCount: scenarioAggregates.filter((scenario) => scenario.passRate > 0 && scenario.passRate < 1).length,
|
|
7638
8620
|
generatedRunCount: input.reports.length,
|
|
7639
|
-
reconnectCoverageRate: roundMetric5(
|
|
7640
|
-
reconnectSuccessRate: roundMetric5(
|
|
8621
|
+
reconnectCoverageRate: roundMetric5(average4(reconnectCoverageRates)),
|
|
8622
|
+
reconnectSuccessRate: roundMetric5(average4(reconnectRates)),
|
|
7641
8623
|
scenarioCount: scenarioAggregates.length,
|
|
7642
8624
|
stableScenarioCount: scenarioAggregates.filter((scenario) => scenario.passRate === 1).length,
|
|
7643
8625
|
totalPassCount,
|
|
@@ -7680,10 +8662,981 @@ var runVoiceSessionBenchmarkSeries = async (input) => {
|
|
|
7680
8662
|
});
|
|
7681
8663
|
};
|
|
7682
8664
|
// src/telephony/twilio.ts
|
|
7683
|
-
import { Buffer as
|
|
8665
|
+
import { Buffer as Buffer3 } from "buffer";
|
|
8666
|
+
import { Elysia as Elysia2 } from "elysia";
|
|
8667
|
+
|
|
8668
|
+
// src/telephonyOutcome.ts
|
|
8669
|
+
import { Elysia } from "elysia";
|
|
8670
|
+
var DEFAULT_COMPLETED_STATUSES = [
|
|
8671
|
+
"answered",
|
|
8672
|
+
"completed",
|
|
8673
|
+
"complete",
|
|
8674
|
+
"connected",
|
|
8675
|
+
"in-progress",
|
|
8676
|
+
"live"
|
|
8677
|
+
];
|
|
8678
|
+
var DEFAULT_NO_ANSWER_STATUSES = [
|
|
8679
|
+
"busy",
|
|
8680
|
+
"canceled",
|
|
8681
|
+
"cancelled",
|
|
8682
|
+
"failed",
|
|
8683
|
+
"no-answer",
|
|
8684
|
+
"no_answer",
|
|
8685
|
+
"not-answered",
|
|
8686
|
+
"ring-no-answer",
|
|
8687
|
+
"timeout",
|
|
8688
|
+
"unanswered"
|
|
8689
|
+
];
|
|
8690
|
+
var DEFAULT_VOICEMAIL_STATUSES = [
|
|
8691
|
+
"answering-machine",
|
|
8692
|
+
"machine",
|
|
8693
|
+
"voicemail",
|
|
8694
|
+
"voice-mail"
|
|
8695
|
+
];
|
|
8696
|
+
var DEFAULT_TRANSFER_STATUSES = ["bridged", "forwarded", "transferred"];
|
|
8697
|
+
var DEFAULT_ESCALATION_STATUSES = ["escalated", "human-required", "operator"];
|
|
8698
|
+
var DEFAULT_FAILED_STATUSES = ["busy", "failed", "no-answer"];
|
|
8699
|
+
var DEFAULT_MACHINE_VOICEMAIL_VALUES = [
|
|
8700
|
+
"answering-machine",
|
|
8701
|
+
"fax",
|
|
8702
|
+
"machine",
|
|
8703
|
+
"machine-end-beep",
|
|
8704
|
+
"machine-end-other",
|
|
8705
|
+
"machine-start",
|
|
8706
|
+
"voicemail"
|
|
8707
|
+
];
|
|
8708
|
+
var DEFAULT_NO_ANSWER_SIP_CODES = [408, 480, 486, 487, 603];
|
|
8709
|
+
var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
8710
|
+
var uniqueSorted = (values) => Array.from(new Set(values)).sort();
|
|
8711
|
+
var findMissing = (values, required) => {
|
|
8712
|
+
if (!required?.length) {
|
|
8713
|
+
return [];
|
|
8714
|
+
}
|
|
8715
|
+
const valueSet = new Set(values);
|
|
8716
|
+
return required.filter((value) => !valueSet.has(value));
|
|
8717
|
+
};
|
|
8718
|
+
|
|
8719
|
+
class VoiceTelephonyWebhookVerificationError extends Error {
|
|
8720
|
+
result;
|
|
8721
|
+
constructor(result) {
|
|
8722
|
+
super(result.ok ? "telephony webhook verified" : result.reason);
|
|
8723
|
+
this.name = "VoiceTelephonyWebhookVerificationError";
|
|
8724
|
+
this.result = result;
|
|
8725
|
+
}
|
|
8726
|
+
}
|
|
8727
|
+
var createMemoryVoiceTelephonyWebhookIdempotencyStore = () => {
|
|
8728
|
+
const decisions = new Map;
|
|
8729
|
+
return {
|
|
8730
|
+
get: (key) => decisions.get(key),
|
|
8731
|
+
set: (key, decision) => {
|
|
8732
|
+
decisions.set(key, decision);
|
|
8733
|
+
}
|
|
8734
|
+
};
|
|
8735
|
+
};
|
|
8736
|
+
var isTelephonyWebhookProvider = (value) => value === "generic" || value === "plivo" || value === "telnyx" || value === "twilio";
|
|
8737
|
+
var isTelephonyOutcomeAction = (value) => value === "complete" || value === "escalate" || value === "ignore" || value === "no-answer" || value === "transfer" || value === "voicemail";
|
|
8738
|
+
var isCallDisposition = (value) => value === "completed" || value === "escalated" || value === "failed" || value === "no-answer" || value === "transferred" || value === "voicemail";
|
|
8739
|
+
var evaluateVoiceTelephonyWebhookNormalizationEvidence = (input = {}) => {
|
|
8740
|
+
const issues = [];
|
|
8741
|
+
const decisions = input.decisions ?? [];
|
|
8742
|
+
const verificationAttempts = input.verificationAttempts ?? [];
|
|
8743
|
+
const actions = uniqueSorted(decisions.map((decision) => decision.decision?.action ?? decision.action).filter(isTelephonyOutcomeAction));
|
|
8744
|
+
const dispositions = uniqueSorted(decisions.map((decision) => decision.decision?.disposition ?? decision.disposition).filter(isCallDisposition));
|
|
8745
|
+
const providers = uniqueSorted(decisions.map((decision) => decision.provider ?? decision.event?.provider).filter(isTelephonyWebhookProvider));
|
|
8746
|
+
const sources = uniqueSorted(decisions.map((decision) => decision.decision?.source ?? decision.source).filter((source) => typeof source === "string"));
|
|
8747
|
+
const applied = decisions.filter((decision) => decision.applied === true).length;
|
|
8748
|
+
const duplicateDecisions = decisions.filter((decision) => decision.duplicate === true);
|
|
8749
|
+
const duplicateProviders = uniqueSorted(duplicateDecisions.map((decision) => decision.provider ?? decision.event?.provider).filter(isTelephonyWebhookProvider));
|
|
8750
|
+
const duplicateIdempotencyKeys = new Set(duplicateDecisions.map((decision) => decision.idempotencyKey).filter((key) => typeof key === "string" && key.length > 0)).size;
|
|
8751
|
+
const duplicateCampaignOutcomesApplied = duplicateDecisions.filter((decision) => isRecord(decision.campaignOutcome) && decision.campaignOutcome.applied === true).length;
|
|
8752
|
+
const duplicateOutcomeReasons = uniqueSorted(duplicateDecisions.map((decision) => isRecord(decision.campaignOutcome) ? decision.campaignOutcome.reason : undefined).filter((reason) => typeof reason === "string"));
|
|
8753
|
+
const routeResults = decisions.filter((decision) => isRecord(decision.routeResult)).length;
|
|
8754
|
+
const missingSessionIds = decisions.filter((decision) => !decision.sessionId).length;
|
|
8755
|
+
const rejectedVerificationAttempts = verificationAttempts.filter((attempt) => attempt.rejected === true || attempt.status === 401 || attempt.verification?.ok === false && attempt.verification.reason === "invalid-signature");
|
|
8756
|
+
const rejectedVerificationProviders = uniqueSorted(rejectedVerificationAttempts.map((attempt) => attempt.provider).filter(isTelephonyWebhookProvider));
|
|
8757
|
+
const replayRejectedVerificationAttempts = rejectedVerificationAttempts.filter((attempt) => attempt.replayRejected === true);
|
|
8758
|
+
const replayRejectedVerificationProviders = uniqueSorted(replayRejectedVerificationAttempts.map((attempt) => attempt.provider).filter(isTelephonyWebhookProvider));
|
|
8759
|
+
const rejectedVerificationSideEffects = rejectedVerificationAttempts.reduce((total, attempt) => total + Math.max(0, attempt.sideEffects ?? 0), 0);
|
|
8760
|
+
if (input.minDecisions !== undefined && decisions.length < input.minDecisions) {
|
|
8761
|
+
issues.push(`Expected at least ${String(input.minDecisions)} telephony webhook decision(s), found ${String(decisions.length)}.`);
|
|
8762
|
+
}
|
|
8763
|
+
if (input.minApplied !== undefined && applied < input.minApplied) {
|
|
8764
|
+
issues.push(`Expected at least ${String(input.minApplied)} applied telephony webhook decision(s), found ${String(applied)}.`);
|
|
8765
|
+
}
|
|
8766
|
+
if (input.minDuplicates !== undefined && duplicateDecisions.length < input.minDuplicates) {
|
|
8767
|
+
issues.push(`Expected at least ${String(input.minDuplicates)} duplicate telephony webhook decision(s), found ${String(duplicateDecisions.length)}.`);
|
|
8768
|
+
}
|
|
8769
|
+
if (input.minDuplicateIdempotencyKeys !== undefined && duplicateIdempotencyKeys < input.minDuplicateIdempotencyKeys) {
|
|
8770
|
+
issues.push(`Expected at least ${String(input.minDuplicateIdempotencyKeys)} duplicate telephony webhook idempotency key(s), found ${String(duplicateIdempotencyKeys)}.`);
|
|
8771
|
+
}
|
|
8772
|
+
if (input.maxDuplicateCampaignOutcomesApplied !== undefined && duplicateCampaignOutcomesApplied > input.maxDuplicateCampaignOutcomesApplied) {
|
|
8773
|
+
issues.push(`Expected at most ${String(input.maxDuplicateCampaignOutcomesApplied)} duplicate telephony webhook campaign outcome application(s), found ${String(duplicateCampaignOutcomesApplied)}.`);
|
|
8774
|
+
}
|
|
8775
|
+
if (input.minRejectedVerificationAttempts !== undefined && rejectedVerificationAttempts.length < input.minRejectedVerificationAttempts) {
|
|
8776
|
+
issues.push(`Expected at least ${String(input.minRejectedVerificationAttempts)} rejected telephony webhook verification attempt(s), found ${String(rejectedVerificationAttempts.length)}.`);
|
|
8777
|
+
}
|
|
8778
|
+
if (input.maxRejectedVerificationSideEffects !== undefined && rejectedVerificationSideEffects > input.maxRejectedVerificationSideEffects) {
|
|
8779
|
+
issues.push(`Expected at most ${String(input.maxRejectedVerificationSideEffects)} rejected telephony webhook side effect(s), found ${String(rejectedVerificationSideEffects)}.`);
|
|
8780
|
+
}
|
|
8781
|
+
if (input.minReplayRejectedVerificationAttempts !== undefined && replayRejectedVerificationAttempts.length < input.minReplayRejectedVerificationAttempts) {
|
|
8782
|
+
issues.push(`Expected at least ${String(input.minReplayRejectedVerificationAttempts)} replay-rejected telephony webhook verification attempt(s), found ${String(replayRejectedVerificationAttempts.length)}.`);
|
|
8783
|
+
}
|
|
8784
|
+
if (input.maxMissingSessionIds !== undefined && missingSessionIds > input.maxMissingSessionIds) {
|
|
8785
|
+
issues.push(`Expected at most ${String(input.maxMissingSessionIds)} telephony webhook decision(s) without sessionId, found ${String(missingSessionIds)}.`);
|
|
8786
|
+
}
|
|
8787
|
+
if (input.requireRouteResults && routeResults < decisions.length) {
|
|
8788
|
+
issues.push(`Expected every telephony webhook decision to include a route result, found ${String(routeResults)} of ${String(decisions.length)}.`);
|
|
8789
|
+
}
|
|
8790
|
+
for (const provider of findMissing(providers, input.requiredProviders)) {
|
|
8791
|
+
issues.push(`Missing telephony webhook provider: ${provider}.`);
|
|
8792
|
+
}
|
|
8793
|
+
for (const provider of findMissing(duplicateProviders, input.requiredDuplicateProviders)) {
|
|
8794
|
+
issues.push(`Missing duplicate telephony webhook provider: ${provider}.`);
|
|
8795
|
+
}
|
|
8796
|
+
for (const provider of findMissing(rejectedVerificationProviders, input.requiredRejectedVerificationProviders)) {
|
|
8797
|
+
issues.push(`Missing rejected telephony webhook verification provider: ${provider}.`);
|
|
8798
|
+
}
|
|
8799
|
+
for (const provider of findMissing(replayRejectedVerificationProviders, input.requiredReplayRejectedVerificationProviders)) {
|
|
8800
|
+
issues.push(`Missing replay-rejected telephony webhook verification provider: ${provider}.`);
|
|
8801
|
+
}
|
|
8802
|
+
for (const action of findMissing(actions, input.requiredActions)) {
|
|
8803
|
+
issues.push(`Missing telephony webhook action: ${action}.`);
|
|
8804
|
+
}
|
|
8805
|
+
for (const disposition of findMissing(dispositions, input.requiredDispositions)) {
|
|
8806
|
+
issues.push(`Missing telephony webhook disposition: ${disposition}.`);
|
|
8807
|
+
}
|
|
8808
|
+
return {
|
|
8809
|
+
actions,
|
|
8810
|
+
applied,
|
|
8811
|
+
decisions: decisions.length,
|
|
8812
|
+
dispositions,
|
|
8813
|
+
duplicateCampaignOutcomesApplied,
|
|
8814
|
+
duplicateIdempotencyKeys,
|
|
8815
|
+
duplicateOutcomeReasons,
|
|
8816
|
+
duplicateProviders,
|
|
8817
|
+
duplicates: duplicateDecisions.length,
|
|
8818
|
+
issues,
|
|
8819
|
+
missingSessionIds,
|
|
8820
|
+
ok: issues.length === 0,
|
|
8821
|
+
providers,
|
|
8822
|
+
rejectedVerificationAttempts: rejectedVerificationAttempts.length,
|
|
8823
|
+
rejectedVerificationProviders,
|
|
8824
|
+
rejectedVerificationSideEffects,
|
|
8825
|
+
replayRejectedVerificationAttempts: replayRejectedVerificationAttempts.length,
|
|
8826
|
+
replayRejectedVerificationProviders,
|
|
8827
|
+
routeResults,
|
|
8828
|
+
sources,
|
|
8829
|
+
verificationAttempts: verificationAttempts.length
|
|
8830
|
+
};
|
|
8831
|
+
};
|
|
8832
|
+
var assertVoiceTelephonyWebhookNormalizationEvidence = (input = {}) => {
|
|
8833
|
+
const assertion = evaluateVoiceTelephonyWebhookNormalizationEvidence(input);
|
|
8834
|
+
if (!assertion.ok) {
|
|
8835
|
+
throw new Error(`Voice telephony webhook normalization evidence assertion failed: ${assertion.issues.join(" ")}`);
|
|
8836
|
+
}
|
|
8837
|
+
return assertion;
|
|
8838
|
+
};
|
|
8839
|
+
var normalizeToken = (value) => typeof value === "string" ? value.trim().toLowerCase().replace(/\s+/g, "-").replace(/_+/g, "-") : undefined;
|
|
8840
|
+
var firstString = (source, keys) => {
|
|
8841
|
+
for (const key of keys) {
|
|
8842
|
+
const value = source[key];
|
|
8843
|
+
if (typeof value === "string" && value.trim()) {
|
|
8844
|
+
return value.trim();
|
|
8845
|
+
}
|
|
8846
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
8847
|
+
return String(value);
|
|
8848
|
+
}
|
|
8849
|
+
}
|
|
8850
|
+
};
|
|
8851
|
+
var firstNumber = (source, keys) => {
|
|
8852
|
+
for (const key of keys) {
|
|
8853
|
+
const value = source[key];
|
|
8854
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
8855
|
+
return value;
|
|
8856
|
+
}
|
|
8857
|
+
if (typeof value === "string" && value.trim()) {
|
|
8858
|
+
const parsed = Number(value);
|
|
8859
|
+
if (Number.isFinite(parsed)) {
|
|
8860
|
+
return parsed;
|
|
8861
|
+
}
|
|
8862
|
+
}
|
|
8863
|
+
}
|
|
8864
|
+
};
|
|
8865
|
+
var parseMaybeJSON = (value) => {
|
|
8866
|
+
try {
|
|
8867
|
+
return JSON.parse(value);
|
|
8868
|
+
} catch {
|
|
8869
|
+
return;
|
|
8870
|
+
}
|
|
8871
|
+
};
|
|
8872
|
+
var flattenPayload = (value) => {
|
|
8873
|
+
if (!isRecord(value)) {
|
|
8874
|
+
return {};
|
|
8875
|
+
}
|
|
8876
|
+
const data = isRecord(value.data) ? value.data : undefined;
|
|
8877
|
+
const payload = isRecord(value.payload) ? value.payload : undefined;
|
|
8878
|
+
const event = isRecord(value.event) ? value.event : undefined;
|
|
8879
|
+
return {
|
|
8880
|
+
...value,
|
|
8881
|
+
...payload,
|
|
8882
|
+
...event,
|
|
8883
|
+
...data,
|
|
8884
|
+
...isRecord(data?.payload) ? data.payload : undefined
|
|
8885
|
+
};
|
|
8886
|
+
};
|
|
8887
|
+
var toBase64 = (bytes) => Buffer.from(new Uint8Array(bytes)).toString("base64");
|
|
8888
|
+
var timingSafeEqual = (left, right) => {
|
|
8889
|
+
const encoder = new TextEncoder;
|
|
8890
|
+
const leftBytes = encoder.encode(left);
|
|
8891
|
+
const rightBytes = encoder.encode(right);
|
|
8892
|
+
if (leftBytes.length !== rightBytes.length) {
|
|
8893
|
+
return false;
|
|
8894
|
+
}
|
|
8895
|
+
let diff = 0;
|
|
8896
|
+
for (let index = 0;index < leftBytes.length; index += 1) {
|
|
8897
|
+
diff |= leftBytes[index] ^ rightBytes[index];
|
|
8898
|
+
}
|
|
8899
|
+
return diff === 0;
|
|
8900
|
+
};
|
|
8901
|
+
var signHmacSHA1Base64 = async (secret, payload) => {
|
|
8902
|
+
const encoder = new TextEncoder;
|
|
8903
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
|
|
8904
|
+
hash: "SHA-1",
|
|
8905
|
+
name: "HMAC"
|
|
8906
|
+
}, false, ["sign"]);
|
|
8907
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
8908
|
+
return toBase64(signature);
|
|
8909
|
+
};
|
|
8910
|
+
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("");
|
|
8911
|
+
var normalizeList = (values, fallback) => new Set((values ?? fallback).map(normalizeToken).filter(Boolean));
|
|
8912
|
+
var metadataValue = (metadata, keys) => {
|
|
8913
|
+
for (const key of keys) {
|
|
8914
|
+
const value = metadata?.[key];
|
|
8915
|
+
if (typeof value === "string" && value.trim()) {
|
|
8916
|
+
return value.trim();
|
|
8917
|
+
}
|
|
8918
|
+
}
|
|
8919
|
+
};
|
|
8920
|
+
var resolveTransferTarget = (event, policy) => {
|
|
8921
|
+
if (typeof event.target === "string" && event.target.trim()) {
|
|
8922
|
+
return event.target.trim();
|
|
8923
|
+
}
|
|
8924
|
+
const metadataTarget = metadataValue(event.metadata, [
|
|
8925
|
+
"transferTarget",
|
|
8926
|
+
"target",
|
|
8927
|
+
"queue",
|
|
8928
|
+
"department"
|
|
8929
|
+
]);
|
|
8930
|
+
if (metadataTarget) {
|
|
8931
|
+
return metadataTarget;
|
|
8932
|
+
}
|
|
8933
|
+
if (typeof policy.transferTarget === "function") {
|
|
8934
|
+
const target = policy.transferTarget(event);
|
|
8935
|
+
return typeof target === "string" && target.trim() ? target.trim() : undefined;
|
|
8936
|
+
}
|
|
8937
|
+
return typeof policy.transferTarget === "string" && policy.transferTarget.trim() ? policy.transferTarget.trim() : undefined;
|
|
8938
|
+
};
|
|
8939
|
+
var mergeMetadata = (event, policy) => ({
|
|
8940
|
+
...policy.includeProviderPayload ? {
|
|
8941
|
+
answeredBy: event.answeredBy,
|
|
8942
|
+
durationMs: event.durationMs,
|
|
8943
|
+
provider: event.provider,
|
|
8944
|
+
reason: event.reason,
|
|
8945
|
+
sipCode: event.sipCode,
|
|
8946
|
+
status: event.status
|
|
8947
|
+
} : undefined,
|
|
8948
|
+
...policy.metadata,
|
|
8949
|
+
...event.metadata
|
|
8950
|
+
});
|
|
8951
|
+
var withDecisionDefaults = (decision, input) => {
|
|
8952
|
+
if (typeof decision === "string") {
|
|
8953
|
+
return buildDecision(decision, input);
|
|
8954
|
+
}
|
|
8955
|
+
return {
|
|
8956
|
+
...buildDecision(decision.action, input),
|
|
8957
|
+
...decision,
|
|
8958
|
+
confidence: decision.confidence ?? "high",
|
|
8959
|
+
metadata: {
|
|
8960
|
+
...mergeMetadata(input.event, input.policy),
|
|
8961
|
+
...decision.metadata
|
|
8962
|
+
},
|
|
8963
|
+
source: decision.source ?? input.source,
|
|
8964
|
+
target: decision.target ?? (decision.action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined)
|
|
8965
|
+
};
|
|
8966
|
+
};
|
|
8967
|
+
var dispositionForAction = (action) => {
|
|
8968
|
+
switch (action) {
|
|
8969
|
+
case "complete":
|
|
8970
|
+
return "completed";
|
|
8971
|
+
case "escalate":
|
|
8972
|
+
return "escalated";
|
|
8973
|
+
case "no-answer":
|
|
8974
|
+
return "no-answer";
|
|
8975
|
+
case "transfer":
|
|
8976
|
+
return "transferred";
|
|
8977
|
+
case "voicemail":
|
|
8978
|
+
return "voicemail";
|
|
8979
|
+
default:
|
|
8980
|
+
return;
|
|
8981
|
+
}
|
|
8982
|
+
};
|
|
8983
|
+
var buildDecision = (action, input) => ({
|
|
8984
|
+
action,
|
|
8985
|
+
confidence: action === "ignore" ? "low" : "high",
|
|
8986
|
+
disposition: dispositionForAction(action),
|
|
8987
|
+
metadata: mergeMetadata(input.event, input.policy),
|
|
8988
|
+
reason: input.event.reason,
|
|
8989
|
+
source: input.source,
|
|
8990
|
+
target: action === "transfer" ? resolveTransferTarget(input.event, input.policy) : undefined
|
|
8991
|
+
});
|
|
8992
|
+
var createVoiceTelephonyOutcomePolicy = (policy = {}) => ({
|
|
8993
|
+
completedStatuses: policy.completedStatuses ?? DEFAULT_COMPLETED_STATUSES,
|
|
8994
|
+
escalationStatuses: policy.escalationStatuses ?? DEFAULT_ESCALATION_STATUSES,
|
|
8995
|
+
failedAsNoAnswer: policy.failedAsNoAnswer ?? true,
|
|
8996
|
+
failedStatuses: policy.failedStatuses ?? DEFAULT_FAILED_STATUSES,
|
|
8997
|
+
includeProviderPayload: policy.includeProviderPayload ?? true,
|
|
8998
|
+
machineDetectionVoicemailValues: policy.machineDetectionVoicemailValues ?? DEFAULT_MACHINE_VOICEMAIL_VALUES,
|
|
8999
|
+
metadata: policy.metadata,
|
|
9000
|
+
minAnsweredDurationMs: policy.minAnsweredDurationMs,
|
|
9001
|
+
noAnswerOnZeroDuration: policy.noAnswerOnZeroDuration ?? true,
|
|
9002
|
+
noAnswerSipCodes: policy.noAnswerSipCodes ?? DEFAULT_NO_ANSWER_SIP_CODES,
|
|
9003
|
+
noAnswerStatuses: policy.noAnswerStatuses ?? DEFAULT_NO_ANSWER_STATUSES,
|
|
9004
|
+
statusMap: policy.statusMap,
|
|
9005
|
+
transferStatuses: policy.transferStatuses ?? DEFAULT_TRANSFER_STATUSES,
|
|
9006
|
+
transferTarget: policy.transferTarget,
|
|
9007
|
+
voicemailStatuses: policy.voicemailStatuses ?? DEFAULT_VOICEMAIL_STATUSES
|
|
9008
|
+
});
|
|
9009
|
+
var resolveVoiceTelephonyOutcome = (event, policyInput = {}) => {
|
|
9010
|
+
const policy = createVoiceTelephonyOutcomePolicy(policyInput);
|
|
9011
|
+
const status = normalizeToken(event.status);
|
|
9012
|
+
const provider = normalizeToken(event.provider);
|
|
9013
|
+
const answeredBy = normalizeToken(event.answeredBy);
|
|
9014
|
+
const target = resolveTransferTarget(event, policy);
|
|
9015
|
+
if (status) {
|
|
9016
|
+
const mapped = policy.statusMap?.[status] ?? (provider ? policy.statusMap?.[`${provider}:${status}`] : undefined);
|
|
9017
|
+
if (mapped) {
|
|
9018
|
+
return withDecisionDefaults(mapped, {
|
|
9019
|
+
event,
|
|
9020
|
+
policy,
|
|
9021
|
+
source: "policy"
|
|
9022
|
+
});
|
|
9023
|
+
}
|
|
9024
|
+
}
|
|
9025
|
+
if (answeredBy && normalizeList(policy.machineDetectionVoicemailValues, []).has(answeredBy)) {
|
|
9026
|
+
return buildDecision("voicemail", { event, policy, source: "answered-by" });
|
|
9027
|
+
}
|
|
9028
|
+
if (typeof event.sipCode === "number" && policy.noAnswerSipCodes.includes(event.sipCode)) {
|
|
9029
|
+
return buildDecision("no-answer", { event, policy, source: "sip" });
|
|
9030
|
+
}
|
|
9031
|
+
if (target && status && normalizeList(policy.transferStatuses, []).has(status)) {
|
|
9032
|
+
return buildDecision("transfer", { event, policy, source: "status" });
|
|
9033
|
+
}
|
|
9034
|
+
if (status && normalizeList(policy.voicemailStatuses, []).has(status)) {
|
|
9035
|
+
return buildDecision("voicemail", { event, policy, source: "status" });
|
|
9036
|
+
}
|
|
9037
|
+
if (status && normalizeList(policy.escalationStatuses, []).has(status)) {
|
|
9038
|
+
return buildDecision("escalate", { event, policy, source: "status" });
|
|
9039
|
+
}
|
|
9040
|
+
if (status && (policy.failedAsNoAnswer ? normalizeList(policy.noAnswerStatuses, []).has(status) || normalizeList(policy.failedStatuses, []).has(status) : normalizeList(policy.noAnswerStatuses, []).has(status))) {
|
|
9041
|
+
return buildDecision("no-answer", { event, policy, source: "status" });
|
|
9042
|
+
}
|
|
9043
|
+
if (policy.noAnswerOnZeroDuration && typeof event.durationMs === "number" && event.durationMs <= 0) {
|
|
9044
|
+
return buildDecision("no-answer", { event, policy, source: "duration" });
|
|
9045
|
+
}
|
|
9046
|
+
if (typeof policy.minAnsweredDurationMs === "number" && typeof event.durationMs === "number" && event.durationMs < policy.minAnsweredDurationMs) {
|
|
9047
|
+
return {
|
|
9048
|
+
...buildDecision("no-answer", { event, policy, source: "duration" }),
|
|
9049
|
+
confidence: "medium"
|
|
9050
|
+
};
|
|
9051
|
+
}
|
|
9052
|
+
if (status && normalizeList(policy.completedStatuses, []).has(status)) {
|
|
9053
|
+
return buildDecision("complete", { event, policy, source: "status" });
|
|
9054
|
+
}
|
|
9055
|
+
if (target) {
|
|
9056
|
+
return {
|
|
9057
|
+
...buildDecision("transfer", { event, policy, source: "explicit-target" }),
|
|
9058
|
+
confidence: "medium"
|
|
9059
|
+
};
|
|
9060
|
+
}
|
|
9061
|
+
return buildDecision("ignore", { event, policy, source: "status" });
|
|
9062
|
+
};
|
|
9063
|
+
var voiceTelephonyOutcomeToRouteResult = (decision, result) => {
|
|
9064
|
+
switch (decision.action) {
|
|
9065
|
+
case "complete":
|
|
9066
|
+
return { complete: true, result };
|
|
9067
|
+
case "escalate":
|
|
9068
|
+
return {
|
|
9069
|
+
escalate: {
|
|
9070
|
+
metadata: decision.metadata,
|
|
9071
|
+
reason: decision.reason ?? "telephony-escalation"
|
|
9072
|
+
},
|
|
9073
|
+
result
|
|
9074
|
+
};
|
|
9075
|
+
case "no-answer":
|
|
9076
|
+
return {
|
|
9077
|
+
noAnswer: {
|
|
9078
|
+
metadata: decision.metadata
|
|
9079
|
+
},
|
|
9080
|
+
result
|
|
9081
|
+
};
|
|
9082
|
+
case "transfer":
|
|
9083
|
+
if (!decision.target) {
|
|
9084
|
+
return { result };
|
|
9085
|
+
}
|
|
9086
|
+
return {
|
|
9087
|
+
result,
|
|
9088
|
+
transfer: {
|
|
9089
|
+
metadata: decision.metadata,
|
|
9090
|
+
reason: decision.reason,
|
|
9091
|
+
target: decision.target
|
|
9092
|
+
}
|
|
9093
|
+
};
|
|
9094
|
+
case "voicemail":
|
|
9095
|
+
return {
|
|
9096
|
+
result,
|
|
9097
|
+
voicemail: {
|
|
9098
|
+
metadata: decision.metadata
|
|
9099
|
+
}
|
|
9100
|
+
};
|
|
9101
|
+
default:
|
|
9102
|
+
return { result };
|
|
9103
|
+
}
|
|
9104
|
+
};
|
|
9105
|
+
var applyVoiceTelephonyOutcome = async (api, decision, result) => {
|
|
9106
|
+
switch (decision.action) {
|
|
9107
|
+
case "complete":
|
|
9108
|
+
await api.complete(result);
|
|
9109
|
+
break;
|
|
9110
|
+
case "escalate":
|
|
9111
|
+
await api.escalate({
|
|
9112
|
+
metadata: decision.metadata,
|
|
9113
|
+
reason: decision.reason ?? "telephony-escalation",
|
|
9114
|
+
result
|
|
9115
|
+
});
|
|
9116
|
+
break;
|
|
9117
|
+
case "no-answer":
|
|
9118
|
+
await api.markNoAnswer({
|
|
9119
|
+
metadata: decision.metadata,
|
|
9120
|
+
result
|
|
9121
|
+
});
|
|
9122
|
+
break;
|
|
9123
|
+
case "transfer":
|
|
9124
|
+
if (!decision.target) {
|
|
9125
|
+
return;
|
|
9126
|
+
}
|
|
9127
|
+
await api.transfer({
|
|
9128
|
+
metadata: decision.metadata,
|
|
9129
|
+
reason: decision.reason,
|
|
9130
|
+
result,
|
|
9131
|
+
target: decision.target
|
|
9132
|
+
});
|
|
9133
|
+
break;
|
|
9134
|
+
case "voicemail":
|
|
9135
|
+
await api.markVoicemail({
|
|
9136
|
+
metadata: decision.metadata,
|
|
9137
|
+
result
|
|
9138
|
+
});
|
|
9139
|
+
break;
|
|
9140
|
+
default:
|
|
9141
|
+
break;
|
|
9142
|
+
}
|
|
9143
|
+
};
|
|
9144
|
+
var parseRequestBodyText = (input) => {
|
|
9145
|
+
const { contentType, text } = input;
|
|
9146
|
+
if (!text) {
|
|
9147
|
+
return {};
|
|
9148
|
+
}
|
|
9149
|
+
if (contentType.includes("application/json")) {
|
|
9150
|
+
return parseMaybeJSON(text) ?? {};
|
|
9151
|
+
}
|
|
9152
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
9153
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
9154
|
+
}
|
|
9155
|
+
return parseMaybeJSON(text) ?? Object.fromEntries(new URLSearchParams(text));
|
|
9156
|
+
};
|
|
9157
|
+
var readRequestBody = async (request) => {
|
|
9158
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
9159
|
+
const text = await request.text();
|
|
9160
|
+
return {
|
|
9161
|
+
body: parseRequestBodyText({ contentType, text }),
|
|
9162
|
+
rawBody: text
|
|
9163
|
+
};
|
|
9164
|
+
};
|
|
9165
|
+
var signVoiceTwilioWebhook = async (input) => signHmacSHA1Base64(input.authToken, `${input.url}${sortedParamsForSignature(input.body ?? {})}`);
|
|
9166
|
+
var verifyVoiceTwilioWebhookSignature = async (input) => {
|
|
9167
|
+
if (!input.authToken) {
|
|
9168
|
+
return { ok: false, reason: "missing-secret" };
|
|
9169
|
+
}
|
|
9170
|
+
const signature = input.headers.get("x-twilio-signature");
|
|
9171
|
+
if (!signature) {
|
|
9172
|
+
return { ok: false, reason: "missing-signature" };
|
|
9173
|
+
}
|
|
9174
|
+
const expected = await signVoiceTwilioWebhook({
|
|
9175
|
+
authToken: input.authToken,
|
|
9176
|
+
body: input.body,
|
|
9177
|
+
url: input.url
|
|
9178
|
+
});
|
|
9179
|
+
return timingSafeEqual(signature, expected) ? { ok: true } : { ok: false, reason: "invalid-signature" };
|
|
9180
|
+
};
|
|
9181
|
+
var resolveVerificationUrl = (option, input) => typeof option === "function" ? option(input) : option ?? input.request.url;
|
|
9182
|
+
var verifyVoiceTelephonyWebhook = async (input) => {
|
|
9183
|
+
if (input.options.verify) {
|
|
9184
|
+
return input.options.verify({
|
|
9185
|
+
body: input.body,
|
|
9186
|
+
headers: input.request.headers,
|
|
9187
|
+
provider: input.provider,
|
|
9188
|
+
query: input.query,
|
|
9189
|
+
rawBody: input.rawBody,
|
|
9190
|
+
request: input.request
|
|
9191
|
+
});
|
|
9192
|
+
}
|
|
9193
|
+
if (!input.options.signingSecret) {
|
|
9194
|
+
return input.options.requireVerification ? { ok: false, reason: "missing-secret" } : { ok: true };
|
|
9195
|
+
}
|
|
9196
|
+
if (input.provider !== "twilio") {
|
|
9197
|
+
return { ok: false, reason: "unsupported-provider" };
|
|
9198
|
+
}
|
|
9199
|
+
return verifyVoiceTwilioWebhookSignature({
|
|
9200
|
+
authToken: input.options.signingSecret,
|
|
9201
|
+
body: input.body,
|
|
9202
|
+
headers: input.request.headers,
|
|
9203
|
+
url: resolveVerificationUrl(input.options.verificationUrl, {
|
|
9204
|
+
query: input.query,
|
|
9205
|
+
request: input.request
|
|
9206
|
+
})
|
|
9207
|
+
});
|
|
9208
|
+
};
|
|
9209
|
+
var durationMsFromSeconds = (value) => typeof value === "number" ? value * 1000 : undefined;
|
|
9210
|
+
var parseVoiceTelephonyWebhookEvent = (input) => {
|
|
9211
|
+
const payload = flattenPayload(input.body);
|
|
9212
|
+
const provider = firstString(payload, ["provider", "Provider"]) ?? input.provider;
|
|
9213
|
+
const status = firstString(payload, [
|
|
9214
|
+
"CallStatus",
|
|
9215
|
+
"call_status",
|
|
9216
|
+
"callStatus",
|
|
9217
|
+
"DialCallStatus",
|
|
9218
|
+
"dial_call_status",
|
|
9219
|
+
"status",
|
|
9220
|
+
"event_type",
|
|
9221
|
+
"type"
|
|
9222
|
+
]);
|
|
9223
|
+
const durationMs = firstNumber(payload, ["durationMs", "duration_ms"]) ?? durationMsFromSeconds(firstNumber(payload, [
|
|
9224
|
+
"CallDuration",
|
|
9225
|
+
"call_duration",
|
|
9226
|
+
"callDuration",
|
|
9227
|
+
"DialCallDuration",
|
|
9228
|
+
"dial_call_duration",
|
|
9229
|
+
"duration"
|
|
9230
|
+
]));
|
|
9231
|
+
const sipCode = firstNumber(payload, [
|
|
9232
|
+
"SipResponseCode",
|
|
9233
|
+
"sip_response_code",
|
|
9234
|
+
"sipCode",
|
|
9235
|
+
"sip_code",
|
|
9236
|
+
"hangupCauseCode"
|
|
9237
|
+
]);
|
|
9238
|
+
const from = firstString(payload, ["From", "from", "caller_id", "callerId"]);
|
|
9239
|
+
const to = firstString(payload, ["To", "to", "called_number", "calledNumber"]);
|
|
9240
|
+
const target = firstString(payload, [
|
|
9241
|
+
"transferTarget",
|
|
9242
|
+
"TransferTarget",
|
|
9243
|
+
"target",
|
|
9244
|
+
"queue",
|
|
9245
|
+
"department"
|
|
9246
|
+
]);
|
|
9247
|
+
return {
|
|
9248
|
+
answeredBy: firstString(payload, [
|
|
9249
|
+
"AnsweredBy",
|
|
9250
|
+
"answered_by",
|
|
9251
|
+
"answeredBy",
|
|
9252
|
+
"machineDetection",
|
|
9253
|
+
"machine_detection"
|
|
9254
|
+
]),
|
|
9255
|
+
durationMs,
|
|
9256
|
+
from,
|
|
9257
|
+
metadata: {
|
|
9258
|
+
...input.query,
|
|
9259
|
+
...payload
|
|
9260
|
+
},
|
|
9261
|
+
provider,
|
|
9262
|
+
reason: firstString(payload, [
|
|
9263
|
+
"Reason",
|
|
9264
|
+
"reason",
|
|
9265
|
+
"HangupCause",
|
|
9266
|
+
"hangup_cause",
|
|
9267
|
+
"hangupCause"
|
|
9268
|
+
]),
|
|
9269
|
+
sipCode,
|
|
9270
|
+
status,
|
|
9271
|
+
target,
|
|
9272
|
+
to
|
|
9273
|
+
};
|
|
9274
|
+
};
|
|
9275
|
+
var defaultSessionId = (input) => {
|
|
9276
|
+
const payload = flattenPayload(input.body);
|
|
9277
|
+
const metadataSessionId = input.event.metadata?.sessionId;
|
|
9278
|
+
return firstString(input.query, ["sessionId", "session_id"]) ?? firstString(payload, [
|
|
9279
|
+
"sessionId",
|
|
9280
|
+
"session_id",
|
|
9281
|
+
"SessionId",
|
|
9282
|
+
"CallSid",
|
|
9283
|
+
"call_sid",
|
|
9284
|
+
"callSid",
|
|
9285
|
+
"CallUUID",
|
|
9286
|
+
"call_uuid",
|
|
9287
|
+
"callControlId",
|
|
9288
|
+
"call_control_id"
|
|
9289
|
+
]) ?? (typeof metadataSessionId === "string" ? metadataSessionId : undefined);
|
|
9290
|
+
};
|
|
9291
|
+
var defaultIdempotencyKey = (input) => {
|
|
9292
|
+
const payload = flattenPayload(input.body);
|
|
9293
|
+
const eventId = firstString(payload, [
|
|
9294
|
+
"id",
|
|
9295
|
+
"event_id",
|
|
9296
|
+
"eventId",
|
|
9297
|
+
"EventSid",
|
|
9298
|
+
"event_sid",
|
|
9299
|
+
"MessageSid",
|
|
9300
|
+
"message_sid",
|
|
9301
|
+
"CallSid",
|
|
9302
|
+
"call_sid",
|
|
9303
|
+
"CallUUID",
|
|
9304
|
+
"call_uuid",
|
|
9305
|
+
"callControlId",
|
|
9306
|
+
"call_control_id"
|
|
9307
|
+
]);
|
|
9308
|
+
const status = normalizeToken(input.event.status) ?? "unknown";
|
|
9309
|
+
if (eventId) {
|
|
9310
|
+
return `${input.provider}:${eventId}:${status}`;
|
|
9311
|
+
}
|
|
9312
|
+
if (input.sessionId) {
|
|
9313
|
+
return `${input.provider}:${input.sessionId}:${status}`;
|
|
9314
|
+
}
|
|
9315
|
+
};
|
|
9316
|
+
var createVoiceTelephonyWebhookHandler = (options = {}) => async (input) => {
|
|
9317
|
+
const provider = options.provider ?? "generic";
|
|
9318
|
+
const query = input.query ?? {};
|
|
9319
|
+
const { body, rawBody } = await readRequestBody(input.request);
|
|
9320
|
+
const verification = await verifyVoiceTelephonyWebhook({
|
|
9321
|
+
body,
|
|
9322
|
+
options,
|
|
9323
|
+
provider,
|
|
9324
|
+
query,
|
|
9325
|
+
rawBody,
|
|
9326
|
+
request: input.request
|
|
9327
|
+
});
|
|
9328
|
+
if (!verification.ok) {
|
|
9329
|
+
throw new VoiceTelephonyWebhookVerificationError(verification);
|
|
9330
|
+
}
|
|
9331
|
+
const event = options.parse ? await options.parse({
|
|
9332
|
+
body,
|
|
9333
|
+
headers: input.request.headers,
|
|
9334
|
+
provider,
|
|
9335
|
+
query,
|
|
9336
|
+
request: input.request
|
|
9337
|
+
}) : parseVoiceTelephonyWebhookEvent({
|
|
9338
|
+
body,
|
|
9339
|
+
headers: input.request.headers,
|
|
9340
|
+
provider,
|
|
9341
|
+
query,
|
|
9342
|
+
request: input.request
|
|
9343
|
+
});
|
|
9344
|
+
const sessionId = await (options.resolveSessionId?.({
|
|
9345
|
+
body,
|
|
9346
|
+
event,
|
|
9347
|
+
query,
|
|
9348
|
+
request: input.request
|
|
9349
|
+
}) ?? defaultSessionId({ body, event, query }));
|
|
9350
|
+
const idempotencyEnabled = options.idempotency?.enabled !== false;
|
|
9351
|
+
const idempotencyKey = idempotencyEnabled ? await (options.idempotency?.key?.({
|
|
9352
|
+
body,
|
|
9353
|
+
event,
|
|
9354
|
+
provider,
|
|
9355
|
+
query,
|
|
9356
|
+
request: input.request,
|
|
9357
|
+
sessionId
|
|
9358
|
+
}) ?? defaultIdempotencyKey({ body, event, provider, sessionId })) : undefined;
|
|
9359
|
+
const idempotencyStore = options.idempotency?.store;
|
|
9360
|
+
if (idempotencyKey && idempotencyStore) {
|
|
9361
|
+
const existing = await idempotencyStore.get(idempotencyKey);
|
|
9362
|
+
if (existing) {
|
|
9363
|
+
const duplicateDecision = {
|
|
9364
|
+
...existing,
|
|
9365
|
+
duplicate: true
|
|
9366
|
+
};
|
|
9367
|
+
await options.onDecision?.({
|
|
9368
|
+
...duplicateDecision,
|
|
9369
|
+
context: options.context,
|
|
9370
|
+
request: input.request
|
|
9371
|
+
});
|
|
9372
|
+
return duplicateDecision;
|
|
9373
|
+
}
|
|
9374
|
+
}
|
|
9375
|
+
const decision = resolveVoiceTelephonyOutcome(event, options.policy);
|
|
9376
|
+
const resultResolver = options.result;
|
|
9377
|
+
const result = typeof resultResolver === "function" ? await resultResolver({
|
|
9378
|
+
decision,
|
|
9379
|
+
event,
|
|
9380
|
+
sessionId
|
|
9381
|
+
}) : resultResolver;
|
|
9382
|
+
const routeResult = voiceTelephonyOutcomeToRouteResult(decision, result);
|
|
9383
|
+
const shouldApply = typeof options.apply === "function" ? options.apply({
|
|
9384
|
+
applied: false,
|
|
9385
|
+
decision,
|
|
9386
|
+
event,
|
|
9387
|
+
routeResult,
|
|
9388
|
+
sessionId
|
|
9389
|
+
}) : options.apply === true;
|
|
9390
|
+
let applied = false;
|
|
9391
|
+
if (shouldApply && decision.action !== "ignore" && options.getSessionHandle) {
|
|
9392
|
+
const api = await options.getSessionHandle({
|
|
9393
|
+
context: options.context,
|
|
9394
|
+
decision,
|
|
9395
|
+
event,
|
|
9396
|
+
request: input.request,
|
|
9397
|
+
sessionId
|
|
9398
|
+
});
|
|
9399
|
+
if (api) {
|
|
9400
|
+
await applyVoiceTelephonyOutcome(api, decision, result);
|
|
9401
|
+
applied = true;
|
|
9402
|
+
}
|
|
9403
|
+
}
|
|
9404
|
+
const webhookDecision = {
|
|
9405
|
+
applied,
|
|
9406
|
+
decision,
|
|
9407
|
+
event,
|
|
9408
|
+
idempotencyKey,
|
|
9409
|
+
routeResult,
|
|
9410
|
+
sessionId
|
|
9411
|
+
};
|
|
9412
|
+
if (idempotencyKey && idempotencyStore) {
|
|
9413
|
+
const now = Date.now();
|
|
9414
|
+
await idempotencyStore.set(idempotencyKey, {
|
|
9415
|
+
...webhookDecision,
|
|
9416
|
+
createdAt: now,
|
|
9417
|
+
updatedAt: now
|
|
9418
|
+
});
|
|
9419
|
+
}
|
|
9420
|
+
await options.onDecision?.({
|
|
9421
|
+
...webhookDecision,
|
|
9422
|
+
context: options.context,
|
|
9423
|
+
request: input.request
|
|
9424
|
+
});
|
|
9425
|
+
return webhookDecision;
|
|
9426
|
+
};
|
|
9427
|
+
var createVoiceTelephonyWebhookRoutes = (options = {}) => {
|
|
9428
|
+
const path = options.path ?? "/api/voice/telephony/webhook";
|
|
9429
|
+
const handler = createVoiceTelephonyWebhookHandler(options);
|
|
9430
|
+
return new Elysia({
|
|
9431
|
+
name: options.name ?? "absolutejs-voice-telephony-webhooks"
|
|
9432
|
+
}).post(path, async ({ query, request }) => {
|
|
9433
|
+
try {
|
|
9434
|
+
return await handler({ query, request });
|
|
9435
|
+
} catch (error) {
|
|
9436
|
+
if (error instanceof VoiceTelephonyWebhookVerificationError) {
|
|
9437
|
+
return new Response(JSON.stringify({ verification: error.result }), {
|
|
9438
|
+
headers: {
|
|
9439
|
+
"content-type": "application/json"
|
|
9440
|
+
},
|
|
9441
|
+
status: 401
|
|
9442
|
+
});
|
|
9443
|
+
}
|
|
9444
|
+
throw error;
|
|
9445
|
+
}
|
|
9446
|
+
}, {
|
|
9447
|
+
parse: "none"
|
|
9448
|
+
});
|
|
9449
|
+
};
|
|
9450
|
+
|
|
9451
|
+
// src/telephony/twilio.ts
|
|
7684
9452
|
var TWILIO_MULAW_SAMPLE_RATE = 8000;
|
|
7685
9453
|
var VOICE_PCM_SAMPLE_RATE = 16000;
|
|
7686
9454
|
var escapeXml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
9455
|
+
var resolveRequestOrigin = (request) => {
|
|
9456
|
+
const url = new URL(request.url);
|
|
9457
|
+
const forwardedHost = request.headers.get("x-forwarded-host");
|
|
9458
|
+
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
9459
|
+
const host = forwardedHost ?? request.headers.get("host") ?? url.host;
|
|
9460
|
+
const protocol = forwardedProto ?? url.protocol.replace(":", "");
|
|
9461
|
+
return `${protocol}://${host}`;
|
|
9462
|
+
};
|
|
9463
|
+
var resolveTwilioStreamUrl = async (options, input) => {
|
|
9464
|
+
if (typeof options.twiml?.streamUrl === "function") {
|
|
9465
|
+
return options.twiml.streamUrl(input);
|
|
9466
|
+
}
|
|
9467
|
+
if (typeof options.twiml?.streamUrl === "string") {
|
|
9468
|
+
return options.twiml.streamUrl;
|
|
9469
|
+
}
|
|
9470
|
+
const origin = resolveRequestOrigin(input.request);
|
|
9471
|
+
const wsOrigin = origin.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
9472
|
+
return `${wsOrigin}${input.streamPath}`;
|
|
9473
|
+
};
|
|
9474
|
+
var resolveTwilioStreamParameters = async (parameters, input) => {
|
|
9475
|
+
if (typeof parameters === "function") {
|
|
9476
|
+
return parameters(input);
|
|
9477
|
+
}
|
|
9478
|
+
return parameters;
|
|
9479
|
+
};
|
|
9480
|
+
var joinUrlPath = (origin, path) => `${origin.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
9481
|
+
var escapeHtml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
9482
|
+
var getWebhookVerificationUrl = (webhook, input) => {
|
|
9483
|
+
if (!webhook?.verificationUrl) {
|
|
9484
|
+
return;
|
|
9485
|
+
}
|
|
9486
|
+
if (typeof webhook.verificationUrl === "function") {
|
|
9487
|
+
return webhook.verificationUrl(input);
|
|
9488
|
+
}
|
|
9489
|
+
return webhook.verificationUrl;
|
|
9490
|
+
};
|
|
9491
|
+
var buildTwilioVoiceSetupStatus = async (options, input) => {
|
|
9492
|
+
const origin = resolveRequestOrigin(input.request);
|
|
9493
|
+
const stream = await resolveTwilioStreamUrl(options, input);
|
|
9494
|
+
const twiml = joinUrlPath(origin, input.twimlPath);
|
|
9495
|
+
const webhook = joinUrlPath(origin, input.webhookPath);
|
|
9496
|
+
const verificationUrl = getWebhookVerificationUrl(options.webhook, input);
|
|
9497
|
+
const missing = Object.entries(options.setup?.requiredEnv ?? {}).filter((entry) => !entry[1]).map(([name]) => name);
|
|
9498
|
+
const signingConfigured = Boolean(options.webhook?.signingSecret || options.webhook?.verify);
|
|
9499
|
+
const warnings = [
|
|
9500
|
+
...stream.startsWith("wss://") ? [] : ["Twilio media streams should use wss:// in production."],
|
|
9501
|
+
...signingConfigured ? [] : ["Webhook signature verification is not configured."],
|
|
9502
|
+
...verificationUrl || !signingConfigured ? [] : ["Webhook signing is configured without an explicit verification URL."]
|
|
9503
|
+
];
|
|
9504
|
+
return {
|
|
9505
|
+
generatedAt: Date.now(),
|
|
9506
|
+
missing,
|
|
9507
|
+
provider: "twilio",
|
|
9508
|
+
ready: missing.length === 0 && signingConfigured && warnings.length === 0,
|
|
9509
|
+
signing: {
|
|
9510
|
+
configured: signingConfigured,
|
|
9511
|
+
mode: options.webhook?.verify ? "custom" : options.webhook?.signingSecret ? "twilio-signature" : "none",
|
|
9512
|
+
verificationUrl
|
|
9513
|
+
},
|
|
9514
|
+
urls: {
|
|
9515
|
+
stream,
|
|
9516
|
+
twiml,
|
|
9517
|
+
webhook
|
|
9518
|
+
},
|
|
9519
|
+
warnings
|
|
9520
|
+
};
|
|
9521
|
+
};
|
|
9522
|
+
var renderTwilioVoiceSetupHTML = (status, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
9523
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio setup</p>
|
|
9524
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
9525
|
+
<p><strong>Status:</strong> ${status.ready ? "Ready" : "Needs attention"}</p>
|
|
9526
|
+
<section>
|
|
9527
|
+
<h2>URLs</h2>
|
|
9528
|
+
<ul>
|
|
9529
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(status.urls.twiml)}</code></li>
|
|
9530
|
+
<li><strong>Media stream:</strong> <code>${escapeHtml2(status.urls.stream)}</code></li>
|
|
9531
|
+
<li><strong>Status webhook:</strong> <code>${escapeHtml2(status.urls.webhook)}</code></li>
|
|
9532
|
+
</ul>
|
|
9533
|
+
</section>
|
|
9534
|
+
<section>
|
|
9535
|
+
<h2>Signing</h2>
|
|
9536
|
+
<p>Mode: <code>${status.signing.mode}</code></p>
|
|
9537
|
+
${status.signing.verificationUrl ? `<p>Verification URL: <code>${escapeHtml2(status.signing.verificationUrl)}</code></p>` : ""}
|
|
9538
|
+
</section>
|
|
9539
|
+
${status.missing.length ? `<section><h2>Missing env</h2><ul>${status.missing.map((name) => `<li><code>${escapeHtml2(name)}</code></li>`).join("")}</ul></section>` : ""}
|
|
9540
|
+
${status.warnings.length ? `<section><h2>Warnings</h2><ul>${status.warnings.map((warning) => `<li>${escapeHtml2(warning)}</li>`).join("")}</ul></section>` : ""}
|
|
9541
|
+
</main>`;
|
|
9542
|
+
var extractTwilioStreamUrl = (twiml) => twiml.match(/<Stream\b[^>]*\surl="([^"]+)"/i)?.[1]?.replaceAll("&", "&");
|
|
9543
|
+
var createSmokeCheck = (name, status, message, details) => ({
|
|
9544
|
+
details,
|
|
9545
|
+
message,
|
|
9546
|
+
name,
|
|
9547
|
+
status
|
|
9548
|
+
});
|
|
9549
|
+
var renderTwilioVoiceSmokeHTML = (report, title) => `<main style="font-family: ui-sans-serif, system-ui; max-width: 860px; margin: 40px auto; padding: 0 20px;">
|
|
9550
|
+
<p style="letter-spacing: .12em; text-transform: uppercase; color: #52606d;">Twilio smoke test</p>
|
|
9551
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
9552
|
+
<p><strong>Status:</strong> ${report.pass ? "Pass" : "Fail"}</p>
|
|
9553
|
+
<section>
|
|
9554
|
+
<h2>Checks</h2>
|
|
9555
|
+
<ul>
|
|
9556
|
+
${report.checks.map((check) => `<li><strong>${escapeHtml2(check.name)}</strong>: ${escapeHtml2(check.status)}${check.message ? ` - ${escapeHtml2(check.message)}` : ""}</li>`).join("")}
|
|
9557
|
+
</ul>
|
|
9558
|
+
</section>
|
|
9559
|
+
<section>
|
|
9560
|
+
<h2>Observed URLs</h2>
|
|
9561
|
+
<ul>
|
|
9562
|
+
<li><strong>TwiML:</strong> <code>${escapeHtml2(report.setup.urls.twiml)}</code></li>
|
|
9563
|
+
<li><strong>Stream:</strong> <code>${escapeHtml2(report.twiml?.streamUrl ?? report.setup.urls.stream)}</code></li>
|
|
9564
|
+
<li><strong>Webhook:</strong> <code>${escapeHtml2(report.setup.urls.webhook)}</code></li>
|
|
9565
|
+
</ul>
|
|
9566
|
+
</section>
|
|
9567
|
+
</main>`;
|
|
9568
|
+
var runTwilioVoiceSmokeTest = async (input) => {
|
|
9569
|
+
const setup = await buildTwilioVoiceSetupStatus(input.options, input);
|
|
9570
|
+
const checks = [];
|
|
9571
|
+
const twimlUrl = new URL(setup.urls.twiml);
|
|
9572
|
+
twimlUrl.searchParams.set("scenarioId", input.options.smoke?.scenarioId ?? "smoke");
|
|
9573
|
+
twimlUrl.searchParams.set("sessionId", input.options.smoke?.sessionId ?? "smoke-session");
|
|
9574
|
+
const twimlResponse = await input.app.handle(new Request(twimlUrl, {
|
|
9575
|
+
headers: input.request.headers
|
|
9576
|
+
}));
|
|
9577
|
+
const twiml = await twimlResponse.text();
|
|
9578
|
+
const streamUrl = extractTwilioStreamUrl(twiml);
|
|
9579
|
+
checks.push(createSmokeCheck("twiml", twimlResponse.ok && Boolean(streamUrl) ? "pass" : "fail", streamUrl ? "TwiML includes a media stream URL." : 'TwiML is missing <Stream url="...">.', {
|
|
9580
|
+
status: twimlResponse.status,
|
|
9581
|
+
streamUrl
|
|
9582
|
+
}));
|
|
9583
|
+
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.", {
|
|
9584
|
+
streamUrl
|
|
9585
|
+
}));
|
|
9586
|
+
const webhookBody = {
|
|
9587
|
+
CallSid: input.options.smoke?.callSid ?? "CA_SMOKE_TEST",
|
|
9588
|
+
CallStatus: input.options.smoke?.status ?? "busy",
|
|
9589
|
+
SipResponseCode: String(input.options.smoke?.sipCode ?? 486)
|
|
9590
|
+
};
|
|
9591
|
+
const webhookHeaders = new Headers({
|
|
9592
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
9593
|
+
});
|
|
9594
|
+
const verificationUrl = setup.signing.verificationUrl ?? setup.urls.webhook;
|
|
9595
|
+
if (input.options.webhook?.signingSecret) {
|
|
9596
|
+
webhookHeaders.set("x-twilio-signature", await signVoiceTwilioWebhook({
|
|
9597
|
+
authToken: input.options.webhook.signingSecret,
|
|
9598
|
+
body: webhookBody,
|
|
9599
|
+
url: verificationUrl
|
|
9600
|
+
}));
|
|
9601
|
+
}
|
|
9602
|
+
const webhookResponse = await input.app.handle(new Request(setup.urls.webhook, {
|
|
9603
|
+
body: new URLSearchParams(webhookBody),
|
|
9604
|
+
headers: webhookHeaders,
|
|
9605
|
+
method: "POST"
|
|
9606
|
+
}));
|
|
9607
|
+
const webhookText = await webhookResponse.text();
|
|
9608
|
+
const webhookPayload = (() => {
|
|
9609
|
+
try {
|
|
9610
|
+
return JSON.parse(webhookText);
|
|
9611
|
+
} catch {
|
|
9612
|
+
return webhookText;
|
|
9613
|
+
}
|
|
9614
|
+
})();
|
|
9615
|
+
checks.push(createSmokeCheck("webhook", webhookResponse.ok ? "pass" : "fail", webhookResponse.ok ? "Synthetic Twilio status callback was accepted." : "Synthetic Twilio status callback failed.", {
|
|
9616
|
+
status: webhookResponse.status
|
|
9617
|
+
}));
|
|
9618
|
+
for (const warning of setup.warnings) {
|
|
9619
|
+
checks.push(createSmokeCheck("setup-warning", "warn", warning));
|
|
9620
|
+
}
|
|
9621
|
+
for (const name of setup.missing) {
|
|
9622
|
+
checks.push(createSmokeCheck("missing-env", "fail", `${name} is missing.`));
|
|
9623
|
+
}
|
|
9624
|
+
return {
|
|
9625
|
+
checks,
|
|
9626
|
+
generatedAt: Date.now(),
|
|
9627
|
+
pass: checks.every((check) => check.status !== "fail"),
|
|
9628
|
+
provider: "twilio",
|
|
9629
|
+
setup,
|
|
9630
|
+
twiml: {
|
|
9631
|
+
status: twimlResponse.status,
|
|
9632
|
+
streamUrl
|
|
9633
|
+
},
|
|
9634
|
+
webhook: {
|
|
9635
|
+
body: webhookPayload,
|
|
9636
|
+
status: webhookResponse.status
|
|
9637
|
+
}
|
|
9638
|
+
};
|
|
9639
|
+
};
|
|
7687
9640
|
var normalizeOnTurn = (handler) => {
|
|
7688
9641
|
if (handler.length > 1) {
|
|
7689
9642
|
const directHandler = handler;
|
|
@@ -7785,7 +9738,7 @@ var bytesToInt16Array = (bytes) => {
|
|
|
7785
9738
|
return output;
|
|
7786
9739
|
};
|
|
7787
9740
|
var decodeTwilioMulawBase64 = (payload) => {
|
|
7788
|
-
const bytes = Uint8Array.from(
|
|
9741
|
+
const bytes = Uint8Array.from(Buffer3.from(payload, "base64"));
|
|
7789
9742
|
const samples = new Int16Array(bytes.length);
|
|
7790
9743
|
for (let index = 0;index < bytes.length; index += 1) {
|
|
7791
9744
|
samples[index] = decodeMulawSample(bytes[index] ?? 0);
|
|
@@ -7797,7 +9750,7 @@ var encodeTwilioMulawBase64 = (samples) => {
|
|
|
7797
9750
|
for (let index = 0;index < samples.length; index += 1) {
|
|
7798
9751
|
bytes[index] = encodeMulawSample(samples[index] ?? 0);
|
|
7799
9752
|
}
|
|
7800
|
-
return
|
|
9753
|
+
return Buffer3.from(bytes).toString("base64");
|
|
7801
9754
|
};
|
|
7802
9755
|
var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
|
|
7803
9756
|
const narrowband = decodeTwilioMulawBase64(payload);
|
|
@@ -7806,7 +9759,7 @@ var transcodeTwilioInboundPayloadToPCM16 = (payload) => {
|
|
|
7806
9759
|
};
|
|
7807
9760
|
var transcodePCMToTwilioOutboundPayload = (chunk, format) => {
|
|
7808
9761
|
if (format.container === "raw" && format.encoding === "mulaw" && format.channels === 1 && format.sampleRateHz === TWILIO_MULAW_SAMPLE_RATE) {
|
|
7809
|
-
return
|
|
9762
|
+
return Buffer3.from(chunk).toString("base64");
|
|
7810
9763
|
}
|
|
7811
9764
|
if (format.encoding !== "pcm_s16le") {
|
|
7812
9765
|
throw new Error(`Unsupported outbound telephony audio format: ${format.container}/${format.encoding}`);
|
|
@@ -7847,7 +9800,7 @@ var createTwilioSocketAdapter = (socket, getState) => ({
|
|
|
7847
9800
|
return;
|
|
7848
9801
|
}
|
|
7849
9802
|
if (message.type === "audio") {
|
|
7850
|
-
const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(
|
|
9803
|
+
const payload = transcodePCMToTwilioOutboundPayload(Uint8Array.from(Buffer3.from(message.chunkBase64, "base64")), message.format);
|
|
7851
9804
|
state.hasOutboundAudioSinceLastInbound = true;
|
|
7852
9805
|
state.reviewRecorder?.recordTwilioOutbound({
|
|
7853
9806
|
bytes: payload.length,
|
|
@@ -8060,6 +10013,148 @@ var createTwilioMediaStreamBridge = (socket, options) => {
|
|
|
8060
10013
|
}
|
|
8061
10014
|
};
|
|
8062
10015
|
};
|
|
10016
|
+
var createTwilioVoiceRoutes = (options) => {
|
|
10017
|
+
const streamPath = options.streamPath ?? "/api/voice/twilio/stream";
|
|
10018
|
+
const twimlPath = options.twiml?.path ?? "/api/voice/twilio";
|
|
10019
|
+
const webhookPath = options.webhook?.path ?? "/api/voice/twilio/webhook";
|
|
10020
|
+
const setupPath = options.setup?.path === false ? false : options.setup?.path ?? "/api/voice/twilio/setup";
|
|
10021
|
+
const smokePath = options.smoke?.path === false ? false : options.smoke?.path ?? "/api/voice/twilio/smoke";
|
|
10022
|
+
const bridges = new WeakMap;
|
|
10023
|
+
const webhookPolicy = options.webhook?.policy ?? options.outcomePolicy ?? createVoiceTelephonyOutcomePolicy();
|
|
10024
|
+
const app = new Elysia2({
|
|
10025
|
+
name: options.name ?? "absolutejs-voice-twilio"
|
|
10026
|
+
}).get(twimlPath, async ({ query, request }) => {
|
|
10027
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
10028
|
+
query,
|
|
10029
|
+
request,
|
|
10030
|
+
streamPath
|
|
10031
|
+
});
|
|
10032
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
10033
|
+
query,
|
|
10034
|
+
request
|
|
10035
|
+
});
|
|
10036
|
+
return new Response(createTwilioVoiceResponse({
|
|
10037
|
+
parameters,
|
|
10038
|
+
streamName: options.twiml?.streamName,
|
|
10039
|
+
streamUrl,
|
|
10040
|
+
track: options.twiml?.track
|
|
10041
|
+
}), {
|
|
10042
|
+
headers: {
|
|
10043
|
+
"content-type": "text/xml; charset=utf-8"
|
|
10044
|
+
}
|
|
10045
|
+
});
|
|
10046
|
+
}).post(twimlPath, async ({ query, request }) => {
|
|
10047
|
+
const streamUrl = await resolveTwilioStreamUrl(options, {
|
|
10048
|
+
query,
|
|
10049
|
+
request,
|
|
10050
|
+
streamPath
|
|
10051
|
+
});
|
|
10052
|
+
const parameters = await resolveTwilioStreamParameters(options.twiml?.parameters, {
|
|
10053
|
+
query,
|
|
10054
|
+
request
|
|
10055
|
+
});
|
|
10056
|
+
return new Response(createTwilioVoiceResponse({
|
|
10057
|
+
parameters,
|
|
10058
|
+
streamName: options.twiml?.streamName,
|
|
10059
|
+
streamUrl,
|
|
10060
|
+
track: options.twiml?.track
|
|
10061
|
+
}), {
|
|
10062
|
+
headers: {
|
|
10063
|
+
"content-type": "text/xml; charset=utf-8"
|
|
10064
|
+
}
|
|
10065
|
+
});
|
|
10066
|
+
}).ws(streamPath, {
|
|
10067
|
+
close: async (ws, _code, reason) => {
|
|
10068
|
+
const bridge = bridges.get(ws);
|
|
10069
|
+
bridges.delete(ws);
|
|
10070
|
+
await bridge?.close(reason);
|
|
10071
|
+
},
|
|
10072
|
+
message: async (ws, raw) => {
|
|
10073
|
+
let bridge = bridges.get(ws);
|
|
10074
|
+
if (!bridge) {
|
|
10075
|
+
bridge = createTwilioMediaStreamBridge({
|
|
10076
|
+
close: (code, reason) => {
|
|
10077
|
+
ws.close(code, reason);
|
|
10078
|
+
},
|
|
10079
|
+
send: (data) => {
|
|
10080
|
+
ws.send(data);
|
|
10081
|
+
}
|
|
10082
|
+
}, options);
|
|
10083
|
+
bridges.set(ws, bridge);
|
|
10084
|
+
}
|
|
10085
|
+
await bridge.handleMessage(raw);
|
|
10086
|
+
}
|
|
10087
|
+
}).use(createVoiceTelephonyWebhookRoutes({
|
|
10088
|
+
...options.webhook ?? {},
|
|
10089
|
+
context: options.context,
|
|
10090
|
+
path: webhookPath,
|
|
10091
|
+
policy: webhookPolicy,
|
|
10092
|
+
provider: "twilio"
|
|
10093
|
+
}));
|
|
10094
|
+
if (!setupPath) {
|
|
10095
|
+
if (!smokePath) {
|
|
10096
|
+
return app;
|
|
10097
|
+
}
|
|
10098
|
+
return app.get(smokePath, async ({ query, request }) => {
|
|
10099
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
10100
|
+
app,
|
|
10101
|
+
options,
|
|
10102
|
+
query,
|
|
10103
|
+
request,
|
|
10104
|
+
streamPath,
|
|
10105
|
+
twimlPath,
|
|
10106
|
+
webhookPath
|
|
10107
|
+
});
|
|
10108
|
+
if (query.format === "html") {
|
|
10109
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
10110
|
+
headers: {
|
|
10111
|
+
"content-type": "text/html; charset=utf-8"
|
|
10112
|
+
}
|
|
10113
|
+
});
|
|
10114
|
+
}
|
|
10115
|
+
return report;
|
|
10116
|
+
});
|
|
10117
|
+
}
|
|
10118
|
+
const withSetup = app.get(setupPath, async ({ query, request }) => {
|
|
10119
|
+
const status = await buildTwilioVoiceSetupStatus(options, {
|
|
10120
|
+
query,
|
|
10121
|
+
request,
|
|
10122
|
+
streamPath,
|
|
10123
|
+
twimlPath,
|
|
10124
|
+
webhookPath
|
|
10125
|
+
});
|
|
10126
|
+
if (query.format === "html") {
|
|
10127
|
+
return new Response(renderTwilioVoiceSetupHTML(status, options.setup?.title ?? "AbsoluteJS Twilio Voice Setup"), {
|
|
10128
|
+
headers: {
|
|
10129
|
+
"content-type": "text/html; charset=utf-8"
|
|
10130
|
+
}
|
|
10131
|
+
});
|
|
10132
|
+
}
|
|
10133
|
+
return status;
|
|
10134
|
+
});
|
|
10135
|
+
if (!smokePath) {
|
|
10136
|
+
return withSetup;
|
|
10137
|
+
}
|
|
10138
|
+
return withSetup.get(smokePath, async ({ query, request }) => {
|
|
10139
|
+
const report = await runTwilioVoiceSmokeTest({
|
|
10140
|
+
app,
|
|
10141
|
+
options,
|
|
10142
|
+
query,
|
|
10143
|
+
request,
|
|
10144
|
+
streamPath,
|
|
10145
|
+
twimlPath,
|
|
10146
|
+
webhookPath
|
|
10147
|
+
});
|
|
10148
|
+
if (query.format === "html") {
|
|
10149
|
+
return new Response(renderTwilioVoiceSmokeHTML(report, options.smoke?.title ?? "AbsoluteJS Twilio Voice Smoke Test"), {
|
|
10150
|
+
headers: {
|
|
10151
|
+
"content-type": "text/html; charset=utf-8"
|
|
10152
|
+
}
|
|
10153
|
+
});
|
|
10154
|
+
}
|
|
10155
|
+
return report;
|
|
10156
|
+
});
|
|
10157
|
+
};
|
|
8063
10158
|
|
|
8064
10159
|
// src/testing/telephony.ts
|
|
8065
10160
|
var DEFAULT_PCM16_FORMAT = {
|
|
@@ -8325,7 +10420,7 @@ var runVoiceTelephonyBenchmark = async (scenarios = getDefaultVoiceTelephonyBenc
|
|
|
8325
10420
|
};
|
|
8326
10421
|
};
|
|
8327
10422
|
// src/testing/tts.ts
|
|
8328
|
-
var
|
|
10423
|
+
var DEFAULT_REALTIME_FORMAT2 = {
|
|
8329
10424
|
channels: 1,
|
|
8330
10425
|
container: "raw",
|
|
8331
10426
|
encoding: "pcm_s16le",
|
|
@@ -8384,7 +10479,7 @@ var runTTSAdapterFixture = async (adapter, fixture, options = {}) => {
|
|
|
8384
10479
|
let audioDurationMs = 0;
|
|
8385
10480
|
let audioChunkCount = 0;
|
|
8386
10481
|
const session = adapter.kind === "realtime" ? await adapter.open({
|
|
8387
|
-
format: options.realtimeFormat ??
|
|
10482
|
+
format: options.realtimeFormat ?? DEFAULT_REALTIME_FORMAT2,
|
|
8388
10483
|
sessionId: `tts-benchmark:${fixture.id}`,
|
|
8389
10484
|
...openOptions ?? {}
|
|
8390
10485
|
}) : await adapter.open({
|
|
@@ -8551,6 +10646,7 @@ export {
|
|
|
8551
10646
|
getDefaultTTSBenchmarkFixtures,
|
|
8552
10647
|
evaluateSTTBenchmarkAcceptance,
|
|
8553
10648
|
createVoiceProviderFailureSimulator,
|
|
10649
|
+
createVoiceIOProviderFailureSimulator,
|
|
8554
10650
|
createVoiceCallReviewRecorder,
|
|
8555
10651
|
createVoiceCallReviewFromLiveTelephonyReport,
|
|
8556
10652
|
createTelephonyVoiceTestFixtures,
|