openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
3,372 lines • 119 kB
JavaScript
import { c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js";
import { C as resolveExpiresAtMsFromDurationMs, j as resolveTimerTimeoutMs, n as MAX_TIMER_TIMEOUT_MS, o as asDateTimestampMs } from "./number-coercion-CJQ8TR--.js";
import { i as formatErrorMessage } from "./errors-BXgSefBE.js";
import { l as normalizeStringEntries } from "./string-normalization-WNUDCpXX.js";
import { a as root } from "./secure-temp-dir-XAWcZnE2.js";
import { i as isLoopbackHost } from "./net-DTe7AQiu.js";
import { c as isBlockedHostnameOrIp } from "./ssrf-CvPEXMGn.js";
import "./error-runtime-C8vbtAJt.js";
import "./number-runtime-DBLVDypr.js";
import "./security-runtime-CQm7DD1u.js";
import "./string-coerce-runtime-CEGJWkQ_.js";
import { n as parseTtsDirectives } from "./directives-BaSUaNZN.js";
import { a as isRequestBodyLimitError, c as requestBodyErrorToText, s as readRequestBodyWithLimit } from "./http-body-DASeqdKJ.js";
import "./gateway-runtime-BMyBmMxe.js";
import { F as resolveRealtimeVoiceAgentConsultTools, H as createTalkSessionController, I as resolveRealtimeVoiceAgentConsultToolsAllow, O as buildRealtimeVoiceAgentConsultPolicyInstructions, T as REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, W as recordTalkObservabilityEvent } from "./session-log-runtime-GkK49CJb.js";
import { c as consultRealtimeVoiceAgent, o as resolveRealtimeVoiceFastContextConsult, t as convertPcmToMulaw8k } from "./realtime-voice-BRCFRGg7.js";
import { t as resolveConfiguredCapabilityProvider } from "./provider-selection-runtime-CtYJinY9.js";
import "./speech-DeKvI3F1.js";
import { a as createWebhookInFlightLimiter, t as WEBHOOK_BODY_READ_DEFAULTS } from "./webhook-request-guards-gS2khsvD.js";
import "./webhook-ingress-Dgd3Jtks.js";
import "./api-CS4KHy5x.js";
import { a as resolveVoiceCallEffectiveConfig, c as deepMergeDefined, i as resolveVoiceCallConfig, n as normalizeVoiceCallConfig, o as resolveVoiceCallSessionKey, r as resolveTwilioAuthToken, s as validateProviderConfig } from "./config-BYJnzp4g.js";
import { c as getCallHistoryFromStore, d as persistCallRecord, h as TerminalStates, l as loadActiveCallsFromStore, m as setVoiceCallStateRuntime } from "./store-BBv6gmoy.js";
import { n as mapVoiceToPolly, t as escapeXml } from "./voice-mapping-sBS7nmKq.js";
import { t as resolveVoiceResponseModel } from "./response-model-DBgnh7Hz.js";
import { t as isProviderStatusTerminal } from "./call-status-Cm2PXhHi.js";
import { t as getHeader } from "./http-headers-BKIHh6IS.js";
import { URL as URL$1 } from "node:url";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import { WebSocket as WebSocket$1, WebSocketServer } from "ws";
import http from "node:http";
//#region extensions/voice-call/src/allowlist.ts
/** Normalize a phone number to digits only. */
function normalizePhoneNumber(input) {
if (!input) return "";
return input.replace(/\D/g, "");
}
/** Return true when the normalized caller exactly matches an allowlist entry. */
function isAllowlistedCaller(normalizedFrom, allowFrom) {
if (!normalizedFrom) return false;
return (allowFrom ?? []).some((num) => {
const normalizedAllow = normalizePhoneNumber(num);
return normalizedAllow !== "" && normalizedAllow === normalizedFrom;
});
}
//#endregion
//#region extensions/voice-call/src/manager/state.ts
const ConversationStates = new Set(["speaking", "listening"]);
const StateOrder = [
"initiated",
"ringing",
"answered",
"active",
"speaking",
"listening"
];
function transitionState(call, newState) {
if (call.state === newState || TerminalStates.has(call.state)) return;
if (TerminalStates.has(newState)) {
call.state = newState;
return;
}
if (ConversationStates.has(call.state) && ConversationStates.has(newState)) {
call.state = newState;
return;
}
const currentIndex = StateOrder.indexOf(call.state);
if (StateOrder.indexOf(newState) > currentIndex) call.state = newState;
}
function addTranscriptEntry(call, speaker, text) {
const entry = {
timestamp: Date.now(),
speaker,
text,
isFinal: true
};
call.transcript.push(entry);
}
//#endregion
//#region extensions/voice-call/src/manager/timer-delays.ts
/** Convert seconds to a safe timeout delay in milliseconds. */
function resolveVoiceCallSecondsTimerDelayMs(seconds, minMs = 1) {
if (!Number.isFinite(seconds)) return resolveTimerTimeoutMs(MAX_TIMER_TIMEOUT_MS, MAX_TIMER_TIMEOUT_MS, minMs);
const timeoutMs = Math.floor(seconds * 1e3);
return resolveTimerTimeoutMs(Number.isFinite(timeoutMs) ? timeoutMs : MAX_TIMER_TIMEOUT_MS, minMs, minMs);
}
/** Normalize a millisecond timeout delay with fallback behavior. */
function resolveVoiceCallTimerDelayMs(timeoutMs, fallbackMs = 1) {
return resolveTimerTimeoutMs(timeoutMs, fallbackMs);
}
//#endregion
//#region extensions/voice-call/src/manager/timers.ts
/** Clear and forget the max-duration timer for a call. */
function clearMaxDurationTimer(ctx, callId) {
const timer = ctx.maxDurationTimers.get(callId);
if (timer) {
clearTimeout(timer);
ctx.maxDurationTimers.delete(callId);
}
}
/** Start or replace the max-duration timer for a call. */
function startMaxDurationTimer(params) {
clearMaxDurationTimer(params.ctx, params.callId);
const maxDurationMs = params.timeoutMs === void 0 ? resolveVoiceCallSecondsTimerDelayMs(params.ctx.config.maxDurationSeconds) : resolveVoiceCallTimerDelayMs(params.timeoutMs);
console.log(`[voice-call] Starting max duration timer (${Math.ceil(maxDurationMs / 1e3)}s) for call ${params.callId}`);
const timer = setTimeout(() => {
(async () => {
params.ctx.maxDurationTimers.delete(params.callId);
const call = params.ctx.activeCalls.get(params.callId);
if (call && !TerminalStates.has(call.state)) {
console.log(`[voice-call] Max duration reached (${Math.ceil(maxDurationMs / 1e3)}s), ending call ${params.callId}`);
call.endReason = "timeout";
persistCallRecord(params.ctx.storePath, call);
await params.onTimeout(params.callId);
}
})();
}, maxDurationMs);
params.ctx.maxDurationTimers.set(params.callId, timer);
}
/** Clear and forget a pending final-transcript waiter. */
function clearTranscriptWaiter(ctx, callId) {
const waiter = ctx.transcriptWaiters.get(callId);
if (!waiter) return;
clearTimeout(waiter.timeout);
ctx.transcriptWaiters.delete(callId);
}
/** Reject a pending transcript waiter during call finalization or error paths. */
function rejectTranscriptWaiter(ctx, callId, reason) {
const waiter = ctx.transcriptWaiters.get(callId);
if (!waiter) return;
clearTranscriptWaiter(ctx, callId);
waiter.reject(new Error(reason));
}
/** Resolve a transcript waiter when the matching turn's final transcript arrives. */
function resolveTranscriptWaiter(ctx, callId, transcript, turnToken) {
const waiter = ctx.transcriptWaiters.get(callId);
if (!waiter) return false;
if (waiter.turnToken && waiter.turnToken !== turnToken) return false;
clearTranscriptWaiter(ctx, callId);
waiter.resolve(transcript);
return true;
}
/** Wait for the next final transcript for a call, optionally scoped to a turn token. */
function waitForFinalTranscript(ctx, callId, turnToken) {
if (ctx.transcriptWaiters.has(callId)) return Promise.reject(/* @__PURE__ */ new Error("Already waiting for transcript"));
const timeoutMs = resolveVoiceCallTimerDelayMs(ctx.config.transcriptTimeoutMs);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ctx.transcriptWaiters.delete(callId);
reject(/* @__PURE__ */ new Error(`Timed out waiting for transcript after ${timeoutMs}ms`));
}, timeoutMs);
ctx.transcriptWaiters.set(callId, {
resolve,
reject,
timeout,
turnToken
});
});
}
//#endregion
//#region extensions/voice-call/src/manager/lifecycle.ts
/** Remove a provider-call mapping only when it still points at this call. */
function removeProviderCallMapping(providerCallIdMap, call) {
if (!call.providerCallId) return;
if (providerCallIdMap.get(call.providerCallId) === call.callId) providerCallIdMap.delete(call.providerCallId);
}
/** Persist terminal state, clean timers/waiters, and remove active call indexes. */
function finalizeCall(params) {
const { ctx, call, endReason } = params;
call.endedAt = params.endedAt ?? Date.now();
call.endReason = endReason;
transitionState(call, endReason);
persistCallRecord(ctx.storePath, call);
if (ctx.maxDurationTimers) clearMaxDurationTimer({ maxDurationTimers: ctx.maxDurationTimers }, call.callId);
if (ctx.transcriptWaiters) rejectTranscriptWaiter({ transcriptWaiters: ctx.transcriptWaiters }, call.callId, params.transcriptRejectReason ?? `Call ended: ${endReason}`);
ctx.activeCalls.delete(call.callId);
removeProviderCallMapping(ctx.providerCallIdMap, call);
}
//#endregion
//#region extensions/voice-call/src/manager/lookup.ts
/** Resolve an active call from provider call id with map lookup plus stale-map fallback scan. */
function getCallByProviderCallId(params) {
const callId = params.providerCallIdMap.get(params.providerCallId);
if (callId) return params.activeCalls.get(callId);
for (const call of params.activeCalls.values()) if (call.providerCallId === params.providerCallId) return call;
}
/** Resolve an active call by internal call id or provider call id. */
function findCall(params) {
const directCall = params.activeCalls.get(params.callIdOrProviderCallId);
if (directCall) return directCall;
return getCallByProviderCallId({
activeCalls: params.activeCalls,
providerCallIdMap: params.providerCallIdMap,
providerCallId: params.callIdOrProviderCallId
});
}
//#endregion
//#region extensions/voice-call/src/tts-provider-voice.ts
/** Read voice setting aliases from one provider-specific config block. */
function resolveProviderVoiceSetting(providerConfig) {
if (!providerConfig || typeof providerConfig !== "object") return;
const candidate = providerConfig;
return normalizeOptionalString(candidate.speakerVoice) ?? normalizeOptionalString(candidate.speakerVoiceId) ?? normalizeOptionalString(candidate.voice) ?? normalizeOptionalString(candidate.voiceId);
}
/** Resolve the active provider's preferred voice id/name from voice-call TTS config. */
function resolvePreferredTtsVoice(config) {
const providerId = config.tts?.provider;
if (!providerId) return;
return resolveProviderVoiceSetting(config.tts?.providers?.[providerId]);
}
//#endregion
//#region extensions/voice-call/src/manager/twiml.ts
/** Generate TwiML that speaks one notification and hangs up. */
function generateNotifyTwiml(message, voice) {
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="${voice}">${escapeXml(message)}</Say>
<Hangup/>
</Response>`;
}
/** Generate TwiML that plays DTMF digits before redirecting to a webhook URL. */
function generateDtmfRedirectTwiml(digits, webhookUrl) {
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Play digits="${escapeXml(digits)}" />
<Redirect method="POST">${escapeXml(webhookUrl)}</Redirect>
</Response>`;
}
//#endregion
//#region extensions/voice-call/src/manager/outbound.ts
function lookupConnectedCall(ctx, callId) {
const call = ctx.activeCalls.get(callId);
if (!call) return {
kind: "error",
error: "Call not found"
};
if (!ctx.provider || !call.providerCallId) return {
kind: "error",
error: "Call not connected"
};
if (TerminalStates.has(call.state)) return {
kind: "ended",
call
};
return {
kind: "ok",
call,
providerCallId: call.providerCallId,
provider: ctx.provider
};
}
function requireConnectedCall(ctx, callId) {
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") return {
ok: false,
error: lookup.error
};
if (lookup.kind === "ended") return {
ok: false,
error: "Call has ended"
};
return {
ok: true,
call: lookup.call,
providerCallId: lookup.providerCallId,
provider: lookup.provider
};
}
function validateDtmfDigits(digits) {
return /^[0-9*#wWpP,]+$/.test(digits) ? null : "digits may only contain digits, *, #, comma, w, p";
}
async function initiateCall(ctx, to, sessionKey, options) {
const opts = typeof options === "string" ? { message: options } : options ?? {};
const initialMessage = opts.message;
const mode = opts.mode ?? ctx.config.outbound.defaultMode;
const dtmfSequence = opts.dtmfSequence;
const requesterSessionKey = opts.requesterSessionKey?.trim();
if (dtmfSequence) {
const validationError = validateDtmfDigits(dtmfSequence);
if (validationError) return {
callId: "",
success: false,
error: validationError
};
if (mode !== "conversation") return {
callId: "",
success: false,
error: "dtmfSequence requires conversation mode"
};
}
if (!ctx.provider) return {
callId: "",
success: false,
error: "Provider not initialized"
};
if (!ctx.webhookUrl) return {
callId: "",
success: false,
error: "Webhook URL not configured"
};
if (ctx.activeCalls.size >= ctx.config.maxConcurrentCalls) return {
callId: "",
success: false,
error: `Maximum concurrent calls (${ctx.config.maxConcurrentCalls}) reached`
};
const callId = crypto.randomUUID();
const from = ctx.config.fromNumber || (ctx.provider?.name === "mock" ? "+15550000000" : void 0);
if (!from) return {
callId: "",
success: false,
error: "fromNumber not configured"
};
const callRecord = {
callId,
provider: ctx.provider.name,
direction: "outbound",
state: "initiated",
from,
to,
sessionKey: resolveVoiceCallSessionKey({
config: ctx.config,
callId,
phone: to,
explicitSessionKey: sessionKey
}),
startedAt: Date.now(),
transcript: [],
processedEventIds: [],
metadata: {
...initialMessage && { initialMessage },
mode,
...requesterSessionKey ? { requesterSessionKey } : {}
}
};
ctx.activeCalls.set(callId, callRecord);
persistCallRecord(ctx.storePath, callRecord);
try {
let inlineTwiml;
let preConnectTwiml;
if (mode === "notify" && initialMessage) {
const pollyVoice = mapVoiceToPolly(resolvePreferredTtsVoice(ctx.config));
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
} else if (dtmfSequence) {
preConnectTwiml = generateDtmfRedirectTwiml(dtmfSequence, ctx.webhookUrl);
console.log(`[voice-call] Using pre-connect DTMF TwiML for call ${callId} (digits=${dtmfSequence.length}, initialMessage=${initialMessage ? "yes" : "no"})`);
}
const streamSession = ctx.config.realtime?.enabled && ctx.provider.name === "telnyx" && ctx.streamSessionIssuer ? ctx.streamSessionIssuer({
providerName: "telnyx",
callId,
from,
to,
direction: "outbound"
}) : void 0;
const result = await ctx.provider.initiateCall({
callId,
from,
to,
webhookUrl: ctx.webhookUrl,
inlineTwiml,
preConnectTwiml,
...streamSession ? {
streamUrl: streamSession.streamUrl,
streamAuthToken: streamSession.token
} : {}
});
callRecord.providerCallId = result.providerCallId;
ctx.providerCallIdMap.set(result.providerCallId, callId);
persistCallRecord(ctx.storePath, callRecord);
console.log(`[voice-call] Outbound call initiated: callId=${callId} providerCallId=${result.providerCallId} mode=${mode} preConnectDtmf=${preConnectTwiml ? "yes" : "no"} initialMessage=${initialMessage ? "yes" : "no"}`);
return {
callId,
success: true
};
} catch (err) {
finalizeCall({
ctx,
call: callRecord,
endReason: "failed"
});
return {
callId,
success: false,
error: formatErrorMessage(err)
};
}
}
async function speak(ctx, callId, text) {
const connected = requireConnectedCall(ctx, callId);
if (!connected.ok) return {
success: false,
error: connected.error
};
const { call, providerCallId, provider } = connected;
try {
transitionState(call, "speaking");
persistCallRecord(ctx.storePath, call);
const numberRouteKey = typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to;
const voice = resolvePreferredTtsVoice(resolveVoiceCallEffectiveConfig(ctx.config, numberRouteKey).config);
await provider.playTts({
callId,
providerCallId,
text,
voice
});
addTranscriptEntry(call, "bot", text);
persistCallRecord(ctx.storePath, call);
return { success: true };
} catch (err) {
transitionState(call, "listening");
persistCallRecord(ctx.storePath, call);
return {
success: false,
error: formatErrorMessage(err)
};
}
}
function shouldStartListeningAfterInitialMessage(ctx) {
if (ctx.provider?.name !== "twilio") return true;
if (!ctx.config.streaming.enabled) return true;
return ctx.provider.isConversationStreamConnectEnabled?.() !== true;
}
async function sendDtmf(ctx, callId, digits) {
const validationError = validateDtmfDigits(digits);
if (validationError) return {
success: false,
error: validationError
};
const connected = requireConnectedCall(ctx, callId);
if (!connected.ok) return {
success: false,
error: connected.error
};
if (!connected.provider.sendDtmf) return {
success: false,
error: `${connected.provider.name} does not support outbound DTMF`
};
try {
await connected.provider.sendDtmf({
callId,
providerCallId: connected.providerCallId,
digits
});
return { success: true };
} catch (err) {
return {
success: false,
error: formatErrorMessage(err)
};
}
}
async function speakInitialMessage(ctx, providerCallId) {
const call = getCallByProviderCallId({
activeCalls: ctx.activeCalls,
providerCallIdMap: ctx.providerCallIdMap,
providerCallId
});
if (!call) {
console.warn(`[voice-call] speakInitialMessage: no call found for ${providerCallId}`);
return;
}
const initialMessage = call.metadata?.initialMessage;
const mode = call.metadata?.mode ?? "conversation";
if (!initialMessage) {
console.log(`[voice-call] speakInitialMessage: no initial message for ${call.callId}`);
return;
}
if (ctx.initialMessageInFlight.has(call.callId)) {
console.log(`[voice-call] speakInitialMessage: initial message already in flight for ${call.callId}`);
return;
}
ctx.initialMessageInFlight.add(call.callId);
try {
console.log(`[voice-call] Speaking initial message for call ${call.callId} (mode: ${mode})`);
const result = await speak(ctx, call.callId, initialMessage);
if (!result.success) {
console.warn(`[voice-call] Failed to speak initial message: ${result.error}`);
return;
}
if (call.metadata) {
delete call.metadata.initialMessage;
persistCallRecord(ctx.storePath, call);
}
if (mode === "notify") {
const delaySec = ctx.config.outbound.notifyHangupDelaySec;
const delayMs = resolveVoiceCallSecondsTimerDelayMs(delaySec, 0);
console.log(`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`);
setTimeout(() => {
(async () => {
const currentCall = ctx.activeCalls.get(call.callId);
if (currentCall && !TerminalStates.has(currentCall.state)) {
console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`);
await endCall(ctx, call.callId);
}
})();
}, delayMs);
} else if (mode === "conversation" && ctx.provider && shouldStartListeningAfterInitialMessage(ctx)) {
transitionState(call, "listening");
persistCallRecord(ctx.storePath, call);
await ctx.provider.startListening({
callId: call.callId,
providerCallId
});
}
} finally {
ctx.initialMessageInFlight.delete(call.callId);
}
}
async function continueCall(ctx, callId, prompt) {
const connected = requireConnectedCall(ctx, callId);
if (!connected.ok) return {
success: false,
error: connected.error
};
const { call, providerCallId, provider } = connected;
if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) return {
success: false,
error: "Already waiting for transcript"
};
ctx.activeTurnCalls.add(callId);
const turnStartedAt = Date.now();
const turnToken = provider.name === "twilio" ? crypto.randomUUID() : void 0;
try {
await speak(ctx, callId, prompt);
transitionState(call, "listening");
persistCallRecord(ctx.storePath, call);
const listenStartedAt = Date.now();
await provider.startListening({
callId,
providerCallId,
turnToken
});
const transcript = await waitForFinalTranscript(ctx, callId, turnToken);
const transcriptReceivedAt = Date.now();
await provider.stopListening({
callId,
providerCallId
});
const lastTurnLatencyMs = transcriptReceivedAt - turnStartedAt;
const lastTurnListenWaitMs = transcriptReceivedAt - listenStartedAt;
const turnCount = call.metadata && typeof call.metadata.turnCount === "number" ? call.metadata.turnCount + 1 : 1;
call.metadata = {
...call.metadata,
turnCount,
lastTurnLatencyMs,
lastTurnListenWaitMs,
lastTurnCompletedAt: transcriptReceivedAt
};
persistCallRecord(ctx.storePath, call);
console.log("[voice-call] continueCall latency call=" + call.callId + " totalMs=" + String(lastTurnLatencyMs) + " listenWaitMs=" + String(lastTurnListenWaitMs));
return {
success: true,
transcript
};
} catch (err) {
return {
success: false,
error: formatErrorMessage(err)
};
} finally {
ctx.activeTurnCalls.delete(callId);
clearTranscriptWaiter(ctx, callId);
}
}
async function endCall(ctx, callId, options) {
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") return {
success: false,
error: lookup.error
};
if (lookup.kind === "ended") return { success: true };
const { call, providerCallId, provider } = lookup;
const reason = options?.reason ?? "hangup-bot";
try {
await provider.hangupCall({
callId,
providerCallId,
reason
});
finalizeCall({
ctx,
call,
endReason: reason
});
return { success: true };
} catch (err) {
return {
success: false,
error: formatErrorMessage(err)
};
}
}
//#endregion
//#region extensions/voice-call/src/manager/events.ts
function shouldAcceptInbound(config, from) {
const { inboundPolicy: policy, allowFrom } = config;
switch (policy) {
case "disabled":
console.log("[voice-call] Inbound call rejected: policy is disabled");
return false;
case "open":
console.log("[voice-call] Inbound call accepted: policy is open");
return true;
case "allowlist":
case "pairing": {
const normalized = normalizePhoneNumber(from);
if (!normalized) {
console.log("[voice-call] Inbound call rejected: missing caller ID");
return false;
}
const allowed = isAllowlistedCaller(normalized, allowFrom);
console.log(`[voice-call] Inbound call ${allowed ? "accepted" : "rejected"}: ${from} ${allowed ? "is in" : "not in"} allowlist`);
return allowed;
}
default: return false;
}
}
function createWebhookCall(params) {
const callId = crypto.randomUUID();
const effective = resolveVoiceCallEffectiveConfig(params.ctx.config, params.direction === "inbound" ? params.to : void 0);
const effectiveConfig = effective.config;
const callRecord = {
callId,
providerCallId: params.providerCallId,
provider: params.ctx.provider?.name || "twilio",
direction: params.direction,
state: "ringing",
from: params.from,
to: params.to,
sessionKey: resolveVoiceCallSessionKey({
config: effectiveConfig,
callId,
phone: params.direction === "outbound" ? params.to : params.from
}),
startedAt: Date.now(),
transcript: [],
processedEventIds: [],
metadata: {
initialMessage: params.direction === "inbound" ? effectiveConfig.inboundGreeting || "Hello! How can I help you today?" : void 0,
...effective.numberRouteKey ? { numberRouteKey: effective.numberRouteKey } : {}
}
};
params.ctx.activeCalls.set(callId, callRecord);
params.ctx.providerCallIdMap.set(params.providerCallId, callId);
persistCallRecord(params.ctx.storePath, callRecord);
console.log(`[voice-call] Created ${params.direction} call record: ${callId} from ${params.from}`);
return callRecord;
}
function persistRejectedInboundCall(params) {
const callId = params.event.callId || params.providerCallId;
const now = Date.now();
const rejectedCall = {
callId,
providerCallId: params.providerCallId,
provider: params.ctx.provider?.name || "twilio",
direction: "inbound",
state: "hangup-bot",
from: params.event.from || "unknown",
to: params.event.to || params.ctx.config.fromNumber || "unknown",
startedAt: params.event.timestamp || now,
endedAt: now,
endReason: "hangup-bot",
transcript: [],
processedEventIds: [params.dedupeKey],
metadata: { rejectionReason: "inbound-policy" }
};
persistCallRecord(params.ctx.storePath, rejectedCall);
}
function processEvent(ctx, event) {
const dedupeKey = event.dedupeKey || event.id;
if (ctx.processedEventIds.has(dedupeKey)) return;
let call = findCall({
activeCalls: ctx.activeCalls,
providerCallIdMap: ctx.providerCallIdMap,
callIdOrProviderCallId: event.callId
});
const providerCallId = event.providerCallId;
const eventDirection = event.direction === "inbound" || event.direction === "outbound" ? event.direction : void 0;
if (!call && providerCallId && eventDirection) {
if (eventDirection === "inbound" && !shouldAcceptInbound(ctx.config, event.from)) {
const pid = providerCallId;
if (!ctx.provider) {
console.warn(`[voice-call] Inbound call rejected by policy but no provider to hang up (providerCallId: ${pid}, from: ${event.from}); call will time out on provider side.`);
return;
}
ctx.processedEventIds.add(dedupeKey);
if (ctx.rejectedProviderCallIds.has(pid)) return;
ctx.rejectedProviderCallIds.add(pid);
const callId = event.callId ?? pid;
persistRejectedInboundCall({
ctx,
event,
dedupeKey,
providerCallId: pid
});
console.log(`[voice-call] Rejecting inbound call by policy: ${pid}`);
ctx.provider.hangupCall({
callId,
providerCallId: pid,
reason: "hangup-bot"
}).catch((err) => {
ctx.rejectedProviderCallIds.delete(pid);
const message = formatErrorMessage(err);
console.warn(`[voice-call] Failed to reject inbound call ${pid}:`, message);
});
return;
}
call = createWebhookCall({
ctx,
providerCallId,
direction: eventDirection === "outbound" ? "outbound" : "inbound",
from: event.from || "unknown",
to: event.to || ctx.config.fromNumber || "unknown"
});
event.callId = call.callId;
}
if (!call) return;
if (event.providerCallId && event.providerCallId !== call.providerCallId) {
const previousProviderCallId = call.providerCallId;
call.providerCallId = event.providerCallId;
ctx.providerCallIdMap.set(event.providerCallId, call.callId);
if (previousProviderCallId) {
if (ctx.providerCallIdMap.get(previousProviderCallId) === call.callId) ctx.providerCallIdMap.delete(previousProviderCallId);
}
}
if (!(event.type === "call.error" && event.retryable)) {
ctx.processedEventIds.add(dedupeKey);
call.processedEventIds.push(dedupeKey);
}
switch (event.type) {
case "call.initiated":
transitionState(call, "initiated");
if (call.direction === "inbound" && call.providerCallId && ctx.provider?.answerCall) {
const inboundStreamSession = ctx.config.realtime?.enabled && ctx.provider.name === "telnyx" && ctx.streamSessionIssuer ? ctx.streamSessionIssuer({
providerName: "telnyx",
callId: call.callId,
from: call.from,
to: call.to,
direction: "inbound"
}) : void 0;
ctx.provider.answerCall({
callId: call.callId,
providerCallId: call.providerCallId,
...inboundStreamSession ? {
streamUrl: inboundStreamSession.streamUrl,
streamAuthToken: inboundStreamSession.token
} : {}
}).catch((err) => {
const message = formatErrorMessage(err);
console.warn(`[voice-call] Failed to answer inbound call ${call.providerCallId}:`, message);
});
}
break;
case "call.ringing":
transitionState(call, "ringing");
break;
case "call.answered":
call.answeredAt = event.timestamp;
transitionState(call, "answered");
startMaxDurationTimer({
ctx,
callId: call.callId,
onTimeout: async (callId) => {
await endCall(ctx, callId, { reason: "timeout" });
}
});
ctx.onCallAnswered?.(call);
break;
case "call.active":
transitionState(call, "active");
break;
case "call.speaking":
transitionState(call, "speaking");
break;
case "call.speech":
if (event.isFinal) {
const hadWaiter = ctx.transcriptWaiters.has(call.callId);
const resolved = resolveTranscriptWaiter(ctx, call.callId, event.transcript, event.turnToken);
if (hadWaiter && !resolved) {
console.warn(`[voice-call] Ignoring speech event with mismatched turn token for ${call.callId}`);
break;
}
addTranscriptEntry(call, "user", event.transcript);
}
transitionState(call, "listening");
break;
case "call.silence":
case "call.dtmf": break;
case "call.ended":
finalizeCall({
ctx,
call,
endReason: event.reason,
endedAt: event.timestamp
});
return;
case "call.error":
if (!event.retryable) {
finalizeCall({
ctx,
call,
endReason: "error",
endedAt: event.timestamp,
transcriptRejectReason: `Call error: ${event.error}`
});
return;
}
break;
}
persistCallRecord(ctx.storePath, call);
}
//#endregion
//#region extensions/voice-call/src/utils.ts
/** Resolve user input paths, including "~" against the current OS home. */
function resolveUserPath(input) {
const trimmed = input.trim();
if (!trimmed) return trimmed;
if (trimmed.startsWith("~")) {
const expanded = trimmed.replace(/^~(?=$|[\\/])/, os.homedir());
return path.resolve(expanded);
}
return path.resolve(trimmed);
}
//#endregion
//#region extensions/voice-call/src/manager.ts
function markRestoredCallSkipped(call, endReason) {
call.endedAt = Date.now();
call.endReason = endReason;
call.state = endReason;
}
function incrementRestoreStatusCount(counts, status) {
const key = normalizeOptionalString(status) ?? "terminal";
counts.set(key, (counts.get(key) ?? 0) + 1);
}
function resolveDefaultStoreBase(config, storePath) {
const rawOverride = storePath?.trim() || config.store?.trim();
if (rawOverride) return resolveUserPath(rawOverride);
const preferred = path.join(os.homedir(), ".openclaw", "voice-calls");
return [preferred].map((dir) => resolveUserPath(dir)).find((dir) => {
try {
return fs.existsSync(path.join(dir, "calls.jsonl")) || fs.existsSync(dir);
} catch {
return false;
}
}) ?? resolveUserPath(preferred);
}
/**
* Manages voice calls: state ownership and delegation to manager helper modules.
*/
var CallManager = class {
constructor(config, storePath) {
this.activeCalls = /* @__PURE__ */ new Map();
this.providerCallIdMap = /* @__PURE__ */ new Map();
this.processedEventIds = /* @__PURE__ */ new Set();
this.rejectedProviderCallIds = /* @__PURE__ */ new Set();
this.provider = null;
this.webhookUrl = null;
this.activeTurnCalls = /* @__PURE__ */ new Set();
this.transcriptWaiters = /* @__PURE__ */ new Map();
this.maxDurationTimers = /* @__PURE__ */ new Map();
this.initialMessageInFlight = /* @__PURE__ */ new Set();
this.config = config;
this.storePath = resolveDefaultStoreBase(config, storePath);
}
/**
* Initialize the call manager with a provider.
* Verifies persisted calls with the provider and restarts timers.
*/
async initialize(provider, webhookUrl) {
this.provider = provider;
this.webhookUrl = webhookUrl;
fs.mkdirSync(this.storePath, { recursive: true });
const persisted = loadActiveCallsFromStore(this.storePath);
this.processedEventIds = persisted.processedEventIds;
this.rejectedProviderCallIds = persisted.rejectedProviderCallIds;
const verified = await this.verifyRestoredCalls(provider, persisted.activeCalls);
this.activeCalls = verified;
this.providerCallIdMap = /* @__PURE__ */ new Map();
for (const [callId, call] of verified) if (call.providerCallId) this.providerCallIdMap.set(call.providerCallId, callId);
let skippedAlreadyElapsedTimers = 0;
for (const [callId, call] of verified) if (call.answeredAt && !TerminalStates.has(call.state)) {
const elapsed = Date.now() - call.answeredAt;
const maxDurationMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds);
if (elapsed >= maxDurationMs) {
verified.delete(callId);
if (call.providerCallId) this.providerCallIdMap.delete(call.providerCallId);
skippedAlreadyElapsedTimers += 1;
continue;
}
startMaxDurationTimer({
ctx: this.getContext(),
callId,
timeoutMs: maxDurationMs - elapsed,
onTimeout: async (id) => {
await endCall(this.getContext(), id, { reason: "timeout" });
}
});
console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`);
}
if (skippedAlreadyElapsedTimers > 0) console.log(`[voice-call] Skipped ${skippedAlreadyElapsedTimers} restored call(s) whose max-duration timer already elapsed`);
if (verified.size > 0) console.log(`[voice-call] Restored ${verified.size} active call(s) from store`);
}
/**
* Verify persisted calls with the provider before restoring.
* Calls without providerCallId or older than maxDurationSeconds are skipped.
* Transient provider errors keep the call (rely on timer fallback).
*/
async verifyRestoredCalls(provider, candidates) {
if (candidates.size === 0) return /* @__PURE__ */ new Map();
const maxAgeMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds);
const now = Date.now();
const verified = /* @__PURE__ */ new Map();
const verifyTasks = [];
let skippedNoProviderCallId = 0;
let skippedOlderThanMaxDuration = 0;
const skippedTerminalStatuses = /* @__PURE__ */ new Map();
let keptVerifiedActive = 0;
let keptUnknownProviderStatus = 0;
let keptVerificationFailures = 0;
for (const [callId, call] of candidates) {
if (!call.providerCallId) {
skippedNoProviderCallId += 1;
continue;
}
if (now - call.startedAt > maxAgeMs) {
skippedOlderThanMaxDuration += 1;
markRestoredCallSkipped(call, "timeout");
persistCallRecord(this.storePath, call);
await provider.hangupCall({
callId,
providerCallId: call.providerCallId,
reason: "timeout"
}).catch((err) => {
console.warn(`[voice-call] Failed to hang up expired restored call ${callId}:`, err instanceof Error ? err.message : String(err));
});
continue;
}
const task = {
callId,
call,
promise: provider.getCallStatus({ providerCallId: call.providerCallId }).then((result) => {
if (result.isTerminal) {
incrementRestoreStatusCount(skippedTerminalStatuses, result.status);
markRestoredCallSkipped(call, "completed");
persistCallRecord(this.storePath, call);
} else if (result.isUnknown) {
keptUnknownProviderStatus += 1;
verified.set(callId, call);
} else {
keptVerifiedActive += 1;
verified.set(callId, call);
}
}).catch(() => {
keptVerificationFailures += 1;
verified.set(callId, call);
})
};
verifyTasks.push(task);
}
await Promise.allSettled(verifyTasks.map((t) => t.promise));
if (skippedNoProviderCallId > 0) console.log(`[voice-call] Skipped ${skippedNoProviderCallId} restored call(s) with no providerCallId`);
if (skippedOlderThanMaxDuration > 0) console.log(`[voice-call] Skipped ${skippedOlderThanMaxDuration} restored call(s) older than maxDurationSeconds`);
for (const [status, count] of [...skippedTerminalStatuses].toSorted(([a], [b]) => a.localeCompare(b))) console.log(`[voice-call] Skipped ${count} restored call(s) with provider status: ${status}`);
if (keptVerifiedActive > 0) console.log(`[voice-call] Kept ${keptVerifiedActive} restored call(s) confirmed active by provider`);
if (keptUnknownProviderStatus > 0) console.log(`[voice-call] Kept ${keptUnknownProviderStatus} restored call(s) with unknown provider status (relying on timer)`);
if (keptVerificationFailures > 0) console.log(`[voice-call] Kept ${keptVerificationFailures} restored call(s) after verification failure (relying on timer)`);
return verified;
}
/**
* Get the current provider.
*/
getProvider() {
return this.provider;
}
/**
* Initiate an outbound call.
*/
async initiateCall(to, sessionKey, options) {
return initiateCall(this.getContext(), to, sessionKey, options);
}
/**
* Speak to user in an active call.
*/
async speak(callId, text) {
return speak(this.getContext(), callId, text);
}
/**
* Send DTMF digits to an active call.
*/
async sendDtmf(callId, digits) {
return sendDtmf(this.getContext(), callId, digits);
}
/**
* Speak the initial message for a call (called when media stream connects).
*/
async speakInitialMessage(providerCallId) {
return speakInitialMessage(this.getContext(), providerCallId);
}
/**
* Continue call: speak prompt, then wait for user's final transcript.
*/
async continueCall(callId, prompt) {
return continueCall(this.getContext(), callId, prompt);
}
/**
* End an active call.
*/
async endCall(callId) {
return endCall(this.getContext(), callId);
}
getContext() {
return {
activeCalls: this.activeCalls,
providerCallIdMap: this.providerCallIdMap,
processedEventIds: this.processedEventIds,
rejectedProviderCallIds: this.rejectedProviderCallIds,
provider: this.provider,
config: this.config,
storePath: this.storePath,
webhookUrl: this.webhookUrl,
activeTurnCalls: this.activeTurnCalls,
transcriptWaiters: this.transcriptWaiters,
maxDurationTimers: this.maxDurationTimers,
initialMessageInFlight: this.initialMessageInFlight,
onCallAnswered: (call) => {
this.maybeSpeakInitialMessageOnAnswered(call);
},
streamSessionIssuer: this.streamSessionIssuer
};
}
/**
* Process a webhook event.
*/
processEvent(event) {
processEvent(this.getContext(), event);
}
shouldDeferConversationInitialMessageUntilStreamConnect() {
if (!this.provider || this.provider.name !== "twilio" || !this.config.streaming.enabled) return false;
const streamAwareProvider = this.provider;
if (typeof streamAwareProvider.isConversationStreamConnectEnabled !== "function") return false;
return streamAwareProvider.isConversationStreamConnectEnabled();
}
maybeSpeakInitialMessageOnAnswered(call) {
if (!(normalizeOptionalString(call.metadata?.initialMessage) ?? "")) return;
const mode = call.metadata?.mode ?? "conversation";
if (mode === "conversation") {
if (this.config.realtime.enabled) return;
if (this.shouldDeferConversationInitialMessageUntilStreamConnect()) return;
} else if (mode !== "notify") return;
if (!this.provider || !call.providerCallId) return;
this.speakInitialMessage(call.providerCallId).catch((err) => {
console.warn(`[voice-call] Failed to speak initial message for call ${call.callId}: ${formatErrorMessage(err)}`);
});
}
/**
* Get an active call by ID.
*/
getCall(callId) {
return this.activeCalls.get(callId);
}
/**
* Get an active call by provider call ID (e.g., Twilio CallSid).
*/
getCallByProviderCallId(providerCallId) {
return getCallByProviderCallId({
activeCalls: this.activeCalls,
providerCallIdMap: this.providerCallIdMap,
providerCallId
});
}
/**
* Get all active calls.
*/
getActiveCalls() {
return Array.from(this.activeCalls.values());
}
/**
* Get call history (from persisted logs).
*/
async getCallHistory(limit = 50) {
return getCallHistoryFromStore(this.storePath, limit);
}
};
//#endregion
//#region extensions/voice-call/src/realtime-agent-context.ts
/** Limit injected context while preserving an explicit truncation marker. */
function limitText(text, maxChars) {
if (text.length <= maxChars) return text;
return `${text.slice(0, Math.max(0, maxChars - 32)).trimEnd()}\n[truncated]`;
}
/** Read configured workspace context files through the safe workspace root. */
async function readWorkspaceVoiceContextFiles(params) {
const sections = [];
let remaining = params.maxChars;
const workspaceRoot = await root(params.workspaceDir).catch(() => null);
if (!workspaceRoot) return sections;
for (const file of params.files) {
if (remaining <= 0) continue;
const trimmed = (await workspaceRoot.readText(file).catch(() => void 0))?.trim();
if (!trimmed) continue;
const section = `### ${file}\n${limitText(trimmed, Math.max(0, remaining - file.length - 16))}`;
sections.push(section);
remaining -= section.length;
}
return sections;
}
/** Build final realtime instructions from base instructions, consult policy, and fast context. */
async function buildRealtimeVoiceInstructions(params) {
const { config } = params;
const sections = [params.baseInstructions];
const consultGuidance = buildRealtimeVoiceAgentConsultPolicyInstructions(config.realtime);
if (consultGuidance) sections.push(consultGuidance);
const contextConfig = config.realtime.agentContext;
if (!contextConfig.enabled) return sections.filter(Boolean).join("\n\n");
const agentId = config.agentId ?? "main";
const capsule = [
"OpenClaw agent voice context:",
`- Agent id: ${agentId}`,
"- Use this context to match the OpenClaw agent's personality and standing preferences on fast voice turns.",
"- Treat this as compact context only; call openclaw_agent_consult when the caller needs the full agent brain, tools, memory, or workspace state."
];
if (contextConfig.includeIdentity) {
const identity = params.agentRuntime.resolveAgentIdentity(params.coreConfig, agentId);
const identityLines = [
normalizeOptionalString(identity?.name) ? `- Name: ${normalizeOptionalString(identity?.name)}` : void 0,
normalizeOptionalString(identity?.emoji) ? `- Emoji: ${normalizeOptionalString(identity?.emoji)}` : void 0,
normalizeOptionalString(identity?.vibe) ? `- Vibe: ${normalizeOptionalString(identity?.vibe)}` : void 0,
normalizeOptionalString(identity?.theme) ? `- Theme: ${normalizeOptionalString(identity?.theme)}` : void 0,
normalizeOptionalString(identity?.creature) ? `- Creature/persona: ${normalizeOptionalString(identity?.creature)}` : void 0
].filter(Boolean);
if (identityLines.length > 0) capsule.push(`Configured identity:\n${identityLines.join("\n")}`);
}
if (contextConfig.includeWorkspaceFiles) {
const fileSections = await readWorkspaceVoiceContextFiles({
workspaceDir: params.agentRuntime.resolveAgentWorkspaceDir(params.coreConfig, agentId),
files: contextConfig.files,
maxChars: contextConfig.maxChars
});
if (fileSections.length > 0) capsule.push(`Workspace voice context:\n${fileSections.join("\n\n")}`);
}
sections.push(limitText(capsule.join("\n\n"), contextConfig.maxChars));
return sections.filter(Boolean).join("\n\n");
}
//#endregion
//#region extensions/voice-call/src/realtime-fast-context.ts
/** Resolve fast-context consult data using caller-oriented labels. */
async function resolveRealtimeFastContextConsult(params) {
return await resolveRealtimeVoiceFastContextConsult({
...params,
labels: {
audienceLabel: "caller",
contextName: "OpenClaw memory or session context"
}
});
}
//#endregion
//#region extensions/voice-call/src/telephony-tts.ts
/** Default timeout for one telephony synthesis request. */
const TELEPHONY_DEFAULT_TTS_TIMEOUT_MS = 8e3;
/** Create a TTS provider that honors voice-call overrides and converts PCM to mulaw. */
function createTelephonyTtsProvider(params) {
const { coreConfig, ttsOverride, runtime, logger } = params;
const mergedConfig = applyTtsOverride(coreConfig, ttsOverride);
const ttsConfig = mergedConfig.messages?.tts;
const modelOverrides = resolveTelephonyModelOverridePolicy(readTelephonyModelOverrides(ttsConfig));
const providerConfigs = collectTelephonyProviderConfigs(ttsConfig);
const activeProvider = normalizeProviderId(ttsConfig?.provider);
return {
synthesisTimeoutMs: resolveTimerTimeoutMs(mergedConfig.messages?.tts?.timeoutMs, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS),
synthesizeForTelephony: async (text) => {
const directives = parseTtsDirectives(text, modelOverrides, {
cfg: mergedConfig,
providerConfigs,
preferredProviderId: activeProvider
});
if (directives.warnings.length > 0) logger?.warn?.(`[voice-call] Ignored telephony TTS directive overrides (${directives.warnings.join("; ")})`);
const cleanText = directives.hasDirective ? directives.ttsText?.trim() || directives.cleanedText.trim() : text;
const result = await runtime.textToSpeechTelephony({
text: cleanText,
cfg: mergedConfig,
overrides: directives.overrides
});
if (!result.success || !result.audioBuffer || !result.sampleRate) throw new Error(result.error ?? "TTS conversion failed");
if (result.fallbackFrom && result.provider && result.fallbackFrom !== result.provider) {
const attemptedChain = result.attemptedProviders && result.attemptedProviders.length > 0 ? result.attemptedProviders.join(" -> ") : `${result.fallbackFrom} -> ${result.provider}`;
logger?.warn?.(`[voice-call] Telephony TTS fallback used from=${result.fallbackFrom} to=${result.provider} attempts=${attemptedChain}`);
}
return convertPcmToMulaw8k(result.audioBuffer, result.sampleRate);
}
};
}
/** Apply voice-call TTS overrides to core config without mutating the original object. */
function applyTtsOverride(coreConfig, override) {
if (!override) return coreConfig;
const base = coreConfig.messages?.tts;
const merged = mergeTtsConfig(base, override);
if (!merged) return coreConfig;
return {
...coreConfig,
messages: {
...coreConfig.messages,
tts: merged
}
};
}
/** Merge core and voice-call TTS config, keeping undefined override fields out. */
function mergeTtsConfig(base, override) {
if (!base && !override) return;
if (!override) return base;
if (!base) return override;
return deepMergeDefined(base, override);
}
/** Resolve directive override policy for telephony synthesis. */
function resolveTelephonyModelOverridePolicy(overrides) {
if (!(overrides?.enabled ?? true)) return {
enabled: false,
allowText: false,
allowProvider: false,
allowVoice: false,
allowModelId: false,
allowVoiceSettings: false,
allowNormalization: false,
allowSeed: false
};
const allow = (value, defaultValue = true) => value ?? defaultValue;
return {
enabled: true,
allowText: allow(overrides?.allowText),
allowProvider: allow(overrides?.allowProvider, false),
allowVoice: allow(overrides?.allowVoice),
allowModelId: allow(overrides?.allowModelId),
allowVoiceSettings: allow(overrides?.allowVoiceSettings),
allowNormalization: allow(overrides?.allowNormalization),
allowSeed: allow(overrides?.allowSeed)
};
}
/** Read model override policy from TTS config when present. */
function readTelephonyModelOverrides(ttsConfig) {
const value = ttsConfig?.modelOverrides;
return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
}
/** Normalize provider ids for config lookup. */
function normalizeProviderId(value) {
return typeof value === "string" ? value.trim().toLowerCase() || void 0 : void 0;
}
/** Coerce provider config objects while rejecting arrays and primitives. */
function asProviderConfig(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
/** Collect named provider configs from canonical and legacy TTS config shapes. */
function collectTelephonyProviderConfigs(ttsConfig) {
if (!ttsConfig) return {};
const entries = {};
const rawProviders = ttsConfig.providers && typeof ttsConfig.providers === "object" && !Array.isArray(ttsConfig.providers) ? ttsConfig.providers : {};
for (const [providerId, value] of Object.entries(rawProviders)) {
const normalized = normalizeProviderId(providerId) ?? providerId;
entries[normalized] = asProviderConfig(value);
}
const reservedKeys = new Set([
"auto",
"enabled",
"maxTextLength",
"mode",
"modelOverrides",
"persona",
"personas",
"prefsPath",
"provider",
"providers",
"summaryModel",
"timeoutMs"
]);
for (const [key, value] of Object.entries(ttsConfig)) {
if (reservedKeys.has(key) || typeof value !== "object" || value === null || Array.isArray(value)) continue;
const normalized = normalizeProviderId(key) ?? key;
entries[normalized] ??= asProviderConfig(value);
}
return entries;
}
//#endregion
//#region extensions/voice-call/src/bounded-child-output.ts
const DEFAULT_MAX_OUTPUT_CHARS = 16384;
/** Create an empty bounded output buffer. */
function emptyBoundedChildOutput() {
return {
text: "",
truncated: false
};
}
/** Append output while retaining the newest maxChars and recording truncation. */
function appendBoundedChildOutput(current, chunk, maxChars = DEFAULT_MAX_OUTPUT_CHARS) {
const appended = current.text + chunk;
if (appended.length <= maxChars) return {
text: appended,
truncated: current.truncated
};
return {
text: appended.slice(-maxChars),
truncated: true
};
}
/** Format captured output with a truncation marker when older text was dropped. */
function formatBoundedChildOutput(output) {
return output.truncated ? `[output truncated]\n${output.text}` : output.text;
}
//#endregion
//#region extensions/voice-call/src/webhook/tailscale.ts
const TAILSCALE_COMMAND_STDOUT_MAX_BYTES = 4 * 1024 * 1024;
function appendTailscaleCommandStdout(current, data, maxBytes = TAILSCALE_COMMAND_STDOUT_MAX_BYTES) {
if (current.exceeded) return current;
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
const bytes = current.bytes + buffer.byteLength;
if (bytes > maxBytes) return {
bytes,
exceeded: true,
text: ""
};
return {
bytes,
exceeded: false,
text: `${current.text}${buffer.toString("utf8")}`
};
}
function runTailscaleCommand(args, timeoutMs = 2500) {
return new Promise((resolve) => {
const proc = spawn("tailscale", args, { stdio: [
"ignore",
"pipe",
"ignore"
] });
let stdout = {
bytes: 0,
exceeded: false,
text: ""
};
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
};
proc.stdout.on("data", (data) => {
stdout = appendTailscaleCommandStdout(stdout, data);
if (stdout.exceeded) {
proc.kill("SIGKILL");
finish({
code: -1,
stdout: ""
});
}
});
const timer = setTimeout(() => {
proc.kill("SIGKILL");
finish({
code: -1,
stdout: ""
});
}, timeoutMs);
proc.on("error", () => {
finish({
code: -1,
stdout: ""
});
});
proc.on("close", (code) => {
finish({
code: code ?? -1,
stdout: stdout.text
});
});
});
}
async function getTailscaleSelfInfo() {
const { code, stdout } = await runTailscaleCommand([
"status",
"--json",
"--peers=false"
]);
if (code !== 0) return null;
try {
const status = JSON.parse(stdout);
return {
dnsName: status.Self?.DNSName?.replace(/\.$/, "") || null,
nodeId: status.Self?.ID || null
};
} catch {
return null;
}
}
async function getTailscaleDnsName() {
return (await getTailscaleSelfInfo())?.dnsName ?? null;
}
async function setupTailscaleExposureRoute(opts) {
const dnsName = await getTailscaleDnsName();
if (!dnsName) {
console.warn("[voice-call] Could not get Tailscale DNS name");
return null;
}
const { code } = await runTailscaleCommand([
opts.mode,
"--bg",
"--yes",
"--set-path",
opts.path,
opts.localUrl
]);
if (code === 0) {
const publicUrl = `https://${dnsName}${opts.path}`;
console.log(`[voice-call] Tailscale ${opts.mode} active: ${publicUrl}`);
return publicUrl;
}
console.warn(`[voice-call] Tailscale ${opts.mode} failed`);
return null;
}
async function cleanupTailscaleExposureRoute(opts) {
await runTailscaleCommand([
opts.mode,
"off",
opts.path
]);
}
async function setupTailscaleExposure(config) {
if (config.tailscale.mode === "off") return null;
const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
const localUrl = `http://127.0.0.1:${config.serve.port}${config.serve.path}`;
return setupTailscaleExposureRoute({
mode,
path: config.tailscale.path,
localUrl
});
}
async function cleanupTailscaleExposure(config) {
if (config.tailscale.mode === "off") return;
await cleanupTailscaleExposureRoute({
mode: config.tailscale.mode === "funnel" ? "funnel" : "serve",
path: config.tailscale.path
});
}
//#endregion
//#region extensions/voice-call/src/tunnel.ts
const NGROK_LOG_BUFFER_MAX_CHARS = 16384;
/**
* Start an ngrok tunnel to expose the local webhook server.
*
* Uses the ngrok CLI which must be installed: https://ngrok.com/download
*
* @example
* const tunnel = await startNgrokTunnel({ port: 3334, path: '/voice/webhook' });
* console.log('Public URL:', tunnel.publicUrl);
* // Later: await tunnel.stop();
*/
async function startNgrokTunnel(config) {
if (config.authToken) await runNgrokCommand([
"config",
"add-authtoken",
config.authToken
]);
const args = [
"http",
String(config.port),
"--log",
"stdout",
"--log-format",
"json"
];
if (config.domain) args.push("--domain", config.domain);
return new Promise((resolve, reject) => {
const proc = spawn("ngrok", args, { stdio: [
"ignore",
"pipe",
"pipe"
] });
let resolved = false;
let publicUrl = null;
let outputBuffer = "";
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
proc.kill("SIGTERM");
reject(/* @__PURE__ */ new Error("ngrok startup timed out (30s)"));
}
}, 3e4);
const processLine = (line) => {
try {
const log = JSON.parse(line);
if (log.msg === "started tunnel" && log.url) publicUrl = log.url;
if (log.addr && log.url && !publicUrl) publicUrl = log.url;
if (publicUrl && !resolved) {
resolved = true;
clearTimeout(timeout);
const fullUrl = publicUrl + config.path;
console.log(`[voice-call] ngrok tunnel active: ${fullUrl}`);
resolve({
publicUrl: fullUrl,
provider: "ngrok",
stop: async () => {
proc.kill("SIGTERM");
await new Promise((res) => {
proc.on("close", () => res());
setTimeout(res, 2e3);
});
}
});
}
} catch {}
};
proc.stdout.on("data", (data) => {
const lines = (outputBuffer + data.toString()).split("\n");
outputBuffer = lines.pop() || "";
if (outputBuffer.length > NGROK_LOG_BUFFER_MAX_CHARS) outputBuffer = outputBuffer.slice(-16384);
for (const line of lines) if (line.trim()) processLine(line);
});
proc.stderr.on("data", (data) => {
const msg = data.toString();
if (msg.includes("ERR_NGROK")) {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
const output = appendBoundedChildOutput(emptyBoundedChildOutput(), msg);
reject(/* @__PURE__ */ new Error(`ngrok error: ${formatBoundedChildOutput(output)}`));
}
}
});
proc.on("error", (err) => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(/* @__PURE__ */ new Error(`Failed to start ngrok: ${err.message}`));
}
});
proc.on("close", (code) => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(/* @__PURE__ */ new Error(`ngrok exited unexpectedly with code ${code}`));
}
});
});
}
/**
* Run an ngrok command and wait for completion.
*/
async function runNgrokCommand(args) {
return new Promise((resolve, reject) => {
const proc = spawn("ngrok", args, { stdio: [
"ignore",
"pipe",
"pipe"
] });
let stdout = emptyBoundedChildOutput();
let stderr = emptyBoundedChildOutput();
proc.stdout.on("data", (data) => {
stdout = appendBoundedChildOutput(stdout, data.toString());
});
proc.stderr.on("data", (data) => {
stderr = appendBoundedChildOutput(stderr, data.toString());
});
proc.on("close", (code) => {
if (code === 0) resolve(stdout.text);
else {
const output = stderr.text ? stderr : stdout;
reject(/* @__PURE__ */ new Error(`ngrok command failed: ${formatBoundedChildOutput(output)}`));
}
});
proc.on("error", reject);
});
}
/**
* Start a Tailscale serve/funnel tunnel.
*/
async function startTailscaleTunnel(config) {
const dnsName = await getTailscaleDnsName();
if (!dnsName) throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
const localUrl = `http://127.0.0.1:${config.port}${path}`;
return new Promise((resolve, reject) => {
const proc = spawn("tailscale", [
config.mode,
"--bg",
"--yes",
"--set-path",
path,
localUrl
], { stdio: [
"ignore",
"pipe",
"pipe"
] });
let stdout = emptyBoundedChildOutput();
let stderr = emptyBoundedChildOutput();
const timeout = setTimeout(() => {
proc.kill("SIGKILL");
reject(/* @__PURE__ */ new Error(`Tailscale ${config.mode} timed out`));
}, 1e4);
proc.stdout.on("data", (data) => {
stdout = appendBoundedChildOutput(stdout, data.toString());
});
proc.stderr.on("data", (data) => {
stderr = appendBoundedChildOutput(stderr, data.toString());
});
proc.on("close", (code) => {
clearTimeout(timeout);
if (code === 0) {
const publicUrl = `https://${dnsName}${path}`;
console.log(`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`);
resolve({
publicUrl,
provider: `tailscale-${config.mode}`,
stop: async () => {
await stopTailscaleTunnel(config.mode, path);
}
});
} else {
const output = stderr.text ? stderr : stdout;
const detail = output.text ? `: ${formatBoundedChildOutput(output)}` : "";
reject(/* @__PURE__ */ new Error(`Tailscale ${config.mode} failed with code ${code}${detail}`));
}
});
proc.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
});
}
/**
* Stop a Tailscale serve/funnel tunnel.
*/
async function stopTailscaleTunnel(mode, path) {
return new Promise((resolve) => {
const proc = spawn("tailscale", [
mode,
"off",
path
], { stdio: "ignore" });
const timeout = setTimeout(() => {
proc.kill("SIGKILL");
resolve();
}, 5e3);
proc.on("close", () => {
clearTimeout(timeout);
resolve();
});
});
}
/**
* Start a tunnel based on configuration.
*/
async function startTunnel(config) {
switch (config.provider) {
case "ngrok": return startNgrokTunnel({
port: config.port,
path: config.path,
authToken: config.ngrokAuthToken,
domain: config.ngrokDomain
});
case "tailscale-serve": return startTailscaleTunnel({
mode: "serve",
port: config.port,
path: config.path
});
case "tailscale-funnel": return startTailscaleTunnel({
mode: "funnel",
port: config.port,
path: config.path
});
default: return null;
}
}
//#endregion
//#region extensions/voice-call/src/webhook-exposure.ts
/** Return true when a provider requires a public webhook URL or tunnel. */
function providerRequiresPublicWebhook(providerName) {
return providerName === "twilio" || providerName === "telnyx" || providerName === "plivo";
}
/** Return true for localhost, private, or otherwise provider-unreachable hosts. */
function isLocalOnlyWebhookHost(hostname) {
return isBlockedHostnameOrIp(hostname);
}
/** Return true when a webhook URL parses to a local/private host. */
function isProviderUnreachableWebhookUrl(webhookUrl) {
try {
return isLocalOnlyWebhookHost(new URL(webhookUrl).hostname);
} catch {
return false;
}
}
/** Resolve a human-readable webhook exposure status for doctor/setup surfaces. */
function resolveWebhookExposureStatus(config) {
if (config.provider === "mock") return {
ok: true,
configured: true,
message: "Mock provider does not need a public webhook"
};
if (config.publicUrl) {
if (isProviderUnreachableWebhookUrl(config.publicUrl)) return {
ok: false,
configured: true,
message: `Public webhook URL is local/private and cannot be reached by ${config.provider ?? "the provider"}: ${config.publicUrl}`
};
return {
ok: true,
configured: true,
message: `Public webhook URL configured: ${config.publicUrl}`
};
}
if (config.tunnel?.provider && config.tunnel.provider !== "none") return {
ok: true,
configured: true,
message: "Webhook exposure configured through tunnel"
};
if (config.tailscale?.mode && config.tailscale.mode !== "off") return {
ok: true,
configured: true,
message: "Webhook exposure configured through Tailscale"
};
return {
ok: false,
configured: false,
message: "Set publicUrl or configure tunnel/tailscale so the provider can reach webhooks"
};
}
//#endregion
//#region extensions/voice-call/src/media-stream.ts
const DEFAULT_PRE_START_TIMEOUT_MS = 5e3;
const DEFAULT_MAX_PENDING_CONNECTIONS = 32;
const DEFAULT_MAX_PENDING_CONNECTIONS_PER_IP = 4;
const DEFAULT_MAX_CONNECTIONS = 128;
const MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
const MAX_WS_BUFFERED_BYTES = 1024 * 1024;
const CLOSE_REASON_LOG_MAX_CHARS = 120;
function sanitizeLogText(value, maxChars) {
const sanitized = value.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
if (sanitized.length <= maxChars) return sanitized;
return `${sanitized.slice(0, maxChars)}...`;
}
function normalizeWsMessageData(data) {
if (Buffer.isBuffer(data)) return data;
if (Array.isArray(data)) return Buffer.concat(data);
return Buffer.from(data);
}
function parseTwilioMediaMessage(data) {
const raw = normalizeWsMessageData(data);
try {
return JSON.parse(raw.toString("utf8"));
} catch (cause) {
throw new Error("Twilio media stream message was malformed JSON", { cause });
}
}
/**
* Manages WebSocket connections for Twilio media streams.
*/
var MediaStreamHandler = class {
constructor(config) {
this.wss = null;
this.sessions = /* @__PURE__ */ new Map();
this.pendingConnections = /* @__PURE__ */ new Map();
this.pendingByIp = /* @__PURE__ */ new Map();
this.inflightUpgrades = 0;
this.ttsQueues = /* @__PURE__ */ new Map();
this.ttsPlaying = /* @__PURE__ */ new Map();
this.ttsActiveControllers = /* @__PURE__ */ new Map();
this.config = config;
this.preStartTimeoutMs = resolveTimerTimeoutMs(config.preStartTimeoutMs, DEFAULT_PRE_START_TIMEOUT_MS);
this.maxPendingConnections = config.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS;
this.maxPendingConnectionsPerIp = config.maxPendingConnectionsPerIp ?? DEFAULT_MAX_PENDING_CONNECTIONS_PER_IP;
this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
}
/**
* Handle WebSocket upgrade for media stream connections.
*/
handleUpgrade(request, socket, head) {
if (!this.wss) {
this.wss = new WebSocketServer({
noServer: true,
maxPayload: MAX_INBOUND_MESSAGE_BYTES
});
this.wss.on("connection", (ws, req) => {
this.handleConnection(ws, req);
});
}
if (this.getCurrentConnectionCount() >= this.maxConnections) {
this.rejectUpgrade(socket, 503, "Too many media stream connections");
return;
}
this.inflightUpgrades += 1;
let released = false;
const releaseUpgradeReservation = () => {
if (released) return;
released = true;
this.inflightUpgrades = Math.max(0, this.inflightUpgrades - 1);
};
const handleUpgradeAbort = () => {
socket.removeListener("error", handleUpgradeAbort);
socket.removeListener("close", handleUpgradeAbort);
releaseUpgradeReservation();
};
socket.once("error", handleUpgradeAbort);
socket.once("close", handleUpgradeAbort);
try {
this.wss.handleUpgrade(request, socket, head, (ws) => {
socket.removeListener("error", handleUpgradeAbort);
socket.removeListener("close", handleUpgradeAbort);
releaseUpgradeReservation();
this.wss?.emit("connection", ws, request);
});
} catch (error) {
socket.removeListener("error", handleUpgradeAbort);
socket.removeListener("close", handleUpgradeAbort);
releaseUpgradeReservation();
throw error;
}
}
/**
* Handle new WebSocket connection from Twilio.
*/
async handleConnection(ws, _request) {
let session = null;
const streamToken = this.getStreamToken(_request);
const ip = this.getClientIp(_request);
if (!this.registerPendingConnection(ws, ip)) {
ws.close(1013, "Too many pending media stream connections");
return;
}
ws.on("message", (data) => {
try {
const message = parseTwilioMediaMessage(data);
switch (message.event) {
case "connected":
console.log("[MediaStream] Twilio connected");
break;
case "start":
session = this.handleStart(ws, message, streamToken);
if (session) this.clearPendingConnection(ws);
break;
case "media":
if (session && message.media?.payload) {
const audioBuffer = Buffer.from(message.media.payload, "base64");
const turnId = this.ensureActiveTurn(session);
this.emitTalkEvent(session, {
type: "input.audio.delta",
turnId,
payload: {
callId: session.callId,
streamSid: session.streamSid,
bytes: audioBuffer.byteLength
}
});
session.sttSession.sendAudio(audioBuffer);
}
break;
case "stop":
if (session) {
this.handleStop(session);
session = null;
}
break;
case "clear":
case "mark": break;
}
} catch (error) {
console.error("[MediaStream] Error processing message:", error);
}
});
ws.on("close", (code, reason) => {
const reasonText = sanitizeLogText(Buffer.isBuffer(reason) ? reason.toString("utf8") : String(reason || ""), CLOSE_REASON_LOG_MAX_CHARS);
console.log(`[MediaStream] WebSocket closed (code: ${code}, reason: ${reasonText || "none"})`);
this.clearPendingConnection(ws);
if (session) this.handleStop(session);
});
ws.on("error", (error) => {
console.error("[MediaStream] WebSocket error:", error);
});
}
/**
* Handle stream start event.
*/
handleStart(ws, message, streamToken) {
const streamSid = message.streamSid || "";
const callSid = message.start?.callSid || "";
const effectiveToken = message.start?.customParameters?.token ?? streamToken;
console.log(`[MediaStream] Stream started: ${streamSid} (call: ${callSid})`);
if (!callSid) {
console.warn("[MediaStream] Missing callSid; closing stream");
ws.close(1008, "Missing callSid");
return null;
}
if (this.config.shouldAcceptStream && !this.config.shouldAcceptStream({
callId: callSid,
streamSid,
token: effectiveToken
})) {
console.warn(`[MediaStream] Rejecting stream for unknown call: ${callSid}`);
ws.close(1008, "Unknown call");
return null;
}
const session = {
callId: callSid,
streamSid,
ws,
sttSession: this.config.transcriptionProvider.createSession({
cfg: this.config.cfg,
providerConfig: this.config.providerConfig,
onPartial: (partial) => {
const session = this.sessions.get(streamSid);
if (session) this.emitTalkEvent(session, {
type: "transcript.delta",
turnId: this.ensureActiveTurn(session),
payload: {
callId: callSid,
streamSid,
text: partial,
role: "user"
}
});
this.config.onPartialTranscript?.(callSid, partial);
},
onTranscript: (transcript) => {
const session = this.sessions.get(streamSid);
if (session) {
const turnId = this.ensureActiveTurn(session);
this.emitTalkEvent(session, {
type: "input.audio.committed",
turnId,
final: true,
payload: {
callId: callSid,
streamSid
}
});
this.emitTalkEvent(session, {
type: "transcript.done",
turnId,
final: true,
payload: {
callId: callSid,
streamSid,
text: transcript,
role: "user"
}
});
}
this.config.onTranscript?.(callSid, transcript);
},
onSpeechStart: () => {
const session = this.sessions.get(streamSid);
if (session) this.ensureActiveTurn(session);
this.config.onSpeechStart?.(callSid);
},
onError: (error) => {
console.warn("[MediaStream] Transcription session error:", error.message);
const session = this.sessions.get(streamSid);
if (session) this.emitTalkEvent(session, {
type: "session.error",
final: true,
payload: {
callId: callSid,
streamSid,
error: error.message
}
});
}
}),
talk: this.createTalkEvents(callSid, streamSid)
};
this.sessions.set(streamSid, session);
this.config.onConnect?.(callSid, streamSid);
this.emitTalkEvent(session, {
type: "session.started",
payload: {
callId: callSid,
streamSid,
provider: this.config.transcriptionProvider.id
}
});
this.connectTranscriptionAndNotify(session);
return session;
}
async connectTranscriptionAndNotify(session) {
try {
await session.sttSession.connect();
} catch (error) {
console.warn("[MediaStream] STT connection failed; closing media stream:", error instanceof Error ? error.message : String(error));
this.emitTalkEvent(session, {
type: "session.error",
final: true,
payload: {
callId: session.callId,
streamSid: session.streamSid,
error: error instanceof Error ? error.message : String(error)
}
});
if (this.sessions.get(session.streamSid) === session && session.ws.readyState === WebSocket$1.OPEN) session.ws.close(1011, "STT connection failed");
else session.sttSession.close();
return;
}
if (this.sessions.get(session.streamSid) !== session || session.ws.readyState !== WebSocket$1.OPEN) {
session.sttSession.close();
return;
}
this.emitTalkEvent(session, {
type: "session.ready",
payload: {
callId: session.callId,
streamSid: session.streamSid
}
});
this.config.onTranscriptionReady?.(session.callId, session.streamSid);
}
/**
* Handle stream stop event.
*/
handleStop(session) {
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
this.clearTtsState(session.streamSid);
session.sttSession.close();
this.sessions.delete(session.streamSid);
this.emitTalkEvent(session, {
type: "session.closed",
final: true,
payload: {
callId: session.callId,
streamSid: session.streamSid
}
});
this.config.onDisconnect?.(session.callId, session.streamSid);
}
getStreamToken(request) {
if (!request.url || !request.headers.host) return;
try {
return new URL(request.url, `http://${request.headers.host}`).searchParams.get("token") ?? void 0;
} catch {
return;
}
}
getClientIp(request) {
const resolvedIp = this.config.resolveClientIp?.(request)?.trim();
if (resolvedIp) return resolvedIp;
return request.socket.remoteAddress || "unknown";
}
getCurrentConnectionCount() {
return this.wss ? this.wss.clients.size + this.inflightUpgrades : this.inflightUpgrades;
}
registerPendingConnection(ws, ip) {
if (this.pendingConnections.size >= this.maxPendingConnections) {
console.warn("[MediaStream] Rejecting connection: pending connection limit reached");
return false;
}
const pendingForIp = this.pendingByIp.get(ip) ?? 0;
if (pendingForIp >= this.maxPendingConnectionsPerIp) {
console.warn(`[MediaStream] Rejecting connection: pending per-IP limit reached (${ip})`);
return false;
}
const timeout = setTimeout(() => {
if (!this.pendingConnections.has(ws)) return;
console.warn(`[MediaStream] Closing pre-start idle connection after ${this.preStartTimeoutMs}ms (${ip})`);
ws.close(1008, "Start timeout");
}, this.preStartTimeoutMs);
timeout.unref?.();
this.pendingConnections.set(ws, {
ip,
timeout
});
this.pendingByIp.set(ip, pendingForIp + 1);
return true;
}
clearPendingConnection(ws) {
const pending = this.pendingConnections.get(ws);
if (!pending) return;
clearTimeout(pending.timeout);
this.pendingConnections.delete(ws);
const current = this.pendingByIp.get(pending.ip) ?? 0;
if (current <= 1) {
this.pendingByIp.delete(pending.ip);
return;
}
this.pendingByIp.set(pending.ip, current - 1);
}
rejectUpgrade(socket, statusCode, message) {
const statusText = statusCode === 429 ? "Too Many Requests" : "Service Unavailable";
const body = `${message}\n`;
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r
Content-Type: text/plain; charset=utf-8\r
Content-Length: ${Buffer.byteLength(body)}\r\n\r
` + body);
socket.destroy();
}
/**
* Get an active session with an open WebSocket, or undefined if unavailable.
*/
getOpenSession(streamSid) {
const session = this.sessions.get(streamSid);
return session?.ws.readyState === WebSocket$1.OPEN ? session : void 0;
}
/**
* Send a message to a stream's WebSocket if available.
*/
sendToStream(streamSid, message) {
const session = this.sessions.get(streamSid);
if (!session) return {
sent: false,
bufferedBeforeBytes: 0,
bufferedAfterBytes: 0
};
const readyState = session.ws.readyState;
const bufferedBeforeBytes = session.ws.bufferedAmount;
if (readyState !== WebSocket$1.OPEN) return {
sent: false,
readyState,
bufferedBeforeBytes,
bufferedAfterBytes: session.ws.bufferedAmount
};
if (bufferedBeforeBytes > MAX_WS_BUFFERED_BYTES) {
try {
session.ws.close(1013, "Backpressure: send buffer exceeded");
} catch {}
return {
sent: false,
readyState,
bufferedBeforeBytes,
bufferedAfterBytes: session.ws.bufferedAmount
};
}
try {
session.ws.send(JSON.stringify(message));
const bufferedAfterBytes = session.ws.bufferedAmount;
if (bufferedAfterBytes > MAX_WS_BUFFERED_BYTES) {
try {
session.ws.close(1013, "Backpressure: send buffer exceeded");
} catch {}
return {
sent: false,
readyState,
bufferedBeforeBytes,
bufferedAfterBytes
};
}
return {
sent: true,
readyState,
bufferedBeforeBytes,
bufferedAfterBytes
};
} catch {
return {
sent: false,
readyState,
bufferedBeforeBytes,
bufferedAfterBytes: session.ws.bufferedAmount
};
}
}
/**
* Send audio to a specific stream (for TTS playback).
* Audio should be mu-law encoded at 8kHz mono.
*/
sendAudio(streamSid, muLawAudio) {
const session = this.getOpenSession(streamSid);
if (session) this.emitTalkEvent(session, {
type: "output.audio.delta",
turnId: this.ensureActiveTurn(session),
payload: {
callId: session.callId,
streamSid,
bytes: muLawAudio.byteLength
}
});
return this.sendToStream(streamSid, {
event: "media",
streamSid,
media: { payload: muLawAudio.toString("base64") }
});
}
/**
* Send a mark event to track audio playback position.
*/
sendMark(streamSid, name) {
return this.sendToStream(streamSid, {
event: "mark",
streamSid,
mark: { name }
});
}
/**
* Clear audio buffer (interrupt playback).
*/
clearAudio(streamSid) {
return this.sendToStream(streamSid, {
event: "clear",
streamSid
});
}
/**
* Queue a TTS operation for sequential playback.
* Only one TTS operation plays at a time per stream to prevent overlap.
*/
async queueTts(streamSid, playFn) {
const queue = this.getTtsQueue(streamSid);
let resolveEntry;
let rejectEntry;
const promise = new Promise((resolve, reject) => {
resolveEntry = resolve;
rejectEntry = reject;
});
queue.push({
playFn,
controller: new AbortController(),
resolve: resolveEntry,
reject: rejectEntry
});
if (!this.ttsPlaying.get(streamSid)) this.processQueue(streamSid);
return promise;
}
/**
* Clear TTS queue and interrupt current playback (barge-in).
*/
clearTtsQueue(streamSid, _reason = "unspecified") {
const queue = this.getTtsQueue(streamSid);
this.resolveQueuedTtsEntries(queue);
this.ttsActiveControllers.get(streamSid)?.abort();
const session = this.sessions.get(streamSid);
if (session?.talk.activeTurnId) {
const cancelled = session.talk.cancelTurn({ payload: {
callId: session.callId,
streamSid,
reason: _reason
} });
if (cancelled.ok) this.config.onTalkEvent?.(session.callId, session.streamSid, cancelled.event);
}
this.clearAudio(streamSid);
}
/**
* Get active session by call ID.
*/
getSessionByCallId(callId) {
return [...this.sessions.values()].find((session) => session.callId === callId);
}
/**
* Close all sessions.
*/
closeAll() {
for (const session of this.sessions.values()) {
this.clearTtsState(session.streamSid);
session.sttSession.close();
session.ws.close();
}
this.sessions.clear();
}
getTtsQueue(streamSid) {
const existing = this.ttsQueues.get(streamSid);
if (existing) return existing;
const queue = [];
this.ttsQueues.set(streamSid, queue);
return queue;
}
/**
* Process the TTS queue for a stream.
* Uses iterative approach to avoid stack accumulation from recursion.
*/
async processQueue(streamSid) {
this.ttsPlaying.set(streamSid, true);
while (true) {
const queue = this.ttsQueues.get(streamSid);
if (!queue || queue.length === 0) {
this.ttsPlaying.set(streamSid, false);
this.ttsActiveControllers.delete(streamSid);
return;
}
const entry = queue.shift();
this.ttsActiveControllers.set(streamSid, entry.controller);
const session = this.sessions.get(streamSid);
let playbackTurnId;
try {
if (session) {
playbackTurnId = this.ensureActiveTurn(session);
this.emitTalkEvent(session, {
type: "output.audio.started",
turnId: playbackTurnId,
payload: {
callId: session.callId,
streamSid
}
});
}
await entry.playFn(entry.controller.signal);
if (entry.controller.signal.aborted) {
entry.resolve();
continue;
}
if (session) {
const turnId = playbackTurnId ?? this.ensureActiveTurn(session);
this.emitTalkEvent(session, {
type: "output.audio.done",
turnId,
final: true,
payload: {
callId: session.callId,
streamSid
}
});
if (session.talk.activeTurnId) {
const ended = session.talk.endTurn({ payload: {
callId: session.callId,
streamSid
} });
if (ended.ok) this.config.onTalkEvent?.(session.callId, session.streamSid, ended.event);
}
}
entry.resolve();
} catch (error) {
if (entry.controller.signal.aborted) entry.resolve();
else {
console.error("[MediaStream] TTS playback error:", error);
entry.reject(error);
}
} finally {
if (this.ttsActiveControllers.get(streamSid) === entry.controller) this.ttsActiveControllers.delete(streamSid);
}
}
}
createTalkEvents(callId, streamSid) {
return createTalkSessionController({
sessionId: `voice-call:${callId}:${streamSid}`,
mode: "stt-tts",
transport: "gateway-relay",
brain: "agent-consult",
provider: this.config.transcriptionProvider.id,
turnIdPrefix: `${streamSid}:turn`
}, { onEvent: recordTalkObservabilityEvent });
}
emitTalkEvent(session, input) {
const event = session.talk.emit(input);
this.config.onTalkEvent?.(session.callId, session.streamSid, event);
}
ensureActiveTurn(session) {
const turn = session.talk.ensureTurn({ payload: {
callId: session.callId,
streamSid: session.streamSid
} });
if (turn.event) this.config.onTalkEvent?.(session.callId, session.streamSid, turn.event);
return turn.turnId;
}
clearTtsState(streamSid) {
const queue = this.ttsQueues.get(streamSid);
if (queue) this.resolveQueuedTtsEntries(queue);
this.ttsActiveControllers.get(streamSid)?.abort();
this.ttsActiveControllers.delete(streamSid);
this.ttsPlaying.delete(streamSid);
this.ttsQueues.delete(streamSid);
}
resolveQueuedTtsEntries(queue) {
const pending = queue.splice(0);
for (const entry of pending) {
entry.controller.abort();
entry.resolve();
}
}
};
//#endregion
//#region extensions/voice-call/src/webhook/stale-call-reaper.ts
const CHECK_INTERVAL_MS = 3e4;
/** Start a stale-call reaper and return its cleanup callback. */
function startStaleCallReaper(params) {
const maxAgeSeconds = params.staleCallReaperSeconds;
if (!maxAgeSeconds || maxAgeSeconds <= 0) return null;
const maxAgeMs = maxAgeSeconds * 1e3;
const interval = setInterval(() => {
const now = Date.now();
for (const call of params.manager.getActiveCalls()) {
if (call.answeredAt || TerminalStates.has(call.state)) continue;
const age = now - call.startedAt;
if (age > maxAgeMs) {
console.log(`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1e3)}s, state: ${call.state})`);
params.manager.endCall(call.callId).catch((err) => {
console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
});
}
}
}, CHECK_INTERVAL_MS);
return () => {
clearInterval(interval);
};
}
//#endregion
//#region extensions/voice-call/src/webhook.ts
const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2e3;
const TRANSCRIPT_LOG_MAX_CHARS = 200;
let realtimeTranscriptionRuntimePromise;
let responseGeneratorModulePromise;
function loadRealtimeTranscriptionRuntime() {
realtimeTranscriptionRuntimePromise ??= import("./realtime-transcription.runtime.js");
return realtimeTranscriptionRuntimePromise;
}
function loadResponseGeneratorModule() {
responseGeneratorModulePromise ??= import("./response-generator-BfhkwxyG.js");
return responseGeneratorModulePromise;
}
function sanitizeTranscriptForLog(value) {
const sanitized = value.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
if (sanitized.length <= TRANSCRIPT_LOG_MAX_CHARS) return sanitized;
return `${sanitized.slice(0, TRANSCRIPT_LOG_MAX_CHARS)}...`;
}
function appendRecentTalkEventMetadata(call, event) {
const metadata = call.metadata ?? {};
const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
recent.push({
at: event.timestamp,
type: event.type,
sessionId: event.sessionId,
turnId: event.turnId
});
call.metadata = {
...metadata,
lastTalkEventAt: event.timestamp,
lastTalkEventType: event.type,
recentTalkEvents: recent.slice(-10)
};
}
function buildRequestUrl(requestUrl) {
return new URL$1(requestUrl ?? "/", "http://localhost");
}
function normalizeProxyIp(value) {
const trimmed = value?.trim();
if (!trimmed) return;
const normalized = (trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed).toLowerCase();
if (normalized.startsWith("::ffff:")) {
const mappedIpv4 = normalized.slice(7);
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(mappedIpv4)) return mappedIpv4;
}
return normalized;
}
function resolveForwardedClientIp(request, trustedProxyIPs) {
const normalizedTrustedProxyIps = new Set(trustedProxyIPs.map((ip) => normalizeProxyIp(ip)).filter((ip) => Boolean(ip)));
const forwardedFor = getHeader(request.headers, "x-forwarded-for");
if (forwardedFor) {
const forwardedIps = normalizeStringEntries(forwardedFor.split(","));
if (forwardedIps.length > 0) {
if (normalizedTrustedProxyIps.size === 0) return forwardedIps[0];
for (let index = forwardedIps.length - 1; index >= 0; index -= 1) {
const hop = forwardedIps[index];
if (!normalizedTrustedProxyIps.has(normalizeProxyIp(hop) ?? "")) return hop;
}
return forwardedIps[0];
}
}
return getHeader(request.headers, "x-real-ip")?.trim() || void 0;
}
function normalizeWebhookResponse(parsed) {
return {
statusCode: parsed.statusCode ?? 200,
headers: parsed.providerResponseHeaders,
body: parsed.providerResponseBody ?? "OK"
};
}
function buildRealtimeRejectedTwiML() {
return {
statusCode: 200,
headers: { "Content-Type": "text/xml" },
body: "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response><Reject reason=\"rejected\" /></Response>"
};
}
function buildTwilioReplayTwiML() {
return {
statusCode: 200,
headers: { "Content-Type": "text/xml" },
body: "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response></Response>"
};
}
const WEBHOOK_REPLAY_RESPONSE_TTL_MS = 600 * 1e3;
const WEBHOOK_REPLAY_RESPONSE_MAX_ENTRIES = 1e4;
const WEBHOOK_REPLAY_RESPONSE_PRUNE_INTERVAL = 64;
function cloneWebhookResponsePayload(payload) {
return {
statusCode: payload.statusCode,
headers: payload.headers ? { ...payload.headers } : void 0,
body: payload.body
};
}
/**
* HTTP server for receiving voice call webhooks from providers.
* Supports WebSocket upgrades for media streams when streaming is enabled.
*/
var VoiceCallWebhookServer = class {
constructor(config, manager, provider, coreConfig, fullConfig, agentRuntime, logger) {
this.server = null;
this.listeningUrl = null;
this.startPromise = null;
this.stopStaleCallReaper = null;
this.webhookInFlightLimiter = createWebhookInFlightLimiter();
this.mediaStreamHandler = null;
this.pendingDisconnectHangups = /* @__PURE__ */ new Map();
this.realtimeHandler = null;
this.replayResponses = /* @__PURE__ */ new Map();
this.replayResponseCacheCalls = 0;
this.config = normalizeVoiceCallConfig(config);
this.manager = manager;
this.provider = provider;
this.coreConfig = coreConfig ?? null;
this.fullConfig = fullConfig ?? null;
this.agentRuntime = agentRuntime ?? null;
this.logger = logger ?? {
info: console.log,
warn: console.warn,
error: console.error,
debug: console.debug
};
}
/**
* Get the media stream handler (for wiring to provider).
*/
getMediaStreamHandler() {
return this.mediaStreamHandler;
}
getRealtimeHandler() {
return this.realtimeHandler;
}
speakRealtime(callId, instructions) {
if (!this.realtimeHandler) return {
success: false,
error: "Realtime voice handler is not configured"
};
return this.realtimeHandler.speak(callId, instructions);
}
setRealtimeHandler(handler) {
this.realtimeHandler = handler;
}
clearPendingDisconnectHangup(providerCallId) {
const existing = this.pendingDisconnectHangups.get(providerCallId);
if (!existing) return;
clearTimeout(existing);
this.pendingDisconnectHangups.delete(providerCallId);
}
resolveMediaStreamClientIp(request) {
const remoteIp = request.socket.remoteAddress ?? void 0;
const trustedProxyIPs = this.config.webhookSecurity.trustedProxyIPs.filter(Boolean);
const normalizedTrustedProxyIps = new Set(trustedProxyIPs.map((ip) => normalizeProxyIp(ip)).filter((ip) => Boolean(ip)));
const normalizedRemoteIp = normalizeProxyIp(remoteIp);
const fromTrustedProxy = normalizedTrustedProxyIps.size > 0 && normalizedRemoteIp !== void 0 && normalizedTrustedProxyIps.has(normalizedRemoteIp);
if (this.config.webhookSecurity.trustForwardingHeaders && fromTrustedProxy) {
const forwardedIp = resolveForwardedClientIp(request, trustedProxyIPs);
if (forwardedIp) return forwardedIp;
}
return remoteIp;
}
shouldSuppressBargeInForInitialMessage(call) {
if (!call || call.direction !== "outbound") return false;
if (call.state !== "speaking") return false;
if ((call.metadata?.mode ?? "conversation") !== "conversation") return false;
return (normalizeOptionalString(call.metadata?.initialMessage) ?? "").length > 0;
}
/**
* Initialize media streaming with the selected realtime transcription provider.
*/
async initializeMediaStreaming() {
const streaming = this.config.streaming;
const pluginConfig = this.fullConfig ?? this.coreConfig;
const { getRealtimeTranscriptionProvider, listRealtimeTranscriptionProviders } = await loadRealtimeTranscriptionRuntime();
const resolution = resolveConfiguredCapabilityProvider({
configuredProviderId: streaming.provider,
providerConfigs: streaming.providers,
cfg: pluginConfig,
cfgForResolve: pluginConfig ?? {},
getConfiguredProvider: (providerId) => getRealtimeTranscriptionProvider(providerId, pluginConfig),
listProviders: () => listRealtimeTranscriptionProviders(pluginConfig),
resolveProviderConfig: ({ provider, cfg, rawConfig }) => provider.resolveConfig?.({
cfg,
rawConfig
}) ?? rawConfig,
isProviderConfigured: ({ provider, cfg, providerConfig }) => provider.isConfigured({
cfg,
providerConfig
})
});
if (!resolution.ok && resolution.code === "missing-configured-provider") {
console.warn(`[voice-call] Streaming enabled but realtime transcription provider "${resolution.configuredProviderId}" is not registered`);
return;
}
if (!resolution.ok && resolution.code === "no-registered-provider") {
console.warn("[voice-call] Streaming enabled but no realtime transcription provider is registered");
return;
}
if (!resolution.ok) {
console.warn(`[voice-call] Streaming enabled but provider "${resolution.provider?.id}" is not configured`);
return;
}
const streamConfig = {
transcriptionProvider: resolution.provider,
providerConfig: resolution.providerConfig,
cfg: this.fullConfig ?? this.coreConfig ?? void 0,
preStartTimeoutMs: streaming.preStartTimeoutMs,
maxPendingConnections: streaming.maxPendingConnections,
maxPendingConnectionsPerIp: streaming.maxPendingConnectionsPerIp,
maxConnections: streaming.maxConnections,
resolveClientIp: (request) => this.resolveMediaStreamClientIp(request),
shouldAcceptStream: ({ callId, token }) => {
if (!this.manager.getCallByProviderCallId(callId)) return false;
if (this.provider.name === "twilio") {
if (!this.provider.isValidStreamToken(callId, token)) {
console.warn(`[voice-call] Rejecting media stream: invalid token for ${callId}`);
return false;
}
}
return true;
},
onTranscript: (providerCallId, transcript) => {
const safeTranscript = sanitizeTranscriptForLog(transcript);
console.log(`[voice-call] Transcript for ${providerCallId}: ${safeTranscript} (chars=${transcript.length})`);
const call = this.manager.getCallByProviderCallId(providerCallId);
if (!call) {
console.warn(`[voice-call] No active call found for provider ID: ${providerCallId}`);
return;
}
if (this.shouldSuppressBargeInForInitialMessage(call)) {
console.log(`[voice-call] Ignoring barge transcript while initial message is still playing (${providerCallId})`);
return;
}
if (this.provider.name === "twilio") this.provider.clearTtsQueue(providerCallId);
const event = {
id: `stream-transcript-${Date.now()}`,
type: "call.speech",
callId: call.callId,
providerCallId,
timestamp: Date.now(),
transcript,
isFinal: true
};
this.manager.processEvent(event);
const callMode = call.metadata?.mode;
if (call.direction === "inbound" || callMode === "conversation") this.handleInboundResponse(call.callId, transcript).catch((err) => {
console.warn(`[voice-call] Failed to auto-respond:`, err);
});
},
onSpeechStart: (providerCallId) => {
if (this.provider.name !== "twilio") return;
const call = this.manager.getCallByProviderCallId(providerCallId);
if (this.shouldSuppressBargeInForInitialMessage(call)) return;
this.provider.clearTtsQueue(providerCallId);
},
onPartialTranscript: (callId, partial) => {
const safePartial = sanitizeTranscriptForLog(partial);
console.log(`[voice-call] Partial for ${callId}: ${safePartial} (chars=${partial.length})`);
},
onTalkEvent: (providerCallId, _streamSid, event) => {
const call = this.manager.getCallByProviderCallId(providerCallId);
if (call) appendRecentTalkEventMetadata(call, event);
},
onConnect: (callId, streamSid) => {
console.log(`[voice-call] Media stream connected: ${callId} -> ${streamSid}`);
this.clearPendingDisconnectHangup(callId);
if (this.provider.name === "twilio") this.provider.registerCallStream(callId, streamSid);
},
onTranscriptionReady: (callId) => {
this.manager.speakInitialMessage(callId).catch((err) => {
console.warn(`[voice-call] Failed to speak initial message:`, err);
});
},
onDisconnect: (callId, streamSid) => {
console.log(`[voice-call] Media stream disconnected: ${callId} (${streamSid})`);
if (this.provider.name === "twilio") this.provider.unregisterCallStream(callId, streamSid);
this.clearPendingDisconnectHangup(callId);
const timer = setTimeout(() => {
this.pendingDisconnectHangups.delete(callId);
const disconnectedCall = this.manager.getCallByProviderCallId(callId);
if (!disconnectedCall) return;
if (this.provider.name === "twilio") {
if (this.provider.hasRegisteredStream(callId)) return;
}
console.log(`[voice-call] Auto-ending call ${disconnectedCall.callId} after stream disconnect grace`);
this.manager.endCall(disconnectedCall.callId).catch((err) => {
console.warn(`[voice-call] Failed to auto-end call ${disconnectedCall.callId}:`, err);
});
}, STREAM_DISCONNECT_HANGUP_GRACE_MS);
timer.unref?.();
this.pendingDisconnectHangups.set(callId, timer);
}
};
this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
console.log("[voice-call] Media streaming initialized");
}
/**
* Start the webhook server.
* Idempotent: returns immediately if the server is already listening.
*/
async start() {
const { port, bind, path: webhookPath } = this.config.serve;
const streamPath = this.config.streaming.streamPath;
if (this.server?.listening) return this.listeningUrl ?? this.resolveListeningUrl(bind, webhookPath);
if (this.config.streaming.enabled && !this.mediaStreamHandler) await this.initializeMediaStreaming();
if (this.startPromise) return this.startPromise;
this.startPromise = new Promise((resolve, reject) => {
this.server = http.createServer((req, res) => {
this.handleRequest(req, res, webhookPath).catch((err) => {
console.error("[voice-call] Webhook error:", err);
res.statusCode = 500;
res.end("Internal Server Error");
});
});
if (this.realtimeHandler || this.mediaStreamHandler) this.server.on("upgrade", (request, socket, head) => {
if (this.realtimeHandler && this.isRealtimeWebSocketUpgrade(request)) {
this.realtimeHandler.handleWebSocketUpgrade(request, socket, head);
return;
}
if (this.getUpgradePathname(request) === streamPath && this.mediaStreamHandler) this.mediaStreamHandler?.handleUpgrade(request, socket, head);
else socket.destroy();
});
this.server.on("error", (err) => {
this.server = null;
this.listeningUrl = null;
this.startPromise = null;
reject(err);
});
this.server.listen(port, bind, () => {
const url = this.resolveListeningUrl(bind, webhookPath);
this.listeningUrl = url;
this.startPromise = null;
this.logger.info(`[voice-call] Webhook server listening on ${url}`);
if (this.mediaStreamHandler) {
const address = this.server?.address();
const actualPort = address && typeof address === "object" ? address.port : this.config.serve.port;
this.logger.info(`[voice-call] Media stream WebSocket on ws://${bind}:${actualPort}${streamPath}`);
}
resolve(url);
this.stopStaleCallReaper = startStaleCallReaper({
manager: this.manager,
staleCallReaperSeconds: this.config.staleCallReaperSeconds
});
});
});
return this.startPromise;
}
/**
* Stop the webhook server.
*/
async stop() {
for (const timer of this.pendingDisconnectHangups.values()) clearTimeout(timer);
this.pendingDisconnectHangups.clear();
this.webhookInFlightLimiter.clear();
this.startPromise = null;
if (this.stopStaleCallReaper) {
this.stopStaleCallReaper();
this.stopStaleCallReaper = null;
}
return new Promise((resolve) => {
if (this.server) this.server.close(() => {
this.server = null;
this.listeningUrl = null;
resolve();
});
else {
this.listeningUrl = null;
resolve();
}
});
}
resolveListeningUrl(bind, webhookPath) {
const address = this.server?.address();
if (address && typeof address === "object") {
const host = address.address && address.address.length > 0 ? address.address : bind;
return `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${address.port}${webhookPath}`;
}
return `http://${bind}:${this.config.serve.port}${webhookPath}`;
}
getUpgradePathname(request) {
try {
return buildRequestUrl(request.url).pathname;
} catch {
return null;
}
}
normalizeWebhookPathForMatch(pathname) {
const trimmed = pathname.trim();
if (!trimmed) return "/";
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (prefixed === "/") return prefixed;
return prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed;
}
isWebhookPathMatch(requestPath, configuredPath) {
return this.normalizeWebhookPathForMatch(requestPath) === this.normalizeWebhookPathForMatch(configuredPath);
}
/**
* Handle incoming HTTP request.
*/
async handleRequest(req, res, webhookPath) {
const payload = await this.runWebhookPipeline(req, webhookPath);
this.writeWebhookResponse(res, payload);
}
async runWebhookPipeline(req, webhookPath) {
const url = buildRequestUrl(req.url);
if (url.pathname === "/voice/hold-music") return {
statusCode: 200,
headers: { "Content-Type": "text/xml" },
body: `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="alice">All agents are currently busy. Please hold.</Say>
<Play loop="0">https://s3.amazonaws.com/com.twilio.music.classical/BusyStrings.mp3</Play>
</Response>`
};
if (!this.isWebhookPathMatch(url.pathname, webhookPath)) return {
statusCode: 404,
body: "Not Found"
};
if (req.method !== "POST") return {
statusCode: 405,
body: "Method Not Allowed"
};
const headerGate = this.verifyPreAuthWebhookHeaders(req.headers);
if (!headerGate.ok) {
console.warn(`[voice-call] Webhook rejected before body read: ${headerGate.reason}`);
return {
statusCode: 401,
body: "Unauthorized"
};
}
const remoteAddress = req.socket.remoteAddress;
if (!remoteAddress) console.warn(`[voice-call] Webhook accepted with no remote address; using shared fallback in-flight key`);
const inFlightKey = remoteAddress || MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY;
if (!this.webhookInFlightLimiter.tryAcquire(inFlightKey)) {
console.warn(`[voice-call] Webhook rejected before body read: too many in-flight requests`);
return {
statusCode: 429,
body: "Too Many Requests"
};
}
try {
let body = "";
try {
body = await this.readBody(req, MAX_WEBHOOK_BODY_BYTES, WEBHOOK_BODY_TIMEOUT_MS);
} catch (err) {
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) return {
statusCode: 413,
body: "Payload Too Large"
};
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) return {
statusCode: 408,
body: requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
};
throw err;
}
const ctx = {
headers: req.headers,
rawBody: body,
url: url.toString(),
method: "POST",
query: Object.fromEntries(url.searchParams),
remoteAddress: req.socket.remoteAddress ?? void 0
};
const verification = this.provider.verifyWebhook(ctx);
if (!verification.ok) {
console.warn(`[voice-call] Webhook verification failed: ${verification.reason}`);
return {
statusCode: 401,
body: "Unauthorized"
};
}
if (!verification.verifiedRequestKey) {
console.warn("[voice-call] Webhook verification succeeded without request identity key");
return {
statusCode: 401,
body: "Unauthorized"
};
}
const isReplay = Boolean(verification.isReplay);
if (isReplay) {
console.warn("[voice-call] Replay detected; skipping event side effects");
if (this.provider.name === "twilio") return buildTwilioReplayTwiML();
const cachedResponse = await this.getCachedReplayResponse(verification.verifiedRequestKey);
if (cachedResponse) return cachedResponse;
}
const buildResponse = async () => {
const initialTwiML = this.provider.consumeInitialTwiML?.(ctx);
if (initialTwiML !== void 0 && initialTwiML !== null) {
const params = new URLSearchParams(ctx.rawBody);
console.log(`[voice-call] Serving provider initial TwiML before realtime handling (callSid=${params.get("CallSid") ?? "unknown"}, direction=${params.get("Direction") ?? "unknown"})`);
return {
statusCode: 200,
headers: { "Content-Type": "application/xml" },
body: initialTwiML
};
}
const realtimeParams = this.getRealtimeTwimlParams(ctx);
if (realtimeParams) {
const direction = realtimeParams.get("Direction");
if ((!direction || direction === "inbound") && !this.shouldAcceptRealtimeInboundRequest(realtimeParams)) {
console.log("[voice-call] Realtime inbound call rejected before stream setup");
return buildRealtimeRejectedTwiML();
}
console.log(`[voice-call] Serving realtime TwiML for Twilio call ${realtimeParams.get("CallSid") ?? "unknown"} (direction=${direction ?? "unknown"})`);
return this.realtimeHandler.buildTwiMLPayload(req, realtimeParams);
}
const parsed = this.provider.parseWebhookEvent(ctx, { verifiedRequestKey: verification.verifiedRequestKey });
if (!isReplay) this.processParsedEvents(parsed.events);
return normalizeWebhookResponse(parsed);
};
if (isReplay) return await buildResponse();
if (this.provider.name === "twilio") return await buildResponse();
return await this.cacheReplayResponse(verification.verifiedRequestKey, buildResponse);
} finally {
this.webhookInFlightLimiter.release(inFlightKey);
}
}
pruneReplayResponses(rawNow) {
const now = asDateTimestampMs(rawNow);
if (now !== void 0) {
for (const [key, entry] of this.replayResponses) if (entry.expiresAt <= now) this.replayResponses.delete(key);
}
while (this.replayResponses.size > WEBHOOK_REPLAY_RESPONSE_MAX_ENTRIES) {
const oldest = this.replayResponses.keys().next().value;
if (!oldest) break;
this.replayResponses.delete(oldest);
}
}
async getCachedReplayResponse(key) {
const now = asDateTimestampMs(Date.now());
const entry = this.replayResponses.get(key);
if (!entry || now === void 0) return null;
if (entry.expiresAt <= now) {
this.replayResponses.delete(key);
return null;
}
return cloneWebhookResponsePayload(await entry.response);
}
async cacheReplayResponse(key, buildResponse) {
const now = Date.now();
const expiresAt = resolveExpiresAtMsFromDurationMs(WEBHOOK_REPLAY_RESPONSE_TTL_MS, { nowMs: now });
this.replayResponseCacheCalls += 1;
if (this.replayResponseCacheCalls % WEBHOOK_REPLAY_RESPONSE_PRUNE_INTERVAL === 0) this.pruneReplayResponses(now);
const response = buildResponse().then(cloneWebhookResponsePayload).catch((err) => {
this.replayResponses.delete(key);
throw err;
});
if (expiresAt !== void 0) this.replayResponses.set(key, {
expiresAt,
response
});
if (this.replayResponses.size > WEBHOOK_REPLAY_RESPONSE_MAX_ENTRIES) this.pruneReplayResponses(now);
return cloneWebhookResponsePayload(await response);
}
verifyPreAuthWebhookHeaders(headers) {
if (this.config.skipSignatureVerification) return { ok: true };
switch (this.provider.name) {
case "telnyx": {
const signature = getHeader(headers, "telnyx-signature-ed25519");
const timestamp = getHeader(headers, "telnyx-timestamp");
if (signature && timestamp) return { ok: true };
return {
ok: false,
reason: "missing Telnyx signature or timestamp header"
};
}
case "twilio":
if (getHeader(headers, "x-twilio-signature")) return { ok: true };
return {
ok: false,
reason: "missing X-Twilio-Signature header"
};
case "plivo": {
const hasV3 = Boolean(getHeader(headers, "x-plivo-signature-v3")) && Boolean(getHeader(headers, "x-plivo-signature-v3-nonce"));
const hasV2 = Boolean(getHeader(headers, "x-plivo-signature-v2")) && Boolean(getHeader(headers, "x-plivo-signature-v2-nonce"));
if (hasV3 || hasV2) return { ok: true };
return {
ok: false,
reason: "missing Plivo signature headers"
};
}
default: return { ok: true };
}
}
isRealtimeWebSocketUpgrade(req) {
try {
const pathname = buildRequestUrl(req.url).pathname;
const pattern = this.realtimeHandler?.getStreamPathPattern();
return Boolean(pattern && pathname.startsWith(pattern));
} catch {
return false;
}
}
getRealtimeTwimlParams(ctx) {
if (!this.realtimeHandler || this.provider.name !== "twilio") return null;
const params = new URLSearchParams(ctx.rawBody);
const direction = params.get("Direction");
if (!(!direction || direction === "inbound" || direction.startsWith("outbound"))) return null;
if (ctx.query?.type === "status") return null;
const callStatus = params.get("CallStatus");
if (callStatus && isProviderStatusTerminal(callStatus)) return null;
return !params.get("SpeechResult") && !params.get("Digits") ? params : null;
}
shouldAcceptRealtimeInboundRequest(params) {
switch (this.config.inboundPolicy) {
case "open": return true;
case "allowlist":
case "pairing": return isAllowlistedCaller(normalizePhoneNumber(params.get("From") ?? void 0), this.config.allowFrom);
default: return false;
}
}
processParsedEvents(events) {
for (const event of events) try {
this.manager.processEvent(event);
} catch (err) {
console.error(`[voice-call] Error processing event ${event.type}:`, err);
}
}
writeWebhookResponse(res, payload) {
res.statusCode = payload.statusCode;
if (payload.headers) for (const [key, value] of Object.entries(payload.headers)) res.setHeader(key, value);
res.end(payload.body);
}
/**
* Read request body as string with timeout protection.
*/
readBody(req, maxBytes, timeoutMs = WEBHOOK_BODY_TIMEOUT_MS) {
return readRequestBodyWithLimit(req, {
maxBytes,
timeoutMs
});
}
/**
* Handle auto-response for inbound calls using the agent system.
* Supports tool calling for richer voice interactions.
*/
async handleInboundResponse(callId, userMessage) {
console.log(`[voice-call] Auto-responding to inbound call ${callId}: "${userMessage}"`);
const call = this.manager.getCall(callId);
if (!call) {
console.warn(`[voice-call] Call ${callId} not found for auto-response`);
return;
}
if (!this.coreConfig) {
console.warn("[voice-call] Core config missing; skipping auto-response");
return;
}
if (!this.agentRuntime) {
console.warn("[voice-call] Agent runtime missing; skipping auto-response");
return;
}
try {
const { generateVoiceResponse } = await loadResponseGeneratorModule();
const numberRouteKey = typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to;
const effectiveConfig = resolveVoiceCallEffectiveConfig(this.config, numberRouteKey).config;
const result = await generateVoiceResponse({
voiceConfig: effectiveConfig,
coreConfig: this.coreConfig,
agentRuntime: this.agentRuntime,
callId,
sessionKey: call.sessionKey,
from: call.from,
transcript: call.transcript,
userMessage
});
if (result.error) {
console.error(`[voice-call] Response generation error: ${result.error}`);
return;
}
if (result.text) {
console.log(`[voice-call] AI response: "${result.text}"`);
await this.manager.speak(callId, result.text);
}
} catch (err) {
console.error(`[voice-call] Auto-response error:`, err);
}
}
};
//#endregion
//#region extensions/voice-call/src/runtime.ts
const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
"You are the configured OpenClaw agent receiving delegated requests from a live phone voice bridge.",
"Act on behalf of the caller using the normal available tools when the caller asks you to do work.",
"Prioritize completing the user's request and returning a fast, speakable result over exhaustive investigation.",
"For tool-backed status checks, prefer one or two bounded read-only queries before answering.",
"Do not print secret values or dump environment variables; only check whether required configuration is present.",
"Be accurate, brief, and speakable."
].join(" ");
let telnyxProviderPromise;
let twilioProviderPromise;
let plivoProviderPromise;
let mockProviderPromise;
let realtimeVoiceRuntimePromise;
let realtimeHandlerPromise;
function loadTelnyxProvider() {
telnyxProviderPromise ??= import("./telnyx-CQ1R-HBd.js");
return telnyxProviderPromise;
}
function loadTwilioProvider() {
twilioProviderPromise ??= import("./twilio-CUSrkrAg.js");
return twilioProviderPromise;
}
function loadPlivoProvider() {
plivoProviderPromise ??= import("./plivo-7fqjBJZn.js");
return plivoProviderPromise;
}
function loadMockProvider() {
mockProviderPromise ??= import("./mock-aVfpeidK.js");
return mockProviderPromise;
}
function loadRealtimeVoiceRuntime() {
realtimeVoiceRuntimePromise ??= import("./realtime-voice.runtime.js");
return realtimeVoiceRuntimePromise;
}
function loadRealtimeHandler() {
realtimeHandlerPromise ??= import("./realtime-handler-DNoEhSme.js");
return realtimeHandlerPromise;
}
function resolveVoiceCallConsultSessionKey(call) {
if (call.sessionKey) return call.sessionKey;
const phone = call.direction === "outbound" ? call.to : call.from;
return resolveVoiceCallSessionKey({
config: call.config,
callId: call.callId,
phone
});
}
function mapVoiceCallConsultTranscript(call, context) {
const transcript = (call.transcript ?? []).map((entry) => ({
role: entry.speaker === "bot" ? "assistant" : "user",
text: entry.text
}));
const partial = context?.partialUserTranscript?.trim();
if (partial && transcript.at(-1)?.text !== partial) transcript.push({
role: "user",
text: partial
});
return transcript;
}
function createRuntimeResourceLifecycle(params) {
let tunnelResult = null;
let stopped = false;
const runStep = async (step, suppressErrors) => {
if (suppressErrors) {
await step().catch(() => {});
return;
}
await step();
};
return {
setTunnelResult: (result) => {
tunnelResult = result;
},
stop: async (opts) => {
if (stopped) return;
stopped = true;
const suppressErrors = opts?.suppressErrors ?? false;
await runStep(async () => {
if (tunnelResult) await tunnelResult.stop();
}, suppressErrors);
await runStep(async () => {
await cleanupTailscaleExposure(params.config);
}, suppressErrors);
await runStep(async () => {
await params.webhookServer.stop();
}, suppressErrors);
}
};
}
async function resolveProvider(config) {
const allowNgrokFreeTierLoopbackBypass = config.tunnel?.provider === "ngrok" && isLoopbackHost(config.serve?.bind ?? "") && (config.tunnel?.allowNgrokFreeTierLoopbackBypass ?? false);
switch (config.provider) {
case "telnyx": {
const { TelnyxProvider } = await loadTelnyxProvider();
return new TelnyxProvider({
apiKey: config.telnyx?.apiKey,
connectionId: config.telnyx?.connectionId,
publicKey: config.telnyx?.publicKey
}, { skipVerification: config.skipSignatureVerification });
}
case "twilio": {
const { TwilioProvider } = await loadTwilioProvider();
return new TwilioProvider({
accountSid: config.twilio?.accountSid,
authToken: resolveTwilioAuthToken(config)
}, {
allowNgrokFreeTierLoopbackBypass,
publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification,
streamPath: config.streaming?.enabled ? config.streaming.streamPath : void 0,
webhookSecurity: config.webhookSecurity
});
}
case "plivo": {
const { PlivoProvider } = await loadPlivoProvider();
return new PlivoProvider({
authId: config.plivo?.authId,
authToken: config.plivo?.authToken
}, {
publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification,
ringTimeoutSec: Math.max(1, Math.floor(config.ringTimeoutMs / 1e3)),
webhookSecurity: config.webhookSecurity
});
}
case "mock": {
const { MockProvider } = await loadMockProvider();
return new MockProvider();
}
default: throw new Error(`Unsupported voice-call provider: ${String(config.provider)}`);
}
}
async function resolveRealtimeProvider(params) {
const { resolveConfiguredRealtimeVoiceProvider } = await loadRealtimeVoiceRuntime();
return resolveConfiguredRealtimeVoiceProvider({
configuredProviderId: params.config.realtime.provider,
providerConfigs: params.config.realtime.providers,
cfg: params.fullConfig
});
}
async function createVoiceCallRuntime(params) {
const { config: rawConfig, coreConfig, fullConfig, agentRuntime, stateRuntime, ttsRuntime, logger } = params;
const log = logger ?? {
info: console.log,
warn: console.warn,
error: console.error,
debug: console.debug
};
const config = resolveVoiceCallConfig(rawConfig);
const cfg = fullConfig ?? coreConfig;
if (!config.enabled) throw new Error("Voice call disabled. Enable the plugin entry in config.");
if (config.skipSignatureVerification) log.warn("[voice-call] SECURITY WARNING: skipSignatureVerification=true disables webhook signature verification (development only). Do not use in production.");
const validation = validateProviderConfig(config);
if (!validation.valid) throw new Error(`Invalid voice-call config: ${validation.errors.join("; ")}`);
const provider = await resolveProvider(config);
if (stateRuntime) setVoiceCallStateRuntime({ state: stateRuntime });
const manager = new CallManager(config);
const realtimeProvider = config.realtime.enabled ? await resolveRealtimeProvider({
config,
fullConfig: cfg
}) : null;
const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig, fullConfig ?? coreConfig, agentRuntime, log);
if (realtimeProvider) {
const { RealtimeCallHandler } = await loadRealtimeHandler();
const realtimeInstructions = await buildRealtimeVoiceInstructions({
baseInstructions: config.realtime.instructions,
config,
coreConfig,
agentRuntime
});
const realtimeHandler = new RealtimeCallHandler({
...config.realtime,
instructions: realtimeInstructions,
tools: resolveRealtimeVoiceAgentConsultTools(config.realtime.toolPolicy, config.realtime.tools)
}, manager, provider, realtimeProvider.provider, realtimeProvider.providerConfig, config.serve.path, cfg);
if (config.realtime.toolPolicy !== "none") realtimeHandler.registerToolHandler(REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, async (args, callId, handlerContext) => {
const call = manager.getCall(callId);
if (!call) return { error: `Call "${callId}" not found` };
const effectiveConfig = resolveVoiceCallEffectiveConfig(config, typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to).config;
const agentId = effectiveConfig.agentId ?? "main";
const sessionKey = resolveVoiceCallConsultSessionKey({
...call,
config: effectiveConfig
});
const requesterSessionKey = typeof call.metadata?.requesterSessionKey === "string" ? call.metadata.requesterSessionKey : void 0;
const fastContext = await resolveRealtimeFastContextConsult({
cfg,
agentId,
sessionKey,
config: effectiveConfig.realtime.fastContext,
args,
logger: log
});
if (fastContext.handled) return fastContext.result;
const { provider: agentProvider, model } = resolveVoiceResponseModel({
voiceConfig: effectiveConfig,
agentRuntime
});
const thinkLevel = effectiveConfig.realtime.consultThinkingLevel ?? agentRuntime.resolveThinkingDefault({
cfg,
provider: agentProvider,
model
});
return await consultRealtimeVoiceAgent({
cfg,
agentRuntime,
logger: log,
agentId,
sessionKey,
messageProvider: "voice",
lane: "voice",
runIdPrefix: `voice-realtime-consult:${callId}`,
args,
transcript: mapVoiceCallConsultTranscript(call, handlerContext),
surface: "a live phone call",
userLabel: "Caller",
assistantLabel: "Agent",
questionSourceLabel: "caller",
provider: agentProvider,
model,
thinkLevel,
fastMode: effectiveConfig.realtime.consultFastMode,
timeoutMs: effectiveConfig.responseTimeoutMs,
spawnedBy: requesterSessionKey,
contextMode: requesterSessionKey ? "fork" : void 0,
toolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow(effectiveConfig.realtime.toolPolicy),
extraSystemPrompt: REALTIME_VOICE_CONSULT_SYSTEM_PROMPT
});
});
webhookServer.setRealtimeHandler(realtimeHandler);
}
const lifecycle = createRuntimeResourceLifecycle({
config,
webhookServer
});
const localUrl = await webhookServer.start();
try {
let publicUrl = config.publicUrl ?? null;
if (!publicUrl && config.tunnel?.provider && config.tunnel.provider !== "none") try {
const nextTunnelResult = await startTunnel({
provider: config.tunnel.provider,
port: config.serve.port,
path: config.serve.path,
ngrokAuthToken: config.tunnel.ngrokAuthToken,
ngrokDomain: config.tunnel.ngrokDomain
});
lifecycle.setTunnelResult(nextTunnelResult);
publicUrl = nextTunnelResult?.publicUrl ?? null;
} catch (err) {
log.error(`[voice-call] Tunnel setup failed: ${formatErrorMessage(err)}`);
}
if (!publicUrl && config.tailscale?.mode !== "off") publicUrl = await setupTailscaleExposure(config);
const webhookUrl = publicUrl ?? localUrl;
if (providerRequiresPublicWebhook(provider.name) && isProviderUnreachableWebhookUrl(webhookUrl)) throw new Error(`[voice-call] ${provider.name} requires a publicly reachable webhook URL. Refusing to use local-only webhook ${webhookUrl}. Set plugins.entries.voice-call.config.publicUrl or enable tunnel/tailscale exposure.`);
if (publicUrl) provider.setPublicUrl?.(publicUrl);
if (publicUrl && realtimeProvider) webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
const realtimeHandler = webhookServer.getRealtimeHandler();
if (realtimeHandler) manager.streamSessionIssuer = (request) => realtimeHandler.issueStreamSession(request);
if (provider.name === "twilio" && config.streaming?.enabled) {
const twilioProvider = provider;
if (ttsRuntime?.textToSpeechTelephony) try {
const ttsProvider = createTelephonyTtsProvider({
coreConfig,
ttsOverride: config.tts,
runtime: ttsRuntime,
logger: log
});
twilioProvider.setTTSProvider(ttsProvider);
log.info("[voice-call] Telephony TTS provider configured");
} catch (err) {
log.warn(`[voice-call] Failed to initialize telephony TTS: ${formatErrorMessage(err)}`);
}
else log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled");
const mediaHandler = webhookServer.getMediaStreamHandler();
if (mediaHandler) {
twilioProvider.setMediaStreamHandler(mediaHandler);
log.info("[voice-call] Media stream handler wired to provider");
}
}
if (realtimeProvider) log.info(`[voice-call] Realtime voice provider: ${realtimeProvider.provider.id}`);
await manager.initialize(provider, webhookUrl);
const stop = async () => await lifecycle.stop();
log.info("[voice-call] Runtime initialized");
log.info(`[voice-call] Webhook URL: ${webhookUrl}`);
if (publicUrl && publicUrl !== webhookUrl) log.info(`[voice-call] Public URL: ${publicUrl}`);
return {
config,
provider,
manager,
webhookServer,
webhookUrl,
publicUrl,
stop
};
} catch (err) {
await lifecycle.stop({ suppressErrors: true });
throw err;
}
}
//#endregion
export { setupTailscaleExposureRoute as a, getTailscaleSelfInfo as i, resolveWebhookExposureStatus as n, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as o, cleanupTailscaleExposureRoute as r, resolveUserPath as s, createVoiceCallRuntime as t };