/** * 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 { initCodecBridge } from './opusbridge.ts'; import { initAnnouncement } from './announcement.ts'; import { PromptCache } from './call/prompt-cache.ts'; import { VoiceboxManager } from './voicebox.ts'; import { initProxyEngine, configureProxyEngine, onProxyEvent, hangupCall, shutdownProxyEngine, } 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 IActiveCall { id: string; direction: string; callerNumber: string | null; calleeNumber: string | null; providerUsed: string | null; state: string; startedAt: number; } interface ICallHistoryEntry { id: string; direction: string; callerNumber: string | null; calleeNumber: string | null; startedAt: number; duration: number; } const providerStatuses = new Map(); const deviceStatuses = new Map(); const activeCalls = new Map(); const callHistory: ICallHistoryEntry[] = []; const MAX_HISTORY = 100; // 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() { return { instanceId, uptime: Math.floor((Date.now() - startTime) / 1000), lanIp: appConfig.proxy.lanIp, providers: [...providerStatuses.values()], devices: [...deviceStatuses.values()], calls: [...activeCalls.values()].map((c) => ({ ...c, duration: Math.floor((Date.now() - c.startedAt) / 1000), legs: [], })), 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(), }); // 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(), }); }); 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 }) => { const call = activeCalls.get(data.call_id); if (call) { call.state = 'connected'; log(`[call] ${data.call_id} connected`); } }); 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)`); // Move to history. callHistory.unshift({ id: call.id, direction: call.direction, callerNumber: call.callerNumber, calleeNumber: call.calleeNumber, startedAt: call.startedAt, duration: data.duration, }); if (callHistory.length > MAX_HISTORY) callHistory.pop(); activeCalls.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}`); }); // 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}`); // Initialize audio codec bridge (still needed for WebRTC transcoding). try { await initCodecBridge(log); 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(`[codec] init failed: ${e}`); } } // --------------------------------------------------------------------------- // Web UI // --------------------------------------------------------------------------- initWebUi( getStatus, log, (number, _deviceId, _providerId) => { // Outbound calls from dashboard — send make_call command to Rust. // For now, log only. Full implementation needs make_call in Rust. log(`[dashboard] start call requested: ${number}`); // TODO: send make_call command when implemented in Rust return null; }, (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 — WebRTC calls handled separately in Phase 2 voiceboxManager, ); // --------------------------------------------------------------------------- // Start // --------------------------------------------------------------------------- startProxyEngine(); process.on('SIGINT', () => { log('SIGINT, exiting'); shutdownProxyEngine(); process.exit(0); }); process.on('SIGTERM', () => { log('SIGTERM, exiting'); shutdownProxyEngine(); process.exit(0); });