feat(storage): persist siprouter data in smartdata and smartbucket

This commit is contained in:
2026-05-21 23:35:50 +00:00
parent 04e706715f
commit 3e2fee16c1
14 changed files with 2018 additions and 492 deletions
+14
View File
@@ -19,5 +19,19 @@
"dockerregistry.lossless.digital": "serve.zone/siprouter" "dockerregistry.lossless.digital": "serve.zone/siprouter"
}, },
"platforms": ["linux/amd64", "linux/arm64"] "platforms": ["linux/amd64", "linux/arm64"]
},
"@git.zone/cli": {
"release": {
"targets": {
"git": {
"enabled": true,
"remote": "origin"
},
"docker": {
"enabled": true,
"engine": "tsdocker"
}
}
}
} }
} }
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## Pending
### Features
- persist siprouter config and media through SmartData and SmartBucket (storage)
- store runtime config, voicemail metadata, fax jobs, and fax inbox metadata in SmartData
- store voicemail audio, custom greetings, and fax payloads in SmartBucket while keeping local cache paths for Rust media access
- migrate legacy local voicemail and fax metadata/media into SmartData and SmartBucket on startup
- enable gitzone Docker release publishing through the configured tsdocker target
## 2026-04-20 - 1.26.0 - feat(fax) ## 2026-04-20 - 1.26.0 - feat(fax)
add fax routing, job tracking, inbox management, and T.38/UDPTL media support add fax routing, job tracking, inbox management, and T.38/UDPTL media support
+15 -2
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"bundle": "node node_modules/.pnpm/esbuild@0.27.7/node_modules/esbuild/bin/esbuild ts_web/index.ts --bundle --format=esm --outfile=dist_ts_web/bundle.js --platform=browser --target=es2022 --minify", "bundle": "esbuild ts_web/index.ts --bundle --format=esm --outfile=dist_ts_web/bundle.js --platform=browser --target=es2022 --minify",
"buildRust": "tsrust", "buildRust": "tsrust",
"build": "pnpm run buildRust && pnpm run bundle", "build": "pnpm run buildRust && pnpm run bundle",
"build:docker": "tsdocker build --verbose", "build:docker": "tsdocker build --verbose",
@@ -15,6 +15,8 @@
"dependencies": { "dependencies": {
"@design.estate/dees-catalog": "^3.81.0", "@design.estate/dees-catalog": "^3.81.0",
"@design.estate/dees-element": "^2.2.4", "@design.estate/dees-element": "^2.2.4",
"@push.rocks/smartbucket": "^4.6.1",
"@push.rocks/smartdata": "^7.1.7",
"@push.rocks/smartrust": "^1.4.0", "@push.rocks/smartrust": "^1.4.0",
"@push.rocks/smartstate": "^2.3.1", "@push.rocks/smartstate": "^2.3.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
@@ -25,6 +27,17 @@
"@git.zone/tsdocker": "^2.2.5", "@git.zone/tsdocker": "^2.2.5",
"@git.zone/tsrust": "^1.3.3", "@git.zone/tsrust": "^1.3.3",
"@git.zone/tswatch": "^3.3.3", "@git.zone/tswatch": "^3.3.3",
"@types/ws": "^8.18.1" "@types/node": "^25.8.0",
"@types/ws": "^8.18.1",
"esbuild": "^0.27.7"
},
"pnpm": {
"ignoredBuiltDependencies": [
"@design.estate/dees-catalog"
],
"onlyBuiltDependencies": [
"esbuild",
"mongodb-memory-server"
]
} }
} }
+1177
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
esbuild: true
mongodb-memory-server: true
ignoredBuiltDependencies:
- '@design.estate/dees-catalog'
+114 -17
View File
@@ -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, * All network addresses, credentials, provider settings, device definitions,
* and routing rules come from this single config file. No hardcoded values * and routing rules are persisted through SmartData.
* in source.
*/ */
import fs from 'node:fs';
import path from 'node:path';
import type { IFaxBoxConfig } from './faxbox.ts'; import type { IFaxBoxConfig } from './faxbox.ts';
import type { IVoiceboxConfig } from './voicebox.js'; import type { IVoiceboxConfig } from './voicebox.js';
@@ -266,21 +263,26 @@ 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];
export function loadConfig(): IAppConfig { if (!value) {
let raw: string; throw new Error(`Missing required initial config environment variable: ${keyArg}`);
try { }
raw = fs.readFileSync(CONFIG_PATH, 'utf8'); return value;
} catch {
throw new Error(`config not found at ${CONFIG_PATH} — create .nogit/config.json`);
} }
const cfg = JSON.parse(raw) as IAppConfig; 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. // Basic validation.
if (!cfg.proxy) throw new Error('config: missing "proxy" section'); if (!cfg.proxy) throw new Error('config: missing "proxy" section');
if (!cfg.proxy.lanIp) throw new Error('config: missing proxy.lanIp'); if (!cfg.proxy.lanIp) throw new Error('config: missing proxy.lanIp');
@@ -335,7 +337,6 @@ export function loadConfig(): IAppConfig {
fb.maxMessages ??= 50; fb.maxMessages ??= 50;
} }
// Voicebox defaults.
cfg.voiceboxes ??= []; cfg.voiceboxes ??= [];
for (const vb of cfg.voiceboxes) { for (const vb of cfg.voiceboxes) {
vb.enabled ??= true; vb.enabled ??= true;
@@ -345,7 +346,6 @@ export function loadConfig(): IAppConfig {
vb.greetingVoice ??= 'af_bella'; vb.greetingVoice ??= 'af_bella';
} }
// IVR defaults.
if (cfg.ivr) { if (cfg.ivr) {
cfg.ivr.enabled ??= false; cfg.ivr.enabled ??= false;
cfg.ivr.menus ??= []; cfg.ivr.menus ??= [];
@@ -357,6 +357,103 @@ export function loadConfig(): IAppConfig {
} }
return cfg; 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 // Route resolution, pattern matching, and provider/device lookup
+93 -45
View File
@@ -1,6 +1,9 @@
import fs from 'node:fs'; import fs from 'node:fs';
import * as fsPromises from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import type { SiprouterStorage } from './storage.ts';
export interface IFaxBoxConfig { export interface IFaxBoxConfig {
id: string; id: string;
enabled: boolean; enabled: boolean;
@@ -13,6 +16,7 @@ export interface IFaxMessage {
callerNumber?: string; callerNumber?: string;
timestamp: number; timestamp: number;
fileName: string; fileName: string;
objectKey?: string;
completionCode?: number | null; completionCode?: number | null;
completionLabel?: string | null; completionLabel?: string | null;
pageCount?: number; pageCount?: number;
@@ -21,24 +25,28 @@ export interface IFaxMessage {
export class FaxBoxManager { export class FaxBoxManager {
private boxes = new Map<string, IFaxBoxConfig>(); private boxes = new Map<string, IFaxBoxConfig>();
private messagesByBox = new Map<string, IFaxMessage[]>();
private readonly basePath: string; private readonly basePath: string;
private readonly log: (msg: string) => void; private readonly log: (msg: string) => void;
private readonly storage: SiprouterStorage;
constructor(log: (msg: string) => void) { constructor(log: (msg: string) => void, storageArg: SiprouterStorage) {
this.basePath = path.join(process.cwd(), '.nogit', 'fax', 'inboxes'); this.basePath = path.join(process.cwd(), '.nogit', 'fax', 'inboxes');
this.log = log; this.log = log;
this.storage = storageArg;
} }
init(faxBoxConfigs: IFaxBoxConfig[]): void { async init(faxBoxConfigs: IFaxBoxConfig[]): Promise<void> {
this.boxes.clear(); this.boxes.clear();
for (const cfg of faxBoxConfigs) { for (const cfg of faxBoxConfigs) {
cfg.enabled ??= true; cfg.enabled ??= true;
cfg.maxMessages ??= 50; cfg.maxMessages ??= 50;
this.boxes.set(cfg.id, cfg); this.boxes.set(cfg.id, cfg);
this.messagesByBox.set(cfg.id, await this.loadMessages(cfg.id));
} }
fs.mkdirSync(this.basePath, { recursive: true }); await fsPromises.mkdir(this.basePath, { recursive: true });
this.log(`[faxbox] initialized ${this.boxes.size} fax box(es)`); this.log(`[faxbox] initialized ${this.boxes.size} fax box(es)`);
} }
@@ -50,7 +58,13 @@ export class FaxBoxManager {
return path.join(this.basePath, boxId); return path.join(this.basePath, boxId);
} }
addMessage( async prepareOutboundFaxFile(filePathArg: string): Promise<string> {
const localPath = path.isAbsolute(filePathArg) ? filePathArg : path.join(process.cwd(), filePathArg);
await fsPromises.access(localPath);
return localPath;
}
async addMessage(
boxId: string, boxId: string,
info: { info: {
callerNumber?: string; callerNumber?: string;
@@ -60,90 +74,124 @@ export class FaxBoxManager {
pageCount?: number; pageCount?: number;
bitRate?: number; bitRate?: number;
}, },
): void { ): Promise<void> {
const id = crypto.randomUUID();
const localPath = path.isAbsolute(info.fileName) ? info.fileName : path.join(process.cwd(), info.fileName);
const objectKey = await this.storage.putFileObject(`fax/inboxes/${boxId}/${id}.tif`, localPath);
const msg: IFaxMessage = { const msg: IFaxMessage = {
id: crypto.randomUUID(), id,
boxId, boxId,
callerNumber: info.callerNumber, callerNumber: info.callerNumber,
timestamp: Date.now(), timestamp: Date.now(),
fileName: path.basename(info.fileName), fileName: path.basename(localPath),
objectKey,
completionCode: info.completionCode ?? null, completionCode: info.completionCode ?? null,
completionLabel: info.completionLabel ?? null, completionLabel: info.completionLabel ?? null,
pageCount: info.pageCount, pageCount: info.pageCount,
bitRate: info.bitRate, bitRate: info.bitRate,
}; };
this.saveMessage(msg);
}
saveMessage(msg: IFaxMessage): void { const messages = this.getMessages(boxId);
const boxDir = this.getBoxDir(msg.boxId);
fs.mkdirSync(boxDir, { recursive: true });
const messages = this.loadMessages(msg.boxId);
messages.unshift(msg); messages.unshift(msg);
await this.enforceLimit(boxId, messages);
const box = this.boxes.get(msg.boxId); await this.writeMessages(boxId, messages);
const maxMessages = box?.maxMessages ?? 50; await fsPromises.rm(localPath, { force: true }).catch(() => {});
while (messages.length > maxMessages) {
const old = messages.pop()!;
const oldPath = path.join(boxDir, old.fileName);
try {
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
} catch {}
}
this.writeMessages(msg.boxId, messages);
this.log(`[faxbox] saved fax ${msg.id} in box "${msg.boxId}" (${msg.fileName})`); this.log(`[faxbox] saved fax ${msg.id} in box "${msg.boxId}" (${msg.fileName})`);
} }
getMessages(boxId: string): IFaxMessage[] { getMessages(boxId: string): IFaxMessage[] {
return this.loadMessages(boxId); return [...(this.messagesByBox.get(boxId) || [])];
} }
getMessage(boxId: string, messageId: string): IFaxMessage | null { getMessage(boxId: string, messageId: string): IFaxMessage | null {
return this.loadMessages(boxId).find((m) => m.id === messageId) ?? null; const messages = this.messagesByBox.get(boxId) || [];
return messages.find((m) => m.id === messageId) ?? null;
} }
getMessageFilePath(boxId: string, messageId: string): string | null { async getMessageFilePath(boxId: string, messageId: string): Promise<string | null> {
const msg = this.getMessage(boxId, messageId); const msg = this.getMessage(boxId, messageId);
if (!msg) return null; if (!msg) return null;
if (msg.objectKey) {
return await this.storage.getObjectAsCachedFile(msg.objectKey, msg.fileName);
}
const filePath = path.join(this.getBoxDir(boxId), msg.fileName); const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
return fs.existsSync(filePath) ? filePath : null; return fs.existsSync(filePath) ? filePath : null;
} }
deleteMessage(boxId: string, messageId: string): boolean { async deleteMessage(boxId: string, messageId: string): Promise<boolean> {
const messages = this.loadMessages(boxId); const messages = this.messagesByBox.get(boxId) || [];
const idx = messages.findIndex((m) => m.id === messageId); const idx = messages.findIndex((m) => m.id === messageId);
if (idx === -1) return false; if (idx === -1) return false;
const msg = messages[idx]; const msg = messages[idx];
const filePath = path.join(this.getBoxDir(boxId), msg.fileName); await this.storage.removeObject(msg.objectKey);
try { if (!msg.objectKey) {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath); await fsPromises.rm(path.join(this.getBoxDir(boxId), msg.fileName), { force: true }).catch(() => {});
} catch {} }
messages.splice(idx, 1); messages.splice(idx, 1);
this.writeMessages(boxId, messages); await this.writeMessages(boxId, messages);
return true; return true;
} }
private messagesPath(boxId: string): string { private async enforceLimit(boxId: string, messages: IFaxMessage[]): Promise<void> {
return path.join(this.getBoxDir(boxId), 'messages.json'); const box = this.boxes.get(boxId);
const maxMessages = box?.maxMessages ?? 50;
while (messages.length > maxMessages) {
const old = messages.pop()!;
await this.storage.removeObject(old.objectKey);
if (!old.objectKey) {
await fsPromises.rm(path.join(this.getBoxDir(boxId), old.fileName), { force: true }).catch(() => {});
}
}
} }
private loadMessages(boxId: string): IFaxMessage[] { private async loadMessages(boxId: string): Promise<IFaxMessage[]> {
const filePath = this.messagesPath(boxId); const storedMessages = await this.storage.getFaxMessages(boxId);
if (storedMessages.length) return await this.ensureMessageObjects(boxId, storedMessages);
const filePath = path.join(this.getBoxDir(boxId), 'messages.json');
try { try {
if (!fs.existsSync(filePath)) return []; if (!fs.existsSync(filePath)) return [];
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as IFaxMessage[]; const raw = await fsPromises.readFile(filePath, 'utf8');
const legacyMessages = await this.ensureMessageObjects(boxId, JSON.parse(raw) as IFaxMessage[]);
await this.storage.writeFaxMessages(boxId, legacyMessages);
return legacyMessages;
} catch { } catch {
return []; return [];
} }
} }
private writeMessages(boxId: string, messages: IFaxMessage[]): void { private async ensureMessageObjects(boxId: string, messages: IFaxMessage[]): Promise<IFaxMessage[]> {
const boxDir = this.getBoxDir(boxId); let changed = false;
fs.mkdirSync(boxDir, { recursive: true });
fs.writeFileSync(this.messagesPath(boxId), JSON.stringify(messages, null, 2), 'utf8'); for (const msg of messages) {
if (!msg.id) {
msg.id = crypto.randomUUID();
changed = true;
}
if (msg.objectKey) continue;
const localPath = path.isAbsolute(msg.fileName) ? msg.fileName : path.join(this.getBoxDir(boxId), msg.fileName);
if (!fs.existsSync(localPath)) continue;
const extension = path.extname(localPath) || '.tif';
msg.objectKey = await this.storage.putFileObject(`fax/inboxes/${boxId}/${msg.id}${extension}`, localPath);
msg.fileName = path.basename(localPath);
changed = true;
}
if (changed) {
await this.storage.writeFaxMessages(boxId, messages);
this.log(`[faxbox] migrated legacy messages for box "${boxId}" to smartbucket`);
}
return messages;
}
private async writeMessages(boxId: string, messages: IFaxMessage[]): Promise<void> {
this.messagesByBox.set(boxId, [...messages]);
await this.storage.writeFaxMessages(boxId, messages);
} }
} }
+37 -41
View File
@@ -1,6 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import type { SiprouterStorage } from './storage.ts';
import type { import type {
IFaxCompletedEvent, IFaxCompletedEvent,
IFaxFailedEvent, IFaxFailedEvent,
@@ -16,6 +17,7 @@ export interface IFaxJob {
status: 'dialing' | 'started' | 'completed' | 'failed'; status: 'dialing' | 'started' | 'completed' | 'failed';
transport?: 'audio' | 't38'; transport?: 'audio' | 't38';
filePath?: string; filePath?: string;
objectKey?: string;
codec?: string; codec?: string;
remoteMedia?: string; remoteMedia?: string;
success?: boolean; success?: boolean;
@@ -28,25 +30,21 @@ export interface IFaxJob {
} }
export class FaxJobManager { export class FaxJobManager {
private readonly basePath: string; private jobs: IFaxJob[] = [];
private readonly jobsPath: string;
private readonly log: (msg: string) => void; private readonly log: (msg: string) => void;
private readonly storage: SiprouterStorage;
constructor(log: (msg: string) => void) { constructor(log: (msg: string) => void, storageArg: SiprouterStorage) {
this.basePath = path.join(process.cwd(), '.nogit', 'fax');
this.jobsPath = path.join(this.basePath, 'jobs.json');
this.log = log; this.log = log;
this.storage = storageArg;
} }
init(): void { async init(): Promise<void> {
fs.mkdirSync(this.basePath, { recursive: true }); this.jobs = await this.storage.getFaxJobs();
if (!fs.existsSync(this.jobsPath)) {
this.writeJobs([]);
}
} }
noteDialing(callId: string, number: string, providerId: string): void { async noteDialing(callId: string, number: string, providerId: string): Promise<void> {
const jobs = this.loadJobs(); const jobs = this.jobs;
const now = Date.now(); const now = Date.now();
const existing = jobs.find((job) => job.callId === callId); const existing = jobs.find((job) => job.callId === callId);
if (existing) { if (existing) {
@@ -65,62 +63,61 @@ export class FaxJobManager {
updatedAt: now, updatedAt: now,
}); });
} }
this.writeJobs(jobs); await this.writeJobs();
} }
noteStarted(event: IFaxStartedEvent): void { async noteStarted(event: IFaxStartedEvent): Promise<void> {
const jobs = this.loadJobs();
const now = Date.now(); const now = Date.now();
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now); const job = this.getOrCreateJob(event.call_id, event.direction, now);
job.status = 'started'; job.status = 'started';
job.transport = event.transport; job.transport = event.transport;
job.filePath = event.file_path; job.filePath = event.file_path;
await this.ensureOutboundFileObject(job, event.file_path);
job.codec = event.codec; job.codec = event.codec;
job.remoteMedia = event.remote_media; job.remoteMedia = event.remote_media;
job.updatedAt = now; job.updatedAt = now;
this.writeJobs(jobs); await this.writeJobs();
} }
noteCompleted(event: IFaxCompletedEvent): void { async noteCompleted(event: IFaxCompletedEvent): Promise<void> {
const jobs = this.loadJobs();
const now = Date.now(); const now = Date.now();
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now); const job = this.getOrCreateJob(event.call_id, event.direction, now);
job.status = 'completed'; job.status = 'completed';
job.transport = event.transport; job.transport = event.transport;
job.filePath = event.file_path; job.filePath = event.file_path;
await this.ensureOutboundFileObject(job, event.file_path);
job.codec = event.codec; job.codec = event.codec;
job.success = event.success; job.success = event.success;
job.completionCode = event.completion_code ?? null; job.completionCode = event.completion_code ?? null;
job.completionLabel = event.completion_label ?? null; job.completionLabel = event.completion_label ?? null;
job.stats = event.stats; job.stats = event.stats;
job.updatedAt = now; job.updatedAt = now;
this.writeJobs(jobs); await this.writeJobs();
} }
noteFailed(event: IFaxFailedEvent): void { async noteFailed(event: IFaxFailedEvent): Promise<void> {
const jobs = this.loadJobs();
const now = Date.now(); const now = Date.now();
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now); const job = this.getOrCreateJob(event.call_id, event.direction, now);
job.status = 'failed'; job.status = 'failed';
job.transport = event.transport; job.transport = event.transport;
job.filePath = event.file_path; job.filePath = event.file_path;
await this.ensureOutboundFileObject(job, event.file_path);
job.error = event.error; job.error = event.error;
job.success = false; job.success = false;
job.updatedAt = now; job.updatedAt = now;
this.writeJobs(jobs); await this.writeJobs();
} }
getJobs(): IFaxJob[] { getJobs(): IFaxJob[] {
return this.loadJobs(); return [...this.jobs];
} }
private getOrCreateJob( private getOrCreateJob(
jobs: IFaxJob[],
callId: string, callId: string,
direction: 'outbound' | 'inbound', direction: 'outbound' | 'inbound',
now: number, now: number,
): IFaxJob { ): IFaxJob {
let job = jobs.find((entry) => entry.callId === callId); let job = this.jobs.find((entry) => entry.callId === callId);
if (!job) { if (!job) {
job = { job = {
id: callId, id: callId,
@@ -130,24 +127,23 @@ export class FaxJobManager {
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
jobs.unshift(job); this.jobs.unshift(job);
} }
return job; return job;
} }
private loadJobs(): IFaxJob[] { private async ensureOutboundFileObject(jobArg: IFaxJob, filePathArg: string | undefined): Promise<void> {
try { if (jobArg.direction !== 'outbound' || jobArg.objectKey || !filePathArg) return;
const content = fs.readFileSync(this.jobsPath, 'utf8');
const parsed = JSON.parse(content); const localPath = path.isAbsolute(filePathArg) ? filePathArg : path.join(process.cwd(), filePathArg);
return Array.isArray(parsed) ? parsed as IFaxJob[] : []; if (!fs.existsSync(localPath)) return;
} catch {
return []; const extension = path.extname(localPath) || '.tif';
} jobArg.objectKey = await this.storage.putFileObject(`fax/outbound/${jobArg.callId}${extension}`, localPath);
} }
private writeJobs(jobs: IFaxJob[]): void { private async writeJobs(): Promise<void> {
fs.mkdirSync(this.basePath, { recursive: true }); await this.storage.writeFaxJobs(this.jobs);
fs.writeFileSync(this.jobsPath, JSON.stringify(jobs, null, 2)); this.log(`[fax] persisted ${this.jobs.length} job(s)`);
this.log(`[fax] persisted ${jobs.length} job(s)`);
} }
} }
+21 -76
View File
@@ -11,19 +11,19 @@ import path from 'node:path';
import http from 'node:http'; import http from 'node:http';
import https from 'node:https'; import https from 'node:https';
import { WebSocketServer, WebSocket } from 'ws'; import { WebSocketServer, WebSocket } from 'ws';
import { maskConfig, type IAppConfig } from './config.ts';
import type { FaxBoxManager } from './faxbox.ts'; import type { FaxBoxManager } from './faxbox.ts';
import type { FaxJobManager } from './faxjobs.ts'; import type { FaxJobManager } from './faxjobs.ts';
import { handleWebRtcSignaling } from './webrtcbridge.ts'; import { handleWebRtcSignaling } from './webrtcbridge.ts';
import type { VoiceboxManager } from './voicebox.ts'; import type { VoiceboxManager } from './voicebox.ts';
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
interface IHandleRequestContext { interface IHandleRequestContext {
getStatus: () => unknown; getStatus: () => unknown;
getConfig: () => IAppConfig;
updateConfig: (updatesArg: any) => Promise<IAppConfig>;
log: (msg: string) => void; log: (msg: string) => void;
onStartCall: (number: string, deviceId?: string, providerId?: string) => Promise<{ id: string } | null>; onStartCall: (number: string, deviceId?: string, providerId?: string) => Promise<{ id: string } | null>;
onHangupCall: (callId: string) => boolean; onHangupCall: (callId: string) => boolean;
onConfigSaved?: () => void | Promise<void>;
faxBoxManager?: FaxBoxManager; faxBoxManager?: FaxBoxManager;
faxJobManager?: FaxJobManager; faxJobManager?: FaxJobManager;
voiceboxManager?: VoiceboxManager; voiceboxManager?: VoiceboxManager;
@@ -112,7 +112,7 @@ async function handleRequest(
res: http.ServerResponse, res: http.ServerResponse,
context: IHandleRequestContext, context: IHandleRequestContext,
): Promise<void> { ): Promise<void> {
const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager } = context; const { getStatus, getConfig, updateConfig, log, onStartCall, onHangupCall, faxBoxManager, faxJobManager, voiceboxManager } = context;
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const method = req.method || 'GET'; const method = req.method || 'GET';
@@ -156,13 +156,16 @@ async function handleRequest(
try { try {
const body = await readJsonBody(req); const body = await readJsonBody(req);
const number = body?.number; const number = body?.number;
const filePath = body?.filePath; let filePath = body?.filePath;
if (!number || typeof number !== 'string') { if (!number || typeof number !== 'string') {
return sendJson(res, { ok: false, error: 'missing "number" field' }, 400); return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
} }
if (!filePath || typeof filePath !== 'string') { if (!filePath || typeof filePath !== 'string') {
return sendJson(res, { ok: false, error: 'missing "filePath" field' }, 400); return sendJson(res, { ok: false, error: 'missing "filePath" field' }, 400);
} }
if (faxBoxManager) {
filePath = await faxBoxManager.prepareOutboundFaxFile(filePath);
}
const { sendFax } = await import('./proxybridge.ts'); const { sendFax } = await import('./proxybridge.ts');
const callId = await sendFax(number, filePath, body?.providerId); const callId = await sendFax(number, filePath, body?.providerId);
if (callId) { if (callId) {
@@ -191,7 +194,7 @@ async function handleRequest(
const faxFileMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)\/file$/); const faxFileMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)\/file$/);
if (faxFileMatch && method === 'GET' && faxBoxManager) { if (faxFileMatch && method === 'GET' && faxBoxManager) {
const [, boxId, msgId] = faxFileMatch; const [, boxId, msgId] = faxFileMatch;
const filePath = faxBoxManager.getMessageFilePath(boxId, msgId); const filePath = await faxBoxManager.getMessageFilePath(boxId, msgId);
if (!filePath) return sendJson(res, { ok: false, error: 'not found' }, 404); if (!filePath) return sendJson(res, { ok: false, error: 'not found' }, 404);
const stat = fs.statSync(filePath); const stat = fs.statSync(filePath);
res.writeHead(200, { res.writeHead(200, {
@@ -207,7 +210,7 @@ async function handleRequest(
const faxDeleteMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)$/); const faxDeleteMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)$/);
if (faxDeleteMatch && method === 'DELETE' && faxBoxManager) { if (faxDeleteMatch && method === 'DELETE' && faxBoxManager) {
const [, boxId, msgId] = faxDeleteMatch; const [, boxId, msgId] = faxDeleteMatch;
return sendJson(res, { ok: faxBoxManager.deleteMessage(boxId, msgId) }); return sendJson(res, { ok: await faxBoxManager.deleteMessage(boxId, msgId) });
} }
// API: add a SIP device to a call (mid-call INVITE to desk phone). // API: add a SIP device to a call (mid-call INVITE to desk phone).
@@ -272,10 +275,7 @@ async function handleRequest(
// API: get config (sans passwords). // API: get config (sans passwords).
if (url.pathname === '/api/config' && method === 'GET') { if (url.pathname === '/api/config' && method === 'GET') {
try { try {
const raw = fs.readFileSync(CONFIG_PATH, 'utf8'); return sendJson(res, maskConfig(getConfig()));
const cfg = JSON.parse(raw);
const safe = { ...cfg, providers: cfg.providers?.map((p: any) => ({ ...p, password: '••••••' })) };
return sendJson(res, safe);
} catch (e: any) { } catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 500); return sendJson(res, { ok: false, error: e.message }, 500);
} }
@@ -285,65 +285,9 @@ async function handleRequest(
if (url.pathname === '/api/config' && method === 'POST') { if (url.pathname === '/api/config' && method === 'POST') {
try { try {
const updates = await readJsonBody(req); const updates = await readJsonBody(req);
const raw = fs.readFileSync(CONFIG_PATH, 'utf8'); const config = await updateConfig(updates);
const cfg = JSON.parse(raw); log('[config] updated smartdata config');
return sendJson(res, { ok: true, config: maskConfig(config) });
// Update existing providers.
if (updates.providers) {
for (const up of updates.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;
}
}
}
// Add a new provider.
if (updates.addProvider) {
cfg.providers ??= [];
cfg.providers.push(updates.addProvider);
}
// Remove a provider.
if (updates.removeProvider) {
cfg.providers = (cfg.providers || []).filter((p: any) => p.id !== updates.removeProvider);
// Clean up routing references — remove routes that reference this provider.
if (cfg.routing?.routes) {
cfg.routing.routes = cfg.routing.routes.filter((r: any) =>
r.match?.sourceProvider !== updates.removeProvider &&
r.action?.provider !== updates.removeProvider
);
}
}
if (updates.devices) {
for (const ud of updates.devices) {
const existing = cfg.devices?.find((d: any) => d.id === ud.id);
if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName;
}
}
if (updates.incomingNumbers !== undefined) cfg.incomingNumbers = updates.incomingNumbers;
if (updates.routing) {
if (updates.routing.routes) {
cfg.routing.routes = updates.routing.routes;
}
}
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
if (updates.faxboxes !== undefined) cfg.faxboxes = updates.faxboxes;
if (updates.voiceboxes !== undefined) cfg.voiceboxes = updates.voiceboxes;
if (updates.ivr !== undefined) cfg.ivr = updates.ivr;
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
log('[config] updated config.json');
await onConfigSaved?.();
return sendJson(res, { ok: true });
} catch (e: any) { } catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400); return sendJson(res, { ok: false, error: e.message }, 400);
} }
@@ -367,7 +311,7 @@ async function handleRequest(
const vmAudioMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/audio$/); const vmAudioMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/audio$/);
if (vmAudioMatch && method === 'GET' && voiceboxManager) { if (vmAudioMatch && method === 'GET' && voiceboxManager) {
const [, boxId, msgId] = vmAudioMatch; const [, boxId, msgId] = vmAudioMatch;
const audioPath = voiceboxManager.getMessageAudioPath(boxId, msgId); const audioPath = await voiceboxManager.getMessageAudioPath(boxId, msgId);
if (!audioPath) return sendJson(res, { ok: false, error: 'not found' }, 404); if (!audioPath) return sendJson(res, { ok: false, error: 'not found' }, 404);
const stat = fs.statSync(audioPath); const stat = fs.statSync(audioPath);
res.writeHead(200, { res.writeHead(200, {
@@ -383,14 +327,14 @@ async function handleRequest(
const vmHeardMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/heard$/); const vmHeardMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/heard$/);
if (vmHeardMatch && method === 'POST' && voiceboxManager) { if (vmHeardMatch && method === 'POST' && voiceboxManager) {
const [, boxId, msgId] = vmHeardMatch; const [, boxId, msgId] = vmHeardMatch;
return sendJson(res, { ok: voiceboxManager.markHeard(boxId, msgId) }); return sendJson(res, { ok: await voiceboxManager.markHeard(boxId, msgId) });
} }
// API: voicemail - delete message. // API: voicemail - delete message.
const vmDeleteMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)$/); const vmDeleteMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)$/);
if (vmDeleteMatch && method === 'DELETE' && voiceboxManager) { if (vmDeleteMatch && method === 'DELETE' && voiceboxManager) {
const [, boxId, msgId] = vmDeleteMatch; const [, boxId, msgId] = vmDeleteMatch;
return sendJson(res, { ok: voiceboxManager.deleteMessage(boxId, msgId) }); return sendJson(res, { ok: await voiceboxManager.deleteMessage(boxId, msgId) });
} }
// Static files. // Static files.
@@ -428,10 +372,11 @@ export function initWebUi(
const { const {
port, port,
getStatus, getStatus,
getConfig,
updateConfig,
log, log,
onStartCall, onStartCall,
onHangupCall, onHangupCall,
onConfigSaved,
faxBoxManager, faxBoxManager,
faxJobManager, faxJobManager,
voiceboxManager, voiceboxManager,
@@ -453,12 +398,12 @@ export function initWebUi(
const cert = fs.readFileSync(certPath, 'utf8'); const cert = fs.readFileSync(certPath, 'utf8');
const key = fs.readFileSync(keyPath, 'utf8'); const key = fs.readFileSync(keyPath, 'utf8');
server = https.createServer({ cert, key }, (req, res) => server = https.createServer({ cert, key }, (req, res) =>
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }), handleRequest(req, res, { getStatus, getConfig, updateConfig, log, onStartCall, onHangupCall, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
); );
useTls = true; useTls = true;
} catch { } catch {
server = http.createServer((req, res) => server = http.createServer((req, res) =>
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }), handleRequest(req, res, { getStatus, getConfig, updateConfig, log, onStartCall, onHangupCall, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
); );
} }
+4
View File
@@ -0,0 +1,4 @@
import * as smartbucket from '@push.rocks/smartbucket';
import * as smartdata from '@push.rocks/smartdata';
export { smartbucket, smartdata };
+9 -8
View File
@@ -102,7 +102,8 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
statusStore.noteOutboundCallStarted(data); statusStore.noteOutboundCallStarted(data);
if (data.ring_browsers === false) { if (data.ring_browsers === false) {
faxJobManager.noteDialing(data.call_id, data.number, data.provider_id); void faxJobManager.noteDialing(data.call_id, data.number, data.provider_id)
.catch((error) => log(`[fax] persist dialing failed: ${error instanceof Error ? error.message : String(error)}`));
} }
if (data.ring_browsers === false) { if (data.ring_browsers === false) {
@@ -218,12 +219,12 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
onProxyEvent('recording_done', (data) => { onProxyEvent('recording_done', (data) => {
const boxId = data.voicebox_id || 'default'; const boxId = data.voicebox_id || 'default';
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) box=${boxId} caller=${data.caller_number}`); log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) box=${boxId} caller=${data.caller_number}`);
voiceboxManager.addMessage(boxId, { void voiceboxManager.addMessage(boxId, {
callerNumber: data.caller_number || 'Unknown', callerNumber: data.caller_number || 'Unknown',
callerName: null, callerName: null,
fileName: data.file_path, fileName: data.file_path,
durationMs: data.duration_ms, durationMs: data.duration_ms,
}); }).catch((error) => log(`[voicemail] persist failed: ${error instanceof Error ? error.message : String(error)}`));
}); });
onProxyEvent('voicemail_error', (data) => { onProxyEvent('voicemail_error', (data) => {
@@ -231,24 +232,24 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
}); });
onProxyEvent('fax_started', (data) => { onProxyEvent('fax_started', (data) => {
faxJobManager.noteStarted(data); void faxJobManager.noteStarted(data).catch((error) => log(`[fax] persist start failed: ${error instanceof Error ? error.message : String(error)}`));
log(`[fax] started: call=${data.call_id} leg=${data.leg_id} ${data.direction}/${data.transport} codec=${data.codec || '?'} file=${data.file_path}`); log(`[fax] started: call=${data.call_id} leg=${data.leg_id} ${data.direction}/${data.transport} codec=${data.codec || '?'} file=${data.file_path}`);
}); });
onProxyEvent('fax_completed', (data) => { onProxyEvent('fax_completed', (data) => {
faxJobManager.noteCompleted(data); void faxJobManager.noteCompleted(data).catch((error) => log(`[fax] persist completion failed: ${error instanceof Error ? error.message : String(error)}`));
log( log(
`[fax] completed: call=${data.call_id} leg=${data.leg_id} success=${data.success} pagesTx=${data.stats.pages_tx} bitrate=${data.stats.bit_rate} completion=${data.completion_label || data.completion_code || 'unknown'}`, `[fax] completed: call=${data.call_id} leg=${data.leg_id} success=${data.success} pagesTx=${data.stats.pages_tx} bitrate=${data.stats.bit_rate} completion=${data.completion_label || data.completion_code || 'unknown'}`,
); );
if (data.direction === 'inbound' && data.success && data.fax_box_id) { if (data.direction === 'inbound' && data.success && data.fax_box_id) {
faxBoxManager.addMessage(data.fax_box_id, { void faxBoxManager.addMessage(data.fax_box_id, {
callerNumber: data.caller_number, callerNumber: data.caller_number,
fileName: data.file_path, fileName: data.file_path,
completionCode: data.completion_code, completionCode: data.completion_code,
completionLabel: data.completion_label, completionLabel: data.completion_label,
pageCount: data.stats.pages_rx || data.stats.pages_tx, pageCount: data.stats.pages_rx || data.stats.pages_tx,
bitRate: data.stats.bit_rate, bitRate: data.stats.bit_rate,
}); }).catch((error) => log(`[fax] persist inbox failed: ${error instanceof Error ? error.message : String(error)}`));
} }
if (data.direction === 'outbound' || data.fax_box_id) { if (data.direction === 'outbound' || data.fax_box_id) {
void hangupCall(data.call_id); void hangupCall(data.call_id);
@@ -256,7 +257,7 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
}); });
onProxyEvent('fax_failed', (data) => { onProxyEvent('fax_failed', (data) => {
faxJobManager.noteFailed(data); void faxJobManager.noteFailed(data).catch((error) => log(`[fax] persist failure failed: ${error instanceof Error ? error.message : String(error)}`));
log(`[fax] failed: call=${data.call_id} leg=${data.leg_id} error=${data.error}`); log(`[fax] failed: call=${data.call_id} leg=${data.leg_id} error=${data.error}`);
if (data.direction === 'outbound' || data.fax_box_id) { if (data.direction === 'outbound' || data.fax_box_id) {
void hangupCall(data.call_id); void hangupCall(data.call_id);
+45 -17
View File
@@ -8,7 +8,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { loadConfig, type IAppConfig } from './config.ts'; import { applyConfigUpdates, type IAppConfig } from './config.ts';
import { FaxBoxManager } from './faxbox.ts'; import { FaxBoxManager } from './faxbox.ts';
import { FaxJobManager } from './faxjobs.ts'; import { FaxJobManager } from './faxjobs.ts';
import { broadcastWs, initWebUi } from './frontend.ts'; import { broadcastWs, initWebUi } from './frontend.ts';
@@ -27,24 +27,21 @@ import {
} from './proxybridge.ts'; } from './proxybridge.ts';
import { registerProxyEventHandlers } from './runtime/proxy-events.ts'; import { registerProxyEventHandlers } from './runtime/proxy-events.ts';
import { StatusStore } from './runtime/status-store.ts'; import { StatusStore } from './runtime/status-store.ts';
import { SiprouterStorage } from './storage.ts';
import { WebRtcLinkManager, type IProviderMediaInfo } from './runtime/webrtc-linking.ts'; import { WebRtcLinkManager, type IProviderMediaInfo } from './runtime/webrtc-linking.ts';
let appConfig: IAppConfig = loadConfig(); let appConfig: IAppConfig;
const LOG_PATH = path.join(process.cwd(), 'sip_trace.log'); const LOG_PATH = path.join(process.cwd(), 'sip_trace.log');
const startTime = Date.now(); const startTime = Date.now();
const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const statusStore = new StatusStore(appConfig); const storage = new SiprouterStorage(log);
const webRtcLinks = new WebRtcLinkManager(); let statusStore: StatusStore;
const faxBoxManager = new FaxBoxManager(log); let webRtcLinks: WebRtcLinkManager;
const faxJobManager = new FaxJobManager(log); let faxBoxManager: FaxBoxManager;
const voiceboxManager = new VoiceboxManager(log); let faxJobManager: FaxJobManager;
let voiceboxManager: VoiceboxManager;
faxBoxManager.init(appConfig.faxboxes ?? []);
faxJobManager.init();
voiceboxManager.init(appConfig.voiceboxes ?? []);
initWebRtcSignaling({ log });
function now(): string { function now(): string {
return new Date().toISOString().replace('T', ' ').slice(0, 19); return new Date().toISOString().replace('T', ' ').slice(0, 19);
@@ -96,12 +93,12 @@ async function configureRuntime(config: IAppConfig): Promise<boolean> {
async function reloadConfig(): Promise<void> { async function reloadConfig(): Promise<void> {
try { try {
const previousConfig = appConfig; const previousConfig = appConfig;
const nextConfig = loadConfig(); const nextConfig = await storage.getAppConfig();
appConfig = nextConfig; appConfig = nextConfig;
statusStore.updateConfig(nextConfig); statusStore.updateConfig(nextConfig);
faxBoxManager.init(nextConfig.faxboxes ?? []); await faxBoxManager.init(nextConfig.faxboxes ?? []);
voiceboxManager.init(nextConfig.voiceboxes ?? []); await voiceboxManager.init(nextConfig.voiceboxes ?? []);
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) { if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
log('[config] proxy.lanPort changed; restart required for SIP socket rebinding'); log('[config] proxy.lanPort changed; restart required for SIP socket rebinding');
@@ -121,6 +118,13 @@ async function reloadConfig(): Promise<void> {
} }
} }
async function updateConfig(updatesArg: any): Promise<IAppConfig> {
const nextConfig = applyConfigUpdates(appConfig, updatesArg);
await storage.writeAppConfig(nextConfig);
await reloadConfig();
return appConfig;
}
async function startProxyEngine(): Promise<void> { async function startProxyEngine(): Promise<void> {
const started = await initProxyEngine(log); const started = await initProxyEngine(log);
if (!started) { if (!started) {
@@ -155,9 +159,26 @@ async function startProxyEngine(): Promise<void> {
log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`); log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`);
} }
async function main(): Promise<void> {
await storage.init();
appConfig = await storage.getAppConfig();
statusStore = new StatusStore(appConfig);
webRtcLinks = new WebRtcLinkManager();
faxBoxManager = new FaxBoxManager(log, storage);
faxJobManager = new FaxJobManager(log, storage);
voiceboxManager = new VoiceboxManager(log, storage);
await faxBoxManager.init(appConfig.faxboxes ?? []);
await faxJobManager.init();
await voiceboxManager.init(appConfig.voiceboxes ?? []);
initWebRtcSignaling({ log });
initWebUi({ initWebUi({
port: appConfig.proxy.webUiPort, port: appConfig.proxy.webUiPort,
getStatus, getStatus,
getConfig: () => appConfig,
updateConfig,
log, log,
onStartCall: async (number, deviceId, providerId) => { onStartCall: async (number, deviceId, providerId) => {
log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`); log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`);
@@ -174,7 +195,6 @@ initWebUi({
void hangupCall(callId); void hangupCall(callId);
return true; return true;
}, },
onConfigSaved: reloadConfig,
faxBoxManager, faxBoxManager,
faxJobManager, faxJobManager,
voiceboxManager, voiceboxManager,
@@ -216,16 +236,24 @@ initWebUi({
}, },
}); });
void startProxyEngine(); await startProxyEngine();
}
void main().catch((error) => {
log(`[FATAL] ${errorMessage(error)}`);
process.exit(1);
});
process.on('SIGINT', () => { process.on('SIGINT', () => {
log('SIGINT, exiting'); log('SIGINT, exiting');
shutdownProxyEngine(); shutdownProxyEngine();
void storage.close();
process.exit(0); process.exit(0);
}); });
process.on('SIGTERM', () => { process.on('SIGTERM', () => {
log('SIGTERM, exiting'); log('SIGTERM, exiting');
shutdownProxyEngine(); shutdownProxyEngine();
void storage.close();
process.exit(0); process.exit(0);
}); });
+250
View File
@@ -0,0 +1,250 @@
import fs from 'node:fs';
import * as fsPromises from 'node:fs/promises';
import path from 'node:path';
import * as plugins from './plugins.ts';
import {
createInitialConfigFromEnv,
normalizeConfig,
type IAppConfig,
} from './config.ts';
import type { IFaxMessage } from './faxbox.ts';
import type { IFaxJob } from './faxjobs.ts';
import type { IVoicemailMessage } from './voicebox.ts';
interface ISiprouterDataStore {
appConfig: IAppConfig;
faxJobs: IFaxJob[];
faxMessagesByBox: Record<string, IFaxMessage[]>;
voicemailMessagesByBox: Record<string, IVoicemailMessage[]>;
}
type TLogFunction = (messageArg: string) => void;
const legacyConfigPath = path.join(process.cwd(), '.nogit', 'config.json');
function requiredEnv(keysArg: string[]): string {
for (const key of keysArg) {
const value = process.env[key];
if (value) return value;
}
throw new Error(`Missing required environment variable: ${keysArg.join(' or ')}`);
}
function optionalNumber(valueArg: string | undefined, fallbackArg?: number): number | undefined {
if (!valueArg) return fallbackArg;
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? parsed : fallbackArg;
}
function optionalBoolean(valueArg: string | undefined, fallbackArg?: boolean): boolean | undefined {
if (valueArg === undefined) return fallbackArg;
return !['0', 'false', 'no', 'off'].includes(valueArg.toLowerCase());
}
function normalizeObjectKey(keyArg: string): string {
const normalizedKey = keyArg.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/');
if (normalizedKey.split('/').includes('..')) {
throw new Error(`Invalid object key: ${keyArg}`);
}
return normalizedKey;
}
export class SiprouterStorage {
private db!: InstanceType<typeof plugins.smartdata.SmartdataDb>;
private store!: any;
private bucket!: any;
private readonly cacheDir = path.join(process.cwd(), '.nogit', 'cache');
private readonly log: TLogFunction;
constructor(logArg: TLogFunction) {
this.log = logArg;
}
public async init(): Promise<void> {
this.db = new plugins.smartdata.SmartdataDb(this.getMongoDescriptor() as any);
await this.db.init();
this.store = await this.db.createEasyStore('siprouter-data');
const smartBucket = new plugins.smartbucket.SmartBucket(this.getS3Descriptor() as any);
const bucketName = requiredEnv(['SIPROUTER_S3_BUCKET', 'S3_BUCKET']);
this.bucket = await smartBucket.bucketExists(bucketName)
? await smartBucket.getBucketByName(bucketName)
: await smartBucket.createBucket(bucketName);
await fsPromises.mkdir(this.cacheDir, { recursive: true });
this.log('[storage] smartdata and smartbucket initialized');
}
public async close(): Promise<void> {
if (this.db) {
await this.db.close();
}
}
public async getAppConfig(): Promise<IAppConfig> {
const storedConfig = await this.readKey('appConfig');
if (storedConfig) {
return normalizeConfig(storedConfig);
}
const legacyConfig = await this.readLegacyConfig();
const initialConfig = legacyConfig || createInitialConfigFromEnv();
await this.writeAppConfig(initialConfig);
this.log(legacyConfig ? '[storage] imported legacy .nogit/config.json into smartdata' : '[storage] created initial smartdata config');
return initialConfig;
}
public async writeAppConfig(configArg: IAppConfig): Promise<void> {
await this.writeKey('appConfig', normalizeConfig(configArg));
}
public async getFaxJobs(): Promise<IFaxJob[]> {
return (await this.readKey('faxJobs')) || [];
}
public async writeFaxJobs(jobsArg: IFaxJob[]): Promise<void> {
await this.writeKey('faxJobs', jobsArg);
}
public async getVoicemailMessages(boxIdArg: string): Promise<IVoicemailMessage[]> {
const allMessages = (await this.readKey('voicemailMessagesByBox')) || {};
return allMessages[boxIdArg] || [];
}
public async writeVoicemailMessages(boxIdArg: string, messagesArg: IVoicemailMessage[]): Promise<void> {
const allMessages = (await this.readKey('voicemailMessagesByBox')) || {};
allMessages[boxIdArg] = messagesArg;
await this.writeKey('voicemailMessagesByBox', allMessages);
}
public async getFaxMessages(boxIdArg: string): Promise<IFaxMessage[]> {
const allMessages = (await this.readKey('faxMessagesByBox')) || {};
return allMessages[boxIdArg] || [];
}
public async writeFaxMessages(boxIdArg: string, messagesArg: IFaxMessage[]): Promise<void> {
const allMessages = (await this.readKey('faxMessagesByBox')) || {};
allMessages[boxIdArg] = messagesArg;
await this.writeKey('faxMessagesByBox', allMessages);
}
public async putFileObject(objectKeyArg: string, filePathArg: string): Promise<string> {
const objectKey = normalizeObjectKey(objectKeyArg);
const contents = await fsPromises.readFile(filePathArg);
await this.bucket.fastPut({ path: objectKey, contents, overwrite: true });
await this.removeCachedObject(objectKey);
return objectKey;
}
public async putBufferObject(objectKeyArg: string, bufferArg: Buffer): Promise<string> {
const objectKey = normalizeObjectKey(objectKeyArg);
await this.bucket.fastPut({ path: objectKey, contents: bufferArg, overwrite: true });
await this.removeCachedObject(objectKey);
return objectKey;
}
public async getObjectAsCachedFile(objectKeyArg: string, fileNameArg?: string): Promise<string | null> {
const objectKey = normalizeObjectKey(objectKeyArg);
const cachePath = this.getCachePath(objectKey);
try {
if (fs.existsSync(cachePath)) {
return cachePath;
}
const contents = await this.bucket.fastGet({ path: objectKey });
await fsPromises.mkdir(path.dirname(cachePath), { recursive: true });
await fsPromises.writeFile(cachePath, contents);
return cachePath;
} catch {
if (fileNameArg) {
const fallbackPath = path.join(this.cacheDir, path.basename(fileNameArg));
return fs.existsSync(fallbackPath) ? fallbackPath : null;
}
return null;
}
}
public async removeObject(objectKeyArg: string | undefined): Promise<void> {
if (!objectKeyArg) return;
const objectKey = normalizeObjectKey(objectKeyArg);
try {
await this.bucket.fastRemove({ path: objectKey });
} catch {
// Missing objects are harmless during metadata cleanup.
}
await this.removeCachedObject(objectKey);
}
private getCachePath(objectKeyArg: string): string {
return path.join(this.cacheDir, normalizeObjectKey(objectKeyArg));
}
private async removeCachedObject(objectKeyArg: string): Promise<void> {
await fsPromises.rm(this.getCachePath(objectKeyArg), { force: true }).catch(() => {});
}
private async readLegacyConfig(): Promise<IAppConfig | null> {
try {
const raw = await fsPromises.readFile(legacyConfigPath, 'utf8');
return normalizeConfig(JSON.parse(raw) as IAppConfig);
} catch {
return null;
}
}
private async readKey<TKey extends keyof ISiprouterDataStore>(keyArg: TKey): Promise<ISiprouterDataStore[TKey] | undefined> {
try {
return await this.store.readKey(keyArg) as ISiprouterDataStore[TKey] | undefined;
} catch {
return undefined;
}
}
private async writeKey<TKey extends keyof ISiprouterDataStore>(
keyArg: TKey,
valueArg: ISiprouterDataStore[TKey],
): Promise<void> {
await this.store.writeKey(keyArg, valueArg);
}
private getMongoDescriptor(): Record<string, string> {
const mongoDbUrl = requiredEnv([
'SIPROUTER_MONGODB_URL',
'MONGODB_URI',
'MONGODB_URL',
]);
const descriptor: Record<string, string> = {
mongoDbUrl,
mongoDbName: process.env.SIPROUTER_MONGODB_NAME || process.env.MONGODB_DATABASE || process.env.MONGODB_NAME || 'siprouter',
};
const mongoDbUser = process.env.SIPROUTER_MONGODB_USER || process.env.MONGODB_USERNAME || process.env.MONGODB_USER;
const mongoDbPass = process.env.SIPROUTER_MONGODB_PASS || process.env.MONGODB_PASSWORD || process.env.MONGODB_PASS;
if (mongoDbUser) descriptor.mongoDbUser = mongoDbUser;
if (mongoDbPass) descriptor.mongoDbPass = mongoDbPass;
return descriptor;
}
private getS3Descriptor(): Record<string, string | number | boolean> {
const rawEndpoint = requiredEnv(['SIPROUTER_S3_ENDPOINT', 'S3_ENDPOINT', 'AWS_ENDPOINT_URL']);
let endpoint = rawEndpoint;
let port = optionalNumber(process.env.SIPROUTER_S3_PORT || process.env.S3_PORT);
let useSsl = optionalBoolean(process.env.SIPROUTER_S3_USESSL || process.env.S3_USESSL || process.env.S3_USE_SSL);
if (/^https?:\/\//.test(rawEndpoint)) {
const url = new URL(rawEndpoint);
endpoint = url.hostname;
port = url.port ? Number(url.port) : port;
useSsl = url.protocol === 'https:';
}
return {
endpoint,
accessKey: requiredEnv(['SIPROUTER_S3_ACCESS_KEY', 'S3_ACCESS_KEY', 'AWS_ACCESS_KEY_ID']),
accessSecret: requiredEnv(['SIPROUTER_S3_SECRET_KEY', 'S3_SECRET_KEY', 'AWS_SECRET_ACCESS_KEY']),
region: process.env.SIPROUTER_S3_REGION || process.env.S3_REGION || process.env.AWS_REGION || 'us-east-1',
...(port ? { port } : {}),
...(useSsl !== undefined ? { useSsl } : {}),
};
}
}
+104 -166
View File
@@ -1,22 +1,12 @@
/** /**
* VoiceboxManager manages voicemail boxes, message storage, and MWI. * VoiceboxManager manages voicemail boxes, message metadata, and audio objects.
*
* Each voicebox corresponds to a device/extension. Messages are stored
* as WAV files with JSON metadata in .nogit/voicemail/{boxId}/.
*
* Supports:
* - Per-box configurable TTS greetings (text + voice) or uploaded WAV
* - Message CRUD: save, list, mark heard, delete
* - Unheard count for MWI (Message Waiting Indicator)
* - Storage limit (max messages per box)
*/ */
import fs from 'node:fs'; import fs from 'node:fs';
import * as fsPromises from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
// --------------------------------------------------------------------------- import type { SiprouterStorage } from './storage.ts';
// Types
// ---------------------------------------------------------------------------
export interface IVoiceboxConfig { export interface IVoiceboxConfig {
/** Unique ID — typically matches device ID or extension. */ /** Unique ID — typically matches device ID or extension. */
@@ -27,11 +17,9 @@ export interface IVoiceboxConfig {
greetingText?: string; greetingText?: string;
/** Kokoro TTS voice ID for the greeting (default 'af_bella'). */ /** Kokoro TTS voice ID for the greeting (default 'af_bella'). */
greetingVoice?: string; greetingVoice?: string;
/** Path to uploaded WAV greeting (overrides TTS). */ /** Path to cached uploaded WAV greeting (overrides TTS). */
greetingWavPath?: string; greetingWavPath?: string;
/** Seconds to wait before routing to voicemail. Defaults to 25 when /** Seconds to wait before routing to voicemail. */
* absent both the config loader and `VoiceboxManager.init` apply
* the default via `??=`. */
noAnswerTimeoutSec?: number; noAnswerTimeoutSec?: number;
/** Maximum recording duration in seconds. Defaults to 120. */ /** Maximum recording duration in seconds. Defaults to 120. */
maxRecordingSec?: number; maxRecordingSec?: number;
@@ -52,112 +40,80 @@ export interface IVoicemailMessage {
timestamp: number; timestamp: number;
/** Duration in milliseconds. */ /** Duration in milliseconds. */
durationMs: number; durationMs: number;
/** Relative path to the WAV file (within the box directory). */ /** Display file name. */
fileName: string; fileName: string;
/** SmartBucket object key for the WAV payload. */
objectKey?: string;
/** Whether the message has been listened to. */ /** Whether the message has been listened to. */
heard: boolean; heard: boolean;
} }
// Default greeting text when no custom text is configured.
const DEFAULT_GREETING = 'The person you are trying to reach is not available. Please leave a message after the tone.'; const DEFAULT_GREETING = 'The person you are trying to reach is not available. Please leave a message after the tone.';
// ---------------------------------------------------------------------------
// VoiceboxManager
// ---------------------------------------------------------------------------
export class VoiceboxManager { export class VoiceboxManager {
private boxes = new Map<string, IVoiceboxConfig>(); private boxes = new Map<string, IVoiceboxConfig>();
private basePath: string; private messagesByBox = new Map<string, IVoicemailMessage[]>();
private log: (msg: string) => void; private readonly basePath: string;
private readonly log: (msg: string) => void;
private readonly storage: SiprouterStorage;
constructor(log: (msg: string) => void) { constructor(log: (msg: string) => void, storageArg: SiprouterStorage) {
this.basePath = path.join(process.cwd(), '.nogit', 'voicemail'); this.basePath = path.join(process.cwd(), '.nogit', 'voicemail');
this.log = log; this.log = log;
this.storage = storageArg;
} }
// ------------------------------------------------------------------------- async init(voiceboxConfigs: IVoiceboxConfig[]): Promise<void> {
// Initialization
// -------------------------------------------------------------------------
/**
* Load voicebox configurations from the app config.
*/
init(voiceboxConfigs: IVoiceboxConfig[]): void {
this.boxes.clear(); this.boxes.clear();
for (const cfg of voiceboxConfigs) { for (const cfg of voiceboxConfigs) {
// Apply defaults.
cfg.noAnswerTimeoutSec ??= 25; cfg.noAnswerTimeoutSec ??= 25;
cfg.maxRecordingSec ??= 120; cfg.maxRecordingSec ??= 120;
cfg.maxMessages ??= 50; cfg.maxMessages ??= 50;
cfg.greetingVoice ??= 'af_bella'; cfg.greetingVoice ??= 'af_bella';
this.boxes.set(cfg.id, cfg); this.boxes.set(cfg.id, cfg);
this.messagesByBox.set(cfg.id, await this.loadMessages(cfg.id));
} }
// Ensure base directory exists. await fsPromises.mkdir(this.basePath, { recursive: true });
fs.mkdirSync(this.basePath, { recursive: true });
this.log(`[voicebox] initialized ${this.boxes.size} voicebox(es)`); this.log(`[voicebox] initialized ${this.boxes.size} voicebox(es)`);
} }
// -------------------------------------------------------------------------
// Box management
// -------------------------------------------------------------------------
/** Get config for a specific voicebox. */
getBox(boxId: string): IVoiceboxConfig | null { getBox(boxId: string): IVoiceboxConfig | null {
return this.boxes.get(boxId) ?? null; return this.boxes.get(boxId) ?? null;
} }
/** Get all configured voicebox IDs. */
getBoxIds(): string[] { getBoxIds(): string[] {
return [...this.boxes.keys()]; return [...this.boxes.keys()];
} }
/** Get the greeting text for a voicebox. */
getGreetingText(boxId: string): string { getGreetingText(boxId: string): string {
const box = this.boxes.get(boxId); const box = this.boxes.get(boxId);
return box?.greetingText || DEFAULT_GREETING; return box?.greetingText || DEFAULT_GREETING;
} }
/** Get the greeting voice for a voicebox. */
getGreetingVoice(boxId: string): string { getGreetingVoice(boxId: string): string {
const box = this.boxes.get(boxId); const box = this.boxes.get(boxId);
return box?.greetingVoice || 'af_bella'; return box?.greetingVoice || 'af_bella';
} }
/** Check if a voicebox has a custom WAV greeting. */
hasCustomGreetingWav(boxId: string): boolean { hasCustomGreetingWav(boxId: string): boolean {
const box = this.boxes.get(boxId); const box = this.boxes.get(boxId);
if (!box?.greetingWavPath) return false; if (!box?.greetingWavPath) return false;
return fs.existsSync(box.greetingWavPath); return fs.existsSync(box.greetingWavPath);
} }
/** Get the greeting WAV path (custom or null). */
getCustomGreetingWavPath(boxId: string): string | null { getCustomGreetingWavPath(boxId: string): string | null {
const box = this.boxes.get(boxId); const box = this.boxes.get(boxId);
if (!box?.greetingWavPath) return null; if (!box?.greetingWavPath) return null;
return fs.existsSync(box.greetingWavPath) ? box.greetingWavPath : null; return fs.existsSync(box.greetingWavPath) ? box.greetingWavPath : null;
} }
/** Get the directory path for a voicebox. */
getBoxDir(boxId: string): string { getBoxDir(boxId: string): string {
return path.join(this.basePath, boxId); return path.join(this.basePath, boxId);
} }
// ------------------------------------------------------------------------- async addMessage(
// Message CRUD
// -------------------------------------------------------------------------
/**
* Convenience wrapper around `saveMessage` used by the `recording_done`
* event handler, which has a raw recording path + caller info and needs
* to persist metadata. Generates `id`, sets `timestamp = now`, defaults
* `heard = false`, and normalizes `fileName` to a basename (the WAV is
* expected to already live in the box's directory).
*/
addMessage(
boxId: string, boxId: string,
info: { info: {
callerNumber: string; callerNumber: string;
@@ -165,124 +121,87 @@ export class VoiceboxManager {
fileName: string; fileName: string;
durationMs: number; durationMs: number;
}, },
): void { ): Promise<void> {
const id = crypto.randomUUID();
const localPath = path.isAbsolute(info.fileName) ? info.fileName : path.join(process.cwd(), info.fileName);
const objectKey = await this.storage.putFileObject(`voicemail/${boxId}/${id}.wav`, localPath);
const msg: IVoicemailMessage = { const msg: IVoicemailMessage = {
id: crypto.randomUUID(), id,
boxId, boxId,
callerNumber: info.callerNumber, callerNumber: info.callerNumber,
callerName: info.callerName ?? undefined, callerName: info.callerName ?? undefined,
timestamp: Date.now(), timestamp: Date.now(),
durationMs: info.durationMs, durationMs: info.durationMs,
fileName: path.basename(info.fileName), fileName: path.basename(localPath),
objectKey,
heard: false, heard: false,
}; };
this.saveMessage(msg);
}
/** const messages = this.getMessages(boxId);
* Save a new voicemail message. messages.unshift(msg);
* The WAV file should already exist at the expected path. await this.enforceLimit(boxId, messages);
*/ await this.writeMessages(boxId, messages);
saveMessage(msg: IVoicemailMessage): void {
const boxDir = this.getBoxDir(msg.boxId);
fs.mkdirSync(boxDir, { recursive: true });
const messages = this.loadMessages(msg.boxId); await fsPromises.rm(localPath, { force: true }).catch(() => {});
messages.unshift(msg); // newest first
// Enforce max messages — delete oldest.
const box = this.boxes.get(msg.boxId);
const maxMessages = box?.maxMessages ?? 50;
while (messages.length > maxMessages) {
const old = messages.pop()!;
const oldPath = path.join(boxDir, old.fileName);
try {
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
} catch { /* best effort */ }
}
this.writeMessages(msg.boxId, messages);
this.log(`[voicebox] saved message ${msg.id} in box "${msg.boxId}" (${msg.durationMs}ms from ${msg.callerNumber})`); this.log(`[voicebox] saved message ${msg.id} in box "${msg.boxId}" (${msg.durationMs}ms from ${msg.callerNumber})`);
} }
/**
* List messages for a voicebox (newest first).
*/
getMessages(boxId: string): IVoicemailMessage[] { getMessages(boxId: string): IVoicemailMessage[] {
return this.loadMessages(boxId); return [...(this.messagesByBox.get(boxId) || [])];
} }
/**
* Get a single message by ID.
*/
getMessage(boxId: string, messageId: string): IVoicemailMessage | null { getMessage(boxId: string, messageId: string): IVoicemailMessage | null {
const messages = this.loadMessages(boxId); const messages = this.messagesByBox.get(boxId) || [];
return messages.find((m) => m.id === messageId) ?? null; return messages.find((m) => m.id === messageId) ?? null;
} }
/** async markHeard(boxId: string, messageId: string): Promise<boolean> {
* Mark a message as heard. const messages = this.messagesByBox.get(boxId) || [];
*/
markHeard(boxId: string, messageId: string): boolean {
const messages = this.loadMessages(boxId);
const msg = messages.find((m) => m.id === messageId); const msg = messages.find((m) => m.id === messageId);
if (!msg) return false; if (!msg) return false;
msg.heard = true; msg.heard = true;
this.writeMessages(boxId, messages); await this.writeMessages(boxId, messages);
return true; return true;
} }
/** async deleteMessage(boxId: string, messageId: string): Promise<boolean> {
* Delete a message (both metadata and WAV file). const messages = this.messagesByBox.get(boxId) || [];
*/
deleteMessage(boxId: string, messageId: string): boolean {
const messages = this.loadMessages(boxId);
const idx = messages.findIndex((m) => m.id === messageId); const idx = messages.findIndex((m) => m.id === messageId);
if (idx === -1) return false; if (idx === -1) return false;
const msg = messages[idx]; const msg = messages[idx];
const boxDir = this.getBoxDir(boxId); await this.storage.removeObject(msg.objectKey);
const wavPath = path.join(boxDir, msg.fileName); if (!msg.objectKey) {
await fsPromises.rm(path.join(this.getBoxDir(boxId), msg.fileName), { force: true }).catch(() => {});
}
// Delete WAV file.
try {
if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath);
} catch { /* best effort */ }
// Remove from list and save.
messages.splice(idx, 1); messages.splice(idx, 1);
this.writeMessages(boxId, messages); await this.writeMessages(boxId, messages);
this.log(`[voicebox] deleted message ${messageId} from box "${boxId}"`); this.log(`[voicebox] deleted message ${messageId} from box "${boxId}"`);
return true; return true;
} }
/** async getMessageAudioPath(boxId: string, messageId: string): Promise<string | null> {
* Get the full file path for a message's WAV file.
*/
getMessageAudioPath(boxId: string, messageId: string): string | null {
const msg = this.getMessage(boxId, messageId); const msg = this.getMessage(boxId, messageId);
if (!msg) return null; if (!msg) return null;
if (msg.objectKey) {
return await this.storage.getObjectAsCachedFile(msg.objectKey, msg.fileName);
}
const filePath = path.join(this.getBoxDir(boxId), msg.fileName); const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
return fs.existsSync(filePath) ? filePath : null; return fs.existsSync(filePath) ? filePath : null;
} }
// -------------------------------------------------------------------------
// Counts
// -------------------------------------------------------------------------
/** Get count of unheard messages for a voicebox. */
getUnheardCount(boxId: string): number { getUnheardCount(boxId: string): number {
const messages = this.loadMessages(boxId); const messages = this.messagesByBox.get(boxId) || [];
return messages.filter((m) => !m.heard).length; return messages.filter((m) => !m.heard).length;
} }
/** Get total message count for a voicebox. */
getTotalCount(boxId: string): number { getTotalCount(boxId: string): number {
return this.loadMessages(boxId).length; return (this.messagesByBox.get(boxId) || []).length;
} }
/** Get unheard counts for all voiceboxes. */
getAllUnheardCounts(): Record<string, number> { getAllUnheardCounts(): Record<string, number> {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const boxId of this.boxes.keys()) { for (const boxId of this.boxes.keys()) {
@@ -291,55 +210,74 @@ export class VoiceboxManager {
return counts; return counts;
} }
// ------------------------------------------------------------------------- async saveCustomGreeting(boxId: string, wavData: Buffer): Promise<string> {
// Greeting management const objectKey = await this.storage.putBufferObject(`voicemail/${boxId}/greeting.wav`, wavData);
// ------------------------------------------------------------------------- const greetingPath = await this.storage.getObjectAsCachedFile(objectKey, `voicemail-${boxId}-greeting.wav`);
/**
* Save a custom greeting WAV file for a voicebox.
*/
saveCustomGreeting(boxId: string, wavData: Buffer): string {
const boxDir = this.getBoxDir(boxId);
fs.mkdirSync(boxDir, { recursive: true });
const greetingPath = path.join(boxDir, 'greeting.wav');
fs.writeFileSync(greetingPath, wavData);
this.log(`[voicebox] saved custom greeting for box "${boxId}"`); this.log(`[voicebox] saved custom greeting for box "${boxId}"`);
return greetingPath; return greetingPath || '';
} }
/** async deleteCustomGreeting(boxId: string): Promise<void> {
* Delete the custom greeting for a voicebox (falls back to TTS). await this.storage.removeObject(`voicemail/${boxId}/greeting.wav`);
*/
deleteCustomGreeting(boxId: string): void {
const boxDir = this.getBoxDir(boxId);
const greetingPath = path.join(boxDir, 'greeting.wav');
try {
if (fs.existsSync(greetingPath)) fs.unlinkSync(greetingPath);
} catch { /* best effort */ }
} }
// ------------------------------------------------------------------------- private async enforceLimit(boxId: string, messages: IVoicemailMessage[]): Promise<void> {
// Internal: JSON persistence const box = this.boxes.get(boxId);
// ------------------------------------------------------------------------- const maxMessages = box?.maxMessages ?? 50;
while (messages.length > maxMessages) {
private messagesPath(boxId: string): string { const old = messages.pop()!;
return path.join(this.getBoxDir(boxId), 'messages.json'); await this.storage.removeObject(old.objectKey);
if (!old.objectKey) {
await fsPromises.rm(path.join(this.getBoxDir(boxId), old.fileName), { force: true }).catch(() => {});
}
}
} }
private loadMessages(boxId: string): IVoicemailMessage[] { private async loadMessages(boxId: string): Promise<IVoicemailMessage[]> {
const filePath = this.messagesPath(boxId); const storedMessages = await this.storage.getVoicemailMessages(boxId);
if (storedMessages.length) return await this.ensureMessageObjects(boxId, storedMessages);
const filePath = path.join(this.getBoxDir(boxId), 'messages.json');
try { try {
if (!fs.existsSync(filePath)) return []; if (!fs.existsSync(filePath)) return [];
const raw = fs.readFileSync(filePath, 'utf8'); const raw = await fsPromises.readFile(filePath, 'utf8');
return JSON.parse(raw) as IVoicemailMessage[]; const legacyMessages = await this.ensureMessageObjects(boxId, JSON.parse(raw) as IVoicemailMessage[]);
await this.storage.writeVoicemailMessages(boxId, legacyMessages);
return legacyMessages;
} catch { } catch {
return []; return [];
} }
} }
private writeMessages(boxId: string, messages: IVoicemailMessage[]): void { private async ensureMessageObjects(boxId: string, messages: IVoicemailMessage[]): Promise<IVoicemailMessage[]> {
const boxDir = this.getBoxDir(boxId); let changed = false;
fs.mkdirSync(boxDir, { recursive: true });
fs.writeFileSync(this.messagesPath(boxId), JSON.stringify(messages, null, 2), 'utf8'); for (const msg of messages) {
if (!msg.id) {
msg.id = crypto.randomUUID();
changed = true;
}
if (msg.objectKey) continue;
const localPath = path.isAbsolute(msg.fileName) ? msg.fileName : path.join(this.getBoxDir(boxId), msg.fileName);
if (!fs.existsSync(localPath)) continue;
const extension = path.extname(localPath) || '.wav';
msg.objectKey = await this.storage.putFileObject(`voicemail/${boxId}/${msg.id}${extension}`, localPath);
msg.fileName = path.basename(localPath);
changed = true;
}
if (changed) {
await this.storage.writeVoicemailMessages(boxId, messages);
this.log(`[voicebox] migrated legacy messages for box "${boxId}" to smartbucket`);
}
return messages;
}
private async writeMessages(boxId: string, messages: IVoicemailMessage[]): Promise<void> {
this.messagesByBox.set(boxId, [...messages]);
await this.storage.writeVoicemailMessages(boxId, messages);
} }
} }