feat(runtime): refactor runtime state and proxy event handling for typed WebRTC linking and shared status models

This commit is contained in:
2026-04-14 10:45:59 +00:00
parent 5a280c5c41
commit 51f7560730
15 changed files with 1105 additions and 813 deletions

View File

@@ -1,5 +1,14 @@
# Changelog # Changelog
## 2026-04-14 - 1.23.0 - feat(runtime)
refactor runtime state and proxy event handling for typed WebRTC linking and shared status models
- extract proxy event handling into dedicated runtime modules for status tracking and WebRTC session-to-call linking
- introduce shared typed proxy event and status interfaces used by both backend and web UI
- update web UI server initialization to use structured options and await async config save hooks
- simplify browser signaling by routing WebRTC offer/ICE handling through frontend-to-Rust integration
- align device status rendering with the new address/port fields in dashboard views
## 2026-04-12 - 1.22.0 - feat(proxy-engine) ## 2026-04-12 - 1.22.0 - feat(proxy-engine)
add on-demand TTS caching for voicemail and IVR prompts add on-demand TTS caching for voicemail and IVR prompts

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: 'siprouter', name: 'siprouter',
version: '1.22.0', version: '1.23.0',
description: 'undefined' description: 'undefined'
} }

View File

@@ -14,12 +14,36 @@ import { WebSocketServer, WebSocket } from 'ws';
import { handleWebRtcSignaling } from './webrtcbridge.ts'; import { handleWebRtcSignaling } from './webrtcbridge.ts';
import type { VoiceboxManager } from './voicebox.ts'; import type { VoiceboxManager } from './voicebox.ts';
// CallManager was previously used for WebRTC call handling. Now replaced by Rust proxy-engine.
// Kept as `any` type for backward compat with the function signature until full WebRTC port.
type CallManager = any;
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json'); const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
interface IHandleRequestContext {
getStatus: () => unknown;
log: (msg: string) => void;
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null;
onHangupCall: (callId: string) => boolean;
onConfigSaved?: () => void | Promise<void>;
voiceboxManager?: VoiceboxManager;
}
interface IWebUiOptions extends IHandleRequestContext {
port: number;
onWebRtcOffer?: (sessionId: string, sdp: string, ws: WebSocket) => Promise<void>;
onWebRtcIce?: (sessionId: string, candidate: unknown) => Promise<void>;
onWebRtcClose?: (sessionId: string) => Promise<void>;
onWebRtcAccept?: (callId: string, sessionId: string) => void;
}
interface IWebRtcSocketMessage {
type?: string;
sessionId?: string;
callId?: string;
sdp?: string;
candidate?: unknown;
userAgent?: string;
_remoteIp?: string | null;
[key: string]: unknown;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// WebSocket broadcast // WebSocket broadcast
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -82,14 +106,9 @@ function loadStaticFiles(): void {
async function handleRequest( async function handleRequest(
req: http.IncomingMessage, req: http.IncomingMessage,
res: http.ServerResponse, res: http.ServerResponse,
getStatus: () => unknown, context: IHandleRequestContext,
log: (msg: string) => void,
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null,
onHangupCall: (callId: string) => boolean,
onConfigSaved?: () => void,
callManager?: CallManager,
voiceboxManager?: VoiceboxManager,
): Promise<void> { ): Promise<void> {
const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager } = context;
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const method = req.method || 'GET'; const method = req.method || 'GET';
@@ -258,7 +277,7 @@ async function handleRequest(
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n'); fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
log('[config] updated config.json'); log('[config] updated config.json');
onConfigSaved?.(); await onConfigSaved?.();
return sendJson(res, { ok: true }); return sendJson(res, { ok: true });
} catch (e: any) { } catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400); return sendJson(res, { ok: false, error: e.message }, 400);
@@ -339,21 +358,21 @@ async function handleRequest(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function initWebUi( export function initWebUi(
getStatus: () => unknown, options: IWebUiOptions,
log: (msg: string) => void,
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null,
onHangupCall: (callId: string) => boolean,
onConfigSaved?: () => void,
callManager?: CallManager,
voiceboxManager?: VoiceboxManager,
/** WebRTC signaling handlers — forwarded to Rust proxy-engine. */
onWebRtcOffer?: (sessionId: string, sdp: string, ws: WebSocket) => Promise<void>,
onWebRtcIce?: (sessionId: string, candidate: any) => Promise<void>,
onWebRtcClose?: (sessionId: string) => Promise<void>,
/** Called when browser sends webrtc-accept (callId + sessionId linking). */
onWebRtcAccept?: (callId: string, sessionId: string) => void,
): void { ): void {
const WEB_PORT = 3060; const {
port,
getStatus,
log,
onStartCall,
onHangupCall,
onConfigSaved,
voiceboxManager,
onWebRtcOffer,
onWebRtcIce,
onWebRtcClose,
onWebRtcAccept,
} = options;
loadStaticFiles(); loadStaticFiles();
@@ -367,12 +386,12 @@ export function initWebUi(
const cert = fs.readFileSync(certPath, 'utf8'); const cert = fs.readFileSync(certPath, 'utf8');
const key = fs.readFileSync(keyPath, 'utf8'); const key = fs.readFileSync(keyPath, 'utf8');
server = https.createServer({ cert, key }, (req, res) => server = https.createServer({ cert, key }, (req, res) =>
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager, voiceboxManager).catch(() => { res.writeHead(500); res.end(); }), handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
); );
useTls = true; useTls = true;
} catch { } catch {
server = http.createServer((req, res) => server = http.createServer((req, res) =>
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager, voiceboxManager).catch(() => { res.writeHead(500); res.end(); }), handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
); );
} }
@@ -386,12 +405,12 @@ export function initWebUi(
socket.on('message', (raw) => { socket.on('message', (raw) => {
try { try {
const msg = JSON.parse(raw.toString()); const msg = JSON.parse(raw.toString()) as IWebRtcSocketMessage;
if (msg.type === 'webrtc-offer' && msg.sessionId) { if (msg.type === 'webrtc-offer' && msg.sessionId) {
// Forward to Rust proxy-engine for WebRTC handling. // Forward to Rust proxy-engine for WebRTC handling.
if (onWebRtcOffer) { if (onWebRtcOffer && typeof msg.sdp === 'string') {
log(`[webrtc-ws] offer msg keys: ${Object.keys(msg).join(',')}, sdp type: ${typeof msg.sdp}, sdp len: ${msg.sdp?.length || 0}`); log(`[webrtc-ws] offer msg keys: ${Object.keys(msg).join(',')}, sdp type: ${typeof msg.sdp}, sdp len: ${msg.sdp?.length || 0}`);
onWebRtcOffer(msg.sessionId, msg.sdp, socket as any).catch((e: any) => onWebRtcOffer(msg.sessionId, msg.sdp, socket).catch((e: any) =>
log(`[webrtc] offer error: ${e.message}`)); log(`[webrtc] offer error: ${e.message}`));
} }
} else if (msg.type === 'webrtc-ice' && msg.sessionId) { } else if (msg.type === 'webrtc-ice' && msg.sessionId) {
@@ -409,7 +428,7 @@ export function initWebUi(
} }
} else if (msg.type?.startsWith('webrtc-')) { } else if (msg.type?.startsWith('webrtc-')) {
msg._remoteIp = remoteIp; msg._remoteIp = remoteIp;
handleWebRtcSignaling(socket as any, msg); handleWebRtcSignaling(socket, msg);
} }
} catch { /* ignore */ } } catch { /* ignore */ }
}); });
@@ -418,8 +437,8 @@ export function initWebUi(
socket.on('error', () => wsClients.delete(socket)); socket.on('error', () => wsClients.delete(socket));
}); });
server.listen(WEB_PORT, '0.0.0.0', () => { server.listen(port, '0.0.0.0', () => {
log(`web ui listening on ${useTls ? 'https' : 'http'}://0.0.0.0:${WEB_PORT}`); log(`web ui listening on ${useTls ? 'https' : 'http'}://0.0.0.0:${port}`);
}); });
setInterval(() => broadcastWs('status', getStatus()), 1000); setInterval(() => broadcastWs('status', getStatus()), 1000);

