678 lines
22 KiB
TypeScript
678 lines
22 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 { 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<string, unknown>;
|
|
}
|
|
|
|
interface IActiveCall {
|
|
id: string;
|
|
direction: string;
|
|
callerNumber: string | null;
|
|
calleeNumber: string | null;
|
|
providerUsed: string | null;
|
|
state: string;
|
|
startedAt: number;
|
|
legs: Map<string, IActiveLeg>;
|
|
}
|
|
|
|
interface IHistoryLeg {
|
|
id: string;
|
|
type: string;
|
|
metadata: Record<string, unknown>;
|
|
}
|
|
|
|
interface ICallHistoryEntry {
|
|
id: string;
|
|
direction: string;
|
|
callerNumber: string | null;
|
|
calleeNumber: string | null;
|
|
startedAt: number;
|
|
duration: number;
|
|
legs: IHistoryLeg[];
|
|
}
|
|
|
|
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;
|
|
|
|
// WebRTC session ↔ call linking state.
|
|
// Both pieces (session accept + call media info) can arrive in any order.
|
|
const webrtcSessionToCall = new Map<string, string>(); // sessionId → callId
|
|
const webrtcCallToSession = new Map<string, string>(); // callId → sessionId
|
|
const pendingCallMedia = new Map<string, { addr: string; port: number; sipPt: number }>(); // 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<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(),
|
|
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<number, string> = { 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); });
|