392 lines
12 KiB
TypeScript
392 lines
12 KiB
TypeScript
/**
|
|
* 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<string, IProviderStatus>();
|
|
const deviceStatuses = new Map<string, IDeviceStatus>();
|
|
const activeCalls = new Map<string, IActiveCall>();
|
|
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<void> {
|
|
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); });
|