import fs from 'node:fs'; import * as fsPromises from 'node:fs/promises'; import path from 'node:path'; import type { SiprouterStorage } from './storage.ts'; export interface IFaxBoxConfig { id: string; enabled: boolean; maxMessages?: number; } export interface IFaxMessage { id: string; boxId: string; callerNumber?: string; timestamp: number; fileName: string; objectKey?: string; completionCode?: number | null; completionLabel?: string | null; pageCount?: number; bitRate?: number; } export class FaxBoxManager { private boxes = new Map(); private messagesByBox = new Map(); private readonly basePath: string; private readonly log: (msg: string) => void; private readonly storage: SiprouterStorage; constructor(log: (msg: string) => void, storageArg: SiprouterStorage) { this.basePath = path.join(process.cwd(), '.nogit', 'fax', 'inboxes'); this.log = log; this.storage = storageArg; } async init(faxBoxConfigs: IFaxBoxConfig[]): Promise { this.boxes.clear(); for (const cfg of faxBoxConfigs) { cfg.enabled ??= true; cfg.maxMessages ??= 50; this.boxes.set(cfg.id, cfg); this.messagesByBox.set(cfg.id, await this.loadMessages(cfg.id)); } await fsPromises.mkdir(this.basePath, { recursive: true }); this.log(`[faxbox] initialized ${this.boxes.size} fax box(es)`); } getBox(boxId: string): IFaxBoxConfig | null { return this.boxes.get(boxId) ?? null; } getBoxDir(boxId: string): string { return path.join(this.basePath, boxId); } async prepareOutboundFaxFile(filePathArg: string): Promise { const localPath = path.isAbsolute(filePathArg) ? filePathArg : path.join(process.cwd(), filePathArg); await fsPromises.access(localPath); return localPath; } async addMessage( boxId: string, info: { callerNumber?: string; fileName: string; completionCode?: number | null; completionLabel?: string | null; pageCount?: number; bitRate?: number; }, ): Promise { const id = crypto.randomUUID(); const localPath = path.isAbsolute(info.fileName) ? info.fileName : path.join(process.cwd(), info.fileName); const objectKey = await this.storage.putFileObject(`fax/inboxes/${boxId}/${id}.tif`, localPath); const msg: IFaxMessage = { id, boxId, callerNumber: info.callerNumber, timestamp: Date.now(), fileName: path.basename(localPath), objectKey, completionCode: info.completionCode ?? null, completionLabel: info.completionLabel ?? null, pageCount: info.pageCount, bitRate: info.bitRate, }; const messages = this.getMessages(boxId); messages.unshift(msg); await this.enforceLimit(boxId, messages); await this.writeMessages(boxId, messages); await fsPromises.rm(localPath, { force: true }).catch(() => {}); this.log(`[faxbox] saved fax ${msg.id} in box "${msg.boxId}" (${msg.fileName})`); } getMessages(boxId: string): IFaxMessage[] { return [...(this.messagesByBox.get(boxId) || [])]; } getMessage(boxId: string, messageId: string): IFaxMessage | null { const messages = this.messagesByBox.get(boxId) || []; return messages.find((m) => m.id === messageId) ?? null; } async getMessageFilePath(boxId: string, messageId: string): Promise { const msg = this.getMessage(boxId, messageId); if (!msg) return null; if (msg.objectKey) { return await this.storage.getObjectAsCachedFile(msg.objectKey, msg.fileName); } const filePath = path.join(this.getBoxDir(boxId), msg.fileName); return fs.existsSync(filePath) ? filePath : null; } async deleteMessage(boxId: string, messageId: string): Promise { const messages = this.messagesByBox.get(boxId) || []; const idx = messages.findIndex((m) => m.id === messageId); if (idx === -1) return false; const msg = messages[idx]; await this.storage.removeObject(msg.objectKey); if (!msg.objectKey) { await fsPromises.rm(path.join(this.getBoxDir(boxId), msg.fileName), { force: true }).catch(() => {}); } messages.splice(idx, 1); await this.writeMessages(boxId, messages); return true; } private async enforceLimit(boxId: string, messages: IFaxMessage[]): Promise { 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 async loadMessages(boxId: string): Promise { const storedMessages = await this.storage.getFaxMessages(boxId); if (storedMessages.length) return await this.ensureMessageObjects(boxId, storedMessages); const filePath = path.join(this.getBoxDir(boxId), 'messages.json'); try { if (!fs.existsSync(filePath)) return []; const raw = await fsPromises.readFile(filePath, 'utf8'); const legacyMessages = await this.ensureMessageObjects(boxId, JSON.parse(raw) as IFaxMessage[]); await this.storage.writeFaxMessages(boxId, legacyMessages); return legacyMessages; } catch { return []; } } private async ensureMessageObjects(boxId: string, messages: IFaxMessage[]): Promise { let changed = false; for (const msg of messages) { if (!msg.id) { msg.id = crypto.randomUUID(); changed = true; } if (msg.objectKey) continue; const localPath = path.isAbsolute(msg.fileName) ? msg.fileName : path.join(this.getBoxDir(boxId), msg.fileName); if (!fs.existsSync(localPath)) continue; const extension = path.extname(localPath) || '.tif'; msg.objectKey = await this.storage.putFileObject(`fax/inboxes/${boxId}/${msg.id}${extension}`, localPath); msg.fileName = path.basename(localPath); changed = true; } if (changed) { await this.storage.writeFaxMessages(boxId, messages); this.log(`[faxbox] migrated legacy messages for box "${boxId}" to smartbucket`); } return messages; } private async writeMessages(boxId: string, messages: IFaxMessage[]): Promise { this.messagesByBox.set(boxId, [...messages]); await this.storage.writeFaxMessages(boxId, messages); } }