@openclaw/voice-call 2026.9.1-beta.1 → 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/README.md +1 -1
- package/dist/api.js +2 -2
- package/dist/cli-DNq2qUU0.js +630 -0
- package/dist/config-DQwvsxeV.js +697 -0
- package/dist/doctor-contract-api.js +9 -6
- package/dist/{guarded-json-api-BmMfqKzQ.js → guarded-json-api-9je4i7qf.js} +10 -7
- package/dist/index.js +18 -582
- package/dist/{mock-Dv7KxYGt.js → mock-C57qj18W.js} +1 -1
- package/dist/{plivo-CNPyQgvw.js → plivo-BfKoYCMW.js} +2 -2
- package/dist/{realtime-handler-DYTBiNVr.js → realtime-handler-BYWqpZ6N.js} +67 -45
- package/dist/{response-generator-BCm6S1G6.js → response-generator-C0577tL-.js} +3 -3
- package/dist/runtime-api.js +2 -2
- package/dist/{runtime-entry-ptAKgGER.js → runtime-entry-gE2Yd4V8.js} +138 -766
- package/dist/runtime-entry.js +1 -1
- package/dist/setup-api.js +58 -38
- package/dist/{store-path-51_oC_xn.js → store-path-C_z9zKnA.js} +11 -21
- package/dist/{telnyx-DWpKHVeV.js → telnyx-rcjslHOP.js} +1 -1
- package/dist/{twilio-DUIVX0Eb.js → twilio-B100ALoj.js} +5 -4
- package/dist/{webhook-replay-FQrEGVSM.js → webhook-replay-CCQSoBNF.js} +1 -1
- package/node_modules/typebox/build/format/email.mjs +1 -1
- package/node_modules/typebox/build/format/idna/idn-hostname.mjs +19 -1
- package/node_modules/typebox/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +8 -6
|
@@ -1,22 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, sendHttpRequestRejection } from "./runtime-api.js";
|
|
2
2
|
import "./api.js";
|
|
3
|
-
import {
|
|
4
|
-
import { _ as releaseRejectedProviderCall, b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, g as appendCallReplayKey, l as
|
|
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
|
+
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) {
|
|
@@ -1077,8 +388,8 @@ async function initiateCall(ctx, to, sessionKey, options) {
|
|
|
1077
388
|
...requesterSessionKey ? { requesterSessionKey } : {}
|
|
1078
389
|
}
|
|
1079
390
|
};
|
|
1080
|
-
ctx.activeCalls.set(callId, callRecord);
|
|
1081
391
|
persistCallRecord(ctx.storePath, callRecord);
|
|
392
|
+
ctx.activeCalls.set(callId, callRecord);
|
|
1082
393
|
try {
|
|
1083
394
|
let inlineTwiml;
|
|
1084
395
|
let preConnectTwiml;
|
|
@@ -1109,10 +420,12 @@ async function initiateCall(ctx, to, sessionKey, options) {
|
|
|
1109
420
|
streamAuthToken: streamSession.token
|
|
1110
421
|
} : {}
|
|
1111
422
|
});
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
423
|
+
if (ctx.activeCalls.get(callId) === callRecord && !callRecord.providerCallId) {
|
|
424
|
+
callRecord.providerCallId = result.providerCallId;
|
|
425
|
+
ctx.providerCallIdMap.set(result.providerCallId, callId);
|
|
426
|
+
persistCallRecord(ctx.storePath, callRecord);
|
|
427
|
+
}
|
|
428
|
+
console.log(`[voice-call] Outbound call initiated: callId=${callId} providerCallId=${callRecord.providerCallId ?? result.providerCallId} mode=${mode} preConnectDtmf=${preConnectTwiml ? "yes" : "no"} initialMessage=${initialMessage ? "yes" : "no"}`);
|
|
1116
429
|
return {
|
|
1117
430
|
callId,
|
|
1118
431
|
success: true
|
|
@@ -1442,7 +755,22 @@ function processEvent(ctx, event) {
|
|
|
1442
755
|
providerCallIdMap: ctx.providerCallIdMap,
|
|
1443
756
|
callIdOrProviderCallId: event.callId
|
|
1444
757
|
});
|
|
1445
|
-
|
|
758
|
+
let providerCallId = event.providerCallId;
|
|
759
|
+
let retained;
|
|
760
|
+
if (!call) {
|
|
761
|
+
retained = findCallInStore(ctx.storePath, event.callId);
|
|
762
|
+
if (!retained && providerCallId && providerCallId !== event.callId) retained = findCallInStore(ctx.storePath, providerCallId);
|
|
763
|
+
if (retained && retained.metadata?.rejectionReason !== "inbound-policy") {
|
|
764
|
+
call = ctx.activeCalls.get(retained.callId);
|
|
765
|
+
if (!call) return TerminalStates.has(retained.state) ? { kind: "ignored" } : {
|
|
766
|
+
kind: "ignored",
|
|
767
|
+
replayable: true
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if (call && providerCallId && providerCallId !== call.providerCallId) {
|
|
772
|
+
if ((providerCallId === event.callId && retained ? retained : findCallInStore(ctx.storePath, providerCallId))?.callId === call.callId) providerCallId = call.providerCallId;
|
|
773
|
+
}
|
|
1446
774
|
const eventDirection = event.direction === "inbound" || event.direction === "outbound" ? event.direction : void 0;
|
|
1447
775
|
if (!call && providerCallId && eventDirection) {
|
|
1448
776
|
if (eventDirection === "inbound" && !shouldAcceptInbound(ctx.config, event.from)) {
|
|
@@ -1514,14 +842,14 @@ function processEvent(ctx, event) {
|
|
|
1514
842
|
}
|
|
1515
843
|
};
|
|
1516
844
|
const publishProviderCallId = (terminal = false) => {
|
|
1517
|
-
if (!
|
|
1518
|
-
if (!terminal) ctx.providerCallIdMap.set(
|
|
845
|
+
if (!providerCallId || providerCallId === previousCall.providerCallId) return;
|
|
846
|
+
if (!terminal) ctx.providerCallIdMap.set(providerCallId, activeCall.callId);
|
|
1519
847
|
if (previousCall.providerCallId) {
|
|
1520
848
|
if (ctx.providerCallIdMap.get(previousCall.providerCallId) === activeCall.callId) ctx.providerCallIdMap.delete(previousCall.providerCallId);
|
|
1521
849
|
}
|
|
1522
850
|
};
|
|
1523
851
|
try {
|
|
1524
|
-
if (
|
|
852
|
+
if (providerCallId && providerCallId !== activeCall.providerCallId) activeCall.providerCallId = providerCallId;
|
|
1525
853
|
if (shouldCommitReplayKey) appendCallReplayKey(activeCall.processedEventIds, dedupeKey);
|
|
1526
854
|
switch (event.type) {
|
|
1527
855
|
case "call.initiated": {
|
|
@@ -1587,6 +915,7 @@ function processEvent(ctx, event) {
|
|
|
1587
915
|
resolveTranscriptWaiter(ctx, activeCall.callId, event.transcript, event.turnToken);
|
|
1588
916
|
});
|
|
1589
917
|
}
|
|
918
|
+
if (event.transcript.trim()) effects.push(() => ctx.onCallerSpeech?.(activeCall));
|
|
1590
919
|
prepareLiveDurationTimer();
|
|
1591
920
|
transitionState(activeCall, "listening");
|
|
1592
921
|
break;
|
|
@@ -1619,7 +948,6 @@ function processEvent(ctx, event) {
|
|
|
1619
948
|
kind: "processed",
|
|
1620
949
|
replayable: true
|
|
1621
950
|
};
|
|
1622
|
-
break;
|
|
1623
951
|
}
|
|
1624
952
|
persistCallRecord(ctx.storePath, activeCall);
|
|
1625
953
|
publishProviderCallId();
|
|
@@ -1686,6 +1014,7 @@ var CallManager = class {
|
|
|
1686
1014
|
this.transcriptWaiters = /* @__PURE__ */ new Map();
|
|
1687
1015
|
this.maxDurationTimers = /* @__PURE__ */ new Map();
|
|
1688
1016
|
this.initialMessageInFlight = /* @__PURE__ */ new Set();
|
|
1017
|
+
this.autoResponseOwners = /* @__PURE__ */ new WeakMap();
|
|
1689
1018
|
this.config = config;
|
|
1690
1019
|
this.coreSession = coreSession;
|
|
1691
1020
|
this.storePath = resolveDefaultStoreBase(config, storePath);
|
|
@@ -1857,6 +1186,7 @@ var CallManager = class {
|
|
|
1857
1186
|
transcriptWaiters: this.transcriptWaiters,
|
|
1858
1187
|
maxDurationTimers: this.maxDurationTimers,
|
|
1859
1188
|
initialMessageInFlight: this.initialMessageInFlight,
|
|
1189
|
+
onCallerSpeech: (call) => this.invalidateAutoResponse(call),
|
|
1860
1190
|
onCallAnswered: (call) => {
|
|
1861
1191
|
this.maybeSpeakInitialMessageOnAnswered(call);
|
|
1862
1192
|
},
|
|
@@ -1869,6 +1199,19 @@ var CallManager = class {
|
|
|
1869
1199
|
processEvent(event) {
|
|
1870
1200
|
return processEvent(this.getContext(), event);
|
|
1871
1201
|
}
|
|
1202
|
+
createAutoResponseGuard(call) {
|
|
1203
|
+
const owner = Symbol("automatic response");
|
|
1204
|
+
this.autoResponseOwners.set(call, owner);
|
|
1205
|
+
return {
|
|
1206
|
+
isCurrent: () => this.activeCalls.get(call.callId) === call && !TerminalStates.has(call.state) && this.autoResponseOwners.get(call) === owner,
|
|
1207
|
+
release: () => {
|
|
1208
|
+
if (this.autoResponseOwners.get(call) === owner) this.autoResponseOwners.delete(call);
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
invalidateAutoResponse(call) {
|
|
1213
|
+
this.autoResponseOwners.delete(call);
|
|
1214
|
+
}
|
|
1872
1215
|
shouldDeferConversationInitialMessageUntilStreamConnect() {
|
|
1873
1216
|
if (!this.provider || this.provider.name !== "twilio" || !this.config.streaming.enabled) return false;
|
|
1874
1217
|
const streamAwareProvider = this.provider;
|
|
@@ -1913,8 +1256,7 @@ var CallManager = class {
|
|
|
1913
1256
|
async getCallFromMemoryOrStore(callId) {
|
|
1914
1257
|
const active = this.getCall(callId) ?? this.getCallByProviderCallId(callId);
|
|
1915
1258
|
if (active) return active;
|
|
1916
|
-
|
|
1917
|
-
return persisted.byCallId ?? persisted.byProviderCallId;
|
|
1259
|
+
return findCallInStore(this.storePath, callId);
|
|
1918
1260
|
}
|
|
1919
1261
|
/**
|
|
1920
1262
|
* Get call history (from persisted logs).
|
|
@@ -1997,6 +1339,13 @@ async function resolveRealtimeFastContextConsult(params) {
|
|
|
1997
1339
|
}
|
|
1998
1340
|
//#endregion
|
|
1999
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
|
+
}
|
|
2000
1349
|
/** Keep one agent owner for the full call, including legacy stored records. */
|
|
2001
1350
|
function resolveCallAgentId(call, config) {
|
|
2002
1351
|
return normalizeAgentId(call.agentId ?? config.agentId);
|
|
@@ -2106,7 +1455,7 @@ function formatBoundedChildOutput(output) {
|
|
|
2106
1455
|
}
|
|
2107
1456
|
//#endregion
|
|
2108
1457
|
//#region extensions/voice-call/src/webhook/tailscale.ts
|
|
2109
|
-
const TAILSCALE_COMMAND_STDOUT_MAX_BYTES =
|
|
1458
|
+
const TAILSCALE_COMMAND_STDOUT_MAX_BYTES = 4194304;
|
|
2110
1459
|
const DEFAULT_TAILSCALE_HTTPS_PORT = 443;
|
|
2111
1460
|
function buildTailscaleExposureArgs(opts) {
|
|
2112
1461
|
if (!opts.localUrl && opts.port === DEFAULT_TAILSCALE_HTTPS_PORT) return [
|
|
@@ -2506,8 +1855,8 @@ const DEFAULT_PRE_START_TIMEOUT_MS = 5e3;
|
|
|
2506
1855
|
const DEFAULT_MAX_PENDING_CONNECTIONS = 32;
|
|
2507
1856
|
const DEFAULT_MAX_PENDING_CONNECTIONS_PER_IP = 4;
|
|
2508
1857
|
const DEFAULT_MAX_CONNECTIONS = 128;
|
|
2509
|
-
const MAX_INBOUND_MESSAGE_BYTES =
|
|
2510
|
-
const MAX_WS_BUFFERED_BYTES =
|
|
1858
|
+
const MAX_INBOUND_MESSAGE_BYTES = 65536;
|
|
1859
|
+
const MAX_WS_BUFFERED_BYTES = 1048576;
|
|
2511
1860
|
const MAX_PENDING_TTS_OPERATIONS_PER_STREAM = 8;
|
|
2512
1861
|
const MAX_IGNORED_PLAYBACK_MARKS_PER_STREAM = 64;
|
|
2513
1862
|
const PLAYBACK_MARK_TIMEOUT_GRACE_MS = 2e3;
|
|
@@ -2670,10 +2019,7 @@ var MediaStreamHandler = class {
|
|
|
2670
2019
|
session = null;
|
|
2671
2020
|
}
|
|
2672
2021
|
break;
|
|
2673
|
-
case "mark":
|
|
2674
|
-
if (session && message.mark?.name) this.acknowledgePlaybackMark(session.streamSid, message.mark.name);
|
|
2675
|
-
break;
|
|
2676
|
-
case "clear": break;
|
|
2022
|
+
case "mark": if (session && message.mark?.name) this.acknowledgePlaybackMark(session.streamSid, message.mark.name);
|
|
2677
2023
|
}
|
|
2678
2024
|
} catch (error) {
|
|
2679
2025
|
console.error("[MediaStream] Error processing message:", error);
|
|
@@ -2735,7 +2081,7 @@ var MediaStreamHandler = class {
|
|
|
2735
2081
|
role: "user"
|
|
2736
2082
|
}
|
|
2737
2083
|
});
|
|
2738
|
-
this.config.onPartialTranscript?.(callSid, partial);
|
|
2084
|
+
this.config.onPartialTranscript?.(callSid, partial, streamSid);
|
|
2739
2085
|
},
|
|
2740
2086
|
onTranscript: (transcript) => {
|
|
2741
2087
|
const session = this.sessions.get(streamSid);
|
|
@@ -2762,12 +2108,12 @@ var MediaStreamHandler = class {
|
|
|
2762
2108
|
}
|
|
2763
2109
|
});
|
|
2764
2110
|
}
|
|
2765
|
-
this.config.onTranscript?.(callSid, transcript);
|
|
2111
|
+
this.config.onTranscript?.(callSid, transcript, streamSid);
|
|
2766
2112
|
},
|
|
2767
2113
|
onSpeechStart: () => {
|
|
2768
2114
|
const session = this.sessions.get(streamSid);
|
|
2769
2115
|
if (session) this.ensureActiveTurn(session);
|
|
2770
|
-
this.config.onSpeechStart?.(callSid);
|
|
2116
|
+
this.config.onSpeechStart?.(callSid, streamSid);
|
|
2771
2117
|
},
|
|
2772
2118
|
onError: (error) => {
|
|
2773
2119
|
console.warn("[MediaStream] Transcription session error:", error.message);
|
|
@@ -3341,7 +2687,7 @@ const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
|
|
|
3341
2687
|
const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
|
|
3342
2688
|
const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
|
|
3343
2689
|
const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
|
|
3344
|
-
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-
|
|
2690
|
+
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-C0577tL-.js"));
|
|
3345
2691
|
function appendRecentTalkEventMetadata(call, event) {
|
|
3346
2692
|
const metadata = call.metadata ?? {};
|
|
3347
2693
|
const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
|
|
@@ -3398,7 +2744,7 @@ function buildTwilioReplayTwiML() {
|
|
|
3398
2744
|
body: "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response></Response>"
|
|
3399
2745
|
};
|
|
3400
2746
|
}
|
|
3401
|
-
const WEBHOOK_REPLAY_RESPONSE_TTL_MS =
|
|
2747
|
+
const WEBHOOK_REPLAY_RESPONSE_TTL_MS = 6e5;
|
|
3402
2748
|
const WEBHOOK_REPLAY_RESPONSE_MAX_ENTRIES = 1e4;
|
|
3403
2749
|
const WEBHOOK_REPLAY_RESPONSE_PRUNE_INTERVAL = 64;
|
|
3404
2750
|
function cloneWebhookResponsePayload(payload) {
|
|
@@ -3495,6 +2841,21 @@ var VoiceCallWebhookServer = class {
|
|
|
3495
2841
|
if ((call.metadata?.mode ?? "conversation") !== "conversation") return false;
|
|
3496
2842
|
return (normalizeOptionalString(call.metadata?.initialMessage) ?? "").length > 0;
|
|
3497
2843
|
}
|
|
2844
|
+
getCurrentStream(providerCallId, streamSid) {
|
|
2845
|
+
if (this.provider.name !== "twilio") return;
|
|
2846
|
+
const provider = this.provider;
|
|
2847
|
+
const call = this.manager.getCallByProviderCallId(providerCallId);
|
|
2848
|
+
return call && provider.hasRegisteredStream(providerCallId, streamSid) ? {
|
|
2849
|
+
call,
|
|
2850
|
+
provider
|
|
2851
|
+
} : void 0;
|
|
2852
|
+
}
|
|
2853
|
+
interruptStreamReply(providerCallId, streamSid) {
|
|
2854
|
+
const current = this.getCurrentStream(providerCallId, streamSid);
|
|
2855
|
+
if (!current || this.shouldSuppressBargeInForInitialMessage(current.call)) return;
|
|
2856
|
+
this.manager.invalidateAutoResponse(current.call);
|
|
2857
|
+
current.provider.clearTtsQueue(providerCallId);
|
|
2858
|
+
}
|
|
3498
2859
|
/**
|
|
3499
2860
|
* Initialize media streaming with the selected realtime transcription provider.
|
|
3500
2861
|
*/
|
|
@@ -3508,7 +2869,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3508
2869
|
cfg: pluginConfig,
|
|
3509
2870
|
cfgForResolve: pluginConfig ?? {},
|
|
3510
2871
|
getConfiguredProvider: (providerId) => getRealtimeTranscriptionProvider(providerId, pluginConfig),
|
|
3511
|
-
listProviders: () => listRealtimeTranscriptionProviders(pluginConfig),
|
|
2872
|
+
listProviders: () => listRealtimeTranscriptionProviders(pluginConfig, Object.keys(streaming.providers)),
|
|
3512
2873
|
resolveProviderConfig: ({ provider, cfg, rawConfig }) => provider.resolveConfig?.({
|
|
3513
2874
|
cfg,
|
|
3514
2875
|
rawConfig
|
|
@@ -3551,18 +2912,19 @@ var VoiceCallWebhookServer = class {
|
|
|
3551
2912
|
}
|
|
3552
2913
|
return true;
|
|
3553
2914
|
},
|
|
3554
|
-
onTranscript: (providerCallId, transcript) => {
|
|
2915
|
+
onTranscript: (providerCallId, transcript, streamSid) => {
|
|
3555
2916
|
this.logger.info(`Transcript received ${providerCallId} chars=${transcript.length}`);
|
|
3556
|
-
const
|
|
3557
|
-
if (!
|
|
3558
|
-
this.logger.
|
|
2917
|
+
const current = this.getCurrentStream(providerCallId, streamSid);
|
|
2918
|
+
if (!current) {
|
|
2919
|
+
this.logger.info(`Ignoring transcript from inactive stream ${streamSid} (${providerCallId})`);
|
|
3559
2920
|
return;
|
|
3560
2921
|
}
|
|
2922
|
+
const { call, provider: streamProvider } = current;
|
|
3561
2923
|
if (this.shouldSuppressBargeInForInitialMessage(call)) {
|
|
3562
2924
|
this.logger.info(`Ignoring barge transcript while initial message is still playing (${providerCallId})`);
|
|
3563
2925
|
return;
|
|
3564
2926
|
}
|
|
3565
|
-
|
|
2927
|
+
streamProvider.clearTtsQueue(providerCallId);
|
|
3566
2928
|
const event = {
|
|
3567
2929
|
id: `stream-transcript-${Date.now()}`,
|
|
3568
2930
|
type: "call.speech",
|
|
@@ -3574,32 +2936,36 @@ var VoiceCallWebhookServer = class {
|
|
|
3574
2936
|
};
|
|
3575
2937
|
this.processEventWithAutoResponse(event);
|
|
3576
2938
|
},
|
|
3577
|
-
onSpeechStart: (providerCallId) =>
|
|
3578
|
-
|
|
3579
|
-
const call = this.manager.getCallByProviderCallId(providerCallId);
|
|
3580
|
-
if (this.shouldSuppressBargeInForInitialMessage(call)) return;
|
|
3581
|
-
this.provider.clearTtsQueue(providerCallId);
|
|
3582
|
-
},
|
|
3583
|
-
onPartialTranscript: (callId, partial) => {
|
|
2939
|
+
onSpeechStart: (providerCallId, streamSid) => this.interruptStreamReply(providerCallId, streamSid),
|
|
2940
|
+
onPartialTranscript: (callId, partial, streamSid) => {
|
|
3584
2941
|
this.logger.info(`Partial transcript ${callId} chars=${partial.length}`);
|
|
2942
|
+
this.interruptStreamReply(callId, streamSid);
|
|
3585
2943
|
},
|
|
3586
|
-
onTalkEvent: (providerCallId,
|
|
3587
|
-
const
|
|
3588
|
-
if (
|
|
2944
|
+
onTalkEvent: (providerCallId, streamSid, event) => {
|
|
2945
|
+
const current = this.getCurrentStream(providerCallId, streamSid);
|
|
2946
|
+
if (current) appendRecentTalkEventMetadata(current.call, event);
|
|
3589
2947
|
},
|
|
3590
2948
|
onConnect: (callId, streamSid) => {
|
|
3591
2949
|
this.logger.info(`Media stream connected: ${callId} -> ${streamSid}`);
|
|
3592
2950
|
this.streamDisconnectGrace.connect(callId, streamSid);
|
|
2951
|
+
const call = this.manager.getCallByProviderCallId(callId);
|
|
2952
|
+
if (call) this.manager.invalidateAutoResponse(call);
|
|
3593
2953
|
if (this.provider.name === "twilio") this.provider.registerCallStream(callId, streamSid);
|
|
3594
2954
|
},
|
|
3595
|
-
onTranscriptionReady: (callId) => {
|
|
2955
|
+
onTranscriptionReady: (callId, streamSid) => {
|
|
2956
|
+
if (!this.getCurrentStream(callId, streamSid)) return;
|
|
3596
2957
|
this.manager.speakInitialMessage(callId).catch((err) => {
|
|
3597
2958
|
this.logger.warn(`Failed to speak initial message: ${String(err)}`);
|
|
3598
2959
|
});
|
|
3599
2960
|
},
|
|
3600
2961
|
onDisconnect: (callId, streamSid) => {
|
|
3601
2962
|
this.logger.info(`Media stream disconnected: ${callId} (${streamSid})`);
|
|
3602
|
-
if (this.provider.name === "twilio")
|
|
2963
|
+
if (this.provider.name === "twilio") {
|
|
2964
|
+
const twilio = this.provider;
|
|
2965
|
+
twilio.unregisterCallStream(callId, streamSid);
|
|
2966
|
+
const call = this.manager.getCallByProviderCallId(callId);
|
|
2967
|
+
if (call && !twilio.hasRegisteredStream(callId)) this.manager.invalidateAutoResponse(call);
|
|
2968
|
+
}
|
|
3603
2969
|
this.streamDisconnectGrace.disconnect(callId, streamSid);
|
|
3604
2970
|
}
|
|
3605
2971
|
};
|
|
@@ -3722,10 +3088,10 @@ var VoiceCallWebhookServer = class {
|
|
|
3722
3088
|
* Handle incoming HTTP request.
|
|
3723
3089
|
*/
|
|
3724
3090
|
async handleRequest(req, res, webhookPath) {
|
|
3725
|
-
const payload = await this.runWebhookPipeline(req, webhookPath);
|
|
3726
|
-
this.writeWebhookResponse(res, payload);
|
|
3091
|
+
const payload = await this.runWebhookPipeline(req, webhookPath, res);
|
|
3092
|
+
if (payload) this.writeWebhookResponse(res, payload);
|
|
3727
3093
|
}
|
|
3728
|
-
async runWebhookPipeline(req, webhookPath) {
|
|
3094
|
+
async runWebhookPipeline(req, webhookPath, res) {
|
|
3729
3095
|
const url = buildRequestUrl(req.url);
|
|
3730
3096
|
if (url.pathname === "/voice/hold-music") return {
|
|
3731
3097
|
statusCode: 200,
|
|
@@ -3767,14 +3133,14 @@ var VoiceCallWebhookServer = class {
|
|
|
3767
3133
|
try {
|
|
3768
3134
|
body = await this.readBody(req, MAX_WEBHOOK_BODY_BYTES, WEBHOOK_BODY_TIMEOUT_MS);
|
|
3769
3135
|
} catch (err) {
|
|
3770
|
-
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE"))
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
}
|
|
3774
|
-
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT"))
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
}
|
|
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
|
+
}
|
|
3778
3144
|
throw err;
|
|
3779
3145
|
}
|
|
3780
3146
|
const ctx = {
|
|
@@ -3972,10 +3338,11 @@ var VoiceCallWebhookServer = class {
|
|
|
3972
3338
|
/**
|
|
3973
3339
|
* Read request body as string with timeout protection.
|
|
3974
3340
|
*/
|
|
3975
|
-
readBody(req, maxBytes, timeoutMs
|
|
3341
|
+
readBody(req, maxBytes, timeoutMs) {
|
|
3976
3342
|
return readRequestBodyWithLimit(req, {
|
|
3977
3343
|
maxBytes,
|
|
3978
|
-
timeoutMs
|
|
3344
|
+
timeoutMs,
|
|
3345
|
+
destroyOnLimit: false
|
|
3979
3346
|
});
|
|
3980
3347
|
}
|
|
3981
3348
|
/**
|
|
@@ -3997,8 +3364,18 @@ var VoiceCallWebhookServer = class {
|
|
|
3997
3364
|
this.logger.warn("Agent runtime missing; skipping auto-response");
|
|
3998
3365
|
return;
|
|
3999
3366
|
}
|
|
3367
|
+
const response = this.manager.createAutoResponseGuard(call);
|
|
3368
|
+
const speakResponse = async (text) => {
|
|
3369
|
+
if (!response.isCurrent()) {
|
|
3370
|
+
this.logger.info(`Discarding superseded automatic reply ${callId}`);
|
|
3371
|
+
return false;
|
|
3372
|
+
}
|
|
3373
|
+
this.logger.info(`AI response queued ${callId} chars=${text.length}`);
|
|
3374
|
+
return (await this.manager.speak(callId, text, { listenAfterPlayback: true })).success;
|
|
3375
|
+
};
|
|
4000
3376
|
try {
|
|
4001
3377
|
const { generateVoiceResponse } = await loadResponseGeneratorModule();
|
|
3378
|
+
if (!response.isCurrent()) return;
|
|
4002
3379
|
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
|
4003
3380
|
const effectiveConfig = resolveVoiceCallEffectiveConfig(this.config, numberRouteKey).config;
|
|
4004
3381
|
const result = await generateVoiceResponse({
|
|
@@ -4012,21 +3389,17 @@ var VoiceCallWebhookServer = class {
|
|
|
4012
3389
|
agentId: resolveCallAgentId(call, effectiveConfig),
|
|
4013
3390
|
transcript: call.transcript,
|
|
4014
3391
|
userMessage,
|
|
4015
|
-
onEarlyText:
|
|
4016
|
-
this.logger.info(`Early AI response queued ${callId} chars=${text.length}`);
|
|
4017
|
-
return (await this.manager.speak(callId, text, { listenAfterPlayback: true })).success;
|
|
4018
|
-
}
|
|
3392
|
+
onEarlyText: speakResponse
|
|
4019
3393
|
});
|
|
4020
3394
|
if (result.error) {
|
|
4021
3395
|
this.logger.error(`Response generation error: ${result.error}`);
|
|
4022
3396
|
return;
|
|
4023
3397
|
}
|
|
4024
|
-
if (result.text && !result.deliveredEarly)
|
|
4025
|
-
this.logger.info(`AI response delivered ${callId} chars=${result.text.length}`);
|
|
4026
|
-
await this.manager.speak(callId, result.text, { listenAfterPlayback: true });
|
|
4027
|
-
}
|
|
3398
|
+
if (result.text && !result.deliveredEarly) await speakResponse(result.text);
|
|
4028
3399
|
} catch (err) {
|
|
4029
3400
|
this.logger.error(`Auto-response error: ${String(err)}`);
|
|
3401
|
+
} finally {
|
|
3402
|
+
response.release();
|
|
4030
3403
|
}
|
|
4031
3404
|
}
|
|
4032
3405
|
};
|
|
@@ -4040,12 +3413,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
|
|
|
4040
3413
|
"Do not print secret values or dump environment variables; only check whether required configuration is present.",
|
|
4041
3414
|
"Be accurate, brief, and speakable."
|
|
4042
3415
|
].join(" ");
|
|
4043
|
-
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-
|
|
4044
|
-
const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-
|
|
4045
|
-
const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-
|
|
4046
|
-
const loadMockProvider = createLazyRuntimeModule(() => import("./mock-
|
|
3416
|
+
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-rcjslHOP.js"));
|
|
3417
|
+
const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-B100ALoj.js"));
|
|
3418
|
+
const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-BfKoYCMW.js"));
|
|
3419
|
+
const loadMockProvider = createLazyRuntimeModule(() => import("./mock-C57qj18W.js"));
|
|
4047
3420
|
const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
|
|
4048
|
-
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-
|
|
3421
|
+
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-BYWqpZ6N.js"));
|
|
4049
3422
|
function resolveVoiceCallConsultSessionKey(call) {
|
|
4050
3423
|
return resolveVoiceCallSessionKey({
|
|
4051
3424
|
config: call.config,
|
|
@@ -4196,10 +3569,9 @@ async function createVoiceCallRuntime(params) {
|
|
|
4196
3569
|
};
|
|
4197
3570
|
const cfg = fullConfig ?? coreConfig;
|
|
4198
3571
|
const unresolvedConfig = resolveVoiceCallConfig(rawConfig);
|
|
4199
|
-
const configuredAgentId = unresolvedConfig.agentId ? normalizeAgentId(unresolvedConfig.agentId) : resolveDefaultAgentId(cfg);
|
|
4200
3572
|
const config = {
|
|
4201
3573
|
...unresolvedConfig,
|
|
4202
|
-
agentId:
|
|
3574
|
+
agentId: resolveVoiceCallAgentId(unresolvedConfig, cfg)
|
|
4203
3575
|
};
|
|
4204
3576
|
if (!config.enabled) throw new Error("Voice call disabled. Enable the plugin entry in config.");
|
|
4205
3577
|
if (config.skipSignatureVerification) log.warn("[voice-call] SECURITY WARNING: skipSignatureVerification=true disables webhook signature verification (development only). Do not use in production.");
|
|
@@ -4388,4 +3760,4 @@ async function createVoiceCallRuntime(params) {
|
|
|
4388
3760
|
}
|
|
4389
3761
|
}
|
|
4390
3762
|
//#endregion
|
|
4391
|
-
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 };
|