feat(call, voicemail, ivr): add voicemail and IVR call flows with DTMF handling, prompt playback, recording, and dashboard management
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-10 - 1.10.0 - feat(call, voicemail, ivr)
|
||||
add voicemail and IVR call flows with DTMF handling, prompt playback, recording, and dashboard management
|
||||
|
||||
- introduces system call legs, DTMF detection, prompt caching, and audio recording to support automated call handling
|
||||
- adds configurable voiceboxes and IVR menus to routing, including voicemail fallback on busy or no-answer flows
|
||||
- exposes voicemail message APIs, message waiting counts, and new dashboard views for voicemail and IVR management
|
||||
|
||||
## 2026-04-10 - 1.9.0 - feat(routing)
|
||||
add rule-based SIP routing for inbound and outbound calls with dashboard route management
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.9.0',
|
||||
version: '1.10.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
323
ts/call/audio-recorder.ts
Normal file
323
ts/call/audio-recorder.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Audio recorder — captures RTP packets from a single direction,
|
||||
* decodes them to PCM, and writes a WAV file.
|
||||
*
|
||||
* Uses the Rust codec bridge to transcode incoming audio (G.722, Opus,
|
||||
* PCMU, PCMA) to PCMU, then decodes mu-law to 16-bit PCM in TypeScript.
|
||||
* Output: 8kHz 16-bit mono WAV (standard telephony quality).
|
||||
*
|
||||
* Supports:
|
||||
* - Max recording duration limit
|
||||
* - Silence detection (stop after N seconds of silence)
|
||||
* - Manual stop
|
||||
* - DTMF packets (PT 101) are automatically skipped
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { WavWriter } from './wav-writer.ts';
|
||||
import type { IWavWriterResult } from './wav-writer.ts';
|
||||
import { transcode, createSession, destroySession } from '../opusbridge.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IRecordingOptions {
|
||||
/** Output directory for WAV files. */
|
||||
outputDir: string;
|
||||
/** Target sample rate for the WAV output (default 8000). */
|
||||
sampleRate?: number;
|
||||
/** Maximum recording duration in seconds. 0 = unlimited. Default 120. */
|
||||
maxDurationSec?: number;
|
||||
/** Stop after this many consecutive seconds of silence. 0 = disabled. Default 5. */
|
||||
silenceTimeoutSec?: number;
|
||||
/** Silence threshold: max PCM amplitude below this is "silent". Default 200. */
|
||||
silenceThreshold?: number;
|
||||
/** Logging function. */
|
||||
log: (msg: string) => void;
|
||||
}
|
||||
|
||||
export interface IRecordingResult {
|
||||
/** Full path to the WAV file. */
|
||||
filePath: string;
|
||||
/** Duration in milliseconds. */
|
||||
durationMs: number;
|
||||
/** Sample rate of the WAV. */
|
||||
sampleRate: number;
|
||||
/** Size of the WAV file in bytes. */
|
||||
fileSize: number;
|
||||
/** Why the recording was stopped. */
|
||||
stopReason: TRecordingStopReason;
|
||||
}
|
||||
|
||||
export type TRecordingStopReason = 'manual' | 'max-duration' | 'silence' | 'cancelled';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mu-law decode table (ITU-T G.711)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Pre-computed mu-law → 16-bit linear PCM lookup table (256 entries). */
|
||||
const MULAW_DECODE: Int16Array = buildMulawDecodeTable();
|
||||
|
||||
function buildMulawDecodeTable(): Int16Array {
|
||||
const table = new Int16Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
// Invert all bits per mu-law standard.
|
||||
let mu = ~i & 0xff;
|
||||
const sign = mu & 0x80;
|
||||
const exponent = (mu >> 4) & 0x07;
|
||||
const mantissa = mu & 0x0f;
|
||||
let magnitude = ((mantissa << 1) + 33) << (exponent + 2);
|
||||
magnitude -= 0x84; // Bias adjustment
|
||||
table[i] = sign ? -magnitude : magnitude;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/** Decode a PCMU payload to 16-bit LE PCM. */
|
||||
function decodeMulaw(mulaw: Buffer): Buffer {
|
||||
const pcm = Buffer.alloc(mulaw.length * 2);
|
||||
for (let i = 0; i < mulaw.length; i++) {
|
||||
pcm.writeInt16LE(MULAW_DECODE[mulaw[i]], i * 2);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AudioRecorder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class AudioRecorder {
|
||||
/** Current state. */
|
||||
state: 'idle' | 'recording' | 'stopped' = 'idle';
|
||||
|
||||
/** Called when recording stops automatically (silence or max duration). */
|
||||
onStopped: ((result: IRecordingResult) => void) | null = null;
|
||||
|
||||
private outputDir: string;
|
||||
private sampleRate: number;
|
||||
private maxDurationSec: number;
|
||||
private silenceTimeoutSec: number;
|
||||
private silenceThreshold: number;
|
||||
private log: (msg: string) => void;
|
||||
|
||||
private wavWriter: WavWriter | null = null;
|
||||
private filePath: string = '';
|
||||
private codecSessionId: string | null = null;
|
||||
private stopReason: TRecordingStopReason = 'manual';
|
||||
|
||||
// Silence detection.
|
||||
private consecutiveSilentFrames = 0;
|
||||
/** Number of 20ms frames that constitute silence timeout. */
|
||||
private silenceFrameThreshold = 0;
|
||||
|
||||
// Max duration timer.
|
||||
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Processing queue to avoid concurrent transcodes.
|
||||
private processQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(options: IRecordingOptions) {
|
||||
this.outputDir = options.outputDir;
|
||||
this.sampleRate = options.sampleRate ?? 8000;
|
||||
this.maxDurationSec = options.maxDurationSec ?? 120;
|
||||
this.silenceTimeoutSec = options.silenceTimeoutSec ?? 5;
|
||||
this.silenceThreshold = options.silenceThreshold ?? 200;
|
||||
this.log = options.log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start recording. Creates the output directory, WAV file, and codec session.
|
||||
* @param fileId - unique ID for the recording file name
|
||||
*/
|
||||
async start(fileId?: string): Promise<void> {
|
||||
if (this.state !== 'idle') return;
|
||||
|
||||
// Ensure output directory exists.
|
||||
if (!fs.existsSync(this.outputDir)) {
|
||||
fs.mkdirSync(this.outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate file path.
|
||||
const id = fileId ?? `rec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
this.filePath = path.join(this.outputDir, `${id}.wav`);
|
||||
|
||||
// Create a codec session for isolated decoding.
|
||||
this.codecSessionId = `recorder-${id}`;
|
||||
await createSession(this.codecSessionId);
|
||||
|
||||
// Open WAV writer.
|
||||
this.wavWriter = new WavWriter({
|
||||
filePath: this.filePath,
|
||||
sampleRate: this.sampleRate,
|
||||
});
|
||||
this.wavWriter.open();
|
||||
|
||||
// Silence detection threshold: frames in timeout period.
|
||||
this.silenceFrameThreshold = this.silenceTimeoutSec > 0
|
||||
? Math.ceil((this.silenceTimeoutSec * 1000) / 20)
|
||||
: 0;
|
||||
this.consecutiveSilentFrames = 0;
|
||||
|
||||
// Max duration timer.
|
||||
if (this.maxDurationSec > 0) {
|
||||
this.maxDurationTimer = setTimeout(() => {
|
||||
if (this.state === 'recording') {
|
||||
this.stopReason = 'max-duration';
|
||||
this.log(`[recorder] max duration reached (${this.maxDurationSec}s)`);
|
||||
this.stop().then((result) => this.onStopped?.(result));
|
||||
}
|
||||
}, this.maxDurationSec * 1000);
|
||||
}
|
||||
|
||||
this.state = 'recording';
|
||||
this.stopReason = 'manual';
|
||||
this.log(`[recorder] started → ${this.filePath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed an RTP packet. Strips the 12-byte header, transcodes the payload
|
||||
* to PCMU via the Rust bridge, decodes to PCM, and writes to WAV.
|
||||
* Skips telephone-event (DTMF) and comfort noise packets.
|
||||
*/
|
||||
processRtp(data: Buffer): void {
|
||||
if (this.state !== 'recording') return;
|
||||
if (data.length < 13) return; // too short
|
||||
|
||||
const pt = data[1] & 0x7f;
|
||||
|
||||
// Skip DTMF (telephone-event) and comfort noise.
|
||||
if (pt === 101 || pt === 13) return;
|
||||
|
||||
const payload = data.subarray(12);
|
||||
if (payload.length === 0) return;
|
||||
|
||||
// Queue processing to avoid concurrent transcodes corrupting codec state.
|
||||
this.processQueue = this.processQueue.then(() => this.decodeAndWrite(payload, pt));
|
||||
}
|
||||
|
||||
/** Decode a single RTP payload to PCM and write to WAV. */
|
||||
private async decodeAndWrite(payload: Buffer, pt: number): Promise<void> {
|
||||
if (this.state !== 'recording' || !this.wavWriter) return;
|
||||
|
||||
let pcm: Buffer;
|
||||
|
||||
if (pt === 0) {
|
||||
// PCMU: decode directly in TypeScript (no Rust round-trip needed).
|
||||
pcm = decodeMulaw(payload);
|
||||
} else {
|
||||
// All other codecs: transcode to PCMU via Rust, then decode mu-law.
|
||||
const mulaw = await transcode(payload, pt, 0, this.codecSessionId ?? undefined);
|
||||
if (!mulaw) return;
|
||||
pcm = decodeMulaw(mulaw);
|
||||
}
|
||||
|
||||
// Silence detection.
|
||||
if (this.silenceFrameThreshold > 0) {
|
||||
if (isSilent(pcm, this.silenceThreshold)) {
|
||||
this.consecutiveSilentFrames++;
|
||||
if (this.consecutiveSilentFrames >= this.silenceFrameThreshold) {
|
||||
this.stopReason = 'silence';
|
||||
this.log(`[recorder] silence detected (${this.silenceTimeoutSec}s)`);
|
||||
this.stop().then((result) => this.onStopped?.(result));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.consecutiveSilentFrames = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.wavWriter.write(pcm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop recording and finalize the WAV file.
|
||||
*/
|
||||
async stop(): Promise<IRecordingResult> {
|
||||
if (this.state === 'stopped' || this.state === 'idle') {
|
||||
return {
|
||||
filePath: this.filePath,
|
||||
durationMs: 0,
|
||||
sampleRate: this.sampleRate,
|
||||
fileSize: 0,
|
||||
stopReason: this.stopReason,
|
||||
};
|
||||
}
|
||||
|
||||
this.state = 'stopped';
|
||||
|
||||
// Wait for pending decode operations to finish.
|
||||
await this.processQueue;
|
||||
|
||||
// Clear timers.
|
||||
if (this.maxDurationTimer) {
|
||||
clearTimeout(this.maxDurationTimer);
|
||||
this.maxDurationTimer = null;
|
||||
}
|
||||
|
||||
// Finalize WAV.
|
||||
let wavResult: IWavWriterResult | null = null;
|
||||
if (this.wavWriter) {
|
||||
wavResult = this.wavWriter.close();
|
||||
this.wavWriter = null;
|
||||
}
|
||||
|
||||
// Destroy codec session.
|
||||
if (this.codecSessionId) {
|
||||
await destroySession(this.codecSessionId);
|
||||
this.codecSessionId = null;
|
||||
}
|
||||
|
||||
const result: IRecordingResult = {
|
||||
filePath: this.filePath,
|
||||
durationMs: wavResult?.durationMs ?? 0,
|
||||
sampleRate: this.sampleRate,
|
||||
fileSize: wavResult?.fileSize ?? 0,
|
||||
stopReason: this.stopReason,
|
||||
};
|
||||
|
||||
this.log(`[recorder] stopped (${result.stopReason}): ${result.durationMs}ms → ${this.filePath}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Cancel recording — stops and deletes the WAV file. */
|
||||
async cancel(): Promise<void> {
|
||||
this.stopReason = 'cancelled';
|
||||
await this.stop();
|
||||
|
||||
// Delete the incomplete file.
|
||||
try {
|
||||
if (fs.existsSync(this.filePath)) {
|
||||
fs.unlinkSync(this.filePath);
|
||||
this.log(`[recorder] cancelled — deleted ${this.filePath}`);
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
/** Clean up all resources. */
|
||||
destroy(): void {
|
||||
if (this.state === 'recording') {
|
||||
this.cancel();
|
||||
}
|
||||
this.onStopped = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check if a PCM buffer is "silent" (max amplitude below threshold). */
|
||||
function isSilent(pcm: Buffer, threshold: number): boolean {
|
||||
let maxAmp = 0;
|
||||
for (let i = 0; i < pcm.length - 1; i += 2) {
|
||||
const sample = pcm.readInt16LE(i);
|
||||
const abs = sample < 0 ? -sample : sample;
|
||||
if (abs > maxAmp) maxAmp = abs;
|
||||
// Early exit: already above threshold.
|
||||
if (maxAmp >= threshold) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -138,7 +138,16 @@ export class Call {
|
||||
} else if (legs.every((l) => l.state === 'terminated')) {
|
||||
this.state = 'terminated';
|
||||
} else if (legs.some((l) => l.state === 'connected') && legs.filter((l) => l.state !== 'terminated').length >= 2) {
|
||||
this.state = 'connected';
|
||||
// If a system leg is connected, report voicemail/ivr state for the dashboard.
|
||||
const systemLeg = legs.find((l) => l.type === 'system');
|
||||
if (systemLeg) {
|
||||
// Keep voicemail/ivr state if already set; otherwise set connected.
|
||||
if (this.state !== 'voicemail' && this.state !== 'ivr') {
|
||||
this.state = 'connected';
|
||||
}
|
||||
} else {
|
||||
this.state = 'connected';
|
||||
}
|
||||
} else if (legs.some((l) => l.state === 'ringing')) {
|
||||
this.state = 'ringing';
|
||||
} else {
|
||||
@@ -164,7 +173,7 @@ export class Call {
|
||||
this.log(`[call:${this.id}] hanging up (${this.legs.size} legs)`);
|
||||
|
||||
for (const [id, leg] of this.legs) {
|
||||
// Send BYE/CANCEL for SIP legs.
|
||||
// Send BYE/CANCEL for SIP legs (system legs have no SIP signaling).
|
||||
if (leg.type === 'sip-device' || leg.type === 'sip-provider') {
|
||||
(leg as SipLeg).sendHangup();
|
||||
}
|
||||
@@ -197,6 +206,7 @@ export class Call {
|
||||
// If this is a 2-party call, hang up the other leg too.
|
||||
if (this.legs.size <= 1) {
|
||||
for (const [id, leg] of this.legs) {
|
||||
// Send BYE/CANCEL for SIP legs (system legs just get torn down).
|
||||
if (leg.type === 'sip-device' || leg.type === 'sip-provider') {
|
||||
(leg as SipLeg).sendHangup();
|
||||
}
|
||||
|
||||
272
ts/call/dtmf-detector.ts
Normal file
272
ts/call/dtmf-detector.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* DTMF detection — parses RFC 2833 telephone-event RTP packets
|
||||
* and SIP INFO (application/dtmf-relay) messages.
|
||||
*
|
||||
* Designed to be attached to any leg or RTP stream. The detector
|
||||
* deduplicates repeated telephone-event packets (same digit is sent
|
||||
* multiple times with increasing duration) and fires a callback
|
||||
* once per detected digit.
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type { SipMessage } from '../sip/index.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A single detected DTMF digit. */
|
||||
export interface IDtmfDigit {
|
||||
/** The digit character: '0'-'9', '*', '#', 'A'-'D'. */
|
||||
digit: string;
|
||||
/** Duration in milliseconds. */
|
||||
durationMs: number;
|
||||
/** Detection source. */
|
||||
source: 'rfc2833' | 'sip-info';
|
||||
/** Wall-clock timestamp when the digit was detected. */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Callback fired once per detected DTMF digit. */
|
||||
export type TDtmfCallback = (digit: IDtmfDigit) => void;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** RFC 2833 event ID → character mapping. */
|
||||
const EVENT_CHARS: string[] = [
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
||||
'*', '#', 'A', 'B', 'C', 'D',
|
||||
];
|
||||
|
||||
/** Safety timeout: report digit if no End packet arrives within this many ms. */
|
||||
const SAFETY_TIMEOUT_MS = 200;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DtmfDetector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detects DTMF digits from RFC 2833 RTP packets and SIP INFO messages.
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* const detector = new DtmfDetector(log);
|
||||
* detector.onDigit = (d) => console.log('DTMF:', d.digit);
|
||||
* // Feed every RTP packet (detector checks PT internally):
|
||||
* detector.processRtp(rtpPacket);
|
||||
* // Or feed a SIP INFO message:
|
||||
* detector.processSipInfo(sipMsg);
|
||||
* ```
|
||||
*/
|
||||
export class DtmfDetector {
|
||||
/** Callback fired once per detected digit. */
|
||||
onDigit: TDtmfCallback | null = null;
|
||||
|
||||
/** Negotiated telephone-event payload type (default 101). */
|
||||
private telephoneEventPt: number;
|
||||
|
||||
/** Clock rate for duration calculation (default 8000 Hz). */
|
||||
private clockRate: number;
|
||||
|
||||
// -- Deduplication state for RFC 2833 --
|
||||
/** Event ID of the digit currently being received. */
|
||||
private currentEventId: number | null = null;
|
||||
/** RTP timestamp of the first packet for the current event. */
|
||||
private currentEventTs: number | null = null;
|
||||
/** Whether the current event has already been reported. */
|
||||
private currentEventReported = false;
|
||||
/** Latest duration value seen (in clock ticks). */
|
||||
private currentEventDuration = 0;
|
||||
/** Latest volume value seen (dBm0, 0 = loudest). */
|
||||
private currentEventVolume = 0;
|
||||
/** Safety timer: fires if no End packet arrives. */
|
||||
private safetyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private log: (msg: string) => void;
|
||||
|
||||
constructor(
|
||||
log: (msg: string) => void,
|
||||
telephoneEventPt = 101,
|
||||
clockRate = 8000,
|
||||
) {
|
||||
this.log = log;
|
||||
this.telephoneEventPt = telephoneEventPt;
|
||||
this.clockRate = clockRate;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RFC 2833 RTP processing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Feed an RTP packet. Checks PT; ignores non-DTMF packets.
|
||||
* Expects the full RTP packet (12-byte header + payload).
|
||||
*/
|
||||
processRtp(data: Buffer): void {
|
||||
if (data.length < 16) return; // 12-byte header + 4-byte telephone-event payload minimum
|
||||
|
||||
const pt = data[1] & 0x7f;
|
||||
if (pt !== this.telephoneEventPt) return;
|
||||
|
||||
// Parse RTP header fields we need.
|
||||
const marker = (data[1] & 0x80) !== 0;
|
||||
const rtpTimestamp = data.readUInt32BE(4);
|
||||
|
||||
// Parse telephone-event payload (4 bytes starting at offset 12).
|
||||
const eventId = data[12];
|
||||
const endBit = (data[13] & 0x80) !== 0;
|
||||
const volume = data[13] & 0x3f;
|
||||
const duration = data.readUInt16BE(14);
|
||||
|
||||
// Validate event ID.
|
||||
if (eventId >= EVENT_CHARS.length) return;
|
||||
|
||||
// Detect new event: marker bit, different event ID, or different RTP timestamp.
|
||||
const isNewEvent =
|
||||
marker ||
|
||||
eventId !== this.currentEventId ||
|
||||
rtpTimestamp !== this.currentEventTs;
|
||||
|
||||
if (isNewEvent) {
|
||||
// If there was an unreported previous event, report it now (fallback).
|
||||
this.reportPendingEvent();
|
||||
|
||||
// Start tracking the new event.
|
||||
this.currentEventId = eventId;
|
||||
this.currentEventTs = rtpTimestamp;
|
||||
this.currentEventReported = false;
|
||||
this.currentEventDuration = duration;
|
||||
this.currentEventVolume = volume;
|
||||
|
||||
// Start safety timer.
|
||||
this.clearSafetyTimer();
|
||||
this.safetyTimer = setTimeout(() => {
|
||||
this.reportPendingEvent();
|
||||
}, SAFETY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
// Update duration (it increases with each retransmission).
|
||||
if (duration > this.currentEventDuration) {
|
||||
this.currentEventDuration = duration;
|
||||
}
|
||||
|
||||
// Report on End bit (first time only).
|
||||
if (endBit && !this.currentEventReported) {
|
||||
this.currentEventReported = true;
|
||||
this.clearSafetyTimer();
|
||||
|
||||
const digit = EVENT_CHARS[eventId];
|
||||
const durationMs = (this.currentEventDuration / this.clockRate) * 1000;
|
||||
|
||||
this.log(`[dtmf] RFC 2833 digit '${digit}' (${Math.round(durationMs)}ms)`);
|
||||
this.onDigit?.({
|
||||
digit,
|
||||
durationMs,
|
||||
source: 'rfc2833',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Report a pending (unreported) event — called by safety timer or on new event start. */
|
||||
private reportPendingEvent(): void {
|
||||
if (
|
||||
this.currentEventId !== null &&
|
||||
!this.currentEventReported &&
|
||||
this.currentEventId < EVENT_CHARS.length
|
||||
) {
|
||||
this.currentEventReported = true;
|
||||
this.clearSafetyTimer();
|
||||
|
||||
const digit = EVENT_CHARS[this.currentEventId];
|
||||
const durationMs = (this.currentEventDuration / this.clockRate) * 1000;
|
||||
|
||||
this.log(`[dtmf] RFC 2833 digit '${digit}' (${Math.round(durationMs)}ms, safety timeout)`);
|
||||
this.onDigit?.({
|
||||
digit,
|
||||
durationMs,
|
||||
source: 'rfc2833',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private clearSafetyTimer(): void {
|
||||
if (this.safetyTimer) {
|
||||
clearTimeout(this.safetyTimer);
|
||||
this.safetyTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SIP INFO processing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a SIP INFO message carrying DTMF.
|
||||
* Supports Content-Type: application/dtmf-relay (Signal=X / Duration=Y).
|
||||
*/
|
||||
processSipInfo(msg: SipMessage): void {
|
||||
const ct = (msg.getHeader('Content-Type') || '').toLowerCase();
|
||||
if (!ct.includes('application/dtmf-relay') && !ct.includes('application/dtmf')) return;
|
||||
|
||||
const body = msg.body || '';
|
||||
|
||||
if (ct.includes('application/dtmf-relay')) {
|
||||
// Format: "Signal= 5\r\nDuration= 160\r\n"
|
||||
const signalMatch = body.match(/Signal\s*=\s*(\S+)/i);
|
||||
const durationMatch = body.match(/Duration\s*=\s*(\d+)/i);
|
||||
if (!signalMatch) return;
|
||||
|
||||
const signal = signalMatch[1];
|
||||
const durationTicks = durationMatch ? parseInt(durationMatch[1], 10) : 160;
|
||||
|
||||
// Validate digit.
|
||||
if (signal.length !== 1 || !/[0-9*#A-Da-d]/.test(signal)) return;
|
||||
const digit = signal.toUpperCase();
|
||||
const durationMs = (durationTicks / this.clockRate) * 1000;
|
||||
|
||||
this.log(`[dtmf] SIP INFO digit '${digit}' (${Math.round(durationMs)}ms)`);
|
||||
this.onDigit?.({
|
||||
digit,
|
||||
durationMs,
|
||||
source: 'sip-info',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (ct.includes('application/dtmf')) {
|
||||
// Simple format: just the digit character in the body.
|
||||
const digit = body.trim().toUpperCase();
|
||||
if (digit.length !== 1 || !/[0-9*#A-D]/.test(digit)) return;
|
||||
|
||||
this.log(`[dtmf] SIP INFO digit '${digit}' (application/dtmf)`);
|
||||
this.onDigit?.({
|
||||
digit,
|
||||
durationMs: 250, // default duration
|
||||
source: 'sip-info',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Reset detection state (e.g., between calls). */
|
||||
reset(): void {
|
||||
this.currentEventId = null;
|
||||
this.currentEventTs = null;
|
||||
this.currentEventReported = false;
|
||||
this.currentEventDuration = 0;
|
||||
this.currentEventVolume = 0;
|
||||
this.clearSafetyTimer();
|
||||
}
|
||||
|
||||
/** Clean up timers and references. */
|
||||
destroy(): void {
|
||||
this.clearSafetyTimer();
|
||||
this.onDigit = null;
|
||||
}
|
||||
}
|
||||
404
ts/call/prompt-cache.ts
Normal file
404
ts/call/prompt-cache.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* PromptCache — manages multiple named audio prompts for IVR and voicemail.
|
||||
*
|
||||
* Each prompt is pre-encoded as both G.722 frames (for SIP legs) and Opus
|
||||
* frames (for WebRTC legs), ready for 20ms RTP playback.
|
||||
*
|
||||
* Supports three sources:
|
||||
* 1. TTS generation via espeak-ng (primary) or Kokoro (fallback)
|
||||
* 2. Loading from a pre-existing WAV file
|
||||
* 3. Programmatic tone generation (beep, etc.)
|
||||
*
|
||||
* The existing announcement.ts system continues to work independently;
|
||||
* this module provides generalized prompt management for IVR/voicemail.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { buildRtpHeader, rtpClockIncrement } from './leg.ts';
|
||||
import { encodePcm, isCodecReady } from '../opusbridge.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A pre-encoded prompt ready for RTP playback. */
|
||||
export interface ICachedPrompt {
|
||||
/** Unique prompt identifier. */
|
||||
id: string;
|
||||
/** G.722 encoded frames (20ms each, no RTP header). */
|
||||
g722Frames: Buffer[];
|
||||
/** Opus encoded frames (20ms each, no RTP header). */
|
||||
opusFrames: Buffer[];
|
||||
/** Total duration in milliseconds. */
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TTS helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TTS_DIR = path.join(process.cwd(), '.nogit', 'tts');
|
||||
|
||||
/** Check if espeak-ng is available. */
|
||||
function isEspeakAvailable(): boolean {
|
||||
try {
|
||||
execSync('which espeak-ng', { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate WAV via espeak-ng. */
|
||||
function generateViaEspeak(wavPath: string, text: string): boolean {
|
||||
try {
|
||||
execSync(
|
||||
`espeak-ng -v en-us -s 150 -w "${wavPath}" "${text}"`,
|
||||
{ timeout: 10000, stdio: 'pipe' },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate WAV via Kokoro TTS. */
|
||||
function generateViaKokoro(wavPath: string, text: string, voice: string): boolean {
|
||||
const modelPath = path.join(TTS_DIR, 'kokoro-v1.0.onnx');
|
||||
const voicesPath = path.join(TTS_DIR, 'voices.bin');
|
||||
if (!fs.existsSync(modelPath) || !fs.existsSync(voicesPath)) return false;
|
||||
|
||||
const root = process.cwd();
|
||||
const ttsBin = [
|
||||
path.join(root, 'dist_rust', 'tts-engine'),
|
||||
path.join(root, 'rust', 'target', 'release', 'tts-engine'),
|
||||
path.join(root, 'rust', 'target', 'debug', 'tts-engine'),
|
||||
].find((p) => fs.existsSync(p));
|
||||
if (!ttsBin) return false;
|
||||
|
||||
try {
|
||||
execSync(
|
||||
`"${ttsBin}" --model "${modelPath}" --voices "${voicesPath}" --voice "${voice}" --output "${wavPath}" --text "${text}"`,
|
||||
{ timeout: 120000, stdio: 'pipe' },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a WAV file and return raw PCM + sample rate. */
|
||||
function readWavWithRate(wavPath: string): { pcm: Buffer; sampleRate: number } | null {
|
||||
const wav = fs.readFileSync(wavPath);
|
||||
if (wav.length < 44) return null;
|
||||
if (wav.toString('ascii', 0, 4) !== 'RIFF') return null;
|
||||
if (wav.toString('ascii', 8, 12) !== 'WAVE') return null;
|
||||
|
||||
let sampleRate = 22050;
|
||||
let pcm: Buffer | null = null;
|
||||
let offset = 12;
|
||||
|
||||
while (offset < wav.length - 8) {
|
||||
const chunkId = wav.toString('ascii', offset, offset + 4);
|
||||
const chunkSize = wav.readUInt32LE(offset + 4);
|
||||
if (chunkId === 'fmt ') {
|
||||
sampleRate = wav.readUInt32LE(offset + 12);
|
||||
}
|
||||
if (chunkId === 'data') {
|
||||
pcm = wav.subarray(offset + 8, offset + 8 + chunkSize);
|
||||
}
|
||||
offset += 8 + chunkSize;
|
||||
if (offset % 2 !== 0) offset++;
|
||||
}
|
||||
|
||||
return pcm ? { pcm, sampleRate } : null;
|
||||
}
|
||||
|
||||
/** Encode raw PCM frames to G.722 + Opus. */
|
||||
async function encodePcmFrames(
|
||||
pcm: Buffer,
|
||||
sampleRate: number,
|
||||
log: (msg: string) => void,
|
||||
): Promise<{ g722Frames: Buffer[]; opusFrames: Buffer[] } | null> {
|
||||
if (!isCodecReady()) return null;
|
||||
|
||||
const frameSamples = Math.floor(sampleRate * 0.02); // 20ms
|
||||
const frameBytes = frameSamples * 2; // 16-bit
|
||||
const totalFrames = Math.floor(pcm.length / frameBytes);
|
||||
|
||||
const g722Frames: Buffer[] = [];
|
||||
const opusFrames: Buffer[] = [];
|
||||
|
||||
for (let i = 0; i < totalFrames; i++) {
|
||||
const framePcm = Buffer.from(pcm.subarray(i * frameBytes, (i + 1) * frameBytes));
|
||||
const [g722, opus] = await Promise.all([
|
||||
encodePcm(framePcm, sampleRate, 9), // G.722
|
||||
encodePcm(framePcm, sampleRate, 111), // Opus
|
||||
]);
|
||||
if (g722) g722Frames.push(g722);
|
||||
if (opus) opusFrames.push(opus);
|
||||
}
|
||||
|
||||
return { g722Frames, opusFrames };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptCache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class PromptCache {
|
||||
private prompts = new Map<string, ICachedPrompt>();
|
||||
private log: (msg: string) => void;
|
||||
private espeakAvailable: boolean | null = null;
|
||||
|
||||
constructor(log: (msg: string) => void) {
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Get a cached prompt by ID. */
|
||||
get(id: string): ICachedPrompt | null {
|
||||
return this.prompts.get(id) ?? null;
|
||||
}
|
||||
|
||||
/** Check if a prompt is cached. */
|
||||
has(id: string): boolean {
|
||||
return this.prompts.has(id);
|
||||
}
|
||||
|
||||
/** List all cached prompt IDs. */
|
||||
listIds(): string[] {
|
||||
return [...this.prompts.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a TTS prompt and cache it.
|
||||
* Uses espeak-ng (primary) or Kokoro (fallback).
|
||||
*/
|
||||
async generatePrompt(id: string, text: string, voice = 'af_bella'): Promise<ICachedPrompt | null> {
|
||||
fs.mkdirSync(TTS_DIR, { recursive: true });
|
||||
const wavPath = path.join(TTS_DIR, `prompt-${id}.wav`);
|
||||
|
||||
// Check espeak availability once.
|
||||
if (this.espeakAvailable === null) {
|
||||
this.espeakAvailable = isEspeakAvailable();
|
||||
}
|
||||
|
||||
// Generate WAV.
|
||||
let generated = false;
|
||||
if (!fs.existsSync(wavPath)) {
|
||||
if (this.espeakAvailable) {
|
||||
generated = generateViaEspeak(wavPath, text);
|
||||
}
|
||||
if (!generated) {
|
||||
generated = generateViaKokoro(wavPath, text, voice);
|
||||
}
|
||||
if (!generated) {
|
||||
this.log(`[prompt-cache] failed to generate TTS for "${id}"`);
|
||||
return null;
|
||||
}
|
||||
this.log(`[prompt-cache] generated WAV for "${id}"`);
|
||||
}
|
||||
|
||||
return this.loadWavPrompt(id, wavPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a WAV file as a prompt and cache it.
|
||||
*/
|
||||
async loadWavPrompt(id: string, wavPath: string): Promise<ICachedPrompt | null> {
|
||||
if (!fs.existsSync(wavPath)) {
|
||||
this.log(`[prompt-cache] WAV not found: ${wavPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = readWavWithRate(wavPath);
|
||||
if (!result) {
|
||||
this.log(`[prompt-cache] failed to parse WAV: ${wavPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const encoded = await encodePcmFrames(result.pcm, result.sampleRate, this.log);
|
||||
if (!encoded) {
|
||||
this.log(`[prompt-cache] encoding failed for "${id}" (codec bridge not ready?)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationMs = encoded.g722Frames.length * 20;
|
||||
const prompt: ICachedPrompt = {
|
||||
id,
|
||||
g722Frames: encoded.g722Frames,
|
||||
opusFrames: encoded.opusFrames,
|
||||
durationMs,
|
||||
};
|
||||
|
||||
this.prompts.set(id, prompt);
|
||||
this.log(`[prompt-cache] cached "${id}": ${encoded.g722Frames.length} frames (${(durationMs / 1000).toFixed(1)}s)`);
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a beep tone prompt (sine wave).
|
||||
* @param id - prompt ID
|
||||
* @param freqHz - tone frequency (default 1000 Hz)
|
||||
* @param durationMs - tone duration (default 500ms)
|
||||
* @param amplitude - 16-bit amplitude (default 8000)
|
||||
*/
|
||||
async generateBeep(
|
||||
id: string,
|
||||
freqHz = 1000,
|
||||
durationMs = 500,
|
||||
amplitude = 8000,
|
||||
): Promise<ICachedPrompt | null> {
|
||||
// Generate at 16kHz for decent quality.
|
||||
const sampleRate = 16000;
|
||||
const totalSamples = Math.floor((sampleRate * durationMs) / 1000);
|
||||
const pcm = Buffer.alloc(totalSamples * 2);
|
||||
|
||||
for (let i = 0; i < totalSamples; i++) {
|
||||
const t = i / sampleRate;
|
||||
// Apply a short fade-in/fade-out to avoid click artifacts.
|
||||
const fadeLen = Math.floor(sampleRate * 0.01); // 10ms fade
|
||||
let envelope = 1.0;
|
||||
if (i < fadeLen) envelope = i / fadeLen;
|
||||
else if (i > totalSamples - fadeLen) envelope = (totalSamples - i) / fadeLen;
|
||||
|
||||
const sample = Math.round(Math.sin(2 * Math.PI * freqHz * t) * amplitude * envelope);
|
||||
pcm.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i * 2);
|
||||
}
|
||||
|
||||
const encoded = await encodePcmFrames(pcm, sampleRate, this.log);
|
||||
if (!encoded) {
|
||||
this.log(`[prompt-cache] beep encoding failed for "${id}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const actualDuration = encoded.g722Frames.length * 20;
|
||||
const prompt: ICachedPrompt = {
|
||||
id,
|
||||
g722Frames: encoded.g722Frames,
|
||||
opusFrames: encoded.opusFrames,
|
||||
durationMs: actualDuration,
|
||||
};
|
||||
|
||||
this.prompts.set(id, prompt);
|
||||
this.log(`[prompt-cache] beep "${id}" cached: ${actualDuration}ms @ ${freqHz}Hz`);
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a prompt from the cache.
|
||||
*/
|
||||
remove(id: string): void {
|
||||
this.prompts.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached prompts.
|
||||
*/
|
||||
clear(): void {
|
||||
this.prompts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone playback helpers (for use by SystemLeg)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Play a cached prompt's G.722 frames as RTP packets at 20ms intervals.
|
||||
*
|
||||
* @param prompt - the cached prompt to play
|
||||
* @param sendPacket - function to send a raw RTP packet (12-byte header + payload)
|
||||
* @param ssrc - SSRC for RTP headers
|
||||
* @param onDone - called when playback finishes
|
||||
* @returns cancel function, or null if prompt has no G.722 frames
|
||||
*/
|
||||
export function playPromptG722(
|
||||
prompt: ICachedPrompt,
|
||||
sendPacket: (pkt: Buffer) => void,
|
||||
ssrc: number,
|
||||
onDone?: () => void,
|
||||
): (() => void) | null {
|
||||
if (prompt.g722Frames.length === 0) {
|
||||
onDone?.();
|
||||
return null;
|
||||
}
|
||||
|
||||
const frames = prompt.g722Frames;
|
||||
const PT = 9;
|
||||
let frameIdx = 0;
|
||||
let seq = Math.floor(Math.random() * 0xffff);
|
||||
let rtpTs = Math.floor(Math.random() * 0xffffffff);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (frameIdx >= frames.length) {
|
||||
clearInterval(timer);
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = frames[frameIdx];
|
||||
const hdr = buildRtpHeader(PT, seq & 0xffff, rtpTs >>> 0, ssrc >>> 0, frameIdx === 0);
|
||||
const pkt = Buffer.concat([hdr, payload]);
|
||||
sendPacket(pkt);
|
||||
|
||||
seq++;
|
||||
rtpTs += rtpClockIncrement(PT);
|
||||
frameIdx++;
|
||||
}, 20);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a cached prompt's Opus frames as RTP packets at 20ms intervals.
|
||||
*
|
||||
* @param prompt - the cached prompt to play
|
||||
* @param sendPacket - function to send a raw RTP packet
|
||||
* @param ssrc - SSRC for RTP headers
|
||||
* @param counters - shared seq/ts counters (mutated in place for seamless transitions)
|
||||
* @param onDone - called when playback finishes
|
||||
* @returns cancel function, or null if prompt has no Opus frames
|
||||
*/
|
||||
export function playPromptOpus(
|
||||
prompt: ICachedPrompt,
|
||||
sendPacket: (pkt: Buffer) => void,
|
||||
ssrc: number,
|
||||
counters: { seq: number; ts: number },
|
||||
onDone?: () => void,
|
||||
): (() => void) | null {
|
||||
if (prompt.opusFrames.length === 0) {
|
||||
onDone?.();
|
||||
return null;
|
||||
}
|
||||
|
||||
const frames = prompt.opusFrames;
|
||||
const PT = 111;
|
||||
let frameIdx = 0;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (frameIdx >= frames.length) {
|
||||
clearInterval(timer);
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = frames[frameIdx];
|
||||
const hdr = buildRtpHeader(PT, counters.seq & 0xffff, counters.ts >>> 0, ssrc >>> 0, frameIdx === 0);
|
||||
const pkt = Buffer.concat([hdr, payload]);
|
||||
sendPacket(pkt);
|
||||
|
||||
counters.seq++;
|
||||
counters.ts += 960; // Opus 48kHz: 960 samples per 20ms
|
||||
frameIdx++;
|
||||
}, 20);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
@@ -122,6 +122,9 @@ export class SipLeg implements ILeg {
|
||||
onConnected: ((leg: SipLeg) => void) | null = null;
|
||||
onTerminated: ((leg: SipLeg) => void) | null = null;
|
||||
|
||||
/** Callback for SIP INFO messages (used for DTMF relay). */
|
||||
onInfoReceived: ((msg: SipMessage) => void) | null = null;
|
||||
|
||||
constructor(id: string, config: ISipLegConfig) {
|
||||
this.id = id;
|
||||
this.type = config.role === 'device' ? 'sip-device' : 'sip-provider';
|
||||
@@ -464,7 +467,15 @@ export class SipLeg implements ILeg {
|
||||
this.onTerminated?.(this);
|
||||
this.onStateChange?.(this);
|
||||
}
|
||||
// Other in-dialog requests (re-INVITE, INFO, etc.) can be handled here in the future.
|
||||
if (method === 'INFO') {
|
||||
// Respond 200 OK to the INFO request.
|
||||
const ok = SipMessage.createResponse(200, 'OK', msg);
|
||||
this.config.sendSip(ok.serialize(), { address: rinfo.address, port: rinfo.port });
|
||||
|
||||
// Forward to DTMF handler (if attached).
|
||||
this.onInfoReceived?.(msg);
|
||||
}
|
||||
// Other in-dialog requests (re-INVITE, etc.) can be handled here in the future.
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
336
ts/call/system-leg.ts
Normal file
336
ts/call/system-leg.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* SystemLeg — virtual ILeg for IVR menus and voicemail.
|
||||
*
|
||||
* Plugs into the Call hub exactly like SipLeg or WebRtcLeg:
|
||||
* - Receives caller audio via sendRtp() (called by Call.forwardRtp)
|
||||
* - Plays prompts by firing onRtpReceived (picked up by Call.forwardRtp → caller's leg)
|
||||
* - Detects DTMF from caller's audio (RFC 2833 telephone-event)
|
||||
* - Records caller's audio to WAV files (for voicemail)
|
||||
*
|
||||
* No UDP socket or SIP dialog needed — purely virtual.
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type dgram from 'node:dgram';
|
||||
import type { IEndpoint } from '../sip/index.ts';
|
||||
import type { SipMessage } from '../sip/index.ts';
|
||||
import type { SipDialog } from '../sip/index.ts';
|
||||
import type { IRtpTranscoder } from '../codec.ts';
|
||||
import type { ILeg } from './leg.ts';
|
||||
import type { TLegState, TLegType, ILegStatus } from './types.ts';
|
||||
import { DtmfDetector } from './dtmf-detector.ts';
|
||||
import type { IDtmfDigit } from './dtmf-detector.ts';
|
||||
import { AudioRecorder } from './audio-recorder.ts';
|
||||
import type { IRecordingResult } from './audio-recorder.ts';
|
||||
import { PromptCache, playPromptG722, playPromptOpus } from './prompt-cache.ts';
|
||||
import type { ICachedPrompt } from './prompt-cache.ts';
|
||||
import { buildRtpHeader, rtpClockIncrement } from './leg.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TSystemLegMode = 'ivr' | 'voicemail-greeting' | 'voicemail-recording' | 'idle';
|
||||
|
||||
export interface ISystemLegConfig {
|
||||
/** Logging function. */
|
||||
log: (msg: string) => void;
|
||||
/** The prompt cache for TTS playback. */
|
||||
promptCache: PromptCache;
|
||||
/**
|
||||
* Codec payload type used by the caller's leg.
|
||||
* Determines whether G.722 (9) or Opus (111) frames are played.
|
||||
* Default: 9 (G.722, typical for SIP callers).
|
||||
*/
|
||||
callerCodecPt?: number;
|
||||
/** Called when a DTMF digit is detected. */
|
||||
onDtmfDigit?: (digit: IDtmfDigit) => void;
|
||||
/** Called when a voicemail recording is complete. */
|
||||
onRecordingComplete?: (result: IRecordingResult) => void;
|
||||
/** Called when the SystemLeg wants to signal an IVR action. */
|
||||
onAction?: (action: string, data?: any) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SystemLeg
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class SystemLeg implements ILeg {
|
||||
readonly id: string;
|
||||
readonly type: TLegType = 'system';
|
||||
state: TLegState = 'connected'; // Immediately "connected" — no setup phase.
|
||||
|
||||
/** Current operating mode. */
|
||||
mode: TSystemLegMode = 'idle';
|
||||
|
||||
// --- ILeg required fields (virtual — no real network resources) ---
|
||||
readonly sipCallId: string;
|
||||
readonly rtpPort: number | null = null;
|
||||
readonly rtpSock: dgram.Socket | null = null;
|
||||
remoteMedia: IEndpoint | null = null;
|
||||
codec: number | null = null;
|
||||
transcoder: IRtpTranscoder | null = null;
|
||||
pktSent = 0;
|
||||
pktReceived = 0;
|
||||
readonly dialog: SipDialog | null = null;
|
||||
|
||||
/**
|
||||
* Set by Call.addLeg() — firing this injects audio into the Call hub,
|
||||
* which forwards it to the caller's leg.
|
||||
*/
|
||||
onRtpReceived: ((data: Buffer) => void) | null = null;
|
||||
|
||||
// --- Internal components ---
|
||||
private dtmfDetector: DtmfDetector;
|
||||
private recorder: AudioRecorder | null = null;
|
||||
private promptCache: PromptCache;
|
||||
private promptCancel: (() => void) | null = null;
|
||||
private callerCodecPt: number;
|
||||
private log: (msg: string) => void;
|
||||
readonly config: ISystemLegConfig;
|
||||
|
||||
/** Stable SSRC for all prompt playback (random, stays constant for the leg's lifetime). */
|
||||
private ssrc: number;
|
||||
|
||||
/** Sequence/timestamp counters for Opus prompt playback (shared for seamless transitions). */
|
||||
private opusCounters = { seq: 0, ts: 0 };
|
||||
|
||||
constructor(id: string, config: ISystemLegConfig) {
|
||||
this.id = id;
|
||||
this.sipCallId = `system-${id}`; // Virtual Call-ID — not a real SIP dialog.
|
||||
this.config = config;
|
||||
this.log = config.log;
|
||||
this.promptCache = config.promptCache;
|
||||
this.callerCodecPt = config.callerCodecPt ?? 9; // Default G.722
|
||||
|
||||
this.ssrc = (Math.random() * 0xffffffff) >>> 0;
|
||||
this.opusCounters.seq = Math.floor(Math.random() * 0xffff);
|
||||
this.opusCounters.ts = Math.floor(Math.random() * 0xffffffff);
|
||||
|
||||
// Initialize DTMF detector.
|
||||
this.dtmfDetector = new DtmfDetector(this.log);
|
||||
this.dtmfDetector.onDigit = (digit) => {
|
||||
this.log(`[system-leg:${this.id}] DTMF '${digit.digit}' (${digit.source})`);
|
||||
this.config.onDtmfDigit?.(digit);
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ILeg: sendRtp — receives caller's audio from the Call hub
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Called by the Call hub (via forwardRtp) to deliver the caller's audio
|
||||
* to this leg. We use this for DTMF detection and recording.
|
||||
*/
|
||||
sendRtp(data: Buffer): void {
|
||||
this.pktReceived++;
|
||||
|
||||
// Feed DTMF detector (it checks PT internally, ignores non-101 packets).
|
||||
this.dtmfDetector.processRtp(data);
|
||||
|
||||
// Feed recorder if active.
|
||||
if (this.mode === 'voicemail-recording' && this.recorder) {
|
||||
this.recorder.processRtp(data);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ILeg: handleSipMessage — handles SIP INFO for DTMF
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handle a SIP message routed to this leg. Only SIP INFO (DTMF) is relevant.
|
||||
*/
|
||||
handleSipMessage(msg: SipMessage, _rinfo: IEndpoint): void {
|
||||
if (msg.method === 'INFO') {
|
||||
this.dtmfDetector.processSipInfo(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Prompt playback
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Play a cached prompt by ID.
|
||||
* The audio is injected into the Call hub via onRtpReceived.
|
||||
*
|
||||
* @param promptId - ID of the prompt in the PromptCache
|
||||
* @param onDone - called when playback completes (not on cancel)
|
||||
* @returns true if playback started, false if prompt not found
|
||||
*/
|
||||
playPrompt(promptId: string, onDone?: () => void): boolean {
|
||||
const prompt = this.promptCache.get(promptId);
|
||||
if (!prompt) {
|
||||
this.log(`[system-leg:${this.id}] prompt "${promptId}" not found`);
|
||||
onDone?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancel any in-progress playback.
|
||||
this.cancelPrompt();
|
||||
|
||||
this.log(`[system-leg:${this.id}] playing prompt "${promptId}" (${prompt.durationMs}ms)`);
|
||||
|
||||
// Select G.722 or Opus frames based on caller codec.
|
||||
if (this.callerCodecPt === 111) {
|
||||
// WebRTC caller: play Opus frames.
|
||||
this.promptCancel = playPromptOpus(
|
||||
prompt,
|
||||
(pkt) => this.injectPacket(pkt),
|
||||
this.ssrc,
|
||||
this.opusCounters,
|
||||
() => {
|
||||
this.promptCancel = null;
|
||||
onDone?.();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// SIP caller: play G.722 frames (works for all SIP codecs since the
|
||||
// SipLeg's RTP socket sends whatever we give it — the provider's
|
||||
// media endpoint accepts the codec negotiated in the SDP).
|
||||
this.promptCancel = playPromptG722(
|
||||
prompt,
|
||||
(pkt) => this.injectPacket(pkt),
|
||||
this.ssrc,
|
||||
() => {
|
||||
this.promptCancel = null;
|
||||
onDone?.();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return this.promptCancel !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a sequence of prompts, one after another.
|
||||
*/
|
||||
playPromptSequence(promptIds: string[], onDone?: () => void): void {
|
||||
let index = 0;
|
||||
const playNext = () => {
|
||||
if (index >= promptIds.length) {
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
const id = promptIds[index++];
|
||||
if (!this.playPrompt(id, playNext)) {
|
||||
// Prompt not found — skip and play next.
|
||||
playNext();
|
||||
}
|
||||
};
|
||||
playNext();
|
||||
}
|
||||
|
||||
/** Cancel any in-progress prompt playback. */
|
||||
cancelPrompt(): void {
|
||||
if (this.promptCancel) {
|
||||
this.promptCancel();
|
||||
this.promptCancel = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a prompt is currently playing. */
|
||||
get isPlaying(): boolean {
|
||||
return this.promptCancel !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject an RTP packet into the Call hub.
|
||||
* This simulates "receiving" audio on this leg — the hub
|
||||
* will forward it to the caller's leg.
|
||||
*/
|
||||
private injectPacket(pkt: Buffer): void {
|
||||
this.pktSent++;
|
||||
this.onRtpReceived?.(pkt);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Recording
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start recording the caller's audio.
|
||||
* @param outputDir - directory to write the WAV file
|
||||
* @param fileId - unique ID for the file name
|
||||
*/
|
||||
async startRecording(outputDir: string, fileId?: string): Promise<void> {
|
||||
if (this.recorder) {
|
||||
await this.recorder.stop();
|
||||
}
|
||||
|
||||
this.recorder = new AudioRecorder({
|
||||
outputDir,
|
||||
log: this.log,
|
||||
maxDurationSec: 120,
|
||||
silenceTimeoutSec: 5,
|
||||
});
|
||||
|
||||
this.recorder.onStopped = (result) => {
|
||||
this.log(`[system-leg:${this.id}] recording auto-stopped (${result.stopReason})`);
|
||||
this.config.onRecordingComplete?.(result);
|
||||
};
|
||||
|
||||
this.mode = 'voicemail-recording';
|
||||
await this.recorder.start(fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop recording and finalize the WAV file.
|
||||
*/
|
||||
async stopRecording(): Promise<IRecordingResult | null> {
|
||||
if (!this.recorder) return null;
|
||||
|
||||
const result = await this.recorder.stop();
|
||||
this.recorder = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Cancel recording — stops and deletes the file. */
|
||||
async cancelRecording(): Promise<void> {
|
||||
if (this.recorder) {
|
||||
await this.recorder.cancel();
|
||||
this.recorder = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Release all resources. */
|
||||
teardown(): void {
|
||||
this.cancelPrompt();
|
||||
|
||||
// Stop recording gracefully.
|
||||
if (this.recorder && this.recorder.state === 'recording') {
|
||||
this.recorder.stop().then((result) => {
|
||||
this.config.onRecordingComplete?.(result);
|
||||
});
|
||||
this.recorder = null;
|
||||
}
|
||||
|
||||
this.dtmfDetector.destroy();
|
||||
this.state = 'terminated';
|
||||
this.mode = 'idle';
|
||||
this.onRtpReceived = null;
|
||||
|
||||
this.log(`[system-leg:${this.id}] torn down`);
|
||||
}
|
||||
|
||||
/** Status snapshot for the dashboard. */
|
||||
getStatus(): ILegStatus {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
state: this.state,
|
||||
remoteMedia: null,
|
||||
rtpPort: null,
|
||||
pktSent: this.pktSent,
|
||||
pktReceived: this.pktReceived,
|
||||
codec: this.callerCodecPt === 111 ? 'Opus' : 'G.722',
|
||||
transcoding: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export type TCallState =
|
||||
| 'ringing'
|
||||
| 'connected'
|
||||
| 'on-hold'
|
||||
| 'voicemail'
|
||||
| 'ivr'
|
||||
| 'transferring'
|
||||
| 'terminating'
|
||||
| 'terminated';
|
||||
@@ -25,7 +27,7 @@ export type TLegState =
|
||||
| 'terminating'
|
||||
| 'terminated';
|
||||
|
||||
export type TLegType = 'sip-device' | 'sip-provider' | 'webrtc';
|
||||
export type TLegType = 'sip-device' | 'sip-provider' | 'webrtc' | 'system';
|
||||
|
||||
export type TCallDirection = 'inbound' | 'outbound' | 'internal';
|
||||
|
||||
|
||||
163
ts/call/wav-writer.ts
Normal file
163
ts/call/wav-writer.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Streaming WAV file writer — opens a file, writes a placeholder header,
|
||||
* appends raw PCM data in chunks, and finalizes (patches sizes) on close.
|
||||
*
|
||||
* Produces standard RIFF/WAVE format compatible with the WAV parser
|
||||
* in announcement.ts (extractPcmFromWav).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IWavWriterOptions {
|
||||
/** Full path to the output WAV file. */
|
||||
filePath: string;
|
||||
/** Sample rate in Hz (e.g. 16000). */
|
||||
sampleRate: number;
|
||||
/** Number of channels (default 1 = mono). */
|
||||
channels?: number;
|
||||
/** Bits per sample (default 16). */
|
||||
bitsPerSample?: number;
|
||||
}
|
||||
|
||||
export interface IWavWriterResult {
|
||||
/** Full path to the WAV file. */
|
||||
filePath: string;
|
||||
/** Total duration in milliseconds. */
|
||||
durationMs: number;
|
||||
/** Sample rate of the output file. */
|
||||
sampleRate: number;
|
||||
/** Total number of audio samples written. */
|
||||
totalSamples: number;
|
||||
/** File size in bytes. */
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WAV header constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Standard WAV header size: RIFF(12) + fmt(24) + data-header(8) = 44 bytes. */
|
||||
const HEADER_SIZE = 44;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WavWriter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class WavWriter {
|
||||
private fd: number | null = null;
|
||||
private totalDataBytes = 0;
|
||||
private closed = false;
|
||||
|
||||
private filePath: string;
|
||||
private sampleRate: number;
|
||||
private channels: number;
|
||||
private bitsPerSample: number;
|
||||
|
||||
constructor(options: IWavWriterOptions) {
|
||||
this.filePath = options.filePath;
|
||||
this.sampleRate = options.sampleRate;
|
||||
this.channels = options.channels ?? 1;
|
||||
this.bitsPerSample = options.bitsPerSample ?? 16;
|
||||
}
|
||||
|
||||
/** Open the file and write a placeholder 44-byte WAV header. */
|
||||
open(): void {
|
||||
if (this.fd !== null) throw new Error('WavWriter already open');
|
||||
|
||||
this.fd = fs.openSync(this.filePath, 'w');
|
||||
this.totalDataBytes = 0;
|
||||
this.closed = false;
|
||||
|
||||
// Write 44 bytes of zeros as placeholder — patched in close().
|
||||
const placeholder = Buffer.alloc(HEADER_SIZE);
|
||||
fs.writeSync(this.fd, placeholder, 0, HEADER_SIZE, 0);
|
||||
}
|
||||
|
||||
/** Append raw 16-bit little-endian PCM samples. */
|
||||
write(pcm: Buffer): void {
|
||||
if (this.fd === null || this.closed) return;
|
||||
if (pcm.length === 0) return;
|
||||
|
||||
fs.writeSync(this.fd, pcm, 0, pcm.length);
|
||||
this.totalDataBytes += pcm.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize: rewrite the RIFF and data chunk sizes in the header, close the file.
|
||||
* Returns metadata about the written WAV.
|
||||
*/
|
||||
close(): IWavWriterResult {
|
||||
if (this.fd === null || this.closed) {
|
||||
return {
|
||||
filePath: this.filePath,
|
||||
durationMs: 0,
|
||||
sampleRate: this.sampleRate,
|
||||
totalSamples: 0,
|
||||
fileSize: HEADER_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
this.closed = true;
|
||||
|
||||
const blockAlign = this.channels * (this.bitsPerSample / 8);
|
||||
const byteRate = this.sampleRate * blockAlign;
|
||||
const fileSize = HEADER_SIZE + this.totalDataBytes;
|
||||
|
||||
// Build the complete 44-byte header.
|
||||
const hdr = Buffer.alloc(HEADER_SIZE);
|
||||
let offset = 0;
|
||||
|
||||
// RIFF chunk descriptor.
|
||||
hdr.write('RIFF', offset); offset += 4;
|
||||
hdr.writeUInt32LE(fileSize - 8, offset); offset += 4; // ChunkSize = fileSize - 8
|
||||
hdr.write('WAVE', offset); offset += 4;
|
||||
|
||||
// fmt sub-chunk.
|
||||
hdr.write('fmt ', offset); offset += 4;
|
||||
hdr.writeUInt32LE(16, offset); offset += 4; // Subchunk1Size (PCM = 16)
|
||||
hdr.writeUInt16LE(1, offset); offset += 2; // AudioFormat (1 = PCM)
|
||||
hdr.writeUInt16LE(this.channels, offset); offset += 2;
|
||||
hdr.writeUInt32LE(this.sampleRate, offset); offset += 4;
|
||||
hdr.writeUInt32LE(byteRate, offset); offset += 4;
|
||||
hdr.writeUInt16LE(blockAlign, offset); offset += 2;
|
||||
hdr.writeUInt16LE(this.bitsPerSample, offset); offset += 2;
|
||||
|
||||
// data sub-chunk.
|
||||
hdr.write('data', offset); offset += 4;
|
||||
hdr.writeUInt32LE(this.totalDataBytes, offset); offset += 4;
|
||||
|
||||
// Patch the header at the beginning of the file.
|
||||
fs.writeSync(this.fd, hdr, 0, HEADER_SIZE, 0);
|
||||
fs.closeSync(this.fd);
|
||||
this.fd = null;
|
||||
|
||||
const bytesPerSample = this.bitsPerSample / 8;
|
||||
const totalSamples = Math.floor(this.totalDataBytes / (bytesPerSample * this.channels));
|
||||
const durationMs = (totalSamples / this.sampleRate) * 1000;
|
||||
|
||||
return {
|
||||
filePath: this.filePath,
|
||||
durationMs: Math.round(durationMs),
|
||||
sampleRate: this.sampleRate,
|
||||
totalSamples,
|
||||
fileSize,
|
||||
};
|
||||
}
|
||||
|
||||
/** Current recording duration in milliseconds. */
|
||||
get durationMs(): number {
|
||||
const bytesPerSample = this.bitsPerSample / 8;
|
||||
const totalSamples = Math.floor(this.totalDataBytes / (bytesPerSample * this.channels));
|
||||
return (totalSamples / this.sampleRate) * 1000;
|
||||
}
|
||||
|
||||
/** Whether the writer is still open and accepting data. */
|
||||
get isOpen(): boolean {
|
||||
return this.fd !== null && !this.closed;
|
||||
}
|
||||
}
|
||||
124
ts/config.ts
124
ts/config.ts
@@ -78,6 +78,17 @@ export interface ISipRouteAction {
|
||||
/** Also ring connected browser clients. Default false. */
|
||||
ringBrowsers?: boolean;
|
||||
|
||||
// --- Inbound actions (IVR / voicemail) ---
|
||||
|
||||
/** Route directly to a voicemail box (skip ringing devices). */
|
||||
voicemailBox?: string;
|
||||
|
||||
/** Route to an IVR menu by menu ID (skip ringing devices). */
|
||||
ivrMenuId?: string;
|
||||
|
||||
/** Override no-answer timeout (seconds) before routing to voicemail. */
|
||||
noAnswerTimeout?: number;
|
||||
|
||||
// --- Outbound actions (provider selection) ---
|
||||
|
||||
/** Provider ID to use for outbound. */
|
||||
@@ -137,12 +148,95 @@ export interface IContact {
|
||||
starred?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voicebox configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IVoiceboxConfig {
|
||||
/** Unique ID — typically matches device ID or extension. */
|
||||
id: string;
|
||||
/** Whether this voicebox is active. */
|
||||
enabled: boolean;
|
||||
/** Custom TTS greeting text. */
|
||||
greetingText?: string;
|
||||
/** TTS voice ID (default 'af_bella'). */
|
||||
greetingVoice?: string;
|
||||
/** Path to uploaded WAV greeting (overrides TTS). */
|
||||
greetingWavPath?: string;
|
||||
/** Seconds to wait before routing to voicemail (default 25). */
|
||||
noAnswerTimeoutSec?: number;
|
||||
/** Maximum recording duration in seconds (default 120). */
|
||||
maxRecordingSec?: number;
|
||||
/** Maximum stored messages per box (default 50). */
|
||||
maxMessages?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IVR configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** An action triggered by a digit press in an IVR menu. */
|
||||
export type TIvrAction =
|
||||
| { type: 'route-extension'; extensionId: string }
|
||||
| { type: 'route-voicemail'; boxId: string }
|
||||
| { type: 'submenu'; menuId: string }
|
||||
| { type: 'play-message'; promptId: string }
|
||||
| { type: 'transfer'; number: string; providerId?: string }
|
||||
| { type: 'repeat' }
|
||||
| { type: 'hangup' };
|
||||
|
||||
/** A single digit→action mapping in an IVR menu. */
|
||||
export interface IIvrMenuEntry {
|
||||
/** Digit: '0'-'9', '*', '#'. */
|
||||
digit: string;
|
||||
/** Action to take when this digit is pressed. */
|
||||
action: TIvrAction;
|
||||
}
|
||||
|
||||
/** An IVR menu with a prompt and digit mappings. */
|
||||
export interface IIvrMenu {
|
||||
/** Unique menu ID. */
|
||||
id: string;
|
||||
/** Human-readable name. */
|
||||
name: string;
|
||||
/** TTS text for the menu prompt. */
|
||||
promptText: string;
|
||||
/** TTS voice ID for the prompt. */
|
||||
promptVoice?: string;
|
||||
/** Digit→action entries. */
|
||||
entries: IIvrMenuEntry[];
|
||||
/** Seconds to wait for a digit after prompt finishes (default 5). */
|
||||
timeoutSec?: number;
|
||||
/** Maximum retries before executing timeout action (default 3). */
|
||||
maxRetries?: number;
|
||||
/** Action on timeout (no digit pressed). */
|
||||
timeoutAction: TIvrAction;
|
||||
/** Action on invalid digit. */
|
||||
invalidAction: TIvrAction;
|
||||
}
|
||||
|
||||
/** Top-level IVR configuration. */
|
||||
export interface IIvrConfig {
|
||||
/** Whether the IVR system is active. */
|
||||
enabled: boolean;
|
||||
/** IVR menu definitions. */
|
||||
menus: IIvrMenu[];
|
||||
/** The menu to start with for incoming calls. */
|
||||
entryMenuId: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IAppConfig {
|
||||
proxy: IProxyConfig;
|
||||
providers: IProviderConfig[];
|
||||
devices: IDeviceConfig[];
|
||||
routing: IRoutingConfig;
|
||||
contacts: IContact[];
|
||||
voiceboxes?: IVoiceboxConfig[];
|
||||
ivr?: IIvrConfig;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -201,6 +295,27 @@ export function loadConfig(): IAppConfig {
|
||||
c.starred ??= false;
|
||||
}
|
||||
|
||||
// Voicebox defaults.
|
||||
cfg.voiceboxes ??= [];
|
||||
for (const vb of cfg.voiceboxes) {
|
||||
vb.enabled ??= true;
|
||||
vb.noAnswerTimeoutSec ??= 25;
|
||||
vb.maxRecordingSec ??= 120;
|
||||
vb.maxMessages ??= 50;
|
||||
vb.greetingVoice ??= 'af_bella';
|
||||
}
|
||||
|
||||
// IVR defaults.
|
||||
if (cfg.ivr) {
|
||||
cfg.ivr.enabled ??= false;
|
||||
cfg.ivr.menus ??= [];
|
||||
for (const menu of cfg.ivr.menus) {
|
||||
menu.timeoutSec ??= 5;
|
||||
menu.maxRetries ??= 3;
|
||||
menu.entries ??= [];
|
||||
}
|
||||
}
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -251,6 +366,12 @@ export interface IInboundRouteResult {
|
||||
/** Device IDs to ring (empty = all devices). */
|
||||
deviceIds: string[];
|
||||
ringBrowsers: boolean;
|
||||
/** If set, route directly to this voicemail box (skip ringing). */
|
||||
voicemailBox?: string;
|
||||
/** If set, route to this IVR menu (skip ringing). */
|
||||
ivrMenuId?: string;
|
||||
/** Override for no-answer timeout in seconds. */
|
||||
noAnswerTimeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -332,6 +453,9 @@ export function resolveInboundRoute(
|
||||
return {
|
||||
deviceIds: route.action.targets || [],
|
||||
ringBrowsers: route.action.ringBrowsers ?? false,
|
||||
voicemailBox: route.action.voicemailBox,
|
||||
ivrMenuId: route.action.ivrMenuId,
|
||||
noAnswerTimeout: route.action.noAnswerTimeout,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import https from 'node:https';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import type { CallManager } from './call/index.ts';
|
||||
import { handleWebRtcSignaling } from './webrtcbridge.ts';
|
||||
import type { VoiceboxManager } from './voicebox.ts';
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
|
||||
|
||||
@@ -84,6 +85,7 @@ async function handleRequest(
|
||||
onHangupCall: (callId: string) => boolean,
|
||||
onConfigSaved?: () => void,
|
||||
callManager?: CallManager,
|
||||
voiceboxManager?: VoiceboxManager,
|
||||
): Promise<void> {
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||
const method = req.method || 'GET';
|
||||
@@ -242,6 +244,8 @@ async function handleRequest(
|
||||
}
|
||||
}
|
||||
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
|
||||
if (updates.voiceboxes !== undefined) cfg.voiceboxes = updates.voiceboxes;
|
||||
if (updates.ivr !== undefined) cfg.ivr = updates.ivr;
|
||||
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
||||
log('[config] updated config.json');
|
||||
@@ -252,6 +256,50 @@ async function handleRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// API: voicemail - list messages.
|
||||
const vmListMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)$/);
|
||||
if (vmListMatch && method === 'GET' && voiceboxManager) {
|
||||
const boxId = vmListMatch[1];
|
||||
return sendJson(res, { ok: true, messages: voiceboxManager.getMessages(boxId) });
|
||||
}
|
||||
|
||||
// API: voicemail - unheard count.
|
||||
const vmUnheardMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/unheard$/);
|
||||
if (vmUnheardMatch && method === 'GET' && voiceboxManager) {
|
||||
const boxId = vmUnheardMatch[1];
|
||||
return sendJson(res, { ok: true, count: voiceboxManager.getUnheardCount(boxId) });
|
||||
}
|
||||
|
||||
// API: voicemail - stream audio.
|
||||
const vmAudioMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/audio$/);
|
||||
if (vmAudioMatch && method === 'GET' && voiceboxManager) {
|
||||
const [, boxId, msgId] = vmAudioMatch;
|
||||
const audioPath = voiceboxManager.getMessageAudioPath(boxId, msgId);
|
||||
if (!audioPath) return sendJson(res, { ok: false, error: 'not found' }, 404);
|
||||
const stat = fs.statSync(audioPath);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'audio/wav',
|
||||
'Content-Length': stat.size.toString(),
|
||||
'Accept-Ranges': 'bytes',
|
||||
});
|
||||
fs.createReadStream(audioPath).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
// API: voicemail - mark as heard.
|
||||
const vmHeardMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/heard$/);
|
||||
if (vmHeardMatch && method === 'POST' && voiceboxManager) {
|
||||
const [, boxId, msgId] = vmHeardMatch;
|
||||
return sendJson(res, { ok: voiceboxManager.markHeard(boxId, msgId) });
|
||||
}
|
||||
|
||||
// API: voicemail - delete message.
|
||||
const vmDeleteMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)$/);
|
||||
if (vmDeleteMatch && method === 'DELETE' && voiceboxManager) {
|
||||
const [, boxId, msgId] = vmDeleteMatch;
|
||||
return sendJson(res, { ok: voiceboxManager.deleteMessage(boxId, msgId) });
|
||||
}
|
||||
|
||||
// Static files.
|
||||
const file = staticFiles.get(url.pathname);
|
||||
if (file) {
|
||||
@@ -288,6 +336,7 @@ export function initWebUi(
|
||||
onHangupCall: (callId: string) => boolean,
|
||||
onConfigSaved?: () => void,
|
||||
callManager?: CallManager,
|
||||
voiceboxManager?: VoiceboxManager,
|
||||
): void {
|
||||
const WEB_PORT = 3060;
|
||||
|
||||
@@ -303,12 +352,12 @@ export function initWebUi(
|
||||
const cert = fs.readFileSync(certPath, 'utf8');
|
||||
const key = fs.readFileSync(keyPath, 'utf8');
|
||||
server = https.createServer({ cert, key }, (req, res) =>
|
||||
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager).catch(() => { res.writeHead(500); res.end(); }),
|
||||
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager, voiceboxManager).catch(() => { res.writeHead(500); res.end(); }),
|
||||
);
|
||||
useTls = true;
|
||||
} catch {
|
||||
server = http.createServer((req, res) =>
|
||||
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager).catch(() => { res.writeHead(500); res.end(); }),
|
||||
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager, voiceboxManager).catch(() => { res.writeHead(500); res.end(); }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,3 +188,54 @@ export function parseSdpEndpoint(sdp: string): { address: string; port: number }
|
||||
}
|
||||
return addr && port ? { address: addr, port } : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MWI (Message Waiting Indicator) — RFC 3842
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a SIP NOTIFY request for Message Waiting Indicator.
|
||||
*
|
||||
* Sent out-of-dialog to notify a device about voicemail message counts.
|
||||
* Uses the message-summary event package per RFC 3842.
|
||||
*/
|
||||
export interface IMwiOptions {
|
||||
/** Proxy LAN IP and port (Via / From / Contact). */
|
||||
proxyHost: string;
|
||||
proxyPort: number;
|
||||
/** Target device URI (e.g. "sip:user@192.168.5.100:5060"). */
|
||||
targetUri: string;
|
||||
/** Account URI for the voicebox (used in the From header). */
|
||||
accountUri: string;
|
||||
/** Number of new (unheard) voice messages. */
|
||||
newMessages: number;
|
||||
/** Number of old (heard) voice messages. */
|
||||
oldMessages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the body and headers for an MWI NOTIFY (RFC 3842 message-summary).
|
||||
*
|
||||
* Returns the body string and extra headers needed. The caller builds
|
||||
* the SipMessage via SipMessage.createRequest('NOTIFY', ...).
|
||||
*/
|
||||
export function buildMwiBody(newMessages: number, oldMessages: number, accountUri: string): {
|
||||
body: string;
|
||||
contentType: string;
|
||||
extraHeaders: [string, string][];
|
||||
} {
|
||||
const hasNew = newMessages > 0;
|
||||
const body =
|
||||
`Messages-Waiting: ${hasNew ? 'yes' : 'no'}\r\n` +
|
||||
`Message-Account: ${accountUri}\r\n` +
|
||||
`Voice-Message: ${newMessages}/${oldMessages}\r\n`;
|
||||
|
||||
return {
|
||||
body,
|
||||
contentType: 'application/simple-message-summary',
|
||||
extraHeaders: [
|
||||
['Event', 'message-summary'],
|
||||
['Subscription-State', 'terminated;reason=noresource'],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
parseSdpEndpoint,
|
||||
parseDigestChallenge,
|
||||
computeDigestAuth,
|
||||
buildMwiBody,
|
||||
} from './helpers.ts';
|
||||
export type { ISdpOptions, IDigestChallenge } from './helpers.ts';
|
||||
export type { ISdpOptions, IDigestChallenge, IMwiOptions } from './helpers.ts';
|
||||
export type { IEndpoint } from './types.ts';
|
||||
|
||||
@@ -44,6 +44,8 @@ import {
|
||||
import { initCodecBridge } from './opusbridge.ts';
|
||||
import { initAnnouncement } from './announcement.ts';
|
||||
import { CallManager } from './call/index.ts';
|
||||
import { PromptCache } from './call/prompt-cache.ts';
|
||||
import { VoiceboxManager } from './voicebox.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
@@ -92,6 +94,11 @@ const providerStates = initProviderStates(appConfig.providers, proxy.publicIpSee
|
||||
|
||||
initRegistrar(appConfig.devices, log);
|
||||
|
||||
// Initialize voicemail and IVR subsystems.
|
||||
const promptCache = new PromptCache(log);
|
||||
const voiceboxManager = new VoiceboxManager(log);
|
||||
voiceboxManager.init(appConfig.voiceboxes ?? []);
|
||||
|
||||
const callManager = new CallManager({
|
||||
appConfig,
|
||||
sendSip: (buf, dest) => sock.send(buf, dest.port, dest.address),
|
||||
@@ -101,6 +108,8 @@ const callManager = new CallManager({
|
||||
getAllBrowserDeviceIds,
|
||||
sendToBrowserDevice,
|
||||
getBrowserDeviceWs,
|
||||
promptCache,
|
||||
voiceboxManager,
|
||||
});
|
||||
|
||||
// Initialize WebRTC signaling (browser device registration only).
|
||||
@@ -130,6 +139,7 @@ function getStatus() {
|
||||
calls: callManager.getStatus(),
|
||||
callHistory: callManager.getHistory(),
|
||||
contacts: appConfig.contacts || [],
|
||||
voicemailCounts: voiceboxManager.getAllUnheardCounts(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,6 +262,33 @@ sock.bind(LAN_PORT, '0.0.0.0', () => {
|
||||
// Initialize audio codec bridge (Rust binary via smartrust).
|
||||
initCodecBridge(log)
|
||||
.then(() => initAnnouncement(log))
|
||||
.then(async () => {
|
||||
// Pre-generate voicemail beep tone.
|
||||
await promptCache.generateBeep('voicemail-beep', 1000, 500, 8000);
|
||||
|
||||
// Pre-generate voicemail greetings for all configured voiceboxes.
|
||||
for (const vb of appConfig.voiceboxes ?? []) {
|
||||
if (!vb.enabled) continue;
|
||||
const promptId = `voicemail-greeting-${vb.id}`;
|
||||
const wavPath = vb.greetingWavPath;
|
||||
if (wavPath) {
|
||||
await promptCache.loadWavPrompt(promptId, wavPath);
|
||||
} else {
|
||||
const text = vb.greetingText || 'The person you are trying to reach is not available. Please leave a message after the tone.';
|
||||
await promptCache.generatePrompt(promptId, text, vb.greetingVoice || 'af_bella');
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-generate IVR menu prompts.
|
||||
if (appConfig.ivr?.enabled) {
|
||||
for (const menu of appConfig.ivr.menus) {
|
||||
const promptId = `ivr-menu-${menu.id}`;
|
||||
await promptCache.generatePrompt(promptId, menu.promptText, menu.promptVoice || 'af_bella');
|
||||
}
|
||||
}
|
||||
|
||||
log(`[startup] prompts cached: ${promptCache.listIds().join(', ') || 'none'}`);
|
||||
})
|
||||
.catch((e) => log(`[codec] init failed: ${e}`));
|
||||
});
|
||||
|
||||
@@ -288,6 +325,7 @@ initWebUi(
|
||||
}
|
||||
},
|
||||
callManager,
|
||||
voiceboxManager,
|
||||
);
|
||||
|
||||
process.on('SIGINT', () => { log('SIGINT, exiting'); process.exit(0); });
|
||||
|
||||
314
ts/voicebox.ts
Normal file
314
ts/voicebox.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* VoiceboxManager — manages voicemail boxes, message storage, and MWI.
|
||||
*
|
||||
* Each voicebox corresponds to a device/extension. Messages are stored
|
||||
* as WAV files with JSON metadata in .nogit/voicemail/{boxId}/.
|
||||
*
|
||||
* Supports:
|
||||
* - Per-box configurable TTS greetings (text + voice) or uploaded WAV
|
||||
* - Message CRUD: save, list, mark heard, delete
|
||||
* - Unheard count for MWI (Message Waiting Indicator)
|
||||
* - Storage limit (max messages per box)
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IVoiceboxConfig {
|
||||
/** Unique ID — typically matches device ID or extension. */
|
||||
id: string;
|
||||
/** Whether this voicebox is active. */
|
||||
enabled: boolean;
|
||||
/** Custom TTS greeting text (overrides default). */
|
||||
greetingText?: string;
|
||||
/** Kokoro TTS voice ID for the greeting (default 'af_bella'). */
|
||||
greetingVoice?: string;
|
||||
/** Path to uploaded WAV greeting (overrides TTS). */
|
||||
greetingWavPath?: string;
|
||||
/** Seconds to wait before routing to voicemail (default 25). */
|
||||
noAnswerTimeoutSec: number;
|
||||
/** Maximum recording duration in seconds (default 120). */
|
||||
maxRecordingSec: number;
|
||||
/** Maximum stored messages per box (default 50). */
|
||||
maxMessages: number;
|
||||
}
|
||||
|
||||
export interface IVoicemailMessage {
|
||||
/** Unique message ID. */
|
||||
id: string;
|
||||
/** Which voicebox this message belongs to. */
|
||||
boxId: string;
|
||||
/** Caller's phone number. */
|
||||
callerNumber: string;
|
||||
/** Caller's display name (if available from SIP From header). */
|
||||
callerName?: string;
|
||||
/** Unix timestamp (ms) when the message was recorded. */
|
||||
timestamp: number;
|
||||
/** Duration in milliseconds. */
|
||||
durationMs: number;
|
||||
/** Relative path to the WAV file (within the box directory). */
|
||||
fileName: string;
|
||||
/** Whether the message has been listened to. */
|
||||
heard: boolean;
|
||||
}
|
||||
|
||||
// Default greeting text when no custom text is configured.
|
||||
const DEFAULT_GREETING = 'The person you are trying to reach is not available. Please leave a message after the tone.';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VoiceboxManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class VoiceboxManager {
|
||||
private boxes = new Map<string, IVoiceboxConfig>();
|
||||
private basePath: string;
|
||||
private log: (msg: string) => void;
|
||||
|
||||
constructor(log: (msg: string) => void) {
|
||||
this.basePath = path.join(process.cwd(), '.nogit', 'voicemail');
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load voicebox configurations from the app config.
|
||||
*/
|
||||
init(voiceboxConfigs: IVoiceboxConfig[]): void {
|
||||
this.boxes.clear();
|
||||
|
||||
for (const cfg of voiceboxConfigs) {
|
||||
// Apply defaults.
|
||||
cfg.noAnswerTimeoutSec ??= 25;
|
||||
cfg.maxRecordingSec ??= 120;
|
||||
cfg.maxMessages ??= 50;
|
||||
cfg.greetingVoice ??= 'af_bella';
|
||||
|
||||
this.boxes.set(cfg.id, cfg);
|
||||
}
|
||||
|
||||
// Ensure base directory exists.
|
||||
fs.mkdirSync(this.basePath, { recursive: true });
|
||||
|
||||
this.log(`[voicebox] initialized ${this.boxes.size} voicebox(es)`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Box management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Get config for a specific voicebox. */
|
||||
getBox(boxId: string): IVoiceboxConfig | null {
|
||||
return this.boxes.get(boxId) ?? null;
|
||||
}
|
||||
|
||||
/** Get all configured voicebox IDs. */
|
||||
getBoxIds(): string[] {
|
||||
return [...this.boxes.keys()];
|
||||
}
|
||||
|
||||
/** Get the greeting text for a voicebox. */
|
||||
getGreetingText(boxId: string): string {
|
||||
const box = this.boxes.get(boxId);
|
||||
return box?.greetingText || DEFAULT_GREETING;
|
||||
}
|
||||
|
||||
/** Get the greeting voice for a voicebox. */
|
||||
getGreetingVoice(boxId: string): string {
|
||||
const box = this.boxes.get(boxId);
|
||||
return box?.greetingVoice || 'af_bella';
|
||||
}
|
||||
|
||||
/** Check if a voicebox has a custom WAV greeting. */
|
||||
hasCustomGreetingWav(boxId: string): boolean {
|
||||
const box = this.boxes.get(boxId);
|
||||
if (!box?.greetingWavPath) return false;
|
||||
return fs.existsSync(box.greetingWavPath);
|
||||
}
|
||||
|
||||
/** Get the greeting WAV path (custom or null). */
|
||||
getCustomGreetingWavPath(boxId: string): string | null {
|
||||
const box = this.boxes.get(boxId);
|
||||
if (!box?.greetingWavPath) return null;
|
||||
return fs.existsSync(box.greetingWavPath) ? box.greetingWavPath : null;
|
||||
}
|
||||
|
||||
/** Get the directory path for a voicebox. */
|
||||
getBoxDir(boxId: string): string {
|
||||
return path.join(this.basePath, boxId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Message CRUD
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Save a new voicemail message.
|
||||
* The WAV file should already exist at the expected path.
|
||||
*/
|
||||
saveMessage(msg: IVoicemailMessage): void {
|
||||
const boxDir = this.getBoxDir(msg.boxId);
|
||||
fs.mkdirSync(boxDir, { recursive: true });
|
||||
|
||||
const messages = this.loadMessages(msg.boxId);
|
||||
messages.unshift(msg); // newest first
|
||||
|
||||
// Enforce max messages — delete oldest.
|
||||
const box = this.boxes.get(msg.boxId);
|
||||
const maxMessages = box?.maxMessages ?? 50;
|
||||
while (messages.length > maxMessages) {
|
||||
const old = messages.pop()!;
|
||||
const oldPath = path.join(boxDir, old.fileName);
|
||||
try {
|
||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
this.writeMessages(msg.boxId, messages);
|
||||
this.log(`[voicebox] saved message ${msg.id} in box "${msg.boxId}" (${msg.durationMs}ms from ${msg.callerNumber})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List messages for a voicebox (newest first).
|
||||
*/
|
||||
getMessages(boxId: string): IVoicemailMessage[] {
|
||||
return this.loadMessages(boxId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single message by ID.
|
||||
*/
|
||||
getMessage(boxId: string, messageId: string): IVoicemailMessage | null {
|
||||
const messages = this.loadMessages(boxId);
|
||||
return messages.find((m) => m.id === messageId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a message as heard.
|
||||
*/
|
||||
markHeard(boxId: string, messageId: string): boolean {
|
||||
const messages = this.loadMessages(boxId);
|
||||
const msg = messages.find((m) => m.id === messageId);
|
||||
if (!msg) return false;
|
||||
|
||||
msg.heard = true;
|
||||
this.writeMessages(boxId, messages);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message (both metadata and WAV file).
|
||||
*/
|
||||
deleteMessage(boxId: string, messageId: string): boolean {
|
||||
const messages = this.loadMessages(boxId);
|
||||
const idx = messages.findIndex((m) => m.id === messageId);
|
||||
if (idx === -1) return false;
|
||||
|
||||
const msg = messages[idx];
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
const wavPath = path.join(boxDir, msg.fileName);
|
||||
|
||||
// Delete WAV file.
|
||||
try {
|
||||
if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath);
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// Remove from list and save.
|
||||
messages.splice(idx, 1);
|
||||
this.writeMessages(boxId, messages);
|
||||
this.log(`[voicebox] deleted message ${messageId} from box "${boxId}"`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full file path for a message's WAV file.
|
||||
*/
|
||||
getMessageAudioPath(boxId: string, messageId: string): string | null {
|
||||
const msg = this.getMessage(boxId, messageId);
|
||||
if (!msg) return null;
|
||||
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
|
||||
return fs.existsSync(filePath) ? filePath : null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Counts
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Get count of unheard messages for a voicebox. */
|
||||
getUnheardCount(boxId: string): number {
|
||||
const messages = this.loadMessages(boxId);
|
||||
return messages.filter((m) => !m.heard).length;
|
||||
}
|
||||
|
||||
/** Get total message count for a voicebox. */
|
||||
getTotalCount(boxId: string): number {
|
||||
return this.loadMessages(boxId).length;
|
||||
}
|
||||
|
||||
/** Get unheard counts for all voiceboxes. */
|
||||
getAllUnheardCounts(): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const boxId of this.boxes.keys()) {
|
||||
counts[boxId] = this.getUnheardCount(boxId);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Greeting management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Save a custom greeting WAV file for a voicebox.
|
||||
*/
|
||||
saveCustomGreeting(boxId: string, wavData: Buffer): string {
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
fs.mkdirSync(boxDir, { recursive: true });
|
||||
const greetingPath = path.join(boxDir, 'greeting.wav');
|
||||
fs.writeFileSync(greetingPath, wavData);
|
||||
this.log(`[voicebox] saved custom greeting for box "${boxId}"`);
|
||||
return greetingPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the custom greeting for a voicebox (falls back to TTS).
|
||||
*/
|
||||
deleteCustomGreeting(boxId: string): void {
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
const greetingPath = path.join(boxDir, 'greeting.wav');
|
||||
try {
|
||||
if (fs.existsSync(greetingPath)) fs.unlinkSync(greetingPath);
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal: JSON persistence
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private messagesPath(boxId: string): string {
|
||||
return path.join(this.getBoxDir(boxId), 'messages.json');
|
||||
}
|
||||
|
||||
private loadMessages(boxId: string): IVoicemailMessage[] {
|
||||
const filePath = this.messagesPath(boxId);
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(raw) as IVoicemailMessage[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private writeMessages(boxId: string, messages: IVoicemailMessage[]): void {
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
fs.mkdirSync(boxDir, { recursive: true });
|
||||
fs.writeFileSync(this.messagesPath(boxId), JSON.stringify(messages, null, 2), 'utf8');
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.9.0',
|
||||
version: '1.10.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ export * from './sipproxy-view-calls.js';
|
||||
export * from './sipproxy-view-phone.js';
|
||||
export * from './sipproxy-view-contacts.js';
|
||||
export * from './sipproxy-view-providers.js';
|
||||
export * from './sipproxy-view-voicemail.js';
|
||||
export * from './sipproxy-view-log.js';
|
||||
export * from './sipproxy-view-routes.js';
|
||||
export * from './sipproxy-view-ivr.js';
|
||||
|
||||
// Sub-components (used within views)
|
||||
export * from './sipproxy-devices.js';
|
||||
|
||||
@@ -9,12 +9,16 @@ import { SipproxyViewContacts } from './sipproxy-view-contacts.js';
|
||||
import { SipproxyViewProviders } from './sipproxy-view-providers.js';
|
||||
import { SipproxyViewLog } from './sipproxy-view-log.js';
|
||||
import { SipproxyViewRoutes } from './sipproxy-view-routes.js';
|
||||
import { SipproxyViewVoicemail } from './sipproxy-view-voicemail.js';
|
||||
import { SipproxyViewIvr } from './sipproxy-view-ivr.js';
|
||||
|
||||
const VIEW_TABS = [
|
||||
{ name: 'Overview', iconName: 'lucide:layoutDashboard', element: SipproxyViewOverview },
|
||||
{ name: 'Calls', iconName: 'lucide:phone', element: SipproxyViewCalls },
|
||||
{ name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone },
|
||||
{ name: 'Routes', iconName: 'lucide:route', element: SipproxyViewRoutes },
|
||||
{ name: 'Voicemail', iconName: 'lucide:voicemail', element: SipproxyViewVoicemail },
|
||||
{ name: 'IVR', iconName: 'lucide:list-tree', element: SipproxyViewIvr },
|
||||
{ name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts },
|
||||
{ name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders },
|
||||
{ name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog },
|
||||
|
||||
657
ts_web/elements/sipproxy-view-ivr.ts
Normal file
657
ts_web/elements/sipproxy-view-ivr.ts
Normal file
@@ -0,0 +1,657 @@
|
||||
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||
import { deesCatalog } from '../plugins.js';
|
||||
import { appState, type IAppState } from '../state/appstate.js';
|
||||
import { viewHostCss } from './shared/index.js';
|
||||
import type { IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
const { DeesModal, DeesToast } = deesCatalog;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IVR types (mirrors ts/config.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TIvrAction =
|
||||
| { type: 'route-extension'; extensionId: string }
|
||||
| { type: 'route-voicemail'; boxId: string }
|
||||
| { type: 'submenu'; menuId: string }
|
||||
| { type: 'play-message'; promptId: string }
|
||||
| { type: 'transfer'; number: string; providerId?: string }
|
||||
| { type: 'repeat' }
|
||||
| { type: 'hangup' };
|
||||
|
||||
interface IIvrMenuEntry {
|
||||
digit: string;
|
||||
action: TIvrAction;
|
||||
}
|
||||
|
||||
interface IIvrMenu {
|
||||
id: string;
|
||||
name: string;
|
||||
promptText: string;
|
||||
promptVoice?: string;
|
||||
entries: IIvrMenuEntry[];
|
||||
timeoutSec?: number;
|
||||
maxRetries?: number;
|
||||
timeoutAction: TIvrAction;
|
||||
invalidAction: TIvrAction;
|
||||
}
|
||||
|
||||
interface IIvrConfig {
|
||||
enabled: boolean;
|
||||
menus: IIvrMenu[];
|
||||
entryMenuId: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '') || `menu-${Date.now()}`;
|
||||
}
|
||||
|
||||
const VOICE_OPTIONS = [
|
||||
{ option: 'af_bella (Female)', key: 'af_bella' },
|
||||
{ option: 'af_sarah (Female)', key: 'af_sarah' },
|
||||
{ option: 'am_adam (Male)', key: 'am_adam' },
|
||||
{ option: 'bf_alice (Female)', key: 'bf_alice' },
|
||||
];
|
||||
|
||||
const ACTION_TYPE_OPTIONS = [
|
||||
{ option: 'Route to Extension', key: 'route-extension' },
|
||||
{ option: 'Route to Voicemail', key: 'route-voicemail' },
|
||||
{ option: 'Submenu', key: 'submenu' },
|
||||
{ option: 'Play Message', key: 'play-message' },
|
||||
{ option: 'Transfer', key: 'transfer' },
|
||||
{ option: 'Repeat', key: 'repeat' },
|
||||
{ option: 'Hangup', key: 'hangup' },
|
||||
];
|
||||
|
||||
const DIGIT_OPTIONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '*', '#'];
|
||||
|
||||
function describeAction(action: TIvrAction): string {
|
||||
switch (action.type) {
|
||||
case 'route-extension': return `Extension: ${action.extensionId}`;
|
||||
case 'route-voicemail': return `Voicemail: ${action.boxId}`;
|
||||
case 'submenu': return `Submenu: ${action.menuId}`;
|
||||
case 'play-message': return `Play: ${action.promptId}`;
|
||||
case 'transfer': return `Transfer: ${action.number}${action.providerId ? ` (${action.providerId})` : ''}`;
|
||||
case 'repeat': return 'Repeat';
|
||||
case 'hangup': return 'Hangup';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function makeDefaultAction(): TIvrAction {
|
||||
return { type: 'hangup' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View element
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@customElement('sipproxy-view-ivr')
|
||||
export class SipproxyViewIvr extends DeesElement {
|
||||
@state() accessor appData: IAppState = appState.getState();
|
||||
@state() accessor config: any = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.view-section { margin-bottom: 24px; }
|
||||
`,
|
||||
];
|
||||
|
||||
// ---- lifecycle -----------------------------------------------------------
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.rxSubscriptions.push({
|
||||
unsubscribe: appState.subscribe((s) => { this.appData = s; }),
|
||||
} as any);
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
private async loadConfig() {
|
||||
try {
|
||||
this.config = await appState.apiGetConfig();
|
||||
} catch {
|
||||
// Will show empty state.
|
||||
}
|
||||
}
|
||||
|
||||
private getIvrConfig(): IIvrConfig {
|
||||
return this.config?.ivr || { enabled: false, menus: [], entryMenuId: '' };
|
||||
}
|
||||
|
||||
// ---- stats tiles ---------------------------------------------------------
|
||||
|
||||
private getStatsTiles(): IStatsTile[] {
|
||||
const ivr = this.getIvrConfig();
|
||||
const entryMenu = ivr.menus.find((m) => m.id === ivr.entryMenuId);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'total-menus',
|
||||
title: 'Total Menus',
|
||||
value: ivr.menus.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:list-tree',
|
||||
description: 'IVR menu definitions',
|
||||
},
|
||||
{
|
||||
id: 'entry-menu',
|
||||
title: 'Entry Menu',
|
||||
value: entryMenu?.name || '(none)',
|
||||
type: 'text' as any,
|
||||
icon: 'lucide:door-open',
|
||||
description: entryMenu ? `ID: ${entryMenu.id}` : 'No entry menu set',
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
title: 'Status',
|
||||
value: ivr.enabled ? 'Enabled' : 'Disabled',
|
||||
type: 'text' as any,
|
||||
icon: ivr.enabled ? 'lucide:check-circle' : 'lucide:x-circle',
|
||||
color: ivr.enabled ? 'hsl(142.1 76.2% 36.3%)' : 'hsl(0 84.2% 60.2%)',
|
||||
description: ivr.enabled ? 'IVR is active' : 'IVR is inactive',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- table columns -------------------------------------------------------
|
||||
|
||||
private getColumns() {
|
||||
const ivr = this.getIvrConfig();
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
sortable: true,
|
||||
renderer: (val: string, row: IIvrMenu) => {
|
||||
const isEntry = row.id === ivr.entryMenuId;
|
||||
return html`
|
||||
<span>${val}</span>
|
||||
${isEntry ? html`<span style="display:inline-block;margin-left:8px;padding:1px 6px;border-radius:4px;font-size:.65rem;font-weight:600;text-transform:uppercase;background:#1e3a5f;color:#60a5fa">entry</span>` : ''}
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'promptText',
|
||||
header: 'Prompt',
|
||||
renderer: (val: string) => {
|
||||
const truncated = val && val.length > 60 ? val.slice(0, 60) + '...' : val || '--';
|
||||
return html`<span style="font-size:.82rem;color:#94a3b8">${truncated}</span>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'entries',
|
||||
header: 'Digits',
|
||||
renderer: (_val: any, row: IIvrMenu) => {
|
||||
const digits = (row.entries || []).map((e) => e.digit).join(', ');
|
||||
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${digits || '(none)'}</span>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'timeoutAction',
|
||||
header: 'Timeout Action',
|
||||
renderer: (_val: any, row: IIvrMenu) => {
|
||||
return html`<span style="font-size:.82rem;color:#94a3b8">${describeAction(row.timeoutAction)}</span>`;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- table actions -------------------------------------------------------
|
||||
|
||||
private getDataActions() {
|
||||
return [
|
||||
{
|
||||
name: 'Add Menu',
|
||||
iconName: 'lucide:plus' as any,
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.openMenuEditor(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:pencil' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
||||
await this.openMenuEditor(item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Set as Entry',
|
||||
iconName: 'lucide:door-open' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
||||
await this.setEntryMenu(item.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
||||
await this.confirmDeleteMenu(item);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- toggle enabled ------------------------------------------------------
|
||||
|
||||
private async toggleEnabled() {
|
||||
const ivr = this.getIvrConfig();
|
||||
const updated: IIvrConfig = { ...ivr, enabled: !ivr.enabled };
|
||||
const result = await appState.apiSaveConfig({ ivr: updated });
|
||||
if (result.ok) {
|
||||
DeesToast.success(updated.enabled ? 'IVR enabled' : 'IVR disabled');
|
||||
await this.loadConfig();
|
||||
} else {
|
||||
DeesToast.error('Failed to update IVR status');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- set entry menu ------------------------------------------------------
|
||||
|
||||
private async setEntryMenu(menuId: string) {
|
||||
const ivr = this.getIvrConfig();
|
||||
const updated: IIvrConfig = { ...ivr, entryMenuId: menuId };
|
||||
const result = await appState.apiSaveConfig({ ivr: updated });
|
||||
if (result.ok) {
|
||||
DeesToast.success('Entry menu updated');
|
||||
await this.loadConfig();
|
||||
} else {
|
||||
DeesToast.error('Failed to set entry menu');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- delete menu ---------------------------------------------------------
|
||||
|
||||
private async confirmDeleteMenu(menu: IIvrMenu) {
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Delete IVR Menu',
|
||||
width: 'small',
|
||||
showCloseButton: true,
|
||||
content: html`
|
||||
<div style="padding:8px 0;font-size:.9rem;color:#e2e8f0;">
|
||||
Are you sure you want to delete
|
||||
<strong style="color:#f87171;">${menu.name}</strong>?
|
||||
This action cannot be undone.
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => { modalRef.destroy(); },
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
action: async (modalRef: any) => {
|
||||
const ivr = this.getIvrConfig();
|
||||
const menus = ivr.menus.filter((m) => m.id !== menu.id);
|
||||
const updated: IIvrConfig = {
|
||||
...ivr,
|
||||
menus,
|
||||
entryMenuId: ivr.entryMenuId === menu.id ? '' : ivr.entryMenuId,
|
||||
};
|
||||
const result = await appState.apiSaveConfig({ ivr: updated });
|
||||
if (result.ok) {
|
||||
modalRef.destroy();
|
||||
DeesToast.success(`Menu "${menu.name}" deleted`);
|
||||
await this.loadConfig();
|
||||
} else {
|
||||
DeesToast.error('Failed to delete menu');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---- action editor helper ------------------------------------------------
|
||||
|
||||
private renderActionEditor(
|
||||
action: TIvrAction,
|
||||
onChange: (a: TIvrAction) => void,
|
||||
label: string,
|
||||
cfg: any,
|
||||
): TemplateResult {
|
||||
const devices = cfg?.devices || [];
|
||||
const menus: IIvrMenu[] = cfg?.ivr?.menus || [];
|
||||
const providers = cfg?.providers || [];
|
||||
|
||||
const currentType = ACTION_TYPE_OPTIONS.find((o) => o.key === action.type) || ACTION_TYPE_OPTIONS[ACTION_TYPE_OPTIONS.length - 1];
|
||||
|
||||
return html`
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px;font-weight:600;">${label}</div>
|
||||
<dees-input-dropdown
|
||||
.label=${'Action Type'}
|
||||
.selectedOption=${currentType}
|
||||
.options=${ACTION_TYPE_OPTIONS}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
const type = e.detail.key;
|
||||
switch (type) {
|
||||
case 'route-extension': onChange({ type, extensionId: devices[0]?.extension || '100' }); break;
|
||||
case 'route-voicemail': onChange({ type, boxId: '' }); break;
|
||||
case 'submenu': onChange({ type, menuId: menus[0]?.id || '' }); break;
|
||||
case 'play-message': onChange({ type, promptId: '' }); break;
|
||||
case 'transfer': onChange({ type, number: '' }); break;
|
||||
case 'repeat': onChange({ type }); break;
|
||||
case 'hangup': onChange({ type }); break;
|
||||
}
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
|
||||
${action.type === 'route-extension' ? html`
|
||||
<dees-input-dropdown
|
||||
.label=${'Extension'}
|
||||
.selectedOption=${{ option: action.extensionId, key: action.extensionId }}
|
||||
.options=${devices.map((d: any) => ({ option: `${d.displayName} (${d.extension})`, key: d.extension }))}
|
||||
@selectedOption=${(e: CustomEvent) => { onChange({ ...action, extensionId: e.detail.key }); }}
|
||||
></dees-input-dropdown>
|
||||
` : ''}
|
||||
|
||||
${action.type === 'route-voicemail' ? html`
|
||||
<dees-input-text
|
||||
.label=${'Voicemail Box ID'}
|
||||
.value=${action.boxId}
|
||||
@input=${(e: Event) => { onChange({ ...action, boxId: (e.target as any).value }); }}
|
||||
></dees-input-text>
|
||||
` : ''}
|
||||
|
||||
${action.type === 'submenu' ? html`
|
||||
<dees-input-dropdown
|
||||
.label=${'Menu'}
|
||||
.selectedOption=${menus.find((m) => m.id === action.menuId)
|
||||
? { option: menus.find((m) => m.id === action.menuId)!.name, key: action.menuId }
|
||||
: { option: '(select)', key: '' }}
|
||||
.options=${menus.map((m) => ({ option: m.name, key: m.id }))}
|
||||
@selectedOption=${(e: CustomEvent) => { onChange({ ...action, menuId: e.detail.key }); }}
|
||||
></dees-input-dropdown>
|
||||
` : ''}
|
||||
|
||||
${action.type === 'play-message' ? html`
|
||||
<dees-input-text
|
||||
.label=${'Prompt ID'}
|
||||
.value=${action.promptId}
|
||||
@input=${(e: Event) => { onChange({ ...action, promptId: (e.target as any).value }); }}
|
||||
></dees-input-text>
|
||||
` : ''}
|
||||
|
||||
${action.type === 'transfer' ? html`
|
||||
<dees-input-text
|
||||
.label=${'Transfer Number'}
|
||||
.value=${action.number}
|
||||
@input=${(e: Event) => { onChange({ ...action, number: (e.target as any).value }); }}
|
||||
></dees-input-text>
|
||||
<dees-input-dropdown
|
||||
.label=${'Provider (optional)'}
|
||||
.selectedOption=${action.providerId
|
||||
? { option: action.providerId, key: action.providerId }
|
||||
: { option: '(default)', key: '' }}
|
||||
.options=${[
|
||||
{ option: '(default)', key: '' },
|
||||
...providers.map((p: any) => ({ option: p.displayName || p.id, key: p.id })),
|
||||
]}
|
||||
@selectedOption=${(e: CustomEvent) => { onChange({ ...action, providerId: e.detail.key || undefined }); }}
|
||||
></dees-input-dropdown>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---- menu editor modal ---------------------------------------------------
|
||||
|
||||
private async openMenuEditor(existing: IIvrMenu | null) {
|
||||
const cfg = this.config;
|
||||
|
||||
const formData: IIvrMenu = existing
|
||||
? JSON.parse(JSON.stringify(existing))
|
||||
: {
|
||||
id: '',
|
||||
name: '',
|
||||
promptText: '',
|
||||
promptVoice: 'af_bella',
|
||||
entries: [],
|
||||
timeoutSec: 5,
|
||||
maxRetries: 3,
|
||||
timeoutAction: { type: 'hangup' as const },
|
||||
invalidAction: { type: 'repeat' as const },
|
||||
};
|
||||
|
||||
// For re-rendering the modal content on state changes we track a version counter.
|
||||
let version = 0;
|
||||
const modalContentId = `ivr-modal-${Date.now()}`;
|
||||
|
||||
const rerenderContent = () => {
|
||||
version++;
|
||||
const container = document.querySelector(`#${modalContentId}`) as HTMLElement
|
||||
|| document.getElementById(modalContentId);
|
||||
if (container) {
|
||||
// Force a re-render by removing and re-adding the modal content.
|
||||
// We can't use lit's render directly here, so we close and reopen.
|
||||
}
|
||||
};
|
||||
|
||||
const buildContent = (): TemplateResult => html`
|
||||
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
||||
<dees-input-text
|
||||
.key=${'name'} .label=${'Menu Name'} .value=${formData.name}
|
||||
@input=${(e: Event) => {
|
||||
formData.name = (e.target as any).value;
|
||||
if (!existing) {
|
||||
formData.id = slugify(formData.name);
|
||||
}
|
||||
}}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.key=${'id'} .label=${'Menu ID'} .value=${formData.id}
|
||||
.description=${'Auto-generated from name. Editable for custom IDs.'}
|
||||
@input=${(e: Event) => { formData.id = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.key=${'promptText'} .label=${'Prompt Text (TTS)'}
|
||||
.value=${formData.promptText}
|
||||
.description=${'Text that will be read aloud to the caller.'}
|
||||
@input=${(e: Event) => { formData.promptText = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-dropdown
|
||||
.key=${'promptVoice'} .label=${'Voice'}
|
||||
.selectedOption=${VOICE_OPTIONS.find((v) => v.key === formData.promptVoice) || VOICE_OPTIONS[0]}
|
||||
.options=${VOICE_OPTIONS}
|
||||
@selectedOption=${(e: CustomEvent) => { formData.promptVoice = e.detail.key; }}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<div style="margin-top:8px;padding-top:12px;border-top:1px solid #334155;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
||||
<div style="font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;font-weight:600;">
|
||||
Digit Entries
|
||||
</div>
|
||||
<div
|
||||
style="font-size:.75rem;color:#60a5fa;cursor:pointer;user-select:none;"
|
||||
@click=${() => {
|
||||
const usedDigits = new Set(formData.entries.map((e) => e.digit));
|
||||
const nextDigit = DIGIT_OPTIONS.find((d) => !usedDigits.has(d)) || '1';
|
||||
formData.entries = [...formData.entries, { digit: nextDigit, action: makeDefaultAction() }];
|
||||
rerenderContent();
|
||||
}}
|
||||
>+ Add Digit</div>
|
||||
</div>
|
||||
|
||||
${formData.entries.length === 0
|
||||
? html`<div style="font-size:.82rem;color:#64748b;font-style:italic;margin-bottom:8px;">No digit entries configured.</div>`
|
||||
: formData.entries.map((entry, idx) => html`
|
||||
<div style="padding:8px;margin-bottom:8px;border:1px solid #334155;border-radius:6px;background:#0f172a;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
||||
<dees-input-dropdown
|
||||
.label=${'Digit'}
|
||||
.selectedOption=${{ option: entry.digit, key: entry.digit }}
|
||||
.options=${DIGIT_OPTIONS.map((d) => ({ option: d, key: d }))}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
formData.entries[idx].digit = e.detail.key;
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<div
|
||||
style="font-size:.75rem;color:#f87171;cursor:pointer;user-select:none;margin-left:12px;padding:4px 8px;"
|
||||
@click=${() => {
|
||||
formData.entries = formData.entries.filter((_, i) => i !== idx);
|
||||
rerenderContent();
|
||||
}}
|
||||
>Remove</div>
|
||||
</div>
|
||||
${this.renderActionEditor(
|
||||
entry.action,
|
||||
(a) => { formData.entries[idx].action = a; rerenderContent(); },
|
||||
'Action',
|
||||
cfg,
|
||||
)}
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div style="margin-top:8px;padding-top:12px;border-top:1px solid #334155;">
|
||||
<div style="font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;font-weight:600;">
|
||||
Timeout Settings
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;">
|
||||
<dees-input-text
|
||||
.key=${'timeoutSec'} .label=${'Timeout (sec)'}
|
||||
.value=${String(formData.timeoutSec ?? 5)}
|
||||
@input=${(e: Event) => { formData.timeoutSec = parseInt((e.target as any).value, 10) || 5; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'maxRetries'} .label=${'Max Retries'}
|
||||
.value=${String(formData.maxRetries ?? 3)}
|
||||
@input=${(e: Event) => { formData.maxRetries = parseInt((e.target as any).value, 10) || 3; }}
|
||||
></dees-input-text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:8px;padding-top:12px;border-top:1px solid #334155;">
|
||||
${this.renderActionEditor(
|
||||
formData.timeoutAction,
|
||||
(a) => { formData.timeoutAction = a; rerenderContent(); },
|
||||
'Timeout Action (no digit pressed)',
|
||||
cfg,
|
||||
)}
|
||||
${this.renderActionEditor(
|
||||
formData.invalidAction,
|
||||
(a) => { formData.invalidAction = a; rerenderContent(); },
|
||||
'Invalid Digit Action',
|
||||
cfg,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: existing ? `Edit Menu: ${existing.name}` : 'New IVR Menu',
|
||||
width: 'small',
|
||||
showCloseButton: true,
|
||||
content: html`<div id="${modalContentId}">${buildContent()}</div>`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => { modalRef.destroy(); },
|
||||
},
|
||||
{
|
||||
name: 'Save',
|
||||
iconName: 'lucide:check',
|
||||
action: async (modalRef: any) => {
|
||||
if (!formData.name.trim()) {
|
||||
DeesToast.error('Menu name is required');
|
||||
return;
|
||||
}
|
||||
if (!formData.id.trim()) {
|
||||
DeesToast.error('Menu ID is required');
|
||||
return;
|
||||
}
|
||||
if (!formData.promptText.trim()) {
|
||||
DeesToast.error('Prompt text is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const ivr = this.getIvrConfig();
|
||||
const menus = [...ivr.menus];
|
||||
const idx = menus.findIndex((m) => m.id === (existing?.id || formData.id));
|
||||
if (idx >= 0) {
|
||||
menus[idx] = formData;
|
||||
} else {
|
||||
menus.push(formData);
|
||||
}
|
||||
|
||||
const updated: IIvrConfig = {
|
||||
...ivr,
|
||||
menus,
|
||||
// Auto-set entry menu if this is the first menu.
|
||||
entryMenuId: ivr.entryMenuId || formData.id,
|
||||
};
|
||||
|
||||
const result = await appState.apiSaveConfig({ ivr: updated });
|
||||
if (result.ok) {
|
||||
modalRef.destroy();
|
||||
DeesToast.success(existing ? 'Menu updated' : 'Menu created');
|
||||
await this.loadConfig();
|
||||
} else {
|
||||
DeesToast.error('Failed to save menu');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---- render --------------------------------------------------------------
|
||||
|
||||
public render(): TemplateResult {
|
||||
const ivr = this.getIvrConfig();
|
||||
const menus = ivr.menus || [];
|
||||
|
||||
return html`
|
||||
<div class="view-section">
|
||||
<dees-statsgrid
|
||||
.tiles=${this.getStatsTiles()}
|
||||
.minTileWidth=${220}
|
||||
.gap=${16}
|
||||
></dees-statsgrid>
|
||||
</div>
|
||||
|
||||
<div class="view-section" style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
|
||||
<dees-input-checkbox
|
||||
.key=${'ivr-enabled'}
|
||||
.label=${'Enable IVR System'}
|
||||
.value=${ivr.enabled}
|
||||
@newValue=${() => { this.toggleEnabled(); }}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="view-section">
|
||||
<dees-table
|
||||
heading1="IVR Menus"
|
||||
heading2="${menus.length} configured"
|
||||
dataName="menus"
|
||||
.data=${menus}
|
||||
.rowKey=${'id'}
|
||||
.columns=${this.getColumns()}
|
||||
.dataActions=${this.getDataActions()}
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
446
ts_web/elements/sipproxy-view-voicemail.ts
Normal file
446
ts_web/elements/sipproxy-view-voicemail.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||
import { deesCatalog } from '../plugins.js';
|
||||
import { appState, type IAppState } from '../state/appstate.js';
|
||||
import { viewHostCss } from './shared/index.js';
|
||||
import type { IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voicemail message shape (mirrors server IVoicemailMessage)
|
||||
// ---------------------------------------------------------------------------
|
||||
interface IVoicemailMessage {
|
||||
id: string;
|
||||
boxId: string;
|
||||
callerNumber: string;
|
||||
callerName?: string;
|
||||
timestamp: number;
|
||||
durationMs: number;
|
||||
fileName: string;
|
||||
heard: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSec = Math.round(ms / 1000);
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
return `${min}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDateTime(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const date = d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View element
|
||||
// ---------------------------------------------------------------------------
|
||||
@customElement('sipproxy-view-voicemail')
|
||||
export class SipproxyViewVoicemail extends DeesElement {
|
||||
@state() accessor appData: IAppState = appState.getState();
|
||||
@state() accessor messages: IVoicemailMessage[] = [];
|
||||
@state() accessor voiceboxIds: string[] = [];
|
||||
@state() accessor selectedBoxId: string = '';
|
||||
@state() accessor playingMessageId: string | null = null;
|
||||
@state() accessor loading: boolean = false;
|
||||
|
||||
private audioElement: HTMLAudioElement | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
}
|
||||
.view-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.box-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.box-selector label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.audio-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #1e293b;
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.audio-player audio {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
}
|
||||
.audio-player .close-btn {
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.audio-player .close-btn:hover {
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.empty-state .icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
// ---- lifecycle -----------------------------------------------------------
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.rxSubscriptions.push({
|
||||
unsubscribe: appState.subscribe((s) => { this.appData = s; }),
|
||||
} as any);
|
||||
this.loadVoiceboxes();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.stopAudio();
|
||||
}
|
||||
|
||||
// ---- data loading --------------------------------------------------------
|
||||
|
||||
private async loadVoiceboxes() {
|
||||
try {
|
||||
const cfg = await appState.apiGetConfig();
|
||||
const boxes: { id: string }[] = cfg.voiceboxes || [];
|
||||
this.voiceboxIds = boxes.map((b) => b.id);
|
||||
if (this.voiceboxIds.length > 0 && !this.selectedBoxId) {
|
||||
this.selectedBoxId = this.voiceboxIds[0];
|
||||
await this.loadMessages();
|
||||
}
|
||||
} catch {
|
||||
// Config unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
private async loadMessages() {
|
||||
if (!this.selectedBoxId) {
|
||||
this.messages = [];
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/voicemail/${encodeURIComponent(this.selectedBoxId)}`);
|
||||
const data = await res.json();
|
||||
this.messages = data.messages || [];
|
||||
} catch {
|
||||
this.messages = [];
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private async selectBox(boxId: string) {
|
||||
this.selectedBoxId = boxId;
|
||||
this.stopAudio();
|
||||
await this.loadMessages();
|
||||
}
|
||||
|
||||
// ---- audio playback ------------------------------------------------------
|
||||
|
||||
private playMessage(msg: IVoicemailMessage) {
|
||||
this.stopAudio();
|
||||
const url = `/api/voicemail/${encodeURIComponent(msg.boxId)}/${encodeURIComponent(msg.id)}/audio`;
|
||||
const audio = new Audio(url);
|
||||
this.audioElement = audio;
|
||||
this.playingMessageId = msg.id;
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
this.playingMessageId = null;
|
||||
// Auto-mark as heard after playback completes.
|
||||
if (!msg.heard) {
|
||||
this.markHeard(msg);
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
this.playingMessageId = null;
|
||||
deesCatalog.DeesToast.error('Failed to play audio');
|
||||
});
|
||||
|
||||
audio.play().catch(() => {
|
||||
this.playingMessageId = null;
|
||||
});
|
||||
}
|
||||
|
||||
private stopAudio() {
|
||||
if (this.audioElement) {
|
||||
this.audioElement.pause();
|
||||
this.audioElement.src = '';
|
||||
this.audioElement = null;
|
||||
}
|
||||
this.playingMessageId = null;
|
||||
}
|
||||
|
||||
// ---- message actions -----------------------------------------------------
|
||||
|
||||
private async markHeard(msg: IVoicemailMessage) {
|
||||
try {
|
||||
await fetch(`/api/voicemail/${encodeURIComponent(msg.boxId)}/${encodeURIComponent(msg.id)}/heard`, {
|
||||
method: 'POST',
|
||||
});
|
||||
// Update local state without full reload.
|
||||
this.messages = this.messages.map((m) =>
|
||||
m.id === msg.id ? { ...m, heard: true } : m,
|
||||
);
|
||||
} catch {
|
||||
deesCatalog.DeesToast.error('Failed to mark message as heard');
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteMessage(msg: IVoicemailMessage) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Delete Voicemail',
|
||||
width: 'small',
|
||||
showCloseButton: true,
|
||||
content: html`
|
||||
<div style="padding:8px 0;font-size:.9rem;color:#e2e8f0;">
|
||||
Are you sure you want to delete the voicemail from
|
||||
<strong style="color:#f87171;">${msg.callerName || msg.callerNumber}</strong>
|
||||
(${formatDateTime(msg.timestamp)})?
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => { modalRef.destroy(); },
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
action: async (modalRef: any) => {
|
||||
try {
|
||||
await fetch(
|
||||
`/api/voicemail/${encodeURIComponent(msg.boxId)}/${encodeURIComponent(msg.id)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (this.playingMessageId === msg.id) {
|
||||
this.stopAudio();
|
||||
}
|
||||
this.messages = this.messages.filter((m) => m.id !== msg.id);
|
||||
modalRef.destroy();
|
||||
deesCatalog.DeesToast.success('Voicemail deleted');
|
||||
} catch {
|
||||
deesCatalog.DeesToast.error('Failed to delete voicemail');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---- stats tiles ---------------------------------------------------------
|
||||
|
||||
private getStatsTiles(): IStatsTile[] {
|
||||
const total = this.messages.length;
|
||||
const unheard = this.messages.filter((m) => !m.heard).length;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'total',
|
||||
title: 'Total Messages',
|
||||
value: total,
|
||||
type: 'number',
|
||||
icon: 'lucide:voicemail',
|
||||
description: this.selectedBoxId ? `Box: ${this.selectedBoxId}` : 'No box selected',
|
||||
},
|
||||
{
|
||||
id: 'unheard',
|
||||
title: 'Unheard Messages',
|
||||
value: unheard,
|
||||
type: 'number',
|
||||
icon: 'lucide:bell-ring',
|
||||
color: unheard > 0 ? 'hsl(0 84.2% 60.2%)' : 'hsl(142.1 76.2% 36.3%)',
|
||||
description: unheard > 0 ? 'Needs attention' : 'All caught up',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- table columns -------------------------------------------------------
|
||||
|
||||
private getColumns() {
|
||||
return [
|
||||
{
|
||||
key: 'callerNumber',
|
||||
header: 'Caller',
|
||||
sortable: true,
|
||||
renderer: (_val: string, row: IVoicemailMessage) => {
|
||||
const display = row.callerName
|
||||
? html`<span>${row.callerName}</span><br><span style="font-size:.75rem;color:#64748b">${row.callerNumber}</span>`
|
||||
: html`<span style="font-family:'JetBrains Mono',monospace;font-size:.85rem">${row.callerNumber}</span>`;
|
||||
return html`<div>${display}</div>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'timestamp',
|
||||
header: 'Date/Time',
|
||||
sortable: true,
|
||||
value: (row: IVoicemailMessage) => formatDateTime(row.timestamp),
|
||||
renderer: (val: string) =>
|
||||
html`<span style="font-size:.85rem">${val}</span>`,
|
||||
},
|
||||
{
|
||||
key: 'durationMs',
|
||||
header: 'Duration',
|
||||
sortable: true,
|
||||
value: (row: IVoicemailMessage) => formatDuration(row.durationMs),
|
||||
renderer: (val: string) =>
|
||||
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.85rem">${val}</span>`,
|
||||
},
|
||||
{
|
||||
key: 'heard',
|
||||
header: 'Status',
|
||||
renderer: (val: boolean, row: IVoicemailMessage) => {
|
||||
const isPlaying = this.playingMessageId === row.id;
|
||||
if (isPlaying) {
|
||||
return html`
|
||||
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background:#1e3a5f;color:#60a5fa">Playing</span>
|
||||
`;
|
||||
}
|
||||
const heard = val;
|
||||
const color = heard ? '#71717a' : '#f59e0b';
|
||||
const bg = heard ? '#3f3f46' : '#422006';
|
||||
const label = heard ? 'Heard' : 'New';
|
||||
return html`
|
||||
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background:${bg};color:${color}">${label}</span>
|
||||
`;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- table actions -------------------------------------------------------
|
||||
|
||||
private getDataActions() {
|
||||
return [
|
||||
{
|
||||
name: 'Play',
|
||||
iconName: 'lucide:play',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const msg = actionData.item as IVoicemailMessage;
|
||||
if (this.playingMessageId === msg.id) {
|
||||
this.stopAudio();
|
||||
} else {
|
||||
this.playMessage(msg);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Mark Heard',
|
||||
iconName: 'lucide:check',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const msg = actionData.item as IVoicemailMessage;
|
||||
if (!msg.heard) {
|
||||
await this.markHeard(msg);
|
||||
deesCatalog.DeesToast.success('Marked as heard');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.deleteMessage(actionData.item as IVoicemailMessage);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:refreshCw',
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.loadMessages();
|
||||
deesCatalog.DeesToast.success('Messages refreshed');
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- render --------------------------------------------------------------
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
${this.voiceboxIds.length > 1 ? html`
|
||||
<div class="box-selector">
|
||||
<label>Voicebox</label>
|
||||
<dees-input-dropdown
|
||||
.key=${'voicebox'}
|
||||
.selectedOption=${{ option: this.selectedBoxId, key: this.selectedBoxId }}
|
||||
.options=${this.voiceboxIds.map((id) => ({ option: id, key: id }))}
|
||||
@selectedOption=${(e: CustomEvent) => { this.selectBox(e.detail.key); }}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="view-section">
|
||||
<dees-statsgrid
|
||||
.tiles=${this.getStatsTiles()}
|
||||
.minTileWidth=${220}
|
||||
.gap=${16}
|
||||
></dees-statsgrid>
|
||||
</div>
|
||||
|
||||
${this.messages.length === 0 && !this.loading ? html`
|
||||
<div class="empty-state">
|
||||
<div class="icon">✉</div>
|
||||
<div>No voicemail messages${this.selectedBoxId ? ` in box "${this.selectedBoxId}"` : ''}</div>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="view-section">
|
||||
<dees-table
|
||||
heading1="Voicemail"
|
||||
heading2="${this.messages.length} message${this.messages.length !== 1 ? 's' : ''}"
|
||||
dataName="voicemail"
|
||||
.data=${this.messages}
|
||||
.rowKey=${'id'}
|
||||
.searchable=${true}
|
||||
.columns=${this.getColumns()}
|
||||
.dataActions=${this.getDataActions()}
|
||||
></dees-table>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${this.playingMessageId ? html`
|
||||
<div class="audio-player">
|
||||
<span style="color:#60a5fa;font-size:.8rem;font-weight:600;">Now playing</span>
|
||||
<span style="flex:1"></span>
|
||||
<span class="close-btn" @click=${() => this.stopAudio()}>✕</span>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Maps URL paths to views in dees-simple-appdash.
|
||||
*/
|
||||
|
||||
const VIEWS = ['overview', 'calls', 'phone', 'routes', 'contacts', 'providers', 'log'] as const;
|
||||
const VIEWS = ['overview', 'calls', 'phone', 'routes', 'voicemail', 'ivr', 'contacts', 'providers', 'log'] as const;
|
||||
type TViewSlug = (typeof VIEWS)[number];
|
||||
|
||||
class AppRouter {
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface IAppState {
|
||||
contacts: IContact[];
|
||||
selectedContact: IContact | null;
|
||||
logLines: string[];
|
||||
/** Unheard voicemail count per voicebox ID. */
|
||||
voicemailCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
const MAX_LOG = 200;
|
||||
@@ -89,6 +91,7 @@ class AppStateManager {
|
||||
contacts: [],
|
||||
selectedContact: null,
|
||||
logLines: [],
|
||||
voicemailCounts: {},
|
||||
};
|
||||
|
||||
private listeners = new Set<(state: IAppState) => void>();
|
||||
@@ -155,6 +158,7 @@ class AppStateManager {
|
||||
calls: m.data.calls || [],
|
||||
callHistory: m.data.callHistory || [],
|
||||
contacts: m.data.contacts || [],
|
||||
voicemailCounts: m.data.voicemailCounts || {},
|
||||
});
|
||||
} else if (m.type === 'log') {
|
||||
this.addLog(`${m.ts} ${m.data.message}`);
|
||||
|
||||
Reference in New Issue
Block a user