feat(storage): persist siprouter data in smartdata and smartbucket

This commit is contained in:
2026-05-21 23:35:50 +00:00
parent 04e706715f
commit 3e2fee16c1
14 changed files with 2018 additions and 492 deletions
+104 -166
View File
@@ -1,22 +1,12 @@
/**
* 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)
* 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';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
import type { SiprouterStorage } from './storage.ts';
export interface IVoiceboxConfig {
/** Unique ID — typically matches device ID or extension. */
@@ -27,11 +17,9 @@ export interface IVoiceboxConfig {
greetingText?: string;
/** Kokoro TTS voice ID for the greeting (default 'af_bella'). */
greetingVoice?: string;
/** Path to uploaded WAV greeting (overrides TTS). */
/** Path to cached 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 `??=`. */
/** Seconds to wait before routing to voicemail. */
noAnswerTimeoutSec?: number;
/** Maximum recording duration in seconds. Defaults to 120. */
maxRecordingSec?: number;
@@ -52,112 +40,80 @@ export interface IVoicemailMessage {
timestamp: number;
/** Duration in milliseconds. */
durationMs: number;
/** Relative path to the WAV file (within the box directory). */
/** Display file name. */
fileName: string;
/** SmartBucket object key for the WAV payload. */
objectKey?: 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;
private messagesByBox = new Map<string, IVoicemailMessage[]>();
private readonly basePath: string;
private readonly log: (msg: string) => void;
private readonly storage: SiprouterStorage;
constructor(log: (msg: string) => void) {
constructor(log: (msg: string) => void, storageArg: SiprouterStorage) {
this.basePath = path.join(process.cwd(), '.nogit', 'voicemail');
this.log = log;
this.storage = storageArg;
}
// -------------------------------------------------------------------------
// Initialization
// -------------------------------------------------------------------------
/**
* Load voicebox configurations from the app config.
*/
init(voiceboxConfigs: IVoiceboxConfig[]): void {
async init(voiceboxConfigs: IVoiceboxConfig[]): Promise<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);
this.messagesByBox.set(cfg.id, await this.loadMessages(cfg.id));
}
// Ensure base directory exists.
fs.mkdirSync(this.basePath, { recursive: true });
await fsPromises.mkdir(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(
async addMessage(
boxId: string,
info: {
callerNumber: string;
@@ -165,124 +121,87 @@ export class VoiceboxManager {
fileName: string;
durationMs: number;
},
): void {
): 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);
const msg: IVoicemailMessage = {
id: crypto.randomUUID(),
id,
boxId,
callerNumber: info.callerNumber,
callerName: info.callerName ?? undefined,
timestamp: Date.now(),
durationMs: info.durationMs,
fileName: path.basename(info.fileName),
fileName: path.basename(localPath),
objectKey,
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.getMessages(boxId);
messages.unshift(msg);
await this.enforceLimit(boxId, messages);
await this.writeMessages(boxId, messages);
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);
await fsPromises.rm(localPath, { force: true }).catch(() => {});
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);
return [...(this.messagesByBox.get(boxId) || [])];
}
/**
* Get a single message by ID.
*/
getMessage(boxId: string, messageId: string): IVoicemailMessage | null {
const messages = this.loadMessages(boxId);
const messages = this.messagesByBox.get(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);
async markHeard(boxId: string, messageId: string): Promise<boolean> {
const messages = this.messagesByBox.get(boxId) || [];
const msg = messages.find((m) => m.id === messageId);
if (!msg) return false;
msg.heard = true;
this.writeMessages(boxId, messages);
await 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);
async deleteMessage(boxId: string, messageId: string): Promise<boolean> {
const messages = this.messagesByBox.get(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);
await this.storage.removeObject(msg.objectKey);
if (!msg.objectKey) {
await fsPromises.rm(path.join(this.getBoxDir(boxId), msg.fileName), { force: true }).catch(() => {});
}
// 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);
await 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 {
async getMessageAudioPath(boxId: string, messageId: string): Promise<string | null> {
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;
}
// -------------------------------------------------------------------------
// Counts
// -------------------------------------------------------------------------
/** Get count of unheard messages for a voicebox. */
getUnheardCount(boxId: string): number {
const messages = this.loadMessages(boxId);
const messages = this.messagesByBox.get(boxId) || [];
return messages.filter((m) => !m.heard).length;
}
/** Get total message count for a voicebox. */
getTotalCount(boxId: string): number {
return this.loadMessages(boxId).length;
return (this.messagesByBox.get(boxId) || []).length;
}
/** Get unheard counts for all voiceboxes. */
getAllUnheardCounts(): Record<string, number> {
const counts: Record<string, number> = {};
for (const boxId of this.boxes.keys()) {
@@ -291,55 +210,74 @@ export class VoiceboxManager {
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);
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`);
this.log(`[voicebox] saved custom greeting for box "${boxId}"`);
return greetingPath;
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 */ }
async deleteCustomGreeting(boxId: string): Promise<void> {
await this.storage.removeObject(`voicemail/${boxId}/greeting.wav`);
}
// -------------------------------------------------------------------------
// Internal: JSON persistence
// -------------------------------------------------------------------------
private messagesPath(boxId: string): string {
return path.join(this.getBoxDir(boxId), 'messages.json');
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(() => {});
}
}
}
private loadMessages(boxId: string): IVoicemailMessage[] {
const filePath = this.messagesPath(boxId);
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');
try {
if (!fs.existsSync(filePath)) return [];
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw) as IVoicemailMessage[];
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 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');
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);
}
}