@openclaw/voice-call 2026.7.2-beta.7 → 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 +11 -8
- package/dist/{guarded-json-api-DS9sZa3C.js → guarded-json-api-j4OEG0fR.js} +24 -46
- package/dist/index.js +644 -913
- package/dist/{mock-YsvBTX-7.js → mock-Dv7KxYGt.js} +5 -4
- package/dist/{plivo-vZ--Wqxv.js → plivo-BbVnahnQ.js} +45 -8
- package/dist/{realtime-handler--i_OERv6.js → realtime-handler-BaMEKbmJ.js} +579 -209
- package/dist/{response-generator-D-7ApVfT.js → response-generator-tjyX4tDq.js} +70 -15
- package/dist/{runtime-entry-D0UJiLfi.js → runtime-entry-DK8kPCGC.js} +687 -355
- package/dist/runtime-entry.js +1 -1
- package/dist/setup-api.js +17 -24
- package/dist/{store-path-nYL-yM0S.js → store-path-51_oC_xn.js} +70 -13
- package/dist/{telnyx-Dx6cfLvV.js → telnyx-DO8dFQcb.js} +14 -9
- package/dist/{twilio-D_D8KysU.js → twilio-JDt_1nlz.js} +58 -51
- package/dist/{webhook-replay-aaP79dI8.js → webhook-replay-FQrEGVSM.js} +20 -6
- package/openclaw.plugin.json +69 -6
- package/package.json +4 -4
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { TtsConfigSchema, isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText } from "./runtime-api.js";
|
|
2
2
|
import "./api.js";
|
|
3
3
|
import { t as TWILIO_REGIONS } from "./twilio-region-4fkgz3UG.js";
|
|
4
|
-
import { _ as
|
|
4
|
+
import { _ as releaseRejectedProviderCall, b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, g as appendCallReplayKey, l as findCallMatchesInStore, p as persistCallRecord, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore, v as rememberManagerReplayKey, x as TerminalStates, y as reserveRejectedProviderCall } from "./store-path-51_oC_xn.js";
|
|
5
5
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
6
6
|
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
|
|
7
|
-
import { MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
|
8
7
|
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
|
9
8
|
import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
10
9
|
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
|
11
|
-
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
|
|
10
|
+
import { listAgentIds, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime";
|
|
12
11
|
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
|
13
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";
|
|
14
13
|
import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime";
|
|
@@ -17,6 +16,7 @@ import { canonicalizeMainSessionAlias } from "openclaw/plugin-sdk/session-store-
|
|
|
17
16
|
import { resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core";
|
|
18
17
|
import { WEBHOOK_BODY_READ_DEFAULTS, createWebhookInFlightLimiter, normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
|
19
18
|
import { z } from "zod";
|
|
19
|
+
import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
|
20
20
|
import fs from "node:fs";
|
|
21
21
|
import crypto from "node:crypto";
|
|
22
22
|
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
|
|
@@ -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);
|
|
@@ -1207,7 +1275,8 @@ async function continueCall(ctx, callId, prompt) {
|
|
|
1207
1275
|
const turnStartedAt = Date.now();
|
|
1208
1276
|
const turnToken = provider.name === "twilio" ? crypto.randomUUID() : void 0;
|
|
1209
1277
|
try {
|
|
1210
|
-
await speak(ctx, callId, prompt);
|
|
1278
|
+
const speakResult = await speak(ctx, callId, prompt);
|
|
1279
|
+
if (!speakResult.success) return speakResult;
|
|
1211
1280
|
transitionState(call, "listening");
|
|
1212
1281
|
persistCallRecord(ctx.storePath, call);
|
|
1213
1282
|
const listenStartedAt = Date.now();
|
|
@@ -1248,33 +1317,42 @@ async function continueCall(ctx, callId, prompt) {
|
|
|
1248
1317
|
clearTranscriptWaiter(ctx, callId);
|
|
1249
1318
|
}
|
|
1250
1319
|
}
|
|
1251
|
-
|
|
1320
|
+
function endCall(ctx, callId, options) {
|
|
1321
|
+
const inFlight = ctx.endCallOperations.get(callId);
|
|
1322
|
+
if (inFlight) return inFlight;
|
|
1252
1323
|
const lookup = lookupConnectedCall(ctx, callId);
|
|
1253
|
-
if (lookup.kind === "error") return {
|
|
1324
|
+
if (lookup.kind === "error") return Promise.resolve({
|
|
1254
1325
|
success: false,
|
|
1255
1326
|
error: lookup.error
|
|
1256
|
-
};
|
|
1257
|
-
if (lookup.kind === "ended") return { success: true };
|
|
1327
|
+
});
|
|
1328
|
+
if (lookup.kind === "ended") return Promise.resolve({ success: true });
|
|
1258
1329
|
const { call, providerCallId, provider } = lookup;
|
|
1259
1330
|
const reason = options?.reason ?? "hangup-bot";
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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;
|
|
1278
1356
|
}
|
|
1279
1357
|
//#endregion
|
|
1280
1358
|
//#region extensions/voice-call/src/manager/events.ts
|
|
@@ -1318,7 +1396,8 @@ function createWebhookCall(params) {
|
|
|
1318
1396
|
sessionKey: resolveVoiceCallSessionKey({
|
|
1319
1397
|
config: effectiveConfig,
|
|
1320
1398
|
callId,
|
|
1321
|
-
phone: params.direction === "outbound" ? params.to : params.from
|
|
1399
|
+
phone: params.direction === "outbound" ? params.to : params.from,
|
|
1400
|
+
coreSession: params.ctx.coreSession
|
|
1322
1401
|
}),
|
|
1323
1402
|
agentId: normalizeAgentId(effectiveConfig.agentId),
|
|
1324
1403
|
startedAt: Date.now(),
|
|
@@ -1329,9 +1408,9 @@ function createWebhookCall(params) {
|
|
|
1329
1408
|
...effective.numberRouteKey ? { numberRouteKey: effective.numberRouteKey } : {}
|
|
1330
1409
|
}
|
|
1331
1410
|
};
|
|
1411
|
+
persistCallRecord(params.ctx.storePath, callRecord);
|
|
1332
1412
|
params.ctx.activeCalls.set(callId, callRecord);
|
|
1333
1413
|
params.ctx.providerCallIdMap.set(params.providerCallId, callId);
|
|
1334
|
-
persistCallRecord(params.ctx.storePath, callRecord);
|
|
1335
1414
|
log.info(`Created ${params.direction} call record: ${callId} caller=${redactIdentifier(params.from)}`);
|
|
1336
1415
|
return callRecord;
|
|
1337
1416
|
}
|
|
@@ -1372,9 +1451,7 @@ function processEvent(ctx, event) {
|
|
|
1372
1451
|
log.warn(`Inbound call rejected by policy but no provider to hang up (providerCallId: ${pid}, caller=${redactIdentifier(event.from)}); call will time out on provider side.`);
|
|
1373
1452
|
return { kind: "ignored" };
|
|
1374
1453
|
}
|
|
1375
|
-
ctx.processedEventIds.add(dedupeKey);
|
|
1376
1454
|
if (ctx.rejectedProviderCallIds.has(pid)) return { kind: "ignored" };
|
|
1377
|
-
ctx.rejectedProviderCallIds.add(pid);
|
|
1378
1455
|
const callId = event.callId ?? pid;
|
|
1379
1456
|
persistRejectedInboundCall({
|
|
1380
1457
|
ctx,
|
|
@@ -1382,13 +1459,16 @@ function processEvent(ctx, event) {
|
|
|
1382
1459
|
dedupeKey,
|
|
1383
1460
|
providerCallId: pid
|
|
1384
1461
|
});
|
|
1462
|
+
const rejectionReservation = reserveRejectedProviderCall(ctx.rejectedProviderCallIds, pid);
|
|
1463
|
+
if (rejectionReservation === void 0) return { kind: "ignored" };
|
|
1464
|
+
rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
|
|
1385
1465
|
log.info(`Rejecting inbound call by policy: ${pid}`);
|
|
1386
1466
|
ctx.provider.hangupCall({
|
|
1387
1467
|
callId,
|
|
1388
1468
|
providerCallId: pid,
|
|
1389
1469
|
reason: "hangup-bot"
|
|
1390
1470
|
}).catch((err) => {
|
|
1391
|
-
ctx.rejectedProviderCallIds
|
|
1471
|
+
releaseRejectedProviderCall(ctx.rejectedProviderCallIds, pid, rejectionReservation);
|
|
1392
1472
|
const message = formatErrorMessage(err);
|
|
1393
1473
|
log.warn(`Failed to reject inbound call ${pid}: ${message}`);
|
|
1394
1474
|
});
|
|
@@ -1403,126 +1483,160 @@ function processEvent(ctx, event) {
|
|
|
1403
1483
|
});
|
|
1404
1484
|
event.callId = call.callId;
|
|
1405
1485
|
}
|
|
1406
|
-
if (!call) return {
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1486
|
+
if (!call) return {
|
|
1487
|
+
kind: "ignored",
|
|
1488
|
+
replayable: true
|
|
1489
|
+
};
|
|
1490
|
+
const activeCall = call;
|
|
1491
|
+
const previousCall = {
|
|
1492
|
+
state: activeCall.state,
|
|
1493
|
+
providerCallId: activeCall.providerCallId,
|
|
1494
|
+
answeredAt: activeCall.answeredAt,
|
|
1495
|
+
endedAt: activeCall.endedAt,
|
|
1496
|
+
endReason: activeCall.endReason,
|
|
1497
|
+
transcriptLength: activeCall.transcript.length,
|
|
1498
|
+
processedEventIds: [...activeCall.processedEventIds]
|
|
1499
|
+
};
|
|
1500
|
+
const shouldCommitReplayKey = !(event.type === "call.error" && event.retryable);
|
|
1501
|
+
const effects = [];
|
|
1419
1502
|
let result = { kind: "processed" };
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1503
|
+
const startDurationTimer = () => {
|
|
1504
|
+
startMaxDurationTimer({
|
|
1505
|
+
ctx,
|
|
1506
|
+
callId: activeCall.callId,
|
|
1507
|
+
onTimeout: (callId) => endCall(ctx, callId, { reason: "timeout" })
|
|
1508
|
+
});
|
|
1509
|
+
};
|
|
1510
|
+
const prepareLiveDurationTimer = () => {
|
|
1511
|
+
if (!activeCall.answeredAt) {
|
|
1512
|
+
activeCall.answeredAt = event.timestamp;
|
|
1513
|
+
effects.push(startDurationTimer);
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
const publishProviderCallId = (terminal = false) => {
|
|
1517
|
+
if (!event.providerCallId || event.providerCallId === previousCall.providerCallId) return;
|
|
1518
|
+
if (!terminal) ctx.providerCallIdMap.set(event.providerCallId, activeCall.callId);
|
|
1519
|
+
if (previousCall.providerCallId) {
|
|
1520
|
+
if (ctx.providerCallIdMap.get(previousCall.providerCallId) === activeCall.callId) ctx.providerCallIdMap.delete(previousCall.providerCallId);
|
|
1521
|
+
}
|
|
1522
|
+
};
|
|
1523
|
+
try {
|
|
1524
|
+
if (event.providerCallId && event.providerCallId !== activeCall.providerCallId) activeCall.providerCallId = event.providerCallId;
|
|
1525
|
+
if (shouldCommitReplayKey) appendCallReplayKey(activeCall.processedEventIds, dedupeKey);
|
|
1526
|
+
switch (event.type) {
|
|
1527
|
+
case "call.initiated": {
|
|
1528
|
+
transitionState(activeCall, "initiated");
|
|
1529
|
+
const inboundProvider = ctx.provider;
|
|
1530
|
+
const inboundProviderCallId = activeCall.providerCallId;
|
|
1531
|
+
const answerInboundCall = inboundProvider?.answerCall?.bind(inboundProvider);
|
|
1532
|
+
if (activeCall.direction === "inbound" && inboundProviderCallId && answerInboundCall) effects.push(() => {
|
|
1533
|
+
const inboundStreamSession = ctx.config.realtime?.enabled && inboundProvider?.name === "telnyx" && ctx.streamSessionIssuer ? ctx.streamSessionIssuer({
|
|
1534
|
+
providerName: "telnyx",
|
|
1535
|
+
callId: activeCall.callId,
|
|
1536
|
+
from: activeCall.from,
|
|
1537
|
+
to: activeCall.to,
|
|
1538
|
+
direction: "inbound"
|
|
1539
|
+
}) : void 0;
|
|
1540
|
+
answerInboundCall({
|
|
1541
|
+
callId: activeCall.callId,
|
|
1542
|
+
providerCallId: inboundProviderCallId,
|
|
1543
|
+
...inboundStreamSession ? {
|
|
1544
|
+
streamUrl: inboundStreamSession.streamUrl,
|
|
1545
|
+
streamAuthToken: inboundStreamSession.token
|
|
1546
|
+
} : {}
|
|
1547
|
+
}).catch((err) => {
|
|
1548
|
+
const message = formatErrorMessage(err);
|
|
1549
|
+
log.warn(`Failed to answer inbound call ${activeCall.providerCallId}: ${message}`);
|
|
1550
|
+
});
|
|
1441
1551
|
});
|
|
1552
|
+
break;
|
|
1442
1553
|
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
const resolved = resolveTranscriptWaiter(ctx, call.callId, event.transcript, event.turnToken);
|
|
1479
|
-
if (hadWaiter && !resolved) {
|
|
1480
|
-
log.warn(`Ignoring speech event with mismatched turn token for ${call.callId}`);
|
|
1481
|
-
result = { kind: "ignored" };
|
|
1482
|
-
break;
|
|
1483
|
-
}
|
|
1484
|
-
addTranscriptEntry(call, "user", event.transcript);
|
|
1485
|
-
result = {
|
|
1486
|
-
kind: "final-speech",
|
|
1487
|
-
call,
|
|
1488
|
-
transcript: event.transcript,
|
|
1489
|
-
waiterResolved: resolved
|
|
1490
|
-
};
|
|
1491
|
-
}
|
|
1492
|
-
ensureMaxDurationTimerForLiveCall({
|
|
1493
|
-
ctx,
|
|
1494
|
-
call,
|
|
1495
|
-
liveAt: event.timestamp,
|
|
1496
|
-
onTimeout: async (callId) => {
|
|
1497
|
-
await endCall(ctx, callId, { reason: "timeout" });
|
|
1554
|
+
case "call.ringing":
|
|
1555
|
+
transitionState(activeCall, "ringing");
|
|
1556
|
+
break;
|
|
1557
|
+
case "call.answered":
|
|
1558
|
+
activeCall.answeredAt = event.timestamp;
|
|
1559
|
+
transitionState(activeCall, "answered");
|
|
1560
|
+
effects.push(startDurationTimer, () => ctx.onCallAnswered?.(activeCall));
|
|
1561
|
+
break;
|
|
1562
|
+
case "call.active":
|
|
1563
|
+
transitionState(activeCall, "active");
|
|
1564
|
+
break;
|
|
1565
|
+
case "call.speaking":
|
|
1566
|
+
case "call.assistant-speech":
|
|
1567
|
+
prepareLiveDurationTimer();
|
|
1568
|
+
transitionState(activeCall, "speaking");
|
|
1569
|
+
if (event.type === "call.assistant-speech" && event.transcript.trim()) addTranscriptEntry(activeCall, "bot", event.transcript);
|
|
1570
|
+
break;
|
|
1571
|
+
case "call.speech":
|
|
1572
|
+
if (event.isFinal && event.transcript.trim()) {
|
|
1573
|
+
const waiter = ctx.transcriptWaiters.get(activeCall.callId);
|
|
1574
|
+
if (waiter?.turnToken && waiter.turnToken !== event.turnToken) {
|
|
1575
|
+
log.warn(`Ignoring speech event with mismatched turn token for ${activeCall.callId}`);
|
|
1576
|
+
result = { kind: "ignored" };
|
|
1577
|
+
break;
|
|
1578
|
+
}
|
|
1579
|
+
addTranscriptEntry(activeCall, "user", event.transcript);
|
|
1580
|
+
result = {
|
|
1581
|
+
kind: "final-speech",
|
|
1582
|
+
call: activeCall,
|
|
1583
|
+
transcript: event.transcript,
|
|
1584
|
+
waiterResolved: Boolean(waiter)
|
|
1585
|
+
};
|
|
1586
|
+
if (waiter) effects.push(() => {
|
|
1587
|
+
resolveTranscriptWaiter(ctx, activeCall.callId, event.transcript, event.turnToken);
|
|
1588
|
+
});
|
|
1498
1589
|
}
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
finalizeCall({
|
|
1506
|
-
ctx,
|
|
1507
|
-
call,
|
|
1508
|
-
endReason: event.reason,
|
|
1509
|
-
endedAt: event.timestamp
|
|
1510
|
-
});
|
|
1511
|
-
return { kind: "processed" };
|
|
1512
|
-
case "call.error":
|
|
1513
|
-
if (!event.retryable) {
|
|
1590
|
+
prepareLiveDurationTimer();
|
|
1591
|
+
transitionState(activeCall, "listening");
|
|
1592
|
+
break;
|
|
1593
|
+
case "call.silence":
|
|
1594
|
+
case "call.dtmf": break;
|
|
1595
|
+
case "call.ended":
|
|
1514
1596
|
finalizeCall({
|
|
1515
1597
|
ctx,
|
|
1516
|
-
call,
|
|
1517
|
-
endReason:
|
|
1518
|
-
endedAt: event.timestamp
|
|
1519
|
-
transcriptRejectReason: `Call error: ${event.error}`
|
|
1598
|
+
call: activeCall,
|
|
1599
|
+
endReason: event.reason,
|
|
1600
|
+
endedAt: event.timestamp
|
|
1520
1601
|
});
|
|
1602
|
+
publishProviderCallId(true);
|
|
1603
|
+
rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
|
|
1521
1604
|
return { kind: "processed" };
|
|
1522
|
-
|
|
1523
|
-
|
|
1605
|
+
case "call.error":
|
|
1606
|
+
if (!event.retryable) {
|
|
1607
|
+
finalizeCall({
|
|
1608
|
+
ctx,
|
|
1609
|
+
call: activeCall,
|
|
1610
|
+
endReason: "error",
|
|
1611
|
+
endedAt: event.timestamp,
|
|
1612
|
+
transcriptRejectReason: `Call error: ${event.error}`
|
|
1613
|
+
});
|
|
1614
|
+
publishProviderCallId(true);
|
|
1615
|
+
rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
|
|
1616
|
+
return { kind: "processed" };
|
|
1617
|
+
}
|
|
1618
|
+
result = {
|
|
1619
|
+
kind: "processed",
|
|
1620
|
+
replayable: true
|
|
1621
|
+
};
|
|
1622
|
+
break;
|
|
1623
|
+
}
|
|
1624
|
+
persistCallRecord(ctx.storePath, activeCall);
|
|
1625
|
+
publishProviderCallId();
|
|
1626
|
+
if (shouldCommitReplayKey) rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
|
|
1627
|
+
} catch (err) {
|
|
1628
|
+
Object.assign(activeCall, {
|
|
1629
|
+
state: previousCall.state,
|
|
1630
|
+
providerCallId: previousCall.providerCallId,
|
|
1631
|
+
answeredAt: previousCall.answeredAt,
|
|
1632
|
+
endedAt: previousCall.endedAt,
|
|
1633
|
+
endReason: previousCall.endReason
|
|
1634
|
+
});
|
|
1635
|
+
activeCall.transcript.length = previousCall.transcriptLength;
|
|
1636
|
+
activeCall.processedEventIds.splice(0, activeCall.processedEventIds.length, ...previousCall.processedEventIds);
|
|
1637
|
+
throw err;
|
|
1524
1638
|
}
|
|
1525
|
-
|
|
1639
|
+
for (const effect of effects) effect();
|
|
1526
1640
|
return result;
|
|
1527
1641
|
}
|
|
1528
1642
|
//#endregion
|
|
@@ -1564,10 +1678,11 @@ var CallManager = class {
|
|
|
1564
1678
|
this.activeCalls = /* @__PURE__ */ new Map();
|
|
1565
1679
|
this.providerCallIdMap = /* @__PURE__ */ new Map();
|
|
1566
1680
|
this.processedEventIds = /* @__PURE__ */ new Set();
|
|
1567
|
-
this.rejectedProviderCallIds = /* @__PURE__ */ new
|
|
1681
|
+
this.rejectedProviderCallIds = /* @__PURE__ */ new Map();
|
|
1568
1682
|
this.provider = null;
|
|
1569
1683
|
this.webhookUrl = null;
|
|
1570
1684
|
this.activeTurnCalls = /* @__PURE__ */ new Set();
|
|
1685
|
+
this.endCallOperations = /* @__PURE__ */ new Map();
|
|
1571
1686
|
this.transcriptWaiters = /* @__PURE__ */ new Map();
|
|
1572
1687
|
this.maxDurationTimers = /* @__PURE__ */ new Map();
|
|
1573
1688
|
this.initialMessageInFlight = /* @__PURE__ */ new Set();
|
|
@@ -1585,7 +1700,7 @@ var CallManager = class {
|
|
|
1585
1700
|
fs.mkdirSync(this.storePath, { recursive: true });
|
|
1586
1701
|
const persisted = loadActiveCallsFromStore(this.storePath);
|
|
1587
1702
|
this.processedEventIds = persisted.processedEventIds;
|
|
1588
|
-
this.rejectedProviderCallIds =
|
|
1703
|
+
this.rejectedProviderCallIds = /* @__PURE__ */ new Map();
|
|
1589
1704
|
const verified = await this.verifyRestoredCalls(provider, persisted.activeCalls);
|
|
1590
1705
|
this.activeCalls = verified;
|
|
1591
1706
|
this.providerCallIdMap = /* @__PURE__ */ new Map();
|
|
@@ -1610,9 +1725,7 @@ var CallManager = class {
|
|
|
1610
1725
|
ctx: this.getContext(),
|
|
1611
1726
|
callId,
|
|
1612
1727
|
timeoutMs: maxDurationMs - elapsed,
|
|
1613
|
-
onTimeout:
|
|
1614
|
-
await endCall(this.getContext(), id, { reason: "timeout" });
|
|
1615
|
-
}
|
|
1728
|
+
onTimeout: (id) => this.endCall(id, { reason: "timeout" })
|
|
1616
1729
|
});
|
|
1617
1730
|
console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`);
|
|
1618
1731
|
}
|
|
@@ -1725,8 +1838,8 @@ var CallManager = class {
|
|
|
1725
1838
|
/**
|
|
1726
1839
|
* End an active call.
|
|
1727
1840
|
*/
|
|
1728
|
-
|
|
1729
|
-
return endCall(this.getContext(), callId);
|
|
1841
|
+
endCall(callId, options) {
|
|
1842
|
+
return endCall(this.getContext(), callId, options);
|
|
1730
1843
|
}
|
|
1731
1844
|
getContext() {
|
|
1732
1845
|
return {
|
|
@@ -1740,6 +1853,7 @@ var CallManager = class {
|
|
|
1740
1853
|
storePath: this.storePath,
|
|
1741
1854
|
webhookUrl: this.webhookUrl,
|
|
1742
1855
|
activeTurnCalls: this.activeTurnCalls,
|
|
1856
|
+
endCallOperations: this.endCallOperations,
|
|
1743
1857
|
transcriptWaiters: this.transcriptWaiters,
|
|
1744
1858
|
maxDurationTimers: this.maxDurationTimers,
|
|
1745
1859
|
initialMessageInFlight: this.initialMessageInFlight,
|
|
@@ -1913,6 +2027,20 @@ function chunkAudio(audio, chunkSize = 160) {
|
|
|
1913
2027
|
//#region extensions/voice-call/src/telephony-tts.ts
|
|
1914
2028
|
/** Default timeout for one telephony synthesis request. */
|
|
1915
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
|
+
}
|
|
1916
2044
|
/** Create a TTS provider that honors voice-call overrides and converts PCM to mulaw. */
|
|
1917
2045
|
async function createTelephonyTtsProvider(params) {
|
|
1918
2046
|
const { coreConfig, ttsOverride, runtime, logger } = params;
|
|
@@ -1941,7 +2069,12 @@ async function createTelephonyTtsProvider(params) {
|
|
|
1941
2069
|
const attemptedChain = result.attemptedProviders && result.attemptedProviders.length > 0 ? result.attemptedProviders.join(" -> ") : `${result.fallbackFrom} -> ${result.provider}`;
|
|
1942
2070
|
logger?.warn?.(`[voice-call] Telephony TTS fallback used from=${result.fallbackFrom} to=${result.provider} attempts=${attemptedChain}`);
|
|
1943
2071
|
}
|
|
1944
|
-
return
|
|
2072
|
+
return convertTelephonyTtsOutput({
|
|
2073
|
+
audioBuffer: result.audioBuffer,
|
|
2074
|
+
outputFormat: result.outputFormat,
|
|
2075
|
+
provider: result.provider,
|
|
2076
|
+
sampleRate: result.sampleRate
|
|
2077
|
+
});
|
|
1945
2078
|
}
|
|
1946
2079
|
};
|
|
1947
2080
|
}
|
|
@@ -1974,6 +2107,24 @@ function formatBoundedChildOutput(output) {
|
|
|
1974
2107
|
//#endregion
|
|
1975
2108
|
//#region extensions/voice-call/src/webhook/tailscale.ts
|
|
1976
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
|
+
}
|
|
1977
2128
|
async function runTailscaleCommand(args, timeoutMs = 2500) {
|
|
1978
2129
|
try {
|
|
1979
2130
|
const result = await runCommandWithTimeout(["tailscale", ...args], {
|
|
@@ -2021,51 +2172,69 @@ async function getTailscaleSelfInfo() {
|
|
|
2021
2172
|
async function getTailscaleDnsName() {
|
|
2022
2173
|
return (await getTailscaleSelfInfo())?.dnsName ?? null;
|
|
2023
2174
|
}
|
|
2024
|
-
async function
|
|
2175
|
+
async function cleanupTailscaleExposureRoute(opts) {
|
|
2176
|
+
await runTailscaleCommand(buildTailscaleExposureArgs(opts));
|
|
2177
|
+
}
|
|
2178
|
+
async function setupTailscaleExposureRoutes(opts) {
|
|
2025
2179
|
const dnsName = await getTailscaleDnsName();
|
|
2026
2180
|
if (!dnsName) {
|
|
2027
2181
|
console.warn("[voice-call] Could not get Tailscale DNS name");
|
|
2028
2182
|
return null;
|
|
2029
2183
|
}
|
|
2030
|
-
const
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
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);
|
|
2042
2205
|
}
|
|
2043
|
-
|
|
2044
|
-
return null;
|
|
2045
|
-
}
|
|
2046
|
-
async function cleanupTailscaleExposureRoute(opts) {
|
|
2047
|
-
await runTailscaleCommand([
|
|
2048
|
-
opts.mode,
|
|
2049
|
-
"off",
|
|
2050
|
-
opts.path
|
|
2051
|
-
]);
|
|
2206
|
+
return publicUrl;
|
|
2052
2207
|
}
|
|
2053
2208
|
async function setupTailscaleExposure(config) {
|
|
2054
2209
|
if (config.tailscale.mode === "off") return null;
|
|
2055
2210
|
const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
|
|
2056
2211
|
const localUrl = `http://127.0.0.1:${config.serve.port}${config.serve.path}`;
|
|
2057
|
-
|
|
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({
|
|
2058
2217
|
mode,
|
|
2059
|
-
|
|
2060
|
-
|
|
2218
|
+
port: config.tailscale.port,
|
|
2219
|
+
routes: [{
|
|
2220
|
+
path: config.tailscale.path,
|
|
2221
|
+
localUrl
|
|
2222
|
+
}, ...streamRoutes]
|
|
2061
2223
|
});
|
|
2062
2224
|
}
|
|
2063
2225
|
async function cleanupTailscaleExposure(config) {
|
|
2064
2226
|
if (config.tailscale.mode === "off") return;
|
|
2227
|
+
const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
|
|
2065
2228
|
await cleanupTailscaleExposureRoute({
|
|
2066
|
-
mode
|
|
2229
|
+
mode,
|
|
2230
|
+
port: config.tailscale.port,
|
|
2067
2231
|
path: config.tailscale.path
|
|
2068
2232
|
});
|
|
2233
|
+
for (const { publicPath } of resolveVoiceCallStreamExposurePaths(config)) await cleanupTailscaleExposureRoute({
|
|
2234
|
+
mode,
|
|
2235
|
+
port: config.tailscale.port,
|
|
2236
|
+
path: publicPath
|
|
2237
|
+
});
|
|
2069
2238
|
}
|
|
2070
2239
|
//#endregion
|
|
2071
2240
|
//#region extensions/voice-call/src/tunnel.ts
|
|
@@ -2227,62 +2396,37 @@ async function runNgrokCommand(args) {
|
|
|
2227
2396
|
* Start a Tailscale serve/funnel tunnel.
|
|
2228
2397
|
*/
|
|
2229
2398
|
async function startTailscaleTunnel(config) {
|
|
2230
|
-
const dnsName = await getTailscaleDnsName();
|
|
2231
|
-
if (!dnsName) throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
|
|
2232
2399
|
const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
|
|
2233
|
-
const
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
"
|
|
2238
|
-
"
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
], {
|
|
2243
|
-
killProcessTree: true,
|
|
2244
|
-
maxOutputBytes: TUNNEL_COMMAND_OUTPUT_MAX_BYTES,
|
|
2245
|
-
outputCapture: "tail",
|
|
2246
|
-
timeoutMs: 1e4
|
|
2247
|
-
});
|
|
2248
|
-
if (result.termination === "timeout") throw new Error(`Tailscale ${config.mode} timed out`);
|
|
2249
|
-
if (result.code !== 0) {
|
|
2250
|
-
const output = result.stderr ? {
|
|
2251
|
-
text: result.stderr,
|
|
2252
|
-
truncated: Boolean(result.stderrTruncatedBytes)
|
|
2253
|
-
} : {
|
|
2254
|
-
text: result.stdout,
|
|
2255
|
-
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}`
|
|
2256
2409
|
};
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
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`);
|
|
2262
2417
|
return {
|
|
2263
2418
|
publicUrl,
|
|
2264
2419
|
provider: `tailscale-${config.mode}`,
|
|
2265
2420
|
stop: async () => {
|
|
2266
|
-
|
|
2421
|
+
for (const route of routes) await cleanupTailscaleExposureRoute({
|
|
2422
|
+
mode: config.mode,
|
|
2423
|
+
port: config.tailscalePort,
|
|
2424
|
+
path: route.path
|
|
2425
|
+
});
|
|
2267
2426
|
}
|
|
2268
2427
|
};
|
|
2269
2428
|
}
|
|
2270
2429
|
/**
|
|
2271
|
-
* Stop a Tailscale serve/funnel tunnel.
|
|
2272
|
-
*/
|
|
2273
|
-
async function stopTailscaleTunnel(mode, path) {
|
|
2274
|
-
await runCommandWithTimeout([
|
|
2275
|
-
"tailscale",
|
|
2276
|
-
mode,
|
|
2277
|
-
"off",
|
|
2278
|
-
path
|
|
2279
|
-
], {
|
|
2280
|
-
killProcessTree: true,
|
|
2281
|
-
maxOutputBytes: 1,
|
|
2282
|
-
timeoutMs: 5e3
|
|
2283
|
-
}).catch(() => {});
|
|
2284
|
-
}
|
|
2285
|
-
/**
|
|
2286
2430
|
* Start a tunnel based on configuration.
|
|
2287
2431
|
*/
|
|
2288
2432
|
async function startTunnel(config) {
|
|
@@ -2296,12 +2440,16 @@ async function startTunnel(config) {
|
|
|
2296
2440
|
case "tailscale-serve": return startTailscaleTunnel({
|
|
2297
2441
|
mode: "serve",
|
|
2298
2442
|
port: config.port,
|
|
2299
|
-
|
|
2443
|
+
tailscalePort: config.tailscalePort ?? 443,
|
|
2444
|
+
path: config.path,
|
|
2445
|
+
streamPaths: config.streamPaths
|
|
2300
2446
|
});
|
|
2301
2447
|
case "tailscale-funnel": return startTailscaleTunnel({
|
|
2302
2448
|
mode: "funnel",
|
|
2303
2449
|
port: config.port,
|
|
2304
|
-
|
|
2450
|
+
tailscalePort: config.tailscalePort ?? 443,
|
|
2451
|
+
path: config.path,
|
|
2452
|
+
streamPaths: config.streamPaths
|
|
2305
2453
|
});
|
|
2306
2454
|
default: return null;
|
|
2307
2455
|
}
|
|
@@ -2381,6 +2529,9 @@ const DEFAULT_MAX_PENDING_CONNECTIONS_PER_IP = 4;
|
|
|
2381
2529
|
const DEFAULT_MAX_CONNECTIONS = 128;
|
|
2382
2530
|
const MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
|
|
2383
2531
|
const MAX_WS_BUFFERED_BYTES = 1024 * 1024;
|
|
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;
|
|
2384
2535
|
const CLOSE_REASON_LOG_MAX_CHARS = 120;
|
|
2385
2536
|
function sanitizeLogText(value, maxChars) {
|
|
2386
2537
|
const sanitized = value.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
|
|
@@ -2406,6 +2557,8 @@ function parseTwilioMediaMessage(data) {
|
|
|
2406
2557
|
var MediaStreamHandler = class {
|
|
2407
2558
|
constructor(config) {
|
|
2408
2559
|
this.wss = null;
|
|
2560
|
+
this.closePromise = null;
|
|
2561
|
+
this.closing = false;
|
|
2409
2562
|
this.sessions = /* @__PURE__ */ new Map();
|
|
2410
2563
|
this.pendingConnections = /* @__PURE__ */ new Map();
|
|
2411
2564
|
this.pendingByIp = /* @__PURE__ */ new Map();
|
|
@@ -2413,6 +2566,8 @@ var MediaStreamHandler = class {
|
|
|
2413
2566
|
this.ttsQueues = /* @__PURE__ */ new Map();
|
|
2414
2567
|
this.ttsPlaying = /* @__PURE__ */ new Map();
|
|
2415
2568
|
this.ttsActiveControllers = /* @__PURE__ */ new Map();
|
|
2569
|
+
this.pendingPlaybackMarks = /* @__PURE__ */ new Map();
|
|
2570
|
+
this.ignoredPlaybackMarks = /* @__PURE__ */ new Map();
|
|
2416
2571
|
this.config = config;
|
|
2417
2572
|
this.preStartTimeoutMs = resolveTimerTimeoutMs(config.preStartTimeoutMs, DEFAULT_PRE_START_TIMEOUT_MS);
|
|
2418
2573
|
this.maxPendingConnections = config.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS;
|
|
@@ -2423,6 +2578,10 @@ var MediaStreamHandler = class {
|
|
|
2423
2578
|
* Handle WebSocket upgrade for media stream connections.
|
|
2424
2579
|
*/
|
|
2425
2580
|
handleUpgrade(request, socket, head) {
|
|
2581
|
+
if (this.closing) {
|
|
2582
|
+
this.rejectUpgrade(socket, 503, "Media stream handler is shutting down");
|
|
2583
|
+
return;
|
|
2584
|
+
}
|
|
2426
2585
|
if (!this.wss) {
|
|
2427
2586
|
this.wss = new WebSocketServer({
|
|
2428
2587
|
noServer: true,
|
|
@@ -2464,6 +2623,23 @@ var MediaStreamHandler = class {
|
|
|
2464
2623
|
throw error;
|
|
2465
2624
|
}
|
|
2466
2625
|
}
|
|
2626
|
+
close(shutdownBarrier = Promise.resolve()) {
|
|
2627
|
+
if (this.closePromise) return this.closePromise;
|
|
2628
|
+
this.closing = true;
|
|
2629
|
+
const wss = this.wss;
|
|
2630
|
+
this.wss = null;
|
|
2631
|
+
this.closePromise = (async () => {
|
|
2632
|
+
if (wss) await new Promise((resolve) => {
|
|
2633
|
+
wss.close(() => resolve());
|
|
2634
|
+
for (const ws of wss.clients) ws.terminate();
|
|
2635
|
+
});
|
|
2636
|
+
await shutdownBarrier;
|
|
2637
|
+
})().finally(() => {
|
|
2638
|
+
this.closing = false;
|
|
2639
|
+
this.closePromise = null;
|
|
2640
|
+
});
|
|
2641
|
+
return this.closePromise;
|
|
2642
|
+
}
|
|
2467
2643
|
/**
|
|
2468
2644
|
* Handle new WebSocket connection from Twilio.
|
|
2469
2645
|
*/
|
|
@@ -2483,6 +2659,11 @@ var MediaStreamHandler = class {
|
|
|
2483
2659
|
console.log("[MediaStream] Twilio connected");
|
|
2484
2660
|
break;
|
|
2485
2661
|
case "start":
|
|
2662
|
+
if (session) {
|
|
2663
|
+
console.warn("[MediaStream] Rejecting duplicate start frame for active connection");
|
|
2664
|
+
ws.close(1008, "Duplicate start");
|
|
2665
|
+
break;
|
|
2666
|
+
}
|
|
2486
2667
|
session = this.handleStart(ws, message, streamToken);
|
|
2487
2668
|
if (session) this.clearPendingConnection(ws);
|
|
2488
2669
|
break;
|
|
@@ -2510,8 +2691,10 @@ var MediaStreamHandler = class {
|
|
|
2510
2691
|
session = null;
|
|
2511
2692
|
}
|
|
2512
2693
|
break;
|
|
2513
|
-
case "
|
|
2514
|
-
|
|
2694
|
+
case "mark":
|
|
2695
|
+
if (session && message.mark?.name) this.acknowledgePlaybackMark(session.streamSid, message.mark.name);
|
|
2696
|
+
break;
|
|
2697
|
+
case "clear": break;
|
|
2515
2698
|
}
|
|
2516
2699
|
} catch (error) {
|
|
2517
2700
|
console.error("[MediaStream] Error processing message:", error);
|
|
@@ -2837,10 +3020,44 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2837
3020
|
mark: { name }
|
|
2838
3021
|
});
|
|
2839
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
|
+
}
|
|
2840
3056
|
/**
|
|
2841
3057
|
* Clear audio buffer (interrupt playback).
|
|
2842
3058
|
*/
|
|
2843
3059
|
clearAudio(streamSid) {
|
|
3060
|
+
this.invalidatePlaybackMarks(streamSid);
|
|
2844
3061
|
return this.sendToStream(streamSid, {
|
|
2845
3062
|
event: "clear",
|
|
2846
3063
|
streamSid
|
|
@@ -2852,6 +3069,7 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2852
3069
|
*/
|
|
2853
3070
|
async queueTts(streamSid, playFn) {
|
|
2854
3071
|
const queue = this.getTtsQueue(streamSid);
|
|
3072
|
+
if (queue.length >= MAX_PENDING_TTS_OPERATIONS_PER_STREAM) throw new Error(`Telephony TTS queue is full for stream; maxPending=${MAX_PENDING_TTS_OPERATIONS_PER_STREAM}`);
|
|
2855
3073
|
let resolveEntry;
|
|
2856
3074
|
let rejectEntry;
|
|
2857
3075
|
const promise = new Promise((resolve, reject) => {
|
|
@@ -2871,8 +3089,8 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2871
3089
|
* Clear TTS queue and interrupt current playback (barge-in).
|
|
2872
3090
|
*/
|
|
2873
3091
|
clearTtsQueue(streamSid, _reason = "unspecified") {
|
|
2874
|
-
const queue = this.
|
|
2875
|
-
this.resolveQueuedTtsEntries(queue);
|
|
3092
|
+
const queue = this.ttsQueues.get(streamSid);
|
|
3093
|
+
if (queue) this.resolveQueuedTtsEntries(queue);
|
|
2876
3094
|
this.ttsActiveControllers.get(streamSid)?.abort();
|
|
2877
3095
|
const session = this.sessions.get(streamSid);
|
|
2878
3096
|
if (session?.talk.activeTurnId) {
|
|
@@ -2892,6 +3110,36 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2892
3110
|
this.ttsQueues.set(streamSid, queue);
|
|
2893
3111
|
return queue;
|
|
2894
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
|
+
}
|
|
2895
3143
|
/**
|
|
2896
3144
|
* Process the TTS queue for a stream.
|
|
2897
3145
|
* Uses iterative approach to avoid stack accumulation from recursion.
|
|
@@ -2901,8 +3149,9 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2901
3149
|
while (true) {
|
|
2902
3150
|
const queue = this.ttsQueues.get(streamSid);
|
|
2903
3151
|
if (!queue || queue.length === 0) {
|
|
2904
|
-
this.ttsPlaying.
|
|
3152
|
+
this.ttsPlaying.delete(streamSid);
|
|
2905
3153
|
this.ttsActiveControllers.delete(streamSid);
|
|
3154
|
+
this.ttsQueues.delete(streamSid);
|
|
2906
3155
|
return;
|
|
2907
3156
|
}
|
|
2908
3157
|
const entry = queue.shift();
|
|
@@ -2986,6 +3235,8 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2986
3235
|
this.ttsActiveControllers.delete(streamSid);
|
|
2987
3236
|
this.ttsPlaying.delete(streamSid);
|
|
2988
3237
|
this.ttsQueues.delete(streamSid);
|
|
3238
|
+
this.invalidatePlaybackMarks(streamSid);
|
|
3239
|
+
this.ignoredPlaybackMarks.delete(streamSid);
|
|
2989
3240
|
}
|
|
2990
3241
|
resolveQueuedTtsEntries(queue) {
|
|
2991
3242
|
const pending = queue.splice(0);
|
|
@@ -3052,7 +3303,9 @@ function startStaleCallReaper(params) {
|
|
|
3052
3303
|
if (age > maxAgeMs && !callsBeingReaped.has(call.callId)) {
|
|
3053
3304
|
callsBeingReaped.add(call.callId);
|
|
3054
3305
|
console.log(`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1e3)}s, state: ${call.state})`);
|
|
3055
|
-
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) => {
|
|
3056
3309
|
console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
|
|
3057
3310
|
}).finally(() => {
|
|
3058
3311
|
callsBeingReaped.delete(call.callId);
|
|
@@ -3065,13 +3318,51 @@ function startStaleCallReaper(params) {
|
|
|
3065
3318
|
};
|
|
3066
3319
|
}
|
|
3067
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
|
|
3068
3360
|
//#region extensions/voice-call/src/webhook.ts
|
|
3069
3361
|
const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
|
|
3070
3362
|
const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
|
|
3071
3363
|
const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
|
|
3072
|
-
const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2e3;
|
|
3073
3364
|
const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
|
|
3074
|
-
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-
|
|
3365
|
+
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-tjyX4tDq.js"));
|
|
3075
3366
|
function appendRecentTalkEventMetadata(call, event) {
|
|
3076
3367
|
const metadata = call.metadata ?? {};
|
|
3077
3368
|
const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
|
|
@@ -3147,10 +3438,10 @@ var VoiceCallWebhookServer = class {
|
|
|
3147
3438
|
this.server = null;
|
|
3148
3439
|
this.listeningUrl = null;
|
|
3149
3440
|
this.startPromise = null;
|
|
3441
|
+
this.stopPromise = null;
|
|
3150
3442
|
this.stopStaleCallReaper = null;
|
|
3151
3443
|
this.webhookInFlightLimiter = createWebhookInFlightLimiter();
|
|
3152
3444
|
this.mediaStreamHandler = null;
|
|
3153
|
-
this.pendingDisconnectHangups = /* @__PURE__ */ new Map();
|
|
3154
3445
|
this.realtimeHandler = null;
|
|
3155
3446
|
this.replayResponses = /* @__PURE__ */ new Map();
|
|
3156
3447
|
this.replayResponseCacheCalls = 0;
|
|
@@ -3174,6 +3465,16 @@ var VoiceCallWebhookServer = class {
|
|
|
3174
3465
|
error: (msg) => rawLogger.error(`[voice-call] ${msg}`),
|
|
3175
3466
|
debug: rawDebug ? (msg) => rawDebug(`[voice-call] ${msg}`) : void 0
|
|
3176
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
|
+
});
|
|
3177
3478
|
}
|
|
3178
3479
|
/**
|
|
3179
3480
|
* Get the media stream handler (for wiring to provider).
|
|
@@ -3194,11 +3495,8 @@ var VoiceCallWebhookServer = class {
|
|
|
3194
3495
|
setRealtimeHandler(handler) {
|
|
3195
3496
|
this.realtimeHandler = handler;
|
|
3196
3497
|
}
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
if (!existing) return;
|
|
3200
|
-
clearTimeout(existing);
|
|
3201
|
-
this.pendingDisconnectHangups.delete(providerCallId);
|
|
3498
|
+
getStreamDisconnectLifecycle() {
|
|
3499
|
+
return this.streamDisconnectGrace;
|
|
3202
3500
|
}
|
|
3203
3501
|
resolveMediaStreamClientIp(request) {
|
|
3204
3502
|
const remoteIp = request.socket.remoteAddress ?? void 0;
|
|
@@ -3312,7 +3610,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3312
3610
|
},
|
|
3313
3611
|
onConnect: (callId, streamSid) => {
|
|
3314
3612
|
this.logger.info(`Media stream connected: ${callId} -> ${streamSid}`);
|
|
3315
|
-
this.
|
|
3613
|
+
this.streamDisconnectGrace.connect(callId, streamSid);
|
|
3316
3614
|
if (this.provider.name === "twilio") this.provider.registerCallStream(callId, streamSid);
|
|
3317
3615
|
},
|
|
3318
3616
|
onTranscriptionReady: (callId) => {
|
|
@@ -3323,21 +3621,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3323
3621
|
onDisconnect: (callId, streamSid) => {
|
|
3324
3622
|
this.logger.info(`Media stream disconnected: ${callId} (${streamSid})`);
|
|
3325
3623
|
if (this.provider.name === "twilio") this.provider.unregisterCallStream(callId, streamSid);
|
|
3326
|
-
this.
|
|
3327
|
-
const timer = setTimeout(() => {
|
|
3328
|
-
this.pendingDisconnectHangups.delete(callId);
|
|
3329
|
-
const disconnectedCall = this.manager.getCallByProviderCallId(callId);
|
|
3330
|
-
if (!disconnectedCall) return;
|
|
3331
|
-
if (this.provider.name === "twilio") {
|
|
3332
|
-
if (this.provider.hasRegisteredStream(callId)) return;
|
|
3333
|
-
}
|
|
3334
|
-
this.logger.info(`Auto-ending call ${disconnectedCall.callId} after stream disconnect grace`);
|
|
3335
|
-
this.manager.endCall(disconnectedCall.callId).catch((err) => {
|
|
3336
|
-
this.logger.warn(`Failed to auto-end call ${disconnectedCall.callId}: ${String(err)}`);
|
|
3337
|
-
});
|
|
3338
|
-
}, STREAM_DISCONNECT_HANGUP_GRACE_MS);
|
|
3339
|
-
timer.unref?.();
|
|
3340
|
-
this.pendingDisconnectHangups.set(callId, timer);
|
|
3624
|
+
this.streamDisconnectGrace.disconnect(callId, streamSid);
|
|
3341
3625
|
}
|
|
3342
3626
|
};
|
|
3343
3627
|
this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
|
|
@@ -3348,6 +3632,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3348
3632
|
* Idempotent: returns immediately if the server is already listening.
|
|
3349
3633
|
*/
|
|
3350
3634
|
async start() {
|
|
3635
|
+
if (this.stopPromise) await this.stopPromise;
|
|
3351
3636
|
const { port, bind, path: webhookPath } = this.config.serve;
|
|
3352
3637
|
const streamPath = this.config.streaming.streamPath;
|
|
3353
3638
|
if (this.server?.listening) return this.listeningUrl ?? this.resolveListeningUrl(bind, webhookPath);
|
|
@@ -3397,26 +3682,44 @@ var VoiceCallWebhookServer = class {
|
|
|
3397
3682
|
/**
|
|
3398
3683
|
* Stop the webhook server.
|
|
3399
3684
|
*/
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
this.
|
|
3403
|
-
|
|
3685
|
+
stop() {
|
|
3686
|
+
if (this.stopPromise) return this.stopPromise;
|
|
3687
|
+
const server = this.server;
|
|
3688
|
+
const serverClosePromise = new Promise((resolve, reject) => {
|
|
3689
|
+
if (!server) {
|
|
3690
|
+
resolve();
|
|
3691
|
+
return;
|
|
3692
|
+
}
|
|
3693
|
+
server.close((error) => {
|
|
3694
|
+
if (error) {
|
|
3695
|
+
reject(error);
|
|
3696
|
+
return;
|
|
3697
|
+
}
|
|
3698
|
+
resolve();
|
|
3699
|
+
});
|
|
3700
|
+
});
|
|
3404
3701
|
this.startPromise = null;
|
|
3702
|
+
this.streamDisconnectGrace.close();
|
|
3405
3703
|
if (this.stopStaleCallReaper) {
|
|
3406
3704
|
this.stopStaleCallReaper();
|
|
3407
3705
|
this.stopStaleCallReaper = null;
|
|
3408
3706
|
}
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
resolve()
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3707
|
+
this.webhookInFlightLimiter.clear();
|
|
3708
|
+
this.stopPromise = (async () => {
|
|
3709
|
+
const results = await Promise.allSettled([
|
|
3710
|
+
serverClosePromise,
|
|
3711
|
+
this.mediaStreamHandler?.close(serverClosePromise) ?? Promise.resolve(),
|
|
3712
|
+
this.realtimeHandler?.close(serverClosePromise) ?? Promise.resolve()
|
|
3713
|
+
]);
|
|
3714
|
+
this.streamDisconnectGrace.close();
|
|
3715
|
+
if (this.server === server) this.server = null;
|
|
3716
|
+
this.listeningUrl = null;
|
|
3717
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
3718
|
+
if (failure) throw failure.reason;
|
|
3719
|
+
})().finally(() => {
|
|
3720
|
+
this.stopPromise = null;
|
|
3419
3721
|
});
|
|
3722
|
+
return this.stopPromise;
|
|
3420
3723
|
}
|
|
3421
3724
|
resolveListeningUrl(bind, webhookPath) {
|
|
3422
3725
|
const address = this.server?.address();
|
|
@@ -3521,9 +3824,9 @@ var VoiceCallWebhookServer = class {
|
|
|
3521
3824
|
const isReplay = Boolean(verification.isReplay);
|
|
3522
3825
|
if (isReplay) {
|
|
3523
3826
|
this.logger.warn("Replay detected; skipping event side effects");
|
|
3524
|
-
if (this.provider.name === "twilio") return buildTwilioReplayTwiML();
|
|
3525
3827
|
const cachedResponse = await this.getCachedReplayResponse(verification.verifiedRequestKey);
|
|
3526
3828
|
if (cachedResponse) return cachedResponse;
|
|
3829
|
+
if (this.provider.name === "twilio") return buildTwilioReplayTwiML();
|
|
3527
3830
|
}
|
|
3528
3831
|
const buildResponse = async () => {
|
|
3529
3832
|
const initialTwiML = this.provider.consumeInitialTwiML?.(ctx);
|
|
@@ -3547,12 +3850,11 @@ var VoiceCallWebhookServer = class {
|
|
|
3547
3850
|
return this.realtimeHandler.buildTwiMLPayload(req, realtimeParams);
|
|
3548
3851
|
}
|
|
3549
3852
|
const parsed = this.provider.parseWebhookEvent(ctx, { verifiedRequestKey: verification.verifiedRequestKey });
|
|
3550
|
-
if (!isReplay
|
|
3853
|
+
if (!isReplay && this.processParsedEvents(parsed.events)) verification.releaseReplay?.();
|
|
3551
3854
|
return normalizeWebhookResponse(parsed);
|
|
3552
3855
|
};
|
|
3553
3856
|
if (isReplay) return await buildResponse();
|
|
3554
|
-
|
|
3555
|
-
return await this.cacheReplayResponse(verification.verifiedRequestKey, buildResponse);
|
|
3857
|
+
return await this.cacheReplayResponse(verification.verifiedRequestKey, buildResponse, verification.releaseReplay);
|
|
3556
3858
|
} finally {
|
|
3557
3859
|
this.webhookInFlightLimiter.release(inFlightKey);
|
|
3558
3860
|
}
|
|
@@ -3578,21 +3880,28 @@ var VoiceCallWebhookServer = class {
|
|
|
3578
3880
|
}
|
|
3579
3881
|
return cloneWebhookResponsePayload(await entry.response);
|
|
3580
3882
|
}
|
|
3581
|
-
async cacheReplayResponse(key, buildResponse) {
|
|
3883
|
+
async cacheReplayResponse(key, buildResponse, releaseReplay) {
|
|
3582
3884
|
const now = Date.now();
|
|
3583
3885
|
const expiresAt = resolveExpiresAtMsFromDurationMs(WEBHOOK_REPLAY_RESPONSE_TTL_MS, { nowMs: now });
|
|
3584
3886
|
this.replayResponseCacheCalls += 1;
|
|
3585
3887
|
if (this.replayResponseCacheCalls % WEBHOOK_REPLAY_RESPONSE_PRUNE_INTERVAL === 0) this.pruneReplayResponses(now);
|
|
3586
|
-
|
|
3587
|
-
|
|
3888
|
+
let cachedEntry;
|
|
3889
|
+
const ownerResponse = buildResponse().then(cloneWebhookResponsePayload).catch((err) => {
|
|
3890
|
+
if (cachedEntry && this.replayResponses.get(key) === cachedEntry) this.replayResponses.delete(key);
|
|
3891
|
+
releaseReplay?.();
|
|
3588
3892
|
throw err;
|
|
3589
3893
|
});
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3894
|
+
const response = ownerResponse.then((payload) => this.provider.name === "twilio" ? buildTwilioReplayTwiML() : cloneWebhookResponsePayload(payload));
|
|
3895
|
+
response.catch(() => {});
|
|
3896
|
+
if (expiresAt !== void 0) {
|
|
3897
|
+
cachedEntry = {
|
|
3898
|
+
expiresAt,
|
|
3899
|
+
response
|
|
3900
|
+
};
|
|
3901
|
+
this.replayResponses.set(key, cachedEntry);
|
|
3902
|
+
}
|
|
3594
3903
|
if (this.replayResponses.size > WEBHOOK_REPLAY_RESPONSE_MAX_ENTRIES) this.pruneReplayResponses(now);
|
|
3595
|
-
return cloneWebhookResponsePayload(await
|
|
3904
|
+
return cloneWebhookResponsePayload(await ownerResponse);
|
|
3596
3905
|
}
|
|
3597
3906
|
verifyPreAuthWebhookHeaders(headers) {
|
|
3598
3907
|
if (this.config.skipSignatureVerification) return { ok: true };
|
|
@@ -3656,20 +3965,25 @@ var VoiceCallWebhookServer = class {
|
|
|
3656
3965
|
}
|
|
3657
3966
|
}
|
|
3658
3967
|
processParsedEvents(events) {
|
|
3968
|
+
let replayable = false;
|
|
3659
3969
|
for (const event of events) try {
|
|
3660
|
-
this.processEventWithAutoResponse(event);
|
|
3970
|
+
replayable = this.processEventWithAutoResponse(event) || replayable;
|
|
3661
3971
|
} catch (err) {
|
|
3662
3972
|
this.logger.error(`Error processing event ${event.type}: ${String(err)}`);
|
|
3973
|
+
throw err;
|
|
3663
3974
|
}
|
|
3975
|
+
return replayable;
|
|
3664
3976
|
}
|
|
3665
3977
|
processEventWithAutoResponse(event) {
|
|
3666
3978
|
const result = this.manager.processEvent(event);
|
|
3667
|
-
if (result.kind !== "final-speech"
|
|
3979
|
+
if (result.kind !== "final-speech") return result.replayable === true;
|
|
3980
|
+
if (result.waiterResolved) return false;
|
|
3668
3981
|
const callMode = result.call.metadata?.mode;
|
|
3669
|
-
if (result.call.direction !== "inbound" && callMode !== "conversation") return;
|
|
3982
|
+
if (result.call.direction !== "inbound" && callMode !== "conversation") return false;
|
|
3670
3983
|
this.handleInboundResponse(result.call.callId, result.transcript).catch((err) => {
|
|
3671
3984
|
this.logger.warn(`Failed to auto-respond: ${String(err)}`);
|
|
3672
3985
|
});
|
|
3986
|
+
return false;
|
|
3673
3987
|
}
|
|
3674
3988
|
writeWebhookResponse(res, payload) {
|
|
3675
3989
|
res.statusCode = payload.statusCode;
|
|
@@ -3715,6 +4029,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3715
4029
|
callId,
|
|
3716
4030
|
sessionKey: call.sessionKey,
|
|
3717
4031
|
from: call.from,
|
|
4032
|
+
senderIsOwner: call.direction === "inbound" ? false : void 0,
|
|
3718
4033
|
agentId: resolveCallAgentId(call, effectiveConfig),
|
|
3719
4034
|
transcript: call.transcript,
|
|
3720
4035
|
userMessage,
|
|
@@ -3746,12 +4061,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
|
|
|
3746
4061
|
"Do not print secret values or dump environment variables; only check whether required configuration is present.",
|
|
3747
4062
|
"Be accurate, brief, and speakable."
|
|
3748
4063
|
].join(" ");
|
|
3749
|
-
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-
|
|
3750
|
-
const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-
|
|
3751
|
-
const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-
|
|
3752
|
-
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"));
|
|
3753
4068
|
const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
|
|
3754
|
-
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler
|
|
4069
|
+
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-BaMEKbmJ.js"));
|
|
3755
4070
|
function resolveVoiceCallConsultSessionKey(call) {
|
|
3756
4071
|
return resolveVoiceCallSessionKey({
|
|
3757
4072
|
config: call.config,
|
|
@@ -3775,7 +4090,7 @@ function mapVoiceCallConsultTranscript(call, context) {
|
|
|
3775
4090
|
}
|
|
3776
4091
|
function createRuntimeResourceLifecycle(params) {
|
|
3777
4092
|
let tunnelResult = null;
|
|
3778
|
-
let
|
|
4093
|
+
let stopPromise = null;
|
|
3779
4094
|
const runStep = async (step, suppressErrors) => {
|
|
3780
4095
|
if (suppressErrors) {
|
|
3781
4096
|
await step().catch(() => {});
|
|
@@ -3787,19 +4102,21 @@ function createRuntimeResourceLifecycle(params) {
|
|
|
3787
4102
|
setTunnelResult: (result) => {
|
|
3788
4103
|
tunnelResult = result;
|
|
3789
4104
|
},
|
|
3790
|
-
stop:
|
|
3791
|
-
if (
|
|
3792
|
-
stopped = true;
|
|
4105
|
+
stop: (opts) => {
|
|
4106
|
+
if (stopPromise) return stopPromise;
|
|
3793
4107
|
const suppressErrors = opts?.suppressErrors ?? false;
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
await
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
await
|
|
3802
|
-
|
|
4108
|
+
stopPromise = (async () => {
|
|
4109
|
+
await runStep(async () => {
|
|
4110
|
+
if (tunnelResult) await tunnelResult.stop();
|
|
4111
|
+
}, suppressErrors);
|
|
4112
|
+
await runStep(async () => {
|
|
4113
|
+
await cleanupTailscaleExposure(params.config);
|
|
4114
|
+
}, suppressErrors);
|
|
4115
|
+
await runStep(async () => {
|
|
4116
|
+
await params.webhookServer.stop();
|
|
4117
|
+
}, suppressErrors);
|
|
4118
|
+
})();
|
|
4119
|
+
return stopPromise;
|
|
3803
4120
|
}
|
|
3804
4121
|
};
|
|
3805
4122
|
}
|
|
@@ -3847,17 +4164,9 @@ async function resolveProvider(config) {
|
|
|
3847
4164
|
default: throw new Error(`Unsupported voice-call provider: ${String(config.provider)}`);
|
|
3848
4165
|
}
|
|
3849
4166
|
}
|
|
3850
|
-
async function resolveRealtimeProvider(params) {
|
|
3851
|
-
const { resolveConfiguredRealtimeVoiceProvider } = await loadRealtimeVoiceRuntime();
|
|
3852
|
-
return resolveConfiguredRealtimeVoiceProvider({
|
|
3853
|
-
configuredProviderId: params.config.realtime.provider,
|
|
3854
|
-
providerConfigs: params.config.realtime.providers,
|
|
3855
|
-
cfg: params.fullConfig
|
|
3856
|
-
});
|
|
3857
|
-
}
|
|
3858
4167
|
function listRealtimeAgentIds(config, coreConfig) {
|
|
3859
4168
|
const agentIds = /* @__PURE__ */ new Set([normalizeAgentId(config.agentId)]);
|
|
3860
|
-
for (const
|
|
4169
|
+
for (const agentId of listAgentIds(coreConfig)) agentIds.add(normalizeAgentId(agentId));
|
|
3861
4170
|
for (const route of Object.values(config.numbers)) if (route.agentId) agentIds.add(normalizeAgentId(route.agentId));
|
|
3862
4171
|
return [...agentIds];
|
|
3863
4172
|
}
|
|
@@ -3920,23 +4229,39 @@ async function createVoiceCallRuntime(params) {
|
|
|
3920
4229
|
const provider = await resolveProvider(config);
|
|
3921
4230
|
if (stateRuntime) setVoiceCallStateRuntime({ state: stateRuntime });
|
|
3922
4231
|
const manager = new CallManager(config, void 0, cfg.session);
|
|
3923
|
-
const
|
|
3924
|
-
config,
|
|
3925
|
-
fullConfig: cfg
|
|
3926
|
-
}) : null;
|
|
4232
|
+
const realtimeVoiceRuntime = config.realtime.enabled ? await loadRealtimeVoiceRuntime() : null;
|
|
3927
4233
|
const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig, fullConfig ?? coreConfig, agentRuntime, log);
|
|
3928
|
-
if (
|
|
4234
|
+
if (realtimeVoiceRuntime) {
|
|
3929
4235
|
const { RealtimeCallHandler } = await loadRealtimeHandler();
|
|
3930
4236
|
const resolveRealtimeInstructions = await createRealtimeInstructionsResolver({
|
|
3931
4237
|
config,
|
|
3932
4238
|
coreConfig: cfg,
|
|
3933
4239
|
agentRuntime
|
|
3934
4240
|
});
|
|
3935
|
-
const
|
|
4241
|
+
const realtimeConfig = {
|
|
3936
4242
|
...config.realtime,
|
|
3937
|
-
tools:
|
|
3938
|
-
}
|
|
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);
|
|
3939
4263
|
if (config.realtime.toolPolicy !== "none") realtimeHandler.registerToolHandler(REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, async (args, callId, handlerContext) => {
|
|
4264
|
+
handlerContext.abortSignal?.throwIfAborted();
|
|
3940
4265
|
const call = manager.getCall(callId);
|
|
3941
4266
|
if (!call) return { error: `Call "${callId}" not found` };
|
|
3942
4267
|
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
|
@@ -3967,6 +4292,7 @@ async function createVoiceCallRuntime(params) {
|
|
|
3967
4292
|
args,
|
|
3968
4293
|
logger: log
|
|
3969
4294
|
});
|
|
4295
|
+
handlerContext.abortSignal?.throwIfAborted();
|
|
3970
4296
|
if (fastContext.handled) {
|
|
3971
4297
|
assertRealtimeVoiceAgentConsultModelSelectionUnlocked(modelLockParams);
|
|
3972
4298
|
return fastContext.result;
|
|
@@ -4003,7 +4329,8 @@ async function createVoiceCallRuntime(params) {
|
|
|
4003
4329
|
spawnedBy: requesterSessionKey,
|
|
4004
4330
|
contextMode: requesterSessionKey ? "fork" : void 0,
|
|
4005
4331
|
toolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow(effectiveConfig.realtime.toolPolicy),
|
|
4006
|
-
extraSystemPrompt: REALTIME_VOICE_CONSULT_SYSTEM_PROMPT
|
|
4332
|
+
extraSystemPrompt: REALTIME_VOICE_CONSULT_SYSTEM_PROMPT,
|
|
4333
|
+
abortSignal: handlerContext.abortSignal
|
|
4007
4334
|
});
|
|
4008
4335
|
});
|
|
4009
4336
|
webhookServer.setRealtimeHandler(realtimeHandler);
|
|
@@ -4019,7 +4346,12 @@ async function createVoiceCallRuntime(params) {
|
|
|
4019
4346
|
const nextTunnelResult = await startTunnel({
|
|
4020
4347
|
provider: config.tunnel.provider,
|
|
4021
4348
|
port: config.serve.port,
|
|
4349
|
+
tailscalePort: config.tailscale.port,
|
|
4022
4350
|
path: config.serve.path,
|
|
4351
|
+
streamPaths: resolveVoiceCallStreamExposurePaths(config, {
|
|
4352
|
+
publicWebhookPath: config.serve.path,
|
|
4353
|
+
localWebhookPath: config.serve.path
|
|
4354
|
+
}),
|
|
4023
4355
|
ngrokAuthToken: config.tunnel.ngrokAuthToken,
|
|
4024
4356
|
ngrokDomain: config.tunnel.ngrokDomain
|
|
4025
4357
|
});
|
|
@@ -4032,7 +4364,7 @@ async function createVoiceCallRuntime(params) {
|
|
|
4032
4364
|
const webhookUrl = publicUrl ?? localUrl;
|
|
4033
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.`);
|
|
4034
4366
|
if (publicUrl) provider.setPublicUrl?.(publicUrl);
|
|
4035
|
-
if (publicUrl &&
|
|
4367
|
+
if (publicUrl && realtimeVoiceRuntime) webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
|
|
4036
4368
|
const realtimeHandler = webhookServer.getRealtimeHandler();
|
|
4037
4369
|
if (realtimeHandler) manager.streamSessionIssuer = (request) => realtimeHandler.issueStreamSession(request);
|
|
4038
4370
|
if (provider.name === "twilio" && config.streaming?.enabled) {
|
|
@@ -4056,9 +4388,9 @@ async function createVoiceCallRuntime(params) {
|
|
|
4056
4388
|
log.info("[voice-call] Media stream handler wired to provider");
|
|
4057
4389
|
}
|
|
4058
4390
|
}
|
|
4059
|
-
if (
|
|
4391
|
+
if (realtimeVoiceRuntime) log.info("[voice-call] Realtime voice enabled");
|
|
4060
4392
|
await manager.initialize(provider, webhookUrl);
|
|
4061
|
-
const stop =
|
|
4393
|
+
const stop = () => lifecycle.stop();
|
|
4062
4394
|
log.info("[voice-call] Runtime initialized");
|
|
4063
4395
|
log.info(`[voice-call] Webhook URL: ${webhookUrl}`);
|
|
4064
4396
|
if (publicUrl && publicUrl !== webhookUrl) log.info(`[voice-call] Public URL: ${publicUrl}`);
|
|
@@ -4077,4 +4409,4 @@ async function createVoiceCallRuntime(params) {
|
|
|
4077
4409
|
}
|
|
4078
4410
|
}
|
|
4079
4411
|
//#endregion
|
|
4080
|
-
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 };
|