Files
siprouter/ts/config.ts

332 lines
9.4 KiB
TypeScript

/**
* 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.