@openclaw/voice-call 2026.8.1-beta.2 → 2026.8.1
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 +10 -5
- package/dist/{guarded-json-api-DEDwgf4J.js → guarded-json-api-CQXCe4aR.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-Ct5BT5LQ.js} +2 -2
- package/dist/{realtime-handler-uXR0QgVa.js → realtime-handler-CSK1A1Gg.js} +270 -144
- package/dist/{response-generator-D-hkzo5Y.js → response-generator-xbT37lYx.js} +59 -12
- package/dist/{runtime-entry-F13eJ4pp.js → runtime-entry-q02qkX8F.js} +435 -231
- package/dist/runtime-entry.js +1 -1
- package/dist/{store-path-51_oC_xn.js → store-path-Cf6k3F2U.js} +3 -10
- package/dist/{telnyx-sIS_WgnY.js → telnyx-C9Yizvox.js} +12 -8
- package/dist/{twilio-CbeVrVAl.js → twilio-BLjVv246.js} +40 -27
- package/node_modules/typebox/build/format/date.d.mts +1 -1
- package/node_modules/typebox/build/format/date.mjs +1 -1
- package/node_modules/typebox/build/format/date_time.d.mts +2 -2
- package/node_modules/typebox/build/format/date_time.mjs +3 -3
- package/node_modules/typebox/build/format/email.d.mts +1 -1
- package/node_modules/typebox/build/format/email.mjs +2 -2
- package/node_modules/typebox/build/format/hostname.mjs +2 -10
- package/node_modules/typebox/build/format/idn_email.d.mts +1 -1
- package/node_modules/typebox/build/format/idn_email.mjs +3 -3
- package/node_modules/typebox/build/format/idn_hostname.d.mts +1 -0
- package/node_modules/typebox/build/format/idn_hostname.mjs +3 -11
- package/node_modules/typebox/build/format/idna/format/bidi.d.mts +3 -0
- package/node_modules/typebox/build/format/idna/format/bidi.mjs +98 -0
- package/node_modules/typebox/build/format/idna/format/puny.d.mts +3 -0
- package/node_modules/typebox/build/format/idna/format/puny.mjs +144 -0
- package/node_modules/typebox/build/format/idna/hostname.d.mts +1 -0
- package/node_modules/typebox/build/format/idna/hostname.mjs +16 -0
- package/node_modules/typebox/build/format/idna/idn-hostname.d.mts +1 -0
- package/node_modules/typebox/build/format/idna/idn-hostname.mjs +23 -0
- package/node_modules/typebox/build/format/idna/index.d.mts +2 -0
- package/node_modules/typebox/build/format/idna/index.mjs +2 -0
- package/node_modules/typebox/build/format/idna/label/ascii.d.mts +1 -0
- package/node_modules/typebox/build/format/idna/label/ascii.mjs +9 -0
- package/node_modules/typebox/build/format/idna/label/puny.d.mts +1 -0
- package/node_modules/typebox/build/format/idna/label/puny.mjs +28 -0
- package/node_modules/typebox/build/format/idna/label/unicode.d.mts +1 -0
- package/node_modules/typebox/build/format/idna/label/unicode.mjs +106 -0
- package/node_modules/typebox/build/format/idna/pattern/pattern.d.mts +30 -0
- package/node_modules/typebox/build/format/idna/pattern/pattern.mjs +70 -0
- package/node_modules/typebox/build/format/ipv4.d.mts +0 -1
- package/node_modules/typebox/build/format/ipv4.mjs +2 -32
- package/node_modules/typebox/build/format/ipv6.mjs +2 -61
- package/node_modules/typebox/build/format/iri.d.mts +2 -2
- package/node_modules/typebox/build/format/iri.mjs +26 -8
- package/node_modules/typebox/build/format/iri_reference.d.mts +2 -2
- package/node_modules/typebox/build/format/iri_reference.mjs +9 -55
- package/node_modules/typebox/build/format/regex.d.mts +1 -2
- package/node_modules/typebox/build/format/regex.mjs +2 -6
- package/node_modules/typebox/build/format/time.d.mts +2 -2
- package/node_modules/typebox/build/format/time.mjs +19 -14
- package/node_modules/typebox/build/format/uri.d.mts +1 -1
- package/node_modules/typebox/build/format/uri.mjs +3 -130
- package/node_modules/typebox/build/format/uri_reference.d.mts +1 -1
- package/node_modules/typebox/build/format/uri_reference.mjs +2 -3
- package/node_modules/typebox/build/format/uri_template.d.mts +2 -3
- package/node_modules/typebox/build/format/uri_template.mjs +4 -5
- package/node_modules/typebox/build/format/url.d.mts +3 -3
- package/node_modules/typebox/build/format/url.mjs +4 -5
- package/node_modules/typebox/build/guard/emit.d.mts +3 -1
- package/node_modules/typebox/build/guard/emit.mjs +12 -5
- package/node_modules/typebox/build/guard/guard.d.mts +8 -2
- package/node_modules/typebox/build/guard/guard.mjs +25 -4
- package/node_modules/typebox/build/guard/string.d.mts +2 -2
- package/node_modules/typebox/build/guard/string.mjs +7 -7
- package/node_modules/typebox/build/schema/engine/_context.mjs +3 -3
- package/node_modules/typebox/build/schema/engine/_functions.mjs +1 -2
- package/node_modules/typebox/build/schema/engine/_regexp.d.mts +2 -0
- package/node_modules/typebox/build/schema/engine/_regexp.mjs +4 -0
- package/node_modules/typebox/build/schema/engine/_stack.mjs +12 -13
- package/node_modules/typebox/build/schema/engine/additionalItems.mjs +17 -7
- package/node_modules/typebox/build/schema/engine/additionalProperties.mjs +4 -3
- package/node_modules/typebox/build/schema/engine/contains.mjs +18 -6
- package/node_modules/typebox/build/schema/engine/enum.mjs +1 -1
- package/node_modules/typebox/build/schema/engine/items.mjs +26 -6
- package/node_modules/typebox/build/schema/engine/maxContains.mjs +3 -3
- package/node_modules/typebox/build/schema/engine/minContains.mjs +14 -4
- package/node_modules/typebox/build/schema/engine/not.mjs +4 -2
- package/node_modules/typebox/build/schema/engine/oneOf.mjs +7 -6
- package/node_modules/typebox/build/schema/engine/pattern.mjs +3 -2
- package/node_modules/typebox/build/schema/engine/patternProperties.mjs +4 -3
- package/node_modules/typebox/build/schema/engine/ref.mjs +1 -7
- package/node_modules/typebox/build/schema/engine/schema.mjs +13 -4
- package/node_modules/typebox/build/schema/engine/type.mjs +1 -1
- package/node_modules/typebox/build/schema/resolve/resolve.d.mts +4 -1
- package/node_modules/typebox/build/schema/resolve/resolve.mjs +202 -1
- package/node_modules/typebox/build/schema/static/propertyNames.d.mts +5 -0
- package/node_modules/typebox/build/schema/static/propertyNames.mjs +3 -0
- package/node_modules/typebox/build/schema/static/schema.d.mts +3 -0
- package/node_modules/typebox/build/schema/types/index.d.mts +0 -2
- package/node_modules/typebox/build/schema/types/index.mjs +0 -2
- package/node_modules/typebox/build/system/memory/clone.mjs +14 -11
- package/node_modules/typebox/build/system/memory/discard.mjs +6 -5
- package/node_modules/typebox/build/system/settings/settings.d.mts +9 -0
- package/node_modules/typebox/build/system/settings/settings.mjs +2 -0
- package/node_modules/typebox/build/type/engine/call/distribute_arguments.d.mts +6 -1
- package/node_modules/typebox/build/type/engine/call/distribute_arguments.mjs +16 -3
- package/node_modules/typebox/build/type/engine/call/instantiate.mjs +34 -4
- package/node_modules/typebox/build/type/engine/evaluate/broaden.d.mts +5 -5
- package/node_modules/typebox/build/type/engine/evaluate/broaden.mjs +20 -26
- package/node_modules/typebox/build/type/engine/evaluate/compare.d.mts +6 -10
- package/node_modules/typebox/build/type/engine/evaluate/compare.mjs +9 -13
- package/node_modules/typebox/build/type/engine/evaluate/composite.d.mts +7 -4
- package/node_modules/typebox/build/type/engine/evaluate/composite.mjs +12 -19
- package/node_modules/typebox/build/type/engine/evaluate/distribute.d.mts +4 -20
- package/node_modules/typebox/build/type/engine/evaluate/distribute.mjs +8 -20
- package/node_modules/typebox/build/type/engine/evaluate/evaluate.d.mts +4 -4
- package/node_modules/typebox/build/type/engine/evaluate/evaluate.mjs +11 -8
- package/node_modules/typebox/build/type/engine/evaluate/flatten.d.mts +1 -1
- package/node_modules/typebox/build/type/engine/evaluate/flatten.mjs +3 -4
- package/node_modules/typebox/build/type/engine/evaluate/narrow.d.mts +17 -2
- package/node_modules/typebox/build/type/engine/evaluate/narrow.mjs +26 -6
- package/node_modules/typebox/build/type/engine/exclude/operation.mjs +3 -4
- package/node_modules/typebox/build/type/engine/extract/operation.d.mts +1 -1
- package/node_modules/typebox/build/type/engine/extract/operation.mjs +3 -4
- package/node_modules/typebox/build/type/engine/priority/priority.d.mts +2 -2
- package/node_modules/typebox/build/type/engine/priority/priority.mjs +3 -4
- package/node_modules/typebox/build/type/script/mapping.d.mts +32 -29
- package/node_modules/typebox/build/type/script/mapping.mjs +18 -25
- package/node_modules/typebox/build/type/script/parser.d.mts +19 -25
- package/node_modules/typebox/build/type/script/parser.mjs +27 -23
- package/node_modules/typebox/build/value/errors/errors.d.mts +2 -2
- package/node_modules/typebox/build/value/errors/errors.mjs +1 -1
- package/node_modules/typebox/package.json +27 -27
- package/node_modules/typebox/readme.md +79 -82
- package/node_modules/ws/lib/permessage-deflate.js +3 -1
- package/node_modules/ws/package.json +1 -1
- package/openclaw.plugin.json +20 -2
- package/package.json +6 -6
- package/node_modules/typebox/build/format/_idna.d.mts +0 -2
- package/node_modules/typebox/build/format/_idna.mjs +0 -246
- package/node_modules/typebox/build/format/_puny.d.mts +0 -1
- package/node_modules/typebox/build/format/_puny.mjs +0 -77
- package/node_modules/typebox/build/schema/resolve/ref.d.mts +0 -3
- package/node_modules/typebox/build/schema/resolve/ref.mjs +0 -146
- package/node_modules/typebox/build/schema/types/contentEncoding.d.mts +0 -9
- package/node_modules/typebox/build/schema/types/contentEncoding.mjs +0 -13
- package/node_modules/typebox/build/schema/types/contentMediaType.d.mts +0 -9
- package/node_modules/typebox/build/schema/types/contentMediaType.mjs +0 -13
|
@@ -1,13 +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 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-
|
|
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-Cf6k3F2U.js";
|
|
5
5
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
6
6
|
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}`);
|
|
@@ -1570,7 +1646,7 @@ function resolveUserPath(input) {
|
|
|
1570
1646
|
const trimmed = input.trim();
|
|
1571
1647
|
if (!trimmed) return trimmed;
|
|
1572
1648
|
if (trimmed.startsWith("~")) {
|
|
1573
|
-
const expanded = trimmed.replace(/^~(?=$|[\\/])/, os.homedir());
|
|
1649
|
+
const expanded = trimmed.replace(/^~(?=$|[\\/])/, () => os.homedir());
|
|
1574
1650
|
return path.resolve(expanded);
|
|
1575
1651
|
}
|
|
1576
1652
|
return path.resolve(trimmed);
|
|
@@ -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,57 +2172,74 @@ 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
|
|
2110
2241
|
const NGROK_LOG_BUFFER_MAX_CHARS = 16384;
|
|
2111
2242
|
const NGROK_ERROR_MARKER = "ERR_NGROK";
|
|
2112
|
-
const TUNNEL_COMMAND_OUTPUT_MAX_BYTES = 16384;
|
|
2113
2243
|
const NGROK_STOP_GRACE_MS = 2e3;
|
|
2114
2244
|
const NGROK_FORCE_KILL_WAIT_MS = 1e3;
|
|
2115
2245
|
async function terminateNgrokProcess(proc, isClosed) {
|
|
@@ -2149,11 +2279,6 @@ function listenForChildStreamErrors(proc, onError) {
|
|
|
2149
2279
|
* // Later: await tunnel.stop();
|
|
2150
2280
|
*/
|
|
2151
2281
|
async function startNgrokTunnel(config) {
|
|
2152
|
-
if (config.authToken) await runNgrokCommand([
|
|
2153
|
-
"config",
|
|
2154
|
-
"add-authtoken",
|
|
2155
|
-
config.authToken
|
|
2156
|
-
]);
|
|
2157
2282
|
const args = [
|
|
2158
2283
|
"http",
|
|
2159
2284
|
String(config.port),
|
|
@@ -2164,11 +2289,17 @@ async function startNgrokTunnel(config) {
|
|
|
2164
2289
|
];
|
|
2165
2290
|
if (config.domain) args.push("--domain", config.domain);
|
|
2166
2291
|
return new Promise((resolve, reject) => {
|
|
2167
|
-
const proc = spawn("ngrok", args, {
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2292
|
+
const proc = spawn("ngrok", args, {
|
|
2293
|
+
stdio: [
|
|
2294
|
+
"ignore",
|
|
2295
|
+
"pipe",
|
|
2296
|
+
"pipe"
|
|
2297
|
+
],
|
|
2298
|
+
...config.authToken ? { env: {
|
|
2299
|
+
...process.env,
|
|
2300
|
+
NGROK_AUTHTOKEN: config.authToken
|
|
2301
|
+
} } : {}
|
|
2302
|
+
});
|
|
2172
2303
|
let startupSettled = false;
|
|
2173
2304
|
let childClosed = false;
|
|
2174
2305
|
let publicUrl = null;
|
|
@@ -2241,86 +2372,40 @@ async function startNgrokTunnel(config) {
|
|
|
2241
2372
|
});
|
|
2242
2373
|
}
|
|
2243
2374
|
/**
|
|
2244
|
-
* Run an ngrok command and wait for completion.
|
|
2245
|
-
*/
|
|
2246
|
-
async function runNgrokCommand(args) {
|
|
2247
|
-
const result = await runCommandWithTimeout(["ngrok", ...args], {
|
|
2248
|
-
killProcessTree: true,
|
|
2249
|
-
maxOutputBytes: TUNNEL_COMMAND_OUTPUT_MAX_BYTES,
|
|
2250
|
-
outputCapture: "tail",
|
|
2251
|
-
timeoutMs: 3e4
|
|
2252
|
-
});
|
|
2253
|
-
if (result.termination === "timeout") throw new Error("ngrok command timed out");
|
|
2254
|
-
if (result.code === 0) return result.stdout;
|
|
2255
|
-
const output = result.stderr ? {
|
|
2256
|
-
text: result.stderr,
|
|
2257
|
-
truncated: Boolean(result.stderrTruncatedBytes)
|
|
2258
|
-
} : {
|
|
2259
|
-
text: result.stdout,
|
|
2260
|
-
truncated: Boolean(result.stdoutTruncatedBytes)
|
|
2261
|
-
};
|
|
2262
|
-
throw new Error(`ngrok command failed: ${formatBoundedChildOutput(output)}`);
|
|
2263
|
-
}
|
|
2264
|
-
/**
|
|
2265
2375
|
* Start a Tailscale serve/funnel tunnel.
|
|
2266
2376
|
*/
|
|
2267
2377
|
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
2378
|
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)
|
|
2379
|
+
const routes = [{
|
|
2380
|
+
publicPath: path,
|
|
2381
|
+
localPath: path
|
|
2382
|
+
}, ...config.streamPaths ?? []].map(({ publicPath, localPath }) => {
|
|
2383
|
+
const normalizedPublicPath = publicPath.startsWith("/") ? publicPath : `/${publicPath}`;
|
|
2384
|
+
const normalizedLocalPath = localPath.startsWith("/") ? localPath : `/${localPath}`;
|
|
2385
|
+
return {
|
|
2386
|
+
path: normalizedPublicPath,
|
|
2387
|
+
localUrl: `http://127.0.0.1:${config.port}${normalizedLocalPath}`
|
|
2294
2388
|
};
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2389
|
+
});
|
|
2390
|
+
const publicUrl = await setupTailscaleExposureRoutes({
|
|
2391
|
+
mode: config.mode,
|
|
2392
|
+
port: config.tailscalePort,
|
|
2393
|
+
routes
|
|
2394
|
+
});
|
|
2395
|
+
if (!publicUrl) throw new Error(`Tailscale ${config.mode} failed`);
|
|
2300
2396
|
return {
|
|
2301
2397
|
publicUrl,
|
|
2302
2398
|
provider: `tailscale-${config.mode}`,
|
|
2303
2399
|
stop: async () => {
|
|
2304
|
-
|
|
2400
|
+
for (const route of routes) await cleanupTailscaleExposureRoute({
|
|
2401
|
+
mode: config.mode,
|
|
2402
|
+
port: config.tailscalePort,
|
|
2403
|
+
path: route.path
|
|
2404
|
+
});
|
|
2305
2405
|
}
|
|
2306
2406
|
};
|
|
2307
2407
|
}
|
|
2308
2408
|
/**
|
|
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
2409
|
* Start a tunnel based on configuration.
|
|
2325
2410
|
*/
|
|
2326
2411
|
async function startTunnel(config) {
|
|
@@ -2334,12 +2419,16 @@ async function startTunnel(config) {
|
|
|
2334
2419
|
case "tailscale-serve": return startTailscaleTunnel({
|
|
2335
2420
|
mode: "serve",
|
|
2336
2421
|
port: config.port,
|
|
2337
|
-
|
|
2422
|
+
tailscalePort: config.tailscalePort ?? 443,
|
|
2423
|
+
path: config.path,
|
|
2424
|
+
streamPaths: config.streamPaths
|
|
2338
2425
|
});
|
|
2339
2426
|
case "tailscale-funnel": return startTailscaleTunnel({
|
|
2340
2427
|
mode: "funnel",
|
|
2341
2428
|
port: config.port,
|
|
2342
|
-
|
|
2429
|
+
tailscalePort: config.tailscalePort ?? 443,
|
|
2430
|
+
path: config.path,
|
|
2431
|
+
streamPaths: config.streamPaths
|
|
2343
2432
|
});
|
|
2344
2433
|
default: return null;
|
|
2345
2434
|
}
|
|
@@ -2420,6 +2509,8 @@ const DEFAULT_MAX_CONNECTIONS = 128;
|
|
|
2420
2509
|
const MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
|
|
2421
2510
|
const MAX_WS_BUFFERED_BYTES = 1024 * 1024;
|
|
2422
2511
|
const MAX_PENDING_TTS_OPERATIONS_PER_STREAM = 8;
|
|
2512
|
+
const MAX_IGNORED_PLAYBACK_MARKS_PER_STREAM = 64;
|
|
2513
|
+
const PLAYBACK_MARK_TIMEOUT_GRACE_MS = 2e3;
|
|
2423
2514
|
const CLOSE_REASON_LOG_MAX_CHARS = 120;
|
|
2424
2515
|
function sanitizeLogText(value, maxChars) {
|
|
2425
2516
|
const sanitized = value.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
|
|
@@ -2454,6 +2545,8 @@ var MediaStreamHandler = class {
|
|
|
2454
2545
|
this.ttsQueues = /* @__PURE__ */ new Map();
|
|
2455
2546
|
this.ttsPlaying = /* @__PURE__ */ new Map();
|
|
2456
2547
|
this.ttsActiveControllers = /* @__PURE__ */ new Map();
|
|
2548
|
+
this.pendingPlaybackMarks = /* @__PURE__ */ new Map();
|
|
2549
|
+
this.ignoredPlaybackMarks = /* @__PURE__ */ new Map();
|
|
2457
2550
|
this.config = config;
|
|
2458
2551
|
this.preStartTimeoutMs = resolveTimerTimeoutMs(config.preStartTimeoutMs, DEFAULT_PRE_START_TIMEOUT_MS);
|
|
2459
2552
|
this.maxPendingConnections = config.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS;
|
|
@@ -2577,8 +2670,10 @@ var MediaStreamHandler = class {
|
|
|
2577
2670
|
session = null;
|
|
2578
2671
|
}
|
|
2579
2672
|
break;
|
|
2580
|
-
case "
|
|
2581
|
-
|
|
2673
|
+
case "mark":
|
|
2674
|
+
if (session && message.mark?.name) this.acknowledgePlaybackMark(session.streamSid, message.mark.name);
|
|
2675
|
+
break;
|
|
2676
|
+
case "clear": break;
|
|
2582
2677
|
}
|
|
2583
2678
|
} catch (error) {
|
|
2584
2679
|
console.error("[MediaStream] Error processing message:", error);
|
|
@@ -2904,10 +2999,44 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2904
2999
|
mark: { name }
|
|
2905
3000
|
});
|
|
2906
3001
|
}
|
|
3002
|
+
/** Send a completion mark and wait until Twilio reports that buffered playback reached it. */
|
|
3003
|
+
async sendMarkAndWait(streamSid, name, audioDurationMs, signal) {
|
|
3004
|
+
signal.throwIfAborted();
|
|
3005
|
+
const marks = this.getPendingPlaybackMarks(streamSid);
|
|
3006
|
+
if (marks.has(name)) throw new Error(`Telephony playback mark is already pending: ${name}`);
|
|
3007
|
+
this.ignoredPlaybackMarks.get(streamSid)?.delete(name);
|
|
3008
|
+
let pending;
|
|
3009
|
+
const acknowledgement = new Promise((resolve, reject) => {
|
|
3010
|
+
const timeout = setTimeout(() => {
|
|
3011
|
+
console.warn(`[MediaStream] Playback mark timed out; continuing stream=${streamSid}`);
|
|
3012
|
+
pending.settle();
|
|
3013
|
+
}, Math.max(1, audioDurationMs + PLAYBACK_MARK_TIMEOUT_GRACE_MS));
|
|
3014
|
+
timeout.unref?.();
|
|
3015
|
+
const onAbort = () => {
|
|
3016
|
+
const reason = signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("Telephony playback mark wait aborted");
|
|
3017
|
+
pending.settle(reason, true);
|
|
3018
|
+
};
|
|
3019
|
+
pending = { settle: (error, ignoreLateAck = false) => {
|
|
3020
|
+
if (marks.get(name) !== pending) return;
|
|
3021
|
+
clearTimeout(timeout);
|
|
3022
|
+
signal.removeEventListener("abort", onAbort);
|
|
3023
|
+
marks.delete(name);
|
|
3024
|
+
if (marks.size === 0) this.pendingPlaybackMarks.delete(streamSid);
|
|
3025
|
+
if (ignoreLateAck) this.ignorePlaybackMark(streamSid, name);
|
|
3026
|
+
if (error) reject(error);
|
|
3027
|
+
else resolve();
|
|
3028
|
+
} };
|
|
3029
|
+
marks.set(name, pending);
|
|
3030
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3031
|
+
});
|
|
3032
|
+
if (!this.sendMark(streamSid, name).sent) pending.settle(/* @__PURE__ */ new Error("Telephony stream playback failed: completion mark not delivered"));
|
|
3033
|
+
return acknowledgement;
|
|
3034
|
+
}
|
|
2907
3035
|
/**
|
|
2908
3036
|
* Clear audio buffer (interrupt playback).
|
|
2909
3037
|
*/
|
|
2910
3038
|
clearAudio(streamSid) {
|
|
3039
|
+
this.invalidatePlaybackMarks(streamSid);
|
|
2911
3040
|
return this.sendToStream(streamSid, {
|
|
2912
3041
|
event: "clear",
|
|
2913
3042
|
streamSid
|
|
@@ -2960,6 +3089,36 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2960
3089
|
this.ttsQueues.set(streamSid, queue);
|
|
2961
3090
|
return queue;
|
|
2962
3091
|
}
|
|
3092
|
+
getPendingPlaybackMarks(streamSid) {
|
|
3093
|
+
const existing = this.pendingPlaybackMarks.get(streamSid);
|
|
3094
|
+
if (existing) return existing;
|
|
3095
|
+
const marks = /* @__PURE__ */ new Map();
|
|
3096
|
+
this.pendingPlaybackMarks.set(streamSid, marks);
|
|
3097
|
+
return marks;
|
|
3098
|
+
}
|
|
3099
|
+
acknowledgePlaybackMark(streamSid, name) {
|
|
3100
|
+
const ignored = this.ignoredPlaybackMarks.get(streamSid);
|
|
3101
|
+
if (ignored?.delete(name)) {
|
|
3102
|
+
if (ignored.size === 0) this.ignoredPlaybackMarks.delete(streamSid);
|
|
3103
|
+
return;
|
|
3104
|
+
}
|
|
3105
|
+
this.pendingPlaybackMarks.get(streamSid)?.get(name)?.settle();
|
|
3106
|
+
}
|
|
3107
|
+
invalidatePlaybackMarks(streamSid) {
|
|
3108
|
+
const marks = this.pendingPlaybackMarks.get(streamSid);
|
|
3109
|
+
if (!marks) return;
|
|
3110
|
+
for (const pending of marks.values()) pending.settle(/* @__PURE__ */ new Error("Telephony playback cleared before completion"), true);
|
|
3111
|
+
}
|
|
3112
|
+
ignorePlaybackMark(streamSid, name) {
|
|
3113
|
+
const ignored = this.ignoredPlaybackMarks.get(streamSid) ?? /* @__PURE__ */ new Set();
|
|
3114
|
+
ignored.add(name);
|
|
3115
|
+
while (ignored.size > MAX_IGNORED_PLAYBACK_MARKS_PER_STREAM) {
|
|
3116
|
+
const oldest = ignored.values().next().value;
|
|
3117
|
+
if (oldest === void 0) break;
|
|
3118
|
+
ignored.delete(oldest);
|
|
3119
|
+
}
|
|
3120
|
+
this.ignoredPlaybackMarks.set(streamSid, ignored);
|
|
3121
|
+
}
|
|
2963
3122
|
/**
|
|
2964
3123
|
* Process the TTS queue for a stream.
|
|
2965
3124
|
* Uses iterative approach to avoid stack accumulation from recursion.
|
|
@@ -3055,6 +3214,8 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
3055
3214
|
this.ttsActiveControllers.delete(streamSid);
|
|
3056
3215
|
this.ttsPlaying.delete(streamSid);
|
|
3057
3216
|
this.ttsQueues.delete(streamSid);
|
|
3217
|
+
this.invalidatePlaybackMarks(streamSid);
|
|
3218
|
+
this.ignoredPlaybackMarks.delete(streamSid);
|
|
3058
3219
|
}
|
|
3059
3220
|
resolveQueuedTtsEntries(queue) {
|
|
3060
3221
|
const pending = queue.splice(0);
|
|
@@ -3121,7 +3282,9 @@ function startStaleCallReaper(params) {
|
|
|
3121
3282
|
if (age > maxAgeMs && !callsBeingReaped.has(call.callId)) {
|
|
3122
3283
|
callsBeingReaped.add(call.callId);
|
|
3123
3284
|
console.log(`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1e3)}s, state: ${call.state})`);
|
|
3124
|
-
params.manager.endCall(call.callId).
|
|
3285
|
+
params.manager.endCall(call.callId).then((result) => {
|
|
3286
|
+
if (!result.success) console.warn(`[voice-call] Reaper failed to end call ${call.callId}: ${result.error ?? "unknown error"}`);
|
|
3287
|
+
}).catch((err) => {
|
|
3125
3288
|
console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
|
|
3126
3289
|
}).finally(() => {
|
|
3127
3290
|
callsBeingReaped.delete(call.callId);
|
|
@@ -3134,13 +3297,51 @@ function startStaleCallReaper(params) {
|
|
|
3134
3297
|
};
|
|
3135
3298
|
}
|
|
3136
3299
|
//#endregion
|
|
3300
|
+
//#region extensions/voice-call/src/webhook/stream-disconnect-grace.ts
|
|
3301
|
+
const STREAM_DISCONNECT_GRACE_MS = 2e3;
|
|
3302
|
+
/** Track the current carrier stream and expire disconnected calls after reconnect grace. */
|
|
3303
|
+
var StreamDisconnectGrace = class {
|
|
3304
|
+
constructor(onGraceExpired) {
|
|
3305
|
+
this.onGraceExpired = onGraceExpired;
|
|
3306
|
+
this.streams = /* @__PURE__ */ new Map();
|
|
3307
|
+
}
|
|
3308
|
+
connect(providerCallId, streamId) {
|
|
3309
|
+
const current = this.streams.get(providerCallId);
|
|
3310
|
+
if (current?.disconnectTimer) clearTimeout(current.disconnectTimer);
|
|
3311
|
+
this.streams.set(providerCallId, { streamId });
|
|
3312
|
+
}
|
|
3313
|
+
disconnect(providerCallId, streamId) {
|
|
3314
|
+
const current = this.streams.get(providerCallId);
|
|
3315
|
+
if (!current || current.streamId !== streamId || current.disconnectTimer) return;
|
|
3316
|
+
const disconnectTimer = setTimeout(() => {
|
|
3317
|
+
if (this.streams.get(providerCallId)?.disconnectTimer !== disconnectTimer) return;
|
|
3318
|
+
this.streams.delete(providerCallId);
|
|
3319
|
+
this.onGraceExpired({
|
|
3320
|
+
providerCallId,
|
|
3321
|
+
streamId
|
|
3322
|
+
});
|
|
3323
|
+
}, STREAM_DISCONNECT_GRACE_MS);
|
|
3324
|
+
disconnectTimer.unref?.();
|
|
3325
|
+
current.disconnectTimer = disconnectTimer;
|
|
3326
|
+
}
|
|
3327
|
+
retire(providerCallId, streamId) {
|
|
3328
|
+
const current = this.streams.get(providerCallId);
|
|
3329
|
+
if (!current || current.streamId !== streamId) return;
|
|
3330
|
+
if (current.disconnectTimer) clearTimeout(current.disconnectTimer);
|
|
3331
|
+
this.streams.delete(providerCallId);
|
|
3332
|
+
}
|
|
3333
|
+
close() {
|
|
3334
|
+
for (const state of this.streams.values()) if (state.disconnectTimer) clearTimeout(state.disconnectTimer);
|
|
3335
|
+
this.streams.clear();
|
|
3336
|
+
}
|
|
3337
|
+
};
|
|
3338
|
+
//#endregion
|
|
3137
3339
|
//#region extensions/voice-call/src/webhook.ts
|
|
3138
3340
|
const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
|
|
3139
3341
|
const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
|
|
3140
3342
|
const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
|
|
3141
|
-
const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2e3;
|
|
3142
3343
|
const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
|
|
3143
|
-
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-
|
|
3344
|
+
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-xbT37lYx.js"));
|
|
3144
3345
|
function appendRecentTalkEventMetadata(call, event) {
|
|
3145
3346
|
const metadata = call.metadata ?? {};
|
|
3146
3347
|
const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
|
|
@@ -3220,7 +3421,6 @@ var VoiceCallWebhookServer = class {
|
|
|
3220
3421
|
this.stopStaleCallReaper = null;
|
|
3221
3422
|
this.webhookInFlightLimiter = createWebhookInFlightLimiter();
|
|
3222
3423
|
this.mediaStreamHandler = null;
|
|
3223
|
-
this.pendingDisconnectHangups = /* @__PURE__ */ new Map();
|
|
3224
3424
|
this.realtimeHandler = null;
|
|
3225
3425
|
this.replayResponses = /* @__PURE__ */ new Map();
|
|
3226
3426
|
this.replayResponseCacheCalls = 0;
|
|
@@ -3244,6 +3444,16 @@ var VoiceCallWebhookServer = class {
|
|
|
3244
3444
|
error: (msg) => rawLogger.error(`[voice-call] ${msg}`),
|
|
3245
3445
|
debug: rawDebug ? (msg) => rawDebug(`[voice-call] ${msg}`) : void 0
|
|
3246
3446
|
};
|
|
3447
|
+
this.streamDisconnectGrace = new StreamDisconnectGrace(({ providerCallId }) => {
|
|
3448
|
+
const call = this.manager.getCallByProviderCallId(providerCallId);
|
|
3449
|
+
if (!call) return;
|
|
3450
|
+
this.logger.info(`Call finalization requested reason=stream-disconnect-grace-expired callId=${call.callId} providerCallId=${providerCallId}`);
|
|
3451
|
+
this.manager.endCall(call.callId).then((result) => {
|
|
3452
|
+
if (!result.success) this.logger.warn(`Failed to auto-end call ${call.callId}: ${result.error ?? "unknown error"}`);
|
|
3453
|
+
}).catch((err) => {
|
|
3454
|
+
this.logger.warn(`Failed to auto-end call ${call.callId}: ${String(err)}`);
|
|
3455
|
+
});
|
|
3456
|
+
});
|
|
3247
3457
|
}
|
|
3248
3458
|
/**
|
|
3249
3459
|
* Get the media stream handler (for wiring to provider).
|
|
@@ -3264,11 +3474,8 @@ var VoiceCallWebhookServer = class {
|
|
|
3264
3474
|
setRealtimeHandler(handler) {
|
|
3265
3475
|
this.realtimeHandler = handler;
|
|
3266
3476
|
}
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
if (!existing) return;
|
|
3270
|
-
clearTimeout(existing);
|
|
3271
|
-
this.pendingDisconnectHangups.delete(providerCallId);
|
|
3477
|
+
getStreamDisconnectLifecycle() {
|
|
3478
|
+
return this.streamDisconnectGrace;
|
|
3272
3479
|
}
|
|
3273
3480
|
resolveMediaStreamClientIp(request) {
|
|
3274
3481
|
const remoteIp = request.socket.remoteAddress ?? void 0;
|
|
@@ -3382,7 +3589,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3382
3589
|
},
|
|
3383
3590
|
onConnect: (callId, streamSid) => {
|
|
3384
3591
|
this.logger.info(`Media stream connected: ${callId} -> ${streamSid}`);
|
|
3385
|
-
this.
|
|
3592
|
+
this.streamDisconnectGrace.connect(callId, streamSid);
|
|
3386
3593
|
if (this.provider.name === "twilio") this.provider.registerCallStream(callId, streamSid);
|
|
3387
3594
|
},
|
|
3388
3595
|
onTranscriptionReady: (callId) => {
|
|
@@ -3393,21 +3600,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3393
3600
|
onDisconnect: (callId, streamSid) => {
|
|
3394
3601
|
this.logger.info(`Media stream disconnected: ${callId} (${streamSid})`);
|
|
3395
3602
|
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);
|
|
3603
|
+
this.streamDisconnectGrace.disconnect(callId, streamSid);
|
|
3411
3604
|
}
|
|
3412
3605
|
};
|
|
3413
3606
|
this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
|
|
@@ -3485,8 +3678,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3485
3678
|
});
|
|
3486
3679
|
});
|
|
3487
3680
|
this.startPromise = null;
|
|
3488
|
-
|
|
3489
|
-
this.pendingDisconnectHangups.clear();
|
|
3681
|
+
this.streamDisconnectGrace.close();
|
|
3490
3682
|
if (this.stopStaleCallReaper) {
|
|
3491
3683
|
this.stopStaleCallReaper();
|
|
3492
3684
|
this.stopStaleCallReaper = null;
|
|
@@ -3498,8 +3690,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3498
3690
|
this.mediaStreamHandler?.close(serverClosePromise) ?? Promise.resolve(),
|
|
3499
3691
|
this.realtimeHandler?.close(serverClosePromise) ?? Promise.resolve()
|
|
3500
3692
|
]);
|
|
3501
|
-
|
|
3502
|
-
this.pendingDisconnectHangups.clear();
|
|
3693
|
+
this.streamDisconnectGrace.close();
|
|
3503
3694
|
if (this.server === server) this.server = null;
|
|
3504
3695
|
this.listeningUrl = null;
|
|
3505
3696
|
const failure = results.find((result) => result.status === "rejected");
|
|
@@ -3817,6 +4008,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3817
4008
|
callId,
|
|
3818
4009
|
sessionKey: call.sessionKey,
|
|
3819
4010
|
from: call.from,
|
|
4011
|
+
senderIsOwner: call.direction === "inbound" ? false : void 0,
|
|
3820
4012
|
agentId: resolveCallAgentId(call, effectiveConfig),
|
|
3821
4013
|
transcript: call.transcript,
|
|
3822
4014
|
userMessage,
|
|
@@ -3848,12 +4040,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
|
|
|
3848
4040
|
"Do not print secret values or dump environment variables; only check whether required configuration is present.",
|
|
3849
4041
|
"Be accurate, brief, and speakable."
|
|
3850
4042
|
].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-
|
|
4043
|
+
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-C9Yizvox.js"));
|
|
4044
|
+
const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-BLjVv246.js"));
|
|
4045
|
+
const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-Ct5BT5LQ.js"));
|
|
4046
|
+
const loadMockProvider = createLazyRuntimeModule(() => import("./mock-Dv7KxYGt.js"));
|
|
3855
4047
|
const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
|
|
3856
|
-
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-
|
|
4048
|
+
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-CSK1A1Gg.js"));
|
|
3857
4049
|
function resolveVoiceCallConsultSessionKey(call) {
|
|
3858
4050
|
return resolveVoiceCallSessionKey({
|
|
3859
4051
|
config: call.config,
|
|
@@ -3951,17 +4143,9 @@ async function resolveProvider(config) {
|
|
|
3951
4143
|
default: throw new Error(`Unsupported voice-call provider: ${String(config.provider)}`);
|
|
3952
4144
|
}
|
|
3953
4145
|
}
|
|
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
4146
|
function listRealtimeAgentIds(config, coreConfig) {
|
|
3963
4147
|
const agentIds = /* @__PURE__ */ new Set([normalizeAgentId(config.agentId)]);
|
|
3964
|
-
for (const
|
|
4148
|
+
for (const agentId of listAgentIds(coreConfig)) agentIds.add(normalizeAgentId(agentId));
|
|
3965
4149
|
for (const route of Object.values(config.numbers)) if (route.agentId) agentIds.add(normalizeAgentId(route.agentId));
|
|
3966
4150
|
return [...agentIds];
|
|
3967
4151
|
}
|
|
@@ -4024,22 +4208,37 @@ async function createVoiceCallRuntime(params) {
|
|
|
4024
4208
|
const provider = await resolveProvider(config);
|
|
4025
4209
|
if (stateRuntime) setVoiceCallStateRuntime({ state: stateRuntime });
|
|
4026
4210
|
const manager = new CallManager(config, void 0, cfg.session);
|
|
4027
|
-
const
|
|
4028
|
-
config,
|
|
4029
|
-
fullConfig: cfg
|
|
4030
|
-
}) : null;
|
|
4211
|
+
const realtimeVoiceRuntime = config.realtime.enabled ? await loadRealtimeVoiceRuntime() : null;
|
|
4031
4212
|
const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig, fullConfig ?? coreConfig, agentRuntime, log);
|
|
4032
|
-
if (
|
|
4213
|
+
if (realtimeVoiceRuntime) {
|
|
4033
4214
|
const { RealtimeCallHandler } = await loadRealtimeHandler();
|
|
4034
4215
|
const resolveRealtimeInstructions = await createRealtimeInstructionsResolver({
|
|
4035
4216
|
config,
|
|
4036
4217
|
coreConfig: cfg,
|
|
4037
4218
|
agentRuntime
|
|
4038
4219
|
});
|
|
4039
|
-
const
|
|
4220
|
+
const realtimeConfig = {
|
|
4040
4221
|
...config.realtime,
|
|
4041
|
-
tools:
|
|
4042
|
-
}
|
|
4222
|
+
tools: resolveVoiceCallRealtimeTools(config.realtime.toolPolicy, config.realtime.tools)
|
|
4223
|
+
};
|
|
4224
|
+
const resolveCallRegistration = (call) => {
|
|
4225
|
+
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
|
4226
|
+
const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
|
|
4227
|
+
const agentId = resolveCallAgentId(call, effectiveConfig);
|
|
4228
|
+
const resolved = realtimeVoiceRuntime.resolveConfiguredRealtimeVoiceProvider({
|
|
4229
|
+
configuredProviderId: effectiveConfig.realtime.provider,
|
|
4230
|
+
providerConfigs: effectiveConfig.realtime.providers,
|
|
4231
|
+
cfg,
|
|
4232
|
+
agentId
|
|
4233
|
+
});
|
|
4234
|
+
return {
|
|
4235
|
+
agentId,
|
|
4236
|
+
provider: resolved.provider,
|
|
4237
|
+
providerConfig: resolved.providerConfig,
|
|
4238
|
+
instructions: resolveRealtimeInstructions(call)
|
|
4239
|
+
};
|
|
4240
|
+
};
|
|
4241
|
+
const realtimeHandler = new RealtimeCallHandler(realtimeConfig, manager, resolveCallRegistration, config.serve.path, webhookServer.getStreamDisconnectLifecycle(), cfg);
|
|
4043
4242
|
if (config.realtime.toolPolicy !== "none") realtimeHandler.registerToolHandler(REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, async (args, callId, handlerContext) => {
|
|
4044
4243
|
handlerContext.abortSignal?.throwIfAborted();
|
|
4045
4244
|
const call = manager.getCall(callId);
|
|
@@ -4126,7 +4325,12 @@ async function createVoiceCallRuntime(params) {
|
|
|
4126
4325
|
const nextTunnelResult = await startTunnel({
|
|
4127
4326
|
provider: config.tunnel.provider,
|
|
4128
4327
|
port: config.serve.port,
|
|
4328
|
+
tailscalePort: config.tailscale.port,
|
|
4129
4329
|
path: config.serve.path,
|
|
4330
|
+
streamPaths: resolveVoiceCallStreamExposurePaths(config, {
|
|
4331
|
+
publicWebhookPath: config.serve.path,
|
|
4332
|
+
localWebhookPath: config.serve.path
|
|
4333
|
+
}),
|
|
4130
4334
|
ngrokAuthToken: config.tunnel.ngrokAuthToken,
|
|
4131
4335
|
ngrokDomain: config.tunnel.ngrokDomain
|
|
4132
4336
|
});
|
|
@@ -4139,7 +4343,7 @@ async function createVoiceCallRuntime(params) {
|
|
|
4139
4343
|
const webhookUrl = publicUrl ?? localUrl;
|
|
4140
4344
|
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
4345
|
if (publicUrl) provider.setPublicUrl?.(publicUrl);
|
|
4142
|
-
if (publicUrl &&
|
|
4346
|
+
if (publicUrl && realtimeVoiceRuntime) webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
|
|
4143
4347
|
const realtimeHandler = webhookServer.getRealtimeHandler();
|
|
4144
4348
|
if (realtimeHandler) manager.streamSessionIssuer = (request) => realtimeHandler.issueStreamSession(request);
|
|
4145
4349
|
if (provider.name === "twilio" && config.streaming?.enabled) {
|
|
@@ -4163,7 +4367,7 @@ async function createVoiceCallRuntime(params) {
|
|
|
4163
4367
|
log.info("[voice-call] Media stream handler wired to provider");
|
|
4164
4368
|
}
|
|
4165
4369
|
}
|
|
4166
|
-
if (
|
|
4370
|
+
if (realtimeVoiceRuntime) log.info("[voice-call] Realtime voice enabled");
|
|
4167
4371
|
await manager.initialize(provider, webhookUrl);
|
|
4168
4372
|
const stop = () => lifecycle.stop();
|
|
4169
4373
|
log.info("[voice-call] Runtime initialized");
|
|
@@ -4184,4 +4388,4 @@ async function createVoiceCallRuntime(params) {
|
|
|
4184
4388
|
}
|
|
4185
4389
|
}
|
|
4186
4390
|
//#endregion
|
|
4187
|
-
export {
|
|
4391
|
+
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 };
|