feat(storage): persist siprouter data in smartdata and smartbucket
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
* 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');
|
|
||||||
} catch {
|
|
||||||
throw new Error(`config not found at ${CONFIG_PATH} — create .nogit/config.json`);
|
|
||||||
}
|
}
|
||||||
|
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.
|
// 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
@@ -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
@@ -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
@@ -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(); }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
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);
|
||||||
|
|||||||
+47
-19
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
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,
|
||||||
@@ -214,18 +234,26 @@ initWebUi({
|
|||||||
|
|
||||||
log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`);
|
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', () => {
|
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
@@ -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.
|
* 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user