/** * Application configuration models and normalization helpers. * * All network addresses, credentials, provider settings, device definitions, * and routing rules are persisted through SmartData. */ import type { IFaxBoxConfig } from './faxbox.ts'; import type { IVoiceboxConfig } from './voicebox.js'; // --------------------------------------------------------------------------- // Shared types (previously in ts/sip/types.ts, now inlined) // --------------------------------------------------------------------------- export interface IEndpoint { address: string; port: number; } // --------------------------------------------------------------------------- // Config interfaces // --------------------------------------------------------------------------- export interface IQuirks { earlyMediaSilence: boolean; silencePayloadType?: number; silenceMaxPackets?: number; } export interface IProviderConfig { id: string; displayName: string; domain: string; outboundProxy: IEndpoint; username: string; password: string; registerIntervalSec: number; codecs: number[]; quirks: IQuirks; } export interface IDeviceConfig { id: string; displayName: string; expectedAddress: string; extension: string; } export type TIncomingNumberMode = 'single' | 'range' | 'regex'; export interface IIncomingNumberConfig { id: string; label: string; providerId?: string; mode: TIncomingNumberMode; countryCode?: string; areaCode?: string; localNumber?: string; rangeEnd?: string; pattern?: string; // Legacy persisted fields kept for migration compatibility. number?: string; rangeStart?: string; } // --------------------------------------------------------------------------- // Match/Action routing model // --------------------------------------------------------------------------- /** * Criteria for matching a call to a route. * All specified fields must match (AND logic). * Omitted fields are wildcards (match anything). */ export interface ISipRouteMatch { /** Whether this route applies to inbound or outbound calls. */ direction: 'inbound' | 'outbound'; /** * Match the normalized called number. * * Inbound: matches the provider-delivered DID / Request-URI user part. * Outbound: matches the normalized dialed digits. * Supports: exact string, numeric range `start..end`, prefix with trailing '*' (e.g. "+4930*"), or regex ("/^\\+49/"). */ numberPattern?: string; /** Match caller ID / From number (same pattern syntax). */ callerPattern?: string; /** For inbound: match by source provider ID. */ sourceProvider?: string; /** For outbound: match by source device ID (or 'browser' for browser devices). */ sourceDevice?: string; } /** What to do when a route matches. */ export interface ISipRouteAction { // --- Inbound actions (ring targets) --- /** Device IDs to ring. Empty/omitted = ring all devices. */ targets?: string[]; /** Also ring connected browser clients. Default false. */ ringBrowsers?: boolean; // --- Inbound actions (IVR / voicemail) --- /** Voicemail fallback for matched inbound routes. */ voicemailBox?: string; /** Fax inbox target for matched inbound routes. */ faxBox?: string; /** Route to an IVR menu by menu ID (skip ringing devices). */ ivrMenuId?: string; /** Reserved for future no-answer handling. */ noAnswerTimeout?: number; // --- Outbound actions (provider selection) --- /** Provider ID to use for outbound. */ provider?: string; /** Failover provider IDs, tried in order if primary is unregistered. */ failoverProviders?: string[]; // --- Number transformation (outbound) --- /** Strip this prefix from the dialed number before sending to provider. */ stripPrefix?: string; /** Prepend this prefix to the dialed number before sending to provider. */ prependPrefix?: string; } /** A single routing rule — the core unit of the match/action model. */ export interface ISipRoute { /** Unique identifier for this route. */ id: string; /** Human-readable name shown in the UI. */ name: string; /** Higher priority routes are evaluated first. Default 0. */ priority: number; /** Disabled routes are skipped during evaluation. Default true. */ enabled: boolean; /** Criteria that a call must match for this route to apply. */ match: ISipRouteMatch; /** What to do when the route matches. */ action: ISipRouteAction; } export interface IRoutingConfig { routes: ISipRoute[]; } export interface IProxyConfig { lanIp: string; lanPort: number; publicIpSeed: string | null; rtpPortRange: { min: number; max: number }; webUiPort: number; } export interface IContact { id: string; name: string; number: string; company?: string; notes?: string; starred?: boolean; } // --------------------------------------------------------------------------- // Voicebox configuration // --------------------------------------------------------------------------- // Canonical definition lives in voicebox.ts (imported at the top of this // file) — re-exported here so consumers can import everything from a // single config module without pulling in the voicebox implementation. // This used to be a duplicated interface and caused // "number | undefined is not assignable to number" type errors when // passing config.voiceboxes into VoiceboxManager.init(). export type { IVoiceboxConfig }; export type { IFaxBoxConfig }; // --------------------------------------------------------------------------- // IVR configuration // --------------------------------------------------------------------------- /** An action triggered by a digit press in an IVR menu. */ export type TIvrAction = | { type: 'route-extension'; extensionId: string } | { type: 'route-voicemail'; boxId: string } | { type: 'submenu'; menuId: string } | { type: 'play-message'; promptId: string } | { type: 'transfer'; number: string; providerId?: string } | { type: 'repeat' } | { type: 'hangup' }; /** A single digit→action mapping in an IVR menu. */ export interface IIvrMenuEntry { /** Digit: '0'-'9', '*', '#'. */ digit: string; /** Action to take when this digit is pressed. */ action: TIvrAction; } /** An IVR menu with a prompt and digit mappings. */ export interface IIvrMenu { /** Unique menu ID. */ id: string; /** Human-readable name. */ name: string; /** TTS text for the menu prompt. */ promptText: string; /** TTS voice ID for the prompt. */ promptVoice?: string; /** Digit→action entries. */ entries: IIvrMenuEntry[]; /** Seconds to wait for a digit after prompt finishes (default 5). */ timeoutSec?: number; /** Maximum retries before executing timeout action (default 3). */ maxRetries?: number; /** Action on timeout (no digit pressed). */ timeoutAction: TIvrAction; /** Action on invalid digit. */ invalidAction: TIvrAction; } /** Top-level IVR configuration. */ export interface IIvrConfig { /** Whether the IVR system is active. */ enabled: boolean; /** IVR menu definitions. */ menus: IIvrMenu[]; /** The menu to start with for incoming calls. */ entryMenuId: string; } // --------------------------------------------------------------------------- // App config // --------------------------------------------------------------------------- export interface IAppConfig { proxy: IProxyConfig; providers: IProviderConfig[]; devices: IDeviceConfig[]; incomingNumbers?: IIncomingNumberConfig[]; routing: IRoutingConfig; contacts: IContact[]; faxboxes?: IFaxBoxConfig[]; voiceboxes?: IVoiceboxConfig[]; ivr?: IIvrConfig; } // --------------------------------------------------------------------------- // Defaults and normalization // --------------------------------------------------------------------------- function requiredInitialEnv(keyArg: string): string { const value = process.env[keyArg]; if (!value) { throw new Error(`Missing required initial config environment variable: ${keyArg}`); } return value; } function numberFromEnv(keyArg: string, fallbackArg: number): number { const value = process.env[keyArg]; if (!value) return fallbackArg; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallbackArg; } export function normalizeConfig(cfg: IAppConfig): IAppConfig { try { // Basic validation. if (!cfg.proxy) throw new Error('config: missing "proxy" section'); if (!cfg.proxy.lanIp) throw new Error('config: missing proxy.lanIp'); if (!cfg.proxy.lanPort) throw new Error('config: missing proxy.lanPort'); if (!cfg.proxy.rtpPortRange?.min || !cfg.proxy.rtpPortRange?.max) { throw new Error('config: missing proxy.rtpPortRange.min/max'); } cfg.proxy.webUiPort ??= 3060; cfg.proxy.publicIpSeed ??= null; cfg.providers ??= []; for (const p of cfg.providers) { if (!p.id || !p.domain || !p.outboundProxy || !p.username || !p.password) { throw new Error(`config: provider "${p.id || '?'}" missing required fields`); } p.displayName ??= p.id; p.registerIntervalSec ??= 300; p.codecs ??= [9, 0, 8, 101]; p.quirks ??= { earlyMediaSilence: false }; } if (!Array.isArray(cfg.devices) || !cfg.devices.length) { throw new Error('config: need at least one device'); } for (const d of cfg.devices) { if (!d.id || !d.expectedAddress) { throw new Error(`config: device "${d.id || '?'}" missing required fields`); } d.displayName ??= d.id; d.extension ??= '100'; } cfg.incomingNumbers ??= []; for (const incoming of cfg.incomingNumbers) { if (!incoming.id) incoming.id = `incoming-${Date.now()}`; incoming.label ??= incoming.id; incoming.mode ??= incoming.pattern ? 'regex' : incoming.rangeStart || incoming.rangeEnd ? 'range' : 'single'; incoming.countryCode ??= incoming.mode === 'regex' ? undefined : '+49'; } cfg.routing ??= { routes: [] }; cfg.routing.routes ??= []; cfg.contacts ??= []; for (const c of cfg.contacts) { c.starred ??= false; } cfg.faxboxes ??= []; for (const fb of cfg.faxboxes) { fb.enabled ??= true; fb.maxMessages ??= 50; } cfg.voiceboxes ??= []; for (const vb of cfg.voiceboxes) { vb.enabled ??= true; vb.noAnswerTimeoutSec ??= 25; vb.maxRecordingSec ??= 120; vb.maxMessages ??= 50; vb.greetingVoice ??= 'af_bella'; } if (cfg.ivr) { cfg.ivr.enabled ??= false; cfg.ivr.menus ??= []; for (const menu of cfg.ivr.menus) { menu.timeoutSec ??= 5; menu.maxRetries ??= 3; menu.entries ??= []; } } return cfg; } catch (error) { throw error; } } export function createInitialConfigFromEnv(): IAppConfig { return normalizeConfig({ proxy: { lanIp: requiredInitialEnv('SIPROUTER_LAN_IP'), lanPort: numberFromEnv('SIPROUTER_LAN_PORT', 5070), publicIpSeed: process.env.SIPROUTER_PUBLIC_IP || null, rtpPortRange: { min: numberFromEnv('SIPROUTER_RTP_PORT_MIN', 20000), max: numberFromEnv('SIPROUTER_RTP_PORT_MAX', 20200), }, webUiPort: numberFromEnv('SIPROUTER_WEB_UI_PORT', 3060), }, providers: [], devices: [ { id: process.env.SIPROUTER_INITIAL_DEVICE_ID || 'desk-phone', displayName: process.env.SIPROUTER_INITIAL_DEVICE_DISPLAY_NAME || 'Desk Phone', expectedAddress: requiredInitialEnv('SIPROUTER_INITIAL_DEVICE_ADDRESS'), extension: process.env.SIPROUTER_INITIAL_DEVICE_EXTENSION || '100', }, ], incomingNumbers: [], routing: { routes: [] }, contacts: [], faxboxes: [], voiceboxes: [], ivr: { enabled: false, entryMenuId: 'main-menu', menus: [], }, }); } export function maskConfig(configArg: IAppConfig): IAppConfig { return { ...configArg, providers: configArg.providers?.map((providerArg) => ({ ...providerArg, password: providerArg.password ? '••••••' : providerArg.password, })) || [], }; } export function applyConfigUpdates(configArg: IAppConfig, updatesArg: any): IAppConfig { const cfg = JSON.parse(JSON.stringify(configArg)) as IAppConfig; if (updatesArg.providers) { for (const up of updatesArg.providers) { const existing = cfg.providers?.find((p: any) => p.id === up.id); if (existing) { if (up.displayName !== undefined) existing.displayName = up.displayName; if (up.password && up.password !== '••••••') existing.password = up.password; if (up.domain !== undefined) existing.domain = up.domain; if (up.outboundProxy !== undefined) existing.outboundProxy = up.outboundProxy; if (up.username !== undefined) existing.username = up.username; if (up.registerIntervalSec !== undefined) existing.registerIntervalSec = up.registerIntervalSec; if (up.codecs !== undefined) existing.codecs = up.codecs; if (up.quirks !== undefined) existing.quirks = up.quirks; } } } if (updatesArg.addProvider) { cfg.providers ??= []; cfg.providers.push(updatesArg.addProvider); } if (updatesArg.removeProvider) { cfg.providers = (cfg.providers || []).filter((p: any) => p.id !== updatesArg.removeProvider); if (cfg.routing?.routes) { cfg.routing.routes = cfg.routing.routes.filter((r: any) => r.match?.sourceProvider !== updatesArg.removeProvider && r.action?.provider !== updatesArg.removeProvider ); } } if (updatesArg.devices) { for (const ud of updatesArg.devices) { const existing = cfg.devices?.find((d: any) => d.id === ud.id); if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName; } } if (updatesArg.incomingNumbers !== undefined) cfg.incomingNumbers = updatesArg.incomingNumbers; if (updatesArg.routing?.routes) cfg.routing.routes = updatesArg.routing.routes; if (updatesArg.contacts !== undefined) cfg.contacts = updatesArg.contacts; if (updatesArg.faxboxes !== undefined) cfg.faxboxes = updatesArg.faxboxes; if (updatesArg.voiceboxes !== undefined) cfg.voiceboxes = updatesArg.voiceboxes; if (updatesArg.ivr !== undefined) cfg.ivr = updatesArg.ivr; return normalizeConfig(cfg); } // Route resolution, pattern matching, and provider/device lookup // are now handled entirely by the Rust proxy-engine.