/** * IVR engine — state machine that navigates callers through menus * based on DTMF digit input. * * The IvrEngine is instantiated per-call and drives a SystemLeg: * - Plays menu prompts via the SystemLeg's prompt playback * - Receives DTMF digits and resolves them to actions * - Fires an onAction callback for the CallManager to execute * (route to extension, voicemail, transfer, etc.) */ import type { IIvrConfig, IIvrMenu, TIvrAction } from './config.ts'; import type { SystemLeg } from './call/system-leg.ts'; // --------------------------------------------------------------------------- // IVR Engine // --------------------------------------------------------------------------- export class IvrEngine { private config: IIvrConfig; private systemLeg: SystemLeg; private onAction: (action: TIvrAction) => void; private log: (msg: string) => void; /** The currently active menu. */ private currentMenu: IIvrMenu | null = null; /** How many times the current menu has been replayed (for retry limit). */ private retryCount = 0; /** Timer for digit input timeout. */ private digitTimeout: ReturnType | null = null; /** Whether the engine is waiting for a digit (prompt finished playing). */ private waitingForDigit = false; /** Whether the engine has been destroyed. */ private destroyed = false; constructor( config: IIvrConfig, systemLeg: SystemLeg, onAction: (action: TIvrAction) => void, log: (msg: string) => void, ) { this.config = config; this.systemLeg = systemLeg; this.onAction = onAction; this.log = log; } // ------------------------------------------------------------------------- // Public API // ------------------------------------------------------------------------- /** * Start the IVR — navigates to the entry menu and plays its prompt. */ start(): void { const entryMenu = this.getMenu(this.config.entryMenuId); if (!entryMenu) { this.log(`[ivr] entry menu "${this.config.entryMenuId}" not found — hanging up`); this.onAction({ type: 'hangup' }); return; } this.navigateToMenu(entryMenu); } /** * Handle a DTMF digit from the caller. */ handleDigit(digit: string): void { if (this.destroyed || !this.currentMenu) return; // Clear the timeout — caller pressed something. this.clearDigitTimeout(); // Cancel any playing prompt (caller interrupted it). this.systemLeg.cancelPrompt(); this.waitingForDigit = false; this.log(`[ivr] digit '${digit}' in menu "${this.currentMenu.id}"`); // Look up the digit in the current menu. const entry = this.currentMenu.entries.find((e) => e.digit === digit); if (entry) { this.executeAction(entry.action); } else { this.log(`[ivr] invalid digit '${digit}' in menu "${this.currentMenu.id}"`); this.executeAction(this.currentMenu.invalidAction); } } /** * Clean up timers and state. */ destroy(): void { this.destroyed = true; this.clearDigitTimeout(); this.currentMenu = null; } // ------------------------------------------------------------------------- // Internal // ------------------------------------------------------------------------- /** Navigate to a menu: play its prompt, then wait for digit. */ private navigateToMenu(menu: IIvrMenu): void { if (this.destroyed) return; this.currentMenu = menu; this.waitingForDigit = false; this.clearDigitTimeout(); const promptId = `ivr-menu-${menu.id}`; this.log(`[ivr] playing menu "${menu.id}" prompt`); this.systemLeg.playPrompt(promptId, () => { if (this.destroyed) return; // Prompt finished — start digit timeout. this.waitingForDigit = true; this.startDigitTimeout(); }); } /** Start the timeout timer for digit input. */ private startDigitTimeout(): void { const timeoutSec = this.currentMenu?.timeoutSec ?? 5; this.digitTimeout = setTimeout(() => { if (this.destroyed || !this.currentMenu) return; this.log(`[ivr] digit timeout in menu "${this.currentMenu.id}"`); this.handleTimeout(); }, timeoutSec * 1000); } /** Handle timeout (no digit pressed). */ private handleTimeout(): void { if (!this.currentMenu) return; this.retryCount++; const maxRetries = this.currentMenu.maxRetries ?? 3; if (this.retryCount >= maxRetries) { this.log(`[ivr] max retries (${maxRetries}) reached in menu "${this.currentMenu.id}"`); this.executeAction(this.currentMenu.timeoutAction); } else { this.log(`[ivr] retry ${this.retryCount}/${maxRetries} in menu "${this.currentMenu.id}"`); // Replay the current menu. this.navigateToMenu(this.currentMenu); } } /** Execute an IVR action. */ private executeAction(action: TIvrAction): void { if (this.destroyed) return; switch (action.type) { case 'submenu': { const submenu = this.getMenu(action.menuId); if (submenu) { this.retryCount = 0; this.navigateToMenu(submenu); } else { this.log(`[ivr] submenu "${action.menuId}" not found — hanging up`); this.onAction({ type: 'hangup' }); } break; } case 'repeat': { if (this.currentMenu) { this.navigateToMenu(this.currentMenu); } break; } case 'play-message': { // Play a message prompt, then return to the current menu. this.systemLeg.playPrompt(action.promptId, () => { if (this.destroyed || !this.currentMenu) return; this.navigateToMenu(this.currentMenu); }); break; } default: // All other actions (route-extension, route-voicemail, transfer, hangup) // are handled by the CallManager via the onAction callback. this.log(`[ivr] action: ${action.type}`); this.onAction(action); break; } } /** Look up a menu by ID. */ private getMenu(menuId: string): IIvrMenu | null { return this.config.menus.find((m) => m.id === menuId) ?? null; } /** Clear the digit timeout timer. */ private clearDigitTimeout(): void { if (this.digitTimeout) { clearTimeout(this.digitTimeout); this.digitTimeout = null; } } }