feat(call, voicemail, ivr): add voicemail and IVR call flows with DTMF handling, prompt playback, recording, and dashboard management

This commit is contained in:
2026-04-10 08:54:46 +00:00
parent 6ecd3f434c
commit e6bd64a534
25 changed files with 3892 additions and 10 deletions

View File

@@ -22,6 +22,7 @@ import {
rewriteSdp,
rewriteSipUri,
generateTag,
buildMwiBody,
} from '../sip/index.ts';
import type { IEndpoint } from '../sip/index.ts';
import type { IAppConfig, IProviderConfig } from '../config.ts';
@@ -39,6 +40,13 @@ import {
isKnownDeviceAddress,
} from '../registrar.ts';
import { WebSocket } from 'ws';
import { SystemLeg } from './system-leg.ts';
import type { ISystemLegConfig } from './system-leg.ts';
import { PromptCache } from './prompt-cache.ts';
import { VoiceboxManager } from '../voicebox.ts';
import type { IVoicemailMessage } from '../voicebox.ts';
import { IvrEngine } from '../ivr.ts';
import type { IIvrConfig, TIvrAction, IVoiceboxConfig as IVoiceboxCfg } from '../config.ts';
// ---------------------------------------------------------------------------
// CallManager config
@@ -53,6 +61,10 @@ export interface ICallManagerConfig {
getAllBrowserDeviceIds: () => string[];
sendToBrowserDevice: (deviceId: string, data: unknown) => boolean;
getBrowserDeviceWs: (deviceId: string) => WebSocket | null;
/** Prompt cache for IVR/voicemail audio playback. */
promptCache?: PromptCache;
/** Voicebox manager for voicemail storage and retrieval. */
voiceboxManager?: VoiceboxManager;
}
// ---------------------------------------------------------------------------
@@ -179,6 +191,41 @@ export class CallManager {
return;
}
}
// Intercept busy/unavailable responses — route to voicemail if configured.
// When the device rejects the call (486 Busy, 480 Unavailable, 600/603 Decline),
// answer the provider's INVITE with our own SDP and start voicemail.
if (msg.isResponse && msg.cseqMethod?.toUpperCase() === 'INVITE') {
const code = msg.statusCode;
if (code === 486 || code === 480 || code === 600 || code === 603) {
const callId = pt.call.id;
const boxId = this.findVoiceboxForCall(pt.call);
if (boxId) {
this.config.log(`[call-mgr] device responded ${code} — routing to voicemail box "${boxId}"`);
// Build a 200 OK with our own SDP to answer the provider's INVITE.
const sdpBody = buildSdp({
address: pub,
port: pt.rtpPort,
payloadTypes: pt.providerConfig.codecs || [9, 0, 8, 101],
});
// We need to construct the 200 OK as if *we* are answering the provider.
// The original INVITE from the provider used the passthrough SIP Call-ID.
// Build a response using the forwarded INVITE's headers.
const ok200 = SipMessage.createResponse(200, 'OK', msg, {
body: sdpBody,
contentType: 'application/sdp',
contact: `<sip:${lanIp}:${lanPort}>`,
});
this.config.sendSip(ok200.serialize(), pt.providerConfig.outboundProxy);
// Now route to voicemail.
this.routeToVoicemail(callId, boxId);
return;
}
}
}
// Rewrite Contact.
const contact = msg.getHeader('Contact');
if (contact) {
@@ -601,6 +648,49 @@ export class CallManager {
}
call.state = 'ringing';
// --- IVR / Voicemail routing ---
if (routeResult.ivrMenuId && this.config.appConfig.ivr?.enabled) {
// Route directly to IVR — don't ring devices.
this.config.log(`[call-mgr] inbound call ${callId} routed to IVR menu "${routeResult.ivrMenuId}"`);
// Respond 200 OK to the provider INVITE first.
const okForProvider = SipMessage.createResponse(200, 'OK', invite, {
body: fwdInvite.body, // rewritten SDP
contentType: 'application/sdp',
});
this.config.sendSip(okForProvider.serialize(), rinfo);
this.routeToIvr(callId, this.config.appConfig.ivr);
} else if (routeResult.voicemailBox) {
// Route directly to voicemail — don't ring devices.
this.config.log(`[call-mgr] inbound call ${callId} routed directly to voicemail box "${routeResult.voicemailBox}"`);
const okForProvider = SipMessage.createResponse(200, 'OK', invite, {
body: fwdInvite.body,
contentType: 'application/sdp',
});
this.config.sendSip(okForProvider.serialize(), rinfo);
this.routeToVoicemail(callId, routeResult.voicemailBox);
} else {
// Normal ringing — start voicemail no-answer timer if applicable.
const vm = this.config.voiceboxManager;
if (vm) {
// Find first voicebox for the target devices.
const boxId = this.findVoiceboxForDevices(targetDeviceIds);
if (boxId) {
const box = vm.getBox(boxId);
if (box?.enabled) {
const timeoutSec = routeResult.noAnswerTimeout ?? box.noAnswerTimeoutSec ?? 25;
setTimeout(() => {
const c = this.calls.get(callId);
if (c && c.state === 'ringing') {
this.config.log(`[call-mgr] no answer after ${timeoutSec}s — routing to voicemail box "${boxId}"`);
this.routeToVoicemail(callId, boxId);
}
}, timeoutSec * 1000);
}
}
}
}
return call;
}
@@ -1092,6 +1182,371 @@ export class CallManager {
}
}
// -------------------------------------------------------------------------
// Voicemail routing
// -------------------------------------------------------------------------
/**
* Route a call to voicemail. Cancels ringing devices, creates a SystemLeg,
* plays the greeting, then starts recording.
*/
routeToVoicemail(callId: string, boxId: string): void {
const call = this.calls.get(callId);
if (!call) return;
const vm = this.config.voiceboxManager;
const pc = this.config.promptCache;
if (!vm || !pc) {
this.config.log(`[call-mgr] voicemail not available (manager or prompt cache missing)`);
return;
}
const box = vm.getBox(boxId);
if (!box) {
this.config.log(`[call-mgr] voicebox "${boxId}" not found`);
return;
}
// Cancel all ringing/device legs — keep only provider leg(s).
const legsToRemove: string[] = [];
for (const leg of call.getLegs()) {
if (leg.type === 'sip-device' || leg.type === 'webrtc') {
legsToRemove.push(leg.id);
}
}
for (const legId of legsToRemove) {
const leg = call.getLeg(legId);
if (leg && (leg.type === 'sip-device' || leg.type === 'sip-provider')) {
(leg as SipLeg).sendHangup(); // CANCEL ringing devices
}
call.removeLeg(legId);
}
// Cancel passthrough tracking for this call (if applicable).
for (const [sipCallId, pt] of this.passthroughCalls) {
if (pt.call === call) {
// Keep the RTP socket — the SystemLeg will use it indirectly through the hub.
this.passthroughCalls.delete(sipCallId);
break;
}
}
// Create a SystemLeg.
const systemLegId = `${callId}-vm`;
const systemLeg = new SystemLeg(systemLegId, {
log: this.config.log,
promptCache: pc,
callerCodecPt: 9, // SIP callers use G.722 by default
onDtmfDigit: (digit) => {
// '#' during recording = stop and save.
if (digit.digit === '#' && systemLeg.mode === 'voicemail-recording') {
this.config.log(`[call-mgr] voicemail: caller pressed # — stopping recording`);
systemLeg.stopRecording().then((result) => {
if (result && result.durationMs > 500) {
this.saveVoicemailMessage(boxId, call, result);
}
call.hangup();
});
}
},
onRecordingComplete: (result) => {
if (result.durationMs > 500) {
this.saveVoicemailMessage(boxId, call, result);
}
},
});
call.addLeg(systemLeg);
call.state = 'voicemail';
// Determine greeting prompt ID.
const greetingPromptId = `voicemail-greeting-${boxId}`;
const beepPromptId = 'voicemail-beep';
// Play greeting, then beep, then start recording.
systemLeg.mode = 'voicemail-greeting';
const startSequence = () => {
systemLeg.playPrompt(greetingPromptId, () => {
// Greeting done — play beep.
systemLeg.playPrompt(beepPromptId, () => {
// Beep done — start recording.
const recordDir = vm.getBoxDir(boxId);
const fileId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
systemLeg.startRecording(recordDir, fileId);
this.config.log(`[call-mgr] voicemail recording started for box "${boxId}"`);
});
});
};
// Check if the greeting prompt is already cached; if not, generate it.
if (pc.has(greetingPromptId)) {
startSequence();
} else {
// Generate the greeting on-the-fly.
const wavPath = vm.getCustomGreetingWavPath(boxId);
const generatePromise = wavPath
? pc.loadWavPrompt(greetingPromptId, wavPath)
: pc.generatePrompt(greetingPromptId, vm.getGreetingText(boxId), vm.getGreetingVoice(boxId));
generatePromise.then(() => {
if (call.state !== 'terminated') startSequence();
});
}
}
/** Save a voicemail message after recording completes. */
private saveVoicemailMessage(boxId: string, call: Call, result: import('./audio-recorder.ts').IRecordingResult): void {
const vm = this.config.voiceboxManager;
if (!vm) return;
const fileName = result.filePath.split('/').pop() || 'unknown.wav';
const msg: IVoicemailMessage = {
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
boxId,
callerNumber: call.callerNumber || 'Unknown',
timestamp: Date.now(),
durationMs: result.durationMs,
fileName,
heard: false,
};
vm.saveMessage(msg);
this.config.log(`[call-mgr] voicemail saved: ${msg.id} (${result.durationMs}ms) in box "${boxId}"`);
// Send MWI NOTIFY to the associated device.
this.sendMwiNotify(boxId);
}
/** Send MWI (Message Waiting Indicator) NOTIFY to a device for a voicebox. */
private sendMwiNotify(boxId: string): void {
const vm = this.config.voiceboxManager;
if (!vm) return;
const reg = getRegisteredDevice(boxId);
if (!reg?.contact) return; // Device not registered — skip.
const newCount = vm.getUnheardCount(boxId);
const totalCount = vm.getTotalCount(boxId);
const oldCount = totalCount - newCount;
const lanIp = this.config.appConfig.proxy.lanIp;
const lanPort = this.config.appConfig.proxy.lanPort;
const accountUri = `sip:${boxId}@${lanIp}`;
const targetUri = `sip:${reg.aor || boxId}@${reg.contact.address}:${reg.contact.port}`;
const mwi = buildMwiBody(newCount, oldCount, accountUri);
const notify = SipMessage.createRequest('NOTIFY', targetUri, {
via: { host: lanIp, port: lanPort },
from: { uri: accountUri },
to: { uri: targetUri },
contact: `<sip:${lanIp}:${lanPort}>`,
body: mwi.body,
contentType: mwi.contentType,
extraHeaders: mwi.extraHeaders,
});
this.config.sendSip(notify.serialize(), reg.contact);
this.config.log(`[call-mgr] MWI NOTIFY sent to ${boxId}: ${newCount} new, ${oldCount} old`);
}
// -------------------------------------------------------------------------
// IVR routing
// -------------------------------------------------------------------------
/**
* Route a call to IVR. Creates a SystemLeg and starts the IVR engine.
*/
routeToIvr(callId: string, ivrConfig: IIvrConfig): void {
const call = this.calls.get(callId);
if (!call) return;
const pc = this.config.promptCache;
if (!pc) {
this.config.log(`[call-mgr] IVR not available (prompt cache missing)`);
return;
}
// Cancel all ringing device legs.
const legsToRemove: string[] = [];
for (const leg of call.getLegs()) {
if (leg.type === 'sip-device' || leg.type === 'webrtc') {
legsToRemove.push(leg.id);
}
}
for (const legId of legsToRemove) {
const leg = call.getLeg(legId);
if (leg && (leg.type === 'sip-device' || leg.type === 'sip-provider')) {
(leg as SipLeg).sendHangup();
}
call.removeLeg(legId);
}
// Remove passthrough tracking.
for (const [sipCallId, pt] of this.passthroughCalls) {
if (pt.call === call) {
this.passthroughCalls.delete(sipCallId);
break;
}
}
// Create SystemLeg for IVR.
const systemLegId = `${callId}-ivr`;
const systemLeg = new SystemLeg(systemLegId, {
log: this.config.log,
promptCache: pc,
callerCodecPt: 9,
});
call.addLeg(systemLeg);
call.state = 'ivr';
systemLeg.mode = 'ivr';
// Create IVR engine.
const ivrEngine = new IvrEngine(
ivrConfig,
systemLeg,
(action: TIvrAction) => this.handleIvrAction(callId, action, ivrEngine, systemLeg),
this.config.log,
);
// Wire DTMF digits to the IVR engine.
systemLeg.config.onDtmfDigit = (digit) => {
ivrEngine.handleDigit(digit.digit);
};
// Start the IVR.
ivrEngine.start();
}
/** Handle an action from the IVR engine. */
private handleIvrAction(
callId: string,
action: TIvrAction,
ivrEngine: IvrEngine,
systemLeg: SystemLeg,
): void {
const call = this.calls.get(callId);
if (!call) return;
switch (action.type) {
case 'route-extension': {
// Tear down IVR and ring the target device.
ivrEngine.destroy();
call.removeLeg(systemLeg.id);
const extTarget = this.resolveDeviceTarget(action.extensionId);
if (!extTarget) {
this.config.log(`[call-mgr] IVR: extension "${action.extensionId}" not found — hanging up`);
call.hangup();
break;
}
const rtpExt = this.portPool.allocate();
if (!rtpExt) {
this.config.log(`[call-mgr] IVR: port pool exhausted — hanging up`);
call.hangup();
break;
}
const ps = [...this.config.appConfig.providers]
.map((p) => this.config.getProviderState(p.id))
.find((s) => s?.publicIp);
const extLegConfig: ISipLegConfig = {
role: 'device',
lanIp: this.config.appConfig.proxy.lanIp,
lanPort: this.config.appConfig.proxy.lanPort,
getPublicIp: () => ps?.publicIp ?? null,
sendSip: this.config.sendSip,
log: this.config.log,
sipTarget: extTarget,
rtpPort: rtpExt.port,
rtpSock: rtpExt.sock,
};
const extLeg = new SipLeg(`${callId}-ext`, extLegConfig);
extLeg.onTerminated = (leg) => call.handleLegTerminated(leg.id);
extLeg.onStateChange = () => call.notifyLegStateChange(extLeg);
call.addLeg(extLeg);
call.state = 'ringing';
const sipCallIdExt = `${callId}-ext-${Date.now()}`;
extLeg.sendInvite({
fromUri: `sip:${call.callerNumber || 'unknown'}@${this.config.appConfig.proxy.lanIp}`,
fromDisplayName: call.callerNumber || 'Unknown',
toUri: `sip:user@${extTarget.address}`,
callId: sipCallIdExt,
});
this.sipCallIdIndex.set(sipCallIdExt, call);
this.config.log(`[call-mgr] IVR: ringing extension "${action.extensionId}"`);
break;
}
case 'route-voicemail': {
ivrEngine.destroy();
call.removeLeg(systemLeg.id);
this.routeToVoicemail(callId, action.boxId);
break;
}
case 'transfer': {
ivrEngine.destroy();
call.removeLeg(systemLeg.id);
// Resolve provider for outbound dial.
const xferRoute = resolveOutboundRoute(
this.config.appConfig,
action.number,
undefined,
(pid) => !!this.config.getProviderState(pid)?.registeredAor,
);
if (!xferRoute) {
this.config.log(`[call-mgr] IVR: no provider for transfer to ${action.number} — hanging up`);
call.hangup();
break;
}
const xferPs = this.config.getProviderState(xferRoute.provider.id);
if (!xferPs) {
call.hangup();
break;
}
this.startProviderLeg(call, xferRoute.provider, xferRoute.transformedNumber, xferPs);
this.config.log(`[call-mgr] IVR: transferring to ${action.number} via ${xferRoute.provider.displayName}`);
break;
}
case 'hangup': {
ivrEngine.destroy();
call.hangup();
break;
}
default:
break;
}
}
/** Find the voicebox for a call (uses all device IDs or fallback to first enabled). */
private findVoiceboxForCall(call: Call): string | null {
const allDeviceIds = this.config.appConfig.devices.map((d) => d.id);
return this.findVoiceboxForDevices(allDeviceIds);
}
/** Find the first voicebox ID associated with a set of target device IDs. */
private findVoiceboxForDevices(deviceIds: string[]): string | null {
const voiceboxes = this.config.appConfig.voiceboxes ?? [];
for (const deviceId of deviceIds) {
const box = voiceboxes.find((vb) => vb.id === deviceId);
if (box?.enabled) return box.id;
}
// Fallback: first enabled voicebox.
const first = voiceboxes.find((vb) => vb.enabled);
return first?.id ?? null;
}
// -------------------------------------------------------------------------
// Status
// -------------------------------------------------------------------------