feat(call, voicemail, ivr): add voicemail and IVR call flows with DTMF handling, prompt playback, recording, and dashboard management
This commit is contained in:
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user