feat(storage): persist siprouter data in smartdata and smartbucket
This commit is contained in:
@@ -19,5 +19,19 @@
|
||||
"dockerregistry.lossless.digital": "serve.zone/siprouter"
|
||||
},
|
||||
"platforms": ["linux/amd64", "linux/arm64"]
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"release": {
|
||||
"targets": {
|
||||
"git": {
|
||||
"enabled": true,
|
||||
"remote": "origin"
|
||||
},
|
||||
"docker": {
|
||||
"enabled": true,
|
||||
"engine": "tsdocker"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 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)
|
||||
add fax routing, job tracking, inbox management, and T.38/UDPTL media support
|
||||
|
||||
|
||||
+15
-2
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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",
|
||||
"build": "pnpm run buildRust && pnpm run bundle",
|
||||
"build:docker": "tsdocker build --verbose",
|
||||
@@ -15,6 +15,8 @@
|
||||
"dependencies": {
|
||||
"@design.estate/dees-catalog": "^3.81.0",
|
||||
"@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/smartstate": "^2.3.1",
|
||||
"tsx": "^4.21.0",
|
||||
@@ -25,6 +27,17 @@
|
||||
"@git.zone/tsdocker": "^2.2.5",
|
||||
"@git.zone/tsrust": "^1.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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1177
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
mongodb-memory-server: true
|
||||
ignoredBuiltDependencies:
|
||||
- '@design.estate/dees-catalog'
|
||||
+114
-17
@@ -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,21 +263,26 @@ export interface IAppConfig {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loader
|
||||
// Defaults and normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
|
||||
|
||||
export function loadConfig(): IAppConfig {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
} catch {
|
||||
throw new Error(`config not found at ${CONFIG_PATH} — create .nogit/config.json`);
|
||||
function requiredInitialEnv(keyArg: string): string {
|
||||
const value = process.env[keyArg];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required initial config environment variable: ${keyArg}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
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.
|
||||
if (!cfg.proxy) throw new Error('config: missing "proxy" section');
|
||||
if (!cfg.proxy.lanIp) throw new Error('config: missing proxy.lanIp');
|
||||
@@ -335,7 +337,6 @@ export function loadConfig(): IAppConfig {
|
||||
fb.maxMessages ??= 50;
|
||||
}
|
||||
|
||||
// Voicebox defaults.
|
||||
cfg.voiceboxes ??= [];
|
||||
for (const vb of cfg.voiceboxes) {
|
||||
vb.enabled ??= true;
|
||||
@@ -345,7 +346,6 @@ export function loadConfig(): IAppConfig {
|
||||
vb.greetingVoice ??= 'af_bella';
|
||||
}
|
||||
|
||||
// IVR defaults.
|
||||
if (cfg.ivr) {
|
||||
cfg.ivr.enabled ??= false;
|
||||
cfg.ivr.menus ??= [];
|
||||
@@ -357,6 +357,103 @@ export function loadConfig(): IAppConfig {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
+93
-45
@@ -1,6 +1,9 @@
|
||||
import fs from 'node:fs';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { SiprouterStorage } from './storage.ts';
|
||||
|
||||
export interface IFaxBoxConfig {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
@@ -13,6 +16,7 @@ export interface IFaxMessage {
|
||||
callerNumber?: string;
|
||||
timestamp: number;
|
||||
fileName: string;
|
||||
objectKey?: string;
|
||||
completionCode?: number | null;
|
||||
completionLabel?: string | null;
|
||||
pageCount?: number;
|
||||
@@ -21,24 +25,28 @@ export interface IFaxMessage {
|
||||
|
||||
export class FaxBoxManager {
|
||||
private boxes = new Map<string, IFaxBoxConfig>();
|
||||
private messagesByBox = new Map<string, IFaxMessage[]>();
|
||||
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', 'fax', 'inboxes');
|
||||
this.log = log;
|
||||
this.storage = storageArg;
|
||||
}
|
||||
|
||||
init(faxBoxConfigs: IFaxBoxConfig[]): void {
|
||||
async init(faxBoxConfigs: IFaxBoxConfig[]): Promise<void> {
|
||||
this.boxes.clear();
|
||||
|
||||
for (const cfg of faxBoxConfigs) {
|
||||
cfg.enabled ??= true;
|
||||
cfg.maxMessages ??= 50;
|
||||
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)`);
|
||||
}
|
||||
|
||||
@@ -50,7 +58,13 @@ export class FaxBoxManager {
|
||||
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,
|
||||
info: {
|
||||
callerNumber?: string;
|
||||
@@ -60,90 +74,124 @@ export class FaxBoxManager {
|
||||
pageCount?: 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 = {
|
||||
id: crypto.randomUUID(),
|
||||
id,
|
||||
boxId,
|
||||
callerNumber: info.callerNumber,
|
||||
timestamp: Date.now(),
|
||||
fileName: path.basename(info.fileName),
|
||||
fileName: path.basename(localPath),
|
||||
objectKey,
|
||||
completionCode: info.completionCode ?? null,
|
||||
completionLabel: info.completionLabel ?? null,
|
||||
pageCount: info.pageCount,
|
||||
bitRate: info.bitRate,
|
||||
};
|
||||
this.saveMessage(msg);
|
||||
}
|
||||
|
||||
saveMessage(msg: IFaxMessage): void {
|
||||
const boxDir = this.getBoxDir(msg.boxId);
|
||||
fs.mkdirSync(boxDir, { recursive: true });
|
||||
|
||||
const messages = this.loadMessages(msg.boxId);
|
||||
const messages = this.getMessages(boxId);
|
||||
messages.unshift(msg);
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
||||
this.writeMessages(msg.boxId, messages);
|
||||
await this.enforceLimit(boxId, messages);
|
||||
await this.writeMessages(boxId, messages);
|
||||
await fsPromises.rm(localPath, { force: true }).catch(() => {});
|
||||
this.log(`[faxbox] saved fax ${msg.id} in box "${msg.boxId}" (${msg.fileName})`);
|
||||
}
|
||||
|
||||
getMessages(boxId: string): IFaxMessage[] {
|
||||
return this.loadMessages(boxId);
|
||||
return [...(this.messagesByBox.get(boxId) || [])];
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
return fs.existsSync(filePath) ? filePath : null;
|
||||
}
|
||||
|
||||
deleteMessage(boxId: string, messageId: string): boolean {
|
||||
const messages = this.loadMessages(boxId);
|
||||
async deleteMessage(boxId: string, messageId: string): Promise<boolean> {
|
||||
const messages = this.messagesByBox.get(boxId) || [];
|
||||
const idx = messages.findIndex((m) => m.id === messageId);
|
||||
if (idx === -1) return false;
|
||||
|
||||
const msg = messages[idx];
|
||||
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
|
||||
try {
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
} catch {}
|
||||
await this.storage.removeObject(msg.objectKey);
|
||||
if (!msg.objectKey) {
|
||||
await fsPromises.rm(path.join(this.getBoxDir(boxId), msg.fileName), { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
messages.splice(idx, 1);
|
||||
this.writeMessages(boxId, messages);
|
||||
await this.writeMessages(boxId, messages);
|
||||
return true;
|
||||
}
|
||||
|
||||
private messagesPath(boxId: string): string {
|
||||
return path.join(this.getBoxDir(boxId), 'messages.json');
|
||||
private async enforceLimit(boxId: string, messages: IFaxMessage[]): Promise<void> {
|
||||
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[] {
|
||||
const filePath = this.messagesPath(boxId);
|
||||
private async loadMessages(boxId: string): Promise<IFaxMessage[]> {
|
||||
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 {
|
||||
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 {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private writeMessages(boxId: string, messages: IFaxMessage[]): void {
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
fs.mkdirSync(boxDir, { recursive: true });
|
||||
fs.writeFileSync(this.messagesPath(boxId), JSON.stringify(messages, null, 2), 'utf8');
|
||||
private async ensureMessageObjects(boxId: string, messages: IFaxMessage[]): Promise<IFaxMessage[]> {
|
||||
let changed = false;
|
||||
|
||||
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
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { SiprouterStorage } from './storage.ts';
|
||||
import type {
|
||||
IFaxCompletedEvent,
|
||||
IFaxFailedEvent,
|
||||
@@ -16,6 +17,7 @@ export interface IFaxJob {
|
||||
status: 'dialing' | 'started' | 'completed' | 'failed';
|
||||
transport?: 'audio' | 't38';
|
||||
filePath?: string;
|
||||
objectKey?: string;
|
||||
codec?: string;
|
||||
remoteMedia?: string;
|
||||
success?: boolean;
|
||||
@@ -28,25 +30,21 @@ export interface IFaxJob {
|
||||
}
|
||||
|
||||
export class FaxJobManager {
|
||||
private readonly basePath: string;
|
||||
private readonly jobsPath: string;
|
||||
private jobs: IFaxJob[] = [];
|
||||
private readonly log: (msg: string) => void;
|
||||
private readonly storage: SiprouterStorage;
|
||||
|
||||
constructor(log: (msg: string) => void) {
|
||||
this.basePath = path.join(process.cwd(), '.nogit', 'fax');
|
||||
this.jobsPath = path.join(this.basePath, 'jobs.json');
|
||||
constructor(log: (msg: string) => void, storageArg: SiprouterStorage) {
|
||||
this.log = log;
|
||||
this.storage = storageArg;
|
||||
}
|
||||
|
||||
init(): void {
|
||||
fs.mkdirSync(this.basePath, { recursive: true });
|
||||
if (!fs.existsSync(this.jobsPath)) {
|
||||
this.writeJobs([]);
|
||||
}
|
||||
async init(): Promise<void> {
|
||||
this.jobs = await this.storage.getFaxJobs();
|
||||
}
|
||||
|
||||
noteDialing(callId: string, number: string, providerId: string): void {
|
||||
const jobs = this.loadJobs();
|
||||
async noteDialing(callId: string, number: string, providerId: string): Promise<void> {
|
||||
const jobs = this.jobs;
|
||||
const now = Date.now();
|
||||
const existing = jobs.find((job) => job.callId === callId);
|
||||
if (existing) {
|
||||
@@ -65,62 +63,61 @@ export class FaxJobManager {
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
this.writeJobs(jobs);
|
||||
await this.writeJobs();
|
||||
}
|
||||
|
||||
noteStarted(event: IFaxStartedEvent): void {
|
||||
const jobs = this.loadJobs();
|
||||
async noteStarted(event: IFaxStartedEvent): Promise<void> {
|
||||
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.transport = event.transport;
|
||||
job.filePath = event.file_path;
|
||||
await this.ensureOutboundFileObject(job, event.file_path);
|
||||
job.codec = event.codec;
|
||||
job.remoteMedia = event.remote_media;
|
||||
job.updatedAt = now;
|
||||
this.writeJobs(jobs);
|
||||
await this.writeJobs();
|
||||
}
|
||||
|
||||
noteCompleted(event: IFaxCompletedEvent): void {
|
||||
const jobs = this.loadJobs();
|
||||
async noteCompleted(event: IFaxCompletedEvent): Promise<void> {
|
||||
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.transport = event.transport;
|
||||
job.filePath = event.file_path;
|
||||
await this.ensureOutboundFileObject(job, event.file_path);
|
||||
job.codec = event.codec;
|
||||
job.success = event.success;
|
||||
job.completionCode = event.completion_code ?? null;
|
||||
job.completionLabel = event.completion_label ?? null;
|
||||
job.stats = event.stats;
|
||||
job.updatedAt = now;
|
||||
this.writeJobs(jobs);
|
||||
await this.writeJobs();
|
||||
}
|
||||
|
||||
noteFailed(event: IFaxFailedEvent): void {
|
||||
const jobs = this.loadJobs();
|
||||
async noteFailed(event: IFaxFailedEvent): Promise<void> {
|
||||
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.transport = event.transport;
|
||||
job.filePath = event.file_path;
|
||||
await this.ensureOutboundFileObject(job, event.file_path);
|
||||
job.error = event.error;
|
||||
job.success = false;
|
||||
job.updatedAt = now;
|
||||
this.writeJobs(jobs);
|
||||
await this.writeJobs();
|
||||
}
|
||||
|
||||
getJobs(): IFaxJob[] {
|
||||
return this.loadJobs();
|
||||
return [...this.jobs];
|
||||
}
|
||||
|
||||
private getOrCreateJob(
|
||||
jobs: IFaxJob[],
|
||||
callId: string,
|
||||
direction: 'outbound' | 'inbound',
|
||||
now: number,
|
||||
): IFaxJob {
|
||||
let job = jobs.find((entry) => entry.callId === callId);
|
||||
let job = this.jobs.find((entry) => entry.callId === callId);
|
||||
if (!job) {
|
||||
job = {
|
||||
id: callId,
|
||||
@@ -130,24 +127,23 @@ export class FaxJobManager {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
jobs.unshift(job);
|
||||
this.jobs.unshift(job);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
private loadJobs(): IFaxJob[] {
|
||||
try {
|
||||
const content = fs.readFileSync(this.jobsPath, 'utf8');
|
||||
const parsed = JSON.parse(content);
|
||||
return Array.isArray(parsed) ? parsed as IFaxJob[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
private async ensureOutboundFileObject(jobArg: IFaxJob, filePathArg: string | undefined): Promise<void> {
|
||||
if (jobArg.direction !== 'outbound' || jobArg.objectKey || !filePathArg) return;
|
||||
|
||||
const localPath = path.isAbsolute(filePathArg) ? filePathArg : path.join(process.cwd(), filePathArg);
|
||||
if (!fs.existsSync(localPath)) return;
|
||||
|
||||
const extension = path.extname(localPath) || '.tif';
|
||||
jobArg.objectKey = await this.storage.putFileObject(`fax/outbound/${jobArg.callId}${extension}`, localPath);
|
||||
}
|
||||
|
||||
private writeJobs(jobs: IFaxJob[]): void {
|
||||
fs.mkdirSync(this.basePath, { recursive: true });
|
||||
fs.writeFileSync(this.jobsPath, JSON.stringify(jobs, null, 2));
|
||||
this.log(`[fax] persisted ${jobs.length} job(s)`);
|
||||
private async writeJobs(): Promise<void> {
|
||||
await this.storage.writeFaxJobs(this.jobs);
|
||||
this.log(`[fax] persisted ${this.jobs.length} job(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-76
@@ -11,19 +11,19 @@ import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { maskConfig, type IAppConfig } from './config.ts';
|
||||
import type { FaxBoxManager } from './faxbox.ts';
|
||||
import type { FaxJobManager } from './faxjobs.ts';
|
||||
import { handleWebRtcSignaling } from './webrtcbridge.ts';
|
||||
import type { VoiceboxManager } from './voicebox.ts';
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
|
||||
|
||||
interface IHandleRequestContext {
|
||||
getStatus: () => unknown;
|
||||
getConfig: () => IAppConfig;
|
||||
updateConfig: (updatesArg: any) => Promise<IAppConfig>;
|
||||
log: (msg: string) => void;
|
||||
onStartCall: (number: string, deviceId?: string, providerId?: string) => Promise<{ id: string } | null>;
|
||||
onHangupCall: (callId: string) => boolean;
|
||||
onConfigSaved?: () => void | Promise<void>;
|
||||
faxBoxManager?: FaxBoxManager;
|
||||
faxJobManager?: FaxJobManager;
|
||||
voiceboxManager?: VoiceboxManager;
|
||||
@@ -112,7 +112,7 @@ async function handleRequest(
|
||||
res: http.ServerResponse,
|
||||
context: IHandleRequestContext,
|
||||
): 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 method = req.method || 'GET';
|
||||
|
||||
@@ -156,13 +156,16 @@ async function handleRequest(
|
||||
try {
|
||||
const body = await readJsonBody(req);
|
||||
const number = body?.number;
|
||||
const filePath = body?.filePath;
|
||||
let filePath = body?.filePath;
|
||||
if (!number || typeof number !== 'string') {
|
||||
return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
|
||||
}
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
return sendJson(res, { ok: false, error: 'missing "filePath" field' }, 400);
|
||||
}
|
||||
if (faxBoxManager) {
|
||||
filePath = await faxBoxManager.prepareOutboundFaxFile(filePath);
|
||||
}
|
||||
const { sendFax } = await import('./proxybridge.ts');
|
||||
const callId = await sendFax(number, filePath, body?.providerId);
|
||||
if (callId) {
|
||||
@@ -191,7 +194,7 @@ async function handleRequest(
|
||||
const faxFileMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)\/file$/);
|
||||
if (faxFileMatch && method === 'GET' && faxBoxManager) {
|
||||
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);
|
||||
const stat = fs.statSync(filePath);
|
||||
res.writeHead(200, {
|
||||
@@ -207,7 +210,7 @@ async function handleRequest(
|
||||
const faxDeleteMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)$/);
|
||||
if (faxDeleteMatch && method === 'DELETE' && faxBoxManager) {
|
||||
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).
|
||||
@@ -272,10 +275,7 @@ async function handleRequest(
|
||||
// API: get config (sans passwords).
|
||||
if (url.pathname === '/api/config' && method === 'GET') {
|
||||
try {
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const cfg = JSON.parse(raw);
|
||||
const safe = { ...cfg, providers: cfg.providers?.map((p: any) => ({ ...p, password: '••••••' })) };
|
||||
return sendJson(res, safe);
|
||||
return sendJson(res, maskConfig(getConfig()));
|
||||
} catch (e: any) {
|
||||
return sendJson(res, { ok: false, error: e.message }, 500);
|
||||
}
|
||||
@@ -285,65 +285,9 @@ async function handleRequest(
|
||||
if (url.pathname === '/api/config' && method === 'POST') {
|
||||
try {
|
||||
const updates = await readJsonBody(req);
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const cfg = JSON.parse(raw);
|
||||
|
||||
// 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 });
|
||||
const config = await updateConfig(updates);
|
||||
log('[config] updated smartdata config');
|
||||
return sendJson(res, { ok: true, config: maskConfig(config) });
|
||||
} catch (e: any) {
|
||||
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||
}
|
||||
@@ -367,7 +311,7 @@ async function handleRequest(
|
||||
const vmAudioMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/audio$/);
|
||||
if (vmAudioMatch && method === 'GET' && voiceboxManager) {
|
||||
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);
|
||||
const stat = fs.statSync(audioPath);
|
||||
res.writeHead(200, {
|
||||
@@ -383,14 +327,14 @@ async function handleRequest(
|
||||
const vmHeardMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/heard$/);
|
||||
if (vmHeardMatch && method === 'POST' && voiceboxManager) {
|
||||
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.
|
||||
const vmDeleteMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)$/);
|
||||
if (vmDeleteMatch && method === 'DELETE' && voiceboxManager) {
|
||||
const [, boxId, msgId] = vmDeleteMatch;
|
||||
return sendJson(res, { ok: voiceboxManager.deleteMessage(boxId, msgId) });
|
||||
return sendJson(res, { ok: await voiceboxManager.deleteMessage(boxId, msgId) });
|
||||
}
|
||||
|
||||
// Static files.
|
||||
@@ -428,10 +372,11 @@ export function initWebUi(
|
||||
const {
|
||||
port,
|
||||
getStatus,
|
||||
getConfig,
|
||||
updateConfig,
|
||||
log,
|
||||
onStartCall,
|
||||
onHangupCall,
|
||||
onConfigSaved,
|
||||
faxBoxManager,
|
||||
faxJobManager,
|
||||
voiceboxManager,
|
||||
@@ -453,12 +398,12 @@ export function initWebUi(
|
||||
const cert = fs.readFileSync(certPath, 'utf8');
|
||||
const key = fs.readFileSync(keyPath, 'utf8');
|
||||
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;
|
||||
} catch {
|
||||
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(); }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import * as smartbucket from '@push.rocks/smartbucket';
|
||||
import * as smartdata from '@push.rocks/smartdata';
|
||||
|
||||
export { smartbucket, smartdata };
|
||||
@@ -102,7 +102,8 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
statusStore.noteOutboundCallStarted(data);
|
||||
|
||||
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) {
|
||||
@@ -218,12 +219,12 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
onProxyEvent('recording_done', (data) => {
|
||||
const boxId = data.voicebox_id || 'default';
|
||||
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',
|
||||
callerName: null,
|
||||
fileName: data.file_path,
|
||||
durationMs: data.duration_ms,
|
||||
});
|
||||
}).catch((error) => log(`[voicemail] persist failed: ${error instanceof Error ? error.message : String(error)}`));
|
||||
});
|
||||
|
||||
onProxyEvent('voicemail_error', (data) => {
|
||||
@@ -231,24 +232,24 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
});
|
||||
|
||||
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}`);
|
||||
});
|
||||
|
||||
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(
|
||||
`[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) {
|
||||
faxBoxManager.addMessage(data.fax_box_id, {
|
||||
void faxBoxManager.addMessage(data.fax_box_id, {
|
||||
callerNumber: data.caller_number,
|
||||
fileName: data.file_path,
|
||||
completionCode: data.completion_code,
|
||||
completionLabel: data.completion_label,
|
||||
pageCount: data.stats.pages_rx || data.stats.pages_tx,
|
||||
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) {
|
||||
void hangupCall(data.call_id);
|
||||
@@ -256,7 +257,7 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
});
|
||||
|
||||
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}`);
|
||||
if (data.direction === 'outbound' || data.fax_box_id) {
|
||||
void hangupCall(data.call_id);
|
||||
|
||||
+47
-19
@@ -8,7 +8,7 @@
|
||||
import fs from 'node:fs';
|
||||
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 { FaxJobManager } from './faxjobs.ts';
|
||||
import { broadcastWs, initWebUi } from './frontend.ts';
|
||||
@@ -27,24 +27,21 @@ import {
|
||||
} from './proxybridge.ts';
|
||||
import { registerProxyEventHandlers } from './runtime/proxy-events.ts';
|
||||
import { StatusStore } from './runtime/status-store.ts';
|
||||
import { SiprouterStorage } from './storage.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 startTime = Date.now();
|
||||
const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const statusStore = new StatusStore(appConfig);
|
||||
const webRtcLinks = new WebRtcLinkManager();
|
||||
const faxBoxManager = new FaxBoxManager(log);
|
||||
const faxJobManager = new FaxJobManager(log);
|
||||
const voiceboxManager = new VoiceboxManager(log);
|
||||
|
||||
faxBoxManager.init(appConfig.faxboxes ?? []);
|
||||
faxJobManager.init();
|
||||
voiceboxManager.init(appConfig.voiceboxes ?? []);
|
||||
initWebRtcSignaling({ log });
|
||||
const storage = new SiprouterStorage(log);
|
||||
let statusStore: StatusStore;
|
||||
let webRtcLinks: WebRtcLinkManager;
|
||||
let faxBoxManager: FaxBoxManager;
|
||||
let faxJobManager: FaxJobManager;
|
||||
let voiceboxManager: VoiceboxManager;
|
||||
|
||||
function now(): string {
|
||||
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> {
|
||||
try {
|
||||
const previousConfig = appConfig;
|
||||
const nextConfig = loadConfig();
|
||||
const nextConfig = await storage.getAppConfig();
|
||||
|
||||
appConfig = nextConfig;
|
||||
statusStore.updateConfig(nextConfig);
|
||||
faxBoxManager.init(nextConfig.faxboxes ?? []);
|
||||
voiceboxManager.init(nextConfig.voiceboxes ?? []);
|
||||
await faxBoxManager.init(nextConfig.faxboxes ?? []);
|
||||
await voiceboxManager.init(nextConfig.voiceboxes ?? []);
|
||||
|
||||
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
|
||||
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> {
|
||||
const started = await initProxyEngine(log);
|
||||
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}`);
|
||||
}
|
||||
|
||||
initWebUi({
|
||||
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({
|
||||
port: appConfig.proxy.webUiPort,
|
||||
getStatus,
|
||||
getConfig: () => appConfig,
|
||||
updateConfig,
|
||||
log,
|
||||
onStartCall: async (number, deviceId, providerId) => {
|
||||
log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`);
|
||||
@@ -174,7 +195,6 @@ initWebUi({
|
||||
void hangupCall(callId);
|
||||
return true;
|
||||
},
|
||||
onConfigSaved: reloadConfig,
|
||||
faxBoxManager,
|
||||
faxJobManager,
|
||||
voiceboxManager,
|
||||
@@ -214,18 +234,26 @@ initWebUi({
|
||||
|
||||
log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
void startProxyEngine();
|
||||
await startProxyEngine();
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
log(`[FATAL] ${errorMessage(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
log('SIGINT, exiting');
|
||||
shutdownProxyEngine();
|
||||
void storage.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
log('SIGTERM, exiting');
|
||||
shutdownProxyEngine();
|
||||
void storage.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
+250
@@ -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
@@ -1,22 +1,12 @@
|
||||
/**
|
||||
* VoiceboxManager — manages voicemail boxes, message storage, and MWI.
|
||||
*
|
||||
* 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)
|
||||
* VoiceboxManager — manages voicemail boxes, message metadata, and audio objects.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
import type { SiprouterStorage } from './storage.ts';
|
||||
|
||||
export interface IVoiceboxConfig {
|
||||
/** Unique ID — typically matches device ID or extension. */
|
||||
@@ -27,11 +17,9 @@ export interface IVoiceboxConfig {
|
||||
greetingText?: string;
|
||||
/** Kokoro TTS voice ID for the greeting (default 'af_bella'). */
|
||||
greetingVoice?: string;
|
||||
/** Path to uploaded WAV greeting (overrides TTS). */
|
||||
/** Path to cached uploaded WAV greeting (overrides TTS). */
|
||||
greetingWavPath?: string;
|
||||
/** Seconds to wait before routing to voicemail. Defaults to 25 when
|
||||
* absent — both the config loader and `VoiceboxManager.init` apply
|
||||
* the default via `??=`. */
|
||||
/** Seconds to wait before routing to voicemail. */
|
||||
noAnswerTimeoutSec?: number;
|
||||
/** Maximum recording duration in seconds. Defaults to 120. */
|
||||
maxRecordingSec?: number;
|
||||
@@ -52,112 +40,80 @@ export interface IVoicemailMessage {
|
||||
timestamp: number;
|
||||
/** Duration in milliseconds. */
|
||||
durationMs: number;
|
||||
/** Relative path to the WAV file (within the box directory). */
|
||||
/** Display file name. */
|
||||
fileName: string;
|
||||
/** SmartBucket object key for the WAV payload. */
|
||||
objectKey?: string;
|
||||
/** Whether the message has been listened to. */
|
||||
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.';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VoiceboxManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class VoiceboxManager {
|
||||
private boxes = new Map<string, IVoiceboxConfig>();
|
||||
private basePath: string;
|
||||
private log: (msg: string) => void;
|
||||
private messagesByBox = new Map<string, IVoicemailMessage[]>();
|
||||
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.log = log;
|
||||
this.storage = storageArg;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load voicebox configurations from the app config.
|
||||
*/
|
||||
init(voiceboxConfigs: IVoiceboxConfig[]): void {
|
||||
async init(voiceboxConfigs: IVoiceboxConfig[]): Promise<void> {
|
||||
this.boxes.clear();
|
||||
|
||||
for (const cfg of voiceboxConfigs) {
|
||||
// Apply defaults.
|
||||
cfg.noAnswerTimeoutSec ??= 25;
|
||||
cfg.maxRecordingSec ??= 120;
|
||||
cfg.maxMessages ??= 50;
|
||||
cfg.greetingVoice ??= 'af_bella';
|
||||
|
||||
this.boxes.set(cfg.id, cfg);
|
||||
this.messagesByBox.set(cfg.id, await this.loadMessages(cfg.id));
|
||||
}
|
||||
|
||||
// Ensure base directory exists.
|
||||
fs.mkdirSync(this.basePath, { recursive: true });
|
||||
|
||||
await fsPromises.mkdir(this.basePath, { recursive: true });
|
||||
this.log(`[voicebox] initialized ${this.boxes.size} voicebox(es)`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Box management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Get config for a specific voicebox. */
|
||||
getBox(boxId: string): IVoiceboxConfig | null {
|
||||
return this.boxes.get(boxId) ?? null;
|
||||
}
|
||||
|
||||
/** Get all configured voicebox IDs. */
|
||||
getBoxIds(): string[] {
|
||||
return [...this.boxes.keys()];
|
||||
}
|
||||
|
||||
/** Get the greeting text for a voicebox. */
|
||||
getGreetingText(boxId: string): string {
|
||||
const box = this.boxes.get(boxId);
|
||||
return box?.greetingText || DEFAULT_GREETING;
|
||||
}
|
||||
|
||||
/** Get the greeting voice for a voicebox. */
|
||||
getGreetingVoice(boxId: string): string {
|
||||
const box = this.boxes.get(boxId);
|
||||
return box?.greetingVoice || 'af_bella';
|
||||
}
|
||||
|
||||
/** Check if a voicebox has a custom WAV greeting. */
|
||||
hasCustomGreetingWav(boxId: string): boolean {
|
||||
const box = this.boxes.get(boxId);
|
||||
if (!box?.greetingWavPath) return false;
|
||||
return fs.existsSync(box.greetingWavPath);
|
||||
}
|
||||
|
||||
/** Get the greeting WAV path (custom or null). */
|
||||
getCustomGreetingWavPath(boxId: string): string | null {
|
||||
const box = this.boxes.get(boxId);
|
||||
if (!box?.greetingWavPath) return null;
|
||||
return fs.existsSync(box.greetingWavPath) ? box.greetingWavPath : null;
|
||||
}
|
||||
|
||||
/** Get the directory path for a voicebox. */
|
||||
getBoxDir(boxId: string): string {
|
||||
return path.join(this.basePath, boxId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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(
|
||||
async addMessage(
|
||||
boxId: string,
|
||||
info: {
|
||||
callerNumber: string;
|
||||
@@ -165,124 +121,87 @@ export class VoiceboxManager {
|
||||
fileName: string;
|
||||
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 = {
|
||||
id: crypto.randomUUID(),
|
||||
id,
|
||||
boxId,
|
||||
callerNumber: info.callerNumber,
|
||||
callerName: info.callerName ?? undefined,
|
||||
timestamp: Date.now(),
|
||||
durationMs: info.durationMs,
|
||||
fileName: path.basename(info.fileName),
|
||||
fileName: path.basename(localPath),
|
||||
objectKey,
|
||||
heard: false,
|
||||
};
|
||||
this.saveMessage(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new voicemail message.
|
||||
* The WAV file should already exist at the expected path.
|
||||
*/
|
||||
saveMessage(msg: IVoicemailMessage): void {
|
||||
const boxDir = this.getBoxDir(msg.boxId);
|
||||
fs.mkdirSync(boxDir, { recursive: true });
|
||||
const messages = this.getMessages(boxId);
|
||||
messages.unshift(msg);
|
||||
await this.enforceLimit(boxId, messages);
|
||||
await this.writeMessages(boxId, messages);
|
||||
|
||||
const messages = this.loadMessages(msg.boxId);
|
||||
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);
|
||||
await fsPromises.rm(localPath, { force: true }).catch(() => {});
|
||||
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[] {
|
||||
return this.loadMessages(boxId);
|
||||
return [...(this.messagesByBox.get(boxId) || [])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single message by ID.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a message as heard.
|
||||
*/
|
||||
markHeard(boxId: string, messageId: string): boolean {
|
||||
const messages = this.loadMessages(boxId);
|
||||
async markHeard(boxId: string, messageId: string): Promise<boolean> {
|
||||
const messages = this.messagesByBox.get(boxId) || [];
|
||||
const msg = messages.find((m) => m.id === messageId);
|
||||
if (!msg) return false;
|
||||
|
||||
msg.heard = true;
|
||||
this.writeMessages(boxId, messages);
|
||||
await this.writeMessages(boxId, messages);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message (both metadata and WAV file).
|
||||
*/
|
||||
deleteMessage(boxId: string, messageId: string): boolean {
|
||||
const messages = this.loadMessages(boxId);
|
||||
async deleteMessage(boxId: string, messageId: string): Promise<boolean> {
|
||||
const messages = this.messagesByBox.get(boxId) || [];
|
||||
const idx = messages.findIndex((m) => m.id === messageId);
|
||||
if (idx === -1) return false;
|
||||
|
||||
const msg = messages[idx];
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
const wavPath = path.join(boxDir, msg.fileName);
|
||||
await this.storage.removeObject(msg.objectKey);
|
||||
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);
|
||||
this.writeMessages(boxId, messages);
|
||||
await this.writeMessages(boxId, messages);
|
||||
this.log(`[voicebox] deleted message ${messageId} from box "${boxId}"`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full file path for a message's WAV file.
|
||||
*/
|
||||
getMessageAudioPath(boxId: string, messageId: string): string | null {
|
||||
async getMessageAudioPath(boxId: string, messageId: string): Promise<string | null> {
|
||||
const msg = this.getMessage(boxId, messageId);
|
||||
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);
|
||||
return fs.existsSync(filePath) ? filePath : null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Counts
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Get count of unheard messages for a voicebox. */
|
||||
getUnheardCount(boxId: string): number {
|
||||
const messages = this.loadMessages(boxId);
|
||||
const messages = this.messagesByBox.get(boxId) || [];
|
||||
return messages.filter((m) => !m.heard).length;
|
||||
}
|
||||
|
||||
/** Get total message count for a voicebox. */
|
||||
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> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const boxId of this.boxes.keys()) {
|
||||
@@ -291,55 +210,74 @@ export class VoiceboxManager {
|
||||
return counts;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Greeting management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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);
|
||||
async saveCustomGreeting(boxId: string, wavData: Buffer): Promise<string> {
|
||||
const objectKey = await this.storage.putBufferObject(`voicemail/${boxId}/greeting.wav`, wavData);
|
||||
const greetingPath = await this.storage.getObjectAsCachedFile(objectKey, `voicemail-${boxId}-greeting.wav`);
|
||||
this.log(`[voicebox] saved custom greeting for box "${boxId}"`);
|
||||
return greetingPath;
|
||||
return greetingPath || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the custom greeting for a voicebox (falls back to TTS).
|
||||
*/
|
||||
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 */ }
|
||||
async deleteCustomGreeting(boxId: string): Promise<void> {
|
||||
await this.storage.removeObject(`voicemail/${boxId}/greeting.wav`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal: JSON persistence
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private messagesPath(boxId: string): string {
|
||||
return path.join(this.getBoxDir(boxId), 'messages.json');
|
||||
private async enforceLimit(boxId: string, messages: IVoicemailMessage[]): Promise<void> {
|
||||
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): IVoicemailMessage[] {
|
||||
const filePath = this.messagesPath(boxId);
|
||||
private async loadMessages(boxId: string): Promise<IVoicemailMessage[]> {
|
||||
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 {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(raw) as IVoicemailMessage[];
|
||||
const raw = await fsPromises.readFile(filePath, 'utf8');
|
||||
const legacyMessages = await this.ensureMessageObjects(boxId, JSON.parse(raw) as IVoicemailMessage[]);
|
||||
await this.storage.writeVoicemailMessages(boxId, legacyMessages);
|
||||
return legacyMessages;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private writeMessages(boxId: string, messages: IVoicemailMessage[]): void {
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
fs.mkdirSync(boxDir, { recursive: true });
|
||||
fs.writeFileSync(this.messagesPath(boxId), JSON.stringify(messages, null, 2), 'utf8');
|
||||
private async ensureMessageObjects(boxId: string, messages: IVoicemailMessage[]): Promise<IVoicemailMessage[]> {
|
||||
let changed = false;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user