/** * VoiceboxManager — manages voicemail boxes, message metadata, and audio objects. */ import fs from 'node:fs'; import * as fsPromises from 'node:fs/promises'; import path from 'node:path'; import type { SiprouterStorage } from './storage.ts'; export interface IVoiceboxConfig { /** Unique ID — typically matches device ID or extension. */ id: string; /** Whether this voicebox is active. */ enabled: boolean; /** Custom TTS greeting text (overrides default). */ greetingText?: string; /** Kokoro TTS voice ID for the greeting (default 'af_bella'). */ greetingVoice?: string; /** Path to cached uploaded WAV greeting (overrides TTS). */ greetingWavPath?: string; /** Seconds to wait before routing to voicemail. */ noAnswerTimeoutSec?: number; /** Maximum recording duration in seconds. Defaults to 120. */ maxRecordingSec?: number; /** Maximum stored messages per box. Defaults to 50. */ maxMessages?: number; } export interface IVoicemailMessage { /** Unique message ID. */ id: string; /** Which voicebox this message belongs to. */ boxId: string; /** Caller's phone number. */ callerNumber: string; /** Caller's display name (if available from SIP From header). */ callerName?: string; /** Unix timestamp (ms) when the message was recorded. */ timestamp: number; /** Duration in milliseconds. */ durationMs: number; /** Display file name. */ fileName: string; /** SmartBucket object key for the WAV payload. */ objectKey?: string; /** Whether the message has been listened to. */ heard: boolean; } const DEFAULT_GREETING = 'The person you are trying to reach is not available. Please leave a message after the tone.'; export class VoiceboxManager { 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', 'voicemail'); this.log = log; this.storage = storageArg; } async init(voiceboxConfigs: IVoiceboxConfig[]): Promise { this.boxes.clear(); for (const cfg of voiceboxConfigs) { cfg.noAnswerTimeoutSec ??= 25; cfg.maxRecordingSec ??= 120; cfg.maxMessages ??= 50; cfg.greetingVoice ??= 'af_bella'; this.boxes.set(cfg.id, cfg); this.messagesByBox.set(cfg.id, await this.loadMessages(cfg.id)); } await fsPromises.mkdir(this.basePath, { recursive: true }); this.log(`[voicebox] initialized ${this.boxes.size} voicebox(es)`); } getBox(boxId: string): IVoiceboxConfig | null { return this.boxes.get(boxId) ?? null; } getBoxIds(): string[] { return [...this.boxes.keys()]; } getGreetingText(boxId: string): string { const box = this.boxes.get(boxId); return box?.greetingText || DEFAULT_GREETING; } getGreetingVoice(boxId: string): string { const box = this.boxes.get(boxId); return box?.greetingVoice || 'af_bella'; } hasCustomGreetingWav(boxId: string): boolean { const box = this.boxes.get(boxId); if (!box?.greetingWavPath) return false; return fs.existsSync(box.greetingWavPath); } getCustomGreetingWavPath(boxId: string): string | null { const box = this.boxes.get(boxId); if (!box?.greetingWavPath) return null; return fs.existsSync(box.greetingWavPath) ? box.greetingWavPath : null; } getBoxDir(boxId: string): string { return path.join(this.basePath, boxId); } async addMessage( boxId: string, info: { callerNumber: string; callerName?: string | null; fileName: string; durationMs: 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(`voicemail/${boxId}/${id}.wav`, localPath); const msg: IVoicemailMessage = { id, boxId, callerNumber: info.callerNumber, callerName: info.callerName ?? undefined, timestamp: Date.now(), durationMs: info.durationMs, fileName: path.basename(localPath), objectKey, heard: false, }; 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(`[voicebox] saved message ${msg.id} in box "${msg.boxId}" (${msg.durationMs}ms from ${msg.callerNumber})`); } getMessages(boxId: string): IVoicemailMessage[] { return [...(this.messagesByBox.get(boxId) || [])]; } getMessage(boxId: string, messageId: string): IVoicemailMessage | null { const messages = this.messagesByBox.get(boxId) || []; return messages.find((m) => m.id === messageId) ?? null; } async markHeard(boxId: string, messageId: string): Promise { const messages = this.messagesByBox.get(boxId) || []; const msg = messages.find((m) => m.id === messageId); if (!msg) return false; msg.heard = true; await this.writeMessages(boxId, messages); return true; } 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); this.log(`[voicebox] deleted message ${messageId} from box "${boxId}"`); return true; } async getMessageAudioPath(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; } getUnheardCount(boxId: string): number { const messages = this.messagesByBox.get(boxId) || []; return messages.filter((m) => !m.heard).length; } getTotalCount(boxId: string): number { return (this.messagesByBox.get(boxId) || []).length; } getAllUnheardCounts(): Record { const counts: Record = {}; for (const boxId of this.boxes.keys()) { counts[boxId] = this.getUnheardCount(boxId); } return counts; } async saveCustomGreeting(boxId: string, wavData: Buffer): Promise { const objectKey = await this.storage.putBufferObject(`voicemail/${boxId}/greeting.wav`, wavData); const greetingPath = await this.storage.getObjectAsCachedFile(objectKey, `voicemail-${boxId}-greeting.wav`); this.log(`[voicebox] saved custom greeting for box "${boxId}"`); return greetingPath || ''; } async deleteCustomGreeting(boxId: string): Promise { await this.storage.removeObject(`voicemail/${boxId}/greeting.wav`); } private async enforceLimit(boxId: string, messages: IVoicemailMessage[]): 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.getVoicemailMessages(boxId); if (storedMessages.length) return await this.ensureMessageObjects(boxId, storedMessages); const filePath = path.join(this.getBoxDir(boxId), 'messages.json'); try { if (!fs.existsSync(filePath)) return []; const raw = await fsPromises.readFile(filePath, 'utf8'); const legacyMessages = await this.ensureMessageObjects(boxId, JSON.parse(raw) as IVoicemailMessage[]); await this.storage.writeVoicemailMessages(boxId, legacyMessages); return legacyMessages; } catch { return []; } } private async ensureMessageObjects(boxId: string, messages: IVoicemailMessage[]): 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) || '.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 { this.messagesByBox.set(boxId, [...messages]); await this.storage.writeVoicemailMessages(boxId, messages); } }