/** * 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. Defaults to 25 when * absent — both the config loader and `VoiceboxManager.init` apply * the default via `??=`. */ 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; /** 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 // ------------------------------------------------------------------------- /** * 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, info: { callerNumber: string; callerName?: string | null; fileName: string; durationMs: number; }, ): void { const msg: IVoicemailMessage = { id: crypto.randomUUID(), boxId, callerNumber: info.callerNumber, callerName: info.callerName ?? undefined, timestamp: Date.now(), durationMs: info.durationMs, fileName: path.basename(info.fileName), heard: false, }; this.saveMessage(msg); } /** * Save a new voicemail message. * The WAV file should already exist at the expected path. */ saveMessage(msg: IVoicemailMessage): void { const boxDir = this.getBoxDir(msg.boxId); fs.mkdirSync(boxDir, { recursive: true }); const messages = this.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'); } }