feat(runtime): refactor runtime state and proxy event handling for typed WebRTC linking and shared status models
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
187
ts/runtime/proxy-events.ts
Normal 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
313
ts/runtime/status-store.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
66
ts/runtime/webrtc-linking.ts
Normal file
66
ts/runtime/webrtc-linking.ts
Normal 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
145
ts/shared/proxy-events.ts
Normal 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
89
ts/shared/status.ts
Normal 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>;
|
||||||
|
}
|
||||||
696
ts/sipproxy.ts
696
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.
|
* 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 {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildProxyConfig(config: IAppConfig): Record<string, unknown> {
|
||||||
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user