fix: allow environment variables in voice call config validation
Fixes #1709
This commit is contained in:
@@ -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<string, unknown>;
|
||||
@@ -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) {
|
||||
|
||||
204
extensions/voice-call/src/config.test.ts
Normal file
204
extensions/voice-call/src/config.test.ts
Normal file
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -381,6 +381,52 @@ export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;
|
||||
// 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.
|
||||
*/
|
||||
|
||||
@@ -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<VoiceCallRuntime> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user