210 lines
6.2 KiB
TypeScript
210 lines
6.2 KiB
TypeScript
|
|
/**
|
||
|
|
* 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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|