@openclaw/voice-call 2026.8.1-beta.2 → 2026.8.1-beta.3
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 +4 -3
- package/dist/doctor-contract-api.js +1 -0
- package/dist/{guarded-json-api-DEDwgf4J.js → guarded-json-api-j4OEG0fR.js} +1 -1
- package/dist/index.js +390 -338
- package/dist/{mock-BCq0q36l.js → mock-Dv7KxYGt.js} +3 -1
- package/dist/{plivo-DbI17uAc.js → plivo-BbVnahnQ.js} +2 -2
- package/dist/{realtime-handler-uXR0QgVa.js → realtime-handler-BaMEKbmJ.js} +239 -125
- package/dist/{response-generator-D-hkzo5Y.js → response-generator-tjyX4tDq.js} +59 -12
- package/dist/{runtime-entry-F13eJ4pp.js → runtime-entry-DK8kPCGC.js} +422 -197
- package/dist/runtime-entry.js +1 -1
- package/dist/{telnyx-sIS_WgnY.js → telnyx-DO8dFQcb.js} +12 -8
- package/dist/{twilio-CbeVrVAl.js → twilio-JDt_1nlz.js} +40 -27
- package/openclaw.plugin.json +20 -2
- package/package.json +4 -4
|
@@ -7,7 +7,7 @@ import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
|
|
|
7
7
|
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
|
8
8
|
import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
9
9
|
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
|
10
|
-
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
|
|
10
|
+
import { listAgentIds, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime";
|
|
11
11
|
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
|
12
12
|
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES, assertRealtimeVoiceAgentConsultModelSelectionUnlocked, buildRealtimeVoiceAgentConsultPolicyInstructions, consultRealtimeVoiceAgent, convertPcmToMulaw8k, createTalkSessionController, recordTalkObservabilityEvent, resolveRealtimeVoiceAgentConsultTools, resolveRealtimeVoiceAgentConsultToolsAllow, resolveRealtimeVoiceFastContextConsult } from "openclaw/plugin-sdk/realtime-voice";
|
|
13
13
|
import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime";
|
|
@@ -16,11 +16,11 @@ import { canonicalizeMainSessionAlias } from "openclaw/plugin-sdk/session-store-
|
|
|
16
16
|
import { resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core";
|
|
17
17
|
import { WEBHOOK_BODY_READ_DEFAULTS, createWebhookInFlightLimiter, normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
|
18
18
|
import { z } from "zod";
|
|
19
|
+
import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
|
19
20
|
import fs from "node:fs";
|
|
20
21
|
import crypto from "node:crypto";
|
|
21
22
|
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
|
|
22
23
|
import path from "node:path";
|
|
23
|
-
import { MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
|
24
24
|
import os from "node:os";
|
|
25
25
|
import { root } from "openclaw/plugin-sdk/security-runtime";
|
|
26
26
|
import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
|
@@ -31,9 +31,51 @@ import { URL as URL$1 } from "node:url";
|
|
|
31
31
|
import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime";
|
|
32
32
|
import { WebSocket, WebSocketServer } from "ws";
|
|
33
33
|
import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime";
|
|
34
|
+
//#region extensions/voice-call/src/realtime-call-control.ts
|
|
35
|
+
/** Stable provider-facing tool name for ending the current phone call. */
|
|
36
|
+
const REALTIME_VOICE_END_CALL_TOOL_NAME = "openclaw_end_call";
|
|
37
|
+
/** Closure-bound end-call control exposed on every realtime phone call. */
|
|
38
|
+
const REALTIME_VOICE_END_CALL_TOOL = {
|
|
39
|
+
type: "function",
|
|
40
|
+
name: REALTIME_VOICE_END_CALL_TOOL_NAME,
|
|
41
|
+
description: "End the current phone call immediately. Speak any final words to the caller before invoking this tool because the call ends as soon as it is invoked and no further reply will be spoken.",
|
|
42
|
+
parameters: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/** Merge built-in call controls with consult and configured realtime tools. */
|
|
48
|
+
function resolveVoiceCallRealtimeTools(policy, customTools = []) {
|
|
49
|
+
const tools = resolveRealtimeVoiceAgentConsultTools(policy, customTools).filter((tool) => tool.name !== REALTIME_VOICE_END_CALL_TOOL_NAME);
|
|
50
|
+
return [REALTIME_VOICE_END_CALL_TOOL, ...tools];
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
34
53
|
//#region extensions/voice-call/src/realtime-defaults.ts
|
|
35
54
|
/** Baseline instructions that keep realtime calls brief and route deep work to agent consult. */
|
|
36
|
-
const DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS = `You are OpenClaw's phone-call realtime voice interface. Keep spoken replies brief and natural. When a question needs deeper reasoning, current information, or tools, call ${REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME} before answering.`;
|
|
55
|
+
const DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS = `You are OpenClaw's phone-call realtime voice interface. Keep spoken replies brief and natural. When a question needs deeper reasoning, current information, or tools, call ${REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME} before answering. When the caller asks to end the call, speak any final words first, then call ${REALTIME_VOICE_END_CALL_TOOL_NAME}; it ends the call immediately and nothing after it will be spoken.`;
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region extensions/voice-call/src/tailscale-config.ts
|
|
58
|
+
const VoiceCallTailscaleConfigSchema = z.object({
|
|
59
|
+
mode: z.enum([
|
|
60
|
+
"off",
|
|
61
|
+
"serve",
|
|
62
|
+
"funnel"
|
|
63
|
+
]).default("off"),
|
|
64
|
+
port: z.number().int().positive().max(MAX_TCP_PORT).default(443),
|
|
65
|
+
path: z.string().min(1).default("/voice/webhook")
|
|
66
|
+
}).strict().default({
|
|
67
|
+
mode: "off",
|
|
68
|
+
port: 443,
|
|
69
|
+
path: "/voice/webhook"
|
|
70
|
+
});
|
|
71
|
+
const TAILSCALE_FUNNEL_HTTPS_PORTS = /* @__PURE__ */ new Set([
|
|
72
|
+
443,
|
|
73
|
+
8443,
|
|
74
|
+
1e4
|
|
75
|
+
]);
|
|
76
|
+
function isTailscalePortAllowed(config) {
|
|
77
|
+
return !(config.tailscale.mode === "funnel" || config.tunnel.provider === "tailscale-funnel") || TAILSCALE_FUNNEL_HTTPS_PORTS.has(config.tailscale.port);
|
|
78
|
+
}
|
|
37
79
|
//#endregion
|
|
38
80
|
//#region extensions/voice-call/src/config.ts
|
|
39
81
|
/**
|
|
@@ -103,24 +145,6 @@ const VoiceCallServeConfigSchema = z.object({
|
|
|
103
145
|
bind: "127.0.0.1",
|
|
104
146
|
path: "/voice/webhook"
|
|
105
147
|
});
|
|
106
|
-
const VoiceCallTailscaleConfigSchema = z.object({
|
|
107
|
-
/**
|
|
108
|
-
* Tailscale exposure mode:
|
|
109
|
-
* - "off": No Tailscale exposure
|
|
110
|
-
* - "serve": Tailscale serve (private to tailnet)
|
|
111
|
-
* - "funnel": Tailscale funnel (public HTTPS)
|
|
112
|
-
*/
|
|
113
|
-
mode: z.enum([
|
|
114
|
-
"off",
|
|
115
|
-
"serve",
|
|
116
|
-
"funnel"
|
|
117
|
-
]).default("off"),
|
|
118
|
-
/** Path for Tailscale serve/funnel (should usually match serve.path) */
|
|
119
|
-
path: z.string().min(1).default("/voice/webhook")
|
|
120
|
-
}).strict().default({
|
|
121
|
-
mode: "off",
|
|
122
|
-
path: "/voice/webhook"
|
|
123
|
-
});
|
|
124
148
|
const VoiceCallTunnelConfigSchema = z.object({
|
|
125
149
|
/**
|
|
126
150
|
* Tunnel provider:
|
|
@@ -178,7 +202,11 @@ const VoiceCallWebhookSecurityConfigSchema = z.object({
|
|
|
178
202
|
* - "conversation": Stay open for back-and-forth until explicit end or timeout
|
|
179
203
|
*/
|
|
180
204
|
const CallModeSchema = z.enum(["notify", "conversation"]);
|
|
181
|
-
const VoiceCallSessionScopeSchema = z.enum([
|
|
205
|
+
const VoiceCallSessionScopeSchema = z.enum([
|
|
206
|
+
"per-phone",
|
|
207
|
+
"per-call",
|
|
208
|
+
"main"
|
|
209
|
+
]);
|
|
182
210
|
const OutboundConfigSchema = z.object({
|
|
183
211
|
/** Default call mode for outbound calls */
|
|
184
212
|
defaultMode: CallModeSchema.default("notify"),
|
|
@@ -418,7 +446,10 @@ const VoiceCallConfigSchema = z.object({
|
|
|
418
446
|
responseSystemPrompt: z.string().optional(),
|
|
419
447
|
/** Timeout for response generation in ms (default 30s) */
|
|
420
448
|
responseTimeoutMs: z.number().int().positive().default(3e4)
|
|
421
|
-
}).strict()
|
|
449
|
+
}).strict().refine(isTailscalePortAllowed, {
|
|
450
|
+
path: ["tailscale", "port"],
|
|
451
|
+
message: "Tailscale Funnel HTTPS port must be one of 443, 8443, 10000"
|
|
452
|
+
});
|
|
422
453
|
const TWILIO_AUTH_TOKEN_PATH = "plugins.entries.voice-call.config.twilio.authToken";
|
|
423
454
|
const DEFAULT_VOICE_CALL_CONFIG = VoiceCallConfigSchema.parse({});
|
|
424
455
|
function cloneDefaultVoiceCallConfig() {
|
|
@@ -430,6 +461,31 @@ function defaultRealtimeStreamPathForServePath(servePath) {
|
|
|
430
461
|
if (normalized === "/") return "/voice/stream/realtime";
|
|
431
462
|
return `${normalized}/stream/realtime`;
|
|
432
463
|
}
|
|
464
|
+
function resolveVoiceCallPublicPathPrefix(publicWebhookPath, localWebhookPath) {
|
|
465
|
+
const publicPath = normalizeWebhookPath(publicWebhookPath);
|
|
466
|
+
const localPathIndex = publicPath.indexOf(normalizeWebhookPath(localWebhookPath));
|
|
467
|
+
return localPathIndex > 0 ? publicPath.slice(0, localPathIndex) : "";
|
|
468
|
+
}
|
|
469
|
+
function resolveVoiceCallStreamExposurePaths(config, webhookPaths = {}) {
|
|
470
|
+
const exposurePaths = [];
|
|
471
|
+
const localWebhookPath = webhookPaths.localWebhookPath ?? config.serve.path;
|
|
472
|
+
const publicPathPrefix = resolveVoiceCallPublicPathPrefix(webhookPaths.publicWebhookPath ?? config.tailscale.path, localWebhookPath);
|
|
473
|
+
if (config.realtime.enabled) {
|
|
474
|
+
const localPath = normalizeWebhookPath(config.realtime.streamPath ?? defaultRealtimeStreamPathForServePath(config.serve.path));
|
|
475
|
+
exposurePaths.push({
|
|
476
|
+
localPath,
|
|
477
|
+
publicPath: `${publicPathPrefix}${localPath}`
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (config.streaming.enabled) {
|
|
481
|
+
const localPath = normalizeWebhookPath(config.streaming.streamPath);
|
|
482
|
+
if (!exposurePaths.some((path) => path.localPath === localPath && path.publicPath === localPath)) exposurePaths.push({
|
|
483
|
+
localPath,
|
|
484
|
+
publicPath: localPath
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return exposurePaths;
|
|
488
|
+
}
|
|
433
489
|
function normalizeVoiceCallTtsConfig(defaults, overrides) {
|
|
434
490
|
if (!defaults && !overrides) return;
|
|
435
491
|
return TtsConfigSchema.parse(mergeDeep(defaults ?? {}, overrides ?? {}));
|
|
@@ -553,6 +609,11 @@ function resolveVoiceCallSessionKey(params) {
|
|
|
553
609
|
sessionKey: explicit,
|
|
554
610
|
coreSession: params.coreSession
|
|
555
611
|
});
|
|
612
|
+
if (params.config.sessionScope === "main") return resolveVoiceCallAgentSessionKey({
|
|
613
|
+
config: params.config,
|
|
614
|
+
sessionKey: "main",
|
|
615
|
+
coreSession: params.coreSession
|
|
616
|
+
});
|
|
556
617
|
const prefix = `agent:${normalizeAgentId(params.config.agentId)}:voice`;
|
|
557
618
|
if (params.config.sessionScope === "per-call") return `${prefix}:call:${params.callId}`.toLowerCase();
|
|
558
619
|
const normalizedPhone = params.phone?.replace(/\D/g, "");
|
|
@@ -734,9 +795,12 @@ function startMaxDurationTimer(params) {
|
|
|
734
795
|
const call = params.ctx.activeCalls.get(params.callId);
|
|
735
796
|
if (call && !TerminalStates.has(call.state)) {
|
|
736
797
|
console.log(`[voice-call] Max duration reached (${Math.ceil(maxDurationMs / 1e3)}s), ending call ${params.callId}`);
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
798
|
+
try {
|
|
799
|
+
const result = await params.onTimeout(params.callId);
|
|
800
|
+
if (!result.success) console.warn(`[voice-call] Failed to end max-duration call ${params.callId}: ${result.error ?? "unknown error"}`);
|
|
801
|
+
} catch (error) {
|
|
802
|
+
console.warn(`[voice-call] Failed to end max-duration call ${params.callId}: ${formatErrorMessage(error)}`);
|
|
803
|
+
}
|
|
740
804
|
}
|
|
741
805
|
})();
|
|
742
806
|
}, maxDurationMs);
|
|
@@ -794,6 +858,7 @@ function waitForFinalTranscript(ctx, callId, turnToken) {
|
|
|
794
858
|
}
|
|
795
859
|
//#endregion
|
|
796
860
|
//#region extensions/voice-call/src/manager/lifecycle.ts
|
|
861
|
+
const log$1 = createSubsystemLogger("voice-call/lifecycle");
|
|
797
862
|
/** Remove a provider-call mapping only when it still points at this call. */
|
|
798
863
|
function removeProviderCallMapping(providerCallIdMap, call) {
|
|
799
864
|
if (!call.providerCallId) return;
|
|
@@ -802,10 +867,14 @@ function removeProviderCallMapping(providerCallIdMap, call) {
|
|
|
802
867
|
/** Persist terminal state, clean timers/waiters, and remove active call indexes. */
|
|
803
868
|
function finalizeCall(params) {
|
|
804
869
|
const { ctx, call, endReason } = params;
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
870
|
+
const previousState = call.state;
|
|
871
|
+
if (!TerminalStates.has(previousState)) {
|
|
872
|
+
call.endedAt = params.endedAt ?? Date.now();
|
|
873
|
+
call.endReason = endReason;
|
|
874
|
+
transitionState(call, endReason);
|
|
875
|
+
persistCallRecord(ctx.storePath, call);
|
|
876
|
+
log$1.info(`[voice-call] Call finalized callId=${call.callId} providerCallId=${call.providerCallId ?? "unknown"} endReason=${endReason}`);
|
|
877
|
+
}
|
|
809
878
|
if (ctx.maxDurationTimers) clearMaxDurationTimer({ maxDurationTimers: ctx.maxDurationTimers }, call.callId);
|
|
810
879
|
if (ctx.transcriptWaiters) rejectTranscriptWaiter({ transcriptWaiters: ctx.transcriptWaiters }, call.callId, params.transcriptRejectReason ?? `Call ended: ${endReason}`);
|
|
811
880
|
ctx.activeCalls.delete(call.callId);
|
|
@@ -1073,9 +1142,7 @@ async function speak(ctx, callId, text, options) {
|
|
|
1073
1142
|
ctx,
|
|
1074
1143
|
call,
|
|
1075
1144
|
liveAt: Date.now(),
|
|
1076
|
-
onTimeout:
|
|
1077
|
-
await endCall(ctx, id, { reason: "timeout" });
|
|
1078
|
-
}
|
|
1145
|
+
onTimeout: (id) => endCall(ctx, id, { reason: "timeout" })
|
|
1079
1146
|
});
|
|
1080
1147
|
transitionState(call, "speaking");
|
|
1081
1148
|
persistCallRecord(ctx.storePath, call);
|
|
@@ -1176,7 +1243,8 @@ async function speakInitialMessage(ctx, providerCallId) {
|
|
|
1176
1243
|
const currentCall = ctx.activeCalls.get(call.callId);
|
|
1177
1244
|
if (currentCall && !TerminalStates.has(currentCall.state)) {
|
|
1178
1245
|
console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`);
|
|
1179
|
-
await endCall(ctx, call.callId);
|
|
1246
|
+
const endResult = await endCall(ctx, call.callId);
|
|
1247
|
+
if (!endResult.success) console.warn(`[voice-call] Notify mode failed to hang up call ${call.callId}: ${endResult.error ?? "unknown error"}`);
|
|
1180
1248
|
}
|
|
1181
1249
|
})();
|
|
1182
1250
|
}, delayMs);
|
|
@@ -1249,33 +1317,42 @@ async function continueCall(ctx, callId, prompt) {
|
|
|
1249
1317
|
clearTranscriptWaiter(ctx, callId);
|
|
1250
1318
|
}
|
|
1251
1319
|
}
|
|
1252
|
-
|
|
1320
|
+
function endCall(ctx, callId, options) {
|
|
1321
|
+
const inFlight = ctx.endCallOperations.get(callId);
|
|
1322
|
+
if (inFlight) return inFlight;
|
|
1253
1323
|
const lookup = lookupConnectedCall(ctx, callId);
|
|
1254
|
-
if (lookup.kind === "error") return {
|
|
1324
|
+
if (lookup.kind === "error") return Promise.resolve({
|
|
1255
1325
|
success: false,
|
|
1256
1326
|
error: lookup.error
|
|
1257
|
-
};
|
|
1258
|
-
if (lookup.kind === "ended") return { success: true };
|
|
1327
|
+
});
|
|
1328
|
+
if (lookup.kind === "ended") return Promise.resolve({ success: true });
|
|
1259
1329
|
const { call, providerCallId, provider } = lookup;
|
|
1260
1330
|
const reason = options?.reason ?? "hangup-bot";
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1331
|
+
const operation = (async () => {
|
|
1332
|
+
try {
|
|
1333
|
+
await provider.hangupCall({
|
|
1334
|
+
callId,
|
|
1335
|
+
providerCallId,
|
|
1336
|
+
reason
|
|
1337
|
+
});
|
|
1338
|
+
finalizeCall({
|
|
1339
|
+
ctx,
|
|
1340
|
+
call,
|
|
1341
|
+
endReason: reason
|
|
1342
|
+
});
|
|
1343
|
+
return { success: true };
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
return {
|
|
1346
|
+
success: false,
|
|
1347
|
+
error: formatErrorMessage(err)
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
})();
|
|
1351
|
+
ctx.endCallOperations.set(callId, operation);
|
|
1352
|
+
operation.then(() => {
|
|
1353
|
+
if (ctx.endCallOperations.get(callId) === operation) ctx.endCallOperations.delete(callId);
|
|
1354
|
+
});
|
|
1355
|
+
return operation;
|
|
1279
1356
|
}
|
|
1280
1357
|
//#endregion
|
|
1281
1358
|
//#region extensions/voice-call/src/manager/events.ts
|
|
@@ -1319,7 +1396,8 @@ function createWebhookCall(params) {
|
|
|
1319
1396
|
sessionKey: resolveVoiceCallSessionKey({
|
|
1320
1397
|
config: effectiveConfig,
|
|
1321
1398
|
callId,
|
|
1322
|
-
phone: params.direction === "outbound" ? params.to : params.from
|
|
1399
|
+
phone: params.direction === "outbound" ? params.to : params.from,
|
|
1400
|
+
coreSession: params.ctx.coreSession
|
|
1323
1401
|
}),
|
|
1324
1402
|
agentId: normalizeAgentId(effectiveConfig.agentId),
|
|
1325
1403
|
startedAt: Date.now(),
|
|
@@ -1426,9 +1504,7 @@ function processEvent(ctx, event) {
|
|
|
1426
1504
|
startMaxDurationTimer({
|
|
1427
1505
|
ctx,
|
|
1428
1506
|
callId: activeCall.callId,
|
|
1429
|
-
onTimeout:
|
|
1430
|
-
await endCall(ctx, callId, { reason: "timeout" });
|
|
1431
|
-
}
|
|
1507
|
+
onTimeout: (callId) => endCall(ctx, callId, { reason: "timeout" })
|
|
1432
1508
|
});
|
|
1433
1509
|
};
|
|
1434
1510
|
const prepareLiveDurationTimer = () => {
|
|
@@ -1493,7 +1569,7 @@ function processEvent(ctx, event) {
|
|
|
1493
1569
|
if (event.type === "call.assistant-speech" && event.transcript.trim()) addTranscriptEntry(activeCall, "bot", event.transcript);
|
|
1494
1570
|
break;
|
|
1495
1571
|
case "call.speech":
|
|
1496
|
-
if (event.isFinal) {
|
|
1572
|
+
if (event.isFinal && event.transcript.trim()) {
|
|
1497
1573
|
const waiter = ctx.transcriptWaiters.get(activeCall.callId);
|
|
1498
1574
|
if (waiter?.turnToken && waiter.turnToken !== event.turnToken) {
|
|
1499
1575
|
log.warn(`Ignoring speech event with mismatched turn token for ${activeCall.callId}`);
|
|
@@ -1606,6 +1682,7 @@ var CallManager = class {
|
|
|
1606
1682
|
this.provider = null;
|
|
1607
1683
|
this.webhookUrl = null;
|
|
1608
1684
|
this.activeTurnCalls = /* @__PURE__ */ new Set();
|
|
1685
|
+
this.endCallOperations = /* @__PURE__ */ new Map();
|
|
1609
1686
|
this.transcriptWaiters = /* @__PURE__ */ new Map();
|
|
1610
1687
|
this.maxDurationTimers = /* @__PURE__ */ new Map();
|
|
1611
1688
|
this.initialMessageInFlight = /* @__PURE__ */ new Set();
|
|
@@ -1648,9 +1725,7 @@ var CallManager = class {
|
|
|
1648
1725
|
ctx: this.getContext(),
|
|
1649
1726
|
callId,
|
|
1650
1727
|
timeoutMs: maxDurationMs - elapsed,
|
|
1651
|
-
onTimeout:
|
|
1652
|
-
await endCall(this.getContext(), id, { reason: "timeout" });
|
|
1653
|
-
}
|
|
1728
|
+
onTimeout: (id) => this.endCall(id, { reason: "timeout" })
|
|
1654
1729
|
});
|
|
1655
1730
|
console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`);
|
|
1656
1731
|
}
|
|
@@ -1763,8 +1838,8 @@ var CallManager = class {
|
|
|
1763
1838
|
/**
|
|
1764
1839
|
* End an active call.
|
|
1765
1840
|
*/
|
|
1766
|
-
|
|
1767
|
-
return endCall(this.getContext(), callId);
|
|
1841
|
+
endCall(callId, options) {
|
|
1842
|
+
return endCall(this.getContext(), callId, options);
|
|
1768
1843
|
}
|
|
1769
1844
|
getContext() {
|
|
1770
1845
|
return {
|
|
@@ -1778,6 +1853,7 @@ var CallManager = class {
|
|
|
1778
1853
|
storePath: this.storePath,
|
|
1779
1854
|
webhookUrl: this.webhookUrl,
|
|
1780
1855
|
activeTurnCalls: this.activeTurnCalls,
|
|
1856
|
+
endCallOperations: this.endCallOperations,
|
|
1781
1857
|
transcriptWaiters: this.transcriptWaiters,
|
|
1782
1858
|
maxDurationTimers: this.maxDurationTimers,
|
|
1783
1859
|
initialMessageInFlight: this.initialMessageInFlight,
|
|
@@ -1951,6 +2027,20 @@ function chunkAudio(audio, chunkSize = 160) {
|
|
|
1951
2027
|
//#region extensions/voice-call/src/telephony-tts.ts
|
|
1952
2028
|
/** Default timeout for one telephony synthesis request. */
|
|
1953
2029
|
const TELEPHONY_DEFAULT_TTS_TIMEOUT_MS = 8e3;
|
|
2030
|
+
var UnsupportedTelephonyTtsOutputFormatError = class extends Error {
|
|
2031
|
+
constructor(outputFormat, provider) {
|
|
2032
|
+
super(`Unsupported telephony TTS output format "${outputFormat}" from provider "${provider}"`);
|
|
2033
|
+
this.outputFormat = outputFormat;
|
|
2034
|
+
this.provider = provider;
|
|
2035
|
+
this.name = "UnsupportedTelephonyTtsOutputFormatError";
|
|
2036
|
+
}
|
|
2037
|
+
};
|
|
2038
|
+
function convertTelephonyTtsOutput(result) {
|
|
2039
|
+
const format = result.outputFormat?.trim().toLowerCase();
|
|
2040
|
+
if ((format === "raw-8khz-8bit-mono-mulaw" || format === "ulaw_8000") && result.sampleRate === 8e3) return result.audioBuffer;
|
|
2041
|
+
if (!format || format === "pcm" || /^pcm[_-]\d+$/.test(format) || format.includes("raw") && (format.includes("16bit") || format.includes("16-bit")) && format.includes("pcm")) return convertPcmToMulaw8k(result.audioBuffer, result.sampleRate);
|
|
2042
|
+
throw new UnsupportedTelephonyTtsOutputFormatError(result.outputFormat ?? "absent", result.provider ?? "unknown");
|
|
2043
|
+
}
|
|
1954
2044
|
/** Create a TTS provider that honors voice-call overrides and converts PCM to mulaw. */
|
|
1955
2045
|
async function createTelephonyTtsProvider(params) {
|
|
1956
2046
|
const { coreConfig, ttsOverride, runtime, logger } = params;
|
|
@@ -1979,7 +2069,12 @@ async function createTelephonyTtsProvider(params) {
|
|
|
1979
2069
|
const attemptedChain = result.attemptedProviders && result.attemptedProviders.length > 0 ? result.attemptedProviders.join(" -> ") : `${result.fallbackFrom} -> ${result.provider}`;
|
|
1980
2070
|
logger?.warn?.(`[voice-call] Telephony TTS fallback used from=${result.fallbackFrom} to=${result.provider} attempts=${attemptedChain}`);
|
|
1981
2071
|
}
|
|
1982
|
-
return
|
|
2072
|
+
return convertTelephonyTtsOutput({
|
|
2073
|
+
audioBuffer: result.audioBuffer,
|
|
2074
|
+
outputFormat: result.outputFormat,
|
|
2075
|
+
provider: result.provider,
|
|
2076
|
+
sampleRate: result.sampleRate
|
|
2077
|
+
});
|
|
1983
2078
|
}
|
|
1984
2079
|
};
|
|
1985
2080
|
}
|
|
@@ -2012,6 +2107,24 @@ function formatBoundedChildOutput(output) {
|
|
|
2012
2107
|
//#endregion
|
|
2013
2108
|
//#region extensions/voice-call/src/webhook/tailscale.ts
|
|
2014
2109
|
const TAILSCALE_COMMAND_STDOUT_MAX_BYTES = 4 * 1024 * 1024;
|
|
2110
|
+
const DEFAULT_TAILSCALE_HTTPS_PORT = 443;
|
|
2111
|
+
function buildTailscaleExposureArgs(opts) {
|
|
2112
|
+
if (!opts.localUrl && opts.port === DEFAULT_TAILSCALE_HTTPS_PORT) return [
|
|
2113
|
+
opts.mode,
|
|
2114
|
+
"off",
|
|
2115
|
+
opts.path
|
|
2116
|
+
];
|
|
2117
|
+
const portArgs = opts.port === DEFAULT_TAILSCALE_HTTPS_PORT ? [] : ["--https", String(opts.port)];
|
|
2118
|
+
return [
|
|
2119
|
+
opts.mode,
|
|
2120
|
+
"--bg",
|
|
2121
|
+
"--yes",
|
|
2122
|
+
...portArgs,
|
|
2123
|
+
"--set-path",
|
|
2124
|
+
opts.path,
|
|
2125
|
+
opts.localUrl ?? "off"
|
|
2126
|
+
];
|
|
2127
|
+
}
|
|
2015
2128
|
async function runTailscaleCommand(args, timeoutMs = 2500) {
|
|
2016
2129
|
try {
|
|
2017
2130
|
const result = await runCommandWithTimeout(["tailscale", ...args], {
|
|
@@ -2059,51 +2172,69 @@ async function getTailscaleSelfInfo() {
|
|
|
2059
2172
|
async function getTailscaleDnsName() {
|
|
2060
2173
|
return (await getTailscaleSelfInfo())?.dnsName ?? null;
|
|
2061
2174
|
}
|
|
2062
|
-
async function
|
|
2175
|
+
async function cleanupTailscaleExposureRoute(opts) {
|
|
2176
|
+
await runTailscaleCommand(buildTailscaleExposureArgs(opts));
|
|
2177
|
+
}
|
|
2178
|
+
async function setupTailscaleExposureRoutes(opts) {
|
|
2063
2179
|
const dnsName = await getTailscaleDnsName();
|
|
2064
2180
|
if (!dnsName) {
|
|
2065
2181
|
console.warn("[voice-call] Could not get Tailscale DNS name");
|
|
2066
2182
|
return null;
|
|
2067
2183
|
}
|
|
2068
|
-
const
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2184
|
+
const mountedPaths = [];
|
|
2185
|
+
let publicUrl = null;
|
|
2186
|
+
for (const route of opts.routes) {
|
|
2187
|
+
const { code } = await runTailscaleCommand(buildTailscaleExposureArgs({
|
|
2188
|
+
mode: opts.mode,
|
|
2189
|
+
port: opts.port,
|
|
2190
|
+
...route
|
|
2191
|
+
}));
|
|
2192
|
+
if (code !== 0) {
|
|
2193
|
+
for (const path of mountedPaths.toReversed()) await cleanupTailscaleExposureRoute({
|
|
2194
|
+
mode: opts.mode,
|
|
2195
|
+
port: opts.port,
|
|
2196
|
+
path
|
|
2197
|
+
});
|
|
2198
|
+
console.warn(`[voice-call] Tailscale ${opts.mode} exposure failed for ${route.path}; rolled back ${mountedPaths.length} mounted route(s)`);
|
|
2199
|
+
return null;
|
|
2200
|
+
}
|
|
2201
|
+
const routePublicUrl = `https://${dnsName}${opts.port === DEFAULT_TAILSCALE_HTTPS_PORT ? "" : `:${opts.port}`}${route.path}`;
|
|
2202
|
+
console.log(`[voice-call] Tailscale ${opts.mode} active: ${routePublicUrl}`);
|
|
2203
|
+
publicUrl ??= routePublicUrl;
|
|
2204
|
+
mountedPaths.push(route.path);
|
|
2080
2205
|
}
|
|
2081
|
-
|
|
2082
|
-
return null;
|
|
2083
|
-
}
|
|
2084
|
-
async function cleanupTailscaleExposureRoute(opts) {
|
|
2085
|
-
await runTailscaleCommand([
|
|
2086
|
-
opts.mode,
|
|
2087
|
-
"off",
|
|
2088
|
-
opts.path
|
|
2089
|
-
]);
|
|
2206
|
+
return publicUrl;
|
|
2090
2207
|
}
|
|
2091
2208
|
async function setupTailscaleExposure(config) {
|
|
2092
2209
|
if (config.tailscale.mode === "off") return null;
|
|
2093
2210
|
const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
|
|
2094
2211
|
const localUrl = `http://127.0.0.1:${config.serve.port}${config.serve.path}`;
|
|
2095
|
-
|
|
2212
|
+
const streamRoutes = resolveVoiceCallStreamExposurePaths(config).map(({ publicPath, localPath }) => ({
|
|
2213
|
+
path: publicPath,
|
|
2214
|
+
localUrl: `http://127.0.0.1:${config.serve.port}${localPath}`
|
|
2215
|
+
}));
|
|
2216
|
+
return setupTailscaleExposureRoutes({
|
|
2096
2217
|
mode,
|
|
2097
|
-
|
|
2098
|
-
|
|
2218
|
+
port: config.tailscale.port,
|
|
2219
|
+
routes: [{
|
|
2220
|
+
path: config.tailscale.path,
|
|
2221
|
+
localUrl
|
|
2222
|
+
}, ...streamRoutes]
|
|
2099
2223
|
});
|
|
2100
2224
|
}
|
|
2101
2225
|
async function cleanupTailscaleExposure(config) {
|
|
2102
2226
|
if (config.tailscale.mode === "off") return;
|
|
2227
|
+
const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
|
|
2103
2228
|
await cleanupTailscaleExposureRoute({
|
|
2104
|
-
mode
|
|
2229
|
+
mode,
|
|
2230
|
+
port: config.tailscale.port,
|
|
2105
2231
|
path: config.tailscale.path
|
|
2106
2232
|
});
|
|
2233
|
+
for (const { publicPath } of resolveVoiceCallStreamExposurePaths(config)) await cleanupTailscaleExposureRoute({
|
|
2234
|
+
mode,
|
|
2235
|
+
port: config.tailscale.port,
|
|
2236
|
+
path: publicPath
|
|
2237
|
+
});
|
|
2107
2238
|
}
|
|
2108
2239
|
//#endregion
|
|
2109
2240
|
//#region extensions/voice-call/src/tunnel.ts
|
|
@@ -2265,62 +2396,37 @@ async function runNgrokCommand(args) {
|
|
|
2265
2396
|
* Start a Tailscale serve/funnel tunnel.
|
|
2266
2397
|
*/
|
|
2267
2398
|
async function startTailscaleTunnel(config) {
|
|
2268
|
-
const dnsName = await getTailscaleDnsName();
|
|
2269
|
-
if (!dnsName) throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
|
|
2270
2399
|
const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
|
|
2271
|
-
const
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
"
|
|
2276
|
-
"
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
], {
|
|
2281
|
-
killProcessTree: true,
|
|
2282
|
-
maxOutputBytes: TUNNEL_COMMAND_OUTPUT_MAX_BYTES,
|
|
2283
|
-
outputCapture: "tail",
|
|
2284
|
-
timeoutMs: 1e4
|
|
2285
|
-
});
|
|
2286
|
-
if (result.termination === "timeout") throw new Error(`Tailscale ${config.mode} timed out`);
|
|
2287
|
-
if (result.code !== 0) {
|
|
2288
|
-
const output = result.stderr ? {
|
|
2289
|
-
text: result.stderr,
|
|
2290
|
-
truncated: Boolean(result.stderrTruncatedBytes)
|
|
2291
|
-
} : {
|
|
2292
|
-
text: result.stdout,
|
|
2293
|
-
truncated: Boolean(result.stdoutTruncatedBytes)
|
|
2400
|
+
const routes = [{
|
|
2401
|
+
publicPath: path,
|
|
2402
|
+
localPath: path
|
|
2403
|
+
}, ...config.streamPaths ?? []].map(({ publicPath, localPath }) => {
|
|
2404
|
+
const normalizedPublicPath = publicPath.startsWith("/") ? publicPath : `/${publicPath}`;
|
|
2405
|
+
const normalizedLocalPath = localPath.startsWith("/") ? localPath : `/${localPath}`;
|
|
2406
|
+
return {
|
|
2407
|
+
path: normalizedPublicPath,
|
|
2408
|
+
localUrl: `http://127.0.0.1:${config.port}${normalizedLocalPath}`
|
|
2294
2409
|
};
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2410
|
+
});
|
|
2411
|
+
const publicUrl = await setupTailscaleExposureRoutes({
|
|
2412
|
+
mode: config.mode,
|
|
2413
|
+
port: config.tailscalePort,
|
|
2414
|
+
routes
|
|
2415
|
+
});
|
|
2416
|
+
if (!publicUrl) throw new Error(`Tailscale ${config.mode} failed`);
|
|
2300
2417
|
return {
|
|
2301
2418
|
publicUrl,
|
|
2302
2419
|
provider: `tailscale-${config.mode}`,
|
|
2303
2420
|
stop: async () => {
|
|
2304
|
-
|
|
2421
|
+
for (const route of routes) await cleanupTailscaleExposureRoute({
|
|
2422
|
+
mode: config.mode,
|
|
2423
|
+
port: config.tailscalePort,
|
|
2424
|
+
path: route.path
|
|
2425
|
+
});
|
|
2305
2426
|
}
|
|
2306
2427
|
};
|
|
2307
2428
|
}
|
|
2308
2429
|
/**
|
|
2309
|
-
* Stop a Tailscale serve/funnel tunnel.
|
|
2310
|
-
*/
|
|
2311
|
-
async function stopTailscaleTunnel(mode, path) {
|
|
2312
|
-
await runCommandWithTimeout([
|
|
2313
|
-
"tailscale",
|
|
2314
|
-
mode,
|
|
2315
|
-
"off",
|
|
2316
|
-
path
|
|
2317
|
-
], {
|
|
2318
|
-
killProcessTree: true,
|
|
2319
|
-
maxOutputBytes: 1,
|
|
2320
|
-
timeoutMs: 5e3
|
|
2321
|
-
}).catch(() => {});
|
|
2322
|
-
}
|
|
2323
|
-
/**
|
|
2324
2430
|
* Start a tunnel based on configuration.
|
|
2325
2431
|
*/
|
|
2326
2432
|
async function startTunnel(config) {
|
|
@@ -2334,12 +2440,16 @@ async function startTunnel(config) {
|
|
|
2334
2440
|
case "tailscale-serve": return startTailscaleTunnel({
|
|
2335
2441
|
mode: "serve",
|
|
2336
2442
|
port: config.port,
|
|
2337
|
-
|
|
2443
|
+
tailscalePort: config.tailscalePort ?? 443,
|
|
2444
|
+
path: config.path,
|
|
2445
|
+
streamPaths: config.streamPaths
|
|
2338
2446
|
});
|
|
2339
2447
|
case "tailscale-funnel": return startTailscaleTunnel({
|
|
2340
2448
|
mode: "funnel",
|
|
2341
2449
|
port: config.port,
|
|
2342
|
-
|
|
2450
|
+
tailscalePort: config.tailscalePort ?? 443,
|
|
2451
|
+
path: config.path,
|
|
2452
|
+
streamPaths: config.streamPaths
|
|
2343
2453
|
});
|
|
2344
2454
|
default: return null;
|
|
2345
2455
|
}
|
|
@@ -2420,6 +2530,8 @@ const DEFAULT_MAX_CONNECTIONS = 128;
|
|
|
2420
2530
|
const MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
|
|
2421
2531
|
const MAX_WS_BUFFERED_BYTES = 1024 * 1024;
|
|
2422
2532
|
const MAX_PENDING_TTS_OPERATIONS_PER_STREAM = 8;
|
|
2533
|
+
const MAX_IGNORED_PLAYBACK_MARKS_PER_STREAM = 64;
|
|
2534
|
+
const PLAYBACK_MARK_TIMEOUT_GRACE_MS = 2e3;
|
|
2423
2535
|
const CLOSE_REASON_LOG_MAX_CHARS = 120;
|
|
2424
2536
|
function sanitizeLogText(value, maxChars) {
|
|
2425
2537
|
const sanitized = value.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
|
|
@@ -2454,6 +2566,8 @@ var MediaStreamHandler = class {
|
|
|
2454
2566
|
this.ttsQueues = /* @__PURE__ */ new Map();
|
|
2455
2567
|
this.ttsPlaying = /* @__PURE__ */ new Map();
|
|
2456
2568
|
this.ttsActiveControllers = /* @__PURE__ */ new Map();
|
|
2569
|
+
this.pendingPlaybackMarks = /* @__PURE__ */ new Map();
|
|
2570
|
+
this.ignoredPlaybackMarks = /* @__PURE__ */ new Map();
|
|
2457
2571
|
this.config = config;
|
|
2458
2572
|
this.preStartTimeoutMs = resolveTimerTimeoutMs(config.preStartTimeoutMs, DEFAULT_PRE_START_TIMEOUT_MS);
|
|
2459
2573
|
this.maxPendingConnections = config.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS;
|
|
@@ -2577,8 +2691,10 @@ var MediaStreamHandler = class {
|
|
|
2577
2691
|
session = null;
|
|
2578
2692
|
}
|
|
2579
2693
|
break;
|
|
2580
|
-
case "
|
|
2581
|
-
|
|
2694
|
+
case "mark":
|
|
2695
|
+
if (session && message.mark?.name) this.acknowledgePlaybackMark(session.streamSid, message.mark.name);
|
|
2696
|
+
break;
|
|
2697
|
+
case "clear": break;
|
|
2582
2698
|
}
|
|
2583
2699
|
} catch (error) {
|
|
2584
2700
|
console.error("[MediaStream] Error processing message:", error);
|
|
@@ -2904,10 +3020,44 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2904
3020
|
mark: { name }
|
|
2905
3021
|
});
|
|
2906
3022
|
}
|
|
3023
|
+
/** Send a completion mark and wait until Twilio reports that buffered playback reached it. */
|
|
3024
|
+
async sendMarkAndWait(streamSid, name, audioDurationMs, signal) {
|
|
3025
|
+
signal.throwIfAborted();
|
|
3026
|
+
const marks = this.getPendingPlaybackMarks(streamSid);
|
|
3027
|
+
if (marks.has(name)) throw new Error(`Telephony playback mark is already pending: ${name}`);
|
|
3028
|
+
this.ignoredPlaybackMarks.get(streamSid)?.delete(name);
|
|
3029
|
+
let pending;
|
|
3030
|
+
const acknowledgement = new Promise((resolve, reject) => {
|
|
3031
|
+
const timeout = setTimeout(() => {
|
|
3032
|
+
console.warn(`[MediaStream] Playback mark timed out; continuing stream=${streamSid}`);
|
|
3033
|
+
pending.settle();
|
|
3034
|
+
}, Math.max(1, audioDurationMs + PLAYBACK_MARK_TIMEOUT_GRACE_MS));
|
|
3035
|
+
timeout.unref?.();
|
|
3036
|
+
const onAbort = () => {
|
|
3037
|
+
const reason = signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("Telephony playback mark wait aborted");
|
|
3038
|
+
pending.settle(reason, true);
|
|
3039
|
+
};
|
|
3040
|
+
pending = { settle: (error, ignoreLateAck = false) => {
|
|
3041
|
+
if (marks.get(name) !== pending) return;
|
|
3042
|
+
clearTimeout(timeout);
|
|
3043
|
+
signal.removeEventListener("abort", onAbort);
|
|
3044
|
+
marks.delete(name);
|
|
3045
|
+
if (marks.size === 0) this.pendingPlaybackMarks.delete(streamSid);
|
|
3046
|
+
if (ignoreLateAck) this.ignorePlaybackMark(streamSid, name);
|
|
3047
|
+
if (error) reject(error);
|
|
3048
|
+
else resolve();
|
|
3049
|
+
} };
|
|
3050
|
+
marks.set(name, pending);
|
|
3051
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3052
|
+
});
|
|
3053
|
+
if (!this.sendMark(streamSid, name).sent) pending.settle(/* @__PURE__ */ new Error("Telephony stream playback failed: completion mark not delivered"));
|
|
3054
|
+
return acknowledgement;
|
|
3055
|
+
}
|
|
2907
3056
|
/**
|
|
2908
3057
|
* Clear audio buffer (interrupt playback).
|
|
2909
3058
|
*/
|
|
2910
3059
|
clearAudio(streamSid) {
|
|
3060
|
+
this.invalidatePlaybackMarks(streamSid);
|
|
2911
3061
|
return this.sendToStream(streamSid, {
|
|
2912
3062
|
event: "clear",
|
|
2913
3063
|
streamSid
|
|
@@ -2960,6 +3110,36 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2960
3110
|
this.ttsQueues.set(streamSid, queue);
|
|
2961
3111
|
return queue;
|
|
2962
3112
|
}
|
|
3113
|
+
getPendingPlaybackMarks(streamSid) {
|
|
3114
|
+
const existing = this.pendingPlaybackMarks.get(streamSid);
|
|
3115
|
+
if (existing) return existing;
|
|
3116
|
+
const marks = /* @__PURE__ */ new Map();
|
|
3117
|
+
this.pendingPlaybackMarks.set(streamSid, marks);
|
|
3118
|
+
return marks;
|
|
3119
|
+
}
|
|
3120
|
+
acknowledgePlaybackMark(streamSid, name) {
|
|
3121
|
+
const ignored = this.ignoredPlaybackMarks.get(streamSid);
|
|
3122
|
+
if (ignored?.delete(name)) {
|
|
3123
|
+
if (ignored.size === 0) this.ignoredPlaybackMarks.delete(streamSid);
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3126
|
+
this.pendingPlaybackMarks.get(streamSid)?.get(name)?.settle();
|
|
3127
|
+
}
|
|
3128
|
+
invalidatePlaybackMarks(streamSid) {
|
|
3129
|
+
const marks = this.pendingPlaybackMarks.get(streamSid);
|
|
3130
|
+
if (!marks) return;
|
|
3131
|
+
for (const pending of marks.values()) pending.settle(/* @__PURE__ */ new Error("Telephony playback cleared before completion"), true);
|
|
3132
|
+
}
|
|
3133
|
+
ignorePlaybackMark(streamSid, name) {
|
|
3134
|
+
const ignored = this.ignoredPlaybackMarks.get(streamSid) ?? /* @__PURE__ */ new Set();
|
|
3135
|
+
ignored.add(name);
|
|
3136
|
+
while (ignored.size > MAX_IGNORED_PLAYBACK_MARKS_PER_STREAM) {
|
|
3137
|
+
const oldest = ignored.values().next().value;
|
|
3138
|
+
if (oldest === void 0) break;
|
|
3139
|
+
ignored.delete(oldest);
|
|
3140
|
+
}
|
|
3141
|
+
this.ignoredPlaybackMarks.set(streamSid, ignored);
|
|
3142
|
+
}
|
|
2963
3143
|
/**
|
|
2964
3144
|
* Process the TTS queue for a stream.
|
|
2965
3145
|
* Uses iterative approach to avoid stack accumulation from recursion.
|
|
@@ -3055,6 +3235,8 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
3055
3235
|
this.ttsActiveControllers.delete(streamSid);
|
|
3056
3236
|
this.ttsPlaying.delete(streamSid);
|
|
3057
3237
|
this.ttsQueues.delete(streamSid);
|
|
3238
|
+
this.invalidatePlaybackMarks(streamSid);
|
|
3239
|
+
this.ignoredPlaybackMarks.delete(streamSid);
|
|
3058
3240
|
}
|
|
3059
3241
|
resolveQueuedTtsEntries(queue) {
|
|
3060
3242
|
const pending = queue.splice(0);
|
|
@@ -3121,7 +3303,9 @@ function startStaleCallReaper(params) {
|
|
|
3121
3303
|
if (age > maxAgeMs && !callsBeingReaped.has(call.callId)) {
|
|
3122
3304
|
callsBeingReaped.add(call.callId);
|
|
3123
3305
|
console.log(`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1e3)}s, state: ${call.state})`);
|
|
3124
|
-
params.manager.endCall(call.callId).
|
|
3306
|
+
params.manager.endCall(call.callId).then((result) => {
|
|
3307
|
+
if (!result.success) console.warn(`[voice-call] Reaper failed to end call ${call.callId}: ${result.error ?? "unknown error"}`);
|
|
3308
|
+
}).catch((err) => {
|
|
3125
3309
|
console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
|
|
3126
3310
|
}).finally(() => {
|
|
3127
3311
|
callsBeingReaped.delete(call.callId);
|
|
@@ -3134,13 +3318,51 @@ function startStaleCallReaper(params) {
|
|
|
3134
3318
|
};
|
|
3135
3319
|
}
|
|
3136
3320
|
//#endregion
|
|
3321
|
+
//#region extensions/voice-call/src/webhook/stream-disconnect-grace.ts
|
|
3322
|
+
const STREAM_DISCONNECT_GRACE_MS = 2e3;
|
|
3323
|
+
/** Track the current carrier stream and expire disconnected calls after reconnect grace. */
|
|
3324
|
+
var StreamDisconnectGrace = class {
|
|
3325
|
+
constructor(onGraceExpired) {
|
|
3326
|
+
this.onGraceExpired = onGraceExpired;
|
|
3327
|
+
this.streams = /* @__PURE__ */ new Map();
|
|
3328
|
+
}
|
|
3329
|
+
connect(providerCallId, streamId) {
|
|
3330
|
+
const current = this.streams.get(providerCallId);
|
|
3331
|
+
if (current?.disconnectTimer) clearTimeout(current.disconnectTimer);
|
|
3332
|
+
this.streams.set(providerCallId, { streamId });
|
|
3333
|
+
}
|
|
3334
|
+
disconnect(providerCallId, streamId) {
|
|
3335
|
+
const current = this.streams.get(providerCallId);
|
|
3336
|
+
if (!current || current.streamId !== streamId || current.disconnectTimer) return;
|
|
3337
|
+
const disconnectTimer = setTimeout(() => {
|
|
3338
|
+
if (this.streams.get(providerCallId)?.disconnectTimer !== disconnectTimer) return;
|
|
3339
|
+
this.streams.delete(providerCallId);
|
|
3340
|
+
this.onGraceExpired({
|
|
3341
|
+
providerCallId,
|
|
3342
|
+
streamId
|
|
3343
|
+
});
|
|
3344
|
+
}, STREAM_DISCONNECT_GRACE_MS);
|
|
3345
|
+
disconnectTimer.unref?.();
|
|
3346
|
+
current.disconnectTimer = disconnectTimer;
|
|
3347
|
+
}
|
|
3348
|
+
retire(providerCallId, streamId) {
|
|
3349
|
+
const current = this.streams.get(providerCallId);
|
|
3350
|
+
if (!current || current.streamId !== streamId) return;
|
|
3351
|
+
if (current.disconnectTimer) clearTimeout(current.disconnectTimer);
|
|
3352
|
+
this.streams.delete(providerCallId);
|
|
3353
|
+
}
|
|
3354
|
+
close() {
|
|
3355
|
+
for (const state of this.streams.values()) if (state.disconnectTimer) clearTimeout(state.disconnectTimer);
|
|
3356
|
+
this.streams.clear();
|
|
3357
|
+
}
|
|
3358
|
+
};
|
|
3359
|
+
//#endregion
|
|
3137
3360
|
//#region extensions/voice-call/src/webhook.ts
|
|
3138
3361
|
const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
|
|
3139
3362
|
const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
|
|
3140
3363
|
const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
|
|
3141
|
-
const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2e3;
|
|
3142
3364
|
const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
|
|
3143
|
-
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-
|
|
3365
|
+
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-tjyX4tDq.js"));
|
|
3144
3366
|
function appendRecentTalkEventMetadata(call, event) {
|
|
3145
3367
|
const metadata = call.metadata ?? {};
|
|
3146
3368
|
const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
|
|
@@ -3220,7 +3442,6 @@ var VoiceCallWebhookServer = class {
|
|
|
3220
3442
|
this.stopStaleCallReaper = null;
|
|
3221
3443
|
this.webhookInFlightLimiter = createWebhookInFlightLimiter();
|
|
3222
3444
|
this.mediaStreamHandler = null;
|
|
3223
|
-
this.pendingDisconnectHangups = /* @__PURE__ */ new Map();
|
|
3224
3445
|
this.realtimeHandler = null;
|
|
3225
3446
|
this.replayResponses = /* @__PURE__ */ new Map();
|
|
3226
3447
|
this.replayResponseCacheCalls = 0;
|
|
@@ -3244,6 +3465,16 @@ var VoiceCallWebhookServer = class {
|
|
|
3244
3465
|
error: (msg) => rawLogger.error(`[voice-call] ${msg}`),
|
|
3245
3466
|
debug: rawDebug ? (msg) => rawDebug(`[voice-call] ${msg}`) : void 0
|
|
3246
3467
|
};
|
|
3468
|
+
this.streamDisconnectGrace = new StreamDisconnectGrace(({ providerCallId }) => {
|
|
3469
|
+
const call = this.manager.getCallByProviderCallId(providerCallId);
|
|
3470
|
+
if (!call) return;
|
|
3471
|
+
this.logger.info(`Call finalization requested reason=stream-disconnect-grace-expired callId=${call.callId} providerCallId=${providerCallId}`);
|
|
3472
|
+
this.manager.endCall(call.callId).then((result) => {
|
|
3473
|
+
if (!result.success) this.logger.warn(`Failed to auto-end call ${call.callId}: ${result.error ?? "unknown error"}`);
|
|
3474
|
+
}).catch((err) => {
|
|
3475
|
+
this.logger.warn(`Failed to auto-end call ${call.callId}: ${String(err)}`);
|
|
3476
|
+
});
|
|
3477
|
+
});
|
|
3247
3478
|
}
|
|
3248
3479
|
/**
|
|
3249
3480
|
* Get the media stream handler (for wiring to provider).
|
|
@@ -3264,11 +3495,8 @@ var VoiceCallWebhookServer = class {
|
|
|
3264
3495
|
setRealtimeHandler(handler) {
|
|
3265
3496
|
this.realtimeHandler = handler;
|
|
3266
3497
|
}
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
if (!existing) return;
|
|
3270
|
-
clearTimeout(existing);
|
|
3271
|
-
this.pendingDisconnectHangups.delete(providerCallId);
|
|
3498
|
+
getStreamDisconnectLifecycle() {
|
|
3499
|
+
return this.streamDisconnectGrace;
|
|
3272
3500
|
}
|
|
3273
3501
|
resolveMediaStreamClientIp(request) {
|
|
3274
3502
|
const remoteIp = request.socket.remoteAddress ?? void 0;
|
|
@@ -3382,7 +3610,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3382
3610
|
},
|
|
3383
3611
|
onConnect: (callId, streamSid) => {
|
|
3384
3612
|
this.logger.info(`Media stream connected: ${callId} -> ${streamSid}`);
|
|
3385
|
-
this.
|
|
3613
|
+
this.streamDisconnectGrace.connect(callId, streamSid);
|
|
3386
3614
|
if (this.provider.name === "twilio") this.provider.registerCallStream(callId, streamSid);
|
|
3387
3615
|
},
|
|
3388
3616
|
onTranscriptionReady: (callId) => {
|
|
@@ -3393,21 +3621,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3393
3621
|
onDisconnect: (callId, streamSid) => {
|
|
3394
3622
|
this.logger.info(`Media stream disconnected: ${callId} (${streamSid})`);
|
|
3395
3623
|
if (this.provider.name === "twilio") this.provider.unregisterCallStream(callId, streamSid);
|
|
3396
|
-
this.
|
|
3397
|
-
const timer = setTimeout(() => {
|
|
3398
|
-
this.pendingDisconnectHangups.delete(callId);
|
|
3399
|
-
const disconnectedCall = this.manager.getCallByProviderCallId(callId);
|
|
3400
|
-
if (!disconnectedCall) return;
|
|
3401
|
-
if (this.provider.name === "twilio") {
|
|
3402
|
-
if (this.provider.hasRegisteredStream(callId)) return;
|
|
3403
|
-
}
|
|
3404
|
-
this.logger.info(`Auto-ending call ${disconnectedCall.callId} after stream disconnect grace`);
|
|
3405
|
-
this.manager.endCall(disconnectedCall.callId).catch((err) => {
|
|
3406
|
-
this.logger.warn(`Failed to auto-end call ${disconnectedCall.callId}: ${String(err)}`);
|
|
3407
|
-
});
|
|
3408
|
-
}, STREAM_DISCONNECT_HANGUP_GRACE_MS);
|
|
3409
|
-
timer.unref?.();
|
|
3410
|
-
this.pendingDisconnectHangups.set(callId, timer);
|
|
3624
|
+
this.streamDisconnectGrace.disconnect(callId, streamSid);
|
|
3411
3625
|
}
|
|
3412
3626
|
};
|
|
3413
3627
|
this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
|
|
@@ -3485,8 +3699,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3485
3699
|
});
|
|
3486
3700
|
});
|
|
3487
3701
|
this.startPromise = null;
|
|
3488
|
-
|
|
3489
|
-
this.pendingDisconnectHangups.clear();
|
|
3702
|
+
this.streamDisconnectGrace.close();
|
|
3490
3703
|
if (this.stopStaleCallReaper) {
|
|
3491
3704
|
this.stopStaleCallReaper();
|
|
3492
3705
|
this.stopStaleCallReaper = null;
|
|
@@ -3498,8 +3711,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3498
3711
|
this.mediaStreamHandler?.close(serverClosePromise) ?? Promise.resolve(),
|
|
3499
3712
|
this.realtimeHandler?.close(serverClosePromise) ?? Promise.resolve()
|
|
3500
3713
|
]);
|
|
3501
|
-
|
|
3502
|
-
this.pendingDisconnectHangups.clear();
|
|
3714
|
+
this.streamDisconnectGrace.close();
|
|
3503
3715
|
if (this.server === server) this.server = null;
|
|
3504
3716
|
this.listeningUrl = null;
|
|
3505
3717
|
const failure = results.find((result) => result.status === "rejected");
|
|
@@ -3817,6 +4029,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3817
4029
|
callId,
|
|
3818
4030
|
sessionKey: call.sessionKey,
|
|
3819
4031
|
from: call.from,
|
|
4032
|
+
senderIsOwner: call.direction === "inbound" ? false : void 0,
|
|
3820
4033
|
agentId: resolveCallAgentId(call, effectiveConfig),
|
|
3821
4034
|
transcript: call.transcript,
|
|
3822
4035
|
userMessage,
|
|
@@ -3848,12 +4061,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
|
|
|
3848
4061
|
"Do not print secret values or dump environment variables; only check whether required configuration is present.",
|
|
3849
4062
|
"Be accurate, brief, and speakable."
|
|
3850
4063
|
].join(" ");
|
|
3851
|
-
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-
|
|
3852
|
-
const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-
|
|
3853
|
-
const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-
|
|
3854
|
-
const loadMockProvider = createLazyRuntimeModule(() => import("./mock-
|
|
4064
|
+
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-DO8dFQcb.js"));
|
|
4065
|
+
const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-JDt_1nlz.js"));
|
|
4066
|
+
const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-BbVnahnQ.js"));
|
|
4067
|
+
const loadMockProvider = createLazyRuntimeModule(() => import("./mock-Dv7KxYGt.js"));
|
|
3855
4068
|
const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
|
|
3856
|
-
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-
|
|
4069
|
+
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-BaMEKbmJ.js"));
|
|
3857
4070
|
function resolveVoiceCallConsultSessionKey(call) {
|
|
3858
4071
|
return resolveVoiceCallSessionKey({
|
|
3859
4072
|
config: call.config,
|
|
@@ -3951,17 +4164,9 @@ async function resolveProvider(config) {
|
|
|
3951
4164
|
default: throw new Error(`Unsupported voice-call provider: ${String(config.provider)}`);
|
|
3952
4165
|
}
|
|
3953
4166
|
}
|
|
3954
|
-
async function resolveRealtimeProvider(params) {
|
|
3955
|
-
const { resolveConfiguredRealtimeVoiceProvider } = await loadRealtimeVoiceRuntime();
|
|
3956
|
-
return resolveConfiguredRealtimeVoiceProvider({
|
|
3957
|
-
configuredProviderId: params.config.realtime.provider,
|
|
3958
|
-
providerConfigs: params.config.realtime.providers,
|
|
3959
|
-
cfg: params.fullConfig
|
|
3960
|
-
});
|
|
3961
|
-
}
|
|
3962
4167
|
function listRealtimeAgentIds(config, coreConfig) {
|
|
3963
4168
|
const agentIds = /* @__PURE__ */ new Set([normalizeAgentId(config.agentId)]);
|
|
3964
|
-
for (const
|
|
4169
|
+
for (const agentId of listAgentIds(coreConfig)) agentIds.add(normalizeAgentId(agentId));
|
|
3965
4170
|
for (const route of Object.values(config.numbers)) if (route.agentId) agentIds.add(normalizeAgentId(route.agentId));
|
|
3966
4171
|
return [...agentIds];
|
|
3967
4172
|
}
|
|
@@ -4024,22 +4229,37 @@ async function createVoiceCallRuntime(params) {
|
|
|
4024
4229
|
const provider = await resolveProvider(config);
|
|
4025
4230
|
if (stateRuntime) setVoiceCallStateRuntime({ state: stateRuntime });
|
|
4026
4231
|
const manager = new CallManager(config, void 0, cfg.session);
|
|
4027
|
-
const
|
|
4028
|
-
config,
|
|
4029
|
-
fullConfig: cfg
|
|
4030
|
-
}) : null;
|
|
4232
|
+
const realtimeVoiceRuntime = config.realtime.enabled ? await loadRealtimeVoiceRuntime() : null;
|
|
4031
4233
|
const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig, fullConfig ?? coreConfig, agentRuntime, log);
|
|
4032
|
-
if (
|
|
4234
|
+
if (realtimeVoiceRuntime) {
|
|
4033
4235
|
const { RealtimeCallHandler } = await loadRealtimeHandler();
|
|
4034
4236
|
const resolveRealtimeInstructions = await createRealtimeInstructionsResolver({
|
|
4035
4237
|
config,
|
|
4036
4238
|
coreConfig: cfg,
|
|
4037
4239
|
agentRuntime
|
|
4038
4240
|
});
|
|
4039
|
-
const
|
|
4241
|
+
const realtimeConfig = {
|
|
4040
4242
|
...config.realtime,
|
|
4041
|
-
tools:
|
|
4042
|
-
}
|
|
4243
|
+
tools: resolveVoiceCallRealtimeTools(config.realtime.toolPolicy, config.realtime.tools)
|
|
4244
|
+
};
|
|
4245
|
+
const resolveCallRegistration = (call) => {
|
|
4246
|
+
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
|
4247
|
+
const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
|
|
4248
|
+
const agentId = resolveCallAgentId(call, effectiveConfig);
|
|
4249
|
+
const resolved = realtimeVoiceRuntime.resolveConfiguredRealtimeVoiceProvider({
|
|
4250
|
+
configuredProviderId: effectiveConfig.realtime.provider,
|
|
4251
|
+
providerConfigs: effectiveConfig.realtime.providers,
|
|
4252
|
+
cfg,
|
|
4253
|
+
agentId
|
|
4254
|
+
});
|
|
4255
|
+
return {
|
|
4256
|
+
agentId,
|
|
4257
|
+
provider: resolved.provider,
|
|
4258
|
+
providerConfig: resolved.providerConfig,
|
|
4259
|
+
instructions: resolveRealtimeInstructions(call)
|
|
4260
|
+
};
|
|
4261
|
+
};
|
|
4262
|
+
const realtimeHandler = new RealtimeCallHandler(realtimeConfig, manager, resolveCallRegistration, config.serve.path, webhookServer.getStreamDisconnectLifecycle(), cfg);
|
|
4043
4263
|
if (config.realtime.toolPolicy !== "none") realtimeHandler.registerToolHandler(REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, async (args, callId, handlerContext) => {
|
|
4044
4264
|
handlerContext.abortSignal?.throwIfAborted();
|
|
4045
4265
|
const call = manager.getCall(callId);
|
|
@@ -4126,7 +4346,12 @@ async function createVoiceCallRuntime(params) {
|
|
|
4126
4346
|
const nextTunnelResult = await startTunnel({
|
|
4127
4347
|
provider: config.tunnel.provider,
|
|
4128
4348
|
port: config.serve.port,
|
|
4349
|
+
tailscalePort: config.tailscale.port,
|
|
4129
4350
|
path: config.serve.path,
|
|
4351
|
+
streamPaths: resolveVoiceCallStreamExposurePaths(config, {
|
|
4352
|
+
publicWebhookPath: config.serve.path,
|
|
4353
|
+
localWebhookPath: config.serve.path
|
|
4354
|
+
}),
|
|
4130
4355
|
ngrokAuthToken: config.tunnel.ngrokAuthToken,
|
|
4131
4356
|
ngrokDomain: config.tunnel.ngrokDomain
|
|
4132
4357
|
});
|
|
@@ -4139,7 +4364,7 @@ async function createVoiceCallRuntime(params) {
|
|
|
4139
4364
|
const webhookUrl = publicUrl ?? localUrl;
|
|
4140
4365
|
if (providerRequiresPublicWebhook(provider.name) && isProviderUnreachableWebhookUrl(webhookUrl)) throw new Error(`[voice-call] ${provider.name} requires a publicly reachable webhook URL. Refusing to use local-only webhook ${webhookUrl}. Set plugins.entries.voice-call.config.publicUrl or enable tunnel/tailscale exposure.`);
|
|
4141
4366
|
if (publicUrl) provider.setPublicUrl?.(publicUrl);
|
|
4142
|
-
if (publicUrl &&
|
|
4367
|
+
if (publicUrl && realtimeVoiceRuntime) webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
|
|
4143
4368
|
const realtimeHandler = webhookServer.getRealtimeHandler();
|
|
4144
4369
|
if (realtimeHandler) manager.streamSessionIssuer = (request) => realtimeHandler.issueStreamSession(request);
|
|
4145
4370
|
if (provider.name === "twilio" && config.streaming?.enabled) {
|
|
@@ -4163,7 +4388,7 @@ async function createVoiceCallRuntime(params) {
|
|
|
4163
4388
|
log.info("[voice-call] Media stream handler wired to provider");
|
|
4164
4389
|
}
|
|
4165
4390
|
}
|
|
4166
|
-
if (
|
|
4391
|
+
if (realtimeVoiceRuntime) log.info("[voice-call] Realtime voice enabled");
|
|
4167
4392
|
await manager.initialize(provider, webhookUrl);
|
|
4168
4393
|
const stop = () => lifecycle.stop();
|
|
4169
4394
|
log.info("[voice-call] Runtime initialized");
|
|
@@ -4184,4 +4409,4 @@ async function createVoiceCallRuntime(params) {
|
|
|
4184
4409
|
}
|
|
4185
4410
|
}
|
|
4186
4411
|
//#endregion
|
|
4187
|
-
export {
|
|
4412
|
+
export { resolveVoiceCallStreamExposurePaths as C, resolveVoiceCallSessionKey as S, REALTIME_VOICE_END_CALL_TOOL_NAME as T, escapeXml as _, normalizeProviderStatus as a, resolveVoiceCallConfig as b, resolveWebhookExposureStatus as c, setupTailscaleExposureRoutes as d, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as f, resolveUserPath as g, resolveCallAgentId as h, mapProviderStatusToEndReason as i, cleanupTailscaleExposureRoute as l, resolveVoiceResponseModel as m, normalizeProxyIp as n, canonicalizeVoiceCallMediaBase64 as o, chunkAudio as p, isProviderStatusTerminal as r, getHeader as s, createVoiceCallRuntime as t, getTailscaleSelfInfo as u, mapVoiceToPolly as v, validateProviderConfig as w, resolveVoiceCallPublicPathPrefix as x, VoiceCallConfigSchema as y };
|