View File

@@ -4,13 +4,36 @@
* The proxy-engine handles ALL SIP protocol mechanics. TypeScript only: * The proxy-engine handles ALL SIP protocol mechanics. TypeScript only:
* - Sends configuration * - Sends configuration
* - Receives high-level events (incoming_call, call_ended, etc.) * - Receives high-level events (incoming_call, call_ended, etc.)
* - Sends high-level commands (hangup, make_call, play_audio) * - Sends high-level commands (hangup, make_call, add_leg, webrtc_offer)
* *
* No raw SIP ever touches TypeScript. * No raw SIP ever touches TypeScript.
*/ */
import path from 'node:path'; import path from 'node:path';
import { RustBridge } from '@push.rocks/smartrust'; import { RustBridge } from '@push.rocks/smartrust';
import type { TProxyEventMap } from './shared/proxy-events.ts';
export type {
ICallAnsweredEvent,
ICallEndedEvent,
ICallRingingEvent,
IDeviceRegisteredEvent,
IIncomingCallEvent,
ILegAddedEvent,
ILegRemovedEvent,
ILegStateChangedEvent,
IOutboundCallEvent,
IOutboundCallStartedEvent,
IProviderRegisteredEvent,
IRecordingDoneEvent,
ISipUnhandledEvent,
IVoicemailErrorEvent,
IVoicemailStartedEvent,
IWebRtcAudioRxEvent,
IWebRtcIceCandidateEvent,
IWebRtcStateEvent,
IWebRtcTrackEvent,
TProxyEventMap,
} from './shared/proxy-events.ts';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Command type map for smartrust // Command type map for smartrust
@@ -29,18 +52,6 @@ type TProxyCommands = {
params: { number: string; device_id?: string; provider_id?: string }; params: { number: string; device_id?: string; provider_id?: string };
result: { call_id: string }; result: { call_id: string };
}; };
play_audio: {
params: { call_id: string; leg_id?: string; file_path: string; codec?: number };
result: Record<string, never>;
};
start_recording: {
params: { call_id: string; file_path: string; max_duration_ms?: number };
result: Record<string, never>;
};
stop_recording: {
params: { call_id: string };
result: { file_path: string; duration_ms: number };
};
add_leg: { add_leg: {
params: { call_id: string; number: string; provider_id?: string }; params: { call_id: string; number: string; provider_id?: string };
result: { leg_id: string }; result: { leg_id: string };
@@ -121,50 +132,6 @@ type TProxyCommands = {
}; };
}; };
// ---------------------------------------------------------------------------
// Event types from Rust
// ---------------------------------------------------------------------------
export interface IIncomingCallEvent {
call_id: string;
from_uri: string;
to_number: string;
provider_id: string;
/** Whether registered browsers should see a `webrtc-incoming` toast for
* this call. Set by the Rust engine from the matched inbound route's
* `ringBrowsers` flag (defaults to `true` when no route matches, so
* deployments without explicit routes preserve pre-routing behavior). */
ring_browsers?: boolean;
}
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 // Bridge singleton
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -173,6 +140,16 @@ let bridge: RustBridge<TProxyCommands> | null = null;
let initialized = false; let initialized = false;
let logFn: ((msg: string) => void) | undefined; let logFn: ((msg: string) => void) | undefined;
type TWebRtcIceCandidate = {
candidate?: string;
sdpMid?: string;
sdpMLineIndex?: number;
} | string;
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function buildLocalPaths(): string[] { function buildLocalPaths(): string[] {
const root = process.cwd(); const root = process.cwd();
// Map Node's process.arch to tsrust's friendly target name. // Map Node's process.arch to tsrust's friendly target name.
@@ -231,8 +208,8 @@ export async function initProxyEngine(log?: (msg: string) => void): Promise<bool
initialized = true; initialized = true;
log?.('[proxy-engine] spawned and ready'); log?.('[proxy-engine] spawned and ready');
return true; return true;
} catch (e: any) { } catch (error: unknown) {
log?.(`[proxy-engine] init error: ${e.message}`); log?.(`[proxy-engine] init error: ${errorMessage(error)}`);
bridge = null; bridge = null;
return false; return false;
} }
@@ -242,14 +219,14 @@ export async function initProxyEngine(log?: (msg: string) => void): Promise<bool
* Send the full app config to the proxy engine. * Send the full app config to the proxy engine.
* This binds the SIP socket, starts provider registrations, etc. * This binds the SIP socket, starts provider registrations, etc.
*/ */
export async function configureProxyEngine(config: Record<string, unknown>): Promise<boolean> { export async function configureProxyEngine(config: TProxyCommands['configure']['params']): Promise<boolean> {
if (!bridge || !initialized) return false; if (!bridge || !initialized) return false;
try { try {
const result = await bridge.sendCommand('configure', config as any); const result = await sendProxyCommand('configure', config);
logFn?.(`[proxy-engine] configured, SIP bound on ${(result as any)?.bound || '?'}`); logFn?.(`[proxy-engine] configured, SIP bound on ${result.bound || '?'}`);
return true; return true;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] configure error: ${e.message}`); logFn?.(`[proxy-engine] configure error: ${errorMessage(error)}`);
return false; return false;
} }
} }
@@ -260,14 +237,14 @@ export async function configureProxyEngine(config: Record<string, unknown>): Pro
export async function makeCall(number: string, deviceId?: string, providerId?: string): Promise<string | null> { export async function makeCall(number: string, deviceId?: string, providerId?: string): Promise<string | null> {
if (!bridge || !initialized) return null; if (!bridge || !initialized) return null;
try { try {
const result = await bridge.sendCommand('make_call', { const result = await sendProxyCommand('make_call', {
number, number,
device_id: deviceId, device_id: deviceId,
provider_id: providerId, provider_id: providerId,
} as any); });
return (result as any)?.call_id || null; return result.call_id || null;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] make_call error: ${e?.message || e}`); logFn?.(`[proxy-engine] make_call error: ${errorMessage(error)}`);
return null; return null;
} }
} }
@@ -278,7 +255,7 @@ export async function makeCall(number: string, deviceId?: string, providerId?: s
export async function hangupCall(callId: string): Promise<boolean> { export async function hangupCall(callId: string): Promise<boolean> {
if (!bridge || !initialized) return false; if (!bridge || !initialized) return false;
try { try {
await bridge.sendCommand('hangup', { call_id: callId } as any); await sendProxyCommand('hangup', { call_id: callId });
return true; return true;
} catch { } catch {
return false; return false;
@@ -291,10 +268,9 @@ export async function hangupCall(callId: string): Promise<boolean> {
export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp: string } | null> { export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp: string } | null> {
if (!bridge || !initialized) return null; if (!bridge || !initialized) return null;
try { try {
const result = await bridge.sendCommand('webrtc_offer', { session_id: sessionId, sdp } as any); return await sendProxyCommand('webrtc_offer', { session_id: sessionId, sdp });
return result as any; } catch (error: unknown) {
} catch (e: any) { logFn?.(`[proxy-engine] webrtc_offer error: ${errorMessage(error)}`);
logFn?.(`[proxy-engine] webrtc_offer error: ${e?.message || e}`);
return null; return null;
} }
} }
@@ -302,15 +278,15 @@ export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp
/** /**
* Forward an ICE candidate to the proxy engine. * Forward an ICE candidate to the proxy engine.
*/ */
export async function webrtcIce(sessionId: string, candidate: any): Promise<void> { export async function webrtcIce(sessionId: string, candidate: TWebRtcIceCandidate): Promise<void> {
if (!bridge || !initialized) return; if (!bridge || !initialized) return;
try { try {
await bridge.sendCommand('webrtc_ice', { await sendProxyCommand('webrtc_ice', {
session_id: sessionId, session_id: sessionId,
candidate: candidate?.candidate || candidate, candidate: typeof candidate === 'string' ? candidate : candidate.candidate || '',
sdp_mid: candidate?.sdpMid, sdp_mid: typeof candidate === 'string' ? undefined : candidate.sdpMid,
sdp_mline_index: candidate?.sdpMLineIndex, sdp_mline_index: typeof candidate === 'string' ? undefined : candidate.sdpMLineIndex,
} as any); });
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -321,16 +297,16 @@ export async function webrtcIce(sessionId: string, candidate: any): Promise<void
export async function webrtcLink(sessionId: string, callId: string, providerMediaAddr: string, providerMediaPort: number, sipPt: number = 9): Promise<boolean> { export async function webrtcLink(sessionId: string, callId: string, providerMediaAddr: string, providerMediaPort: number, sipPt: number = 9): Promise<boolean> {
if (!bridge || !initialized) return false; if (!bridge || !initialized) return false;
try { try {
await bridge.sendCommand('webrtc_link', { await sendProxyCommand('webrtc_link', {
session_id: sessionId, session_id: sessionId,
call_id: callId, call_id: callId,
provider_media_addr: providerMediaAddr, provider_media_addr: providerMediaAddr,
provider_media_port: providerMediaPort, provider_media_port: providerMediaPort,
sip_pt: sipPt, sip_pt: sipPt,
} as any); });
return true; return true;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] webrtc_link error: ${e?.message || e}`); logFn?.(`[proxy-engine] webrtc_link error: ${errorMessage(error)}`);
return false; return false;
} }
} }
@@ -341,14 +317,14 @@ export async function webrtcLink(sessionId: string, callId: string, providerMedi
export async function addLeg(callId: string, number: string, providerId?: string): Promise<string | null> { export async function addLeg(callId: string, number: string, providerId?: string): Promise<string | null> {
if (!bridge || !initialized) return null; if (!bridge || !initialized) return null;
try { try {
const result = await bridge.sendCommand('add_leg', { const result = await sendProxyCommand('add_leg', {
call_id: callId, call_id: callId,
number, number,
provider_id: providerId, provider_id: providerId,
} as any); });
return (result as any)?.leg_id || null; return result.leg_id || null;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] add_leg error: ${e?.message || e}`); logFn?.(`[proxy-engine] add_leg error: ${errorMessage(error)}`);
return null; return null;
} }
} }
@@ -359,10 +335,10 @@ export async function addLeg(callId: string, number: string, providerId?: string
export async function removeLeg(callId: string, legId: string): Promise<boolean> { export async function removeLeg(callId: string, legId: string): Promise<boolean> {
if (!bridge || !initialized) return false; if (!bridge || !initialized) return false;
try { try {
await bridge.sendCommand('remove_leg', { call_id: callId, leg_id: legId } as any); await sendProxyCommand('remove_leg', { call_id: callId, leg_id: legId });
return true; return true;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] remove_leg error: ${e?.message || e}`); logFn?.(`[proxy-engine] remove_leg error: ${errorMessage(error)}`);
return false; return false;
} }
} }
@@ -373,7 +349,7 @@ export async function removeLeg(callId: string, legId: string): Promise<boolean>
export async function webrtcClose(sessionId: string): Promise<void> { export async function webrtcClose(sessionId: string): Promise<void> {
if (!bridge || !initialized) return; if (!bridge || !initialized) return;
try { try {
await bridge.sendCommand('webrtc_close', { session_id: sessionId } as any); await sendProxyCommand('webrtc_close', { session_id: sessionId });
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -387,13 +363,13 @@ export async function webrtcClose(sessionId: string): Promise<void> {
export async function addDeviceLeg(callId: string, deviceId: string): Promise<string | null> { export async function addDeviceLeg(callId: string, deviceId: string): Promise<string | null> {
if (!bridge || !initialized) return null; if (!bridge || !initialized) return null;
try { try {
const result = await bridge.sendCommand('add_device_leg', { const result = await sendProxyCommand('add_device_leg', {
call_id: callId, call_id: callId,
device_id: deviceId, device_id: deviceId,
} as any); });
return (result as any)?.leg_id || null; return result.leg_id || null;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] add_device_leg error: ${e?.message || e}`); logFn?.(`[proxy-engine] add_device_leg error: ${errorMessage(error)}`);
return null; return null;
} }
} }
@@ -408,14 +384,14 @@ export async function transferLeg(
): Promise<boolean> { ): Promise<boolean> {
if (!bridge || !initialized) return false; if (!bridge || !initialized) return false;
try { try {
await bridge.sendCommand('transfer_leg', { await sendProxyCommand('transfer_leg', {
source_call_id: sourceCallId, source_call_id: sourceCallId,
leg_id: legId, leg_id: legId,
target_call_id: targetCallId, target_call_id: targetCallId,
} as any); });
return true; return true;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] transfer_leg error: ${e?.message || e}`); logFn?.(`[proxy-engine] transfer_leg error: ${errorMessage(error)}`);
return false; return false;
} }
} }
@@ -431,15 +407,15 @@ export async function replaceLeg(
): Promise<string | null> { ): Promise<string | null> {
if (!bridge || !initialized) return null; if (!bridge || !initialized) return null;
try { try {
const result = await bridge.sendCommand('replace_leg', { const result = await sendProxyCommand('replace_leg', {
call_id: callId, call_id: callId,
old_leg_id: oldLegId, old_leg_id: oldLegId,
number, number,
provider_id: providerId, provider_id: providerId,
} as any); });
return (result as any)?.new_leg_id || null; return result.new_leg_id || null;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] replace_leg error: ${e?.message || e}`); logFn?.(`[proxy-engine] replace_leg error: ${errorMessage(error)}`);
return null; return null;
} }
} }
@@ -457,16 +433,15 @@ export async function startInteraction(
): Promise<{ result: 'digit' | 'timeout' | 'cancelled'; digit?: string } | null> { ): Promise<{ result: 'digit' | 'timeout' | 'cancelled'; digit?: string } | null> {
if (!bridge || !initialized) return null; if (!bridge || !initialized) return null;
try { try {
const result = await bridge.sendCommand('start_interaction', { return await sendProxyCommand('start_interaction', {
call_id: callId, call_id: callId,
leg_id: legId, leg_id: legId,
prompt_wav: promptWav, prompt_wav: promptWav,
expected_digits: expectedDigits, expected_digits: expectedDigits,
timeout_ms: timeoutMs, timeout_ms: timeoutMs,
} as any); });
return result as any; } catch (error: unknown) {
} catch (e: any) { logFn?.(`[proxy-engine] start_interaction error: ${errorMessage(error)}`);
logFn?.(`[proxy-engine] start_interaction error: ${e?.message || e}`);
return null; return null;
} }
} }
@@ -482,14 +457,14 @@ export async function addToolLeg(
): Promise<string | null> { ): Promise<string | null> {
if (!bridge || !initialized) return null; if (!bridge || !initialized) return null;
try { try {
const result = await bridge.sendCommand('add_tool_leg', { const result = await sendProxyCommand('add_tool_leg', {
call_id: callId, call_id: callId,
tool_type: toolType, tool_type: toolType,
config, config,
} as any); });
return (result as any)?.tool_leg_id || null; return result.tool_leg_id || null;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] add_tool_leg error: ${e?.message || e}`); logFn?.(`[proxy-engine] add_tool_leg error: ${errorMessage(error)}`);
return null; return null;
} }
} }
@@ -500,13 +475,13 @@ export async function addToolLeg(
export async function removeToolLeg(callId: string, toolLegId: string): Promise<boolean> { export async function removeToolLeg(callId: string, toolLegId: string): Promise<boolean> {
if (!bridge || !initialized) return false; if (!bridge || !initialized) return false;
try { try {
await bridge.sendCommand('remove_tool_leg', { await sendProxyCommand('remove_tool_leg', {
call_id: callId, call_id: callId,
tool_leg_id: toolLegId, tool_leg_id: toolLegId,
} as any); });
return true; return true;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] remove_tool_leg error: ${e?.message || e}`); logFn?.(`[proxy-engine] remove_tool_leg error: ${errorMessage(error)}`);
return false; return false;
} }
} }
@@ -522,15 +497,15 @@ export async function setLegMetadata(
): Promise<boolean> { ): Promise<boolean> {
if (!bridge || !initialized) return false; if (!bridge || !initialized) return false;
try { try {
await bridge.sendCommand('set_leg_metadata', { await sendProxyCommand('set_leg_metadata', {
call_id: callId, call_id: callId,
leg_id: legId, leg_id: legId,
key, key,
value, value,
} as any); });
return true; return true;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] set_leg_metadata error: ${e?.message || e}`); logFn?.(`[proxy-engine] set_leg_metadata error: ${errorMessage(error)}`);
return false; return false;
} }
} }
@@ -542,7 +517,7 @@ export async function setLegMetadata(
* dtmf_digit, recording_done, tool_recording_done, tool_transcription_done, * dtmf_digit, recording_done, tool_recording_done, tool_transcription_done,
* leg_added, leg_removed, sip_unhandled * leg_added, leg_removed, sip_unhandled
*/ */
export function onProxyEvent(event: string, handler: (data: any) => void): void { export function onProxyEvent<K extends keyof TProxyEventMap>(event: K, handler: (data: TProxyEventMap[K]) => void): void {
if (!bridge) throw new Error('proxy engine not initialized'); if (!bridge) throw new Error('proxy engine not initialized');
bridge.on(`management:${event}`, handler); bridge.on(`management:${event}`, handler);
} }

187
ts/runtime/proxy-events.ts Normal file
View File

@@ -0,0 +1,187 @@
import { onProxyEvent } from '../proxybridge.ts';
import type { VoiceboxManager } from '../voicebox.ts';
import type { StatusStore } from './status-store.ts';
import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts';
export interface IRegisterProxyEventHandlersOptions {
log: (msg: string) => void;
statusStore: StatusStore;
voiceboxManager: VoiceboxManager;
webRtcLinks: WebRtcLinkManager;
getBrowserDeviceIds: () => string[];
sendToBrowserDevice: (deviceId: string, data: unknown) => boolean;
broadcast: (type: string, data: unknown) => void;
onLinkWebRtcSession: (callId: string, sessionId: string, media: IProviderMediaInfo) => void;
onCloseWebRtcSession: (sessionId: string) => void;
}
export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersOptions): void {
const {
log,
statusStore,
voiceboxManager,
webRtcLinks,
getBrowserDeviceIds,
sendToBrowserDevice,
broadcast,
onLinkWebRtcSession,
onCloseWebRtcSession,
} = options;
onProxyEvent('provider_registered', (data) => {
const previous = statusStore.noteProviderRegistered(data);
if (previous) {
if (data.registered && !previous.wasRegistered) {
log(`[provider:${data.provider_id}] registered (publicIp=${data.public_ip})`);
} else if (!data.registered && previous.wasRegistered) {
log(`[provider:${data.provider_id}] registration lost`);
}
}
broadcast('registration', { providerId: data.provider_id, registered: data.registered });
});
onProxyEvent('device_registered', (data) => {
if (statusStore.noteDeviceRegistered(data)) {
log(`[registrar] ${data.display_name} registered from ${data.address}:${data.port}`);
}
});
onProxyEvent('incoming_call', (data) => {
log(`[call] incoming: ${data.from_uri} -> ${data.to_number} via ${data.provider_id} (${data.call_id})`);
statusStore.noteIncomingCall(data);
if (data.ring_browsers === false) {
return;
}
for (const deviceId of getBrowserDeviceIds()) {
sendToBrowserDevice(deviceId, {
type: 'webrtc-incoming',
callId: data.call_id,
from: data.from_uri,
deviceId,
});
}
});
onProxyEvent('outbound_device_call', (data) => {
log(`[call] outbound: device ${data.from_device} -> ${data.to_number} (${data.call_id})`);
statusStore.noteOutboundDeviceCall(data);
});
onProxyEvent('outbound_call_started', (data) => {
log(`[call] outbound started: ${data.call_id} -> ${data.number} via ${data.provider_id}`);
statusStore.noteOutboundCallStarted(data);
for (const deviceId of getBrowserDeviceIds()) {
sendToBrowserDevice(deviceId, {
type: 'webrtc-incoming',
callId: data.call_id,
from: data.number,
deviceId,
});
}
});
onProxyEvent('call_ringing', (data) => {
statusStore.noteCallRinging(data);
});
onProxyEvent('call_answered', (data) => {
if (statusStore.noteCallAnswered(data)) {
log(`[call] ${data.call_id} connected`);
}
if (!data.provider_media_addr || !data.provider_media_port) {
return;
}
const target = webRtcLinks.noteCallAnswered(data.call_id, {
addr: data.provider_media_addr,
port: data.provider_media_port,
sipPt: data.sip_pt ?? 9,
});
if (!target) {
log(`[webrtc] media info cached for call=${data.call_id}, waiting for session accept`);
return;
}
onLinkWebRtcSession(data.call_id, target.sessionId, target.media);
});
onProxyEvent('call_ended', (data) => {
if (statusStore.noteCallEnded(data)) {
log(`[call] ${data.call_id} ended: ${data.reason} (${data.duration}s)`);
}
broadcast('webrtc-call-ended', { callId: data.call_id });
const sessionId = webRtcLinks.cleanupCall(data.call_id);
if (sessionId) {
onCloseWebRtcSession(sessionId);
}
});
onProxyEvent('sip_unhandled', (data) => {
log(`[sip] unhandled ${data.method_or_status} Call-ID=${data.call_id?.slice(0, 20)} from=${data.from_addr}:${data.from_port}`);
});
onProxyEvent('leg_added', (data) => {
log(`[leg] added: call=${data.call_id} leg=${data.leg_id} kind=${data.kind} state=${data.state}`);
statusStore.noteLegAdded(data);
});
onProxyEvent('leg_removed', (data) => {
log(`[leg] removed: call=${data.call_id} leg=${data.leg_id}`);
statusStore.noteLegRemoved(data);
});
onProxyEvent('leg_state_changed', (data) => {
log(`[leg] state: call=${data.call_id} leg=${data.leg_id} -> ${data.state}`);
statusStore.noteLegStateChanged(data);
});
onProxyEvent('webrtc_ice_candidate', (data) => {
broadcast('webrtc-ice', {
sessionId: data.session_id,
candidate: {
candidate: data.candidate,
sdpMid: data.sdp_mid,
sdpMLineIndex: data.sdp_mline_index,
},
});
});
onProxyEvent('webrtc_state', (data) => {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} state=${data.state}`);
});
onProxyEvent('webrtc_track', (data) => {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} track=${data.kind} codec=${data.codec}`);
});
onProxyEvent('webrtc_audio_rx', (data) => {
if (data.packet_count === 1 || data.packet_count === 50) {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} browser audio rx #${data.packet_count}`);
}
});
onProxyEvent('voicemail_started', (data) => {
log(`[voicemail] started for call ${data.call_id} caller=${data.caller_number}`);
});
onProxyEvent('recording_done', (data) => {
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) caller=${data.caller_number}`);
voiceboxManager.addMessage('default', {
callerNumber: data.caller_number || 'Unknown',
callerName: null,
fileName: data.file_path,
durationMs: data.duration_ms,
});
});
onProxyEvent('voicemail_error', (data) => {
log(`[voicemail] error: ${data.error} call=${data.call_id}`);
});
}

313
ts/runtime/status-store.ts Normal file
View File

@@ -0,0 +1,313 @@
import type { IAppConfig } from '../config.ts';
import type {
ICallAnsweredEvent,
ICallEndedEvent,
ICallRingingEvent,
IDeviceRegisteredEvent,
IIncomingCallEvent,
ILegAddedEvent,
ILegRemovedEvent,
ILegStateChangedEvent,
IOutboundCallEvent,
IOutboundCallStartedEvent,
IProviderRegisteredEvent,
} from '../shared/proxy-events.ts';
import type {
IActiveCall,
ICallHistoryEntry,
IDeviceStatus,
IProviderStatus,
IStatusSnapshot,
TLegType,
} from '../shared/status.ts';
const MAX_HISTORY = 100;
const CODEC_NAMES: Record<number, string> = {
0: 'PCMU',
8: 'PCMA',
9: 'G.722',
111: 'Opus',
};
export class StatusStore {
private appConfig: IAppConfig;
private providerStatuses = new Map<string, IProviderStatus>();
private deviceStatuses = new Map<string, IDeviceStatus>();
private activeCalls = new Map<string, IActiveCall>();
private callHistory: ICallHistoryEntry[] = [];
constructor(appConfig: IAppConfig) {
this.appConfig = appConfig;
this.rebuildConfigState();
}
updateConfig(appConfig: IAppConfig): void {
this.appConfig = appConfig;
this.rebuildConfigState();
}
buildStatusSnapshot(
instanceId: string,
startTime: number,
browserDeviceIds: string[],
voicemailCounts: Record<string, number>,
): IStatusSnapshot {
const devices = [...this.deviceStatuses.values()];
for (const deviceId of browserDeviceIds) {
devices.push({
id: deviceId,
displayName: 'Browser',
address: null,
port: 0,
aor: null,
connected: true,
isBrowser: true,
});
}
return {
instanceId,
uptime: Math.floor((Date.now() - startTime) / 1000),
lanIp: this.appConfig.proxy.lanIp,
providers: [...this.providerStatuses.values()],
devices,
calls: [...this.activeCalls.values()].map((call) => ({
...call,
duration: Math.floor((Date.now() - call.startedAt) / 1000),
legs: [...call.legs.values()].map((leg) => ({
...leg,
pktSent: 0,
pktReceived: 0,
transcoding: false,
})),
})),
callHistory: this.callHistory,
contacts: this.appConfig.contacts || [],
voicemailCounts,
};
}
noteDashboardCallStarted(callId: string, number: string, providerId?: string): void {
this.activeCalls.set(callId, {
id: callId,
direction: 'outbound',
callerNumber: null,
calleeNumber: number,
providerUsed: providerId || null,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
}
noteProviderRegistered(data: IProviderRegisteredEvent): { wasRegistered: boolean } | null {
const provider = this.providerStatuses.get(data.provider_id);
if (!provider) {
return null;
}
const wasRegistered = provider.registered;
provider.registered = data.registered;
provider.publicIp = data.public_ip;
return { wasRegistered };
}
noteDeviceRegistered(data: IDeviceRegisteredEvent): boolean {
const device = this.deviceStatuses.get(data.device_id);
if (!device) {
return false;
}
device.address = data.address;
device.port = data.port;
device.aor = data.aor;
device.connected = true;
return true;
}
noteIncomingCall(data: IIncomingCallEvent): void {
this.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(),
});
}
noteOutboundDeviceCall(data: IOutboundCallEvent): void {
this.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(),
});
}
noteOutboundCallStarted(data: IOutboundCallStartedEvent): void {
this.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(),
});
}
noteCallRinging(data: ICallRingingEvent): void {
const call = this.activeCalls.get(data.call_id);
if (call) {
call.state = 'ringing';
}
}
noteCallAnswered(data: ICallAnsweredEvent): boolean {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return false;
}
call.state = 'connected';
if (data.provider_media_addr && data.provider_media_port) {
for (const leg of call.legs.values()) {
if (leg.type !== 'sip-provider') {
continue;
}
leg.remoteMedia = `${data.provider_media_addr}:${data.provider_media_port}`;
if (data.sip_pt !== undefined) {
leg.codec = CODEC_NAMES[data.sip_pt] || `PT${data.sip_pt}`;
}
break;
}
}
return true;
}
noteCallEnded(data: ICallEndedEvent): boolean {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return false;
}
this.callHistory.unshift({
id: call.id,
direction: call.direction,
callerNumber: call.callerNumber,
calleeNumber: call.calleeNumber,
providerUsed: call.providerUsed,
startedAt: call.startedAt,
duration: data.duration,
legs: [...call.legs.values()].map((leg) => ({
id: leg.id,
type: leg.type,
metadata: leg.metadata || {},
})),
});
if (this.callHistory.length > MAX_HISTORY) {
this.callHistory.pop();
}
this.activeCalls.delete(data.call_id);
return true;
}
noteLegAdded(data: ILegAddedEvent): void {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return;
}
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 || {},
});
}
noteLegRemoved(data: ILegRemovedEvent): void {
this.activeCalls.get(data.call_id)?.legs.delete(data.leg_id);
}
noteLegStateChanged(data: ILegStateChangedEvent): void {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return;
}
const existingLeg = call.legs.get(data.leg_id);
if (existingLeg) {
existingLeg.state = data.state;
if (data.metadata) {
existingLeg.metadata = data.metadata;
}
return;
}
call.legs.set(data.leg_id, {
id: data.leg_id,
type: this.inferLegType(data.leg_id),
state: data.state,
codec: null,
rtpPort: null,
remoteMedia: null,
metadata: data.metadata || {},
});
}
private rebuildConfigState(): void {
const nextProviderStatuses = new Map<string, IProviderStatus>();
for (const provider of this.appConfig.providers) {
const previous = this.providerStatuses.get(provider.id);
nextProviderStatuses.set(provider.id, {
id: provider.id,
displayName: provider.displayName,
registered: previous?.registered ?? false,
publicIp: previous?.publicIp ?? null,
});
}
this.providerStatuses = nextProviderStatuses;
const nextDeviceStatuses = new Map<string, IDeviceStatus>();
for (const device of this.appConfig.devices) {
const previous = this.deviceStatuses.get(device.id);
nextDeviceStatuses.set(device.id, {
id: device.id,
displayName: device.displayName,
address: previous?.address ?? null,
port: previous?.port ?? 0,
aor: previous?.aor ?? null,
connected: previous?.connected ?? false,
isBrowser: false,
});
}
this.deviceStatuses = nextDeviceStatuses;
}
private inferLegType(legId: string): TLegType {
if (legId.includes('-prov')) {
return 'sip-provider';
}
if (legId.includes('-dev')) {
return 'sip-device';
}
return 'webrtc';
}
}

View File

@@ -0,0 +1,66 @@
export interface IProviderMediaInfo {
addr: string;
port: number;
sipPt: number;
}
export interface IWebRtcLinkTarget {
sessionId: string;
media: IProviderMediaInfo;
}
export class WebRtcLinkManager {
private sessionToCall = new Map<string, string>();
private callToSession = new Map<string, string>();
private pendingCallMedia = new Map<string, IProviderMediaInfo>();
acceptCall(callId: string, sessionId: string): IProviderMediaInfo | null {
const previousCallId = this.sessionToCall.get(sessionId);
if (previousCallId && previousCallId !== callId) {
this.callToSession.delete(previousCallId);
}
const previousSessionId = this.callToSession.get(callId);
if (previousSessionId && previousSessionId !== sessionId) {
this.sessionToCall.delete(previousSessionId);
}
this.sessionToCall.set(sessionId, callId);
this.callToSession.set(callId, sessionId);
const pendingMedia = this.pendingCallMedia.get(callId) ?? null;
if (pendingMedia) {
this.pendingCallMedia.delete(callId);
}
return pendingMedia;
}
noteCallAnswered(callId: string, media: IProviderMediaInfo): IWebRtcLinkTarget | null {
const sessionId = this.callToSession.get(callId);
if (!sessionId) {
this.pendingCallMedia.set(callId, media);
return null;
}
return { sessionId, media };
}
removeSession(sessionId: string): string | null {
const callId = this.sessionToCall.get(sessionId) ?? null;
this.sessionToCall.delete(sessionId);
if (callId) {
this.callToSession.delete(callId);
}
return callId;
}
cleanupCall(callId: string): string | null {
const sessionId = this.callToSession.get(callId) ?? null;
this.callToSession.delete(callId);
this.pendingCallMedia.delete(callId);
if (sessionId) {
this.sessionToCall.delete(sessionId);
}
return sessionId;
}
}

145
ts/shared/proxy-events.ts Normal file
View File

@@ -0,0 +1,145 @@
import type { TLegType } from './status.ts';
export interface IIncomingCallEvent {
call_id: string;
from_uri: string;
to_number: string;
provider_id: string;
ring_browsers?: boolean;
}
export interface IOutboundCallEvent {
call_id: string;
from_device: string | null;
to_number: string;
}
export interface IOutboundCallStartedEvent {
call_id: string;
number: string;
provider_id: string;
}
export interface ICallRingingEvent {
call_id: string;
}
export interface ICallAnsweredEvent {
call_id: string;
provider_media_addr?: string;
provider_media_port?: number;
sip_pt?: number;
}
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;
}
export interface ISipUnhandledEvent {
method_or_status: string;
call_id?: string;
from_addr: string;
from_port: number;
}
export interface ILegAddedEvent {
call_id: string;
leg_id: string;
kind: TLegType;
state: string;
codec?: string | null;
rtpPort?: number | null;
remoteMedia?: string | null;
metadata?: Record<string, unknown>;
}
export interface ILegRemovedEvent {
call_id: string;
leg_id: string;
}
export interface ILegStateChangedEvent {
call_id: string;
leg_id: string;
state: string;
metadata?: Record<string, unknown>;
}
export interface IWebRtcIceCandidateEvent {
session_id: string;
candidate: string;
sdp_mid?: string;
sdp_mline_index?: number;
}
export interface IWebRtcStateEvent {
session_id?: string;
state: string;
}
export interface IWebRtcTrackEvent {
session_id?: string;
kind: string;
codec: string;
}
export interface IWebRtcAudioRxEvent {
session_id?: string;
packet_count: number;
}
export interface IVoicemailStartedEvent {
call_id: string;
caller_number?: string;
}
export interface IRecordingDoneEvent {
file_path: string;
duration_ms: number;
caller_number?: string;
}
export interface IVoicemailErrorEvent {
call_id: string;
error: string;
}
export type TProxyEventMap = {
provider_registered: IProviderRegisteredEvent;
device_registered: IDeviceRegisteredEvent;
incoming_call: IIncomingCallEvent;
outbound_device_call: IOutboundCallEvent;
outbound_call_started: IOutboundCallStartedEvent;
call_ringing: ICallRingingEvent;
call_answered: ICallAnsweredEvent;
call_ended: ICallEndedEvent;
sip_unhandled: ISipUnhandledEvent;
leg_added: ILegAddedEvent;
leg_removed: ILegRemovedEvent;
leg_state_changed: ILegStateChangedEvent;
webrtc_ice_candidate: IWebRtcIceCandidateEvent;
webrtc_state: IWebRtcStateEvent;
webrtc_track: IWebRtcTrackEvent;
webrtc_audio_rx: IWebRtcAudioRxEvent;
voicemail_started: IVoicemailStartedEvent;
recording_done: IRecordingDoneEvent;
voicemail_error: IVoicemailErrorEvent;
};

89
ts/shared/status.ts Normal file
View File

@@ -0,0 +1,89 @@
import type { IContact } from '../config.ts';
export type TLegType = 'sip-device' | 'sip-provider' | 'webrtc' | 'tool';
export type TCallDirection = 'inbound' | 'outbound';
export interface IProviderStatus {
id: string;
displayName: string;
registered: boolean;
publicIp: string | null;
}
export interface IDeviceStatus {
id: string;
displayName: string;
address: string | null;
port: number;
aor: string | null;
connected: boolean;
isBrowser: boolean;
}
export interface IActiveLeg {
id: string;
type: TLegType;
state: string;
codec: string | null;
rtpPort: number | null;
remoteMedia: string | null;
metadata: Record<string, unknown>;
}
export interface IActiveCall {
id: string;
direction: TCallDirection;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
state: string;
startedAt: number;
legs: Map<string, IActiveLeg>;
}
export interface IHistoryLeg {
id: string;
type: string;
metadata: Record<string, unknown>;
}
export interface ICallHistoryEntry {
id: string;
direction: TCallDirection;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
startedAt: number;
duration: number;
legs: IHistoryLeg[];
}
export interface ILegStatus extends IActiveLeg {
pktSent: number;
pktReceived: number;
transcoding: boolean;
}
export interface ICallStatus {
id: string;
direction: TCallDirection;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
state: string;
startedAt: number;
duration: number;
legs: ILegStatus[];
}
export interface IStatusSnapshot {
instanceId: string;
uptime: number;
lanIp: string;
providers: IProviderStatus[];
devices: IDeviceStatus[];
calls: ICallStatus[];
callHistory: ICallHistoryEntry[];
contacts: IContact[];
voicemailCounts: Record<string, number>;
}

View File

@@ -1,34 +1,20 @@
/** /**
* SIP proxy — entry point. * SIP proxy bootstrap.
* *
* Spawns the Rust proxy-engine which handles ALL SIP protocol mechanics. * Spawns the Rust proxy-engine, wires runtime state/event handling,
* TypeScript is the control plane: * and starts the web dashboard plus browser signaling layer.
* - 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 fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { loadConfig } from './config.ts'; import { loadConfig, type IAppConfig } from './config.ts';
import type { IAppConfig } from './config.ts';
import { broadcastWs, initWebUi } from './frontend.ts'; import { broadcastWs, initWebUi } from './frontend.ts';
import { import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.ts';
initWebRtcSignaling,
sendToBrowserDevice,
getAllBrowserDeviceIds,
getBrowserDeviceWs,
} from './webrtcbridge.ts';
import { VoiceboxManager } from './voicebox.ts'; import { VoiceboxManager } from './voicebox.ts';
import { import {
initProxyEngine, initProxyEngine,
configureProxyEngine, configureProxyEngine,
onProxyEvent,
hangupCall, hangupCall,
makeCall, makeCall,
shutdownProxyEngine, shutdownProxyEngine,
@@ -36,628 +22,200 @@ import {
webrtcIce, webrtcIce,
webrtcLink, webrtcLink,
webrtcClose, webrtcClose,
addLeg,
removeLeg,
} from './proxybridge.ts'; } from './proxybridge.ts';
import type { import { registerProxyEventHandlers } from './runtime/proxy-events.ts';
IIncomingCallEvent, import { StatusStore } from './runtime/status-store.ts';
IOutboundCallEvent, import { WebRtcLinkManager, type IProviderMediaInfo } from './runtime/webrtc-linking.ts';
ICallEndedEvent,
IProviderRegisteredEvent,
IDeviceRegisteredEvent,
} from './proxybridge.ts';
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
let appConfig: IAppConfig = loadConfig(); let appConfig: IAppConfig = loadConfig();
const LOG_PATH = path.join(process.cwd(), 'sip_trace.log'); const LOG_PATH = path.join(process.cwd(), 'sip_trace.log');
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
const startTime = Date.now(); const startTime = Date.now();
const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const statusStore = new StatusStore(appConfig);
const webRtcLinks = new WebRtcLinkManager();
const voiceboxManager = new VoiceboxManager(log);
voiceboxManager.init(appConfig.voiceboxes ?? []);
initWebRtcSignaling({ log });
function now(): string { function now(): string {
return new Date().toISOString().replace('T', ' ').slice(0, 19); return new Date().toISOString().replace('T', ' ').slice(0, 19);
} }
function log(msg: string): void { function log(message: string): void {
const line = `${now()} ${msg}\n`; const line = `${now()} ${message}\n`;
fs.appendFileSync(LOG_PATH, line); fs.appendFileSync(LOG_PATH, line);
process.stdout.write(line); process.stdout.write(line);
broadcastWs('log', { message: msg }); broadcastWs('log', { message });
} }
// --------------------------------------------------------------------------- function errorMessage(error: unknown): string {
// Shadow state — maintained from Rust events for the dashboard return error instanceof Error ? error.message : String(error);
// ---------------------------------------------------------------------------
interface IProviderStatus {
id: string;
displayName: string;
registered: boolean;
publicIp: string | null;
} }
interface IDeviceStatus { function buildProxyConfig(config: IAppConfig): Record<string, unknown> {
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 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 { return {
instanceId, proxy: config.proxy,
uptime: Math.floor((Date.now() - startTime) / 1000), providers: config.providers,
lanIp: appConfig.proxy.lanIp, devices: config.devices,
providers: [...providerStatuses.values()], routing: config.routing,
devices, voiceboxes: config.voiceboxes ?? [],
calls: [...activeCalls.values()].map((c) => ({ ivr: config.ivr,
...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(),
}; };
} }
// --------------------------------------------------------------------------- function getStatus() {
// Start Rust proxy engine return statusStore.buildStatusSnapshot(
// --------------------------------------------------------------------------- instanceId,
startTime,
getAllBrowserDeviceIds(),
voiceboxManager.getAllUnheardCounts(),
);
}
function requestWebRtcLink(callId: string, sessionId: string, media: IProviderMediaInfo): void {
log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${callId} media=${media.addr}:${media.port} pt=${media.sipPt}`);
void webrtcLink(sessionId, callId, media.addr, media.port, media.sipPt).then((ok) => {
log(`[webrtc] link result: ${ok}`);
});
}
async function configureRuntime(config: IAppConfig): Promise<boolean> {
return configureProxyEngine(buildProxyConfig(config));
}
async function reloadConfig(): Promise<void> {
try {
const previousConfig = appConfig;
const nextConfig = loadConfig();
appConfig = nextConfig;
statusStore.updateConfig(nextConfig);
voiceboxManager.init(nextConfig.voiceboxes ?? []);
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
log('[config] proxy.lanPort changed; restart required for SIP socket rebinding');
}
if (nextConfig.proxy.webUiPort !== previousConfig.proxy.webUiPort) {
log('[config] proxy.webUiPort changed; restart required for web UI rebinding');
}
const configured = await configureRuntime(nextConfig);
if (configured) {
log('[config] reloaded - proxy engine reconfigured');
} else {
log('[config] reload failed - proxy engine rejected config');
}
} catch (error: unknown) {
log(`[config] reload failed: ${errorMessage(error)}`);
}
}
async function startProxyEngine(): Promise<void> { async function startProxyEngine(): Promise<void> {
const ok = await initProxyEngine(log); const started = await initProxyEngine(log);
if (!ok) { if (!started) {
log('[FATAL] failed to start proxy engine'); log('[FATAL] failed to start proxy engine');
process.exit(1); process.exit(1);
} }
// Subscribe to events from Rust BEFORE sending configure. registerProxyEventHandlers({
onProxyEvent('provider_registered', (data: IProviderRegisteredEvent) => { log,
const ps = providerStatuses.get(data.provider_id); statusStore,
if (ps) { voiceboxManager,
const wasRegistered = ps.registered; webRtcLinks,
ps.registered = data.registered; getBrowserDeviceIds: getAllBrowserDeviceIds,
ps.publicIp = data.public_ip; sendToBrowserDevice,
if (data.registered && !wasRegistered) { broadcast: broadcastWs,
log(`[provider:${data.provider_id}] registered (publicIp=${data.public_ip})`); onLinkWebRtcSession: requestWebRtcLink,
} else if (!data.registered && wasRegistered) { onCloseWebRtcSession: (sessionId) => {
log(`[provider:${data.provider_id}] registration lost`); void webrtcClose(sessionId);
} },
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 the incoming call, but only if the matched inbound
// route asked for it. `ring_browsers !== false` preserves today's
// ring-by-default behavior for any Rust release that predates this
// field or for the fallback "no route matched" case (where Rust still
// sends `true`). Note: this is an informational toast — browsers do
// NOT race the SIP device to answer. First-to-answer-wins requires
// a multi-leg fork which is not yet implemented.
if (data.ring_browsers !== false) {
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,
voiceboxes: appConfig.voiceboxes ?? [],
ivr: appConfig.ivr,
}); });
const configured = await configureRuntime(appConfig);
if (!configured) { if (!configured) {
log('[FATAL] failed to configure proxy engine'); log('[FATAL] failed to configure proxy engine');
process.exit(1); process.exit(1);
} }
const providerList = appConfig.providers.map((p) => p.displayName).join(', '); const providerList = appConfig.providers.map((provider) => provider.displayName).join(', ');
const deviceList = appConfig.devices.map((d) => d.displayName).join(', '); const deviceList = appConfig.devices.map((device) => device.displayName).join(', ');
log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`); log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`);
// TTS prompts (voicemail greetings, IVR menus) are generated on-demand
// by the Rust TTS engine when first needed. No startup pre-generation.
} }
// --------------------------------------------------------------------------- initWebUi({
// Web UI port: appConfig.proxy.webUiPort,
// ---------------------------------------------------------------------------
initWebUi(
getStatus, getStatus,
log, log,
(number, deviceId, providerId) => { onStartCall: (number, deviceId, providerId) => {
// Outbound calls from dashboard — send make_call command to Rust.
log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`); log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`);
// Fire-and-forget — the async result comes via events. void makeCall(number, deviceId, providerId).then((callId) => {
makeCall(number, deviceId, providerId).then((callId) => {
if (callId) { if (callId) {
log(`[dashboard] call started: ${callId}`); log(`[dashboard] call started: ${callId}`);
activeCalls.set(callId, { statusStore.noteDashboardCallStarted(callId, number, providerId);
id: callId,
direction: 'outbound',
callerNumber: null,
calleeNumber: number,
providerUsed: providerId || null,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
} else { } else {
log(`[dashboard] call failed for ${number}`); log(`[dashboard] call failed for ${number}`);
} }
}); });
// Return a temporary ID so the frontend doesn't show "failed" immediately.
return { id: `pending-${Date.now()}` }; return { id: `pending-${Date.now()}` };
}, },
(callId) => { onHangupCall: (callId) => {
hangupCall(callId); void hangupCall(callId);
return true; return true;
}, },
() => { onConfigSaved: reloadConfig,
// Config saved — reconfigure Rust engine. voiceboxManager,
try { onWebRtcOffer: async (sessionId, sdp, ws) => {
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,
voiceboxes: fresh.voiceboxes ?? [],
ivr: fresh.ivr,
}).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}`); 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) { if (!sdp || typeof sdp !== 'string' || sdp.length < 10) {
log(`[webrtc] WARNING: invalid SDP (type=${typeof sdp}), skipping offer`); log(`[webrtc] WARNING: invalid SDP (type=${typeof sdp}), skipping offer`);
return; return;
} }
log(`[webrtc] sending offer to Rust (${sdp.length}b)...`); log(`[webrtc] sending offer to Rust (${sdp.length}b)...`);
const result = await webrtcOffer(sessionId, sdp); const result = await webrtcOffer(sessionId, sdp);
log(`[webrtc] Rust result: ${JSON.stringify(result)?.slice(0, 200)}`); log(`[webrtc] Rust result: ${JSON.stringify(result)?.slice(0, 200)}`);
if (result?.sdp) { if (result?.sdp) {
ws.send(JSON.stringify({ type: 'webrtc-answer', sessionId, sdp: result.sdp })); ws.send(JSON.stringify({ type: 'webrtc-answer', sessionId, sdp: result.sdp }));
log(`[webrtc] answer sent to browser session=${sessionId.slice(0, 8)}`); log(`[webrtc] answer sent to browser session=${sessionId.slice(0, 8)}`);
} else { return;
log(`[webrtc] ERROR: no answer SDP from Rust`);
} }
log('[webrtc] ERROR: no answer SDP from Rust');
}, },
async (sessionId, candidate) => { onWebRtcIce: async (sessionId, candidate) => {
await webrtcIce(sessionId, candidate); await webrtcIce(sessionId, candidate);
}, },
async (sessionId) => { onWebRtcClose: async (sessionId) => {
webRtcLinks.removeSession(sessionId);
await webrtcClose(sessionId); await webrtcClose(sessionId);
}, },
// onWebRtcAccept — browser has accepted a call, linking session to call. onWebRtcAccept: (callId, sessionId) => {
(callId: string, sessionId: string) => {
log(`[webrtc] accept: callId=${callId} sessionId=${sessionId.slice(0, 8)}`); log(`[webrtc] accept: callId=${callId} sessionId=${sessionId.slice(0, 8)}`);
// Store bidirectional mapping. const pendingMedia = webRtcLinks.acceptCall(callId, sessionId);
webrtcSessionToCall.set(sessionId, callId); if (pendingMedia) {
webrtcCallToSession.set(callId, sessionId); requestWebRtcLink(callId, sessionId, pendingMedia);
return;
// 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`);
} }
log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`);
}, },
); });
// --------------------------------------------------------------------------- void startProxyEngine();
// Start
// ---------------------------------------------------------------------------
startProxyEngine(); process.on('SIGINT', () => {
log('SIGINT, exiting');
shutdownProxyEngine();
process.exit(0);
});
process.on('SIGINT', () => { log('SIGINT, exiting'); shutdownProxyEngine(); process.exit(0); }); process.on('SIGTERM', () => {
process.on('SIGTERM', () => { log('SIGTERM, exiting'); shutdownProxyEngine(); process.exit(0); }); log('SIGTERM, exiting');
shutdownProxyEngine();
process.exit(0);
});

View File

@@ -5,8 +5,8 @@
* - Browser device registration/unregistration via WebSocket * - Browser device registration/unregistration via WebSocket
* - WS → deviceId mapping * - WS → deviceId mapping
* *
* All WebRTC media logic (PeerConnection, RTP, transcoding) lives in * All WebRTC media logic (PeerConnection, RTP, transcoding, mixer wiring)
* ts/call/webrtc-leg.ts and is managed by the CallManager. * lives in the Rust proxy-engine. This module only tracks browser sessions.
*/ */
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
@@ -39,7 +39,7 @@ export function initWebRtcSignaling(cfg: IWebRtcSignalingConfig): void {
/** /**
* Handle a WebRTC signaling message from a browser client. * Handle a WebRTC signaling message from a browser client.
* Only handles registration; offer/ice/hangup are routed through CallManager. * Only handles registration; offer/ice/hangup are routed through frontend.ts.
*/ */
export function handleWebRtcSignaling( export function handleWebRtcSignaling(
ws: WebSocket, ws: WebSocket,
@@ -51,7 +51,7 @@ export function handleWebRtcSignaling(
handleRegister(ws, message.sessionId!, message.userAgent, message._remoteIp); handleRegister(ws, message.sessionId!, message.userAgent, message._remoteIp);
} }
// Other webrtc-* types (offer, ice, hangup, accept) are handled // Other webrtc-* types (offer, ice, hangup, accept) are handled
// by the CallManager via frontend.ts WebSocket handler. // by the frontend.ts WebSocket handler and forwarded to Rust.
} }
/** /**
@@ -64,13 +64,6 @@ export function sendToBrowserDevice(deviceId: string, data: unknown): boolean {
return true; return true;
} }
/**
* Get the WebSocket for a browser device (used by CallManager to create WebRtcLegs).
*/
export function getBrowserDeviceWs(deviceId: string): WebSocket | null {
return deviceIdToWs.get(deviceId) ?? null;
}
/** /**
* Get all registered browser device IDs. * Get all registered browser device IDs.
*/ */

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: 'siprouter', name: 'siprouter',
version: '1.22.0', version: '1.23.0',
description: 'undefined' description: 'undefined'
} }

View File

@@ -41,11 +41,10 @@ export class SipproxyDevices extends DeesElement {
}, },
}, },
{ {
key: 'contact', key: 'address',
header: 'Contact', header: 'Contact',
renderer: (_val: any, row: any) => { renderer: (_val: any, row: any) => {
const c = row.contact; const text = row.address ? (row.port ? `${row.address}:${row.port}` : row.address) : '--';
const text = c ? (c.port ? `${c.address}:${c.port}` : c.address) : '--';
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`; return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`;
}, },
}, },

View File

@@ -186,11 +186,10 @@ export class SipproxyViewOverview extends DeesElement {
}, },
}, },
{ {
key: 'contact', key: 'address',
header: 'Contact', header: 'Contact',
renderer: (_val: any, row: any) => { renderer: (_val: any, row: any) => {
const c = row.contact; const text = row.address ? (row.port ? `${row.address}:${row.port}` : row.address) : '--';
const text = c ? (c.port ? `${c.address}:${c.port}` : c.address) : '--';
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`; return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`;
}, },
}, },

View File

@@ -2,72 +2,12 @@
* Application state — receives live updates from the proxy via WebSocket. * Application state — receives live updates from the proxy via WebSocket.
*/ */
export interface IProviderStatus { import type { IContact } from '../../ts/config.ts';
id: string; import type { ICallHistoryEntry, ICallStatus, IDeviceStatus, IProviderStatus } from '../../ts/shared/status.ts';
displayName: string;
registered: boolean;
publicIp: string | null;
}
export interface IDeviceStatus { export type { IContact };
id: string; export type { ICallHistoryEntry, ICallStatus, IDeviceStatus, IProviderStatus };
displayName: string; export type { ILegStatus } from '../../ts/shared/status.ts';
contact: { address: string; port: number } | null;
aor: string;
connected: boolean;
isBrowser: boolean;
}
export interface ILegStatus {
id: string;
type: 'sip-device' | 'sip-provider' | 'webrtc' | 'tool';
state: string;
remoteMedia: { address: string; port: number } | null;
rtpPort: number | null;
pktSent: number;
pktReceived: number;
codec: string | null;
transcoding: boolean;
metadata?: Record<string, unknown>;
}
export interface ICallStatus {
id: string;
state: string;
direction: 'inbound' | 'outbound' | 'internal';
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
createdAt: number;
duration: number;
legs: ILegStatus[];
}
export interface IHistoryLeg {
id: string;
type: string;
metadata: Record<string, unknown>;
}
export interface ICallHistoryEntry {
id: string;
direction: 'inbound' | 'outbound' | 'internal';
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
startedAt: number;
duration: number;
legs?: IHistoryLeg[];
}
export interface IContact {
id: string;
name: string;
number: string;
company?: string;
notes?: string;
starred?: boolean;
}
export interface IAppState { export interface IAppState {
connected: boolean; connected: boolean;