@openclaw/voice-call 2026.7.1 → 2026.7.2-beta.2
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 +1 -2
- package/dist/doctor-contract-api.js +34 -12
- package/dist/{guarded-json-api-CLVDU_h1.js → guarded-json-api-BKOgUU1q.js} +32 -41
- package/dist/index.js +12 -28
- package/dist/{plivo-D5wI4-pi.js → plivo-ByAIoPFp.js} +2 -2
- package/dist/{realtime-handler-Bz-MNWtD.js → realtime-handler-DJk-eYyf.js} +31 -20
- package/dist/{response-generator-D3YO-WtN.js → response-generator-Cdj8EuLg.js} +20 -4
- package/dist/{runtime-entry-DwMgbq2p.js → runtime-entry-DSgyqIwE.js} +820 -274
- package/dist/runtime-entry.js +1 -1
- package/dist/setup-api.js +86 -2
- package/dist/{store-DtewuuOb.js → store-path-nYL-yM0S.js} +12 -5
- package/dist/{telnyx-DksS6q0n.js → telnyx-DMbLYwj4.js} +1 -1
- package/dist/{twilio-BYcsdUkj.js → twilio-BTKKlkLT.js} +7 -10
- package/dist/twilio-region-4fkgz3UG.js +25 -0
- package/npm-shrinkwrap.json +3 -13
- package/openclaw.plugin.json +1 -0
- package/package.json +4 -6
- package/dist/config-CDSeuGp3.js +0 -674
- package/dist/config-compat-D8fcjOHp.js +0 -156
- package/node_modules/commander/LICENSE +0 -22
- package/node_modules/commander/Readme.md +0 -1172
- package/node_modules/commander/index.js +0 -21
- package/node_modules/commander/lib/argument.js +0 -147
- package/node_modules/commander/lib/command.js +0 -2790
- package/node_modules/commander/lib/error.js +0 -36
- package/node_modules/commander/lib/help.js +0 -731
- package/node_modules/commander/lib/option.js +0 -377
- package/node_modules/commander/lib/suggestSimilar.js +0 -99
- package/node_modules/commander/package-support.json +0 -19
- package/node_modules/commander/package.json +0 -64
- package/node_modules/commander/typings/index.d.ts +0 -1113
|
@@ -1,27 +1,658 @@
|
|
|
1
|
-
import { isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText } from "./runtime-api.js";
|
|
1
|
+
import { TtsConfigSchema, isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText } from "./runtime-api.js";
|
|
2
2
|
import "./api.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { t as TWILIO_REGIONS } from "./twilio-region-4fkgz3UG.js";
|
|
4
|
+
import { _ as TerminalStates, d as loadActiveCallsFromStore, g as setVoiceCallStateRuntime, l as findCallMatchesInStore, p as persistCallRecord, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore } from "./store-path-nYL-yM0S.js";
|
|
5
5
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
6
6
|
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
|
|
7
7
|
import { MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
|
8
|
-
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
|
8
|
+
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
|
9
9
|
import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
10
|
+
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
|
10
11
|
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
|
11
|
-
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, buildRealtimeVoiceAgentConsultPolicyInstructions, consultRealtimeVoiceAgent, convertPcmToMulaw8k, createTalkSessionController, recordTalkObservabilityEvent, resolveRealtimeVoiceAgentConsultTools, resolveRealtimeVoiceAgentConsultToolsAllow, resolveRealtimeVoiceFastContextConsult } from "openclaw/plugin-sdk/realtime-voice";
|
|
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
|
+
import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime";
|
|
14
|
+
import { buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
|
15
|
+
import { canonicalizeMainSessionAlias } from "openclaw/plugin-sdk/session-store-runtime";
|
|
16
|
+
import { resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core";
|
|
12
17
|
import { WEBHOOK_BODY_READ_DEFAULTS, createWebhookInFlightLimiter, normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
|
18
|
+
import { z } from "zod";
|
|
13
19
|
import fs from "node:fs";
|
|
14
|
-
import os from "node:os";
|
|
15
|
-
import path from "node:path";
|
|
16
20
|
import crypto from "node:crypto";
|
|
21
|
+
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import os from "node:os";
|
|
17
24
|
import { root } from "openclaw/plugin-sdk/security-runtime";
|
|
18
|
-
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
|
25
|
+
import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
|
19
26
|
import { parseTtsDirectives } from "openclaw/plugin-sdk/speech";
|
|
20
27
|
import { spawn } from "node:child_process";
|
|
28
|
+
import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
|
|
21
29
|
import http from "node:http";
|
|
22
30
|
import { URL as URL$1 } from "node:url";
|
|
23
31
|
import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime";
|
|
24
32
|
import { WebSocket, WebSocketServer } from "ws";
|
|
33
|
+
//#region extensions/voice-call/src/realtime-defaults.ts
|
|
34
|
+
/** Baseline instructions that keep realtime calls brief and route deep work to agent consult. */
|
|
35
|
+
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.`;
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region extensions/voice-call/src/config.ts
|
|
38
|
+
/**
|
|
39
|
+
* E.164 phone number format: +[country code][number]
|
|
40
|
+
* Examples use 555 prefix (reserved for fictional numbers)
|
|
41
|
+
*/
|
|
42
|
+
const E164Schema = z.string().regex(/^\+[1-9]\d{1,14}$/, "Expected E.164 format, e.g. +15550001234");
|
|
43
|
+
/**
|
|
44
|
+
* Controls how inbound calls are handled:
|
|
45
|
+
* - "disabled": Block all inbound calls (outbound only)
|
|
46
|
+
* - "allowlist": Only accept calls from numbers in allowFrom
|
|
47
|
+
* - "pairing": Unknown callers can request pairing (future)
|
|
48
|
+
* - "open": Accept all inbound calls (dangerous!)
|
|
49
|
+
*/
|
|
50
|
+
const InboundPolicySchema = z.enum([
|
|
51
|
+
"disabled",
|
|
52
|
+
"allowlist",
|
|
53
|
+
"pairing",
|
|
54
|
+
"open"
|
|
55
|
+
]);
|
|
56
|
+
const SecretInputSchema = buildSecretInputSchema();
|
|
57
|
+
const TelnyxConfigSchema = z.object({
|
|
58
|
+
/** Telnyx API v2 key */
|
|
59
|
+
apiKey: z.string().min(1).optional(),
|
|
60
|
+
/** Telnyx connection ID (from Call Control app) */
|
|
61
|
+
connectionId: z.string().min(1).optional(),
|
|
62
|
+
/** Public key for webhook signature verification */
|
|
63
|
+
publicKey: z.string().min(1).optional()
|
|
64
|
+
}).strict();
|
|
65
|
+
const TwilioConfigSchema = z.object({
|
|
66
|
+
/** Twilio Account SID */
|
|
67
|
+
accountSid: z.string().min(1).optional(),
|
|
68
|
+
/** Twilio Auth Token */
|
|
69
|
+
authToken: SecretInputSchema.optional(),
|
|
70
|
+
/** Twilio processing Region (for example, ie1) */
|
|
71
|
+
region: z.enum(TWILIO_REGIONS).optional()
|
|
72
|
+
}).strict();
|
|
73
|
+
const PlivoConfigSchema = z.object({
|
|
74
|
+
/** Plivo Auth ID (starts with MA/SA) */
|
|
75
|
+
authId: z.string().min(1).optional(),
|
|
76
|
+
/** Plivo Auth Token */
|
|
77
|
+
authToken: z.string().min(1).optional()
|
|
78
|
+
}).strict();
|
|
79
|
+
const VoiceCallNumberRouteConfigSchema = z.object({
|
|
80
|
+
/** Greeting message for inbound calls to this number. */
|
|
81
|
+
inboundGreeting: z.string().optional(),
|
|
82
|
+
/** TTS override for inbound calls to this number. Deep-merges with global voice-call TTS. */
|
|
83
|
+
tts: TtsConfigSchema,
|
|
84
|
+
/** Agent ID to use for voice response generation for this number. */
|
|
85
|
+
agentId: z.string().min(1).optional(),
|
|
86
|
+
/** Optional model override for voice responses for this number. */
|
|
87
|
+
responseModel: z.string().optional(),
|
|
88
|
+
/** System prompt for voice responses for this number. */
|
|
89
|
+
responseSystemPrompt: z.string().optional(),
|
|
90
|
+
/** Timeout for response generation in ms for this number. */
|
|
91
|
+
responseTimeoutMs: z.number().int().positive().optional()
|
|
92
|
+
}).strict();
|
|
93
|
+
const VoiceCallServeConfigSchema = z.object({
|
|
94
|
+
/** Port to listen on */
|
|
95
|
+
port: z.number().int().positive().default(3334),
|
|
96
|
+
/** Bind address */
|
|
97
|
+
bind: z.string().default("127.0.0.1"),
|
|
98
|
+
/** Webhook path */
|
|
99
|
+
path: z.string().min(1).default("/voice/webhook")
|
|
100
|
+
}).strict().default({
|
|
101
|
+
port: 3334,
|
|
102
|
+
bind: "127.0.0.1",
|
|
103
|
+
path: "/voice/webhook"
|
|
104
|
+
});
|
|
105
|
+
const VoiceCallTailscaleConfigSchema = z.object({
|
|
106
|
+
/**
|
|
107
|
+
* Tailscale exposure mode:
|
|
108
|
+
* - "off": No Tailscale exposure
|
|
109
|
+
* - "serve": Tailscale serve (private to tailnet)
|
|
110
|
+
* - "funnel": Tailscale funnel (public HTTPS)
|
|
111
|
+
*/
|
|
112
|
+
mode: z.enum([
|
|
113
|
+
"off",
|
|
114
|
+
"serve",
|
|
115
|
+
"funnel"
|
|
116
|
+
]).default("off"),
|
|
117
|
+
/** Path for Tailscale serve/funnel (should usually match serve.path) */
|
|
118
|
+
path: z.string().min(1).default("/voice/webhook")
|
|
119
|
+
}).strict().default({
|
|
120
|
+
mode: "off",
|
|
121
|
+
path: "/voice/webhook"
|
|
122
|
+
});
|
|
123
|
+
const VoiceCallTunnelConfigSchema = z.object({
|
|
124
|
+
/**
|
|
125
|
+
* Tunnel provider:
|
|
126
|
+
* - "none": No tunnel (use publicUrl if set, or manual setup)
|
|
127
|
+
* - "ngrok": Use ngrok for public HTTPS tunnel
|
|
128
|
+
* - "tailscale-serve": Tailscale serve (private to tailnet)
|
|
129
|
+
* - "tailscale-funnel": Tailscale funnel (public HTTPS)
|
|
130
|
+
*/
|
|
131
|
+
provider: z.enum([
|
|
132
|
+
"none",
|
|
133
|
+
"ngrok",
|
|
134
|
+
"tailscale-serve",
|
|
135
|
+
"tailscale-funnel"
|
|
136
|
+
]).default("none"),
|
|
137
|
+
/** ngrok auth token (optional, enables longer sessions and more features) */
|
|
138
|
+
ngrokAuthToken: z.string().min(1).optional(),
|
|
139
|
+
/** ngrok custom domain (paid feature, e.g., "myapp.ngrok.io") */
|
|
140
|
+
ngrokDomain: z.string().min(1).optional(),
|
|
141
|
+
/**
|
|
142
|
+
* Allow ngrok free tier compatibility mode.
|
|
143
|
+
* When true, forwarded headers may be trusted for loopback requests
|
|
144
|
+
* to reconstruct the public ngrok URL used for signing.
|
|
145
|
+
*
|
|
146
|
+
* IMPORTANT: This does NOT bypass signature verification.
|
|
147
|
+
*/
|
|
148
|
+
allowNgrokFreeTierLoopbackBypass: z.boolean().default(false)
|
|
149
|
+
}).strict().default({
|
|
150
|
+
provider: "none",
|
|
151
|
+
allowNgrokFreeTierLoopbackBypass: false
|
|
152
|
+
});
|
|
153
|
+
const VoiceCallWebhookSecurityConfigSchema = z.object({
|
|
154
|
+
/**
|
|
155
|
+
* Allowed hostnames for webhook URL reconstruction.
|
|
156
|
+
* Only these hosts are accepted from forwarding headers.
|
|
157
|
+
*/
|
|
158
|
+
allowedHosts: z.array(z.string().min(1)).default([]),
|
|
159
|
+
/**
|
|
160
|
+
* Trust X-Forwarded-* headers without a hostname allowlist.
|
|
161
|
+
* WARNING: Only enable if you trust your proxy configuration.
|
|
162
|
+
*/
|
|
163
|
+
trustForwardingHeaders: z.boolean().default(false),
|
|
164
|
+
/**
|
|
165
|
+
* Trusted proxy IP addresses. Forwarded headers are only trusted when
|
|
166
|
+
* the remote IP matches one of these addresses.
|
|
167
|
+
*/
|
|
168
|
+
trustedProxyIPs: z.array(z.string().min(1)).default([])
|
|
169
|
+
}).strict().default({
|
|
170
|
+
allowedHosts: [],
|
|
171
|
+
trustForwardingHeaders: false,
|
|
172
|
+
trustedProxyIPs: []
|
|
173
|
+
});
|
|
174
|
+
/**
|
|
175
|
+
* Call mode determines how outbound calls behave:
|
|
176
|
+
* - "notify": Deliver message and auto-hangup after delay (one-way notification)
|
|
177
|
+
* - "conversation": Stay open for back-and-forth until explicit end or timeout
|
|
178
|
+
*/
|
|
179
|
+
const CallModeSchema = z.enum(["notify", "conversation"]);
|
|
180
|
+
const VoiceCallSessionScopeSchema = z.enum(["per-phone", "per-call"]);
|
|
181
|
+
const OutboundConfigSchema = z.object({
|
|
182
|
+
/** Default call mode for outbound calls */
|
|
183
|
+
defaultMode: CallModeSchema.default("notify"),
|
|
184
|
+
/** Seconds to wait after TTS before auto-hangup in notify mode */
|
|
185
|
+
notifyHangupDelaySec: z.number().int().nonnegative().default(3)
|
|
186
|
+
}).strict().default({
|
|
187
|
+
defaultMode: "notify",
|
|
188
|
+
notifyHangupDelaySec: 3
|
|
189
|
+
});
|
|
190
|
+
const RealtimeToolSchema = z.object({
|
|
191
|
+
type: z.literal("function"),
|
|
192
|
+
name: z.string().min(1),
|
|
193
|
+
description: z.string(),
|
|
194
|
+
parameters: z.object({
|
|
195
|
+
type: z.literal("object"),
|
|
196
|
+
properties: z.record(z.string(), z.unknown()),
|
|
197
|
+
required: z.array(z.string()).optional()
|
|
198
|
+
})
|
|
199
|
+
}).strict();
|
|
200
|
+
const VoiceCallRealtimeProvidersConfigSchema = z.record(z.string(), z.record(z.string(), z.unknown())).default({});
|
|
201
|
+
const VoiceCallRealtimeToolPolicySchema = z.enum(REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES);
|
|
202
|
+
const VoiceCallRealtimeConsultPolicySchema = z.enum([
|
|
203
|
+
"auto",
|
|
204
|
+
"substantive",
|
|
205
|
+
"always"
|
|
206
|
+
]);
|
|
207
|
+
const VoiceCallRealtimeFastContextSourceSchema = z.enum(["memory", "sessions"]);
|
|
208
|
+
const VoiceCallRealtimeFastContextConfigSchema = z.object({
|
|
209
|
+
/** Enable bounded memory/session lookup before the full consult agent. */
|
|
210
|
+
enabled: z.boolean().default(false),
|
|
211
|
+
/** Hard deadline for the fast context lookup. */
|
|
212
|
+
timeoutMs: z.number().int().positive().default(800),
|
|
213
|
+
/** Maximum memory/session hits to inject into the realtime tool result. */
|
|
214
|
+
maxResults: z.number().int().positive().default(3),
|
|
215
|
+
/** Indexed sources used by the fast context lookup. */
|
|
216
|
+
sources: z.array(VoiceCallRealtimeFastContextSourceSchema).min(1).default(["memory", "sessions"]),
|
|
217
|
+
/** Fall back to the full agent consult when fast context has no answer. */
|
|
218
|
+
fallbackToConsult: z.boolean().default(false)
|
|
219
|
+
}).strict().default({
|
|
220
|
+
enabled: false,
|
|
221
|
+
timeoutMs: 800,
|
|
222
|
+
maxResults: 3,
|
|
223
|
+
sources: ["memory", "sessions"],
|
|
224
|
+
fallbackToConsult: false
|
|
225
|
+
});
|
|
226
|
+
const VoiceCallRealtimeAgentContextConfigSchema = z.object({
|
|
227
|
+
/** Inject a compact agent persona/context capsule into realtime voice instructions. */
|
|
228
|
+
enabled: z.boolean().default(false),
|
|
229
|
+
/** Maximum number of characters from the generated capsule to append. */
|
|
230
|
+
maxChars: z.number().int().positive().default(6e3),
|
|
231
|
+
/** Include configured agent identity fields. */
|
|
232
|
+
includeIdentity: z.boolean().default(true),
|
|
233
|
+
/** Include selected workspace files such as SOUL.md and IDENTITY.md. */
|
|
234
|
+
includeWorkspaceFiles: z.boolean().default(true),
|
|
235
|
+
/** Workspace-relative files to include, bounded by maxChars. */
|
|
236
|
+
files: z.array(z.string().min(1)).default([
|
|
237
|
+
"SOUL.md",
|
|
238
|
+
"IDENTITY.md",
|
|
239
|
+
"USER.md"
|
|
240
|
+
])
|
|
241
|
+
}).strict().default({
|
|
242
|
+
enabled: false,
|
|
243
|
+
maxChars: 6e3,
|
|
244
|
+
includeIdentity: true,
|
|
245
|
+
includeWorkspaceFiles: true,
|
|
246
|
+
files: [
|
|
247
|
+
"SOUL.md",
|
|
248
|
+
"IDENTITY.md",
|
|
249
|
+
"USER.md"
|
|
250
|
+
]
|
|
251
|
+
});
|
|
252
|
+
const VoiceCallRealtimeConsultThinkingLevelSchema = z.enum([
|
|
253
|
+
"off",
|
|
254
|
+
"minimal",
|
|
255
|
+
"low",
|
|
256
|
+
"medium",
|
|
257
|
+
"high",
|
|
258
|
+
"xhigh",
|
|
259
|
+
"adaptive",
|
|
260
|
+
"max",
|
|
261
|
+
"ultra"
|
|
262
|
+
]);
|
|
263
|
+
const VoiceCallStreamingProvidersConfigSchema = z.record(z.string(), z.record(z.string(), z.unknown())).default({});
|
|
264
|
+
const VoiceCallRealtimeConfigSchema = z.object({
|
|
265
|
+
/** Enable realtime voice-to-voice mode. */
|
|
266
|
+
enabled: z.boolean().default(false),
|
|
267
|
+
/** Provider id from registered realtime voice providers. */
|
|
268
|
+
provider: z.string().min(1).optional(),
|
|
269
|
+
/** Optional override for the local WebSocket route path. */
|
|
270
|
+
streamPath: z.string().min(1).optional(),
|
|
271
|
+
/** System instructions passed to the realtime provider. */
|
|
272
|
+
instructions: z.string().default(DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS),
|
|
273
|
+
/** Tool policy for the shared OpenClaw agent consult tool. */
|
|
274
|
+
toolPolicy: VoiceCallRealtimeToolPolicySchema.default("safe-read-only"),
|
|
275
|
+
/** Guidance for when the realtime model should call the OpenClaw agent consult tool. */
|
|
276
|
+
consultPolicy: VoiceCallRealtimeConsultPolicySchema.default("auto"),
|
|
277
|
+
/** Optional thinking level override for the regular agent behind realtime consults. */
|
|
278
|
+
consultThinkingLevel: VoiceCallRealtimeConsultThinkingLevelSchema.optional(),
|
|
279
|
+
/** Optional fast mode override for the regular agent behind realtime consults. */
|
|
280
|
+
consultFastMode: z.boolean().optional(),
|
|
281
|
+
/** Tool definitions exposed to the realtime provider. */
|
|
282
|
+
tools: z.array(RealtimeToolSchema).default([]),
|
|
283
|
+
/** Low-latency memory/session context for the consult tool. */
|
|
284
|
+
fastContext: VoiceCallRealtimeFastContextConfigSchema,
|
|
285
|
+
/** Bounded agent persona/context injection for the fast realtime voice path. */
|
|
286
|
+
agentContext: VoiceCallRealtimeAgentContextConfigSchema,
|
|
287
|
+
/** Provider-owned raw config blobs keyed by provider id. */
|
|
288
|
+
providers: VoiceCallRealtimeProvidersConfigSchema
|
|
289
|
+
}).strict().default({
|
|
290
|
+
enabled: false,
|
|
291
|
+
instructions: DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS,
|
|
292
|
+
toolPolicy: "safe-read-only",
|
|
293
|
+
consultPolicy: "auto",
|
|
294
|
+
tools: [],
|
|
295
|
+
fastContext: {
|
|
296
|
+
enabled: false,
|
|
297
|
+
timeoutMs: 800,
|
|
298
|
+
maxResults: 3,
|
|
299
|
+
sources: ["memory", "sessions"],
|
|
300
|
+
fallbackToConsult: false
|
|
301
|
+
},
|
|
302
|
+
agentContext: {
|
|
303
|
+
enabled: false,
|
|
304
|
+
maxChars: 6e3,
|
|
305
|
+
includeIdentity: true,
|
|
306
|
+
includeWorkspaceFiles: true,
|
|
307
|
+
files: [
|
|
308
|
+
"SOUL.md",
|
|
309
|
+
"IDENTITY.md",
|
|
310
|
+
"USER.md"
|
|
311
|
+
]
|
|
312
|
+
},
|
|
313
|
+
providers: {}
|
|
314
|
+
});
|
|
315
|
+
const VoiceCallStreamingConfigSchema = z.object({
|
|
316
|
+
/** Enable Twilio Media Streams for real-time transcription. */
|
|
317
|
+
enabled: z.boolean().default(false),
|
|
318
|
+
/** Provider id from registered realtime transcription providers. */
|
|
319
|
+
provider: z.string().min(1).optional(),
|
|
320
|
+
/** WebSocket path for media stream connections */
|
|
321
|
+
streamPath: z.string().min(1).default("/voice/stream"),
|
|
322
|
+
/** Provider-owned raw config blobs keyed by provider id. */
|
|
323
|
+
providers: VoiceCallStreamingProvidersConfigSchema,
|
|
324
|
+
/**
|
|
325
|
+
* Close unauthenticated media stream sockets if no valid `start` frame arrives in time.
|
|
326
|
+
* Protects against pre-auth idle connection hold attacks.
|
|
327
|
+
*/
|
|
328
|
+
preStartTimeoutMs: z.number().int().positive().default(5e3),
|
|
329
|
+
/** Maximum number of concurrently pending (pre-start) media stream sockets. */
|
|
330
|
+
maxPendingConnections: z.number().int().positive().default(32),
|
|
331
|
+
/** Maximum pending media stream sockets per source IP. */
|
|
332
|
+
maxPendingConnectionsPerIp: z.number().int().positive().default(4),
|
|
333
|
+
/** Hard cap for all open media stream sockets (pending + active). */
|
|
334
|
+
maxConnections: z.number().int().positive().default(128)
|
|
335
|
+
}).strict().default({
|
|
336
|
+
enabled: false,
|
|
337
|
+
streamPath: "/voice/stream",
|
|
338
|
+
providers: {},
|
|
339
|
+
preStartTimeoutMs: 5e3,
|
|
340
|
+
maxPendingConnections: 32,
|
|
341
|
+
maxPendingConnectionsPerIp: 4,
|
|
342
|
+
maxConnections: 128
|
|
343
|
+
});
|
|
344
|
+
const VoiceCallConfigSchema = z.object({
|
|
345
|
+
/** Enable voice call functionality */
|
|
346
|
+
enabled: z.boolean().default(false),
|
|
347
|
+
/** Active provider (telnyx, twilio, plivo, or mock) */
|
|
348
|
+
provider: z.enum([
|
|
349
|
+
"telnyx",
|
|
350
|
+
"twilio",
|
|
351
|
+
"plivo",
|
|
352
|
+
"mock"
|
|
353
|
+
]).optional(),
|
|
354
|
+
/** Telnyx-specific configuration */
|
|
355
|
+
telnyx: TelnyxConfigSchema.optional(),
|
|
356
|
+
/** Twilio-specific configuration */
|
|
357
|
+
twilio: TwilioConfigSchema.optional(),
|
|
358
|
+
/** Plivo-specific configuration */
|
|
359
|
+
plivo: PlivoConfigSchema.optional(),
|
|
360
|
+
/** Phone number to call from (E.164) */
|
|
361
|
+
fromNumber: E164Schema.optional(),
|
|
362
|
+
/** Default phone number to call (E.164) */
|
|
363
|
+
toNumber: E164Schema.optional(),
|
|
364
|
+
/** Inbound call policy */
|
|
365
|
+
inboundPolicy: InboundPolicySchema.default("disabled"),
|
|
366
|
+
/** Allowlist of phone numbers for inbound calls (E.164) */
|
|
367
|
+
allowFrom: z.array(E164Schema).default([]),
|
|
368
|
+
/** Greeting message for inbound calls */
|
|
369
|
+
inboundGreeting: z.string().optional(),
|
|
370
|
+
/** Per-dialed-number overrides for inbound calls. Keys are E.164 numbers. */
|
|
371
|
+
numbers: z.record(E164Schema, VoiceCallNumberRouteConfigSchema).default({}),
|
|
372
|
+
/** Outbound call configuration */
|
|
373
|
+
outbound: OutboundConfigSchema,
|
|
374
|
+
/** Maximum call duration in seconds */
|
|
375
|
+
maxDurationSeconds: z.number().int().positive().default(300),
|
|
376
|
+
/**
|
|
377
|
+
* Maximum age of a call in seconds before it is automatically reaped.
|
|
378
|
+
* Catches calls stuck before answer (for example, local mock calls that
|
|
379
|
+
* never receive provider webhooks). Set to 0 to disable.
|
|
380
|
+
*/
|
|
381
|
+
staleCallReaperSeconds: z.number().int().nonnegative().default(120),
|
|
382
|
+
/** Silence timeout for end-of-speech detection (ms) */
|
|
383
|
+
silenceTimeoutMs: z.number().int().positive().default(800),
|
|
384
|
+
/** Timeout for user transcript (ms) */
|
|
385
|
+
transcriptTimeoutMs: z.number().int().positive().default(18e4),
|
|
386
|
+
/** Ring timeout for outbound calls (ms) */
|
|
387
|
+
ringTimeoutMs: z.number().int().positive().default(3e4),
|
|
388
|
+
/** Maximum concurrent calls */
|
|
389
|
+
maxConcurrentCalls: z.number().int().positive().default(1),
|
|
390
|
+
/** Webhook server configuration */
|
|
391
|
+
serve: VoiceCallServeConfigSchema,
|
|
392
|
+
/** @deprecated Prefer tunnel config. */
|
|
393
|
+
tailscale: VoiceCallTailscaleConfigSchema,
|
|
394
|
+
/** Tunnel configuration (unified ngrok/tailscale) */
|
|
395
|
+
tunnel: VoiceCallTunnelConfigSchema,
|
|
396
|
+
/** Webhook signature reconstruction and proxy trust configuration */
|
|
397
|
+
webhookSecurity: VoiceCallWebhookSecurityConfigSchema,
|
|
398
|
+
/** Real-time audio streaming configuration */
|
|
399
|
+
streaming: VoiceCallStreamingConfigSchema,
|
|
400
|
+
/** Realtime voice-to-voice configuration */
|
|
401
|
+
realtime: VoiceCallRealtimeConfigSchema,
|
|
402
|
+
/** Session memory scope for voice conversations. */
|
|
403
|
+
sessionScope: VoiceCallSessionScopeSchema.default("per-phone"),
|
|
404
|
+
/** Public webhook URL override (if set, bypasses tunnel auto-detection) */
|
|
405
|
+
publicUrl: z.string().url().optional(),
|
|
406
|
+
/** Skip webhook signature verification (development only, NOT for production) */
|
|
407
|
+
skipSignatureVerification: z.boolean().default(false),
|
|
408
|
+
/** TTS override (deep-merges with core messages.tts) */
|
|
409
|
+
tts: TtsConfigSchema,
|
|
410
|
+
/** Store path for call logs */
|
|
411
|
+
store: z.string().optional(),
|
|
412
|
+
/** Agent ID to use for voice response generation. Defaults to "main". */
|
|
413
|
+
agentId: z.string().min(1).optional(),
|
|
414
|
+
/** Optional model override for generating voice responses. */
|
|
415
|
+
responseModel: z.string().optional(),
|
|
416
|
+
/** System prompt for voice responses */
|
|
417
|
+
responseSystemPrompt: z.string().optional(),
|
|
418
|
+
/** Timeout for response generation in ms (default 30s) */
|
|
419
|
+
responseTimeoutMs: z.number().int().positive().default(3e4)
|
|
420
|
+
}).strict();
|
|
421
|
+
const TWILIO_AUTH_TOKEN_PATH = "plugins.entries.voice-call.config.twilio.authToken";
|
|
422
|
+
const DEFAULT_VOICE_CALL_CONFIG = VoiceCallConfigSchema.parse({});
|
|
423
|
+
function cloneDefaultVoiceCallConfig() {
|
|
424
|
+
return structuredClone(DEFAULT_VOICE_CALL_CONFIG);
|
|
425
|
+
}
|
|
426
|
+
function defaultRealtimeStreamPathForServePath(servePath) {
|
|
427
|
+
const normalized = normalizeWebhookPath(servePath);
|
|
428
|
+
if (normalized.endsWith("/webhook")) return `${normalized.slice(0, -8)}/stream/realtime`;
|
|
429
|
+
if (normalized === "/") return "/voice/stream/realtime";
|
|
430
|
+
return `${normalized}/stream/realtime`;
|
|
431
|
+
}
|
|
432
|
+
function normalizeVoiceCallTtsConfig(defaults, overrides) {
|
|
433
|
+
if (!defaults && !overrides) return;
|
|
434
|
+
return TtsConfigSchema.parse(mergeDeep(defaults ?? {}, overrides ?? {}));
|
|
435
|
+
}
|
|
436
|
+
function normalizePhoneRouteKey(phone) {
|
|
437
|
+
return phone?.replace(/\D/g, "") ?? "";
|
|
438
|
+
}
|
|
439
|
+
function resolveVoiceCallNumberRouteKey(config, phone) {
|
|
440
|
+
const routes = config.numbers;
|
|
441
|
+
if (!routes) return;
|
|
442
|
+
if (phone && Object.hasOwn(routes, phone)) return phone;
|
|
443
|
+
const normalizedPhone = normalizePhoneRouteKey(phone);
|
|
444
|
+
if (!normalizedPhone) return;
|
|
445
|
+
return Object.keys(routes).find((routeKey) => normalizePhoneRouteKey(routeKey) === normalizedPhone);
|
|
446
|
+
}
|
|
447
|
+
/** Resolve inbound-only number routing from a persisted call record. */
|
|
448
|
+
function resolveVoiceCallNumberRouteKeyForCall(call) {
|
|
449
|
+
if (call.direction !== "inbound") return;
|
|
450
|
+
const storedRouteKey = call.metadata?.numberRouteKey;
|
|
451
|
+
if (typeof storedRouteKey === "string") return storedRouteKey;
|
|
452
|
+
return call.to;
|
|
453
|
+
}
|
|
454
|
+
function resolveVoiceCallEffectiveConfig(config, phoneOrRouteKey) {
|
|
455
|
+
const numberRouteKey = resolveVoiceCallNumberRouteKey(config, phoneOrRouteKey);
|
|
456
|
+
if (!numberRouteKey) return { config };
|
|
457
|
+
const route = config.numbers[numberRouteKey];
|
|
458
|
+
if (!route) return { config };
|
|
459
|
+
return {
|
|
460
|
+
numberRouteKey,
|
|
461
|
+
config: {
|
|
462
|
+
...config,
|
|
463
|
+
...route,
|
|
464
|
+
tts: normalizeVoiceCallTtsConfig(config.tts, route.tts),
|
|
465
|
+
numbers: config.numbers
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
function sanitizeVoiceCallProviderConfigs(value) {
|
|
470
|
+
if (!value) return {};
|
|
471
|
+
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== void 0));
|
|
472
|
+
}
|
|
473
|
+
function sanitizeVoiceCallNumberRoutes(value) {
|
|
474
|
+
if (!value) return {};
|
|
475
|
+
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== void 0).map(([key, route]) => [key, VoiceCallNumberRouteConfigSchema.parse(route)]));
|
|
476
|
+
}
|
|
477
|
+
function resolveTwilioAuthToken(config) {
|
|
478
|
+
return normalizeResolvedSecretInputString({
|
|
479
|
+
value: config.twilio?.authToken,
|
|
480
|
+
path: TWILIO_AUTH_TOKEN_PATH
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
function normalizeVoiceCallConfig(config) {
|
|
484
|
+
const defaults = cloneDefaultVoiceCallConfig();
|
|
485
|
+
const serve = {
|
|
486
|
+
...defaults.serve,
|
|
487
|
+
...config.serve
|
|
488
|
+
};
|
|
489
|
+
const streamingProvider = config.streaming?.provider;
|
|
490
|
+
const streamingProviders = sanitizeVoiceCallProviderConfigs(config.streaming?.providers ?? defaults.streaming.providers);
|
|
491
|
+
const realtimeProvider = config.realtime?.provider ?? defaults.realtime.provider;
|
|
492
|
+
const realtimeProviders = sanitizeVoiceCallProviderConfigs(config.realtime?.providers ?? defaults.realtime.providers);
|
|
493
|
+
const realtimeFastContext = {
|
|
494
|
+
...defaults.realtime.fastContext,
|
|
495
|
+
...config.realtime?.fastContext,
|
|
496
|
+
sources: config.realtime?.fastContext?.sources ?? defaults.realtime.fastContext.sources
|
|
497
|
+
};
|
|
498
|
+
const realtimeAgentContext = {
|
|
499
|
+
...defaults.realtime.agentContext,
|
|
500
|
+
...config.realtime?.agentContext,
|
|
501
|
+
files: config.realtime?.agentContext?.files ?? defaults.realtime.agentContext.files
|
|
502
|
+
};
|
|
503
|
+
return {
|
|
504
|
+
...defaults,
|
|
505
|
+
...config,
|
|
506
|
+
allowFrom: config.allowFrom ?? defaults.allowFrom,
|
|
507
|
+
numbers: sanitizeVoiceCallNumberRoutes(config.numbers ?? defaults.numbers),
|
|
508
|
+
outbound: {
|
|
509
|
+
...defaults.outbound,
|
|
510
|
+
...config.outbound
|
|
511
|
+
},
|
|
512
|
+
serve,
|
|
513
|
+
tailscale: {
|
|
514
|
+
...defaults.tailscale,
|
|
515
|
+
...config.tailscale
|
|
516
|
+
},
|
|
517
|
+
tunnel: {
|
|
518
|
+
...defaults.tunnel,
|
|
519
|
+
...config.tunnel
|
|
520
|
+
},
|
|
521
|
+
webhookSecurity: {
|
|
522
|
+
...defaults.webhookSecurity,
|
|
523
|
+
...config.webhookSecurity,
|
|
524
|
+
allowedHosts: config.webhookSecurity?.allowedHosts ?? defaults.webhookSecurity.allowedHosts,
|
|
525
|
+
trustedProxyIPs: config.webhookSecurity?.trustedProxyIPs ?? defaults.webhookSecurity.trustedProxyIPs
|
|
526
|
+
},
|
|
527
|
+
streaming: {
|
|
528
|
+
...defaults.streaming,
|
|
529
|
+
...config.streaming,
|
|
530
|
+
provider: streamingProvider,
|
|
531
|
+
providers: streamingProviders
|
|
532
|
+
},
|
|
533
|
+
realtime: {
|
|
534
|
+
...defaults.realtime,
|
|
535
|
+
...config.realtime,
|
|
536
|
+
provider: realtimeProvider,
|
|
537
|
+
streamPath: config.realtime?.streamPath ?? defaultRealtimeStreamPathForServePath(serve.path ?? defaults.serve.path),
|
|
538
|
+
tools: config.realtime?.tools ?? defaults.realtime.tools,
|
|
539
|
+
consultThinkingLevel: VoiceCallRealtimeConsultThinkingLevelSchema.optional().parse(config.realtime?.consultThinkingLevel ?? defaults.realtime.consultThinkingLevel),
|
|
540
|
+
consultFastMode: config.realtime?.consultFastMode ?? defaults.realtime.consultFastMode,
|
|
541
|
+
fastContext: realtimeFastContext,
|
|
542
|
+
agentContext: realtimeAgentContext,
|
|
543
|
+
providers: realtimeProviders
|
|
544
|
+
},
|
|
545
|
+
tts: normalizeVoiceCallTtsConfig(defaults.tts, config.tts)
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
function resolveVoiceCallSessionKey(params) {
|
|
549
|
+
const explicit = params.explicitSessionKey?.trim();
|
|
550
|
+
if (explicit) return resolveVoiceCallAgentSessionKey({
|
|
551
|
+
config: params.config,
|
|
552
|
+
sessionKey: explicit,
|
|
553
|
+
coreSession: params.coreSession
|
|
554
|
+
});
|
|
555
|
+
const prefix = `agent:${normalizeAgentId(params.config.agentId)}:voice`;
|
|
556
|
+
if (params.config.sessionScope === "per-call") return `${prefix}:call:${params.callId}`.toLowerCase();
|
|
557
|
+
const normalizedPhone = params.phone?.replace(/\D/g, "");
|
|
558
|
+
return (normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`).toLowerCase();
|
|
559
|
+
}
|
|
560
|
+
/** Resolve persisted or integration-provided keys into the configured agent namespace. */
|
|
561
|
+
function resolveVoiceCallAgentSessionKey(params) {
|
|
562
|
+
const sessionKey = params.sessionKey.trim();
|
|
563
|
+
if (!sessionKey) throw new Error("Voice Call session key cannot be empty");
|
|
564
|
+
const lower = sessionKey.toLowerCase();
|
|
565
|
+
const agentId = normalizeAgentId(params.config.agentId);
|
|
566
|
+
if (lower === "global" || lower === "unknown") return lower;
|
|
567
|
+
const parsedInput = parseAgentSessionKey(sessionKey);
|
|
568
|
+
let normalizedScopedKey;
|
|
569
|
+
if (parsedInput && normalizeAgentId(parsedInput.agentId) === parsedInput.agentId && parsedInput.agentId === agentId) normalizedScopedKey = `agent:${parsedInput.agentId}:${parsedInput.rest}`;
|
|
570
|
+
else {
|
|
571
|
+
const wrappedInput = parseAgentSessionKey(`agent:${agentId}:${sessionKey}`);
|
|
572
|
+
if (!wrappedInput) throw new Error("Voice Call session key could not be normalized");
|
|
573
|
+
normalizedScopedKey = `agent:${agentId}:${wrappedInput.rest}`;
|
|
574
|
+
}
|
|
575
|
+
const canonicalMain = canonicalizeMainSessionAlias({
|
|
576
|
+
cfg: { session: params.coreSession },
|
|
577
|
+
agentId,
|
|
578
|
+
sessionKey: normalizedScopedKey
|
|
579
|
+
});
|
|
580
|
+
return canonicalMain === normalizedScopedKey ? normalizedScopedKey : canonicalMain;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Resolves the configuration by merging environment variables into missing fields.
|
|
584
|
+
* Returns a new configuration object with environment variables applied.
|
|
585
|
+
*/
|
|
586
|
+
function resolveVoiceCallConfig(config) {
|
|
587
|
+
const resolved = normalizeVoiceCallConfig(config);
|
|
588
|
+
if (resolved.provider === "telnyx") {
|
|
589
|
+
resolved.telnyx = resolved.telnyx ?? {};
|
|
590
|
+
resolved.telnyx.apiKey = resolved.telnyx.apiKey ?? resolveSpeechProviderApiKey(process.env.TELNYX_API_KEY);
|
|
591
|
+
resolved.telnyx.connectionId = resolved.telnyx.connectionId ?? resolveSpeechProviderApiKey(process.env.TELNYX_CONNECTION_ID);
|
|
592
|
+
resolved.telnyx.publicKey = resolved.telnyx.publicKey ?? resolveSpeechProviderApiKey(process.env.TELNYX_PUBLIC_KEY);
|
|
593
|
+
}
|
|
594
|
+
if (resolved.provider === "twilio") {
|
|
595
|
+
resolved.fromNumber = resolved.fromNumber ?? resolveSpeechProviderApiKey(process.env.TWILIO_FROM_NUMBER);
|
|
596
|
+
resolved.twilio = resolved.twilio ?? {};
|
|
597
|
+
resolved.twilio.accountSid = resolved.twilio.accountSid ?? resolveSpeechProviderApiKey(process.env.TWILIO_ACCOUNT_SID);
|
|
598
|
+
resolved.twilio.authToken = resolved.twilio.authToken ?? resolveSpeechProviderApiKey(process.env.TWILIO_AUTH_TOKEN);
|
|
599
|
+
}
|
|
600
|
+
if (resolved.provider === "plivo") {
|
|
601
|
+
resolved.plivo = resolved.plivo ?? {};
|
|
602
|
+
resolved.plivo.authId = resolved.plivo.authId ?? resolveSpeechProviderApiKey(process.env.PLIVO_AUTH_ID);
|
|
603
|
+
resolved.plivo.authToken = resolved.plivo.authToken ?? resolveSpeechProviderApiKey(process.env.PLIVO_AUTH_TOKEN);
|
|
604
|
+
}
|
|
605
|
+
resolved.tunnel = resolved.tunnel ?? {
|
|
606
|
+
provider: "none",
|
|
607
|
+
allowNgrokFreeTierLoopbackBypass: false
|
|
608
|
+
};
|
|
609
|
+
resolved.tunnel.allowNgrokFreeTierLoopbackBypass = resolved.tunnel.allowNgrokFreeTierLoopbackBypass ?? false;
|
|
610
|
+
resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? resolveSpeechProviderApiKey(process.env.NGROK_AUTHTOKEN);
|
|
611
|
+
resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain ?? resolveSpeechProviderApiKey(process.env.NGROK_DOMAIN);
|
|
612
|
+
resolved.webhookSecurity = resolved.webhookSecurity ?? {
|
|
613
|
+
allowedHosts: [],
|
|
614
|
+
trustForwardingHeaders: false,
|
|
615
|
+
trustedProxyIPs: []
|
|
616
|
+
};
|
|
617
|
+
resolved.webhookSecurity.allowedHosts = resolved.webhookSecurity.allowedHosts ?? [];
|
|
618
|
+
resolved.webhookSecurity.trustForwardingHeaders = resolved.webhookSecurity.trustForwardingHeaders ?? false;
|
|
619
|
+
resolved.webhookSecurity.trustedProxyIPs = resolved.webhookSecurity.trustedProxyIPs ?? [];
|
|
620
|
+
return normalizeVoiceCallConfig(resolved);
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Validate that the configuration has all required fields for the selected provider.
|
|
624
|
+
*/
|
|
625
|
+
function validateProviderConfig(config) {
|
|
626
|
+
const errors = [];
|
|
627
|
+
if (!config.enabled) return {
|
|
628
|
+
valid: true,
|
|
629
|
+
errors: []
|
|
630
|
+
};
|
|
631
|
+
if (!config.provider) errors.push("plugins.entries.voice-call.config.provider is required");
|
|
632
|
+
if (!config.fromNumber && config.provider !== "mock") errors.push(config.provider === "twilio" ? "plugins.entries.voice-call.config.fromNumber is required (or set TWILIO_FROM_NUMBER env)" : "plugins.entries.voice-call.config.fromNumber is required");
|
|
633
|
+
if (config.provider === "telnyx") {
|
|
634
|
+
if (!config.telnyx?.apiKey) errors.push("plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)");
|
|
635
|
+
if (!config.telnyx?.connectionId) errors.push("plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)");
|
|
636
|
+
if (!config.skipSignatureVerification && !config.telnyx?.publicKey) errors.push("plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)");
|
|
637
|
+
}
|
|
638
|
+
if (config.provider === "twilio") {
|
|
639
|
+
if (!config.twilio?.accountSid) errors.push("plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)");
|
|
640
|
+
if (!hasConfiguredSecretInput(config.twilio?.authToken)) errors.push("plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)");
|
|
641
|
+
}
|
|
642
|
+
if (config.provider === "plivo") {
|
|
643
|
+
if (!config.plivo?.authId) errors.push("plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)");
|
|
644
|
+
if (!config.plivo?.authToken) errors.push("plugins.entries.voice-call.config.plivo.authToken is required (or set PLIVO_AUTH_TOKEN env)");
|
|
645
|
+
}
|
|
646
|
+
if (config.realtime.enabled && config.inboundPolicy === "disabled") errors.push("plugins.entries.voice-call.config.inboundPolicy must not be \"disabled\" when realtime.enabled is true");
|
|
647
|
+
if (config.realtime.enabled && config.streaming.enabled) errors.push("plugins.entries.voice-call.config.realtime.enabled and plugins.entries.voice-call.config.streaming.enabled cannot both be true");
|
|
648
|
+
if (config.streaming.enabled && config.provider && config.provider !== "twilio") errors.push("plugins.entries.voice-call.config.provider must be \"twilio\" when streaming.enabled is true");
|
|
649
|
+
if (config.realtime.enabled && config.provider && config.provider !== "twilio" && config.provider !== "telnyx" && config.provider !== "mock") errors.push("plugins.entries.voice-call.config.provider must be \"twilio\", \"telnyx\", or \"mock\" when realtime.enabled is true");
|
|
650
|
+
return {
|
|
651
|
+
valid: errors.length === 0,
|
|
652
|
+
errors
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
//#endregion
|
|
25
656
|
//#region extensions/voice-call/src/allowlist.ts
|
|
26
657
|
/** Normalize a phone number to digits only. */
|
|
27
658
|
function normalizePhoneNumber(input) {
|
|
@@ -244,7 +875,7 @@ const DEFAULT_POLLY_VOICE = "Polly.Joanna";
|
|
|
244
875
|
function mapVoiceToPolly(voice) {
|
|
245
876
|
if (!voice) return DEFAULT_POLLY_VOICE;
|
|
246
877
|
if (voice.startsWith("Polly.") || voice.startsWith("Google.")) return voice;
|
|
247
|
-
return OPENAI_TO_POLLY_MAP[normalizeLowercaseStringOrEmpty(voice)] ||
|
|
878
|
+
return OPENAI_TO_POLLY_MAP[normalizeLowercaseStringOrEmpty(voice)] || DEFAULT_POLLY_VOICE;
|
|
248
879
|
}
|
|
249
880
|
//#endregion
|
|
250
881
|
//#region extensions/voice-call/src/manager/twiml.ts
|
|
@@ -646,24 +1277,26 @@ async function endCall(ctx, callId, options) {
|
|
|
646
1277
|
}
|
|
647
1278
|
//#endregion
|
|
648
1279
|
//#region extensions/voice-call/src/manager/events.ts
|
|
1280
|
+
const log = createSubsystemLogger("voice-call/events");
|
|
649
1281
|
function shouldAcceptInbound(config, from) {
|
|
650
1282
|
const { inboundPolicy: policy, allowFrom } = config;
|
|
651
1283
|
switch (policy) {
|
|
652
1284
|
case "disabled":
|
|
653
|
-
|
|
1285
|
+
log.info("Inbound call rejected: policy is disabled");
|
|
654
1286
|
return false;
|
|
655
1287
|
case "open":
|
|
656
|
-
|
|
1288
|
+
log.info("Inbound call accepted: policy is open");
|
|
657
1289
|
return true;
|
|
658
1290
|
case "allowlist":
|
|
659
1291
|
case "pairing": {
|
|
660
1292
|
const normalized = normalizePhoneNumber(from);
|
|
661
1293
|
if (!normalized) {
|
|
662
|
-
|
|
1294
|
+
log.info("Inbound call rejected: missing caller ID");
|
|
663
1295
|
return false;
|
|
664
1296
|
}
|
|
665
1297
|
const allowed = isAllowlistedCaller(normalized, allowFrom);
|
|
666
|
-
|
|
1298
|
+
const status = allowed ? "accepted" : "rejected";
|
|
1299
|
+
log.info(`Inbound call ${status}: caller=${redactIdentifier(from)} allowlisted=${allowed}`);
|
|
667
1300
|
return allowed;
|
|
668
1301
|
}
|
|
669
1302
|
default: return false;
|
|
@@ -698,7 +1331,7 @@ function createWebhookCall(params) {
|
|
|
698
1331
|
params.ctx.activeCalls.set(callId, callRecord);
|
|
699
1332
|
params.ctx.providerCallIdMap.set(params.providerCallId, callId);
|
|
700
1333
|
persistCallRecord(params.ctx.storePath, callRecord);
|
|
701
|
-
|
|
1334
|
+
log.info(`Created ${params.direction} call record: ${callId} caller=${redactIdentifier(params.from)}`);
|
|
702
1335
|
return callRecord;
|
|
703
1336
|
}
|
|
704
1337
|
function persistRejectedInboundCall(params) {
|
|
@@ -735,7 +1368,7 @@ function processEvent(ctx, event) {
|
|
|
735
1368
|
if (eventDirection === "inbound" && !shouldAcceptInbound(ctx.config, event.from)) {
|
|
736
1369
|
const pid = providerCallId;
|
|
737
1370
|
if (!ctx.provider) {
|
|
738
|
-
|
|
1371
|
+
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.`);
|
|
739
1372
|
return { kind: "ignored" };
|
|
740
1373
|
}
|
|
741
1374
|
ctx.processedEventIds.add(dedupeKey);
|
|
@@ -748,7 +1381,7 @@ function processEvent(ctx, event) {
|
|
|
748
1381
|
dedupeKey,
|
|
749
1382
|
providerCallId: pid
|
|
750
1383
|
});
|
|
751
|
-
|
|
1384
|
+
log.info(`Rejecting inbound call by policy: ${pid}`);
|
|
752
1385
|
ctx.provider.hangupCall({
|
|
753
1386
|
callId,
|
|
754
1387
|
providerCallId: pid,
|
|
@@ -756,7 +1389,7 @@ function processEvent(ctx, event) {
|
|
|
756
1389
|
}).catch((err) => {
|
|
757
1390
|
ctx.rejectedProviderCallIds.delete(pid);
|
|
758
1391
|
const message = formatErrorMessage(err);
|
|
759
|
-
|
|
1392
|
+
log.warn(`Failed to reject inbound call ${pid}: ${message}`);
|
|
760
1393
|
});
|
|
761
1394
|
return { kind: "processed" };
|
|
762
1395
|
}
|
|
@@ -803,7 +1436,7 @@ function processEvent(ctx, event) {
|
|
|
803
1436
|
} : {}
|
|
804
1437
|
}).catch((err) => {
|
|
805
1438
|
const message = formatErrorMessage(err);
|
|
806
|
-
|
|
1439
|
+
log.warn(`Failed to answer inbound call ${call.providerCallId}: ${message}`);
|
|
807
1440
|
});
|
|
808
1441
|
}
|
|
809
1442
|
break;
|
|
@@ -843,7 +1476,7 @@ function processEvent(ctx, event) {
|
|
|
843
1476
|
const hadWaiter = ctx.transcriptWaiters.has(call.callId);
|
|
844
1477
|
const resolved = resolveTranscriptWaiter(ctx, call.callId, event.transcript, event.turnToken);
|
|
845
1478
|
if (hadWaiter && !resolved) {
|
|
846
|
-
|
|
1479
|
+
log.warn(`Ignoring speech event with mismatched turn token for ${call.callId}`);
|
|
847
1480
|
result = { kind: "ignored" };
|
|
848
1481
|
break;
|
|
849
1482
|
}
|
|
@@ -920,14 +1553,7 @@ function resolveRestoredMaxDurationAnchor(call) {
|
|
|
920
1553
|
function resolveDefaultStoreBase(config, storePath) {
|
|
921
1554
|
const rawOverride = storePath?.trim() || config.store?.trim();
|
|
922
1555
|
if (rawOverride) return resolveUserPath(rawOverride);
|
|
923
|
-
|
|
924
|
-
return [preferred].map((dir) => resolveUserPath(dir)).find((dir) => {
|
|
925
|
-
try {
|
|
926
|
-
return fs.existsSync(path.join(dir, "calls.jsonl")) || fs.existsSync(dir);
|
|
927
|
-
} catch {
|
|
928
|
-
return false;
|
|
929
|
-
}
|
|
930
|
-
}) ?? resolveUserPath(preferred);
|
|
1556
|
+
return resolveDefaultVoiceCallStoreDir();
|
|
931
1557
|
}
|
|
932
1558
|
/**
|
|
933
1559
|
* Manages voice calls: state ownership and delegation to manager helper modules.
|
|
@@ -1337,7 +1963,7 @@ function mergeTtsConfig(base, override) {
|
|
|
1337
1963
|
if (!base && !override) return;
|
|
1338
1964
|
if (!override) return base;
|
|
1339
1965
|
if (!base) return override;
|
|
1340
|
-
return
|
|
1966
|
+
return mergeDeep(base, override);
|
|
1341
1967
|
}
|
|
1342
1968
|
/** Resolve directive override policy for telephony synthesis. */
|
|
1343
1969
|
function resolveTelephonyModelOverridePolicy(overrides) {
|
|
@@ -1424,7 +2050,7 @@ function appendBoundedChildOutput(current, chunk, maxChars = DEFAULT_MAX_OUTPUT_
|
|
|
1424
2050
|
truncated: current.truncated
|
|
1425
2051
|
};
|
|
1426
2052
|
return {
|
|
1427
|
-
text: appended
|
|
2053
|
+
text: sliceUtf16Safe(appended, -maxChars),
|
|
1428
2054
|
truncated: true
|
|
1429
2055
|
};
|
|
1430
2056
|
}
|
|
@@ -1435,77 +2061,32 @@ function formatBoundedChildOutput(output) {
|
|
|
1435
2061
|
//#endregion
|
|
1436
2062
|
//#region extensions/voice-call/src/webhook/tailscale.ts
|
|
1437
2063
|
const TAILSCALE_COMMAND_STDOUT_MAX_BYTES = 4 * 1024 * 1024;
|
|
1438
|
-
function
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
function runTailscaleCommand(args, timeoutMs = 2500) {
|
|
1454
|
-
return new Promise((resolve) => {
|
|
1455
|
-
const proc = spawn("tailscale", args, { stdio: [
|
|
1456
|
-
"ignore",
|
|
1457
|
-
"pipe",
|
|
1458
|
-
"ignore"
|
|
1459
|
-
] });
|
|
1460
|
-
let stdout = {
|
|
1461
|
-
bytes: 0,
|
|
1462
|
-
exceeded: false,
|
|
1463
|
-
text: ""
|
|
2064
|
+
async function runTailscaleCommand(args, timeoutMs = 2500) {
|
|
2065
|
+
try {
|
|
2066
|
+
const result = await runCommandWithTimeout(["tailscale", ...args], {
|
|
2067
|
+
killProcessTree: true,
|
|
2068
|
+
maxOutputBytes: {
|
|
2069
|
+
stdout: TAILSCALE_COMMAND_STDOUT_MAX_BYTES,
|
|
2070
|
+
stderr: 1
|
|
2071
|
+
},
|
|
2072
|
+
outputCapture: "head",
|
|
2073
|
+
terminateOnOutputLimit: { stdout: true },
|
|
2074
|
+
timeoutMs
|
|
2075
|
+
});
|
|
2076
|
+
if (result.termination !== "exit" || result.outputLimitExceeded) return {
|
|
2077
|
+
code: -1,
|
|
2078
|
+
stdout: ""
|
|
1464
2079
|
};
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
settled = true;
|
|
1469
|
-
clearTimeout(timer);
|
|
1470
|
-
resolve(result);
|
|
2080
|
+
return {
|
|
2081
|
+
code: result.code ?? -1,
|
|
2082
|
+
stdout: result.stdout
|
|
1471
2083
|
};
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
}, timeoutMs);
|
|
1479
|
-
proc.stdout.on("data", (data) => {
|
|
1480
|
-
stdout = appendTailscaleCommandStdout(stdout, data);
|
|
1481
|
-
if (stdout.exceeded) {
|
|
1482
|
-
proc.kill("SIGKILL");
|
|
1483
|
-
finish({
|
|
1484
|
-
code: -1,
|
|
1485
|
-
stdout: ""
|
|
1486
|
-
});
|
|
1487
|
-
}
|
|
1488
|
-
});
|
|
1489
|
-
proc.stdout.on("error", () => {
|
|
1490
|
-
proc.kill("SIGKILL");
|
|
1491
|
-
finish({
|
|
1492
|
-
code: -1,
|
|
1493
|
-
stdout: ""
|
|
1494
|
-
});
|
|
1495
|
-
});
|
|
1496
|
-
proc.on("error", () => {
|
|
1497
|
-
finish({
|
|
1498
|
-
code: -1,
|
|
1499
|
-
stdout: ""
|
|
1500
|
-
});
|
|
1501
|
-
});
|
|
1502
|
-
proc.on("close", (code) => {
|
|
1503
|
-
finish({
|
|
1504
|
-
code: code ?? -1,
|
|
1505
|
-
stdout: stdout.text
|
|
1506
|
-
});
|
|
1507
|
-
});
|
|
1508
|
-
});
|
|
2084
|
+
} catch {
|
|
2085
|
+
return {
|
|
2086
|
+
code: -1,
|
|
2087
|
+
stdout: ""
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
1509
2090
|
}
|
|
1510
2091
|
async function getTailscaleSelfInfo() {
|
|
1511
2092
|
const { code, stdout } = await runTailscaleCommand([
|
|
@@ -1576,6 +2157,8 @@ async function cleanupTailscaleExposure(config) {
|
|
|
1576
2157
|
//#endregion
|
|
1577
2158
|
//#region extensions/voice-call/src/tunnel.ts
|
|
1578
2159
|
const NGROK_LOG_BUFFER_MAX_CHARS = 16384;
|
|
2160
|
+
const NGROK_ERROR_MARKER = "ERR_NGROK";
|
|
2161
|
+
const TUNNEL_COMMAND_OUTPUT_MAX_BYTES = 16384;
|
|
1579
2162
|
function listenForChildStreamErrors(proc, onError) {
|
|
1580
2163
|
proc.stdout.on("error", (error) => onError("stdout", error));
|
|
1581
2164
|
proc.stderr.on("error", (error) => onError("stderr", error));
|
|
@@ -1615,6 +2198,7 @@ async function startNgrokTunnel(config) {
|
|
|
1615
2198
|
let closed = false;
|
|
1616
2199
|
let publicUrl = null;
|
|
1617
2200
|
let outputBuffer = "";
|
|
2201
|
+
let stderrTail = "";
|
|
1618
2202
|
const timeout = setTimeout(() => {
|
|
1619
2203
|
if (!resolved) {
|
|
1620
2204
|
resolved = true;
|
|
@@ -1671,12 +2255,13 @@ async function startNgrokTunnel(config) {
|
|
|
1671
2255
|
proc.stdout.on("data", (data) => {
|
|
1672
2256
|
const lines = (outputBuffer + data.toString()).split("\n");
|
|
1673
2257
|
outputBuffer = lines.pop() || "";
|
|
1674
|
-
if (outputBuffer.length > NGROK_LOG_BUFFER_MAX_CHARS) outputBuffer = outputBuffer
|
|
2258
|
+
if (outputBuffer.length > NGROK_LOG_BUFFER_MAX_CHARS) outputBuffer = sliceUtf16Safe(outputBuffer, -16384);
|
|
1675
2259
|
for (const line of lines) if (line.trim()) processLine(line);
|
|
1676
2260
|
});
|
|
1677
2261
|
proc.stderr.on("data", (data) => {
|
|
1678
|
-
const
|
|
1679
|
-
if (
|
|
2262
|
+
const combined = stderrTail + data.toString();
|
|
2263
|
+
if (combined.includes(NGROK_ERROR_MARKER)) rejectIfPending(`ngrok error: ${formatBoundedChildOutput(appendBoundedChildOutput(emptyBoundedChildOutput(), combined))}`, true);
|
|
2264
|
+
stderrTail = sliceUtf16Safe(combined, -8);
|
|
1680
2265
|
});
|
|
1681
2266
|
listenForChildStreamErrors(proc, (stream, error) => {
|
|
1682
2267
|
rejectIfPending(`ngrok ${stream} error: ${error.message}`, true);
|
|
@@ -1698,41 +2283,22 @@ async function startNgrokTunnel(config) {
|
|
|
1698
2283
|
* Run an ngrok command and wait for completion.
|
|
1699
2284
|
*/
|
|
1700
2285
|
async function runNgrokCommand(args) {
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
] });
|
|
1707
|
-
let stdout = emptyBoundedChildOutput();
|
|
1708
|
-
let stderr = emptyBoundedChildOutput();
|
|
1709
|
-
let settled = false;
|
|
1710
|
-
const rejectIfPending = (error, kill = false) => {
|
|
1711
|
-
if (settled) return;
|
|
1712
|
-
settled = true;
|
|
1713
|
-
if (kill) proc.kill("SIGKILL");
|
|
1714
|
-
reject(error);
|
|
1715
|
-
};
|
|
1716
|
-
proc.stdout.on("data", (data) => {
|
|
1717
|
-
stdout = appendBoundedChildOutput(stdout, data.toString());
|
|
1718
|
-
});
|
|
1719
|
-
proc.stderr.on("data", (data) => {
|
|
1720
|
-
stderr = appendBoundedChildOutput(stderr, data.toString());
|
|
1721
|
-
});
|
|
1722
|
-
listenForChildStreamErrors(proc, (stream, error) => {
|
|
1723
|
-
rejectIfPending(/* @__PURE__ */ new Error(`ngrok command ${stream} error: ${error.message}`), true);
|
|
1724
|
-
});
|
|
1725
|
-
proc.on("close", (code) => {
|
|
1726
|
-
if (settled) return;
|
|
1727
|
-
settled = true;
|
|
1728
|
-
if (code === 0) resolve(stdout.text);
|
|
1729
|
-
else {
|
|
1730
|
-
const output = stderr.text ? stderr : stdout;
|
|
1731
|
-
reject(/* @__PURE__ */ new Error(`ngrok command failed: ${formatBoundedChildOutput(output)}`));
|
|
1732
|
-
}
|
|
1733
|
-
});
|
|
1734
|
-
proc.on("error", (error) => rejectIfPending(error));
|
|
2286
|
+
const result = await runCommandWithTimeout(["ngrok", ...args], {
|
|
2287
|
+
killProcessTree: true,
|
|
2288
|
+
maxOutputBytes: TUNNEL_COMMAND_OUTPUT_MAX_BYTES,
|
|
2289
|
+
outputCapture: "tail",
|
|
2290
|
+
timeoutMs: 3e4
|
|
1735
2291
|
});
|
|
2292
|
+
if (result.termination === "timeout") throw new Error("ngrok command timed out");
|
|
2293
|
+
if (result.code === 0) return result.stdout;
|
|
2294
|
+
const output = result.stderr ? {
|
|
2295
|
+
text: result.stderr,
|
|
2296
|
+
truncated: Boolean(result.stderrTruncatedBytes)
|
|
2297
|
+
} : {
|
|
2298
|
+
text: result.stdout,
|
|
2299
|
+
truncated: Boolean(result.stdoutTruncatedBytes)
|
|
2300
|
+
};
|
|
2301
|
+
throw new Error(`ngrok command failed: ${formatBoundedChildOutput(output)}`);
|
|
1736
2302
|
}
|
|
1737
2303
|
/**
|
|
1738
2304
|
* Start a Tailscale serve/funnel tunnel.
|
|
@@ -1742,89 +2308,56 @@ async function startTailscaleTunnel(config) {
|
|
|
1742
2308
|
if (!dnsName) throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
|
|
1743
2309
|
const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
|
|
1744
2310
|
const localUrl = `http://127.0.0.1:${config.port}${path}`;
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
let resolved = false;
|
|
1759
|
-
let stdout = emptyBoundedChildOutput();
|
|
1760
|
-
let stderr = emptyBoundedChildOutput();
|
|
1761
|
-
const rejectIfPending = (error, kill = false) => {
|
|
1762
|
-
if (resolved) return;
|
|
1763
|
-
resolved = true;
|
|
1764
|
-
clearTimeout(timeout);
|
|
1765
|
-
if (kill) proc.kill("SIGKILL");
|
|
1766
|
-
reject(error);
|
|
1767
|
-
};
|
|
1768
|
-
const timeout = setTimeout(() => {
|
|
1769
|
-
rejectIfPending(/* @__PURE__ */ new Error(`Tailscale ${config.mode} timed out`), true);
|
|
1770
|
-
}, 1e4);
|
|
1771
|
-
proc.stdout.on("data", (data) => {
|
|
1772
|
-
stdout = appendBoundedChildOutput(stdout, data.toString());
|
|
1773
|
-
});
|
|
1774
|
-
proc.stderr.on("data", (data) => {
|
|
1775
|
-
stderr = appendBoundedChildOutput(stderr, data.toString());
|
|
1776
|
-
});
|
|
1777
|
-
listenForChildStreamErrors(proc, (stream, error) => {
|
|
1778
|
-
rejectIfPending(/* @__PURE__ */ new Error(`Tailscale ${config.mode} ${stream} error: ${error.message}`), true);
|
|
1779
|
-
});
|
|
1780
|
-
proc.on("close", (code) => {
|
|
1781
|
-
clearTimeout(timeout);
|
|
1782
|
-
if (resolved) return;
|
|
1783
|
-
resolved = true;
|
|
1784
|
-
if (code === 0) {
|
|
1785
|
-
const publicUrl = `https://${dnsName}${path}`;
|
|
1786
|
-
console.log(`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`);
|
|
1787
|
-
resolve({
|
|
1788
|
-
publicUrl,
|
|
1789
|
-
provider: `tailscale-${config.mode}`,
|
|
1790
|
-
stop: async () => {
|
|
1791
|
-
await stopTailscaleTunnel(config.mode, path);
|
|
1792
|
-
}
|
|
1793
|
-
});
|
|
1794
|
-
} else {
|
|
1795
|
-
const output = stderr.text ? stderr : stdout;
|
|
1796
|
-
const detail = output.text ? `: ${formatBoundedChildOutput(output)}` : "";
|
|
1797
|
-
reject(/* @__PURE__ */ new Error(`Tailscale ${config.mode} failed with code ${code}${detail}`));
|
|
1798
|
-
}
|
|
1799
|
-
});
|
|
1800
|
-
proc.on("error", (err) => {
|
|
1801
|
-
rejectIfPending(err);
|
|
1802
|
-
});
|
|
2311
|
+
const result = await runCommandWithTimeout([
|
|
2312
|
+
"tailscale",
|
|
2313
|
+
config.mode,
|
|
2314
|
+
"--bg",
|
|
2315
|
+
"--yes",
|
|
2316
|
+
"--set-path",
|
|
2317
|
+
path,
|
|
2318
|
+
localUrl
|
|
2319
|
+
], {
|
|
2320
|
+
killProcessTree: true,
|
|
2321
|
+
maxOutputBytes: TUNNEL_COMMAND_OUTPUT_MAX_BYTES,
|
|
2322
|
+
outputCapture: "tail",
|
|
2323
|
+
timeoutMs: 1e4
|
|
1803
2324
|
});
|
|
2325
|
+
if (result.termination === "timeout") throw new Error(`Tailscale ${config.mode} timed out`);
|
|
2326
|
+
if (result.code !== 0) {
|
|
2327
|
+
const output = result.stderr ? {
|
|
2328
|
+
text: result.stderr,
|
|
2329
|
+
truncated: Boolean(result.stderrTruncatedBytes)
|
|
2330
|
+
} : {
|
|
2331
|
+
text: result.stdout,
|
|
2332
|
+
truncated: Boolean(result.stdoutTruncatedBytes)
|
|
2333
|
+
};
|
|
2334
|
+
const detail = output.text ? `: ${formatBoundedChildOutput(output)}` : "";
|
|
2335
|
+
throw new Error(`Tailscale ${config.mode} failed with code ${result.code}${detail}`);
|
|
2336
|
+
}
|
|
2337
|
+
const publicUrl = `https://${dnsName}${path}`;
|
|
2338
|
+
console.log(`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`);
|
|
2339
|
+
return {
|
|
2340
|
+
publicUrl,
|
|
2341
|
+
provider: `tailscale-${config.mode}`,
|
|
2342
|
+
stop: async () => {
|
|
2343
|
+
await stopTailscaleTunnel(config.mode, path);
|
|
2344
|
+
}
|
|
2345
|
+
};
|
|
1804
2346
|
}
|
|
1805
2347
|
/**
|
|
1806
2348
|
* Stop a Tailscale serve/funnel tunnel.
|
|
1807
2349
|
*/
|
|
1808
2350
|
async function stopTailscaleTunnel(mode, path) {
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
proc.on("close", () => {
|
|
1820
|
-
clearTimeout(timeout);
|
|
1821
|
-
resolve();
|
|
1822
|
-
});
|
|
1823
|
-
proc.on("error", () => {
|
|
1824
|
-
clearTimeout(timeout);
|
|
1825
|
-
resolve();
|
|
1826
|
-
});
|
|
1827
|
-
});
|
|
2351
|
+
await runCommandWithTimeout([
|
|
2352
|
+
"tailscale",
|
|
2353
|
+
mode,
|
|
2354
|
+
"off",
|
|
2355
|
+
path
|
|
2356
|
+
], {
|
|
2357
|
+
killProcessTree: true,
|
|
2358
|
+
maxOutputBytes: 1,
|
|
2359
|
+
timeoutMs: 5e3
|
|
2360
|
+
}).catch(() => {});
|
|
1828
2361
|
}
|
|
1829
2362
|
/**
|
|
1830
2363
|
* Start a tunnel based on configuration.
|
|
@@ -2308,9 +2841,7 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2308
2841
|
bufferedAfterBytes: session.ws.bufferedAmount
|
|
2309
2842
|
};
|
|
2310
2843
|
if (bufferedBeforeBytes > MAX_WS_BUFFERED_BYTES) {
|
|
2311
|
-
|
|
2312
|
-
session.ws.close(1013, "Backpressure: send buffer exceeded");
|
|
2313
|
-
} catch {}
|
|
2844
|
+
session.ws.close(1013, "Backpressure: send buffer exceeded");
|
|
2314
2845
|
return {
|
|
2315
2846
|
sent: false,
|
|
2316
2847
|
readyState,
|
|
@@ -2322,9 +2853,7 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2322
2853
|
session.ws.send(JSON.stringify(message));
|
|
2323
2854
|
const bufferedAfterBytes = session.ws.bufferedAmount;
|
|
2324
2855
|
if (bufferedAfterBytes > MAX_WS_BUFFERED_BYTES) {
|
|
2325
|
-
|
|
2326
|
-
session.ws.close(1013, "Backpressure: send buffer exceeded");
|
|
2327
|
-
} catch {}
|
|
2856
|
+
session.ws.close(1013, "Backpressure: send buffer exceeded");
|
|
2328
2857
|
return {
|
|
2329
2858
|
sent: false,
|
|
2330
2859
|
readyState,
|
|
@@ -2584,15 +3113,19 @@ function startStaleCallReaper(params) {
|
|
|
2584
3113
|
const maxAgeSeconds = params.staleCallReaperSeconds;
|
|
2585
3114
|
if (!maxAgeSeconds || maxAgeSeconds <= 0) return null;
|
|
2586
3115
|
const maxAgeMs = maxAgeSeconds * 1e3;
|
|
3116
|
+
const callsBeingReaped = /* @__PURE__ */ new Set();
|
|
2587
3117
|
const interval = setInterval(() => {
|
|
2588
3118
|
const now = Date.now();
|
|
2589
3119
|
for (const call of params.manager.getActiveCalls()) {
|
|
2590
3120
|
if (call.answeredAt || TerminalStates.has(call.state) || LiveConversationStates.has(call.state)) continue;
|
|
2591
3121
|
const age = now - call.startedAt;
|
|
2592
|
-
if (age > maxAgeMs) {
|
|
3122
|
+
if (age > maxAgeMs && !callsBeingReaped.has(call.callId)) {
|
|
3123
|
+
callsBeingReaped.add(call.callId);
|
|
2593
3124
|
console.log(`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1e3)}s, state: ${call.state})`);
|
|
2594
3125
|
params.manager.endCall(call.callId).catch((err) => {
|
|
2595
3126
|
console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
|
|
3127
|
+
}).finally(() => {
|
|
3128
|
+
callsBeingReaped.delete(call.callId);
|
|
2596
3129
|
});
|
|
2597
3130
|
}
|
|
2598
3131
|
}
|
|
@@ -2607,14 +3140,8 @@ const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
|
|
|
2607
3140
|
const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
|
|
2608
3141
|
const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
|
|
2609
3142
|
const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2e3;
|
|
2610
|
-
const TRANSCRIPT_LOG_MAX_CHARS = 200;
|
|
2611
3143
|
const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
|
|
2612
|
-
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-
|
|
2613
|
-
function sanitizeTranscriptForLog(value) {
|
|
2614
|
-
const sanitized = value.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
|
|
2615
|
-
if (sanitized.length <= TRANSCRIPT_LOG_MAX_CHARS) return sanitized;
|
|
2616
|
-
return `${truncateUtf16Safe(sanitized, TRANSCRIPT_LOG_MAX_CHARS)}...`;
|
|
2617
|
-
}
|
|
3144
|
+
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-Cdj8EuLg.js"));
|
|
2618
3145
|
function appendRecentTalkEventMetadata(call, event) {
|
|
2619
3146
|
const metadata = call.metadata ?? {};
|
|
2620
3147
|
const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
|
|
@@ -2709,6 +3236,14 @@ var VoiceCallWebhookServer = class {
|
|
|
2709
3236
|
error: console.error,
|
|
2710
3237
|
debug: console.debug
|
|
2711
3238
|
};
|
|
3239
|
+
const rawLogger = this.logger;
|
|
3240
|
+
const rawDebug = rawLogger.debug;
|
|
3241
|
+
this.logger = {
|
|
3242
|
+
info: (msg) => rawLogger.info(`[voice-call] ${msg}`),
|
|
3243
|
+
warn: (msg) => rawLogger.warn(`[voice-call] ${msg}`),
|
|
3244
|
+
error: (msg) => rawLogger.error(`[voice-call] ${msg}`),
|
|
3245
|
+
debug: rawDebug ? (msg) => rawDebug(`[voice-call] ${msg}`) : void 0
|
|
3246
|
+
};
|
|
2712
3247
|
}
|
|
2713
3248
|
/**
|
|
2714
3249
|
* Get the media stream handler (for wiring to provider).
|
|
@@ -2777,15 +3312,15 @@ var VoiceCallWebhookServer = class {
|
|
|
2777
3312
|
})
|
|
2778
3313
|
});
|
|
2779
3314
|
if (!resolution.ok && resolution.code === "missing-configured-provider") {
|
|
2780
|
-
|
|
3315
|
+
this.logger.warn(`Streaming enabled but realtime transcription provider "${resolution.configuredProviderId}" is not registered`);
|
|
2781
3316
|
return;
|
|
2782
3317
|
}
|
|
2783
3318
|
if (!resolution.ok && resolution.code === "no-registered-provider") {
|
|
2784
|
-
|
|
3319
|
+
this.logger.warn("Streaming enabled but no realtime transcription provider is registered");
|
|
2785
3320
|
return;
|
|
2786
3321
|
}
|
|
2787
3322
|
if (!resolution.ok) {
|
|
2788
|
-
|
|
3323
|
+
this.logger.warn(`Streaming enabled but provider "${resolution.provider?.id}" is not configured`);
|
|
2789
3324
|
return;
|
|
2790
3325
|
}
|
|
2791
3326
|
const streamConfig = {
|
|
@@ -2798,25 +3333,26 @@ var VoiceCallWebhookServer = class {
|
|
|
2798
3333
|
maxConnections: streaming.maxConnections,
|
|
2799
3334
|
resolveClientIp: (request) => this.resolveMediaStreamClientIp(request),
|
|
2800
3335
|
shouldAcceptStream: ({ callId, token }) => {
|
|
3336
|
+
if (this.provider.name !== "twilio") {
|
|
3337
|
+
this.logger.warn(`Rejecting media stream: provider ${this.provider.name} does not support authenticated classic streaming`);
|
|
3338
|
+
return false;
|
|
3339
|
+
}
|
|
2801
3340
|
if (!this.manager.getCallByProviderCallId(callId)) return false;
|
|
2802
|
-
if (this.provider.
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
return false;
|
|
2806
|
-
}
|
|
3341
|
+
if (!this.provider.isValidStreamToken(callId, token)) {
|
|
3342
|
+
this.logger.warn(`Rejecting media stream: invalid token for ${callId}`);
|
|
3343
|
+
return false;
|
|
2807
3344
|
}
|
|
2808
3345
|
return true;
|
|
2809
3346
|
},
|
|
2810
3347
|
onTranscript: (providerCallId, transcript) => {
|
|
2811
|
-
|
|
2812
|
-
console.log(`[voice-call] Transcript for ${providerCallId}: ${safeTranscript} (chars=${transcript.length})`);
|
|
3348
|
+
this.logger.info(`Transcript received ${providerCallId} chars=${transcript.length}`);
|
|
2813
3349
|
const call = this.manager.getCallByProviderCallId(providerCallId);
|
|
2814
3350
|
if (!call) {
|
|
2815
|
-
|
|
3351
|
+
this.logger.warn(`No active call found for provider ID: ${providerCallId}`);
|
|
2816
3352
|
return;
|
|
2817
3353
|
}
|
|
2818
3354
|
if (this.shouldSuppressBargeInForInitialMessage(call)) {
|
|
2819
|
-
|
|
3355
|
+
this.logger.info(`Ignoring barge transcript while initial message is still playing (${providerCallId})`);
|
|
2820
3356
|
return;
|
|
2821
3357
|
}
|
|
2822
3358
|
if (this.provider.name === "twilio") this.provider.clearTtsQueue(providerCallId);
|
|
@@ -2838,25 +3374,24 @@ var VoiceCallWebhookServer = class {
|
|
|
2838
3374
|
this.provider.clearTtsQueue(providerCallId);
|
|
2839
3375
|
},
|
|
2840
3376
|
onPartialTranscript: (callId, partial) => {
|
|
2841
|
-
|
|
2842
|
-
console.log(`[voice-call] Partial for ${callId}: ${safePartial} (chars=${partial.length})`);
|
|
3377
|
+
this.logger.info(`Partial transcript ${callId} chars=${partial.length}`);
|
|
2843
3378
|
},
|
|
2844
3379
|
onTalkEvent: (providerCallId, _streamSid, event) => {
|
|
2845
3380
|
const call = this.manager.getCallByProviderCallId(providerCallId);
|
|
2846
3381
|
if (call) appendRecentTalkEventMetadata(call, event);
|
|
2847
3382
|
},
|
|
2848
3383
|
onConnect: (callId, streamSid) => {
|
|
2849
|
-
|
|
3384
|
+
this.logger.info(`Media stream connected: ${callId} -> ${streamSid}`);
|
|
2850
3385
|
this.clearPendingDisconnectHangup(callId);
|
|
2851
3386
|
if (this.provider.name === "twilio") this.provider.registerCallStream(callId, streamSid);
|
|
2852
3387
|
},
|
|
2853
3388
|
onTranscriptionReady: (callId) => {
|
|
2854
3389
|
this.manager.speakInitialMessage(callId).catch((err) => {
|
|
2855
|
-
|
|
3390
|
+
this.logger.warn(`Failed to speak initial message: ${String(err)}`);
|
|
2856
3391
|
});
|
|
2857
3392
|
},
|
|
2858
3393
|
onDisconnect: (callId, streamSid) => {
|
|
2859
|
-
|
|
3394
|
+
this.logger.info(`Media stream disconnected: ${callId} (${streamSid})`);
|
|
2860
3395
|
if (this.provider.name === "twilio") this.provider.unregisterCallStream(callId, streamSid);
|
|
2861
3396
|
this.clearPendingDisconnectHangup(callId);
|
|
2862
3397
|
const timer = setTimeout(() => {
|
|
@@ -2866,9 +3401,9 @@ var VoiceCallWebhookServer = class {
|
|
|
2866
3401
|
if (this.provider.name === "twilio") {
|
|
2867
3402
|
if (this.provider.hasRegisteredStream(callId)) return;
|
|
2868
3403
|
}
|
|
2869
|
-
|
|
3404
|
+
this.logger.info(`Auto-ending call ${disconnectedCall.callId} after stream disconnect grace`);
|
|
2870
3405
|
this.manager.endCall(disconnectedCall.callId).catch((err) => {
|
|
2871
|
-
|
|
3406
|
+
this.logger.warn(`Failed to auto-end call ${disconnectedCall.callId}: ${String(err)}`);
|
|
2872
3407
|
});
|
|
2873
3408
|
}, STREAM_DISCONNECT_HANGUP_GRACE_MS);
|
|
2874
3409
|
timer.unref?.();
|
|
@@ -2876,7 +3411,7 @@ var VoiceCallWebhookServer = class {
|
|
|
2876
3411
|
}
|
|
2877
3412
|
};
|
|
2878
3413
|
this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
|
|
2879
|
-
|
|
3414
|
+
this.logger.info("Media streaming initialized");
|
|
2880
3415
|
}
|
|
2881
3416
|
/**
|
|
2882
3417
|
* Start the webhook server.
|
|
@@ -2891,7 +3426,7 @@ var VoiceCallWebhookServer = class {
|
|
|
2891
3426
|
this.startPromise = new Promise((resolve, reject) => {
|
|
2892
3427
|
this.server = http.createServer((req, res) => {
|
|
2893
3428
|
this.handleRequest(req, res, webhookPath).catch((err) => {
|
|
2894
|
-
|
|
3429
|
+
this.logger.error(`Webhook error: ${String(err)}`);
|
|
2895
3430
|
res.statusCode = 500;
|
|
2896
3431
|
res.end("Internal Server Error");
|
|
2897
3432
|
});
|
|
@@ -2914,11 +3449,11 @@ var VoiceCallWebhookServer = class {
|
|
|
2914
3449
|
const url = this.resolveListeningUrl(bind, webhookPath);
|
|
2915
3450
|
this.listeningUrl = url;
|
|
2916
3451
|
this.startPromise = null;
|
|
2917
|
-
this.logger.info(`
|
|
3452
|
+
this.logger.info(`Webhook server listening on ${url}`);
|
|
2918
3453
|
if (this.mediaStreamHandler) {
|
|
2919
3454
|
const address = this.server?.address();
|
|
2920
3455
|
const actualPort = address && typeof address === "object" ? address.port : this.config.serve.port;
|
|
2921
|
-
this.logger.info(`
|
|
3456
|
+
this.logger.info(`Media stream WebSocket on ws://${bind}:${actualPort}${streamPath}`);
|
|
2922
3457
|
}
|
|
2923
3458
|
resolve(url);
|
|
2924
3459
|
this.stopStaleCallReaper = startStaleCallReaper({
|
|
@@ -2999,17 +3534,17 @@ var VoiceCallWebhookServer = class {
|
|
|
2999
3534
|
};
|
|
3000
3535
|
const headerGate = this.verifyPreAuthWebhookHeaders(req.headers);
|
|
3001
3536
|
if (!headerGate.ok) {
|
|
3002
|
-
|
|
3537
|
+
this.logger.warn(`Webhook rejected before body read: ${headerGate.reason}`);
|
|
3003
3538
|
return {
|
|
3004
3539
|
statusCode: 401,
|
|
3005
3540
|
body: "Unauthorized"
|
|
3006
3541
|
};
|
|
3007
3542
|
}
|
|
3008
3543
|
const remoteAddress = req.socket.remoteAddress;
|
|
3009
|
-
if (!remoteAddress)
|
|
3544
|
+
if (!remoteAddress) this.logger.warn(`Webhook accepted with no remote address; using shared fallback in-flight key`);
|
|
3010
3545
|
const inFlightKey = remoteAddress || MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY;
|
|
3011
3546
|
if (!this.webhookInFlightLimiter.tryAcquire(inFlightKey)) {
|
|
3012
|
-
|
|
3547
|
+
this.logger.warn(`Webhook rejected before body read: too many in-flight requests`);
|
|
3013
3548
|
return {
|
|
3014
3549
|
statusCode: 429,
|
|
3015
3550
|
body: "Too Many Requests"
|
|
@@ -3040,14 +3575,14 @@ var VoiceCallWebhookServer = class {
|
|
|
3040
3575
|
};
|
|
3041
3576
|
const verification = this.provider.verifyWebhook(ctx);
|
|
3042
3577
|
if (!verification.ok) {
|
|
3043
|
-
|
|
3578
|
+
this.logger.warn(`Webhook verification failed: ${verification.reason}`);
|
|
3044
3579
|
return {
|
|
3045
3580
|
statusCode: 401,
|
|
3046
3581
|
body: "Unauthorized"
|
|
3047
3582
|
};
|
|
3048
3583
|
}
|
|
3049
3584
|
if (!verification.verifiedRequestKey) {
|
|
3050
|
-
|
|
3585
|
+
this.logger.warn("Webhook verification succeeded without request identity key");
|
|
3051
3586
|
return {
|
|
3052
3587
|
statusCode: 401,
|
|
3053
3588
|
body: "Unauthorized"
|
|
@@ -3055,7 +3590,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3055
3590
|
}
|
|
3056
3591
|
const isReplay = Boolean(verification.isReplay);
|
|
3057
3592
|
if (isReplay) {
|
|
3058
|
-
|
|
3593
|
+
this.logger.warn("Replay detected; skipping event side effects");
|
|
3059
3594
|
if (this.provider.name === "twilio") return buildTwilioReplayTwiML();
|
|
3060
3595
|
const cachedResponse = await this.getCachedReplayResponse(verification.verifiedRequestKey);
|
|
3061
3596
|
if (cachedResponse) return cachedResponse;
|
|
@@ -3064,7 +3599,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3064
3599
|
const initialTwiML = this.provider.consumeInitialTwiML?.(ctx);
|
|
3065
3600
|
if (initialTwiML !== void 0 && initialTwiML !== null) {
|
|
3066
3601
|
const params = new URLSearchParams(ctx.rawBody);
|
|
3067
|
-
|
|
3602
|
+
this.logger.info(`Serving provider initial TwiML before realtime handling (callSid=${params.get("CallSid") ?? "unknown"}, direction=${params.get("Direction") ?? "unknown"})`);
|
|
3068
3603
|
return {
|
|
3069
3604
|
statusCode: 200,
|
|
3070
3605
|
headers: { "Content-Type": "application/xml" },
|
|
@@ -3075,10 +3610,10 @@ var VoiceCallWebhookServer = class {
|
|
|
3075
3610
|
if (realtimeParams) {
|
|
3076
3611
|
const direction = realtimeParams.get("Direction");
|
|
3077
3612
|
if ((!direction || direction === "inbound") && !this.shouldAcceptRealtimeInboundRequest(realtimeParams)) {
|
|
3078
|
-
|
|
3613
|
+
this.logger.info("Realtime inbound call rejected before stream setup");
|
|
3079
3614
|
return buildRealtimeRejectedTwiML();
|
|
3080
3615
|
}
|
|
3081
|
-
|
|
3616
|
+
this.logger.info(`Serving realtime TwiML for Twilio call ${realtimeParams.get("CallSid") ?? "unknown"} (direction=${direction ?? "unknown"})`);
|
|
3082
3617
|
return this.realtimeHandler.buildTwiMLPayload(req, realtimeParams);
|
|
3083
3618
|
}
|
|
3084
3619
|
const parsed = this.provider.parseWebhookEvent(ctx, { verifiedRequestKey: verification.verifiedRequestKey });
|
|
@@ -3194,7 +3729,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3194
3729
|
for (const event of events) try {
|
|
3195
3730
|
this.processEventWithAutoResponse(event);
|
|
3196
3731
|
} catch (err) {
|
|
3197
|
-
|
|
3732
|
+
this.logger.error(`Error processing event ${event.type}: ${String(err)}`);
|
|
3198
3733
|
}
|
|
3199
3734
|
}
|
|
3200
3735
|
processEventWithAutoResponse(event) {
|
|
@@ -3203,7 +3738,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3203
3738
|
const callMode = result.call.metadata?.mode;
|
|
3204
3739
|
if (result.call.direction !== "inbound" && callMode !== "conversation") return;
|
|
3205
3740
|
this.handleInboundResponse(result.call.callId, result.transcript).catch((err) => {
|
|
3206
|
-
|
|
3741
|
+
this.logger.warn(`Failed to auto-respond: ${String(err)}`);
|
|
3207
3742
|
});
|
|
3208
3743
|
}
|
|
3209
3744
|
writeWebhookResponse(res, payload) {
|
|
@@ -3225,18 +3760,18 @@ var VoiceCallWebhookServer = class {
|
|
|
3225
3760
|
* Supports tool calling for richer voice interactions.
|
|
3226
3761
|
*/
|
|
3227
3762
|
async handleInboundResponse(callId, userMessage) {
|
|
3228
|
-
|
|
3763
|
+
this.logger.info(`Auto-responding to inbound call ${callId} chars=${userMessage.length}`);
|
|
3229
3764
|
const call = this.manager.getCall(callId);
|
|
3230
3765
|
if (!call) {
|
|
3231
|
-
|
|
3766
|
+
this.logger.warn(`Call ${callId} not found for auto-response`);
|
|
3232
3767
|
return;
|
|
3233
3768
|
}
|
|
3234
3769
|
if (!this.coreConfig) {
|
|
3235
|
-
|
|
3770
|
+
this.logger.warn("Core config missing; skipping auto-response");
|
|
3236
3771
|
return;
|
|
3237
3772
|
}
|
|
3238
3773
|
if (!this.agentRuntime) {
|
|
3239
|
-
|
|
3774
|
+
this.logger.warn("Agent runtime missing; skipping auto-response");
|
|
3240
3775
|
return;
|
|
3241
3776
|
}
|
|
3242
3777
|
try {
|
|
@@ -3254,20 +3789,20 @@ var VoiceCallWebhookServer = class {
|
|
|
3254
3789
|
transcript: call.transcript,
|
|
3255
3790
|
userMessage,
|
|
3256
3791
|
onEarlyText: async (text) => {
|
|
3257
|
-
|
|
3792
|
+
this.logger.info(`Early AI response queued ${callId} chars=${text.length}`);
|
|
3258
3793
|
return (await this.manager.speak(callId, text, { listenAfterPlayback: true })).success;
|
|
3259
3794
|
}
|
|
3260
3795
|
});
|
|
3261
3796
|
if (result.error) {
|
|
3262
|
-
|
|
3797
|
+
this.logger.error(`Response generation error: ${result.error}`);
|
|
3263
3798
|
return;
|
|
3264
3799
|
}
|
|
3265
3800
|
if (result.text && !result.deliveredEarly) {
|
|
3266
|
-
|
|
3801
|
+
this.logger.info(`AI response delivered ${callId} chars=${result.text.length}`);
|
|
3267
3802
|
await this.manager.speak(callId, result.text, { listenAfterPlayback: true });
|
|
3268
3803
|
}
|
|
3269
3804
|
} catch (err) {
|
|
3270
|
-
|
|
3805
|
+
this.logger.error(`Auto-response error: ${String(err)}`);
|
|
3271
3806
|
}
|
|
3272
3807
|
}
|
|
3273
3808
|
};
|
|
@@ -3281,12 +3816,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
|
|
|
3281
3816
|
"Do not print secret values or dump environment variables; only check whether required configuration is present.",
|
|
3282
3817
|
"Be accurate, brief, and speakable."
|
|
3283
3818
|
].join(" ");
|
|
3284
|
-
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-
|
|
3285
|
-
const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-
|
|
3286
|
-
const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-
|
|
3819
|
+
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-DMbLYwj4.js"));
|
|
3820
|
+
const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-BTKKlkLT.js"));
|
|
3821
|
+
const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-ByAIoPFp.js"));
|
|
3287
3822
|
const loadMockProvider = createLazyRuntimeModule(() => import("./mock-YsvBTX-7.js"));
|
|
3288
3823
|
const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
|
|
3289
|
-
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-
|
|
3824
|
+
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-DJk-eYyf.js"));
|
|
3290
3825
|
function resolveVoiceCallConsultSessionKey(call) {
|
|
3291
3826
|
return resolveVoiceCallSessionKey({
|
|
3292
3827
|
config: call.config,
|
|
@@ -3479,6 +4014,14 @@ async function createVoiceCallRuntime(params) {
|
|
|
3479
4014
|
coreSession: cfg.session
|
|
3480
4015
|
});
|
|
3481
4016
|
const requesterSessionKey = typeof call.metadata?.requesterSessionKey === "string" ? call.metadata.requesterSessionKey : void 0;
|
|
4017
|
+
const modelLockParams = {
|
|
4018
|
+
cfg,
|
|
4019
|
+
agentRuntime,
|
|
4020
|
+
agentId,
|
|
4021
|
+
sessionKey,
|
|
4022
|
+
spawnedBy: requesterSessionKey
|
|
4023
|
+
};
|
|
4024
|
+
assertRealtimeVoiceAgentConsultModelSelectionUnlocked(modelLockParams);
|
|
3482
4025
|
const fastContext = await resolveRealtimeFastContextConsult({
|
|
3483
4026
|
cfg,
|
|
3484
4027
|
agentId,
|
|
@@ -3487,7 +4030,10 @@ async function createVoiceCallRuntime(params) {
|
|
|
3487
4030
|
args,
|
|
3488
4031
|
logger: log
|
|
3489
4032
|
});
|
|
3490
|
-
if (fastContext.handled)
|
|
4033
|
+
if (fastContext.handled) {
|
|
4034
|
+
assertRealtimeVoiceAgentConsultModelSelectionUnlocked(modelLockParams);
|
|
4035
|
+
return fastContext.result;
|
|
4036
|
+
}
|
|
3491
4037
|
const { provider: agentProvider, model } = resolveVoiceResponseModel({
|
|
3492
4038
|
voiceConfig: effectiveConfig,
|
|
3493
4039
|
agentRuntime
|
|
@@ -3594,4 +4140,4 @@ async function createVoiceCallRuntime(params) {
|
|
|
3594
4140
|
}
|
|
3595
4141
|
}
|
|
3596
4142
|
//#endregion
|
|
3597
|
-
export { mapVoiceToPolly as _, normalizeProviderStatus as a, cleanupTailscaleExposureRoute as c, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as d, chunkAudio as f, escapeXml as g, resolveUserPath as h, mapProviderStatusToEndReason as i, getTailscaleSelfInfo as l, resolveCallAgentId as m, normalizeProxyIp as n, getHeader as o, resolveVoiceResponseModel as p, isProviderStatusTerminal as r, resolveWebhookExposureStatus as s, createVoiceCallRuntime as t, setupTailscaleExposureRoute as u };
|
|
4143
|
+
export { mapVoiceToPolly as _, normalizeProviderStatus as a, resolveVoiceCallSessionKey as b, cleanupTailscaleExposureRoute as c, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as d, chunkAudio as f, escapeXml as g, resolveUserPath as h, mapProviderStatusToEndReason as i, getTailscaleSelfInfo as l, resolveCallAgentId as m, normalizeProxyIp as n, getHeader as o, resolveVoiceResponseModel as p, isProviderStatusTerminal as r, resolveWebhookExposureStatus as s, createVoiceCallRuntime as t, setupTailscaleExposureRoute as u, VoiceCallConfigSchema as v, validateProviderConfig as x, resolveVoiceCallConfig as y };
|