315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
/**
|
|
* 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<string, IVoiceboxConfig>();
|
|
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<string, number> {
|
|
const counts: Record<string, number> = {};
|
|
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');
|
|
}
|
|
}
|