@openclaw/voice-call 2026.8.2 → 2026.9.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/dist/api.js +2 -2
- package/dist/cli-DNq2qUU0.js +630 -0
- package/dist/config-DQwvsxeV.js +697 -0
- package/dist/{guarded-json-api-BL8mhcJj.js → guarded-json-api-9je4i7qf.js} +1 -1
- package/dist/index.js +17 -580
- package/dist/{plivo-CgYWuWVr.js → plivo-BfKoYCMW.js} +2 -2
- package/dist/{realtime-handler-oehCkuE2.js → realtime-handler-BYWqpZ6N.js} +23 -16
- package/dist/{response-generator-BPKgHnuI.js → response-generator-C0577tL-.js} +2 -1
- package/dist/runtime-api.js +2 -2
- package/dist/{runtime-entry-CJkbbEgg.js → runtime-entry-gE2Yd4V8.js} +33 -715
- package/dist/runtime-entry.js +1 -1
- package/dist/{telnyx-BWmhDBtd.js → telnyx-rcjslHOP.js} +1 -1
- package/dist/{twilio-78mzH8Lk.js → twilio-B100ALoj.js} +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -4
|
@@ -1,22 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, sendHttpRequestRejection } from "./runtime-api.js";
|
|
2
2
|
import "./api.js";
|
|
3
|
-
import {
|
|
3
|
+
import { a as resolveVoiceCallEffectiveConfig, c as resolveVoiceCallSessionKey, f as resolveVoiceCallRealtimeTools, i as resolveVoiceCallConfig, l as resolveVoiceCallStreamExposurePaths, n as normalizeVoiceCallConfig, o as resolveVoiceCallNumberRouteKeyForCall, r as resolveTwilioAuthToken, u as validateProviderConfig } from "./config-DQwvsxeV.js";
|
|
4
4
|
import { _ as releaseRejectedProviderCall, b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, g as appendCallReplayKey, l as findCallInStore, p as persistCallRecord, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore, v as rememberManagerReplayKey, x as TerminalStates, y as reserveRejectedProviderCall } from "./store-path-C_z9zKnA.js";
|
|
5
5
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
6
6
|
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
|
|
7
|
-
import { normalizeAgentId
|
|
7
|
+
import { normalizeAgentId } 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
10
|
import { listAgentIds, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime";
|
|
11
11
|
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
|
12
|
-
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME,
|
|
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
|
+
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, assertRealtimeVoiceAgentConsultModelSelectionUnlocked, buildRealtimeVoiceAgentConsultPolicyInstructions, consultRealtimeVoiceAgent, convertPcmToMulaw8k, createTalkSessionController, recordTalkObservabilityEvent, resolveRealtimeVoiceAgentConsultToolsAllow, resolveRealtimeVoiceFastContextConsult } from "openclaw/plugin-sdk/realtime-voice";
|
|
17
13
|
import { WEBHOOK_BODY_READ_DEFAULTS, createWebhookInFlightLimiter, normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
|
18
|
-
import {
|
|
19
|
-
import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
|
14
|
+
import { MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
|
20
15
|
import fs from "node:fs";
|
|
21
16
|
import crypto from "node:crypto";
|
|
22
17
|
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
|
|
@@ -31,690 +26,6 @@ import { URL as URL$1 } from "node:url";
|
|
|
31
26
|
import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime";
|
|
32
27
|
import { WebSocket, WebSocketServer } from "ws";
|
|
33
28
|
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
|
|
53
|
-
//#region extensions/voice-call/src/realtime-defaults.ts
|
|
54
|
-
/** Baseline instructions that keep realtime calls brief and route deep work to agent consult. */
|
|
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
|
-
}
|
|
79
|
-
//#endregion
|
|
80
|
-
//#region extensions/voice-call/src/config.ts
|
|
81
|
-
/**
|
|
82
|
-
* E.164 phone number format: +[country code][number]
|
|
83
|
-
* Examples use 555 prefix (reserved for fictional numbers)
|
|
84
|
-
*/
|
|
85
|
-
const E164Schema = z.string().regex(/^\+[1-9]\d{1,14}$/, "Expected E.164 format, e.g. +15550001234");
|
|
86
|
-
/**
|
|
87
|
-
* Controls how inbound calls are handled:
|
|
88
|
-
* - "disabled": Block all inbound calls (outbound only)
|
|
89
|
-
* - "allowlist": Only accept calls from numbers in allowFrom
|
|
90
|
-
* - "pairing": Unknown callers can request pairing (future)
|
|
91
|
-
* - "open": Accept all inbound calls (dangerous!)
|
|
92
|
-
*/
|
|
93
|
-
const InboundPolicySchema = z.enum([
|
|
94
|
-
"disabled",
|
|
95
|
-
"allowlist",
|
|
96
|
-
"pairing",
|
|
97
|
-
"open"
|
|
98
|
-
]);
|
|
99
|
-
const SecretInputSchema = buildSecretInputSchema();
|
|
100
|
-
const TelnyxConfigSchema = z.object({
|
|
101
|
-
/** Telnyx API v2 key */
|
|
102
|
-
apiKey: z.string().min(1).optional(),
|
|
103
|
-
/** Telnyx connection ID (from Call Control app) */
|
|
104
|
-
connectionId: z.string().min(1).optional(),
|
|
105
|
-
/** Public key for webhook signature verification */
|
|
106
|
-
publicKey: z.string().min(1).optional()
|
|
107
|
-
}).strict();
|
|
108
|
-
const TwilioConfigSchema = z.object({
|
|
109
|
-
/** Twilio Account SID */
|
|
110
|
-
accountSid: z.string().min(1).optional(),
|
|
111
|
-
/** Twilio Auth Token */
|
|
112
|
-
authToken: SecretInputSchema.optional(),
|
|
113
|
-
/** Twilio processing Region (for example, ie1) */
|
|
114
|
-
region: z.enum(TWILIO_REGIONS).optional()
|
|
115
|
-
}).strict();
|
|
116
|
-
const PlivoConfigSchema = z.object({
|
|
117
|
-
/** Plivo Auth ID (starts with MA/SA) */
|
|
118
|
-
authId: z.string().min(1).optional(),
|
|
119
|
-
/** Plivo Auth Token */
|
|
120
|
-
authToken: z.string().min(1).optional()
|
|
121
|
-
}).strict();
|
|
122
|
-
const VoiceCallNumberRouteConfigSchema = z.object({
|
|
123
|
-
/** Greeting message for inbound calls to this number. */
|
|
124
|
-
inboundGreeting: z.string().optional(),
|
|
125
|
-
/** TTS override for inbound calls to this number. Deep-merges with global voice-call TTS. */
|
|
126
|
-
tts: TtsConfigSchema,
|
|
127
|
-
/** Agent ID to use for voice response generation for this number. */
|
|
128
|
-
agentId: z.string().min(1).optional(),
|
|
129
|
-
/** Optional model override for voice responses for this number. */
|
|
130
|
-
responseModel: z.string().optional(),
|
|
131
|
-
/** System prompt for voice responses for this number. */
|
|
132
|
-
responseSystemPrompt: z.string().optional(),
|
|
133
|
-
/** Timeout for response generation in ms for this number. */
|
|
134
|
-
responseTimeoutMs: z.number().int().positive().optional()
|
|
135
|
-
}).strict();
|
|
136
|
-
const VoiceCallServeConfigSchema = z.object({
|
|
137
|
-
/** Port to listen on */
|
|
138
|
-
port: z.number().int().positive().default(3334),
|
|
139
|
-
/** Bind address */
|
|
140
|
-
bind: z.string().default("127.0.0.1"),
|
|
141
|
-
/** Webhook path */
|
|
142
|
-
path: z.string().min(1).default("/voice/webhook")
|
|
143
|
-
}).strict().default({
|
|
144
|
-
port: 3334,
|
|
145
|
-
bind: "127.0.0.1",
|
|
146
|
-
path: "/voice/webhook"
|
|
147
|
-
});
|
|
148
|
-
const VoiceCallTunnelConfigSchema = z.object({
|
|
149
|
-
/**
|
|
150
|
-
* Tunnel provider:
|
|
151
|
-
* - "none": No tunnel (use publicUrl if set, or manual setup)
|
|
152
|
-
* - "ngrok": Use ngrok for public HTTPS tunnel
|
|
153
|
-
* - "tailscale-serve": Tailscale serve (private to tailnet)
|
|
154
|
-
* - "tailscale-funnel": Tailscale funnel (public HTTPS)
|
|
155
|
-
*/
|
|
156
|
-
provider: z.enum([
|
|
157
|
-
"none",
|
|
158
|
-
"ngrok",
|
|
159
|
-
"tailscale-serve",
|
|
160
|
-
"tailscale-funnel"
|
|
161
|
-
]).default("none"),
|
|
162
|
-
/** ngrok auth token (optional, enables longer sessions and more features) */
|
|
163
|
-
ngrokAuthToken: z.string().min(1).optional(),
|
|
164
|
-
/** ngrok custom domain (paid feature, e.g., "myapp.ngrok.io") */
|
|
165
|
-
ngrokDomain: z.string().min(1).optional(),
|
|
166
|
-
/**
|
|
167
|
-
* Allow ngrok free tier compatibility mode.
|
|
168
|
-
* When true, forwarded headers may be trusted for loopback requests
|
|
169
|
-
* to reconstruct the public ngrok URL used for signing.
|
|
170
|
-
*
|
|
171
|
-
* IMPORTANT: This does NOT bypass signature verification.
|
|
172
|
-
*/
|
|
173
|
-
allowNgrokFreeTierLoopbackBypass: z.boolean().default(false)
|
|
174
|
-
}).strict().default({
|
|
175
|
-
provider: "none",
|
|
176
|
-
allowNgrokFreeTierLoopbackBypass: false
|
|
177
|
-
});
|
|
178
|
-
const VoiceCallWebhookSecurityConfigSchema = z.object({
|
|
179
|
-
/**
|
|
180
|
-
* Allowed hostnames for webhook URL reconstruction.
|
|
181
|
-
* Only these hosts are accepted from forwarding headers.
|
|
182
|
-
*/
|
|
183
|
-
allowedHosts: z.array(z.string().min(1)).default([]),
|
|
184
|
-
/**
|
|
185
|
-
* Trust X-Forwarded-* headers without a hostname allowlist.
|
|
186
|
-
* WARNING: Only enable if you trust your proxy configuration.
|
|
187
|
-
*/
|
|
188
|
-
trustForwardingHeaders: z.boolean().default(false),
|
|
189
|
-
/**
|
|
190
|
-
* Trusted proxy IP addresses. Forwarded headers are only trusted when
|
|
191
|
-
* the remote IP matches one of these addresses.
|
|
192
|
-
*/
|
|
193
|
-
trustedProxyIPs: z.array(z.string().min(1)).default([])
|
|
194
|
-
}).strict().default({
|
|
195
|
-
allowedHosts: [],
|
|
196
|
-
trustForwardingHeaders: false,
|
|
197
|
-
trustedProxyIPs: []
|
|
198
|
-
});
|
|
199
|
-
/**
|
|
200
|
-
* Call mode determines how outbound calls behave:
|
|
201
|
-
* - "notify": Deliver message and auto-hangup after delay (one-way notification)
|
|
202
|
-
* - "conversation": Stay open for back-and-forth until explicit end or timeout
|
|
203
|
-
*/
|
|
204
|
-
const CallModeSchema = z.enum(["notify", "conversation"]);
|
|
205
|
-
const VoiceCallSessionScopeSchema = z.enum([
|
|
206
|
-
"per-phone",
|
|
207
|
-
"per-call",
|
|
208
|
-
"main"
|
|
209
|
-
]);
|
|
210
|
-
const OutboundConfigSchema = z.object({
|
|
211
|
-
/** Default call mode for outbound calls */
|
|
212
|
-
defaultMode: CallModeSchema.default("notify"),
|
|
213
|
-
/** Seconds to wait after TTS before auto-hangup in notify mode */
|
|
214
|
-
notifyHangupDelaySec: z.number().int().nonnegative().default(3)
|
|
215
|
-
}).strict().default({
|
|
216
|
-
defaultMode: "notify",
|
|
217
|
-
notifyHangupDelaySec: 3
|
|
218
|
-
});
|
|
219
|
-
const RealtimeToolSchema = z.object({
|
|
220
|
-
type: z.literal("function"),
|
|
221
|
-
name: z.string().min(1),
|
|
222
|
-
description: z.string(),
|
|
223
|
-
parameters: z.object({
|
|
224
|
-
type: z.literal("object"),
|
|
225
|
-
properties: z.record(z.string(), z.unknown()),
|
|
226
|
-
required: z.array(z.string()).optional()
|
|
227
|
-
})
|
|
228
|
-
}).strict();
|
|
229
|
-
const VoiceCallRealtimeProvidersConfigSchema = z.record(z.string(), z.record(z.string(), z.unknown())).default({});
|
|
230
|
-
const VoiceCallRealtimeToolPolicySchema = z.enum(REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES);
|
|
231
|
-
const VoiceCallRealtimeConsultPolicySchema = z.enum([
|
|
232
|
-
"auto",
|
|
233
|
-
"substantive",
|
|
234
|
-
"always"
|
|
235
|
-
]);
|
|
236
|
-
const VoiceCallRealtimeFastContextSourceSchema = z.enum(["memory", "sessions"]);
|
|
237
|
-
const VoiceCallRealtimeFastContextConfigSchema = z.object({
|
|
238
|
-
/** Enable bounded memory/session lookup before the full consult agent. */
|
|
239
|
-
enabled: z.boolean().default(false),
|
|
240
|
-
/** Hard deadline for the fast context lookup. */
|
|
241
|
-
timeoutMs: z.number().int().positive().default(800),
|
|
242
|
-
/** Maximum memory/session hits to inject into the realtime tool result. */
|
|
243
|
-
maxResults: z.number().int().positive().default(3),
|
|
244
|
-
/** Indexed sources used by the fast context lookup. */
|
|
245
|
-
sources: z.array(VoiceCallRealtimeFastContextSourceSchema).min(1).default(["memory", "sessions"]),
|
|
246
|
-
/** Fall back to the full agent consult when fast context has no answer. */
|
|
247
|
-
fallbackToConsult: z.boolean().default(false)
|
|
248
|
-
}).strict().default({
|
|
249
|
-
enabled: false,
|
|
250
|
-
timeoutMs: 800,
|
|
251
|
-
maxResults: 3,
|
|
252
|
-
sources: ["memory", "sessions"],
|
|
253
|
-
fallbackToConsult: false
|
|
254
|
-
});
|
|
255
|
-
const VoiceCallRealtimeAgentContextConfigSchema = z.object({
|
|
256
|
-
/** Inject a compact agent persona/context capsule into realtime voice instructions. */
|
|
257
|
-
enabled: z.boolean().default(false),
|
|
258
|
-
/** Maximum number of characters from the generated capsule to append. */
|
|
259
|
-
maxChars: z.number().int().positive().default(6e3),
|
|
260
|
-
/** Include configured agent identity fields. */
|
|
261
|
-
includeIdentity: z.boolean().default(true),
|
|
262
|
-
/** Include selected workspace files such as SOUL.md and IDENTITY.md. */
|
|
263
|
-
includeWorkspaceFiles: z.boolean().default(true),
|
|
264
|
-
/** Workspace-relative files to include, bounded by maxChars. */
|
|
265
|
-
files: z.array(z.string().min(1)).default([
|
|
266
|
-
"SOUL.md",
|
|
267
|
-
"IDENTITY.md",
|
|
268
|
-
"USER.md"
|
|
269
|
-
])
|
|
270
|
-
}).strict().default({
|
|
271
|
-
enabled: false,
|
|
272
|
-
maxChars: 6e3,
|
|
273
|
-
includeIdentity: true,
|
|
274
|
-
includeWorkspaceFiles: true,
|
|
275
|
-
files: [
|
|
276
|
-
"SOUL.md",
|
|
277
|
-
"IDENTITY.md",
|
|
278
|
-
"USER.md"
|
|
279
|
-
]
|
|
280
|
-
});
|
|
281
|
-
const VoiceCallRealtimeConsultThinkingLevelSchema = z.enum([
|
|
282
|
-
"off",
|
|
283
|
-
"minimal",
|
|
284
|
-
"low",
|
|
285
|
-
"medium",
|
|
286
|
-
"high",
|
|
287
|
-
"xhigh",
|
|
288
|
-
"adaptive",
|
|
289
|
-
"max",
|
|
290
|
-
"ultra"
|
|
291
|
-
]);
|
|
292
|
-
const VoiceCallStreamingProvidersConfigSchema = z.record(z.string(), z.record(z.string(), z.unknown())).default({});
|
|
293
|
-
const VoiceCallRealtimeConfigSchema = z.object({
|
|
294
|
-
/** Enable realtime voice-to-voice mode. */
|
|
295
|
-
enabled: z.boolean().default(false),
|
|
296
|
-
/** Provider id from registered realtime voice providers. */
|
|
297
|
-
provider: z.string().min(1).optional(),
|
|
298
|
-
/** Optional override for the local WebSocket route path. */
|
|
299
|
-
streamPath: z.string().min(1).optional(),
|
|
300
|
-
/** System instructions passed to the realtime provider. */
|
|
301
|
-
instructions: z.string().default(DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS),
|
|
302
|
-
/** Tool policy for the shared OpenClaw agent consult tool. */
|
|
303
|
-
toolPolicy: VoiceCallRealtimeToolPolicySchema.default("safe-read-only"),
|
|
304
|
-
/** Guidance for when the realtime model should call the OpenClaw agent consult tool. */
|
|
305
|
-
consultPolicy: VoiceCallRealtimeConsultPolicySchema.default("auto"),
|
|
306
|
-
/** Optional thinking level override for the regular agent behind realtime consults. */
|
|
307
|
-
consultThinkingLevel: VoiceCallRealtimeConsultThinkingLevelSchema.optional(),
|
|
308
|
-
/** Optional fast mode override for the regular agent behind realtime consults. */
|
|
309
|
-
consultFastMode: z.boolean().optional(),
|
|
310
|
-
/** Tool definitions exposed to the realtime provider. */
|
|
311
|
-
tools: z.array(RealtimeToolSchema).default([]),
|
|
312
|
-
/** Low-latency memory/session context for the consult tool. */
|
|
313
|
-
fastContext: VoiceCallRealtimeFastContextConfigSchema,
|
|
314
|
-
/** Bounded agent persona/context injection for the fast realtime voice path. */
|
|
315
|
-
agentContext: VoiceCallRealtimeAgentContextConfigSchema,
|
|
316
|
-
/** Provider-owned raw config blobs keyed by provider id. */
|
|
317
|
-
providers: VoiceCallRealtimeProvidersConfigSchema
|
|
318
|
-
}).strict().default({
|
|
319
|
-
enabled: false,
|
|
320
|
-
instructions: DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS,
|
|
321
|
-
toolPolicy: "safe-read-only",
|
|
322
|
-
consultPolicy: "auto",
|
|
323
|
-
tools: [],
|
|
324
|
-
fastContext: {
|
|
325
|
-
enabled: false,
|
|
326
|
-
timeoutMs: 800,
|
|
327
|
-
maxResults: 3,
|
|
328
|
-
sources: ["memory", "sessions"],
|
|
329
|
-
fallbackToConsult: false
|
|
330
|
-
},
|
|
331
|
-
agentContext: {
|
|
332
|
-
enabled: false,
|
|
333
|
-
maxChars: 6e3,
|
|
334
|
-
includeIdentity: true,
|
|
335
|
-
includeWorkspaceFiles: true,
|
|
336
|
-
files: [
|
|
337
|
-
"SOUL.md",
|
|
338
|
-
"IDENTITY.md",
|
|
339
|
-
"USER.md"
|
|
340
|
-
]
|
|
341
|
-
},
|
|
342
|
-
providers: {}
|
|
343
|
-
});
|
|
344
|
-
const VoiceCallStreamingConfigSchema = z.object({
|
|
345
|
-
/** Enable Twilio Media Streams for real-time transcription. */
|
|
346
|
-
enabled: z.boolean().default(false),
|
|
347
|
-
/** Provider id from registered realtime transcription providers. */
|
|
348
|
-
provider: z.string().min(1).optional(),
|
|
349
|
-
/** WebSocket path for media stream connections */
|
|
350
|
-
streamPath: z.string().min(1).default("/voice/stream"),
|
|
351
|
-
/** Provider-owned raw config blobs keyed by provider id. */
|
|
352
|
-
providers: VoiceCallStreamingProvidersConfigSchema,
|
|
353
|
-
/**
|
|
354
|
-
* Close unauthenticated media stream sockets if no valid `start` frame arrives in time.
|
|
355
|
-
* Protects against pre-auth idle connection hold attacks.
|
|
356
|
-
*/
|
|
357
|
-
preStartTimeoutMs: z.number().int().positive().default(5e3),
|
|
358
|
-
/** Maximum number of concurrently pending (pre-start) media stream sockets. */
|
|
359
|
-
maxPendingConnections: z.number().int().positive().default(32),
|
|
360
|
-
/** Maximum pending media stream sockets per source IP. */
|
|
361
|
-
maxPendingConnectionsPerIp: z.number().int().positive().default(4),
|
|
362
|
-
/** Hard cap for all open media stream sockets (pending + active). */
|
|
363
|
-
maxConnections: z.number().int().positive().default(128)
|
|
364
|
-
}).strict().default({
|
|
365
|
-
enabled: false,
|
|
366
|
-
streamPath: "/voice/stream",
|
|
367
|
-
providers: {},
|
|
368
|
-
preStartTimeoutMs: 5e3,
|
|
369
|
-
maxPendingConnections: 32,
|
|
370
|
-
maxPendingConnectionsPerIp: 4,
|
|
371
|
-
maxConnections: 128
|
|
372
|
-
});
|
|
373
|
-
const VoiceCallConfigSchema = z.object({
|
|
374
|
-
/** Enable voice call functionality */
|
|
375
|
-
enabled: z.boolean().default(false),
|
|
376
|
-
/** Active provider (telnyx, twilio, plivo, or mock) */
|
|
377
|
-
provider: z.enum([
|
|
378
|
-
"telnyx",
|
|
379
|
-
"twilio",
|
|
380
|
-
"plivo",
|
|
381
|
-
"mock"
|
|
382
|
-
]).optional(),
|
|
383
|
-
/** Telnyx-specific configuration */
|
|
384
|
-
telnyx: TelnyxConfigSchema.optional(),
|
|
385
|
-
/** Twilio-specific configuration */
|
|
386
|
-
twilio: TwilioConfigSchema.optional(),
|
|
387
|
-
/** Plivo-specific configuration */
|
|
388
|
-
plivo: PlivoConfigSchema.optional(),
|
|
389
|
-
/** Phone number to call from (E.164) */
|
|
390
|
-
fromNumber: E164Schema.optional(),
|
|
391
|
-
/** Default phone number to call (E.164) */
|
|
392
|
-
toNumber: E164Schema.optional(),
|
|
393
|
-
/** Inbound call policy */
|
|
394
|
-
inboundPolicy: InboundPolicySchema.default("disabled"),
|
|
395
|
-
/** Allowlist of phone numbers for inbound calls (E.164) */
|
|
396
|
-
allowFrom: z.array(E164Schema).default([]),
|
|
397
|
-
/** Greeting message for inbound calls */
|
|
398
|
-
inboundGreeting: z.string().optional(),
|
|
399
|
-
/** Per-dialed-number overrides for inbound calls. Keys are E.164 numbers. */
|
|
400
|
-
numbers: z.record(E164Schema, VoiceCallNumberRouteConfigSchema).default({}),
|
|
401
|
-
/** Outbound call configuration */
|
|
402
|
-
outbound: OutboundConfigSchema,
|
|
403
|
-
/** Maximum call duration in seconds */
|
|
404
|
-
maxDurationSeconds: z.number().int().positive().default(300),
|
|
405
|
-
/**
|
|
406
|
-
* Maximum age of a call in seconds before it is automatically reaped.
|
|
407
|
-
* Catches calls stuck before answer (for example, local mock calls that
|
|
408
|
-
* never receive provider webhooks). Set to 0 to disable.
|
|
409
|
-
*/
|
|
410
|
-
staleCallReaperSeconds: z.number().int().nonnegative().default(120),
|
|
411
|
-
/** Silence timeout for end-of-speech detection (ms) */
|
|
412
|
-
silenceTimeoutMs: z.number().int().positive().default(800),
|
|
413
|
-
/** Timeout for user transcript (ms) */
|
|
414
|
-
transcriptTimeoutMs: z.number().int().positive().default(18e4),
|
|
415
|
-
/** Ring timeout for outbound calls (ms) */
|
|
416
|
-
ringTimeoutMs: z.number().int().positive().default(3e4),
|
|
417
|
-
/** Maximum concurrent calls */
|
|
418
|
-
maxConcurrentCalls: z.number().int().positive().default(1),
|
|
419
|
-
/** Webhook server configuration */
|
|
420
|
-
serve: VoiceCallServeConfigSchema,
|
|
421
|
-
/** @deprecated Prefer tunnel config. */
|
|
422
|
-
tailscale: VoiceCallTailscaleConfigSchema,
|
|
423
|
-
/** Tunnel configuration (unified ngrok/tailscale) */
|
|
424
|
-
tunnel: VoiceCallTunnelConfigSchema,
|
|
425
|
-
/** Webhook signature reconstruction and proxy trust configuration */
|
|
426
|
-
webhookSecurity: VoiceCallWebhookSecurityConfigSchema,
|
|
427
|
-
/** Real-time audio streaming configuration */
|
|
428
|
-
streaming: VoiceCallStreamingConfigSchema,
|
|
429
|
-
/** Realtime voice-to-voice configuration */
|
|
430
|
-
realtime: VoiceCallRealtimeConfigSchema,
|
|
431
|
-
/** Session memory scope for voice conversations. */
|
|
432
|
-
sessionScope: VoiceCallSessionScopeSchema.default("per-phone"),
|
|
433
|
-
/** Public webhook URL override (if set, bypasses tunnel auto-detection) */
|
|
434
|
-
publicUrl: z.string().url().optional(),
|
|
435
|
-
/** Skip webhook signature verification (development only, NOT for production) */
|
|
436
|
-
skipSignatureVerification: z.boolean().default(false),
|
|
437
|
-
/** TTS override (deep-merges with core tts) */
|
|
438
|
-
tts: TtsConfigSchema,
|
|
439
|
-
/** Store path for call logs */
|
|
440
|
-
store: z.string().optional(),
|
|
441
|
-
/** Agent ID to use for voice response generation. Defaults to "main". */
|
|
442
|
-
agentId: z.string().min(1).optional(),
|
|
443
|
-
/** Optional model override for generating voice responses. */
|
|
444
|
-
responseModel: z.string().optional(),
|
|
445
|
-
/** System prompt for voice responses */
|
|
446
|
-
responseSystemPrompt: z.string().optional(),
|
|
447
|
-
/** Timeout for response generation in ms (default 30s) */
|
|
448
|
-
responseTimeoutMs: z.number().int().positive().default(3e4)
|
|
449
|
-
}).strict().refine(isTailscalePortAllowed, {
|
|
450
|
-
path: ["tailscale", "port"],
|
|
451
|
-
message: "Tailscale Funnel HTTPS port must be one of 443, 8443, 10000"
|
|
452
|
-
});
|
|
453
|
-
const TWILIO_AUTH_TOKEN_PATH = "plugins.entries.voice-call.config.twilio.authToken";
|
|
454
|
-
const DEFAULT_VOICE_CALL_CONFIG = VoiceCallConfigSchema.parse({});
|
|
455
|
-
function cloneDefaultVoiceCallConfig() {
|
|
456
|
-
return structuredClone(DEFAULT_VOICE_CALL_CONFIG);
|
|
457
|
-
}
|
|
458
|
-
function defaultRealtimeStreamPathForServePath(servePath) {
|
|
459
|
-
const normalized = normalizeWebhookPath(servePath);
|
|
460
|
-
if (normalized.endsWith("/webhook")) return `${normalized.slice(0, -8)}/stream/realtime`;
|
|
461
|
-
if (normalized === "/") return "/voice/stream/realtime";
|
|
462
|
-
return `${normalized}/stream/realtime`;
|
|
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
|
-
}
|
|
489
|
-
function normalizeVoiceCallTtsConfig(defaults, overrides) {
|
|
490
|
-
if (!defaults && !overrides) return;
|
|
491
|
-
return TtsConfigSchema.parse(mergeDeep(defaults ?? {}, overrides ?? {}));
|
|
492
|
-
}
|
|
493
|
-
function normalizePhoneRouteKey(phone) {
|
|
494
|
-
return phone?.replace(/\D/g, "") ?? "";
|
|
495
|
-
}
|
|
496
|
-
function resolveVoiceCallNumberRouteKey(config, phone) {
|
|
497
|
-
const routes = config.numbers;
|
|
498
|
-
if (!routes) return;
|
|
499
|
-
if (phone && Object.hasOwn(routes, phone)) return phone;
|
|
500
|
-
const normalizedPhone = normalizePhoneRouteKey(phone);
|
|
501
|
-
if (!normalizedPhone) return;
|
|
502
|
-
return Object.keys(routes).find((routeKey) => normalizePhoneRouteKey(routeKey) === normalizedPhone);
|
|
503
|
-
}
|
|
504
|
-
/** Resolve inbound-only number routing from a persisted call record. */
|
|
505
|
-
function resolveVoiceCallNumberRouteKeyForCall(call) {
|
|
506
|
-
if (call.direction !== "inbound") return;
|
|
507
|
-
const storedRouteKey = call.metadata?.numberRouteKey;
|
|
508
|
-
if (typeof storedRouteKey === "string") return storedRouteKey;
|
|
509
|
-
return call.to;
|
|
510
|
-
}
|
|
511
|
-
function resolveVoiceCallEffectiveConfig(config, phoneOrRouteKey) {
|
|
512
|
-
const numberRouteKey = resolveVoiceCallNumberRouteKey(config, phoneOrRouteKey);
|
|
513
|
-
if (!numberRouteKey) return { config };
|
|
514
|
-
const route = config.numbers[numberRouteKey];
|
|
515
|
-
if (!route) return { config };
|
|
516
|
-
return {
|
|
517
|
-
numberRouteKey,
|
|
518
|
-
config: {
|
|
519
|
-
...config,
|
|
520
|
-
...route,
|
|
521
|
-
tts: normalizeVoiceCallTtsConfig(config.tts, route.tts),
|
|
522
|
-
numbers: config.numbers
|
|
523
|
-
}
|
|
524
|
-
};
|
|
525
|
-
}
|
|
526
|
-
function sanitizeVoiceCallProviderConfigs(value) {
|
|
527
|
-
if (!value) return {};
|
|
528
|
-
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== void 0));
|
|
529
|
-
}
|
|
530
|
-
function sanitizeVoiceCallNumberRoutes(value) {
|
|
531
|
-
if (!value) return {};
|
|
532
|
-
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== void 0).map(([key, route]) => [key, VoiceCallNumberRouteConfigSchema.parse(route)]));
|
|
533
|
-
}
|
|
534
|
-
function resolveTwilioAuthToken(config) {
|
|
535
|
-
return normalizeResolvedSecretInputString({
|
|
536
|
-
value: config.twilio?.authToken,
|
|
537
|
-
path: TWILIO_AUTH_TOKEN_PATH
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
function normalizeVoiceCallConfig(config) {
|
|
541
|
-
const defaults = cloneDefaultVoiceCallConfig();
|
|
542
|
-
const serve = {
|
|
543
|
-
...defaults.serve,
|
|
544
|
-
...config.serve
|
|
545
|
-
};
|
|
546
|
-
const streamingProvider = config.streaming?.provider;
|
|
547
|
-
const streamingProviders = sanitizeVoiceCallProviderConfigs(config.streaming?.providers ?? defaults.streaming.providers);
|
|
548
|
-
const realtimeProvider = config.realtime?.provider ?? defaults.realtime.provider;
|
|
549
|
-
const realtimeProviders = sanitizeVoiceCallProviderConfigs(config.realtime?.providers ?? defaults.realtime.providers);
|
|
550
|
-
const realtimeFastContext = {
|
|
551
|
-
...defaults.realtime.fastContext,
|
|
552
|
-
...config.realtime?.fastContext,
|
|
553
|
-
sources: config.realtime?.fastContext?.sources ?? defaults.realtime.fastContext.sources
|
|
554
|
-
};
|
|
555
|
-
const realtimeAgentContext = {
|
|
556
|
-
...defaults.realtime.agentContext,
|
|
557
|
-
...config.realtime?.agentContext,
|
|
558
|
-
files: config.realtime?.agentContext?.files ?? defaults.realtime.agentContext.files
|
|
559
|
-
};
|
|
560
|
-
return {
|
|
561
|
-
...defaults,
|
|
562
|
-
...config,
|
|
563
|
-
allowFrom: config.allowFrom ?? defaults.allowFrom,
|
|
564
|
-
numbers: sanitizeVoiceCallNumberRoutes(config.numbers ?? defaults.numbers),
|
|
565
|
-
outbound: {
|
|
566
|
-
...defaults.outbound,
|
|
567
|
-
...config.outbound
|
|
568
|
-
},
|
|
569
|
-
serve,
|
|
570
|
-
tailscale: {
|
|
571
|
-
...defaults.tailscale,
|
|
572
|
-
...config.tailscale
|
|
573
|
-
},
|
|
574
|
-
tunnel: {
|
|
575
|
-
...defaults.tunnel,
|
|
576
|
-
...config.tunnel
|
|
577
|
-
},
|
|
578
|
-
webhookSecurity: {
|
|
579
|
-
...defaults.webhookSecurity,
|
|
580
|
-
...config.webhookSecurity,
|
|
581
|
-
allowedHosts: config.webhookSecurity?.allowedHosts ?? defaults.webhookSecurity.allowedHosts,
|
|
582
|
-
trustedProxyIPs: config.webhookSecurity?.trustedProxyIPs ?? defaults.webhookSecurity.trustedProxyIPs
|
|
583
|
-
},
|
|
584
|
-
streaming: {
|
|
585
|
-
...defaults.streaming,
|
|
586
|
-
...config.streaming,
|
|
587
|
-
provider: streamingProvider,
|
|
588
|
-
providers: streamingProviders
|
|
589
|
-
},
|
|
590
|
-
realtime: {
|
|
591
|
-
...defaults.realtime,
|
|
592
|
-
...config.realtime,
|
|
593
|
-
provider: realtimeProvider,
|
|
594
|
-
streamPath: config.realtime?.streamPath ?? defaultRealtimeStreamPathForServePath(serve.path ?? defaults.serve.path),
|
|
595
|
-
tools: config.realtime?.tools ?? defaults.realtime.tools,
|
|
596
|
-
consultThinkingLevel: VoiceCallRealtimeConsultThinkingLevelSchema.optional().parse(config.realtime?.consultThinkingLevel ?? defaults.realtime.consultThinkingLevel),
|
|
597
|
-
consultFastMode: config.realtime?.consultFastMode ?? defaults.realtime.consultFastMode,
|
|
598
|
-
fastContext: realtimeFastContext,
|
|
599
|
-
agentContext: realtimeAgentContext,
|
|
600
|
-
providers: realtimeProviders
|
|
601
|
-
},
|
|
602
|
-
tts: normalizeVoiceCallTtsConfig(defaults.tts, config.tts)
|
|
603
|
-
};
|
|
604
|
-
}
|
|
605
|
-
function resolveVoiceCallSessionKey(params) {
|
|
606
|
-
const explicit = params.explicitSessionKey?.trim();
|
|
607
|
-
if (explicit) return resolveVoiceCallAgentSessionKey({
|
|
608
|
-
config: params.config,
|
|
609
|
-
sessionKey: explicit,
|
|
610
|
-
coreSession: params.coreSession
|
|
611
|
-
});
|
|
612
|
-
if (params.config.sessionScope === "main") return resolveVoiceCallAgentSessionKey({
|
|
613
|
-
config: params.config,
|
|
614
|
-
sessionKey: "main",
|
|
615
|
-
coreSession: params.coreSession
|
|
616
|
-
});
|
|
617
|
-
const prefix = `agent:${normalizeAgentId(params.config.agentId)}:voice`;
|
|
618
|
-
if (params.config.sessionScope === "per-call") return `${prefix}:call:${params.callId}`.toLowerCase();
|
|
619
|
-
const normalizedPhone = params.phone?.replace(/\D/g, "");
|
|
620
|
-
return (normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`).toLowerCase();
|
|
621
|
-
}
|
|
622
|
-
/** Resolve persisted or integration-provided keys into the configured agent namespace. */
|
|
623
|
-
function resolveVoiceCallAgentSessionKey(params) {
|
|
624
|
-
const sessionKey = params.sessionKey.trim();
|
|
625
|
-
if (!sessionKey) throw new Error("Voice Call session key cannot be empty");
|
|
626
|
-
const lower = sessionKey.toLowerCase();
|
|
627
|
-
const agentId = normalizeAgentId(params.config.agentId);
|
|
628
|
-
if (lower === "global" || lower === "unknown") return lower;
|
|
629
|
-
const parsedInput = parseAgentSessionKey(sessionKey);
|
|
630
|
-
let normalizedScopedKey;
|
|
631
|
-
if (parsedInput && normalizeAgentId(parsedInput.agentId) === parsedInput.agentId && parsedInput.agentId === agentId) normalizedScopedKey = `agent:${parsedInput.agentId}:${parsedInput.rest}`;
|
|
632
|
-
else {
|
|
633
|
-
const wrappedInput = parseAgentSessionKey(`agent:${agentId}:${sessionKey}`);
|
|
634
|
-
if (!wrappedInput) throw new Error("Voice Call session key could not be normalized");
|
|
635
|
-
normalizedScopedKey = `agent:${agentId}:${wrappedInput.rest}`;
|
|
636
|
-
}
|
|
637
|
-
const canonicalMain = canonicalizeMainSessionAlias({
|
|
638
|
-
cfg: { session: params.coreSession },
|
|
639
|
-
agentId,
|
|
640
|
-
sessionKey: normalizedScopedKey
|
|
641
|
-
});
|
|
642
|
-
return canonicalMain === normalizedScopedKey ? normalizedScopedKey : canonicalMain;
|
|
643
|
-
}
|
|
644
|
-
/**
|
|
645
|
-
* Resolves the configuration by merging environment variables into missing fields.
|
|
646
|
-
* Returns a new configuration object with environment variables applied.
|
|
647
|
-
*/
|
|
648
|
-
function resolveVoiceCallConfig(config) {
|
|
649
|
-
const resolved = normalizeVoiceCallConfig(config);
|
|
650
|
-
if (resolved.provider === "telnyx") {
|
|
651
|
-
resolved.telnyx = resolved.telnyx ?? {};
|
|
652
|
-
resolved.telnyx.apiKey = resolved.telnyx.apiKey ?? resolveSpeechProviderApiKey(process.env.TELNYX_API_KEY);
|
|
653
|
-
resolved.telnyx.connectionId = resolved.telnyx.connectionId ?? resolveSpeechProviderApiKey(process.env.TELNYX_CONNECTION_ID);
|
|
654
|
-
resolved.telnyx.publicKey = resolved.telnyx.publicKey ?? resolveSpeechProviderApiKey(process.env.TELNYX_PUBLIC_KEY);
|
|
655
|
-
}
|
|
656
|
-
if (resolved.provider === "twilio") {
|
|
657
|
-
resolved.fromNumber = resolved.fromNumber ?? resolveSpeechProviderApiKey(process.env.TWILIO_FROM_NUMBER);
|
|
658
|
-
resolved.twilio = resolved.twilio ?? {};
|
|
659
|
-
resolved.twilio.accountSid = resolved.twilio.accountSid ?? resolveSpeechProviderApiKey(process.env.TWILIO_ACCOUNT_SID);
|
|
660
|
-
resolved.twilio.authToken = resolved.twilio.authToken ?? resolveSpeechProviderApiKey(process.env.TWILIO_AUTH_TOKEN);
|
|
661
|
-
}
|
|
662
|
-
if (resolved.provider === "plivo") {
|
|
663
|
-
resolved.plivo = resolved.plivo ?? {};
|
|
664
|
-
resolved.plivo.authId = resolved.plivo.authId ?? resolveSpeechProviderApiKey(process.env.PLIVO_AUTH_ID);
|
|
665
|
-
resolved.plivo.authToken = resolved.plivo.authToken ?? resolveSpeechProviderApiKey(process.env.PLIVO_AUTH_TOKEN);
|
|
666
|
-
}
|
|
667
|
-
resolved.tunnel = resolved.tunnel ?? {
|
|
668
|
-
provider: "none",
|
|
669
|
-
allowNgrokFreeTierLoopbackBypass: false
|
|
670
|
-
};
|
|
671
|
-
resolved.tunnel.allowNgrokFreeTierLoopbackBypass = resolved.tunnel.allowNgrokFreeTierLoopbackBypass ?? false;
|
|
672
|
-
resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? resolveSpeechProviderApiKey(process.env.NGROK_AUTHTOKEN);
|
|
673
|
-
resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain ?? resolveSpeechProviderApiKey(process.env.NGROK_DOMAIN);
|
|
674
|
-
resolved.webhookSecurity = resolved.webhookSecurity ?? {
|
|
675
|
-
allowedHosts: [],
|
|
676
|
-
trustForwardingHeaders: false,
|
|
677
|
-
trustedProxyIPs: []
|
|
678
|
-
};
|
|
679
|
-
resolved.webhookSecurity.allowedHosts = resolved.webhookSecurity.allowedHosts ?? [];
|
|
680
|
-
resolved.webhookSecurity.trustForwardingHeaders = resolved.webhookSecurity.trustForwardingHeaders ?? false;
|
|
681
|
-
resolved.webhookSecurity.trustedProxyIPs = resolved.webhookSecurity.trustedProxyIPs ?? [];
|
|
682
|
-
return normalizeVoiceCallConfig(resolved);
|
|
683
|
-
}
|
|
684
|
-
/**
|
|
685
|
-
* Validate that the configuration has all required fields for the selected provider.
|
|
686
|
-
*/
|
|
687
|
-
function validateProviderConfig(config) {
|
|
688
|
-
const errors = [];
|
|
689
|
-
if (!config.enabled) return {
|
|
690
|
-
valid: true,
|
|
691
|
-
errors: []
|
|
692
|
-
};
|
|
693
|
-
if (!config.provider) errors.push("plugins.entries.voice-call.config.provider is required");
|
|
694
|
-
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");
|
|
695
|
-
if (config.provider === "telnyx") {
|
|
696
|
-
if (!config.telnyx?.apiKey) errors.push("plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)");
|
|
697
|
-
if (!config.telnyx?.connectionId) errors.push("plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)");
|
|
698
|
-
if (!config.skipSignatureVerification && !config.telnyx?.publicKey) errors.push("plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)");
|
|
699
|
-
}
|
|
700
|
-
if (config.provider === "twilio") {
|
|
701
|
-
if (!config.twilio?.accountSid) errors.push("plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)");
|
|
702
|
-
if (!hasConfiguredSecretInput(config.twilio?.authToken)) errors.push("plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)");
|
|
703
|
-
}
|
|
704
|
-
if (config.provider === "plivo") {
|
|
705
|
-
if (!config.plivo?.authId) errors.push("plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)");
|
|
706
|
-
if (!config.plivo?.authToken) errors.push("plugins.entries.voice-call.config.plivo.authToken is required (or set PLIVO_AUTH_TOKEN env)");
|
|
707
|
-
}
|
|
708
|
-
if (config.realtime.enabled && config.inboundPolicy === "disabled") errors.push("plugins.entries.voice-call.config.inboundPolicy must not be \"disabled\" when realtime.enabled is true");
|
|
709
|
-
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");
|
|
710
|
-
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");
|
|
711
|
-
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");
|
|
712
|
-
return {
|
|
713
|
-
valid: errors.length === 0,
|
|
714
|
-
errors
|
|
715
|
-
};
|
|
716
|
-
}
|
|
717
|
-
//#endregion
|
|
718
29
|
//#region extensions/voice-call/src/allowlist.ts
|
|
719
30
|
/** Normalize a phone number to digits only. */
|
|
720
31
|
function normalizePhoneNumber(input) {
|
|
@@ -2028,6 +1339,13 @@ async function resolveRealtimeFastContextConsult(params) {
|
|
|
2028
1339
|
}
|
|
2029
1340
|
//#endregion
|
|
2030
1341
|
//#region extensions/voice-call/src/resolve-call-agent-id.ts
|
|
1342
|
+
/** Setup and startup must resolve the same owner before provisioning telephony. */
|
|
1343
|
+
function resolveVoiceCallAgentId(config, coreConfig) {
|
|
1344
|
+
return config.agentId ? normalizeAgentId(config.agentId) : resolveDefaultAgentId(coreConfig, {
|
|
1345
|
+
surface: "Voice Call",
|
|
1346
|
+
hint: "Set plugins.entries.voice-call.config.agentId to a configured agent ID."
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
2031
1349
|
/** Keep one agent owner for the full call, including legacy stored records. */
|
|
2032
1350
|
function resolveCallAgentId(call, config) {
|
|
2033
1351
|
return normalizeAgentId(call.agentId ?? config.agentId);
|
|
@@ -3369,7 +2687,7 @@ const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
|
|
|
3369
2687
|
const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
|
|
3370
2688
|
const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
|
|
3371
2689
|
const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
|
|
3372
|
-
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-
|
|
2690
|
+
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-C0577tL-.js"));
|
|
3373
2691
|
function appendRecentTalkEventMetadata(call, event) {
|
|
3374
2692
|
const metadata = call.metadata ?? {};
|
|
3375
2693
|
const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
|
|
@@ -3770,10 +3088,10 @@ var VoiceCallWebhookServer = class {
|
|
|
3770
3088
|
* Handle incoming HTTP request.
|
|
3771
3089
|
*/
|
|
3772
3090
|
async handleRequest(req, res, webhookPath) {
|
|
3773
|
-
const payload = await this.runWebhookPipeline(req, webhookPath);
|
|
3774
|
-
this.writeWebhookResponse(res, payload);
|
|
3091
|
+
const payload = await this.runWebhookPipeline(req, webhookPath, res);
|
|
3092
|
+
if (payload) this.writeWebhookResponse(res, payload);
|
|
3775
3093
|
}
|
|
3776
|
-
async runWebhookPipeline(req, webhookPath) {
|
|
3094
|
+
async runWebhookPipeline(req, webhookPath, res) {
|
|
3777
3095
|
const url = buildRequestUrl(req.url);
|
|
3778
3096
|
if (url.pathname === "/voice/hold-music") return {
|
|
3779
3097
|
statusCode: 200,
|
|
@@ -3815,14 +3133,14 @@ var VoiceCallWebhookServer = class {
|
|
|
3815
3133
|
try {
|
|
3816
3134
|
body = await this.readBody(req, MAX_WEBHOOK_BODY_BYTES, WEBHOOK_BODY_TIMEOUT_MS);
|
|
3817
3135
|
} catch (err) {
|
|
3818
|
-
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE"))
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
}
|
|
3822
|
-
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT"))
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
}
|
|
3136
|
+
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
|
3137
|
+
await sendHttpRequestRejection(req, res, 413, "Payload Too Large");
|
|
3138
|
+
return null;
|
|
3139
|
+
}
|
|
3140
|
+
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
|
3141
|
+
await sendHttpRequestRejection(req, res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
|
|
3142
|
+
return null;
|
|
3143
|
+
}
|
|
3826
3144
|
throw err;
|
|
3827
3145
|
}
|
|
3828
3146
|
const ctx = {
|
|
@@ -4020,10 +3338,11 @@ var VoiceCallWebhookServer = class {
|
|
|
4020
3338
|
/**
|
|
4021
3339
|
* Read request body as string with timeout protection.
|
|
4022
3340
|
*/
|
|
4023
|
-
readBody(req, maxBytes, timeoutMs
|
|
3341
|
+
readBody(req, maxBytes, timeoutMs) {
|
|
4024
3342
|
return readRequestBodyWithLimit(req, {
|
|
4025
3343
|
maxBytes,
|
|
4026
|
-
timeoutMs
|
|
3344
|
+
timeoutMs,
|
|
3345
|
+
destroyOnLimit: false
|
|
4027
3346
|
});
|
|
4028
3347
|
}
|
|
4029
3348
|
/**
|
|
@@ -4094,12 +3413,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
|
|
|
4094
3413
|
"Do not print secret values or dump environment variables; only check whether required configuration is present.",
|
|
4095
3414
|
"Be accurate, brief, and speakable."
|
|
4096
3415
|
].join(" ");
|
|
4097
|
-
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-
|
|
4098
|
-
const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-
|
|
4099
|
-
const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-
|
|
3416
|
+
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-rcjslHOP.js"));
|
|
3417
|
+
const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-B100ALoj.js"));
|
|
3418
|
+
const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-BfKoYCMW.js"));
|
|
4100
3419
|
const loadMockProvider = createLazyRuntimeModule(() => import("./mock-C57qj18W.js"));
|
|
4101
3420
|
const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
|
|
4102
|
-
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-
|
|
3421
|
+
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-BYWqpZ6N.js"));
|
|
4103
3422
|
function resolveVoiceCallConsultSessionKey(call) {
|
|
4104
3423
|
return resolveVoiceCallSessionKey({
|
|
4105
3424
|
config: call.config,
|
|
@@ -4250,10 +3569,9 @@ async function createVoiceCallRuntime(params) {
|
|
|
4250
3569
|
};
|
|
4251
3570
|
const cfg = fullConfig ?? coreConfig;
|
|
4252
3571
|
const unresolvedConfig = resolveVoiceCallConfig(rawConfig);
|
|
4253
|
-
const configuredAgentId = unresolvedConfig.agentId ? normalizeAgentId(unresolvedConfig.agentId) : resolveDefaultAgentId(cfg);
|
|
4254
3572
|
const config = {
|
|
4255
3573
|
...unresolvedConfig,
|
|
4256
|
-
agentId:
|
|
3574
|
+
agentId: resolveVoiceCallAgentId(unresolvedConfig, cfg)
|
|
4257
3575
|
};
|
|
4258
3576
|
if (!config.enabled) throw new Error("Voice call disabled. Enable the plugin entry in config.");
|
|
4259
3577
|
if (config.skipSignatureVerification) log.warn("[voice-call] SECURITY WARNING: skipSignatureVerification=true disables webhook signature verification (development only). Do not use in production.");
|
|
@@ -4442,4 +3760,4 @@ async function createVoiceCallRuntime(params) {
|
|
|
4442
3760
|
}
|
|
4443
3761
|
}
|
|
4444
3762
|
//#endregion
|
|
4445
|
-
export {
|
|
3763
|
+
export { resolveUserPath as _, normalizeProviderStatus as a, resolveWebhookExposureStatus as c, setupTailscaleExposureRoutes as d, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as f, resolveVoiceCallAgentId 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, escapeXml as v, mapVoiceToPolly as y };
|