Files
siprouter/ts/ivr.ts

210 lines
6.2 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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<typeof setTimeout> | 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;
}
}
}