From e6bd64a534a3328afaed5b8059a4f6edca13cb33 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 10 Apr 2026 08:54:46 +0000 Subject: [PATCH] feat(call, voicemail, ivr): add voicemail and IVR call flows with DTMF handling, prompt playback, recording, and dashboard management --- changelog.md | 7 + ts/00_commitinfo_data.ts | 2 +- ts/call/audio-recorder.ts | 323 ++++++++++ ts/call/call-manager.ts | 455 ++++++++++++++ ts/call/call.ts | 14 +- ts/call/dtmf-detector.ts | 272 +++++++++ ts/call/prompt-cache.ts | 404 +++++++++++++ ts/call/sip-leg.ts | 13 +- ts/call/system-leg.ts | 336 +++++++++++ ts/call/types.ts | 4 +- ts/call/wav-writer.ts | 163 +++++ ts/config.ts | 124 ++++ ts/frontend.ts | 53 +- ts/ivr.ts | 209 +++++++ ts/sip/helpers.ts | 51 ++ ts/sip/index.ts | 3 +- ts/sipproxy.ts | 38 ++ ts/voicebox.ts | 314 ++++++++++ ts_web/00_commitinfo_data.ts | 2 +- ts_web/elements/index.ts | 2 + ts_web/elements/sipproxy-app.ts | 4 + ts_web/elements/sipproxy-view-ivr.ts | 657 +++++++++++++++++++++ ts_web/elements/sipproxy-view-voicemail.ts | 446 ++++++++++++++ ts_web/router.ts | 2 +- ts_web/state/appstate.ts | 4 + 25 files changed, 3892 insertions(+), 10 deletions(-) create mode 100644 ts/call/audio-recorder.ts create mode 100644 ts/call/dtmf-detector.ts create mode 100644 ts/call/prompt-cache.ts create mode 100644 ts/call/system-leg.ts create mode 100644 ts/call/wav-writer.ts create mode 100644 ts/ivr.ts create mode 100644 ts/voicebox.ts create mode 100644 ts_web/elements/sipproxy-view-ivr.ts create mode 100644 ts_web/elements/sipproxy-view-voicemail.ts diff --git a/changelog.md b/changelog.md index 5a048e6..07e15b8 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index d69f5cf..04ca5e2 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.9.0', + version: '1.10.0', description: 'undefined' } diff --git a/ts/call/audio-recorder.ts b/ts/call/audio-recorder.ts new file mode 100644 index 0000000..649f13c --- /dev/null +++ b/ts/call/audio-recorder.ts @@ -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 | null = null; + + // Processing queue to avoid concurrent transcodes. + private processQueue: Promise = 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/ts/call/call-manager.ts b/ts/call/call-manager.ts index d1b13a8..9a79146 100644 --- a/ts/call/call-manager.ts +++ b/ts/call/call-manager.ts @@ -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: ``, + }); + 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: ``, + 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 // ------------------------------------------------------------------------- diff --git a/ts/call/call.ts b/ts/call/call.ts index c2be967..8e180a1 100644 --- a/ts/call/call.ts +++ b/ts/call/call.ts @@ -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(); } diff --git a/ts/call/dtmf-detector.ts b/ts/call/dtmf-detector.ts new file mode 100644 index 0000000..43b1610 --- /dev/null +++ b/ts/call/dtmf-detector.ts @@ -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 | 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; + } +} diff --git a/ts/call/prompt-cache.ts b/ts/call/prompt-cache.ts new file mode 100644 index 0000000..d509bb9 --- /dev/null +++ b/ts/call/prompt-cache.ts @@ -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(); + 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 { + 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 { + 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 { + // 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); +} diff --git a/ts/call/sip-leg.ts b/ts/call/sip-leg.ts index 969c99f..3144e62 100644 --- a/ts/call/sip-leg.ts +++ b/ts/call/sip-leg.ts @@ -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. } // ------------------------------------------------------------------------- diff --git a/ts/call/system-leg.ts b/ts/call/system-leg.ts new file mode 100644 index 0000000..3d5cfe5 --- /dev/null +++ b/ts/call/system-leg.ts @@ -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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/ts/call/types.ts b/ts/call/types.ts index 1fac354..f626d48 100644 --- a/ts/call/types.ts +++ b/ts/call/types.ts @@ -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'; diff --git a/ts/call/wav-writer.ts b/ts/call/wav-writer.ts new file mode 100644 index 0000000..35287a6 --- /dev/null +++ b/ts/call/wav-writer.ts @@ -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; + } +} diff --git a/ts/config.ts b/ts/config.ts index 4cfb5ef..8526241 100644 --- a/ts/config.ts +++ b/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, }; } diff --git a/ts/frontend.ts b/ts/frontend.ts index c91f8d3..b2cc62b 100644 --- a/ts/frontend.ts +++ b/ts/frontend.ts @@ -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 { 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(); }), ); } diff --git a/ts/ivr.ts b/ts/ivr.ts new file mode 100644 index 0000000..eb67ccc --- /dev/null +++ b/ts/ivr.ts @@ -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 | 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; + } + } +} diff --git a/ts/sip/helpers.ts b/ts/sip/helpers.ts index 6ba08aa..3874352 100644 --- a/ts/sip/helpers.ts +++ b/ts/sip/helpers.ts @@ -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'], + ], + }; +} diff --git a/ts/sip/index.ts b/ts/sip/index.ts index ab876de..49c3ac8 100644 --- a/ts/sip/index.ts +++ b/ts/sip/index.ts @@ -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'; diff --git a/ts/sipproxy.ts b/ts/sipproxy.ts index ee46842..9e25bbf 100644 --- a/ts/sipproxy.ts +++ b/ts/sipproxy.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); }); diff --git a/ts/voicebox.ts b/ts/voicebox.ts new file mode 100644 index 0000000..04ecc3c --- /dev/null +++ b/ts/voicebox.ts @@ -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(); + 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 { + const counts: Record = {}; + 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'); + } +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index d69f5cf..04ca5e2 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.9.0', + version: '1.10.0', description: 'undefined' } diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 4a6815d..7a20608 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -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'; diff --git a/ts_web/elements/sipproxy-app.ts b/ts_web/elements/sipproxy-app.ts index d656a33..cecb65c 100644 --- a/ts_web/elements/sipproxy-app.ts +++ b/ts_web/elements/sipproxy-app.ts @@ -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 }, diff --git a/ts_web/elements/sipproxy-view-ivr.ts b/ts_web/elements/sipproxy-view-ivr.ts new file mode 100644 index 0000000..40ddd26 --- /dev/null +++ b/ts_web/elements/sipproxy-view-ivr.ts @@ -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` + ${val} + ${isEntry ? html`entry` : ''} + `; + }, + }, + { + key: 'promptText', + header: 'Prompt', + renderer: (val: string) => { + const truncated = val && val.length > 60 ? val.slice(0, 60) + '...' : val || '--'; + return html`${truncated}`; + }, + }, + { + key: 'entries', + header: 'Digits', + renderer: (_val: any, row: IIvrMenu) => { + const digits = (row.entries || []).map((e) => e.digit).join(', '); + return html`${digits || '(none)'}`; + }, + }, + { + key: 'timeoutAction', + header: 'Timeout Action', + renderer: (_val: any, row: IIvrMenu) => { + return html`${describeAction(row.timeoutAction)}`; + }, + }, + ]; + } + + // ---- 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` +
+ Are you sure you want to delete + ${menu.name}? + This action cannot be undone. +
+ `, + 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` +
+
${label}
+ { + 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; + } + }} + > + + ${action.type === 'route-extension' ? html` + ({ option: `${d.displayName} (${d.extension})`, key: d.extension }))} + @selectedOption=${(e: CustomEvent) => { onChange({ ...action, extensionId: e.detail.key }); }} + > + ` : ''} + + ${action.type === 'route-voicemail' ? html` + { onChange({ ...action, boxId: (e.target as any).value }); }} + > + ` : ''} + + ${action.type === 'submenu' ? html` + 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 }); }} + > + ` : ''} + + ${action.type === 'play-message' ? html` + { onChange({ ...action, promptId: (e.target as any).value }); }} + > + ` : ''} + + ${action.type === 'transfer' ? html` + { onChange({ ...action, number: (e.target as any).value }); }} + > + ({ option: p.displayName || p.id, key: p.id })), + ]} + @selectedOption=${(e: CustomEvent) => { onChange({ ...action, providerId: e.detail.key || undefined }); }} + > + ` : ''} +
+ `; + } + + // ---- 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` +
+ { + formData.name = (e.target as any).value; + if (!existing) { + formData.id = slugify(formData.name); + } + }} + > + + { formData.id = (e.target as any).value; }} + > + + { formData.promptText = (e.target as any).value; }} + > + + v.key === formData.promptVoice) || VOICE_OPTIONS[0]} + .options=${VOICE_OPTIONS} + @selectedOption=${(e: CustomEvent) => { formData.promptVoice = e.detail.key; }} + > + +
+
+
+ Digit Entries +
+
{ + 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
+
+ + ${formData.entries.length === 0 + ? html`
No digit entries configured.
` + : formData.entries.map((entry, idx) => html` +
+
+ ({ option: d, key: d }))} + @selectedOption=${(e: CustomEvent) => { + formData.entries[idx].digit = e.detail.key; + }} + > +
{ + formData.entries = formData.entries.filter((_, i) => i !== idx); + rerenderContent(); + }} + >Remove
+
+ ${this.renderActionEditor( + entry.action, + (a) => { formData.entries[idx].action = a; rerenderContent(); }, + 'Action', + cfg, + )} +
+ `) + } +
+ +
+
+ Timeout Settings +
+
+ { formData.timeoutSec = parseInt((e.target as any).value, 10) || 5; }} + > + { formData.maxRetries = parseInt((e.target as any).value, 10) || 3; }} + > +
+
+ +
+ ${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, + )} +
+
+ `; + + await DeesModal.createAndShow({ + heading: existing ? `Edit Menu: ${existing.name}` : 'New IVR Menu', + width: 'small', + showCloseButton: true, + content: html`
${buildContent()}
`, + 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` +
+ +
+ +
+ { this.toggleEnabled(); }} + > +
+ +
+ +
+ `; + } +} diff --git a/ts_web/elements/sipproxy-view-voicemail.ts b/ts_web/elements/sipproxy-view-voicemail.ts new file mode 100644 index 0000000..53fbaab --- /dev/null +++ b/ts_web/elements/sipproxy-view-voicemail.ts @@ -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` +
+ Are you sure you want to delete the voicemail from + ${msg.callerName || msg.callerNumber} + (${formatDateTime(msg.timestamp)})? +
+ `, + 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`${row.callerName}
${row.callerNumber}` + : html`${row.callerNumber}`; + return html`
${display}
`; + }, + }, + { + key: 'timestamp', + header: 'Date/Time', + sortable: true, + value: (row: IVoicemailMessage) => formatDateTime(row.timestamp), + renderer: (val: string) => + html`${val}`, + }, + { + key: 'durationMs', + header: 'Duration', + sortable: true, + value: (row: IVoicemailMessage) => formatDuration(row.durationMs), + renderer: (val: string) => + html`${val}`, + }, + { + key: 'heard', + header: 'Status', + renderer: (val: boolean, row: IVoicemailMessage) => { + const isPlaying = this.playingMessageId === row.id; + if (isPlaying) { + return html` + Playing + `; + } + const heard = val; + const color = heard ? '#71717a' : '#f59e0b'; + const bg = heard ? '#3f3f46' : '#422006'; + const label = heard ? 'Heard' : 'New'; + return html` + ${label} + `; + }, + }, + ]; + } + + // ---- 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` +
+ + ({ option: id, key: id }))} + @selectedOption=${(e: CustomEvent) => { this.selectBox(e.detail.key); }} + > +
+ ` : ''} + +
+ +
+ + ${this.messages.length === 0 && !this.loading ? html` +
+
+
No voicemail messages${this.selectedBoxId ? ` in box "${this.selectedBoxId}"` : ''}
+
+ ` : html` +
+ +
+ `} + + ${this.playingMessageId ? html` +
+ Now playing + + this.stopAudio()}>✕ +
+ ` : ''} + `; + } +} diff --git a/ts_web/router.ts b/ts_web/router.ts index 7a34131..0c4ecf6 100644 --- a/ts_web/router.ts +++ b/ts_web/router.ts @@ -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 { diff --git a/ts_web/state/appstate.ts b/ts_web/state/appstate.ts index 568ed8b..ac6db69 100644 --- a/ts_web/state/appstate.ts +++ b/ts_web/state/appstate.ts @@ -72,6 +72,8 @@ export interface IAppState { contacts: IContact[]; selectedContact: IContact | null; logLines: string[]; + /** Unheard voicemail count per voicebox ID. */ + voicemailCounts: Record; } 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}`);