/** * VoiceboxManager — manages voicemail boxes, message storage, and MWI. * * Each voicebox corresponds to a device/extension. Messages are stored * as WAV files with JSON metadata in .nogit/voicemail/{boxId}/. * * Supports: * - Per-box configurable TTS greetings (text + voice) or uploaded WAV * - Message CRUD: save, list, mark heard, delete * - Unheard count for MWI (Message Waiting Indicator) * - Storage limit (max messages per box) */ import fs from 'node:fs'; import path from 'node:path'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- 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 uploaded WAV greeting (overrides TTS). */ greetingWavPath?: string; /** Seconds to wait before routing to voicemail (default 25). */ noAnswerTimeoutSec: number; /** Maximum recording duration in seconds (default 120). */ maxRecordingSec: number; /** Maximum stored messages per box (default 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; /** Relative path to the WAV file (within the box directory). */ fileName: string; /** Whether the message has been listened to. */ heard: boolean; } // Default greeting text when no custom text is configured. const DEFAULT_GREETING = 'The person you are trying to reach is not available. Please leave a message after the tone.'; // --------------------------------------------------------------------------- // VoiceboxManager // --------------------------------------------------------------------------- export class VoiceboxManager { private boxes = new Map(); private basePath: string; private log: (msg: string) => void; constructor(log: (msg: string) => void) { this.basePath = path.join(process.cwd(), '.nogit', 'voicemail'); this.log = log; } // ------------------------------------------------------------------------- // Initialization // ------------------------------------------------------------------------- /** * Load voicebox configurations from the app config. */ init(voiceboxConfigs: IVoiceboxConfig[]): void { this.boxes.clear(); for (const cfg of voiceboxConfigs) { // Apply defaults. cfg.noAnswerTimeoutSec ??= 25; cfg.maxRecordingSec ??= 120; cfg.maxMessages ??= 50; cfg.greetingVoice ??= 'af_bella'; this.boxes.set(cfg.id, cfg); } // Ensure base directory exists. fs.mkdirSync(this.basePath, { recursive: true }); this.log(`[voicebox] initialized ${this.boxes.size} voicebox(es)`); } // ------------------------------------------------------------------------- // Box management // ------------------------------------------------------------------------- /** Get config for a specific voicebox. */ getBox(boxId: string): IVoiceboxConfig | null { return this.boxes.get(boxId) ?? null; } /** Get all configured voicebox IDs. */ getBoxIds(): string[] { return [...this.boxes.keys()]; } /** Get the greeting text for a voicebox. */ getGreetingText(boxId: string): string { const box = this.boxes.get(boxId); return box?.greetingText || DEFAULT_GREETING; } /** Get the greeting voice for a voicebox. */ getGreetingVoice(boxId: string): string { const box = this.boxes.get(boxId); return box?.greetingVoice || 'af_bella'; } /** Check if a voicebox has a custom WAV greeting. */ hasCustomGreetingWav(boxId: string): boolean { const box = this.boxes.get(boxId); if (!box?.greetingWavPath) return false; return fs.existsSync(box.greetingWavPath); } /** Get the greeting WAV path (custom or null). */ getCustomGreetingWavPath(boxId: string): string | null { const box = this.boxes.get(boxId); if (!box?.greetingWavPath) return null; return fs.existsSync(box.greetingWavPath) ? box.greetingWavPath : null; } /** Get the directory path for a voicebox. */ getBoxDir(boxId: string): string { return path.join(this.basePath, boxId); } // ------------------------------------------------------------------------- // Message CRUD // ------------------------------------------------------------------------- /** * Save a new voicemail message. * The WAV file should already exist at the expected path. */ saveMessage(msg: IVoicemailMessage): void { const boxDir = this.getBoxDir(msg.boxId); fs.mkdirSync(boxDir, { recursive: true }); const messages = this.loadMessages(msg.boxId); messages.unshift(msg); // newest first // Enforce max messages — delete oldest. const box = this.boxes.get(msg.boxId); const maxMessages = box?.maxMessages ?? 50; while (messages.length > maxMessages) { const old = messages.pop()!; const oldPath = path.join(boxDir, old.fileName); try { if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); } catch { /* best effort */ } } this.writeMessages(msg.boxId, messages); this.log(`[voicebox] saved message ${msg.id} in box "${msg.boxId}" (${msg.durationMs}ms from ${msg.callerNumber})`); } /** * List messages for a voicebox (newest first). */ getMessages(boxId: string): IVoicemailMessage[] { return this.loadMessages(boxId); } /** * Get a single message by ID. */ getMessage(boxId: string, messageId: string): IVoicemailMessage | null { const messages = this.loadMessages(boxId); return messages.find((m) => m.id === messageId) ?? null; } /** * Mark a message as heard. */ markHeard(boxId: string, messageId: string): boolean { const messages = this.loadMessages(boxId); const msg = messages.find((m) => m.id === messageId); if (!msg) return false; msg.heard = true; this.writeMessages(boxId, messages); return true; } /** * Delete a message (both metadata and WAV file). */ deleteMessage(boxId: string, messageId: string): boolean { const messages = this.loadMessages(boxId); const idx = messages.findIndex((m) => m.id === messageId); if (idx === -1) return false; const msg = messages[idx]; const boxDir = this.getBoxDir(boxId); const wavPath = path.join(boxDir, msg.fileName); // Delete WAV file. try { if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath); } catch { /* best effort */ } // Remove from list and save. messages.splice(idx, 1); this.writeMessages(boxId, messages); this.log(`[voicebox] deleted message ${messageId} from box "${boxId}"`); return true; } /** * Get the full file path for a message's WAV file. */ getMessageAudioPath(boxId: string, messageId: string): string | null { const msg = this.getMessage(boxId, messageId); if (!msg) return null; const filePath = path.join(this.getBoxDir(boxId), msg.fileName); return fs.existsSync(filePath) ? filePath : null; } // ------------------------------------------------------------------------- // Counts // ------------------------------------------------------------------------- /** Get count of unheard messages for a voicebox. */ getUnheardCount(boxId: string): number { const messages = this.loadMessages(boxId); return messages.filter((m) => !m.heard).length; } /** Get total message count for a voicebox. */ getTotalCount(boxId: string): number { return this.loadMessages(boxId).length; } /** Get unheard counts for all voiceboxes. */ getAllUnheardCounts(): Record { const counts: Record = {}; for (const boxId of this.boxes.keys()) { counts[boxId] = this.getUnheardCount(boxId); } return counts; } // ------------------------------------------------------------------------- // Greeting management // ------------------------------------------------------------------------- /** * Save a custom greeting WAV file for a voicebox. */ saveCustomGreeting(boxId: string, wavData: Buffer): string { const boxDir = this.getBoxDir(boxId); fs.mkdirSync(boxDir, { recursive: true }); const greetingPath = path.join(boxDir, 'greeting.wav'); fs.writeFileSync(greetingPath, wavData); this.log(`[voicebox] saved custom greeting for box "${boxId}"`); return greetingPath; } /** * Delete the custom greeting for a voicebox (falls back to TTS). */ deleteCustomGreeting(boxId: string): void { const boxDir = this.getBoxDir(boxId); const greetingPath = path.join(boxDir, 'greeting.wav'); try { if (fs.existsSync(greetingPath)) fs.unlinkSync(greetingPath); } catch { /* best effort */ } } // ------------------------------------------------------------------------- // Internal: JSON persistence // ------------------------------------------------------------------------- private messagesPath(boxId: string): string { return path.join(this.getBoxDir(boxId), 'messages.json'); } private loadMessages(boxId: string): IVoicemailMessage[] { const filePath = this.messagesPath(boxId); try { if (!fs.existsSync(filePath)) return []; const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw) as IVoicemailMessage[]; } catch { return []; } } private writeMessages(boxId: string, messages: IVoicemailMessage[]): void { const boxDir = this.getBoxDir(boxId); fs.mkdirSync(boxDir, { recursive: true }); fs.writeFileSync(this.messagesPath(boxId), JSON.stringify(messages, null, 2), 'utf8'); } }