Files
siprouter/ts/config.ts
T

461 lines
14 KiB
TypeScript

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