Files
selfphoneng/apps/api/src/generator/index.ts
T
iamdoubz b05361e840
ci / check (push) Successful in 1m2s
ci / images (push) Successful in 22s
contacts: caller names on desk phones while they ring (C4)
Spec 11 §6.1: sp-sub-dial-user asks /api/internal/caller-name (func_curl, 2 s at most; this
machine only, with an APP_SECRET-derived key, not logged) for the ringing user's name for the
caller, and sets it on each ringing leg (pre-dial handler, CONNECTEDLINE), never on the call, so
forwards and other members don't see someone else's contacts. Ring group/queue/number prefixes
kept. Contrack lookups never wait (cache, contactsWarm every 5 min). Proven on real Asterisk
with baresip: apps/api/test/asterisk/cnam-test.ts.
2026-10-23 15:45:36 -05:00

295 lines
11 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// DB → Asterisk config (03). render() is pure; apply() writes changed files and reloads Asterisk.
import { createHash } from 'node:crypto';
import { copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { FastifyBaseLogger } from 'fastify';
import type { AmiClient } from '../ami/client.ts';
import type { Db } from '../db/client.ts';
import { eq } from 'drizzle-orm';
import { generatorRuns, settings } from '../db/schema.ts';
import { getSetting, setSetting } from '../services/settings.ts';
import { resendPauses } from '../services/queues.ts';
import { loadSnapshot, type Snapshot } from './load.ts';
import { renderPjsip, renderRtp, renderTransports } from './pjsip.ts';
import { STATIC_DIALPLAN } from './dialplan/static.ts';
import { renderGlobals, renderIvrDirect, renderLocalNumbers } from './dialplan/local.ts';
import { renderDids, renderTrunkEntries } from './dialplan/inbound.ts';
import { renderOutbound } from './dialplan/outbound.ts';
import { renderEmergency } from './dialplan/emergency.ts';
import { renderRingGroups } from './dialplan/ringgroups.ts';
import { renderQueueDialplan } from './dialplan/queues.ts';
import { renderQueues } from './queues.ts';
import { renderMoh } from './moh.ts';
import { renderConfbridge, renderConferences } from './dialplan/conferences.ts';
import { renderPaging } from './dialplan/paging.ts';
import { renderIvrs } from './dialplan/ivrs.ts';
import { renderTimeConditions } from './dialplan/timeconditions.ts';
import { renderAnnouncements } from './dialplan/announcements.ts';
import { renderVoicemail } from './voicemail.ts';
export { renderTransports };
export const HEADER = '; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.\n';
/** Generated file → the AMI Command that makes Asterisk load it (03 §3). Order = reload order. */
export const RELOADS = {
'pjsip_selfphoneng.conf': 'module reload res_pjsip.so',
'rtp_selfphoneng.conf': 'module reload res_rtp_asterisk.so',
'extensions_globals_selfphoneng.conf': 'dialplan reload',
'extensions_selfphoneng.conf': 'dialplan reload',
'voicemail_selfphoneng.conf': 'voicemail reload',
'queues_selfphoneng.conf': 'queue reload all',
'musiconhold_selfphoneng.conf': 'moh reload',
'confbridge_selfphoneng.conf': 'module reload app_confbridge.so',
} as const;
export type GeneratedFileName = keyof typeof RELOADS;
export const GENERATED_FILES = Object.keys(RELOADS) as GeneratedFileName[];
export type RenderedFiles = Record<GeneratedFileName, string>;
// Each file's body; tasks fill these in (M2-T3 pjsip/rtp, M2-T4 dialplan, ...).
const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
'pjsip_selfphoneng.conf': renderPjsip,
'rtp_selfphoneng.conf': renderRtp,
'musiconhold_selfphoneng.conf': renderMoh,
'extensions_globals_selfphoneng.conf': renderGlobals,
'voicemail_selfphoneng.conf': renderVoicemail,
'queues_selfphoneng.conf': renderQueues,
'confbridge_selfphoneng.conf': renderConfbridge,
'extensions_selfphoneng.conf': (snap) =>
[
STATIC_DIALPLAN,
renderLocalNumbers(snap),
renderRingGroups(snap),
renderQueueDialplan(snap),
renderConferences(snap),
renderPaging(snap),
renderIvrs(snap),
renderIvrDirect(snap),
renderTimeConditions(snap),
renderAnnouncements(snap),
renderTrunkEntries(snap),
renderDids(snap),
renderOutbound(snap),
renderEmergency(snap),
]
.filter(Boolean)
.join('\n'),
};
/** PURE: no I/O, no clock, no randomness. Same snapshot → byte-identical files. */
export function render(snap: Snapshot): RenderedFiles {
return Object.fromEntries(
GENERATED_FILES.map((f) => [f, HEADER + (BODIES[f]?.(snap) ?? '')]),
) as RenderedFiles;
}
export type ApplyResult = {
ok: boolean;
error: string | null;
filesChanged: GeneratedFileName[];
commands: string[];
restartRequired: boolean;
};
export type GeneratorDeps = {
db: Db;
ami: Pick<AmiClient, 'isConnected' | 'command' | 'action'>;
/** /etc/asterisk/selfphoneng */
dir: string;
log: Pick<FastifyBaseLogger, 'info' | 'error'>;
onApplied?: (r: ApplyResult) => void;
debounceMs?: number;
/** RTP_PORT_START/END (config.ts). */
rtpPorts?: { start: number; end: number };
/** The caller-name lookup (11 §6 C4). */
callerNames?: { url: string; token: string };
};
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
export function createGenerator(deps: GeneratorDeps) {
const { db, ami, dir, log } = deps;
let chain: Promise<unknown> = Promise.resolve();
let timer: NodeJS.Timeout | undefined;
/** One at a time: each call waits for the previous one (process-wide mutex). */
function locked<T>(fn: () => Promise<T>): Promise<T> {
const run = chain.then(fn);
chain = run.catch(() => undefined);
return run;
}
async function writeAtomic(name: string, content: string) {
const path = join(dir, name);
await writeFile(`${path}.tmp`, content, { mode: 0o640 });
await rename(`${path}.tmp`, path);
}
async function sendReloads(
commands: string[],
): Promise<{ sent: string[]; error: string | null }> {
if (!ami.isConnected()) return { sent: [], error: 'ami-disconnected' };
const sent: string[] = [];
for (const c of commands) {
try {
await ami.command(c);
sent.push(c);
} catch (e) {
return { sent, error: `${c}: ${(e as Error).message}` };
}
}
return { sent, error: null };
}
async function record(trigger: string, r: ApplyResult, t0: number) {
await db.insert(generatorRuns).values({
trigger,
filesChanged: r.filesChanged,
commands: r.commands,
ok: r.ok,
error: r.error,
durationMs: Math.round(performance.now() - t0),
});
log[r.ok ? 'info' : 'error'](
{ trigger, filesChanged: r.filesChanged, commands: r.commands, error: r.error },
'generator apply',
);
deps.onApplied?.(r);
}
async function doApply(trigger: string, force: boolean): Promise<ApplyResult> {
const t0 = performance.now();
const r: ApplyResult = {
ok: true,
error: null,
filesChanged: [],
commands: [],
restartRequired: false,
};
try {
const snap = {
...(await loadSnapshot(db)),
rtpPorts: deps.rtpPorts,
callerNames: deps.callerNames,
};
const files = render(snap);
await mkdir(join(dir, '.prev'), { recursive: true });
const current = new Map<GeneratedFileName, string | null>();
for (const f of GENERATED_FILES) {
current.set(f, await readFile(join(dir, f), 'utf8').catch(() => null));
if (current.get(f) !== files[f]) r.filesChanged.push(f);
}
if (r.filesChanged.length === 0 && !force) {
await record(trigger, r, t0);
return r;
}
if (r.filesChanged.length) {
// Keep exactly one previous version for rollback.
for (const [f, content] of current)
if (content !== null) await writeFile(join(dir, '.prev', f), content, { mode: 0o640 });
for (const f of r.filesChanged) await writeAtomic(f, files[f]);
}
// PJSIP transports can't be reloaded safely (01 §4.3). The first apply ever (no stored hash)
// only adds transports to an Asterisk that booted with none, so a reload is fine then.
const hash = sha256(renderTransports(snap));
const prevHash = await getSetting(db, 'system.transportHash');
const transportsChanged = prevHash !== null && prevHash !== hash;
if (prevHash !== hash) await setSetting(db, 'system.transportHash', hash);
if (transportsChanged) await setSetting(db, 'system.restartRequired', true);
r.restartRequired = await getSetting(db, 'system.restartRequired');
const reloadFor = force ? GENERATED_FILES : r.filesChanged;
const commands = [...new Set(reloadFor.map((f) => RELOADS[f]))].filter(
(c) => !(transportsChanged && c === 'module reload res_pjsip.so'),
);
const { sent, error } = await sendReloads(commands);
r.commands = sent;
if (sent.includes(RELOADS['queues_selfphoneng.conf'])) await resendPauses(db, ami);
if (error) Object.assign(r, { ok: false, error });
} catch (e) {
Object.assign(r, { ok: false, error: (e as Error).message });
}
await record(trigger, r, t0);
return r;
}
return {
dir,
apply: (opts: { trigger: string; force?: boolean }) =>
locked(() => doApply(opts.trigger, opts.force ?? false)),
/** Debounced (1 s by default): many quick changes become one apply. */
requestApply(trigger: string) {
clearTimeout(timer);
timer = setTimeout(() => {
void locked(() => doApply(trigger, false));
}, deps.debounceMs ?? 1000);
},
/** POST /api/system/config/rollback: put .prev back and reload everything. */
rollback: () =>
locked(async () => {
const t0 = performance.now();
const r: ApplyResult = {
ok: true,
error: null,
filesChanged: [],
commands: [],
restartRequired: false,
};
for (const f of GENERATED_FILES) {
const prev = join(dir, '.prev', f);
const restored = await copyFile(prev, join(dir, `${f}.tmp`)).then(
() => true,
() => false,
);
if (restored) {
await rename(join(dir, `${f}.tmp`), join(dir, f));
r.filesChanged.push(f);
}
}
if (r.filesChanged.length === 0)
Object.assign(r, { ok: false, error: 'no previous version' });
else {
const { sent, error } = await sendReloads([...new Set(Object.values(RELOADS))]);
r.commands = sent;
if (sent.includes(RELOADS['queues_selfphoneng.conf'])) await resendPauses(db, ami);
if (error) Object.assign(r, { ok: false, error });
}
r.restartRequired = await getSetting(db, 'system.restartRequired');
await record('rollback', r, t0);
return r;
}),
/** Cancels a pending debounced apply (shutdown, tests). */
cancelPending: () => clearTimeout(timer),
};
}
export type Generator = ReturnType<typeof createGenerator>;
/**
* On an AMI (re)connect: if Asterisk started after the change that needed a restart, it has
* loaded the new transports, so the restart is no longer required (01 §4.3).
*/
export async function clearRestartIfRestarted(
db: Db,
ami: Pick<AmiClient, 'command'>,
now = Date.now(),
): Promise<boolean> {
if (!(await getSetting(db, 'system.restartRequired'))) return false;
const uptime = /System uptime:\s*(\d+)/.exec(await ami.command('core show uptime seconds'))?.[1];
const [row] = await db
.select({ at: settings.updatedAt })
.from(settings)
.where(eq(settings.key, 'system.restartRequired'));
if (uptime === undefined || !row) return false;
if (now - Number(uptime) * 1000 <= row.at.getTime()) return false;
await setSetting(db, 'system.restartRequired', false);
return true;
}