mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
b7ccf62fbd
* fix(voice-call): support alternate Tailscale HTTPS ports * refactor(voice-call): keep Tailscale DNS lookup private * fix(voice-call): preserve config normalization contract
993 lines
35 KiB
TypeScript
993 lines
35 KiB
TypeScript
// Voice Call helper module supports config behavior.
|
|
import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime";
|
|
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES } from "openclaw/plugin-sdk/realtime-voice";
|
|
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
|
import {
|
|
buildSecretInputSchema,
|
|
hasConfiguredSecretInput,
|
|
normalizeResolvedSecretInputString,
|
|
type SecretInput,
|
|
} from "openclaw/plugin-sdk/secret-input";
|
|
import {
|
|
canonicalizeMainSessionAlias,
|
|
type SessionScope,
|
|
} from "openclaw/plugin-sdk/session-store-runtime";
|
|
import { resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core";
|
|
import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
|
import { z } from "zod";
|
|
import { TtsConfigSchema } from "../api.js";
|
|
import { TWILIO_REGIONS } from "./providers/twilio-region.js";
|
|
import { DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS } from "./realtime-defaults.js";
|
|
import { isTailscalePortAllowed, VoiceCallTailscaleConfigSchema } from "./tailscale-config.js";
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Phone Number Validation
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* E.164 phone number format: +[country code][number]
|
|
* Examples use 555 prefix (reserved for fictional numbers)
|
|
*/
|
|
const E164Schema = z
|
|
.string()
|
|
.regex(/^\+[1-9]\d{1,14}$/, "Expected E.164 format, e.g. +15550001234");
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Inbound Policy
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Controls how inbound calls are handled:
|
|
* - "disabled": Block all inbound calls (outbound only)
|
|
* - "allowlist": Only accept calls from numbers in allowFrom
|
|
* - "pairing": Unknown callers can request pairing (future)
|
|
* - "open": Accept all inbound calls (dangerous!)
|
|
*/
|
|
const InboundPolicySchema = z.enum(["disabled", "allowlist", "pairing", "open"]);
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Provider-Specific Configuration
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const SecretInputSchema = buildSecretInputSchema();
|
|
|
|
const TelnyxConfigSchema = z
|
|
.object({
|
|
/** Telnyx API v2 key */
|
|
apiKey: z.string().min(1).optional(),
|
|
/** Telnyx connection ID (from Call Control app) */
|
|
connectionId: z.string().min(1).optional(),
|
|
/** Public key for webhook signature verification */
|
|
publicKey: z.string().min(1).optional(),
|
|
})
|
|
.strict();
|
|
export type TelnyxConfig = z.infer<typeof TelnyxConfigSchema>;
|
|
|
|
const TwilioConfigSchema = z
|
|
.object({
|
|
/** Twilio Account SID */
|
|
accountSid: z.string().min(1).optional(),
|
|
/** Twilio Auth Token */
|
|
authToken: SecretInputSchema.optional(),
|
|
/** Twilio processing Region (for example, ie1) */
|
|
region: z.enum(TWILIO_REGIONS).optional(),
|
|
})
|
|
.strict();
|
|
|
|
const PlivoConfigSchema = z
|
|
.object({
|
|
/** Plivo Auth ID (starts with MA/SA) */
|
|
authId: z.string().min(1).optional(),
|
|
/** Plivo Auth Token */
|
|
authToken: z.string().min(1).optional(),
|
|
})
|
|
.strict();
|
|
export type PlivoConfig = z.infer<typeof PlivoConfigSchema>;
|
|
|
|
export type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>;
|
|
|
|
const VoiceCallNumberRouteConfigSchema = z
|
|
.object({
|
|
/** Greeting message for inbound calls to this number. */
|
|
inboundGreeting: z.string().optional(),
|
|
/** TTS override for inbound calls to this number. Deep-merges with global voice-call TTS. */
|
|
tts: TtsConfigSchema,
|
|
/** Agent ID to use for voice response generation for this number. */
|
|
agentId: z.string().min(1).optional(),
|
|
/** Optional model override for voice responses for this number. */
|
|
responseModel: z.string().optional(),
|
|
/** System prompt for voice responses for this number. */
|
|
responseSystemPrompt: z.string().optional(),
|
|
/** Timeout for response generation in ms for this number. */
|
|
responseTimeoutMs: z.number().int().positive().optional(),
|
|
})
|
|
.strict();
|
|
type VoiceCallNumberRouteConfig = z.infer<typeof VoiceCallNumberRouteConfigSchema>;
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Webhook Server Configuration
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const VoiceCallServeConfigSchema = z
|
|
.object({
|
|
/** Port to listen on */
|
|
port: z.number().int().positive().default(3334),
|
|
/** Bind address */
|
|
bind: z.string().default("127.0.0.1"),
|
|
/** Webhook path */
|
|
path: z.string().min(1).default("/voice/webhook"),
|
|
})
|
|
.strict()
|
|
.default({ port: 3334, bind: "127.0.0.1", path: "/voice/webhook" });
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Tunnel Configuration (unified ngrok/tailscale)
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const VoiceCallTunnelConfigSchema = z
|
|
.object({
|
|
/**
|
|
* Tunnel provider:
|
|
* - "none": No tunnel (use publicUrl if set, or manual setup)
|
|
* - "ngrok": Use ngrok for public HTTPS tunnel
|
|
* - "tailscale-serve": Tailscale serve (private to tailnet)
|
|
* - "tailscale-funnel": Tailscale funnel (public HTTPS)
|
|
*/
|
|
provider: z.enum(["none", "ngrok", "tailscale-serve", "tailscale-funnel"]).default("none"),
|
|
/** ngrok auth token (optional, enables longer sessions and more features) */
|
|
ngrokAuthToken: z.string().min(1).optional(),
|
|
/** ngrok custom domain (paid feature, e.g., "myapp.ngrok.io") */
|
|
ngrokDomain: z.string().min(1).optional(),
|
|
/**
|
|
* Allow ngrok free tier compatibility mode.
|
|
* When true, forwarded headers may be trusted for loopback requests
|
|
* to reconstruct the public ngrok URL used for signing.
|
|
*
|
|
* IMPORTANT: This does NOT bypass signature verification.
|
|
*/
|
|
allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
|
|
})
|
|
.strict()
|
|
.default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false });
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Webhook Security Configuration
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const VoiceCallWebhookSecurityConfigSchema = z
|
|
.object({
|
|
/**
|
|
* Allowed hostnames for webhook URL reconstruction.
|
|
* Only these hosts are accepted from forwarding headers.
|
|
*/
|
|
allowedHosts: z.array(z.string().min(1)).default([]),
|
|
/**
|
|
* Trust X-Forwarded-* headers without a hostname allowlist.
|
|
* WARNING: Only enable if you trust your proxy configuration.
|
|
*/
|
|
trustForwardingHeaders: z.boolean().default(false),
|
|
/**
|
|
* Trusted proxy IP addresses. Forwarded headers are only trusted when
|
|
* the remote IP matches one of these addresses.
|
|
*/
|
|
trustedProxyIPs: z.array(z.string().min(1)).default([]),
|
|
})
|
|
.strict()
|
|
.default({ allowedHosts: [], trustForwardingHeaders: false, trustedProxyIPs: [] });
|
|
export type WebhookSecurityConfig = z.infer<typeof VoiceCallWebhookSecurityConfigSchema>;
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Outbound Call Configuration
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Call mode determines how outbound calls behave:
|
|
* - "notify": Deliver message and auto-hangup after delay (one-way notification)
|
|
* - "conversation": Stay open for back-and-forth until explicit end or timeout
|
|
*/
|
|
const CallModeSchema = z.enum(["notify", "conversation"]);
|
|
export type CallMode = z.infer<typeof CallModeSchema>;
|
|
|
|
const VoiceCallSessionScopeSchema = z.enum(["per-phone", "per-call", "main"]);
|
|
|
|
const OutboundConfigSchema = z
|
|
.object({
|
|
/** Default call mode for outbound calls */
|
|
defaultMode: CallModeSchema.default("notify"),
|
|
/** Seconds to wait after TTS before auto-hangup in notify mode */
|
|
notifyHangupDelaySec: z.number().int().nonnegative().default(3),
|
|
})
|
|
.strict()
|
|
.default({ defaultMode: "notify", notifyHangupDelaySec: 3 });
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Realtime Voice Configuration
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const RealtimeToolSchema = z
|
|
.object({
|
|
type: z.literal("function"),
|
|
name: z.string().min(1),
|
|
description: z.string(),
|
|
parameters: z.object({
|
|
type: z.literal("object"),
|
|
properties: z.record(z.string(), z.unknown()),
|
|
required: z.array(z.string()).optional(),
|
|
}),
|
|
})
|
|
.strict();
|
|
type RealtimeToolConfig = z.infer<typeof RealtimeToolSchema>;
|
|
|
|
const VoiceCallRealtimeProvidersConfigSchema = z
|
|
.record(z.string(), z.record(z.string(), z.unknown()))
|
|
.default({});
|
|
|
|
const VoiceCallRealtimeToolPolicySchema = z.enum(REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES);
|
|
const VoiceCallRealtimeConsultPolicySchema = z.enum(["auto", "substantive", "always"]);
|
|
|
|
const VoiceCallRealtimeFastContextSourceSchema = z.enum(["memory", "sessions"]);
|
|
|
|
const VoiceCallRealtimeFastContextConfigSchema = z
|
|
.object({
|
|
/** Enable bounded memory/session lookup before the full consult agent. */
|
|
enabled: z.boolean().default(false),
|
|
/** Hard deadline for the fast context lookup. */
|
|
timeoutMs: z.number().int().positive().default(800),
|
|
/** Maximum memory/session hits to inject into the realtime tool result. */
|
|
maxResults: z.number().int().positive().default(3),
|
|
/** Indexed sources used by the fast context lookup. */
|
|
sources: z
|
|
.array(VoiceCallRealtimeFastContextSourceSchema)
|
|
.min(1)
|
|
.default(["memory", "sessions"]),
|
|
/** Fall back to the full agent consult when fast context has no answer. */
|
|
fallbackToConsult: z.boolean().default(false),
|
|
})
|
|
.strict()
|
|
.default({
|
|
enabled: false,
|
|
timeoutMs: 800,
|
|
maxResults: 3,
|
|
sources: ["memory", "sessions"],
|
|
fallbackToConsult: false,
|
|
});
|
|
const VoiceCallRealtimeAgentContextConfigSchema = z
|
|
.object({
|
|
/** Inject a compact agent persona/context capsule into realtime voice instructions. */
|
|
enabled: z.boolean().default(false),
|
|
/** Maximum number of characters from the generated capsule to append. */
|
|
maxChars: z.number().int().positive().default(6000),
|
|
/** Include configured agent identity fields. */
|
|
includeIdentity: z.boolean().default(true),
|
|
/** Include selected workspace files such as SOUL.md and IDENTITY.md. */
|
|
includeWorkspaceFiles: z.boolean().default(true),
|
|
/** Workspace-relative files to include, bounded by maxChars. */
|
|
files: z.array(z.string().min(1)).default(["SOUL.md", "IDENTITY.md", "USER.md"]),
|
|
})
|
|
.strict()
|
|
.default({
|
|
enabled: false,
|
|
maxChars: 6000,
|
|
includeIdentity: true,
|
|
includeWorkspaceFiles: true,
|
|
files: ["SOUL.md", "IDENTITY.md", "USER.md"],
|
|
});
|
|
|
|
const VoiceCallRealtimeConsultThinkingLevelSchema = z.enum([
|
|
"off",
|
|
"minimal",
|
|
"low",
|
|
"medium",
|
|
"high",
|
|
"xhigh",
|
|
"adaptive",
|
|
"max",
|
|
"ultra",
|
|
]);
|
|
|
|
const VoiceCallStreamingProvidersConfigSchema = z
|
|
.record(z.string(), z.record(z.string(), z.unknown()))
|
|
.default({});
|
|
|
|
const VoiceCallRealtimeConfigSchema = z
|
|
.object({
|
|
/** Enable realtime voice-to-voice mode. */
|
|
enabled: z.boolean().default(false),
|
|
/** Provider id from registered realtime voice providers. */
|
|
provider: z.string().min(1).optional(),
|
|
/** Optional override for the local WebSocket route path. */
|
|
streamPath: z.string().min(1).optional(),
|
|
/** System instructions passed to the realtime provider. */
|
|
instructions: z.string().default(DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS),
|
|
/** Tool policy for the shared OpenClaw agent consult tool. */
|
|
toolPolicy: VoiceCallRealtimeToolPolicySchema.default("safe-read-only"),
|
|
/** Guidance for when the realtime model should call the OpenClaw agent consult tool. */
|
|
consultPolicy: VoiceCallRealtimeConsultPolicySchema.default("auto"),
|
|
/** Optional thinking level override for the regular agent behind realtime consults. */
|
|
consultThinkingLevel: VoiceCallRealtimeConsultThinkingLevelSchema.optional(),
|
|
/** Optional fast mode override for the regular agent behind realtime consults. */
|
|
consultFastMode: z.boolean().optional(),
|
|
/** Tool definitions exposed to the realtime provider. */
|
|
tools: z.array(RealtimeToolSchema).default([]),
|
|
/** Low-latency memory/session context for the consult tool. */
|
|
fastContext: VoiceCallRealtimeFastContextConfigSchema,
|
|
/** Bounded agent persona/context injection for the fast realtime voice path. */
|
|
agentContext: VoiceCallRealtimeAgentContextConfigSchema,
|
|
/** Provider-owned raw config blobs keyed by provider id. */
|
|
providers: VoiceCallRealtimeProvidersConfigSchema,
|
|
})
|
|
.strict()
|
|
.default({
|
|
enabled: false,
|
|
instructions: DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS,
|
|
toolPolicy: "safe-read-only",
|
|
consultPolicy: "auto",
|
|
tools: [],
|
|
fastContext: {
|
|
enabled: false,
|
|
timeoutMs: 800,
|
|
maxResults: 3,
|
|
sources: ["memory", "sessions"],
|
|
fallbackToConsult: false,
|
|
},
|
|
agentContext: {
|
|
enabled: false,
|
|
maxChars: 6000,
|
|
includeIdentity: true,
|
|
includeWorkspaceFiles: true,
|
|
files: ["SOUL.md", "IDENTITY.md", "USER.md"],
|
|
},
|
|
providers: {},
|
|
});
|
|
export type VoiceCallRealtimeConfig = z.infer<typeof VoiceCallRealtimeConfigSchema>;
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Streaming Configuration (Realtime Transcription)
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const VoiceCallStreamingConfigSchema = z
|
|
.object({
|
|
/** Enable Twilio Media Streams for real-time transcription. */
|
|
enabled: z.boolean().default(false),
|
|
/** Provider id from registered realtime transcription providers. */
|
|
provider: z.string().min(1).optional(),
|
|
/** WebSocket path for media stream connections */
|
|
streamPath: z.string().min(1).default("/voice/stream"),
|
|
/** Provider-owned raw config blobs keyed by provider id. */
|
|
providers: VoiceCallStreamingProvidersConfigSchema,
|
|
/**
|
|
* Close unauthenticated media stream sockets if no valid `start` frame arrives in time.
|
|
* Protects against pre-auth idle connection hold attacks.
|
|
*/
|
|
preStartTimeoutMs: z.number().int().positive().default(5000),
|
|
/** Maximum number of concurrently pending (pre-start) media stream sockets. */
|
|
maxPendingConnections: z.number().int().positive().default(32),
|
|
/** Maximum pending media stream sockets per source IP. */
|
|
maxPendingConnectionsPerIp: z.number().int().positive().default(4),
|
|
/** Hard cap for all open media stream sockets (pending + active). */
|
|
maxConnections: z.number().int().positive().default(128),
|
|
})
|
|
.strict()
|
|
.default({
|
|
enabled: false,
|
|
streamPath: "/voice/stream",
|
|
providers: {},
|
|
preStartTimeoutMs: 5000,
|
|
maxPendingConnections: 32,
|
|
maxPendingConnectionsPerIp: 4,
|
|
maxConnections: 128,
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Main Voice Call Configuration
|
|
// -----------------------------------------------------------------------------
|
|
|
|
export const VoiceCallConfigSchema = z
|
|
.object({
|
|
/** Enable voice call functionality */
|
|
enabled: z.boolean().default(false),
|
|
|
|
/** Active provider (telnyx, twilio, plivo, or mock) */
|
|
provider: z.enum(["telnyx", "twilio", "plivo", "mock"]).optional(),
|
|
|
|
/** Telnyx-specific configuration */
|
|
telnyx: TelnyxConfigSchema.optional(),
|
|
|
|
/** Twilio-specific configuration */
|
|
twilio: TwilioConfigSchema.optional(),
|
|
|
|
/** Plivo-specific configuration */
|
|
plivo: PlivoConfigSchema.optional(),
|
|
|
|
/** Phone number to call from (E.164) */
|
|
fromNumber: E164Schema.optional(),
|
|
|
|
/** Default phone number to call (E.164) */
|
|
toNumber: E164Schema.optional(),
|
|
|
|
/** Inbound call policy */
|
|
inboundPolicy: InboundPolicySchema.default("disabled"),
|
|
|
|
/** Allowlist of phone numbers for inbound calls (E.164) */
|
|
allowFrom: z.array(E164Schema).default([]),
|
|
|
|
/** Greeting message for inbound calls */
|
|
inboundGreeting: z.string().optional(),
|
|
|
|
/** Per-dialed-number overrides for inbound calls. Keys are E.164 numbers. */
|
|
numbers: z.record(E164Schema, VoiceCallNumberRouteConfigSchema).default({}),
|
|
|
|
/** Outbound call configuration */
|
|
outbound: OutboundConfigSchema,
|
|
|
|
/** Maximum call duration in seconds */
|
|
maxDurationSeconds: z.number().int().positive().default(300),
|
|
|
|
/**
|
|
* Maximum age of a call in seconds before it is automatically reaped.
|
|
* Catches calls stuck before answer (for example, local mock calls that
|
|
* never receive provider webhooks). Set to 0 to disable.
|
|
*/
|
|
staleCallReaperSeconds: z.number().int().nonnegative().default(120),
|
|
|
|
/** Silence timeout for end-of-speech detection (ms) */
|
|
silenceTimeoutMs: z.number().int().positive().default(800),
|
|
|
|
/** Timeout for user transcript (ms) */
|
|
transcriptTimeoutMs: z.number().int().positive().default(180000),
|
|
|
|
/** Ring timeout for outbound calls (ms) */
|
|
ringTimeoutMs: z.number().int().positive().default(30000),
|
|
|
|
/** Maximum concurrent calls */
|
|
maxConcurrentCalls: z.number().int().positive().default(1),
|
|
|
|
/** Webhook server configuration */
|
|
serve: VoiceCallServeConfigSchema,
|
|
|
|
/** @deprecated Prefer tunnel config. */
|
|
tailscale: VoiceCallTailscaleConfigSchema,
|
|
|
|
/** Tunnel configuration (unified ngrok/tailscale) */
|
|
tunnel: VoiceCallTunnelConfigSchema,
|
|
|
|
/** Webhook signature reconstruction and proxy trust configuration */
|
|
webhookSecurity: VoiceCallWebhookSecurityConfigSchema,
|
|
|
|
/** Real-time audio streaming configuration */
|
|
streaming: VoiceCallStreamingConfigSchema,
|
|
|
|
/** Realtime voice-to-voice configuration */
|
|
realtime: VoiceCallRealtimeConfigSchema,
|
|
|
|
/** Session memory scope for voice conversations. */
|
|
sessionScope: VoiceCallSessionScopeSchema.default("per-phone"),
|
|
|
|
/** Public webhook URL override (if set, bypasses tunnel auto-detection) */
|
|
publicUrl: z.string().url().optional(),
|
|
|
|
/** Skip webhook signature verification (development only, NOT for production) */
|
|
skipSignatureVerification: z.boolean().default(false),
|
|
|
|
/** TTS override (deep-merges with core tts) */
|
|
tts: TtsConfigSchema,
|
|
|
|
/** Store path for call logs */
|
|
store: z.string().optional(),
|
|
|
|
/** Agent ID to use for voice response generation. Defaults to "main". */
|
|
agentId: z.string().min(1).optional(),
|
|
|
|
/** Optional model override for generating voice responses. */
|
|
responseModel: z.string().optional(),
|
|
|
|
/** System prompt for voice responses */
|
|
responseSystemPrompt: z.string().optional(),
|
|
|
|
/** Timeout for response generation in ms (default 30s) */
|
|
responseTimeoutMs: z.number().int().positive().default(30000),
|
|
})
|
|
.strict()
|
|
.refine(isTailscalePortAllowed, {
|
|
path: ["tailscale", "port"],
|
|
message: "Tailscale Funnel HTTPS port must be one of 443, 8443, 10000",
|
|
});
|
|
|
|
export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;
|
|
type VoiceCallEffectiveConfigResult = {
|
|
config: VoiceCallConfig;
|
|
numberRouteKey?: string;
|
|
};
|
|
type DeepPartial<T> = T extends SecretInput
|
|
? T
|
|
: T extends Array<infer U>
|
|
? DeepPartial<U>[]
|
|
: T extends object
|
|
? { [K in keyof T]?: DeepPartial<T[K]> }
|
|
: T;
|
|
type VoiceCallConfigInput = DeepPartial<VoiceCallConfig>;
|
|
const TWILIO_AUTH_TOKEN_PATH = "plugins.entries.voice-call.config.twilio.authToken";
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Configuration Helpers
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const DEFAULT_VOICE_CALL_CONFIG = VoiceCallConfigSchema.parse({});
|
|
|
|
function cloneDefaultVoiceCallConfig(): VoiceCallConfig {
|
|
return structuredClone(DEFAULT_VOICE_CALL_CONFIG);
|
|
}
|
|
|
|
function defaultRealtimeStreamPathForServePath(servePath: string): string {
|
|
const normalized = normalizeWebhookPath(servePath);
|
|
if (normalized.endsWith("/webhook")) {
|
|
return `${normalized.slice(0, -"/webhook".length)}/stream/realtime`;
|
|
}
|
|
if (normalized === "/") {
|
|
return "/voice/stream/realtime";
|
|
}
|
|
return `${normalized}/stream/realtime`;
|
|
}
|
|
|
|
export type VoiceCallStreamExposurePath = {
|
|
publicPath: string;
|
|
localPath: string;
|
|
};
|
|
|
|
export function resolveVoiceCallPublicPathPrefix(
|
|
publicWebhookPath: string,
|
|
localWebhookPath: string,
|
|
): string {
|
|
const publicPath = normalizeWebhookPath(publicWebhookPath);
|
|
const localPathIndex = publicPath.indexOf(normalizeWebhookPath(localWebhookPath));
|
|
return localPathIndex > 0 ? publicPath.slice(0, localPathIndex) : "";
|
|
}
|
|
|
|
export function resolveVoiceCallStreamExposurePaths(
|
|
config: VoiceCallConfig,
|
|
webhookPaths: { publicWebhookPath?: string; localWebhookPath?: string } = {},
|
|
): VoiceCallStreamExposurePath[] {
|
|
const exposurePaths: VoiceCallStreamExposurePath[] = [];
|
|
const localWebhookPath = webhookPaths.localWebhookPath ?? config.serve.path;
|
|
const publicWebhookPath = webhookPaths.publicWebhookPath ?? config.tailscale.path;
|
|
const publicPathPrefix = resolveVoiceCallPublicPathPrefix(publicWebhookPath, localWebhookPath);
|
|
if (config.realtime.enabled) {
|
|
const localPath = normalizeWebhookPath(
|
|
config.realtime.streamPath ?? defaultRealtimeStreamPathForServePath(config.serve.path),
|
|
);
|
|
exposurePaths.push({
|
|
localPath,
|
|
publicPath: `${publicPathPrefix}${localPath}`,
|
|
});
|
|
}
|
|
if (config.streaming.enabled) {
|
|
const localPath = normalizeWebhookPath(config.streaming.streamPath);
|
|
if (
|
|
!exposurePaths.some((path) => path.localPath === localPath && path.publicPath === localPath)
|
|
) {
|
|
exposurePaths.push({ localPath, publicPath: localPath });
|
|
}
|
|
}
|
|
return exposurePaths;
|
|
}
|
|
|
|
function normalizeVoiceCallTtsConfig(
|
|
defaults: VoiceCallTtsConfig,
|
|
overrides: DeepPartial<NonNullable<VoiceCallTtsConfig>> | undefined,
|
|
): VoiceCallTtsConfig {
|
|
if (!defaults && !overrides) {
|
|
return undefined;
|
|
}
|
|
|
|
return TtsConfigSchema.parse(mergeDeep(defaults ?? {}, overrides ?? {}));
|
|
}
|
|
|
|
function normalizePhoneRouteKey(phone: string | undefined): string {
|
|
return phone?.replace(/\D/g, "") ?? "";
|
|
}
|
|
|
|
function resolveVoiceCallNumberRouteKey(
|
|
config: Pick<VoiceCallConfig, "numbers">,
|
|
phone: string | undefined,
|
|
): string | undefined {
|
|
const routes = config.numbers;
|
|
if (!routes) {
|
|
return undefined;
|
|
}
|
|
if (phone && Object.hasOwn(routes, phone)) {
|
|
return phone;
|
|
}
|
|
|
|
const normalizedPhone = normalizePhoneRouteKey(phone);
|
|
if (!normalizedPhone) {
|
|
return undefined;
|
|
}
|
|
return Object.keys(routes).find(
|
|
(routeKey) => normalizePhoneRouteKey(routeKey) === normalizedPhone,
|
|
);
|
|
}
|
|
|
|
/** Resolve inbound-only number routing from a persisted call record. */
|
|
export function resolveVoiceCallNumberRouteKeyForCall(call: {
|
|
direction?: "inbound" | "outbound";
|
|
to?: string;
|
|
metadata?: { numberRouteKey?: unknown };
|
|
}): string | undefined {
|
|
if (call.direction !== "inbound") {
|
|
return undefined;
|
|
}
|
|
const storedRouteKey = call.metadata?.numberRouteKey;
|
|
if (typeof storedRouteKey === "string") {
|
|
return storedRouteKey;
|
|
}
|
|
return call.to;
|
|
}
|
|
|
|
export function resolveVoiceCallEffectiveConfig(
|
|
config: VoiceCallConfig,
|
|
phoneOrRouteKey: string | undefined,
|
|
): VoiceCallEffectiveConfigResult {
|
|
const numberRouteKey = resolveVoiceCallNumberRouteKey(config, phoneOrRouteKey);
|
|
if (!numberRouteKey) {
|
|
return { config };
|
|
}
|
|
|
|
const route = config.numbers[numberRouteKey];
|
|
if (!route) {
|
|
return { config };
|
|
}
|
|
|
|
return {
|
|
numberRouteKey,
|
|
config: {
|
|
...config,
|
|
...route,
|
|
tts: normalizeVoiceCallTtsConfig(config.tts, route.tts),
|
|
numbers: config.numbers,
|
|
},
|
|
};
|
|
}
|
|
|
|
function sanitizeVoiceCallProviderConfigs(
|
|
value: Record<string, Record<string, unknown> | undefined> | undefined,
|
|
): Record<string, Record<string, unknown>> {
|
|
if (!value) {
|
|
return {};
|
|
}
|
|
return Object.fromEntries(
|
|
Object.entries(value).filter(
|
|
(entry): entry is [string, Record<string, unknown>] => entry[1] !== undefined,
|
|
),
|
|
);
|
|
}
|
|
|
|
function sanitizeVoiceCallNumberRoutes(
|
|
value: Record<string, unknown> | undefined,
|
|
): Record<string, VoiceCallNumberRouteConfig> {
|
|
if (!value) {
|
|
return {};
|
|
}
|
|
return Object.fromEntries(
|
|
Object.entries(value)
|
|
.filter((entry): entry is [string, unknown] => entry[1] !== undefined)
|
|
.map(([key, route]) => [key, VoiceCallNumberRouteConfigSchema.parse(route)]),
|
|
);
|
|
}
|
|
|
|
export function resolveTwilioAuthToken(
|
|
config: Pick<VoiceCallConfig, "twilio">,
|
|
): string | undefined {
|
|
return normalizeResolvedSecretInputString({
|
|
value: config.twilio?.authToken,
|
|
path: TWILIO_AUTH_TOKEN_PATH,
|
|
});
|
|
}
|
|
|
|
export function normalizeVoiceCallConfig(config: VoiceCallConfigInput): VoiceCallConfig {
|
|
const defaults = cloneDefaultVoiceCallConfig();
|
|
const serve = { ...defaults.serve, ...config.serve };
|
|
const streamingProvider = config.streaming?.provider;
|
|
const streamingProviders = sanitizeVoiceCallProviderConfigs(
|
|
config.streaming?.providers ?? defaults.streaming.providers,
|
|
);
|
|
const realtimeProvider = config.realtime?.provider ?? defaults.realtime.provider;
|
|
const realtimeProviders = sanitizeVoiceCallProviderConfigs(
|
|
config.realtime?.providers ?? defaults.realtime.providers,
|
|
);
|
|
const realtimeFastContext = {
|
|
...defaults.realtime.fastContext,
|
|
...config.realtime?.fastContext,
|
|
sources: config.realtime?.fastContext?.sources ?? defaults.realtime.fastContext.sources,
|
|
};
|
|
const realtimeAgentContext = {
|
|
...defaults.realtime.agentContext,
|
|
...config.realtime?.agentContext,
|
|
files: config.realtime?.agentContext?.files ?? defaults.realtime.agentContext.files,
|
|
};
|
|
return {
|
|
...defaults,
|
|
...config,
|
|
allowFrom: config.allowFrom ?? defaults.allowFrom,
|
|
numbers: sanitizeVoiceCallNumberRoutes(
|
|
(config.numbers ?? defaults.numbers) as Record<string, unknown>,
|
|
),
|
|
outbound: { ...defaults.outbound, ...config.outbound },
|
|
serve,
|
|
tailscale: { ...defaults.tailscale, ...config.tailscale },
|
|
tunnel: { ...defaults.tunnel, ...config.tunnel },
|
|
webhookSecurity: {
|
|
...defaults.webhookSecurity,
|
|
...config.webhookSecurity,
|
|
allowedHosts: config.webhookSecurity?.allowedHosts ?? defaults.webhookSecurity.allowedHosts,
|
|
trustedProxyIPs:
|
|
config.webhookSecurity?.trustedProxyIPs ?? defaults.webhookSecurity.trustedProxyIPs,
|
|
},
|
|
streaming: {
|
|
...defaults.streaming,
|
|
...config.streaming,
|
|
provider: streamingProvider,
|
|
providers: streamingProviders,
|
|
},
|
|
realtime: {
|
|
...defaults.realtime,
|
|
...config.realtime,
|
|
provider: realtimeProvider,
|
|
streamPath:
|
|
config.realtime?.streamPath ??
|
|
defaultRealtimeStreamPathForServePath(serve.path ?? defaults.serve.path),
|
|
tools:
|
|
(config.realtime?.tools as RealtimeToolConfig[] | undefined) ?? defaults.realtime.tools,
|
|
consultThinkingLevel: VoiceCallRealtimeConsultThinkingLevelSchema.optional().parse(
|
|
config.realtime?.consultThinkingLevel ?? defaults.realtime.consultThinkingLevel,
|
|
),
|
|
consultFastMode: config.realtime?.consultFastMode ?? defaults.realtime.consultFastMode,
|
|
fastContext: realtimeFastContext,
|
|
agentContext: realtimeAgentContext,
|
|
providers: realtimeProviders,
|
|
},
|
|
tts: normalizeVoiceCallTtsConfig(defaults.tts, config.tts),
|
|
};
|
|
}
|
|
|
|
export type VoiceCallCoreSessionConfig = { mainKey?: string; scope?: SessionScope };
|
|
|
|
export function resolveVoiceCallSessionKey(params: {
|
|
config: Pick<VoiceCallConfig, "agentId" | "sessionScope">;
|
|
callId: string;
|
|
phone?: string;
|
|
explicitSessionKey?: string;
|
|
coreSession?: VoiceCallCoreSessionConfig;
|
|
}): string {
|
|
const explicit = params.explicitSessionKey?.trim();
|
|
if (explicit) {
|
|
return resolveVoiceCallAgentSessionKey({
|
|
config: params.config,
|
|
sessionKey: explicit,
|
|
coreSession: params.coreSession,
|
|
});
|
|
}
|
|
if (params.config.sessionScope === "main") {
|
|
return resolveVoiceCallAgentSessionKey({
|
|
config: params.config,
|
|
sessionKey: "main",
|
|
coreSession: params.coreSession,
|
|
});
|
|
}
|
|
// Startup migration promotes unambiguous shipped `voice:*` rows;
|
|
// generate only canonical keys here so new history never needs repair.
|
|
const prefix = `agent:${normalizeAgentId(params.config.agentId)}:voice`;
|
|
if (params.config.sessionScope === "per-call") {
|
|
return `${prefix}:call:${params.callId}`.toLowerCase();
|
|
}
|
|
const normalizedPhone = params.phone?.replace(/\D/g, "");
|
|
return (
|
|
normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`
|
|
).toLowerCase();
|
|
}
|
|
|
|
/** Resolve persisted or integration-provided keys into the configured agent namespace. */
|
|
function resolveVoiceCallAgentSessionKey(params: {
|
|
config: Pick<VoiceCallConfig, "agentId">;
|
|
sessionKey: string;
|
|
coreSession?: VoiceCallCoreSessionConfig;
|
|
}): string {
|
|
const sessionKey = params.sessionKey.trim();
|
|
if (!sessionKey) {
|
|
throw new Error("Voice Call session key cannot be empty");
|
|
}
|
|
const lower = sessionKey.toLowerCase();
|
|
const agentId = normalizeAgentId(params.config.agentId);
|
|
if (lower === "global" || lower === "unknown") {
|
|
return lower;
|
|
}
|
|
const parsedInput = parseAgentSessionKey(sessionKey);
|
|
let normalizedScopedKey: string;
|
|
if (
|
|
parsedInput &&
|
|
normalizeAgentId(parsedInput.agentId) === parsedInput.agentId &&
|
|
parsedInput.agentId === agentId
|
|
) {
|
|
normalizedScopedKey = `agent:${parsedInput.agentId}:${parsedInput.rest}`;
|
|
} else {
|
|
// Voice Call's configured agent owns both the store and runtime. Foreign or
|
|
// malformed agent-shaped input is an opaque integration key, not a route.
|
|
const wrappedInput = parseAgentSessionKey(`agent:${agentId}:${sessionKey}`);
|
|
if (!wrappedInput) {
|
|
throw new Error("Voice Call session key could not be normalized");
|
|
}
|
|
normalizedScopedKey = `agent:${agentId}:${wrappedInput.rest}`;
|
|
}
|
|
const canonicalMain = canonicalizeMainSessionAlias({
|
|
cfg: { session: params.coreSession },
|
|
agentId,
|
|
sessionKey: normalizedScopedKey,
|
|
});
|
|
return canonicalMain === normalizedScopedKey ? normalizedScopedKey : canonicalMain;
|
|
}
|
|
|
|
/**
|
|
* Resolves the configuration by merging environment variables into missing fields.
|
|
* Returns a new configuration object with environment variables applied.
|
|
*/
|
|
export function resolveVoiceCallConfig(config: VoiceCallConfigInput): VoiceCallConfig {
|
|
const resolved = normalizeVoiceCallConfig(config);
|
|
|
|
// Telnyx
|
|
if (resolved.provider === "telnyx") {
|
|
resolved.telnyx = resolved.telnyx ?? {};
|
|
resolved.telnyx.apiKey =
|
|
resolved.telnyx.apiKey ?? resolveSpeechProviderApiKey(process.env.TELNYX_API_KEY);
|
|
resolved.telnyx.connectionId =
|
|
resolved.telnyx.connectionId ?? resolveSpeechProviderApiKey(process.env.TELNYX_CONNECTION_ID);
|
|
resolved.telnyx.publicKey =
|
|
resolved.telnyx.publicKey ?? resolveSpeechProviderApiKey(process.env.TELNYX_PUBLIC_KEY);
|
|
}
|
|
|
|
// Twilio
|
|
if (resolved.provider === "twilio") {
|
|
resolved.fromNumber =
|
|
resolved.fromNumber ?? resolveSpeechProviderApiKey(process.env.TWILIO_FROM_NUMBER);
|
|
resolved.twilio = resolved.twilio ?? {};
|
|
resolved.twilio.accountSid =
|
|
resolved.twilio.accountSid ?? resolveSpeechProviderApiKey(process.env.TWILIO_ACCOUNT_SID);
|
|
resolved.twilio.authToken =
|
|
resolved.twilio.authToken ?? resolveSpeechProviderApiKey(process.env.TWILIO_AUTH_TOKEN);
|
|
}
|
|
|
|
// Plivo
|
|
if (resolved.provider === "plivo") {
|
|
resolved.plivo = resolved.plivo ?? {};
|
|
resolved.plivo.authId =
|
|
resolved.plivo.authId ?? resolveSpeechProviderApiKey(process.env.PLIVO_AUTH_ID);
|
|
resolved.plivo.authToken =
|
|
resolved.plivo.authToken ?? resolveSpeechProviderApiKey(process.env.PLIVO_AUTH_TOKEN);
|
|
}
|
|
|
|
// Tunnel Config
|
|
resolved.tunnel = resolved.tunnel ?? {
|
|
provider: "none",
|
|
allowNgrokFreeTierLoopbackBypass: false,
|
|
};
|
|
resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
|
|
resolved.tunnel.allowNgrokFreeTierLoopbackBypass ?? false;
|
|
resolved.tunnel.ngrokAuthToken =
|
|
resolved.tunnel.ngrokAuthToken ?? resolveSpeechProviderApiKey(process.env.NGROK_AUTHTOKEN);
|
|
resolved.tunnel.ngrokDomain =
|
|
resolved.tunnel.ngrokDomain ?? resolveSpeechProviderApiKey(process.env.NGROK_DOMAIN);
|
|
|
|
// Webhook Security Config
|
|
resolved.webhookSecurity = resolved.webhookSecurity ?? {
|
|
allowedHosts: [],
|
|
trustForwardingHeaders: false,
|
|
trustedProxyIPs: [],
|
|
};
|
|
resolved.webhookSecurity.allowedHosts = resolved.webhookSecurity.allowedHosts ?? [];
|
|
resolved.webhookSecurity.trustForwardingHeaders =
|
|
resolved.webhookSecurity.trustForwardingHeaders ?? false;
|
|
resolved.webhookSecurity.trustedProxyIPs = resolved.webhookSecurity.trustedProxyIPs ?? [];
|
|
|
|
return normalizeVoiceCallConfig(resolved);
|
|
}
|
|
|
|
/**
|
|
* Validate that the configuration has all required fields for the selected provider.
|
|
*/
|
|
export function validateProviderConfig(config: VoiceCallConfig): {
|
|
valid: boolean;
|
|
errors: string[];
|
|
} {
|
|
const errors: string[] = [];
|
|
|
|
if (!config.enabled) {
|
|
return { valid: true, errors: [] };
|
|
}
|
|
|
|
if (!config.provider) {
|
|
errors.push("plugins.entries.voice-call.config.provider is required");
|
|
}
|
|
|
|
if (!config.fromNumber && config.provider !== "mock") {
|
|
errors.push(
|
|
config.provider === "twilio"
|
|
? "plugins.entries.voice-call.config.fromNumber is required (or set TWILIO_FROM_NUMBER env)"
|
|
: "plugins.entries.voice-call.config.fromNumber is required",
|
|
);
|
|
}
|
|
|
|
if (config.provider === "telnyx") {
|
|
if (!config.telnyx?.apiKey) {
|
|
errors.push(
|
|
"plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)",
|
|
);
|
|
}
|
|
if (!config.telnyx?.connectionId) {
|
|
errors.push(
|
|
"plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)",
|
|
);
|
|
}
|
|
if (!config.skipSignatureVerification && !config.telnyx?.publicKey) {
|
|
errors.push(
|
|
"plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (config.provider === "twilio") {
|
|
if (!config.twilio?.accountSid) {
|
|
errors.push(
|
|
"plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)",
|
|
);
|
|
}
|
|
if (!hasConfiguredSecretInput(config.twilio?.authToken)) {
|
|
errors.push(
|
|
"plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (config.provider === "plivo") {
|
|
if (!config.plivo?.authId) {
|
|
errors.push(
|
|
"plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)",
|
|
);
|
|
}
|
|
if (!config.plivo?.authToken) {
|
|
errors.push(
|
|
"plugins.entries.voice-call.config.plivo.authToken is required (or set PLIVO_AUTH_TOKEN env)",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (config.realtime.enabled && config.inboundPolicy === "disabled") {
|
|
errors.push(
|
|
'plugins.entries.voice-call.config.inboundPolicy must not be "disabled" when realtime.enabled is true',
|
|
);
|
|
}
|
|
|
|
if (config.realtime.enabled && config.streaming.enabled) {
|
|
errors.push(
|
|
"plugins.entries.voice-call.config.realtime.enabled and plugins.entries.voice-call.config.streaming.enabled cannot both be true",
|
|
);
|
|
}
|
|
|
|
if (config.streaming.enabled && config.provider && config.provider !== "twilio") {
|
|
errors.push(
|
|
'plugins.entries.voice-call.config.provider must be "twilio" when streaming.enabled is true',
|
|
);
|
|
}
|
|
|
|
if (
|
|
config.realtime.enabled &&
|
|
config.provider &&
|
|
config.provider !== "twilio" &&
|
|
config.provider !== "telnyx" &&
|
|
config.provider !== "mock"
|
|
) {
|
|
errors.push(
|
|
'plugins.entries.voice-call.config.provider must be "twilio", "telnyx", or "mock" when realtime.enabled is true',
|
|
);
|
|
}
|
|
|
|
return { valid: errors.length === 0, errors };
|
|
}
|