/** * Application configuration — loaded from .nogit/config.json. * * All network addresses, credentials, provider settings, device definitions, * and routing rules come from this single config file. No hardcoded values * in source. */ import fs from 'node:fs'; import path from 'node:path'; // --------------------------------------------------------------------------- // 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; } // --------------------------------------------------------------------------- // 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 dialed/called number (To/Request-URI for inbound DID, dialed digits for outbound). * Supports: exact string, 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) --- /** Route directly to a voicemail box (skip ringing devices). */ voicemailBox?: string; /** Route to an IVR menu by menu ID (skip ringing devices). */ ivrMenuId?: string; /** Override no-answer timeout (seconds) before routing to voicemail. */ 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 // --------------------------------------------------------------------------- export interface IVoiceboxConfig { /** Unique ID — typically matches device ID or extension. */ id: string; /** Whether this voicebox is active. */ enabled: boolean; /** Custom TTS greeting text. */ greetingText?: string; /** TTS voice ID (default 'af_bella'). */ greetingVoice?: string; /** Path to uploaded WAV greeting (overrides TTS). */ greetingWavPath?: string; /** Seconds to wait before routing to voicemail (default 25). */ noAnswerTimeoutSec?: number; /** Maximum recording duration in seconds (default 120). */ maxRecordingSec?: number; /** Maximum stored messages per box (default 50). */ maxMessages?: number; } // --------------------------------------------------------------------------- // 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[]; routing: IRoutingConfig; contacts: IContact[]; voiceboxes?: IVoiceboxConfig[]; ivr?: IIvrConfig; } // --------------------------------------------------------------------------- // Loader // --------------------------------------------------------------------------- const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json'); export function loadConfig(): IAppConfig { let raw: string; try { raw = fs.readFileSync(CONFIG_PATH, 'utf8'); } catch { throw new Error(`config not found at ${CONFIG_PATH} — create .nogit/config.json`); } const cfg = JSON.parse(raw) as IAppConfig; // 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.routing ??= { routes: [] }; cfg.routing.routes ??= []; cfg.contacts ??= []; for (const c of cfg.contacts) { c.starred ??= false; } // Voicebox defaults. cfg.voiceboxes ??= []; for (const vb of cfg.voiceboxes) { vb.enabled ??= true; vb.noAnswerTimeoutSec ??= 25; vb.maxRecordingSec ??= 120; vb.maxMessages ??= 50; vb.greetingVoice ??= 'af_bella'; } // IVR defaults. 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; } // Route resolution, pattern matching, and provider/device lookup // are now handled entirely by the Rust proxy-engine.