From 51f756073019a9cb8891e0df11978a7fa1aecf35 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 14 Apr 2026 10:45:59 +0000 Subject: [PATCH] feat(runtime): refactor runtime state and proxy event handling for typed WebRTC linking and shared status models --- changelog.md | 9 + ts/00_commitinfo_data.ts | 2 +- ts/frontend.ts | 87 +-- ts/proxybridge.ts | 227 ++++--- ts/runtime/proxy-events.ts | 187 ++++++ ts/runtime/status-store.ts | 313 ++++++++++ ts/runtime/webrtc-linking.ts | 66 ++ ts/shared/proxy-events.ts | 145 +++++ ts/shared/status.ts | 89 +++ ts/sipproxy.ts | 696 ++++------------------ ts/webrtcbridge.ts | 15 +- ts_web/00_commitinfo_data.ts | 2 +- ts_web/elements/sipproxy-devices.ts | 5 +- ts_web/elements/sipproxy-view-overview.ts | 5 +- ts_web/state/appstate.ts | 70 +-- 15 files changed, 1105 insertions(+), 813 deletions(-) create mode 100644 ts/runtime/proxy-events.ts create mode 100644 ts/runtime/status-store.ts create mode 100644 ts/runtime/webrtc-linking.ts create mode 100644 ts/shared/proxy-events.ts create mode 100644 ts/shared/status.ts diff --git a/changelog.md b/changelog.md index 0e5f0cc..8ff9886 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # 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) add on-demand TTS caching for voicemail and IVR prompts diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 7c6514f..6c41e43 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.22.0', + version: '1.23.0', description: 'undefined' } diff --git a/ts/frontend.ts b/ts/frontend.ts index a7c799d..ec7c38f 100644 --- a/ts/frontend.ts +++ b/ts/frontend.ts @@ -14,12 +14,36 @@ import { WebSocketServer, WebSocket } from 'ws'; import { handleWebRtcSignaling } from './webrtcbridge.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'); +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; + voiceboxManager?: VoiceboxManager; +} + +interface IWebUiOptions extends IHandleRequestContext { + port: number; + onWebRtcOffer?: (sessionId: string, sdp: string, ws: WebSocket) => Promise; + onWebRtcIce?: (sessionId: string, candidate: unknown) => Promise; + onWebRtcClose?: (sessionId: string) => Promise; + 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 // --------------------------------------------------------------------------- @@ -82,14 +106,9 @@ function loadStaticFiles(): void { async function handleRequest( req: http.IncomingMessage, res: http.ServerResponse, - getStatus: () => unknown, - log: (msg: string) => void, - onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null, - onHangupCall: (callId: string) => boolean, - onConfigSaved?: () => void, - callManager?: CallManager, - voiceboxManager?: VoiceboxManager, + context: IHandleRequestContext, ): Promise { + const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager } = context; const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const method = req.method || 'GET'; @@ -258,7 +277,7 @@ async function handleRequest( fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n'); log('[config] updated config.json'); - onConfigSaved?.(); + await onConfigSaved?.(); return sendJson(res, { ok: true }); } catch (e: any) { return sendJson(res, { ok: false, error: e.message }, 400); @@ -339,21 +358,21 @@ async function handleRequest( // --------------------------------------------------------------------------- export function initWebUi( - getStatus: () => unknown, - 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, - onWebRtcIce?: (sessionId: string, candidate: any) => Promise, - onWebRtcClose?: (sessionId: string) => Promise, - /** Called when browser sends webrtc-accept (callId + sessionId linking). */ - onWebRtcAccept?: (callId: string, sessionId: string) => void, + options: IWebUiOptions, ): void { - const WEB_PORT = 3060; + const { + port, + getStatus, + log, + onStartCall, + onHangupCall, + onConfigSaved, + voiceboxManager, + onWebRtcOffer, + onWebRtcIce, + onWebRtcClose, + onWebRtcAccept, + } = options; loadStaticFiles(); @@ -367,12 +386,12 @@ export function initWebUi( const cert = fs.readFileSync(certPath, 'utf8'); const key = fs.readFileSync(keyPath, 'utf8'); 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; } catch { 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) => { try { - const msg = JSON.parse(raw.toString()); + const msg = JSON.parse(raw.toString()) as IWebRtcSocketMessage; if (msg.type === 'webrtc-offer' && msg.sessionId) { // 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}`); - 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}`)); } } else if (msg.type === 'webrtc-ice' && msg.sessionId) { @@ -409,7 +428,7 @@ export function initWebUi( } } else if (msg.type?.startsWith('webrtc-')) { msg._remoteIp = remoteIp; - handleWebRtcSignaling(socket as any, msg); + handleWebRtcSignaling(socket, msg); } } catch { /* ignore */ } }); @@ -418,8 +437,8 @@ export function initWebUi( socket.on('error', () => wsClients.delete(socket)); }); - server.listen(WEB_PORT, '0.0.0.0', () => { - log(`web ui listening on ${useTls ? 'https' : 'http'}://0.0.0.0:${WEB_PORT}`); + server.listen(port, '0.0.0.0', () => { + log(`web ui listening on ${useTls ? 'https' : 'http'}://0.0.0.0:${port}`); }); setInterval(() => broadcastWs('status', getStatus()), 1000); diff --git a/ts/proxybridge.ts b/ts/proxybridge.ts index 82aa477..91187b9 100644 --- a/ts/proxybridge.ts +++ b/ts/proxybridge.ts @@ -4,13 +4,36 @@ * 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) + * - Sends high-level commands (hangup, make_call, add_leg, webrtc_offer) * * No raw SIP ever touches TypeScript. */ import path from 'node:path'; 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 @@ -29,18 +52,6 @@ type TProxyCommands = { 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_leg: { params: { call_id: string; number: string; provider_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 // --------------------------------------------------------------------------- @@ -173,6 +140,16 @@ let bridge: RustBridge | null = null; let initialized = false; 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[] { const root = process.cwd(); // Map Node's process.arch to tsrust's friendly target name. @@ -231,8 +208,8 @@ export async function initProxyEngine(log?: (msg: string) => void): Promise void): Promise): Promise { +export async function configureProxyEngine(config: TProxyCommands['configure']['params']): 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 || '?'}`); + const result = await sendProxyCommand('configure', config); + logFn?.(`[proxy-engine] configured, SIP bound on ${result.bound || '?'}`); return true; - } catch (e: any) { - logFn?.(`[proxy-engine] configure error: ${e.message}`); + } catch (error: unknown) { + logFn?.(`[proxy-engine] configure error: ${errorMessage(error)}`); return false; } } @@ -260,14 +237,14 @@ export async function configureProxyEngine(config: Record): Pro export async function makeCall(number: string, deviceId?: string, providerId?: string): Promise { if (!bridge || !initialized) return null; try { - const result = await bridge.sendCommand('make_call', { + const result = await sendProxyCommand('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 result.call_id || null; + } catch (error: unknown) { + logFn?.(`[proxy-engine] make_call error: ${errorMessage(error)}`); return null; } } @@ -278,7 +255,7 @@ export async function makeCall(number: string, deviceId?: string, providerId?: s export async function hangupCall(callId: string): Promise { if (!bridge || !initialized) return false; try { - await bridge.sendCommand('hangup', { call_id: callId } as any); + await sendProxyCommand('hangup', { call_id: callId }); return true; } catch { return false; @@ -291,10 +268,9 @@ export async function hangupCall(callId: string): Promise { 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 await sendProxyCommand('webrtc_offer', { session_id: sessionId, sdp }); + } catch (error: unknown) { + logFn?.(`[proxy-engine] webrtc_offer error: ${errorMessage(error)}`); return null; } } @@ -302,15 +278,15 @@ export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp /** * Forward an ICE candidate to the proxy engine. */ -export async function webrtcIce(sessionId: string, candidate: any): Promise { +export async function webrtcIce(sessionId: string, candidate: TWebRtcIceCandidate): Promise { if (!bridge || !initialized) return; try { - await bridge.sendCommand('webrtc_ice', { + await sendProxyCommand('webrtc_ice', { session_id: sessionId, - candidate: candidate?.candidate || candidate, - sdp_mid: candidate?.sdpMid, - sdp_mline_index: candidate?.sdpMLineIndex, - } as any); + candidate: typeof candidate === 'string' ? candidate : candidate.candidate || '', + sdp_mid: typeof candidate === 'string' ? undefined : candidate.sdpMid, + sdp_mline_index: typeof candidate === 'string' ? undefined : candidate.sdpMLineIndex, + }); } catch { /* ignore */ } } @@ -321,16 +297,16 @@ export async function webrtcIce(sessionId: string, candidate: any): Promise { if (!bridge || !initialized) return false; try { - await bridge.sendCommand('webrtc_link', { + await sendProxyCommand('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}`); + } catch (error: unknown) { + logFn?.(`[proxy-engine] webrtc_link error: ${errorMessage(error)}`); 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 { if (!bridge || !initialized) return null; try { - const result = await bridge.sendCommand('add_leg', { + const result = await sendProxyCommand('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 result.leg_id || null; + } catch (error: unknown) { + logFn?.(`[proxy-engine] add_leg error: ${errorMessage(error)}`); 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 { if (!bridge || !initialized) return false; 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; - } catch (e: any) { - logFn?.(`[proxy-engine] remove_leg error: ${e?.message || e}`); + } catch (error: unknown) { + logFn?.(`[proxy-engine] remove_leg error: ${errorMessage(error)}`); return false; } } @@ -373,7 +349,7 @@ export async function removeLeg(callId: string, legId: string): Promise export async function webrtcClose(sessionId: string): Promise { if (!bridge || !initialized) return; try { - await bridge.sendCommand('webrtc_close', { session_id: sessionId } as any); + await sendProxyCommand('webrtc_close', { session_id: sessionId }); } catch { /* ignore */ } } @@ -387,13 +363,13 @@ export async function webrtcClose(sessionId: string): Promise { export async function addDeviceLeg(callId: string, deviceId: string): Promise { if (!bridge || !initialized) return null; try { - const result = await bridge.sendCommand('add_device_leg', { + const result = await sendProxyCommand('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 result.leg_id || null; + } catch (error: unknown) { + logFn?.(`[proxy-engine] add_device_leg error: ${errorMessage(error)}`); return null; } } @@ -408,14 +384,14 @@ export async function transferLeg( ): Promise { if (!bridge || !initialized) return false; try { - await bridge.sendCommand('transfer_leg', { + await sendProxyCommand('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}`); + } catch (error: unknown) { + logFn?.(`[proxy-engine] transfer_leg error: ${errorMessage(error)}`); return false; } } @@ -431,15 +407,15 @@ export async function replaceLeg( ): Promise { if (!bridge || !initialized) return null; try { - const result = await bridge.sendCommand('replace_leg', { + const result = await sendProxyCommand('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 result.new_leg_id || null; + } catch (error: unknown) { + logFn?.(`[proxy-engine] replace_leg error: ${errorMessage(error)}`); return null; } } @@ -457,16 +433,15 @@ export async function startInteraction( ): Promise<{ result: 'digit' | 'timeout' | 'cancelled'; digit?: string } | null> { if (!bridge || !initialized) return null; try { - const result = await bridge.sendCommand('start_interaction', { + return await sendProxyCommand('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}`); + }); + } catch (error: unknown) { + logFn?.(`[proxy-engine] start_interaction error: ${errorMessage(error)}`); return null; } } @@ -482,14 +457,14 @@ export async function addToolLeg( ): Promise { if (!bridge || !initialized) return null; try { - const result = await bridge.sendCommand('add_tool_leg', { + const result = await sendProxyCommand('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 result.tool_leg_id || null; + } catch (error: unknown) { + logFn?.(`[proxy-engine] add_tool_leg error: ${errorMessage(error)}`); return null; } } @@ -500,13 +475,13 @@ export async function addToolLeg( export async function removeToolLeg(callId: string, toolLegId: string): Promise { if (!bridge || !initialized) return false; try { - await bridge.sendCommand('remove_tool_leg', { + await sendProxyCommand('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}`); + } catch (error: unknown) { + logFn?.(`[proxy-engine] remove_tool_leg error: ${errorMessage(error)}`); return false; } } @@ -522,15 +497,15 @@ export async function setLegMetadata( ): Promise { if (!bridge || !initialized) return false; try { - await bridge.sendCommand('set_leg_metadata', { + await sendProxyCommand('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}`); + } catch (error: unknown) { + logFn?.(`[proxy-engine] set_leg_metadata error: ${errorMessage(error)}`); return false; } } @@ -542,7 +517,7 @@ export async function setLegMetadata( * 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 { +export function onProxyEvent(event: K, handler: (data: TProxyEventMap[K]) => void): void { if (!bridge) throw new Error('proxy engine not initialized'); bridge.on(`management:${event}`, handler); } diff --git a/ts/runtime/proxy-events.ts b/ts/runtime/proxy-events.ts new file mode 100644 index 0000000..c5c3cee --- /dev/null +++ b/ts/runtime/proxy-events.ts @@ -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}`); + }); +} diff --git a/ts/runtime/status-store.ts b/ts/runtime/status-store.ts new file mode 100644 index 0000000..b104c9b --- /dev/null +++ b/ts/runtime/status-store.ts @@ -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 = { + 0: 'PCMU', + 8: 'PCMA', + 9: 'G.722', + 111: 'Opus', +}; + +export class StatusStore { + private appConfig: IAppConfig; + private providerStatuses = new Map(); + private deviceStatuses = new Map(); + private activeCalls = new Map(); + 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, + ): 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(); + 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(); + 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'; + } +} diff --git a/ts/runtime/webrtc-linking.ts b/ts/runtime/webrtc-linking.ts new file mode 100644 index 0000000..92d5277 --- /dev/null +++ b/ts/runtime/webrtc-linking.ts @@ -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(); + private callToSession = new Map(); + private pendingCallMedia = new Map(); + + 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; + } +} diff --git a/ts/shared/proxy-events.ts b/ts/shared/proxy-events.ts new file mode 100644 index 0000000..5cc87e3 --- /dev/null +++ b/ts/shared/proxy-events.ts @@ -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; +} + +export interface ILegRemovedEvent { + call_id: string; + leg_id: string; +} + +export interface ILegStateChangedEvent { + call_id: string; + leg_id: string; + state: string; + metadata?: Record; +} + +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; +}; diff --git a/ts/shared/status.ts b/ts/shared/status.ts new file mode 100644 index 0000000..2351300 --- /dev/null +++ b/ts/shared/status.ts @@ -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; +} + +export interface IActiveCall { + id: string; + direction: TCallDirection; + callerNumber: string | null; + calleeNumber: string | null; + providerUsed: string | null; + state: string; + startedAt: number; + legs: Map; +} + +export interface IHistoryLeg { + id: string; + type: string; + metadata: Record; +} + +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; +} diff --git a/ts/sipproxy.ts b/ts/sipproxy.ts index 1abbd36..ca8b309 100644 --- a/ts/sipproxy.ts +++ b/ts/sipproxy.ts @@ -1,34 +1,20 @@ /** - * SIP proxy — entry point. + * SIP proxy bootstrap. * - * 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. + * Spawns the Rust proxy-engine, wires runtime state/event handling, + * and starts the web dashboard plus browser signaling layer. */ import fs from 'node:fs'; import path from 'node:path'; -import { loadConfig } from './config.ts'; -import type { IAppConfig } from './config.ts'; +import { loadConfig, type IAppConfig } from './config.ts'; import { broadcastWs, initWebUi } from './frontend.ts'; -import { - initWebRtcSignaling, - sendToBrowserDevice, - getAllBrowserDeviceIds, - getBrowserDeviceWs, -} from './webrtcbridge.ts'; +import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.ts'; import { VoiceboxManager } from './voicebox.ts'; import { initProxyEngine, configureProxyEngine, - onProxyEvent, hangupCall, makeCall, shutdownProxyEngine, @@ -36,628 +22,200 @@ import { webrtcIce, webrtcLink, webrtcClose, - addLeg, - removeLeg, } from './proxybridge.ts'; -import type { - IIncomingCallEvent, - IOutboundCallEvent, - ICallEndedEvent, - IProviderRegisteredEvent, - IDeviceRegisteredEvent, -} from './proxybridge.ts'; - -// --------------------------------------------------------------------------- -// Config -// --------------------------------------------------------------------------- +import { registerProxyEventHandlers } from './runtime/proxy-events.ts'; +import { StatusStore } from './runtime/status-store.ts'; +import { WebRtcLinkManager, type IProviderMediaInfo } from './runtime/webrtc-linking.ts'; 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)}`; +const statusStore = new StatusStore(appConfig); +const webRtcLinks = new WebRtcLinkManager(); +const voiceboxManager = new VoiceboxManager(log); + +voiceboxManager.init(appConfig.voiceboxes ?? []); +initWebRtcSignaling({ log }); + function now(): string { return new Date().toISOString().replace('T', ' ').slice(0, 19); } -function log(msg: string): void { - const line = `${now()} ${msg}\n`; +function log(message: string): void { + const line = `${now()} ${message}\n`; fs.appendFileSync(LOG_PATH, line); process.stdout.write(line); - broadcastWs('log', { message: msg }); + broadcastWs('log', { message }); } -// --------------------------------------------------------------------------- -// Shadow state — maintained from Rust events for the dashboard -// --------------------------------------------------------------------------- - -interface IProviderStatus { - id: string; - displayName: string; - registered: boolean; - publicIp: string | null; +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } -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; -} - -interface IActiveCall { - id: string; - direction: string; - callerNumber: string | null; - calleeNumber: string | null; - providerUsed: string | null; - state: string; - startedAt: number; - legs: Map; -} - -interface IHistoryLeg { - id: string; - type: string; - metadata: Record; -} - -interface ICallHistoryEntry { - id: string; - direction: string; - callerNumber: string | null; - calleeNumber: string | null; - startedAt: number; - duration: number; - legs: IHistoryLeg[]; -} - -const providerStatuses = new Map(); -const deviceStatuses = new Map(); -const activeCalls = new Map(); -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(); // sessionId → callId -const webrtcCallToSession = new Map(); // callId → sessionId -const pendingCallMedia = new Map(); // 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, - }); - } - +function buildProxyConfig(config: IAppConfig): Record { 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(), + proxy: config.proxy, + providers: config.providers, + devices: config.devices, + routing: config.routing, + voiceboxes: config.voiceboxes ?? [], + ivr: config.ivr, }; } -// --------------------------------------------------------------------------- -// Start Rust proxy engine -// --------------------------------------------------------------------------- +function getStatus() { + 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 { + return configureProxyEngine(buildProxyConfig(config)); +} + +async function reloadConfig(): Promise { + 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 { - const ok = await initProxyEngine(log); - if (!ok) { + const started = await initProxyEngine(log); + if (!started) { 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 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 = { 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, + registerProxyEventHandlers({ + log, + statusStore, + voiceboxManager, + webRtcLinks, + getBrowserDeviceIds: getAllBrowserDeviceIds, + sendToBrowserDevice, + broadcast: broadcastWs, + onLinkWebRtcSession: requestWebRtcLink, + onCloseWebRtcSession: (sessionId) => { + void webrtcClose(sessionId); + }, }); + const configured = await configureRuntime(appConfig); 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(', '); + const providerList = appConfig.providers.map((provider) => provider.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}`); - - // TTS prompts (voicemail greetings, IVR menus) are generated on-demand - // by the Rust TTS engine when first needed. No startup pre-generation. } -// --------------------------------------------------------------------------- -// Web UI -// --------------------------------------------------------------------------- - -initWebUi( +initWebUi({ + port: appConfig.proxy.webUiPort, getStatus, log, - (number, deviceId, providerId) => { - // Outbound calls from dashboard — send make_call command to Rust. + onStartCall: (number, deviceId, providerId) => { 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) => { + void 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(), - }); + statusStore.noteDashboardCallStarted(callId, number, providerId); } 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); + onHangupCall: (callId) => { + void 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, - 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) => { + onConfigSaved: reloadConfig, + voiceboxManager, + onWebRtcOffer: 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`); + return; } + + log('[webrtc] ERROR: no answer SDP from Rust'); }, - async (sessionId, candidate) => { + onWebRtcIce: async (sessionId, candidate) => { await webrtcIce(sessionId, candidate); }, - async (sessionId) => { + onWebRtcClose: async (sessionId) => { + webRtcLinks.removeSession(sessionId); await webrtcClose(sessionId); }, - // onWebRtcAccept — browser has accepted a call, linking session to call. - (callId: string, sessionId: string) => { + onWebRtcAccept: (callId, sessionId) => { 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`); + const pendingMedia = webRtcLinks.acceptCall(callId, sessionId); + if (pendingMedia) { + requestWebRtcLink(callId, sessionId, pendingMedia); + return; } + + log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`); }, -); +}); -// --------------------------------------------------------------------------- -// Start -// --------------------------------------------------------------------------- +void startProxyEngine(); -startProxyEngine(); +process.on('SIGINT', () => { + log('SIGINT, exiting'); + shutdownProxyEngine(); + process.exit(0); +}); -process.on('SIGINT', () => { log('SIGINT, exiting'); shutdownProxyEngine(); process.exit(0); }); -process.on('SIGTERM', () => { log('SIGTERM, exiting'); shutdownProxyEngine(); process.exit(0); }); +process.on('SIGTERM', () => { + log('SIGTERM, exiting'); + shutdownProxyEngine(); + process.exit(0); +}); diff --git a/ts/webrtcbridge.ts b/ts/webrtcbridge.ts index 1f034cf..230b24d 100644 --- a/ts/webrtcbridge.ts +++ b/ts/webrtcbridge.ts @@ -5,8 +5,8 @@ * - Browser device registration/unregistration via WebSocket * - WS → deviceId mapping * - * All WebRTC media logic (PeerConnection, RTP, transcoding) lives in - * ts/call/webrtc-leg.ts and is managed by the CallManager. + * All WebRTC media logic (PeerConnection, RTP, transcoding, mixer wiring) + * lives in the Rust proxy-engine. This module only tracks browser sessions. */ import { WebSocket } from 'ws'; @@ -39,7 +39,7 @@ export function initWebRtcSignaling(cfg: IWebRtcSignalingConfig): void { /** * 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( ws: WebSocket, @@ -51,7 +51,7 @@ export function handleWebRtcSignaling( handleRegister(ws, message.sessionId!, message.userAgent, message._remoteIp); } // 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; } -/** - * 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. */ diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 7c6514f..6c41e43 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.22.0', + version: '1.23.0', description: 'undefined' } diff --git a/ts_web/elements/sipproxy-devices.ts b/ts_web/elements/sipproxy-devices.ts index 8d3cf89..814a98d 100644 --- a/ts_web/elements/sipproxy-devices.ts +++ b/ts_web/elements/sipproxy-devices.ts @@ -41,11 +41,10 @@ export class SipproxyDevices extends DeesElement { }, }, { - key: 'contact', + key: 'address', header: 'Contact', renderer: (_val: any, row: any) => { - const c = row.contact; - const text = c ? (c.port ? `${c.address}:${c.port}` : c.address) : '--'; + const text = row.address ? (row.port ? `${row.address}:${row.port}` : row.address) : '--'; return html`${text}`; }, }, diff --git a/ts_web/elements/sipproxy-view-overview.ts b/ts_web/elements/sipproxy-view-overview.ts index 90b224a..bed5e5b 100644 --- a/ts_web/elements/sipproxy-view-overview.ts +++ b/ts_web/elements/sipproxy-view-overview.ts @@ -186,11 +186,10 @@ export class SipproxyViewOverview extends DeesElement { }, }, { - key: 'contact', + key: 'address', header: 'Contact', renderer: (_val: any, row: any) => { - const c = row.contact; - const text = c ? (c.port ? `${c.address}:${c.port}` : c.address) : '--'; + const text = row.address ? (row.port ? `${row.address}:${row.port}` : row.address) : '--'; return html`${text}`; }, }, diff --git a/ts_web/state/appstate.ts b/ts_web/state/appstate.ts index 8790be9..bf20ccd 100644 --- a/ts_web/state/appstate.ts +++ b/ts_web/state/appstate.ts @@ -2,72 +2,12 @@ * Application state — receives live updates from the proxy via WebSocket. */ -export interface IProviderStatus { - id: string; - displayName: string; - registered: boolean; - publicIp: string | null; -} +import type { IContact } from '../../ts/config.ts'; +import type { ICallHistoryEntry, ICallStatus, IDeviceStatus, IProviderStatus } from '../../ts/shared/status.ts'; -export interface IDeviceStatus { - id: string; - displayName: string; - 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; -} - -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; -} - -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 type { IContact }; +export type { ICallHistoryEntry, ICallStatus, IDeviceStatus, IProviderStatus }; +export type { ILegStatus } from '../../ts/shared/status.ts'; export interface IAppState { connected: boolean;