feat(call, voicemail, ivr): add voicemail and IVR call flows with DTMF handling, prompt playback, recording, and dashboard management
This commit is contained in:
209
ts/ivr.ts
Normal file
209
ts/ivr.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user