feat(storage): persist siprouter data in smartdata and smartbucket
This commit is contained in:
+178
-81
@@ -1,13 +1,10 @@
|
||||
/**
|
||||
* Application configuration — loaded from .nogit/config.json.
|
||||
* Application configuration models and normalization helpers.
|
||||
*
|
||||
* All network addresses, credentials, provider settings, device definitions,
|
||||
* and routing rules come from this single config file. No hardcoded values
|
||||
* in source.
|
||||
* and routing rules are persisted through SmartData.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { IFaxBoxConfig } from './faxbox.ts';
|
||||
import type { IVoiceboxConfig } from './voicebox.js';
|
||||
|
||||
@@ -266,97 +263,197 @@ export interface IAppConfig {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loader
|
||||
// Defaults and normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
|
||||
function requiredInitialEnv(keyArg: string): string {
|
||||
const value = process.env[keyArg];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required initial config environment variable: ${keyArg}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function loadConfig(): IAppConfig {
|
||||
let raw: string;
|
||||
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 {
|
||||
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`);
|
||||
// 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');
|
||||
}
|
||||
p.displayName ??= p.id;
|
||||
p.registerIntervalSec ??= 300;
|
||||
p.codecs ??= [9, 0, 8, 101];
|
||||
p.quirks ??= { earlyMediaSilence: false };
|
||||
}
|
||||
cfg.proxy.webUiPort ??= 3060;
|
||||
cfg.proxy.publicIpSeed ??= null;
|
||||
|
||||
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`);
|
||||
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 };
|
||||
}
|
||||
d.displayName ??= d.id;
|
||||
d.extension ??= '100';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
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: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
cfg.routing ??= { routes: [] };
|
||||
cfg.routing.routes ??= [];
|
||||
export function maskConfig(configArg: IAppConfig): IAppConfig {
|
||||
return {
|
||||
...configArg,
|
||||
providers: configArg.providers?.map((providerArg) => ({
|
||||
...providerArg,
|
||||
password: providerArg.password ? '••••••' : providerArg.password,
|
||||
})) || [],
|
||||
};
|
||||
}
|
||||
|
||||
cfg.contacts ??= [];
|
||||
for (const c of cfg.contacts) {
|
||||
c.starred ??= false;
|
||||
}
|
||||
export function applyConfigUpdates(configArg: IAppConfig, updatesArg: any): IAppConfig {
|
||||
const cfg = JSON.parse(JSON.stringify(configArg)) as IAppConfig;
|
||||
|
||||
cfg.faxboxes ??= [];
|
||||
for (const fb of cfg.faxboxes) {
|
||||
fb.enabled ??= true;
|
||||
fb.maxMessages ??= 50;
|
||||
}
|
||||
|
||||
// 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 ??= [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cfg;
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user