/** * SIP proxy — entry point. * * Spawns the Rust proxy-engine which handles ALL SIP protocol mechanics. * TypeScript is the control plane: * - Loads config and pushes it to Rust * - Receives high-level events (incoming calls, registration, etc.) * - Drives the web dashboard * - Manages IVR, voicemail, announcements * - Handles WebRTC browser signaling (forwarded to Rust in Phase 2) * * No raw SIP ever touches TypeScript. */ import fs from 'node:fs'; import path from 'node:path'; import { loadConfig } from './config.ts'; import type { IAppConfig } from './config.ts'; import { broadcastWs, initWebUi } from './frontend.ts'; import { initWebRtcSignaling, sendToBrowserDevice, getAllBrowserDeviceIds, getBrowserDeviceWs, } from './webrtcbridge.ts'; import { initAnnouncement } from './announcement.ts'; import { PromptCache } from './call/prompt-cache.ts'; import { VoiceboxManager } from './voicebox.ts'; import { initProxyEngine, configureProxyEngine, onProxyEvent, hangupCall, makeCall, shutdownProxyEngine, webrtcOffer, webrtcIce, webrtcLink, webrtcClose, addLeg, removeLeg, } from './proxybridge.ts'; import type { IIncomingCallEvent, IOutboundCallEvent, ICallEndedEvent, IProviderRegisteredEvent, IDeviceRegisteredEvent, } from './proxybridge.ts'; // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- let appConfig: IAppConfig = loadConfig(); const LOG_PATH = path.join(process.cwd(), 'sip_trace.log'); // --------------------------------------------------------------------------- // Logging // --------------------------------------------------------------------------- const startTime = Date.now(); const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; function now(): string { return new Date().toISOString().replace('T', ' ').slice(0, 19); } function log(msg: string): void { const line = `${now()} ${msg}\n`; fs.appendFileSync(LOG_PATH, line); process.stdout.write(line); broadcastWs('log', { message: msg }); } // --------------------------------------------------------------------------- // Shadow state — maintained from Rust events for the dashboard // --------------------------------------------------------------------------- interface IProviderStatus { id: string; displayName: string; registered: boolean; publicIp: string | null; } interface IDeviceStatus { id: string; displayName: string; address: string | null; port: number; connected: boolean; isBrowser: boolean; } interface IActiveLeg { id: string; type: 'sip-device' | 'sip-provider' | 'webrtc' | 'tool'; state: string; codec: string | null; rtpPort: number | null; remoteMedia: string | null; metadata: Record; } interface IActiveCall { id: string; direction: string; callerNumber: string | null; calleeNumber: string | null; providerUsed: string | null; state: string; startedAt: number; legs: Map; } interface IHistoryLeg { id: string; type: string; metadata: Record; } interface ICallHistoryEntry { id: string; direction: string; callerNumber: string | null; calleeNumber: string | null; startedAt: number; duration: number; legs: IHistoryLeg[]; } const providerStatuses = new Map(); const deviceStatuses = new Map(); const activeCalls = new Map(); const callHistory: ICallHistoryEntry[] = []; const MAX_HISTORY = 100; // WebRTC session ↔ call linking state. // Both pieces (session accept + call media info) can arrive in any order. const webrtcSessionToCall = new Map(); // sessionId → callId const webrtcCallToSession = new Map(); // callId → sessionId const pendingCallMedia = new Map(); // callId → provider media info // Initialize provider statuses from config (all start as unregistered). for (const p of appConfig.providers) { providerStatuses.set(p.id, { id: p.id, displayName: p.displayName, registered: false, publicIp: null, }); } // Initialize device statuses from config. for (const d of appConfig.devices) { deviceStatuses.set(d.id, { id: d.id, displayName: d.displayName, address: null, port: 0, connected: false, isBrowser: false, }); } // --------------------------------------------------------------------------- // Initialize subsystems // --------------------------------------------------------------------------- const promptCache = new PromptCache(log); const voiceboxManager = new VoiceboxManager(log); voiceboxManager.init(appConfig.voiceboxes ?? []); // WebRTC signaling (browser device registration). initWebRtcSignaling({ log }); // --------------------------------------------------------------------------- // Status snapshot (fed to web dashboard) // --------------------------------------------------------------------------- function getStatus() { // Merge SIP devices (from Rust) + browser devices (from TS WebSocket). const devices = [...deviceStatuses.values()]; for (const bid of getAllBrowserDeviceIds()) { devices.push({ id: bid, displayName: 'Browser', address: null, port: 0, connected: true, isBrowser: true, }); } return { instanceId, uptime: Math.floor((Date.now() - startTime) / 1000), lanIp: appConfig.proxy.lanIp, providers: [...providerStatuses.values()], devices, calls: [...activeCalls.values()].map((c) => ({ ...c, duration: Math.floor((Date.now() - c.startedAt) / 1000), legs: [...c.legs.values()].map((l) => ({ id: l.id, type: l.type, state: l.state, codec: l.codec, rtpPort: l.rtpPort, remoteMedia: l.remoteMedia, metadata: l.metadata || {}, pktSent: 0, pktReceived: 0, transcoding: false, })), })), callHistory, contacts: appConfig.contacts || [], voicemailCounts: voiceboxManager.getAllUnheardCounts(), }; } // --------------------------------------------------------------------------- // Start Rust proxy engine // --------------------------------------------------------------------------- async function startProxyEngine(): Promise { const ok = await initProxyEngine(log); if (!ok) { log('[FATAL] failed to start proxy engine'); process.exit(1); } // Subscribe to events from Rust BEFORE sending configure. onProxyEvent('provider_registered', (data: IProviderRegisteredEvent) => { const ps = providerStatuses.get(data.provider_id); if (ps) { const wasRegistered = ps.registered; ps.registered = data.registered; ps.publicIp = data.public_ip; if (data.registered && !wasRegistered) { log(`[provider:${data.provider_id}] registered (publicIp=${data.public_ip})`); } else if (!data.registered && wasRegistered) { log(`[provider:${data.provider_id}] registration lost`); } broadcastWs('registration', { providerId: data.provider_id, registered: data.registered }); } }); onProxyEvent('device_registered', (data: IDeviceRegisteredEvent) => { const ds = deviceStatuses.get(data.device_id); if (ds) { ds.address = data.address; ds.port = data.port; ds.connected = true; log(`[registrar] ${data.display_name} registered from ${data.address}:${data.port}`); } }); onProxyEvent('incoming_call', (data: IIncomingCallEvent) => { log(`[call] incoming: ${data.from_uri} → ${data.to_number} via ${data.provider_id} (${data.call_id})`); activeCalls.set(data.call_id, { id: data.call_id, direction: 'inbound', callerNumber: data.from_uri, calleeNumber: data.to_number, providerUsed: data.provider_id, state: 'ringing', startedAt: Date.now(), legs: new Map(), }); // Notify browsers of incoming call. const browserIds = getAllBrowserDeviceIds(); for (const bid of browserIds) { sendToBrowserDevice(bid, { type: 'webrtc-incoming', callId: data.call_id, from: data.from_uri, deviceId: bid, }); } }); onProxyEvent('outbound_device_call', (data: IOutboundCallEvent) => { log(`[call] outbound: device ${data.from_device} → ${data.to_number} (${data.call_id})`); activeCalls.set(data.call_id, { id: data.call_id, direction: 'outbound', callerNumber: data.from_device, calleeNumber: data.to_number, providerUsed: null, state: 'setting-up', startedAt: Date.now(), legs: new Map(), }); }); onProxyEvent('outbound_call_started', (data: any) => { log(`[call] outbound started: ${data.call_id} → ${data.number} via ${data.provider_id}`); activeCalls.set(data.call_id, { id: data.call_id, direction: 'outbound', callerNumber: null, calleeNumber: data.number, providerUsed: data.provider_id, state: 'setting-up', startedAt: Date.now(), legs: new Map(), }); // Notify all browser devices — they can connect via WebRTC to listen/talk. const browserIds = getAllBrowserDeviceIds(); for (const bid of browserIds) { sendToBrowserDevice(bid, { type: 'webrtc-incoming', callId: data.call_id, from: data.number, deviceId: bid, }); } }); onProxyEvent('call_ringing', (data: { call_id: string }) => { const call = activeCalls.get(data.call_id); if (call) call.state = 'ringing'; }); onProxyEvent('call_answered', (data: { call_id: string; provider_media_addr?: string; provider_media_port?: number; sip_pt?: number }) => { const call = activeCalls.get(data.call_id); if (call) { call.state = 'connected'; log(`[call] ${data.call_id} connected`); // Enrich provider leg with media info from the answered event. if (data.provider_media_addr && data.provider_media_port) { for (const leg of call.legs.values()) { if (leg.type === 'sip-provider') { leg.remoteMedia = `${data.provider_media_addr}:${data.provider_media_port}`; if (data.sip_pt !== undefined) { const codecNames: Record = { 0: 'PCMU', 8: 'PCMA', 9: 'G.722', 111: 'Opus' }; leg.codec = codecNames[data.sip_pt] || `PT${data.sip_pt}`; } break; } } } } // Try to link WebRTC session to this call for audio bridging. if (data.provider_media_addr && data.provider_media_port) { const sessionId = webrtcCallToSession.get(data.call_id); if (sessionId) { // Both session and media info available — link now. const sipPt = data.sip_pt ?? 9; log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${data.call_id} media=${data.provider_media_addr}:${data.provider_media_port} pt=${sipPt}`); webrtcLink(sessionId, data.call_id, data.provider_media_addr, data.provider_media_port, sipPt).then((ok) => { log(`[webrtc] link result: ${ok}`); }); } else { // Session not yet accepted — store media info for when it arrives. pendingCallMedia.set(data.call_id, { addr: data.provider_media_addr, port: data.provider_media_port, sipPt: data.sip_pt ?? 9, }); log(`[webrtc] media info cached for call=${data.call_id}, waiting for session accept`); } } }); onProxyEvent('call_ended', (data: ICallEndedEvent) => { const call = activeCalls.get(data.call_id); if (call) { log(`[call] ${data.call_id} ended: ${data.reason} (${data.duration}s)`); // Snapshot legs with metadata for history. const historyLegs: IHistoryLeg[] = []; for (const [, leg] of call.legs) { historyLegs.push({ id: leg.id, type: leg.type, metadata: leg.metadata || {}, }); } // Move to history. callHistory.unshift({ id: call.id, direction: call.direction, callerNumber: call.callerNumber, calleeNumber: call.calleeNumber, startedAt: call.startedAt, duration: data.duration, legs: historyLegs, }); if (callHistory.length > MAX_HISTORY) callHistory.pop(); activeCalls.delete(data.call_id); // Notify browser(s) that the call ended. broadcastWs('webrtc-call-ended', { callId: data.call_id }); // Clean up WebRTC session mappings. const sessionId = webrtcCallToSession.get(data.call_id); if (sessionId) { webrtcCallToSession.delete(data.call_id); webrtcSessionToCall.delete(sessionId); webrtcClose(sessionId).catch(() => {}); } pendingCallMedia.delete(data.call_id); } }); onProxyEvent('sip_unhandled', (data: any) => { log(`[sip] unhandled ${data.method_or_status} Call-ID=${data.call_id?.slice(0, 20)} from=${data.from_addr}:${data.from_port}`); }); // Leg events (multiparty) — update shadow state so the dashboard shows legs. onProxyEvent('leg_added', (data: any) => { log(`[leg] added: call=${data.call_id} leg=${data.leg_id} kind=${data.kind} state=${data.state}`); const call = activeCalls.get(data.call_id); if (call) { call.legs.set(data.leg_id, { id: data.leg_id, type: data.kind, state: data.state, codec: data.codec ?? null, rtpPort: data.rtpPort ?? null, remoteMedia: data.remoteMedia ?? null, metadata: data.metadata || {}, }); } }); onProxyEvent('leg_removed', (data: any) => { log(`[leg] removed: call=${data.call_id} leg=${data.leg_id}`); activeCalls.get(data.call_id)?.legs.delete(data.leg_id); }); onProxyEvent('leg_state_changed', (data: any) => { log(`[leg] state: call=${data.call_id} leg=${data.leg_id} → ${data.state}`); const call = activeCalls.get(data.call_id); if (!call) return; const leg = call.legs.get(data.leg_id); if (leg) { leg.state = data.state; if (data.metadata) leg.metadata = data.metadata; } else { // Initial legs (provider/device) don't emit leg_added — create on first state change. const legId: string = data.leg_id; const type = legId.includes('-prov') ? 'sip-provider' : legId.includes('-dev') ? 'sip-device' : 'webrtc'; call.legs.set(data.leg_id, { id: data.leg_id, type, state: data.state, codec: null, rtpPort: null, remoteMedia: null, metadata: data.metadata || {}, }); } }); // WebRTC events from Rust — forward ICE candidates to browser via WebSocket. onProxyEvent('webrtc_ice_candidate', (data: any) => { // Find the browser's WebSocket by session ID and send the ICE candidate. broadcastWs('webrtc-ice', { sessionId: data.session_id, candidate: { candidate: data.candidate, sdpMid: data.sdp_mid, sdpMLineIndex: data.sdp_mline_index }, }); }); onProxyEvent('webrtc_state', (data: any) => { log(`[webrtc] session=${data.session_id?.slice(0, 8)} state=${data.state}`); }); onProxyEvent('webrtc_track', (data: any) => { log(`[webrtc] session=${data.session_id?.slice(0, 8)} track=${data.kind} codec=${data.codec}`); }); onProxyEvent('webrtc_audio_rx', (data: any) => { if (data.packet_count === 1 || data.packet_count === 50) { log(`[webrtc] session=${data.session_id?.slice(0, 8)} browser audio rx #${data.packet_count}`); } }); // Voicemail events. onProxyEvent('voicemail_started', (data: any) => { log(`[voicemail] started for call ${data.call_id} caller=${data.caller_number}`); }); onProxyEvent('recording_done', (data: any) => { log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) caller=${data.caller_number}`); // Save voicemail metadata via VoiceboxManager. voiceboxManager.addMessage?.('default', { callerNumber: data.caller_number || 'Unknown', callerName: null, fileName: data.file_path, durationMs: data.duration_ms, }); }); onProxyEvent('voicemail_error', (data: any) => { log(`[voicemail] error: ${data.error} call=${data.call_id}`); }); // Send full config to Rust — this binds the SIP socket and starts registrations. const configured = await configureProxyEngine({ proxy: appConfig.proxy, providers: appConfig.providers, devices: appConfig.devices, routing: appConfig.routing, }); if (!configured) { log('[FATAL] failed to configure proxy engine'); process.exit(1); } const providerList = appConfig.providers.map((p) => p.displayName).join(', '); const deviceList = appConfig.devices.map((d) => d.displayName).join(', '); log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`); // Generate TTS audio (WAV files on disk, played by Rust audio_player). try { await initAnnouncement(log); // Pre-generate prompts. await promptCache.generateBeep('voicemail-beep', 1000, 500, 8000); for (const vb of appConfig.voiceboxes ?? []) { if (!vb.enabled) continue; const promptId = `voicemail-greeting-${vb.id}`; if (vb.greetingWavPath) { await promptCache.loadWavPrompt(promptId, vb.greetingWavPath); } 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'); } } if (appConfig.ivr?.enabled) { for (const menu of appConfig.ivr.menus) { await promptCache.generatePrompt(`ivr-menu-${menu.id}`, menu.promptText, menu.promptVoice || 'af_bella'); } } log(`[startup] prompts cached: ${promptCache.listIds().join(', ') || 'none'}`); } catch (e) { log(`[tts] init failed: ${e}`); } } // --------------------------------------------------------------------------- // Web UI // --------------------------------------------------------------------------- initWebUi( getStatus, log, (number, deviceId, providerId) => { // Outbound calls from dashboard — send make_call command to Rust. log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`); // Fire-and-forget — the async result comes via events. makeCall(number, deviceId, providerId).then((callId) => { if (callId) { log(`[dashboard] call started: ${callId}`); activeCalls.set(callId, { id: callId, direction: 'outbound', callerNumber: null, calleeNumber: number, providerUsed: providerId || null, state: 'setting-up', startedAt: Date.now(), legs: new Map(), }); } else { log(`[dashboard] call failed for ${number}`); } }); // Return a temporary ID so the frontend doesn't show "failed" immediately. return { id: `pending-${Date.now()}` }; }, (callId) => { hangupCall(callId); return true; }, () => { // Config saved — reconfigure Rust engine. try { const fresh = loadConfig(); Object.assign(appConfig, fresh); // Update shadow state. for (const p of fresh.providers) { if (!providerStatuses.has(p.id)) { providerStatuses.set(p.id, { id: p.id, displayName: p.displayName, registered: false, publicIp: null, }); } } for (const d of fresh.devices) { if (!deviceStatuses.has(d.id)) { deviceStatuses.set(d.id, { id: d.id, displayName: d.displayName, address: null, port: 0, connected: false, isBrowser: false, }); } } // Re-send config to Rust. configureProxyEngine({ proxy: fresh.proxy, providers: fresh.providers, devices: fresh.devices, routing: fresh.routing, }).then((ok) => { if (ok) log('[config] reloaded — proxy engine reconfigured'); else log('[config] reload failed — proxy engine rejected config'); }); } catch (e: any) { log(`[config] reload failed: ${e.message}`); } }, undefined, // callManager — legacy, replaced by Rust proxy-engine voiceboxManager, // voiceboxManager // WebRTC signaling → forwarded to Rust proxy-engine. async (sessionId, sdp, ws) => { log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`); if (!sdp || typeof sdp !== 'string' || sdp.length < 10) { log(`[webrtc] WARNING: invalid SDP (type=${typeof sdp}), skipping offer`); return; } log(`[webrtc] sending offer to Rust (${sdp.length}b)...`); const result = await webrtcOffer(sessionId, sdp); log(`[webrtc] Rust result: ${JSON.stringify(result)?.slice(0, 200)}`); if (result?.sdp) { ws.send(JSON.stringify({ type: 'webrtc-answer', sessionId, sdp: result.sdp })); log(`[webrtc] answer sent to browser session=${sessionId.slice(0, 8)}`); } else { log(`[webrtc] ERROR: no answer SDP from Rust`); } }, async (sessionId, candidate) => { await webrtcIce(sessionId, candidate); }, async (sessionId) => { await webrtcClose(sessionId); }, // onWebRtcAccept — browser has accepted a call, linking session to call. (callId: string, sessionId: string) => { log(`[webrtc] accept: callId=${callId} sessionId=${sessionId.slice(0, 8)}`); // Store bidirectional mapping. webrtcSessionToCall.set(sessionId, callId); webrtcCallToSession.set(callId, sessionId); // Check if we already have media info for this call (provider answered first). const media = pendingCallMedia.get(callId); if (media) { pendingCallMedia.delete(callId); log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${callId} media=${media.addr}:${media.port} pt=${media.sipPt}`); webrtcLink(sessionId, callId, media.addr, media.port, media.sipPt).then((ok) => { log(`[webrtc] link result: ${ok}`); }); } else { log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`); } }, ); // --------------------------------------------------------------------------- // Start // --------------------------------------------------------------------------- startProxyEngine(); process.on('SIGINT', () => { log('SIGINT, exiting'); shutdownProxyEngine(); process.exit(0); }); process.on('SIGTERM', () => { log('SIGTERM, exiting'); shutdownProxyEngine(); process.exit(0); });