/** * Proxy engine bridge — manages the Rust proxy-engine subprocess. * * The proxy-engine handles ALL SIP protocol mechanics. TypeScript only: * - Sends configuration * - Receives high-level events (incoming_call, call_ended, etc.) * - Sends high-level commands (hangup, make_call, play_audio) * * No raw SIP ever touches TypeScript. */ import path from 'node:path'; import { RustBridge } from '@push.rocks/smartrust'; // --------------------------------------------------------------------------- // Command type map for smartrust // --------------------------------------------------------------------------- type TProxyCommands = { configure: { params: Record; result: { bound: string }; }; hangup: { params: { call_id: string }; result: Record; }; make_call: { params: { number: string; device_id?: string; provider_id?: string }; result: { call_id: string }; }; play_audio: { params: { call_id: string; leg_id?: string; file_path: string; codec?: number }; result: Record; }; start_recording: { params: { call_id: string; file_path: string; max_duration_ms?: number }; result: Record; }; stop_recording: { params: { call_id: string }; result: { file_path: string; duration_ms: number }; }; add_device_leg: { params: { call_id: string; device_id: string }; result: { leg_id: string }; }; transfer_leg: { params: { source_call_id: string; leg_id: string; target_call_id: string }; result: Record; }; replace_leg: { params: { call_id: string; old_leg_id: string; number: string; provider_id?: string }; result: { new_leg_id: string }; }; start_interaction: { params: { call_id: string; leg_id: string; prompt_wav: string; expected_digits: string; timeout_ms: number; }; result: { result: 'digit' | 'timeout' | 'cancelled'; digit?: string }; }; add_tool_leg: { params: { call_id: string; tool_type: 'recording' | 'transcription'; config?: Record; }; result: { tool_leg_id: string }; }; remove_tool_leg: { params: { call_id: string; tool_leg_id: string }; result: Record; }; set_leg_metadata: { params: { call_id: string; leg_id: string; key: string; value: unknown }; result: Record; }; generate_tts: { params: { model: string; voices: string; voice: string; text: string; output: string }; result: { output: string }; }; }; // --------------------------------------------------------------------------- // Event types from Rust // --------------------------------------------------------------------------- export interface IIncomingCallEvent { call_id: string; from_uri: string; to_number: string; provider_id: string; } export interface IOutboundCallEvent { call_id: string; from_device: string | null; to_number: string; } export interface ICallEndedEvent { call_id: string; reason: string; duration: number; from_side?: string; } export interface IProviderRegisteredEvent { provider_id: string; registered: boolean; public_ip: string | null; } export interface IDeviceRegisteredEvent { device_id: string; display_name: string; address: string; port: number; aor: string; expires: number; } // --------------------------------------------------------------------------- // Bridge singleton // --------------------------------------------------------------------------- let bridge: RustBridge | null = null; let initialized = false; let logFn: ((msg: string) => void) | undefined; function buildLocalPaths(): string[] { const root = process.cwd(); return [ path.join(root, 'dist_rust', 'proxy-engine'), path.join(root, 'rust', 'target', 'release', 'proxy-engine'), path.join(root, 'rust', 'target', 'debug', 'proxy-engine'), ]; } /** * Initialize the proxy engine — spawn the Rust binary. * Call configure() separately to push config and start SIP. */ export async function initProxyEngine(log?: (msg: string) => void): Promise { if (initialized && bridge) return true; logFn = log; try { bridge = new RustBridge({ binaryName: 'proxy-engine', localPaths: buildLocalPaths(), }); const spawned = await bridge.spawn(); if (!spawned) { log?.('[proxy-engine] failed to spawn binary'); bridge = null; return false; } bridge.on('exit', () => { logFn?.('[proxy-engine] process exited — will need re-init'); bridge = null; initialized = false; }); // Forward stderr for debugging. bridge.on('stderr', (line: string) => { logFn?.(`[proxy-engine:stderr] ${line}`); }); initialized = true; log?.('[proxy-engine] spawned and ready'); return true; } catch (e: any) { log?.(`[proxy-engine] init error: ${e.message}`); bridge = null; return false; } } /** * Send the full app config to the proxy engine. * This binds the SIP socket, starts provider registrations, etc. */ export async function configureProxyEngine(config: Record): Promise { if (!bridge || !initialized) return false; try { const result = await bridge.sendCommand('configure', config as any); logFn?.(`[proxy-engine] configured, SIP bound on ${(result as any)?.bound || '?'}`); return true; } catch (e: any) { logFn?.(`[proxy-engine] configure error: ${e.message}`); return false; } } /** * Initiate an outbound call via Rust. Returns the call ID or null on failure. */ export async function makeCall(number: string, deviceId?: string, providerId?: string): Promise { if (!bridge || !initialized) return null; try { const result = await bridge.sendCommand('make_call', { number, device_id: deviceId, provider_id: providerId, } as any); return (result as any)?.call_id || null; } catch (e: any) { logFn?.(`[proxy-engine] make_call error: ${e?.message || e}`); return null; } } /** * Send a hangup command. */ export async function hangupCall(callId: string): Promise { if (!bridge || !initialized) return false; try { await bridge.sendCommand('hangup', { call_id: callId } as any); return true; } catch { return false; } } /** * Send a WebRTC offer to the proxy engine. Returns the SDP answer. */ export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp: string } | null> { if (!bridge || !initialized) return null; try { const result = await bridge.sendCommand('webrtc_offer', { session_id: sessionId, sdp } as any); return result as any; } catch (e: any) { logFn?.(`[proxy-engine] webrtc_offer error: ${e?.message || e}`); return null; } } /** * Forward an ICE candidate to the proxy engine. */ export async function webrtcIce(sessionId: string, candidate: any): Promise { if (!bridge || !initialized) return; try { await bridge.sendCommand('webrtc_ice', { session_id: sessionId, candidate: candidate?.candidate || candidate, sdp_mid: candidate?.sdpMid, sdp_mline_index: candidate?.sdpMLineIndex, } as any); } catch { /* ignore */ } } /** * Link a WebRTC session to a SIP call — enables audio bridging. * The browser's Opus audio will be transcoded and sent to the provider. */ export async function webrtcLink(sessionId: string, callId: string, providerMediaAddr: string, providerMediaPort: number, sipPt: number = 9): Promise { if (!bridge || !initialized) return false; try { await bridge.sendCommand('webrtc_link', { session_id: sessionId, call_id: callId, provider_media_addr: providerMediaAddr, provider_media_port: providerMediaPort, sip_pt: sipPt, } as any); return true; } catch (e: any) { logFn?.(`[proxy-engine] webrtc_link error: ${e?.message || e}`); return false; } } /** * Add an external SIP leg to an existing call (multiparty). */ export async function addLeg(callId: string, number: string, providerId?: string): Promise { if (!bridge || !initialized) return null; try { const result = await bridge.sendCommand('add_leg', { call_id: callId, number, provider_id: providerId, } as any); return (result as any)?.leg_id || null; } catch (e: any) { logFn?.(`[proxy-engine] add_leg error: ${e?.message || e}`); return null; } } /** * Remove a leg from a call. */ export async function removeLeg(callId: string, legId: string): Promise { if (!bridge || !initialized) return false; try { await bridge.sendCommand('remove_leg', { call_id: callId, leg_id: legId } as any); return true; } catch (e: any) { logFn?.(`[proxy-engine] remove_leg error: ${e?.message || e}`); return false; } } /** * Close a WebRTC session. */ export async function webrtcClose(sessionId: string): Promise { if (!bridge || !initialized) return; try { await bridge.sendCommand('webrtc_close', { session_id: sessionId } as any); } catch { /* ignore */ } } // --------------------------------------------------------------------------- // Device leg & interaction commands // --------------------------------------------------------------------------- /** * Add a local SIP device to an existing call (mid-call INVITE to desk phone). */ export async function addDeviceLeg(callId: string, deviceId: string): Promise { if (!bridge || !initialized) return null; try { const result = await bridge.sendCommand('add_device_leg', { call_id: callId, device_id: deviceId, } as any); return (result as any)?.leg_id || null; } catch (e: any) { logFn?.(`[proxy-engine] add_device_leg error: ${e?.message || e}`); return null; } } /** * Transfer a leg from one call to another (leg stays connected, switches mixer). */ export async function transferLeg( sourceCallId: string, legId: string, targetCallId: string, ): Promise { if (!bridge || !initialized) return false; try { await bridge.sendCommand('transfer_leg', { source_call_id: sourceCallId, leg_id: legId, target_call_id: targetCallId, } as any); return true; } catch (e: any) { logFn?.(`[proxy-engine] transfer_leg error: ${e?.message || e}`); return false; } } /** * Replace a leg: terminate the old leg and dial a new number into the same call. */ export async function replaceLeg( callId: string, oldLegId: string, number: string, providerId?: string, ): Promise { if (!bridge || !initialized) return null; try { const result = await bridge.sendCommand('replace_leg', { call_id: callId, old_leg_id: oldLegId, number, provider_id: providerId, } as any); return (result as any)?.new_leg_id || null; } catch (e: any) { logFn?.(`[proxy-engine] replace_leg error: ${e?.message || e}`); return null; } } /** * Start an interaction on a specific leg — isolate it, play a prompt, collect DTMF. * Blocks until the interaction completes (digit pressed, timeout, or cancelled). */ export async function startInteraction( callId: string, legId: string, promptWav: string, expectedDigits: string, timeoutMs: number, ): Promise<{ result: 'digit' | 'timeout' | 'cancelled'; digit?: string } | null> { if (!bridge || !initialized) return null; try { const result = await bridge.sendCommand('start_interaction', { call_id: callId, leg_id: legId, prompt_wav: promptWav, expected_digits: expectedDigits, timeout_ms: timeoutMs, } as any); return result as any; } catch (e: any) { logFn?.(`[proxy-engine] start_interaction error: ${e?.message || e}`); return null; } } /** * Add a tool leg (recording or transcription) to a call. * Tool legs receive per-source unmerged audio from all participants. */ export async function addToolLeg( callId: string, toolType: 'recording' | 'transcription', config?: Record, ): Promise { if (!bridge || !initialized) return null; try { const result = await bridge.sendCommand('add_tool_leg', { call_id: callId, tool_type: toolType, config, } as any); return (result as any)?.tool_leg_id || null; } catch (e: any) { logFn?.(`[proxy-engine] add_tool_leg error: ${e?.message || e}`); return null; } } /** * Remove a tool leg from a call. Triggers finalization (WAV files, metadata). */ export async function removeToolLeg(callId: string, toolLegId: string): Promise { if (!bridge || !initialized) return false; try { await bridge.sendCommand('remove_tool_leg', { call_id: callId, tool_leg_id: toolLegId, } as any); return true; } catch (e: any) { logFn?.(`[proxy-engine] remove_tool_leg error: ${e?.message || e}`); return false; } } /** * Set a metadata key-value pair on a leg. */ export async function setLegMetadata( callId: string, legId: string, key: string, value: unknown, ): Promise { if (!bridge || !initialized) return false; try { await bridge.sendCommand('set_leg_metadata', { call_id: callId, leg_id: legId, key, value, } as any); return true; } catch (e: any) { logFn?.(`[proxy-engine] set_leg_metadata error: ${e?.message || e}`); return false; } } /** * Subscribe to an event from the proxy engine. * Event names: incoming_call, outbound_device_call, call_ringing, * call_answered, call_ended, provider_registered, device_registered, * dtmf_digit, recording_done, tool_recording_done, tool_transcription_done, * leg_added, leg_removed, sip_unhandled */ export function onProxyEvent(event: string, handler: (data: any) => void): void { if (!bridge) throw new Error('proxy engine not initialized'); bridge.on(`management:${event}`, handler); } /** Check if the proxy engine is ready. */ export function isProxyReady(): boolean { return initialized && bridge !== null; } /** Send an arbitrary command to the proxy engine bridge. */ export async function sendProxyCommand( method: K, params: TProxyCommands[K]['params'], ): Promise { if (!bridge || !initialized) throw new Error('proxy engine not initialized'); return bridge.sendCommand(method as string, params as any) as any; } /** Shut down the proxy engine. */ export function shutdownProxyEngine(): void { if (bridge) { try { bridge.kill(); } catch { /* ignore */ } bridge = null; initialized = false; } }