332 lines
9.4 KiB
TypeScript
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.
|