2026-04-10 08:54:46 +00:00
|
|
|
/**
|
2026-05-21 23:35:50 +00:00
|
|
|
* VoiceboxManager — manages voicemail boxes, message metadata, and audio objects.
|
2026-04-10 08:54:46 +00:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import fs from 'node:fs';
|
2026-05-21 23:35:50 +00:00
|
|
|
import * as fsPromises from 'node:fs/promises';
|
2026-04-10 08:54:46 +00:00
|
|
|
import path from 'node:path';
|
|
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
import type { SiprouterStorage } from './storage.ts';
|
2026-04-10 08:54:46 +00:00
|
|
|
|
|
|
|
|
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;
|
2026-05-21 23:35:50 +00:00
|
|
|
/** Path to cached uploaded WAV greeting (overrides TTS). */
|
2026-04-10 08:54:46 +00:00
|
|
|
greetingWavPath?: string;
|
2026-05-21 23:35:50 +00:00
|
|
|
/** Seconds to wait before routing to voicemail. */
|
2026-04-11 19:02:52 +00:00
|
|
|
noAnswerTimeoutSec?: number;
|
|
|
|
|
/** Maximum recording duration in seconds. Defaults to 120. */
|
|
|
|
|
maxRecordingSec?: number;
|
|
|
|
|
/** Maximum stored messages per box. Defaults to 50. */
|
|
|
|
|
maxMessages?: number;
|
2026-04-10 08:54:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-05-21 23:35:50 +00:00
|
|
|
/** Display file name. */
|
2026-04-10 08:54:46 +00:00
|
|
|
fileName: string;
|
2026-05-21 23:35:50 +00:00
|
|
|
/** SmartBucket object key for the WAV payload. */
|
|
|
|
|
objectKey?: string;
|
2026-04-10 08:54:46 +00:00
|
|
|
/** 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<string, IVoiceboxConfig>();
|
2026-05-21 23:35:50 +00:00
|
|
|
private messagesByBox = new Map<string, IVoicemailMessage[]>();
|
|
|
|
|
private readonly basePath: string;
|
|
|
|
|
private readonly log: (msg: string) => void;
|
|
|
|
|
private readonly storage: SiprouterStorage;
|
2026-04-10 08:54:46 +00:00
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
constructor(log: (msg: string) => void, storageArg: SiprouterStorage) {
|
2026-04-10 08:54:46 +00:00
|
|
|
this.basePath = path.join(process.cwd(), '.nogit', 'voicemail');
|
|
|
|
|
this.log = log;
|
2026-05-21 23:35:50 +00:00
|
|
|
this.storage = storageArg;
|
2026-04-10 08:54:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
async init(voiceboxConfigs: IVoiceboxConfig[]): Promise<void> {
|
2026-04-10 08:54:46 +00:00
|
|
|
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);
|
2026-05-21 23:35:50 +00:00
|
|
|
this.messagesByBox.set(cfg.id, await this.loadMessages(cfg.id));
|
2026-04-10 08:54:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
await fsPromises.mkdir(this.basePath, { recursive: true });
|
2026-04-10 08:54:46 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
async addMessage(
|
2026-04-11 19:02:52 +00:00
|
|
|
boxId: string,
|
|
|
|
|
info: {
|
|
|
|
|
callerNumber: string;
|
|
|
|
|
callerName?: string | null;
|
|
|
|
|
fileName: string;
|
|
|
|
|
durationMs: number;
|
|
|
|
|
},
|
2026-05-21 23:35:50 +00:00
|
|
|
): Promise<void> {
|
|
|
|
|
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);
|
|
|
|
|
|
2026-04-11 19:02:52 +00:00
|
|
|
const msg: IVoicemailMessage = {
|
2026-05-21 23:35:50 +00:00
|
|
|
id,
|
2026-04-11 19:02:52 +00:00
|
|
|
boxId,
|
|
|
|
|
callerNumber: info.callerNumber,
|
|
|
|
|
callerName: info.callerName ?? undefined,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
durationMs: info.durationMs,
|
2026-05-21 23:35:50 +00:00
|
|
|
fileName: path.basename(localPath),
|
|
|
|
|
objectKey,
|
2026-04-11 19:02:52 +00:00
|
|
|
heard: false,
|
|
|
|
|
};
|
2026-04-10 08:54:46 +00:00
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
const messages = this.getMessages(boxId);
|
|
|
|
|
messages.unshift(msg);
|
|
|
|
|
await this.enforceLimit(boxId, messages);
|
|
|
|
|
await this.writeMessages(boxId, messages);
|
2026-04-10 08:54:46 +00:00
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
await fsPromises.rm(localPath, { force: true }).catch(() => {});
|
2026-04-10 08:54:46 +00:00
|
|
|
this.log(`[voicebox] saved message ${msg.id} in box "${msg.boxId}" (${msg.durationMs}ms from ${msg.callerNumber})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getMessages(boxId: string): IVoicemailMessage[] {
|
2026-05-21 23:35:50 +00:00
|
|
|
return [...(this.messagesByBox.get(boxId) || [])];
|
2026-04-10 08:54:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getMessage(boxId: string, messageId: string): IVoicemailMessage | null {
|
2026-05-21 23:35:50 +00:00
|
|
|
const messages = this.messagesByBox.get(boxId) || [];
|
2026-04-10 08:54:46 +00:00
|
|
|
return messages.find((m) => m.id === messageId) ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
async markHeard(boxId: string, messageId: string): Promise<boolean> {
|
|
|
|
|
const messages = this.messagesByBox.get(boxId) || [];
|
2026-04-10 08:54:46 +00:00
|
|
|
const msg = messages.find((m) => m.id === messageId);
|
|
|
|
|
if (!msg) return false;
|
|
|
|
|
|
|
|
|
|
msg.heard = true;
|
2026-05-21 23:35:50 +00:00
|
|
|
await this.writeMessages(boxId, messages);
|
2026-04-10 08:54:46 +00:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
async deleteMessage(boxId: string, messageId: string): Promise<boolean> {
|
|
|
|
|
const messages = this.messagesByBox.get(boxId) || [];
|
2026-04-10 08:54:46 +00:00
|
|
|
const idx = messages.findIndex((m) => m.id === messageId);
|
|
|
|
|
if (idx === -1) return false;
|
|
|
|
|
|
|
|
|
|
const msg = messages[idx];
|
2026-05-21 23:35:50 +00:00
|
|
|
await this.storage.removeObject(msg.objectKey);
|
|
|
|
|
if (!msg.objectKey) {
|
|
|
|
|
await fsPromises.rm(path.join(this.getBoxDir(boxId), msg.fileName), { force: true }).catch(() => {});
|
|
|
|
|
}
|
2026-04-10 08:54:46 +00:00
|
|
|
|
|
|
|
|
messages.splice(idx, 1);
|
2026-05-21 23:35:50 +00:00
|
|
|
await this.writeMessages(boxId, messages);
|
2026-04-10 08:54:46 +00:00
|
|
|
this.log(`[voicebox] deleted message ${messageId} from box "${boxId}"`);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
async getMessageAudioPath(boxId: string, messageId: string): Promise<string | null> {
|
2026-04-10 08:54:46 +00:00
|
|
|
const msg = this.getMessage(boxId, messageId);
|
|
|
|
|
if (!msg) return null;
|
2026-05-21 23:35:50 +00:00
|
|
|
if (msg.objectKey) {
|
|
|
|
|
return await this.storage.getObjectAsCachedFile(msg.objectKey, msg.fileName);
|
|
|
|
|
}
|
2026-04-10 08:54:46 +00:00
|
|
|
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
|
|
|
|
|
return fs.existsSync(filePath) ? filePath : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getUnheardCount(boxId: string): number {
|
2026-05-21 23:35:50 +00:00
|
|
|
const messages = this.messagesByBox.get(boxId) || [];
|
2026-04-10 08:54:46 +00:00
|
|
|
return messages.filter((m) => !m.heard).length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getTotalCount(boxId: string): number {
|
2026-05-21 23:35:50 +00:00
|
|
|
return (this.messagesByBox.get(boxId) || []).length;
|
2026-04-10 08:54:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getAllUnheardCounts(): Record<string, number> {
|
|
|
|
|
const counts: Record<string, number> = {};
|
|
|
|
|
for (const boxId of this.boxes.keys()) {
|
|
|
|
|
counts[boxId] = this.getUnheardCount(boxId);
|
|
|
|
|
}
|
|
|
|
|
return counts;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
async saveCustomGreeting(boxId: string, wavData: Buffer): Promise<string> {
|
|
|
|
|
const objectKey = await this.storage.putBufferObject(`voicemail/${boxId}/greeting.wav`, wavData);
|
|
|
|
|
const greetingPath = await this.storage.getObjectAsCachedFile(objectKey, `voicemail-${boxId}-greeting.wav`);
|
2026-04-10 08:54:46 +00:00
|
|
|
this.log(`[voicebox] saved custom greeting for box "${boxId}"`);
|
2026-05-21 23:35:50 +00:00
|
|
|
return greetingPath || '';
|
2026-04-10 08:54:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
async deleteCustomGreeting(boxId: string): Promise<void> {
|
|
|
|
|
await this.storage.removeObject(`voicemail/${boxId}/greeting.wav`);
|
2026-04-10 08:54:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
private async enforceLimit(boxId: string, messages: IVoicemailMessage[]): Promise<void> {
|
|
|
|
|
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(() => {});
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-10 08:54:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
private async loadMessages(boxId: string): Promise<IVoicemailMessage[]> {
|
|
|
|
|
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');
|
2026-04-10 08:54:46 +00:00
|
|
|
try {
|
|
|
|
|
if (!fs.existsSync(filePath)) return [];
|
2026-05-21 23:35:50 +00:00
|
|
|
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;
|
2026-04-10 08:54:46 +00:00
|
|
|
} catch {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:35:50 +00:00
|
|
|
private async ensureMessageObjects(boxId: string, messages: IVoicemailMessage[]): Promise<IVoicemailMessage[]> {
|
|
|
|
|
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<void> {
|
|
|
|
|
this.messagesByBox.set(boxId, [...messages]);
|
|
|
|
|
await this.storage.writeVoicemailMessages(boxId, messages);
|
2026-04-10 08:54:46 +00:00
|
|
|
}
|
|
|
|
|
}
|