diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 760726faa..60076bbe2 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -1,8 +1,8 @@ import { Type } from "@sinclair/typebox"; - import type { CoreConfig } from "./src/core-bridge.js"; import { VoiceCallConfigSchema, + resolveVoiceCallConfig, validateProviderConfig, type VoiceCallConfig, } from "./src/config.js"; @@ -145,8 +145,10 @@ const voiceCallPlugin = { description: "Voice-call plugin with Telnyx/Twilio/Plivo providers", configSchema: voiceCallConfigSchema, register(api) { - const cfg = voiceCallConfigSchema.parse(api.pluginConfig); - const validation = validateProviderConfig(cfg); + const config = resolveVoiceCallConfig( + voiceCallConfigSchema.parse(api.pluginConfig), + ); + const validation = validateProviderConfig(config); if (api.pluginConfig && typeof api.pluginConfig === "object") { const raw = api.pluginConfig as Record; @@ -167,7 +169,7 @@ const voiceCallPlugin = { let runtime: VoiceCallRuntime | null = null; const ensureRuntime = async () => { - if (!cfg.enabled) { + if (!config.enabled) { throw new Error("Voice call disabled in plugin config"); } if (!validation.valid) { @@ -176,7 +178,7 @@ const voiceCallPlugin = { if (runtime) return runtime; if (!runtimePromise) { runtimePromise = createVoiceCallRuntime({ - config: cfg, + config, coreConfig: api.config as CoreConfig, ttsRuntime: api.runtime.tts, logger: api.logger, @@ -457,7 +459,7 @@ const voiceCallPlugin = { ({ program }) => registerVoiceCallCli({ program, - config: cfg, + config, ensureRuntime, logger: api.logger, }), @@ -467,7 +469,7 @@ const voiceCallPlugin = { api.registerService({ id: "voicecall", start: async () => { - if (!cfg.enabled) return; + if (!config.enabled) return; try { await ensureRuntime(); } catch (err) { diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts new file mode 100644 index 000000000..7334498e2 --- /dev/null +++ b/extensions/voice-call/src/config.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { validateProviderConfig, resolveVoiceCallConfig, type VoiceCallConfig } from "./config.js"; + +function createBaseConfig( + provider: "telnyx" | "twilio" | "plivo" | "mock", +): VoiceCallConfig { + return { + enabled: true, + provider, + fromNumber: "+15550001234", + inboundPolicy: "disabled", + allowFrom: [], + outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 }, + maxDurationSeconds: 300, + silenceTimeoutMs: 800, + transcriptTimeoutMs: 180000, + ringTimeoutMs: 30000, + maxConcurrentCalls: 1, + serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" }, + tailscale: { mode: "off", path: "/voice/webhook" }, + tunnel: { provider: "none", allowNgrokFreeTier: true }, + streaming: { + enabled: false, + sttProvider: "openai-realtime", + sttModel: "gpt-4o-transcribe", + silenceDurationMs: 800, + vadThreshold: 0.5, + streamPath: "/voice/stream", + }, + skipSignatureVerification: false, + stt: { provider: "openai", model: "whisper-1" }, + tts: { provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" }, + responseModel: "openai/gpt-4o-mini", + responseTimeoutMs: 30000, + }; +} + +describe("validateProviderConfig", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clear all relevant env vars before each test + delete process.env.TWILIO_ACCOUNT_SID; + delete process.env.TWILIO_AUTH_TOKEN; + delete process.env.TELNYX_API_KEY; + delete process.env.TELNYX_CONNECTION_ID; + delete process.env.PLIVO_AUTH_ID; + delete process.env.PLIVO_AUTH_TOKEN; + }); + + afterEach(() => { + // Restore original env + process.env = { ...originalEnv }; + }); + + describe("twilio provider", () => { + it("passes validation when credentials are in config", () => { + const config = createBaseConfig("twilio"); + config.twilio = { accountSid: "AC123", authToken: "secret" }; + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("passes validation when credentials are in environment variables", () => { + process.env.TWILIO_ACCOUNT_SID = "AC123"; + process.env.TWILIO_AUTH_TOKEN = "secret"; + let config = createBaseConfig("twilio"); + config = resolveVoiceCallConfig(config); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("passes validation with mixed config and env vars", () => { + process.env.TWILIO_AUTH_TOKEN = "secret"; + let config = createBaseConfig("twilio"); + config.twilio = { accountSid: "AC123" }; + config = resolveVoiceCallConfig(config); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("fails validation when accountSid is missing everywhere", () => { + process.env.TWILIO_AUTH_TOKEN = "secret"; + let config = createBaseConfig("twilio"); + config = resolveVoiceCallConfig(config); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(false); + expect(result.errors).toContain( + "plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)", + ); + }); + + it("fails validation when authToken is missing everywhere", () => { + process.env.TWILIO_ACCOUNT_SID = "AC123"; + let config = createBaseConfig("twilio"); + config = resolveVoiceCallConfig(config); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(false); + expect(result.errors).toContain( + "plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)", + ); + }); + }); + + describe("telnyx provider", () => { + it("passes validation when credentials are in config", () => { + const config = createBaseConfig("telnyx"); + config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" }; + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("passes validation when credentials are in environment variables", () => { + process.env.TELNYX_API_KEY = "KEY123"; + process.env.TELNYX_CONNECTION_ID = "CONN456"; + let config = createBaseConfig("telnyx"); + config = resolveVoiceCallConfig(config); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("fails validation when apiKey is missing everywhere", () => { + process.env.TELNYX_CONNECTION_ID = "CONN456"; + let config = createBaseConfig("telnyx"); + config = resolveVoiceCallConfig(config); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(false); + expect(result.errors).toContain( + "plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)", + ); + }); + }); + + describe("plivo provider", () => { + it("passes validation when credentials are in config", () => { + const config = createBaseConfig("plivo"); + config.plivo = { authId: "MA123", authToken: "secret" }; + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("passes validation when credentials are in environment variables", () => { + process.env.PLIVO_AUTH_ID = "MA123"; + process.env.PLIVO_AUTH_TOKEN = "secret"; + let config = createBaseConfig("plivo"); + config = resolveVoiceCallConfig(config); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("fails validation when authId is missing everywhere", () => { + process.env.PLIVO_AUTH_TOKEN = "secret"; + let config = createBaseConfig("plivo"); + config = resolveVoiceCallConfig(config); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(false); + expect(result.errors).toContain( + "plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)", + ); + }); + }); + + describe("disabled config", () => { + it("skips validation when enabled is false", () => { + const config = createBaseConfig("twilio"); + config.enabled = false; + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + }); +}); diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 48f4691fe..6d6036792 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -381,6 +381,52 @@ export type VoiceCallConfig = z.infer; // Configuration Helpers // ----------------------------------------------------------------------------- +/** + * Resolves the configuration by merging environment variables into missing fields. + * Returns a new configuration object with environment variables applied. + */ +export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig { + const resolved = JSON.parse(JSON.stringify(config)) as VoiceCallConfig; + + // Telnyx + if (resolved.provider === "telnyx") { + resolved.telnyx = resolved.telnyx ?? {}; + resolved.telnyx.apiKey = + resolved.telnyx.apiKey ?? process.env.TELNYX_API_KEY; + resolved.telnyx.connectionId = + resolved.telnyx.connectionId ?? process.env.TELNYX_CONNECTION_ID; + resolved.telnyx.publicKey = + resolved.telnyx.publicKey ?? process.env.TELNYX_PUBLIC_KEY; + } + + // Twilio + if (resolved.provider === "twilio") { + resolved.twilio = resolved.twilio ?? {}; + resolved.twilio.accountSid = + resolved.twilio.accountSid ?? process.env.TWILIO_ACCOUNT_SID; + resolved.twilio.authToken = + resolved.twilio.authToken ?? process.env.TWILIO_AUTH_TOKEN; + } + + // Plivo + if (resolved.provider === "plivo") { + resolved.plivo = resolved.plivo ?? {}; + resolved.plivo.authId = + resolved.plivo.authId ?? process.env.PLIVO_AUTH_ID; + resolved.plivo.authToken = + resolved.plivo.authToken ?? process.env.PLIVO_AUTH_TOKEN; + } + + // Tunnel Config + resolved.tunnel = resolved.tunnel ?? { provider: "none", allowNgrokFreeTier: true }; + resolved.tunnel.ngrokAuthToken = + resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN; + resolved.tunnel.ngrokDomain = + resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN; + + return resolved; +} + /** * Validate that the configuration has all required fields for the selected provider. */ diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index 0770333cd..a2eb15315 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -1,6 +1,6 @@ import type { CoreConfig } from "./core-bridge.js"; import type { VoiceCallConfig } from "./config.js"; -import { validateProviderConfig } from "./config.js"; +import { resolveVoiceCallConfig, validateProviderConfig } from "./config.js"; import { CallManager } from "./manager.js"; import type { VoiceCallProvider } from "./providers/base.js"; import { MockProvider } from "./providers/mock.js"; @@ -37,17 +37,15 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider { switch (config.provider) { case "telnyx": return new TelnyxProvider({ - apiKey: config.telnyx?.apiKey ?? process.env.TELNYX_API_KEY, - connectionId: - config.telnyx?.connectionId ?? process.env.TELNYX_CONNECTION_ID, - publicKey: config.telnyx?.publicKey ?? process.env.TELNYX_PUBLIC_KEY, + apiKey: config.telnyx?.apiKey, + connectionId: config.telnyx?.connectionId, + publicKey: config.telnyx?.publicKey, }); case "twilio": return new TwilioProvider( { - accountSid: - config.twilio?.accountSid ?? process.env.TWILIO_ACCOUNT_SID, - authToken: config.twilio?.authToken ?? process.env.TWILIO_AUTH_TOKEN, + accountSid: config.twilio?.accountSid, + authToken: config.twilio?.authToken, }, { allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? true, @@ -61,8 +59,8 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider { case "plivo": return new PlivoProvider( { - authId: config.plivo?.authId ?? process.env.PLIVO_AUTH_ID, - authToken: config.plivo?.authToken ?? process.env.PLIVO_AUTH_TOKEN, + authId: config.plivo?.authId, + authToken: config.plivo?.authToken, }, { publicUrl: config.publicUrl, @@ -85,7 +83,7 @@ export async function createVoiceCallRuntime(params: { ttsRuntime?: TelephonyTtsRuntime; logger?: Logger; }): Promise { - const { config, coreConfig, ttsRuntime, logger } = params; + const { config: rawConfig, coreConfig, ttsRuntime, logger } = params; const log = logger ?? { info: console.log, warn: console.warn, @@ -93,6 +91,8 @@ export async function createVoiceCallRuntime(params: { debug: console.debug, }; + const config = resolveVoiceCallConfig(rawConfig); + if (!config.enabled) { throw new Error( "Voice call disabled. Enable the plugin entry in config.", @@ -125,9 +125,8 @@ export async function createVoiceCallRuntime(params: { provider: config.tunnel.provider, port: config.serve.port, path: config.serve.path, - ngrokAuthToken: - config.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN, - ngrokDomain: config.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN, + ngrokAuthToken: config.tunnel.ngrokAuthToken, + ngrokDomain: config.tunnel.ngrokDomain, }); publicUrl = tunnelResult?.publicUrl ?? null; } catch (err) {