@openclaw/voice-call 2026.5.2 → 2026.5.3-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +2 -0
- package/dist/call-status-CXldV5o8.js +32 -0
- package/dist/cli-metadata.js +12 -0
- package/dist/config-7w04YpHh.js +548 -0
- package/dist/config-compat-B0me39_4.js +129 -0
- package/dist/guarded-json-api-Btx5EE4w.js +591 -0
- package/dist/http-headers-BrnxBasF.js +10 -0
- package/dist/index.js +1284 -0
- package/dist/mock-CeKvfVEd.js +135 -0
- package/dist/plivo-B-a7KFoT.js +393 -0
- package/dist/realtime-handler-B63CIDP2.js +325 -0
- package/dist/realtime-transcription.runtime-B2h70y2W.js +2 -0
- package/dist/realtime-voice.runtime-Bkh4nvLn.js +2 -0
- package/dist/response-generator-BrcmwDZU.js +182 -0
- package/dist/response-model-CyF5K80p.js +12 -0
- package/dist/runtime-api.js +6 -0
- package/dist/runtime-entry-88ytYAQa.js +3119 -0
- package/dist/runtime-entry.js +2 -0
- package/dist/setup-api.js +37 -0
- package/dist/telnyx-jjBE8boz.js +260 -0
- package/dist/twilio-1OqbcXLL.js +676 -0
- package/dist/voice-mapping-BYDGdWGx.js +40 -0
- package/package.json +14 -6
- package/api.ts +0 -16
- package/cli-metadata.ts +0 -10
- package/config-api.ts +0 -12
- package/index.test.ts +0 -943
- package/index.ts +0 -794
- package/runtime-api.ts +0 -20
- package/runtime-entry.ts +0 -1
- package/setup-api.ts +0 -47
- package/src/allowlist.test.ts +0 -18
- package/src/allowlist.ts +0 -19
- package/src/cli.ts +0 -845
- package/src/config-compat.test.ts +0 -120
- package/src/config-compat.ts +0 -227
- package/src/config.test.ts +0 -479
- package/src/config.ts +0 -808
- package/src/core-bridge.ts +0 -14
- package/src/deep-merge.test.ts +0 -40
- package/src/deep-merge.ts +0 -23
- package/src/gateway-continue-operation.ts +0 -200
- package/src/http-headers.test.ts +0 -16
- package/src/http-headers.ts +0 -15
- package/src/manager/context.ts +0 -42
- package/src/manager/events.test.ts +0 -581
- package/src/manager/events.ts +0 -288
- package/src/manager/lifecycle.ts +0 -53
- package/src/manager/lookup.test.ts +0 -52
- package/src/manager/lookup.ts +0 -35
- package/src/manager/outbound.test.ts +0 -528
- package/src/manager/outbound.ts +0 -486
- package/src/manager/state.ts +0 -48
- package/src/manager/store.ts +0 -106
- package/src/manager/timers.test.ts +0 -129
- package/src/manager/timers.ts +0 -113
- package/src/manager/twiml.test.ts +0 -13
- package/src/manager/twiml.ts +0 -17
- package/src/manager.closed-loop.test.ts +0 -236
- package/src/manager.inbound-allowlist.test.ts +0 -188
- package/src/manager.notify.test.ts +0 -377
- package/src/manager.restore.test.ts +0 -183
- package/src/manager.test-harness.ts +0 -127
- package/src/manager.ts +0 -392
- package/src/media-stream.test.ts +0 -768
- package/src/media-stream.ts +0 -708
- package/src/providers/base.ts +0 -97
- package/src/providers/mock.test.ts +0 -78
- package/src/providers/mock.ts +0 -185
- package/src/providers/plivo.test.ts +0 -93
- package/src/providers/plivo.ts +0 -601
- package/src/providers/shared/call-status.test.ts +0 -24
- package/src/providers/shared/call-status.ts +0 -24
- package/src/providers/shared/guarded-json-api.test.ts +0 -106
- package/src/providers/shared/guarded-json-api.ts +0 -42
- package/src/providers/telnyx.test.ts +0 -340
- package/src/providers/telnyx.ts +0 -394
- package/src/providers/twilio/api.test.ts +0 -145
- package/src/providers/twilio/api.ts +0 -93
- package/src/providers/twilio/twiml-policy.test.ts +0 -84
- package/src/providers/twilio/twiml-policy.ts +0 -87
- package/src/providers/twilio/webhook.ts +0 -34
- package/src/providers/twilio.test.ts +0 -591
- package/src/providers/twilio.ts +0 -861
- package/src/providers/twilio.types.ts +0 -17
- package/src/realtime-defaults.ts +0 -3
- package/src/realtime-fast-context.test.ts +0 -88
- package/src/realtime-fast-context.ts +0 -165
- package/src/realtime-transcription.runtime.ts +0 -4
- package/src/realtime-voice.runtime.ts +0 -5
- package/src/response-generator.test.ts +0 -321
- package/src/response-generator.ts +0 -318
- package/src/response-model.test.ts +0 -71
- package/src/response-model.ts +0 -23
- package/src/runtime.test.ts +0 -536
- package/src/runtime.ts +0 -510
- package/src/telephony-audio.test.ts +0 -61
- package/src/telephony-audio.ts +0 -12
- package/src/telephony-tts.test.ts +0 -196
- package/src/telephony-tts.ts +0 -235
- package/src/test-fixtures.ts +0 -73
- package/src/tts-provider-voice.test.ts +0 -34
- package/src/tts-provider-voice.ts +0 -21
- package/src/tunnel.test.ts +0 -166
- package/src/tunnel.ts +0 -314
- package/src/types.ts +0 -291
- package/src/utils.test.ts +0 -17
- package/src/utils.ts +0 -14
- package/src/voice-mapping.test.ts +0 -34
- package/src/voice-mapping.ts +0 -68
- package/src/webhook/realtime-handler.test.ts +0 -598
- package/src/webhook/realtime-handler.ts +0 -485
- package/src/webhook/stale-call-reaper.test.ts +0 -88
- package/src/webhook/stale-call-reaper.ts +0 -38
- package/src/webhook/tailscale.test.ts +0 -214
- package/src/webhook/tailscale.ts +0 -129
- package/src/webhook-exposure.test.ts +0 -33
- package/src/webhook-exposure.ts +0 -84
- package/src/webhook-security.test.ts +0 -770
- package/src/webhook-security.ts +0 -994
- package/src/webhook.hangup-once.lifecycle.test.ts +0 -135
- package/src/webhook.test.ts +0 -1470
- package/src/webhook.ts +0 -908
- package/src/webhook.types.ts +0 -5
- package/src/websocket-test-support.ts +0 -72
- package/tsconfig.json +0 -16
package/src/webhook.ts
DELETED
|
@@ -1,908 +0,0 @@
|
|
|
1
|
-
import http from "node:http";
|
|
2
|
-
import { URL } from "node:url";
|
|
3
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
4
|
-
import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime";
|
|
5
|
-
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
|
6
|
-
import {
|
|
7
|
-
createWebhookInFlightLimiter,
|
|
8
|
-
WEBHOOK_BODY_READ_DEFAULTS,
|
|
9
|
-
} from "openclaw/plugin-sdk/webhook-ingress";
|
|
10
|
-
import {
|
|
11
|
-
isRequestBodyLimitError,
|
|
12
|
-
readRequestBodyWithLimit,
|
|
13
|
-
requestBodyErrorToText,
|
|
14
|
-
} from "../api.js";
|
|
15
|
-
import { isAllowlistedCaller, normalizePhoneNumber } from "./allowlist.js";
|
|
16
|
-
import {
|
|
17
|
-
normalizeVoiceCallConfig,
|
|
18
|
-
resolveVoiceCallEffectiveConfig,
|
|
19
|
-
type VoiceCallConfig,
|
|
20
|
-
} from "./config.js";
|
|
21
|
-
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
|
|
22
|
-
import { getHeader } from "./http-headers.js";
|
|
23
|
-
import type { CallManager } from "./manager.js";
|
|
24
|
-
import type { MediaStreamConfig } from "./media-stream.js";
|
|
25
|
-
import { MediaStreamHandler } from "./media-stream.js";
|
|
26
|
-
import type { VoiceCallProvider } from "./providers/base.js";
|
|
27
|
-
import { isProviderStatusTerminal } from "./providers/shared/call-status.js";
|
|
28
|
-
import type { TwilioProvider } from "./providers/twilio.js";
|
|
29
|
-
import type { CallRecord, NormalizedEvent, WebhookContext } from "./types.js";
|
|
30
|
-
import type { WebhookResponsePayload } from "./webhook.types.js";
|
|
31
|
-
import type { RealtimeCallHandler } from "./webhook/realtime-handler.js";
|
|
32
|
-
import { startStaleCallReaper } from "./webhook/stale-call-reaper.js";
|
|
33
|
-
|
|
34
|
-
const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
|
|
35
|
-
const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
|
|
36
|
-
const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
|
|
37
|
-
const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2000;
|
|
38
|
-
const TRANSCRIPT_LOG_MAX_CHARS = 200;
|
|
39
|
-
|
|
40
|
-
type RealtimeTranscriptionRuntime = typeof import("./realtime-transcription.runtime.js");
|
|
41
|
-
type ResponseGeneratorModule = typeof import("./response-generator.js");
|
|
42
|
-
type Logger = {
|
|
43
|
-
info: (message: string) => void;
|
|
44
|
-
warn: (message: string) => void;
|
|
45
|
-
error: (message: string) => void;
|
|
46
|
-
debug?: (message: string) => void;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
let realtimeTranscriptionRuntimePromise: Promise<RealtimeTranscriptionRuntime> | undefined;
|
|
50
|
-
let responseGeneratorModulePromise: Promise<ResponseGeneratorModule> | undefined;
|
|
51
|
-
|
|
52
|
-
function loadRealtimeTranscriptionRuntime(): Promise<RealtimeTranscriptionRuntime> {
|
|
53
|
-
realtimeTranscriptionRuntimePromise ??= import("./realtime-transcription.runtime.js");
|
|
54
|
-
return realtimeTranscriptionRuntimePromise;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function loadResponseGeneratorModule(): Promise<ResponseGeneratorModule> {
|
|
58
|
-
responseGeneratorModulePromise ??= import("./response-generator.js");
|
|
59
|
-
return responseGeneratorModulePromise;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
type WebhookHeaderGateResult =
|
|
63
|
-
| { ok: true }
|
|
64
|
-
| {
|
|
65
|
-
ok: false;
|
|
66
|
-
reason: string;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
function sanitizeTranscriptForLog(value: string): string {
|
|
70
|
-
const sanitized = value
|
|
71
|
-
.replace(/\p{Cc}/gu, " ")
|
|
72
|
-
.replace(/\s+/g, " ")
|
|
73
|
-
.trim();
|
|
74
|
-
if (sanitized.length <= TRANSCRIPT_LOG_MAX_CHARS) {
|
|
75
|
-
return sanitized;
|
|
76
|
-
}
|
|
77
|
-
return `${sanitized.slice(0, TRANSCRIPT_LOG_MAX_CHARS)}...`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function buildRequestUrl(
|
|
81
|
-
requestUrl: string | undefined,
|
|
82
|
-
requestHost: string | undefined,
|
|
83
|
-
fallbackHost = "localhost",
|
|
84
|
-
): URL {
|
|
85
|
-
return new URL(requestUrl ?? "/", `http://${requestHost ?? fallbackHost}`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function normalizeProxyIp(value: string | undefined): string | undefined {
|
|
89
|
-
const trimmed = value?.trim();
|
|
90
|
-
if (!trimmed) {
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
93
|
-
const unwrapped =
|
|
94
|
-
trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
|
|
95
|
-
const normalized = unwrapped.toLowerCase();
|
|
96
|
-
const mappedIpv4Prefix = "::ffff:";
|
|
97
|
-
if (normalized.startsWith(mappedIpv4Prefix)) {
|
|
98
|
-
const mappedIpv4 = normalized.slice(mappedIpv4Prefix.length);
|
|
99
|
-
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(mappedIpv4)) {
|
|
100
|
-
return mappedIpv4;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return normalized;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function resolveForwardedClientIp(
|
|
107
|
-
request: http.IncomingMessage,
|
|
108
|
-
trustedProxyIPs: readonly string[],
|
|
109
|
-
): string | undefined {
|
|
110
|
-
const normalizedTrustedProxyIps = new Set(
|
|
111
|
-
trustedProxyIPs.map((ip) => normalizeProxyIp(ip)).filter((ip): ip is string => Boolean(ip)),
|
|
112
|
-
);
|
|
113
|
-
const forwardedFor = getHeader(request.headers, "x-forwarded-for");
|
|
114
|
-
if (forwardedFor) {
|
|
115
|
-
const forwardedIps = forwardedFor
|
|
116
|
-
.split(",")
|
|
117
|
-
.map((part) => part.trim())
|
|
118
|
-
.filter(Boolean);
|
|
119
|
-
if (forwardedIps.length > 0) {
|
|
120
|
-
if (normalizedTrustedProxyIps.size === 0) {
|
|
121
|
-
return forwardedIps[0];
|
|
122
|
-
}
|
|
123
|
-
for (let index = forwardedIps.length - 1; index >= 0; index -= 1) {
|
|
124
|
-
const hop = forwardedIps[index];
|
|
125
|
-
if (!normalizedTrustedProxyIps.has(normalizeProxyIp(hop) ?? "")) {
|
|
126
|
-
return hop;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return forwardedIps[0];
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const realIp = getHeader(request.headers, "x-real-ip")?.trim();
|
|
134
|
-
return realIp || undefined;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function normalizeWebhookResponse(parsed: {
|
|
138
|
-
statusCode?: number;
|
|
139
|
-
providerResponseHeaders?: Record<string, string>;
|
|
140
|
-
providerResponseBody?: string;
|
|
141
|
-
}): WebhookResponsePayload {
|
|
142
|
-
return {
|
|
143
|
-
statusCode: parsed.statusCode ?? 200,
|
|
144
|
-
headers: parsed.providerResponseHeaders,
|
|
145
|
-
body: parsed.providerResponseBody ?? "OK",
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function buildRealtimeRejectedTwiML(): WebhookResponsePayload {
|
|
150
|
-
return {
|
|
151
|
-
statusCode: 200,
|
|
152
|
-
headers: { "Content-Type": "text/xml" },
|
|
153
|
-
body: '<?xml version="1.0" encoding="UTF-8"?><Response><Reject reason="rejected" /></Response>',
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* HTTP server for receiving voice call webhooks from providers.
|
|
159
|
-
* Supports WebSocket upgrades for media streams when streaming is enabled.
|
|
160
|
-
*/
|
|
161
|
-
export class VoiceCallWebhookServer {
|
|
162
|
-
private server: http.Server | null = null;
|
|
163
|
-
private listeningUrl: string | null = null;
|
|
164
|
-
private startPromise: Promise<string> | null = null;
|
|
165
|
-
private config: VoiceCallConfig;
|
|
166
|
-
private manager: CallManager;
|
|
167
|
-
private provider: VoiceCallProvider;
|
|
168
|
-
private coreConfig: CoreConfig | null;
|
|
169
|
-
private fullConfig: OpenClawConfig | null;
|
|
170
|
-
private agentRuntime: CoreAgentDeps | null;
|
|
171
|
-
private logger: Logger;
|
|
172
|
-
private stopStaleCallReaper: (() => void) | null = null;
|
|
173
|
-
private readonly webhookInFlightLimiter = createWebhookInFlightLimiter();
|
|
174
|
-
|
|
175
|
-
/** Media stream handler for bidirectional audio (when streaming enabled) */
|
|
176
|
-
private mediaStreamHandler: MediaStreamHandler | null = null;
|
|
177
|
-
/** Delayed auto-hangup timers keyed by provider call ID after stream disconnect. */
|
|
178
|
-
private pendingDisconnectHangups = new Map<string, ReturnType<typeof setTimeout>>();
|
|
179
|
-
/** Realtime voice handler for duplex provider bridges. */
|
|
180
|
-
private realtimeHandler: RealtimeCallHandler | null = null;
|
|
181
|
-
|
|
182
|
-
constructor(
|
|
183
|
-
config: VoiceCallConfig,
|
|
184
|
-
manager: CallManager,
|
|
185
|
-
provider: VoiceCallProvider,
|
|
186
|
-
coreConfig?: CoreConfig,
|
|
187
|
-
fullConfig?: OpenClawConfig,
|
|
188
|
-
agentRuntime?: CoreAgentDeps,
|
|
189
|
-
logger?: Logger,
|
|
190
|
-
) {
|
|
191
|
-
this.config = normalizeVoiceCallConfig(config);
|
|
192
|
-
this.manager = manager;
|
|
193
|
-
this.provider = provider;
|
|
194
|
-
this.coreConfig = coreConfig ?? null;
|
|
195
|
-
this.fullConfig = fullConfig ?? null;
|
|
196
|
-
this.agentRuntime = agentRuntime ?? null;
|
|
197
|
-
this.logger = logger ?? {
|
|
198
|
-
info: console.log,
|
|
199
|
-
warn: console.warn,
|
|
200
|
-
error: console.error,
|
|
201
|
-
debug: console.debug,
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Get the media stream handler (for wiring to provider).
|
|
207
|
-
*/
|
|
208
|
-
getMediaStreamHandler(): MediaStreamHandler | null {
|
|
209
|
-
return this.mediaStreamHandler;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
getRealtimeHandler(): RealtimeCallHandler | null {
|
|
213
|
-
return this.realtimeHandler;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
speakRealtime(callId: string, instructions: string): { success: boolean; error?: string } {
|
|
217
|
-
if (!this.realtimeHandler) {
|
|
218
|
-
return { success: false, error: "Realtime voice handler is not configured" };
|
|
219
|
-
}
|
|
220
|
-
return this.realtimeHandler.speak(callId, instructions);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
setRealtimeHandler(handler: RealtimeCallHandler): void {
|
|
224
|
-
this.realtimeHandler = handler;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
private clearPendingDisconnectHangup(providerCallId: string): void {
|
|
228
|
-
const existing = this.pendingDisconnectHangups.get(providerCallId);
|
|
229
|
-
if (!existing) {
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
clearTimeout(existing);
|
|
233
|
-
this.pendingDisconnectHangups.delete(providerCallId);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
private resolveMediaStreamClientIp(request: http.IncomingMessage): string | undefined {
|
|
237
|
-
const remoteIp = request.socket.remoteAddress ?? undefined;
|
|
238
|
-
const trustedProxyIPs = this.config.webhookSecurity.trustedProxyIPs.filter(Boolean);
|
|
239
|
-
const normalizedTrustedProxyIps = new Set(
|
|
240
|
-
trustedProxyIPs.map((ip) => normalizeProxyIp(ip)).filter((ip): ip is string => Boolean(ip)),
|
|
241
|
-
);
|
|
242
|
-
const normalizedRemoteIp = normalizeProxyIp(remoteIp);
|
|
243
|
-
const fromTrustedProxy =
|
|
244
|
-
normalizedTrustedProxyIps.size > 0 &&
|
|
245
|
-
normalizedRemoteIp !== undefined &&
|
|
246
|
-
normalizedTrustedProxyIps.has(normalizedRemoteIp);
|
|
247
|
-
const shouldTrustForwardingHeaders =
|
|
248
|
-
this.config.webhookSecurity.trustForwardingHeaders && fromTrustedProxy;
|
|
249
|
-
|
|
250
|
-
if (shouldTrustForwardingHeaders) {
|
|
251
|
-
const forwardedIp = resolveForwardedClientIp(request, trustedProxyIPs);
|
|
252
|
-
if (forwardedIp) {
|
|
253
|
-
return forwardedIp;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return remoteIp;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
private shouldSuppressBargeInForInitialMessage(call: CallRecord | undefined): boolean {
|
|
261
|
-
if (!call || call.direction !== "outbound") {
|
|
262
|
-
return false;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Suppress only while the initial greeting is actively being played.
|
|
266
|
-
// If playback fails and the call leaves "speaking", do not block auto-response.
|
|
267
|
-
if (call.state !== "speaking") {
|
|
268
|
-
return false;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const mode = (call.metadata?.mode as string | undefined) ?? "conversation";
|
|
272
|
-
if (mode !== "conversation") {
|
|
273
|
-
return false;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const initialMessage = normalizeOptionalString(call.metadata?.initialMessage) ?? "";
|
|
277
|
-
return initialMessage.length > 0;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Initialize media streaming with the selected realtime transcription provider.
|
|
282
|
-
*/
|
|
283
|
-
private async initializeMediaStreaming(): Promise<void> {
|
|
284
|
-
const streaming = this.config.streaming;
|
|
285
|
-
const pluginConfig =
|
|
286
|
-
this.fullConfig ?? (this.coreConfig as unknown as OpenClawConfig | undefined);
|
|
287
|
-
const { getRealtimeTranscriptionProvider, listRealtimeTranscriptionProviders } =
|
|
288
|
-
await loadRealtimeTranscriptionRuntime();
|
|
289
|
-
const resolution = resolveConfiguredCapabilityProvider({
|
|
290
|
-
configuredProviderId: streaming.provider,
|
|
291
|
-
providerConfigs: streaming.providers,
|
|
292
|
-
cfg: pluginConfig,
|
|
293
|
-
cfgForResolve: pluginConfig ?? ({} as OpenClawConfig),
|
|
294
|
-
getConfiguredProvider: (providerId) =>
|
|
295
|
-
getRealtimeTranscriptionProvider(providerId, pluginConfig),
|
|
296
|
-
listProviders: () => listRealtimeTranscriptionProviders(pluginConfig),
|
|
297
|
-
resolveProviderConfig: ({ provider, cfg, rawConfig }) =>
|
|
298
|
-
provider.resolveConfig?.({ cfg, rawConfig }) ?? rawConfig,
|
|
299
|
-
isProviderConfigured: ({ provider, cfg, providerConfig }) =>
|
|
300
|
-
provider.isConfigured({ cfg, providerConfig }),
|
|
301
|
-
});
|
|
302
|
-
if (!resolution.ok && resolution.code === "missing-configured-provider") {
|
|
303
|
-
console.warn(
|
|
304
|
-
`[voice-call] Streaming enabled but realtime transcription provider "${resolution.configuredProviderId}" is not registered`,
|
|
305
|
-
);
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
if (!resolution.ok && resolution.code === "no-registered-provider") {
|
|
309
|
-
console.warn(
|
|
310
|
-
"[voice-call] Streaming enabled but no realtime transcription provider is registered",
|
|
311
|
-
);
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
if (!resolution.ok) {
|
|
315
|
-
console.warn(
|
|
316
|
-
`[voice-call] Streaming enabled but provider "${resolution.provider?.id}" is not configured`,
|
|
317
|
-
);
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
const provider = resolution.provider;
|
|
321
|
-
const providerConfig = resolution.providerConfig;
|
|
322
|
-
|
|
323
|
-
const streamConfig: MediaStreamConfig = {
|
|
324
|
-
transcriptionProvider: provider,
|
|
325
|
-
providerConfig,
|
|
326
|
-
preStartTimeoutMs: streaming.preStartTimeoutMs,
|
|
327
|
-
maxPendingConnections: streaming.maxPendingConnections,
|
|
328
|
-
maxPendingConnectionsPerIp: streaming.maxPendingConnectionsPerIp,
|
|
329
|
-
maxConnections: streaming.maxConnections,
|
|
330
|
-
resolveClientIp: (request) => this.resolveMediaStreamClientIp(request),
|
|
331
|
-
shouldAcceptStream: ({ callId, token }) => {
|
|
332
|
-
const call = this.manager.getCallByProviderCallId(callId);
|
|
333
|
-
if (!call) {
|
|
334
|
-
return false;
|
|
335
|
-
}
|
|
336
|
-
if (this.provider.name === "twilio") {
|
|
337
|
-
const twilio = this.provider as TwilioProvider;
|
|
338
|
-
if (!twilio.isValidStreamToken(callId, token)) {
|
|
339
|
-
console.warn(`[voice-call] Rejecting media stream: invalid token for ${callId}`);
|
|
340
|
-
return false;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
return true;
|
|
344
|
-
},
|
|
345
|
-
onTranscript: (providerCallId, transcript) => {
|
|
346
|
-
const safeTranscript = sanitizeTranscriptForLog(transcript);
|
|
347
|
-
console.log(
|
|
348
|
-
`[voice-call] Transcript for ${providerCallId}: ${safeTranscript} (chars=${transcript.length})`,
|
|
349
|
-
);
|
|
350
|
-
const call = this.manager.getCallByProviderCallId(providerCallId);
|
|
351
|
-
if (!call) {
|
|
352
|
-
console.warn(`[voice-call] No active call found for provider ID: ${providerCallId}`);
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
const suppressBargeIn = this.shouldSuppressBargeInForInitialMessage(call);
|
|
356
|
-
if (suppressBargeIn) {
|
|
357
|
-
console.log(
|
|
358
|
-
`[voice-call] Ignoring barge transcript while initial message is still playing (${providerCallId})`,
|
|
359
|
-
);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Clear TTS queue on barge-in (user started speaking, interrupt current playback)
|
|
364
|
-
if (this.provider.name === "twilio") {
|
|
365
|
-
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Create a speech event and process it through the manager
|
|
369
|
-
const event: NormalizedEvent = {
|
|
370
|
-
id: `stream-transcript-${Date.now()}`,
|
|
371
|
-
type: "call.speech",
|
|
372
|
-
callId: call.callId,
|
|
373
|
-
providerCallId,
|
|
374
|
-
timestamp: Date.now(),
|
|
375
|
-
transcript,
|
|
376
|
-
isFinal: true,
|
|
377
|
-
};
|
|
378
|
-
this.manager.processEvent(event);
|
|
379
|
-
|
|
380
|
-
// Auto-respond in conversation mode (inbound always, outbound if mode is conversation)
|
|
381
|
-
const callMode = call.metadata?.mode as string | undefined;
|
|
382
|
-
const shouldRespond = call.direction === "inbound" || callMode === "conversation";
|
|
383
|
-
if (shouldRespond) {
|
|
384
|
-
this.handleInboundResponse(call.callId, transcript).catch((err) => {
|
|
385
|
-
console.warn(`[voice-call] Failed to auto-respond:`, err);
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
},
|
|
389
|
-
onSpeechStart: (providerCallId) => {
|
|
390
|
-
if (this.provider.name !== "twilio") {
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
const call = this.manager.getCallByProviderCallId(providerCallId);
|
|
394
|
-
if (this.shouldSuppressBargeInForInitialMessage(call)) {
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
|
|
398
|
-
},
|
|
399
|
-
onPartialTranscript: (callId, partial) => {
|
|
400
|
-
const safePartial = sanitizeTranscriptForLog(partial);
|
|
401
|
-
console.log(`[voice-call] Partial for ${callId}: ${safePartial} (chars=${partial.length})`);
|
|
402
|
-
},
|
|
403
|
-
onConnect: (callId, streamSid) => {
|
|
404
|
-
console.log(`[voice-call] Media stream connected: ${callId} -> ${streamSid}`);
|
|
405
|
-
this.clearPendingDisconnectHangup(callId);
|
|
406
|
-
|
|
407
|
-
// Register stream with provider for TTS routing
|
|
408
|
-
if (this.provider.name === "twilio") {
|
|
409
|
-
(this.provider as TwilioProvider).registerCallStream(callId, streamSid);
|
|
410
|
-
}
|
|
411
|
-
},
|
|
412
|
-
onTranscriptionReady: (callId) => {
|
|
413
|
-
this.manager.speakInitialMessage(callId).catch((err) => {
|
|
414
|
-
console.warn(`[voice-call] Failed to speak initial message:`, err);
|
|
415
|
-
});
|
|
416
|
-
},
|
|
417
|
-
onDisconnect: (callId, streamSid) => {
|
|
418
|
-
console.log(`[voice-call] Media stream disconnected: ${callId} (${streamSid})`);
|
|
419
|
-
if (this.provider.name === "twilio") {
|
|
420
|
-
(this.provider as TwilioProvider).unregisterCallStream(callId, streamSid);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
this.clearPendingDisconnectHangup(callId);
|
|
424
|
-
const timer = setTimeout(() => {
|
|
425
|
-
this.pendingDisconnectHangups.delete(callId);
|
|
426
|
-
const disconnectedCall = this.manager.getCallByProviderCallId(callId);
|
|
427
|
-
if (!disconnectedCall) {
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
if (this.provider.name === "twilio") {
|
|
432
|
-
const twilio = this.provider as TwilioProvider;
|
|
433
|
-
if (twilio.hasRegisteredStream(callId)) {
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
console.log(
|
|
439
|
-
`[voice-call] Auto-ending call ${disconnectedCall.callId} after stream disconnect grace`,
|
|
440
|
-
);
|
|
441
|
-
void this.manager.endCall(disconnectedCall.callId).catch((err) => {
|
|
442
|
-
console.warn(`[voice-call] Failed to auto-end call ${disconnectedCall.callId}:`, err);
|
|
443
|
-
});
|
|
444
|
-
}, STREAM_DISCONNECT_HANGUP_GRACE_MS);
|
|
445
|
-
timer.unref?.();
|
|
446
|
-
this.pendingDisconnectHangups.set(callId, timer);
|
|
447
|
-
},
|
|
448
|
-
};
|
|
449
|
-
|
|
450
|
-
this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
|
|
451
|
-
console.log("[voice-call] Media streaming initialized");
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Start the webhook server.
|
|
456
|
-
* Idempotent: returns immediately if the server is already listening.
|
|
457
|
-
*/
|
|
458
|
-
async start(): Promise<string> {
|
|
459
|
-
const { port, bind, path: webhookPath } = this.config.serve;
|
|
460
|
-
const streamPath = this.config.streaming.streamPath;
|
|
461
|
-
|
|
462
|
-
// Guard: if a server is already listening, return the existing URL.
|
|
463
|
-
// This prevents EADDRINUSE when start() is called more than once on the
|
|
464
|
-
// same instance (e.g. during config hot-reload or concurrent ensureRuntime).
|
|
465
|
-
if (this.server?.listening) {
|
|
466
|
-
return this.listeningUrl ?? this.resolveListeningUrl(bind, webhookPath);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
if (this.config.streaming.enabled && !this.mediaStreamHandler) {
|
|
470
|
-
await this.initializeMediaStreaming();
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
if (this.startPromise) {
|
|
474
|
-
return this.startPromise;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
this.startPromise = new Promise((resolve, reject) => {
|
|
478
|
-
this.server = http.createServer((req, res) => {
|
|
479
|
-
this.handleRequest(req, res, webhookPath).catch((err) => {
|
|
480
|
-
console.error("[voice-call] Webhook error:", err);
|
|
481
|
-
res.statusCode = 500;
|
|
482
|
-
res.end("Internal Server Error");
|
|
483
|
-
});
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
// Handle WebSocket upgrades for realtime voice and media streams.
|
|
487
|
-
if (this.realtimeHandler || this.mediaStreamHandler) {
|
|
488
|
-
this.server.on("upgrade", (request, socket, head) => {
|
|
489
|
-
if (this.realtimeHandler && this.isRealtimeWebSocketUpgrade(request)) {
|
|
490
|
-
this.realtimeHandler.handleWebSocketUpgrade(request, socket, head);
|
|
491
|
-
return;
|
|
492
|
-
}
|
|
493
|
-
const path = this.getUpgradePathname(request);
|
|
494
|
-
if (path === streamPath && this.mediaStreamHandler) {
|
|
495
|
-
this.mediaStreamHandler?.handleUpgrade(request, socket, head);
|
|
496
|
-
} else {
|
|
497
|
-
socket.destroy();
|
|
498
|
-
}
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
this.server.on("error", (err) => {
|
|
503
|
-
this.server = null;
|
|
504
|
-
this.listeningUrl = null;
|
|
505
|
-
this.startPromise = null;
|
|
506
|
-
reject(err);
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
this.server.listen(port, bind, () => {
|
|
510
|
-
const url = this.resolveListeningUrl(bind, webhookPath);
|
|
511
|
-
this.listeningUrl = url;
|
|
512
|
-
this.startPromise = null;
|
|
513
|
-
this.logger.info(`[voice-call] Webhook server listening on ${url}`);
|
|
514
|
-
if (this.mediaStreamHandler) {
|
|
515
|
-
const address = this.server?.address();
|
|
516
|
-
const actualPort =
|
|
517
|
-
address && typeof address === "object" ? address.port : this.config.serve.port;
|
|
518
|
-
this.logger.info(
|
|
519
|
-
`[voice-call] Media stream WebSocket on ws://${bind}:${actualPort}${streamPath}`,
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
resolve(url);
|
|
523
|
-
|
|
524
|
-
// Start the stale call reaper if configured
|
|
525
|
-
this.stopStaleCallReaper = startStaleCallReaper({
|
|
526
|
-
manager: this.manager,
|
|
527
|
-
staleCallReaperSeconds: this.config.staleCallReaperSeconds,
|
|
528
|
-
});
|
|
529
|
-
});
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
return this.startPromise;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/**
|
|
536
|
-
* Stop the webhook server.
|
|
537
|
-
*/
|
|
538
|
-
async stop(): Promise<void> {
|
|
539
|
-
for (const timer of this.pendingDisconnectHangups.values()) {
|
|
540
|
-
clearTimeout(timer);
|
|
541
|
-
}
|
|
542
|
-
this.pendingDisconnectHangups.clear();
|
|
543
|
-
this.webhookInFlightLimiter.clear();
|
|
544
|
-
this.startPromise = null;
|
|
545
|
-
|
|
546
|
-
if (this.stopStaleCallReaper) {
|
|
547
|
-
this.stopStaleCallReaper();
|
|
548
|
-
this.stopStaleCallReaper = null;
|
|
549
|
-
}
|
|
550
|
-
return new Promise((resolve) => {
|
|
551
|
-
if (this.server) {
|
|
552
|
-
this.server.close(() => {
|
|
553
|
-
this.server = null;
|
|
554
|
-
this.listeningUrl = null;
|
|
555
|
-
resolve();
|
|
556
|
-
});
|
|
557
|
-
} else {
|
|
558
|
-
this.listeningUrl = null;
|
|
559
|
-
resolve();
|
|
560
|
-
}
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
private resolveListeningUrl(bind: string, webhookPath: string): string {
|
|
565
|
-
const address = this.server?.address();
|
|
566
|
-
if (address && typeof address === "object") {
|
|
567
|
-
const host = address.address && address.address.length > 0 ? address.address : bind;
|
|
568
|
-
const normalizedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
569
|
-
return `http://${normalizedHost}:${address.port}${webhookPath}`;
|
|
570
|
-
}
|
|
571
|
-
return `http://${bind}:${this.config.serve.port}${webhookPath}`;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
private getUpgradePathname(request: http.IncomingMessage): string | null {
|
|
575
|
-
try {
|
|
576
|
-
return buildRequestUrl(request.url, request.headers.host).pathname;
|
|
577
|
-
} catch {
|
|
578
|
-
return null;
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
private normalizeWebhookPathForMatch(pathname: string): string {
|
|
583
|
-
const trimmed = pathname.trim();
|
|
584
|
-
if (!trimmed) {
|
|
585
|
-
return "/";
|
|
586
|
-
}
|
|
587
|
-
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
588
|
-
if (prefixed === "/") {
|
|
589
|
-
return prefixed;
|
|
590
|
-
}
|
|
591
|
-
return prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
private isWebhookPathMatch(requestPath: string, configuredPath: string): boolean {
|
|
595
|
-
return (
|
|
596
|
-
this.normalizeWebhookPathForMatch(requestPath) ===
|
|
597
|
-
this.normalizeWebhookPathForMatch(configuredPath)
|
|
598
|
-
);
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
/**
|
|
602
|
-
* Handle incoming HTTP request.
|
|
603
|
-
*/
|
|
604
|
-
private async handleRequest(
|
|
605
|
-
req: http.IncomingMessage,
|
|
606
|
-
res: http.ServerResponse,
|
|
607
|
-
webhookPath: string,
|
|
608
|
-
): Promise<void> {
|
|
609
|
-
const payload = await this.runWebhookPipeline(req, webhookPath);
|
|
610
|
-
this.writeWebhookResponse(res, payload);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
private async runWebhookPipeline(
|
|
614
|
-
req: http.IncomingMessage,
|
|
615
|
-
webhookPath: string,
|
|
616
|
-
): Promise<WebhookResponsePayload> {
|
|
617
|
-
const url = buildRequestUrl(req.url, req.headers.host);
|
|
618
|
-
|
|
619
|
-
if (url.pathname === "/voice/hold-music") {
|
|
620
|
-
return {
|
|
621
|
-
statusCode: 200,
|
|
622
|
-
headers: { "Content-Type": "text/xml" },
|
|
623
|
-
body: `<?xml version="1.0" encoding="UTF-8"?>
|
|
624
|
-
<Response>
|
|
625
|
-
<Say voice="alice">All agents are currently busy. Please hold.</Say>
|
|
626
|
-
<Play loop="0">https://s3.amazonaws.com/com.twilio.music.classical/BusyStrings.mp3</Play>
|
|
627
|
-
</Response>`,
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
if (!this.isWebhookPathMatch(url.pathname, webhookPath)) {
|
|
632
|
-
return { statusCode: 404, body: "Not Found" };
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
if (req.method !== "POST") {
|
|
636
|
-
return { statusCode: 405, body: "Method Not Allowed" };
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
const headerGate = this.verifyPreAuthWebhookHeaders(req.headers);
|
|
640
|
-
if (!headerGate.ok) {
|
|
641
|
-
console.warn(`[voice-call] Webhook rejected before body read: ${headerGate.reason}`);
|
|
642
|
-
return { statusCode: 401, body: "Unauthorized" };
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// createWebhookInFlightLimiter intentionally treats an empty key as fail-open.
|
|
646
|
-
// Missing socket metadata must still share one bucket instead of bypassing
|
|
647
|
-
// the pre-auth limiter entirely.
|
|
648
|
-
const remoteAddress = req.socket.remoteAddress;
|
|
649
|
-
if (!remoteAddress) {
|
|
650
|
-
console.warn(
|
|
651
|
-
`[voice-call] Webhook accepted with no remote address; using shared fallback in-flight key`,
|
|
652
|
-
);
|
|
653
|
-
}
|
|
654
|
-
const inFlightKey = remoteAddress || MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY;
|
|
655
|
-
if (!this.webhookInFlightLimiter.tryAcquire(inFlightKey)) {
|
|
656
|
-
console.warn(`[voice-call] Webhook rejected before body read: too many in-flight requests`);
|
|
657
|
-
return { statusCode: 429, body: "Too Many Requests" };
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
try {
|
|
661
|
-
let body = "";
|
|
662
|
-
try {
|
|
663
|
-
body = await this.readBody(req, MAX_WEBHOOK_BODY_BYTES, WEBHOOK_BODY_TIMEOUT_MS);
|
|
664
|
-
} catch (err) {
|
|
665
|
-
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
|
666
|
-
return { statusCode: 413, body: "Payload Too Large" };
|
|
667
|
-
}
|
|
668
|
-
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
|
669
|
-
return { statusCode: 408, body: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") };
|
|
670
|
-
}
|
|
671
|
-
throw err;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
const ctx: WebhookContext = {
|
|
675
|
-
headers: req.headers as Record<string, string | string[] | undefined>,
|
|
676
|
-
rawBody: body,
|
|
677
|
-
url: url.toString(),
|
|
678
|
-
method: "POST",
|
|
679
|
-
query: Object.fromEntries(url.searchParams),
|
|
680
|
-
remoteAddress: req.socket.remoteAddress ?? undefined,
|
|
681
|
-
};
|
|
682
|
-
|
|
683
|
-
const verification = this.provider.verifyWebhook(ctx);
|
|
684
|
-
if (!verification.ok) {
|
|
685
|
-
console.warn(`[voice-call] Webhook verification failed: ${verification.reason}`);
|
|
686
|
-
return { statusCode: 401, body: "Unauthorized" };
|
|
687
|
-
}
|
|
688
|
-
if (!verification.verifiedRequestKey) {
|
|
689
|
-
console.warn("[voice-call] Webhook verification succeeded without request identity key");
|
|
690
|
-
return { statusCode: 401, body: "Unauthorized" };
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
const initialTwiML = this.provider.consumeInitialTwiML?.(ctx);
|
|
694
|
-
if (initialTwiML !== undefined && initialTwiML !== null) {
|
|
695
|
-
const params = new URLSearchParams(ctx.rawBody);
|
|
696
|
-
console.log(
|
|
697
|
-
`[voice-call] Serving provider initial TwiML before realtime handling (callSid=${params.get("CallSid") ?? "unknown"}, direction=${params.get("Direction") ?? "unknown"})`,
|
|
698
|
-
);
|
|
699
|
-
return {
|
|
700
|
-
statusCode: 200,
|
|
701
|
-
headers: { "Content-Type": "application/xml" },
|
|
702
|
-
body: initialTwiML,
|
|
703
|
-
};
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
const realtimeParams = this.getRealtimeTwimlParams(ctx);
|
|
707
|
-
if (realtimeParams) {
|
|
708
|
-
const direction = realtimeParams.get("Direction");
|
|
709
|
-
const isInboundRealtimeRequest = !direction || direction === "inbound";
|
|
710
|
-
if (isInboundRealtimeRequest && !this.shouldAcceptRealtimeInboundRequest(realtimeParams)) {
|
|
711
|
-
console.log("[voice-call] Realtime inbound call rejected before stream setup");
|
|
712
|
-
return buildRealtimeRejectedTwiML();
|
|
713
|
-
}
|
|
714
|
-
console.log(
|
|
715
|
-
`[voice-call] Serving realtime TwiML for Twilio call ${realtimeParams.get("CallSid") ?? "unknown"} (direction=${direction ?? "unknown"})`,
|
|
716
|
-
);
|
|
717
|
-
return this.realtimeHandler!.buildTwiMLPayload(req, realtimeParams);
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
const parsed = this.provider.parseWebhookEvent(ctx, {
|
|
721
|
-
verifiedRequestKey: verification.verifiedRequestKey,
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
if (verification.isReplay) {
|
|
725
|
-
console.warn("[voice-call] Replay detected; skipping event side effects");
|
|
726
|
-
} else {
|
|
727
|
-
this.processParsedEvents(parsed.events);
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
return normalizeWebhookResponse(parsed);
|
|
731
|
-
} finally {
|
|
732
|
-
this.webhookInFlightLimiter.release(inFlightKey);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
private verifyPreAuthWebhookHeaders(headers: http.IncomingHttpHeaders): WebhookHeaderGateResult {
|
|
737
|
-
if (this.config.skipSignatureVerification) {
|
|
738
|
-
return { ok: true };
|
|
739
|
-
}
|
|
740
|
-
switch (this.provider.name) {
|
|
741
|
-
case "telnyx": {
|
|
742
|
-
const signature = getHeader(headers, "telnyx-signature-ed25519");
|
|
743
|
-
const timestamp = getHeader(headers, "telnyx-timestamp");
|
|
744
|
-
if (signature && timestamp) {
|
|
745
|
-
return { ok: true };
|
|
746
|
-
}
|
|
747
|
-
return { ok: false, reason: "missing Telnyx signature or timestamp header" };
|
|
748
|
-
}
|
|
749
|
-
case "twilio":
|
|
750
|
-
if (getHeader(headers, "x-twilio-signature")) {
|
|
751
|
-
return { ok: true };
|
|
752
|
-
}
|
|
753
|
-
return { ok: false, reason: "missing X-Twilio-Signature header" };
|
|
754
|
-
case "plivo": {
|
|
755
|
-
const hasV3 =
|
|
756
|
-
Boolean(getHeader(headers, "x-plivo-signature-v3")) &&
|
|
757
|
-
Boolean(getHeader(headers, "x-plivo-signature-v3-nonce"));
|
|
758
|
-
const hasV2 =
|
|
759
|
-
Boolean(getHeader(headers, "x-plivo-signature-v2")) &&
|
|
760
|
-
Boolean(getHeader(headers, "x-plivo-signature-v2-nonce"));
|
|
761
|
-
if (hasV3 || hasV2) {
|
|
762
|
-
return { ok: true };
|
|
763
|
-
}
|
|
764
|
-
return { ok: false, reason: "missing Plivo signature headers" };
|
|
765
|
-
}
|
|
766
|
-
default:
|
|
767
|
-
return { ok: true };
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
private isRealtimeWebSocketUpgrade(req: http.IncomingMessage): boolean {
|
|
772
|
-
try {
|
|
773
|
-
const pathname = buildRequestUrl(req.url, req.headers.host).pathname;
|
|
774
|
-
const pattern = this.realtimeHandler?.getStreamPathPattern();
|
|
775
|
-
return Boolean(pattern && pathname.startsWith(pattern));
|
|
776
|
-
} catch {
|
|
777
|
-
return false;
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
private getRealtimeTwimlParams(ctx: WebhookContext): URLSearchParams | null {
|
|
782
|
-
if (!this.realtimeHandler || this.provider.name !== "twilio") {
|
|
783
|
-
return null;
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
const params = new URLSearchParams(ctx.rawBody);
|
|
787
|
-
const direction = params.get("Direction");
|
|
788
|
-
const isSupportedDirection =
|
|
789
|
-
!direction || direction === "inbound" || direction.startsWith("outbound");
|
|
790
|
-
if (!isSupportedDirection) {
|
|
791
|
-
return null;
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
if (ctx.query?.type === "status") {
|
|
795
|
-
return null;
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
const callStatus = params.get("CallStatus");
|
|
799
|
-
if (callStatus && isProviderStatusTerminal(callStatus)) {
|
|
800
|
-
return null;
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
// Replays must return the same TwiML body so Twilio retries reconnect cleanly.
|
|
804
|
-
// The one-time token still changes, but the behavior stays identical.
|
|
805
|
-
return !params.get("SpeechResult") && !params.get("Digits") ? params : null;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
private shouldAcceptRealtimeInboundRequest(params: URLSearchParams): boolean {
|
|
809
|
-
switch (this.config.inboundPolicy) {
|
|
810
|
-
case "open":
|
|
811
|
-
return true;
|
|
812
|
-
case "allowlist":
|
|
813
|
-
case "pairing":
|
|
814
|
-
return isAllowlistedCaller(
|
|
815
|
-
normalizePhoneNumber(params.get("From") ?? undefined),
|
|
816
|
-
this.config.allowFrom,
|
|
817
|
-
);
|
|
818
|
-
case "disabled":
|
|
819
|
-
default:
|
|
820
|
-
return false;
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
private processParsedEvents(events: NormalizedEvent[]): void {
|
|
825
|
-
for (const event of events) {
|
|
826
|
-
try {
|
|
827
|
-
this.manager.processEvent(event);
|
|
828
|
-
} catch (err) {
|
|
829
|
-
console.error(`[voice-call] Error processing event ${event.type}:`, err);
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
private writeWebhookResponse(res: http.ServerResponse, payload: WebhookResponsePayload): void {
|
|
835
|
-
res.statusCode = payload.statusCode;
|
|
836
|
-
if (payload.headers) {
|
|
837
|
-
for (const [key, value] of Object.entries(payload.headers)) {
|
|
838
|
-
res.setHeader(key, value);
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
res.end(payload.body);
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
/**
|
|
845
|
-
* Read request body as string with timeout protection.
|
|
846
|
-
*/
|
|
847
|
-
private readBody(
|
|
848
|
-
req: http.IncomingMessage,
|
|
849
|
-
maxBytes: number,
|
|
850
|
-
timeoutMs = WEBHOOK_BODY_TIMEOUT_MS,
|
|
851
|
-
): Promise<string> {
|
|
852
|
-
return readRequestBodyWithLimit(req, { maxBytes, timeoutMs });
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
/**
|
|
856
|
-
* Handle auto-response for inbound calls using the agent system.
|
|
857
|
-
* Supports tool calling for richer voice interactions.
|
|
858
|
-
*/
|
|
859
|
-
private async handleInboundResponse(callId: string, userMessage: string): Promise<void> {
|
|
860
|
-
console.log(`[voice-call] Auto-responding to inbound call ${callId}: "${userMessage}"`);
|
|
861
|
-
|
|
862
|
-
// Get call context for conversation history
|
|
863
|
-
const call = this.manager.getCall(callId);
|
|
864
|
-
if (!call) {
|
|
865
|
-
console.warn(`[voice-call] Call ${callId} not found for auto-response`);
|
|
866
|
-
return;
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
if (!this.coreConfig) {
|
|
870
|
-
console.warn("[voice-call] Core config missing; skipping auto-response");
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
873
|
-
if (!this.agentRuntime) {
|
|
874
|
-
console.warn("[voice-call] Agent runtime missing; skipping auto-response");
|
|
875
|
-
return;
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
try {
|
|
879
|
-
const { generateVoiceResponse } = await loadResponseGeneratorModule();
|
|
880
|
-
const numberRouteKey =
|
|
881
|
-
typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to;
|
|
882
|
-
const effectiveConfig = resolveVoiceCallEffectiveConfig(this.config, numberRouteKey).config;
|
|
883
|
-
|
|
884
|
-
const result = await generateVoiceResponse({
|
|
885
|
-
voiceConfig: effectiveConfig,
|
|
886
|
-
coreConfig: this.coreConfig,
|
|
887
|
-
agentRuntime: this.agentRuntime,
|
|
888
|
-
callId,
|
|
889
|
-
sessionKey: call.sessionKey,
|
|
890
|
-
from: call.from,
|
|
891
|
-
transcript: call.transcript,
|
|
892
|
-
userMessage,
|
|
893
|
-
});
|
|
894
|
-
|
|
895
|
-
if (result.error) {
|
|
896
|
-
console.error(`[voice-call] Response generation error: ${result.error}`);
|
|
897
|
-
return;
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
if (result.text) {
|
|
901
|
-
console.log(`[voice-call] AI response: "${result.text}"`);
|
|
902
|
-
await this.manager.speak(callId, result.text);
|
|
903
|
-
}
|
|
904
|
-
} catch (err) {
|
|
905
|
-
console.error(`[voice-call] Auto-response error:`, err);
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
}
|