feat(fax): add fax routing, job tracking, inbox management, and T.38/UDPTL media support

This commit is contained in:
2026-04-20 20:43:42 +00:00
parent 3c010a3b1b
commit d2c18a4ebb
27 changed files with 4247 additions and 280 deletions
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: 'siprouter',
version: '1.25.2',
version: '1.26.0',
description: 'undefined'
}
+12
View File
@@ -8,6 +8,7 @@
import fs from 'node:fs';
import path from 'node:path';
import type { IFaxBoxConfig } from './faxbox.ts';
import type { IVoiceboxConfig } from './voicebox.js';
// ---------------------------------------------------------------------------
@@ -113,6 +114,9 @@ export interface ISipRouteAction {
/** Voicemail fallback for matched inbound routes. */
voicemailBox?: string;
/** Fax inbox target for matched inbound routes. */
faxBox?: string;
/** Route to an IVR menu by menu ID (skip ringing devices). */
ivrMenuId?: string;
@@ -189,6 +193,7 @@ export interface IContact {
// "number | undefined is not assignable to number" type errors when
// passing config.voiceboxes into VoiceboxManager.init().
export type { IVoiceboxConfig };
export type { IFaxBoxConfig };
// ---------------------------------------------------------------------------
// IVR configuration
@@ -255,6 +260,7 @@ export interface IAppConfig {
incomingNumbers?: IIncomingNumberConfig[];
routing: IRoutingConfig;
contacts: IContact[];
faxboxes?: IFaxBoxConfig[];
voiceboxes?: IVoiceboxConfig[];
ivr?: IIvrConfig;
}
@@ -323,6 +329,12 @@ export function loadConfig(): IAppConfig {
c.starred ??= false;
}
cfg.faxboxes ??= [];
for (const fb of cfg.faxboxes) {
fb.enabled ??= true;
fb.maxMessages ??= 50;
}
// Voicebox defaults.
cfg.voiceboxes ??= [];
for (const vb of cfg.voiceboxes) {
+149
View File
@@ -0,0 +1,149 @@
import fs from 'node:fs';
import path from 'node:path';
export interface IFaxBoxConfig {
id: string;
enabled: boolean;
maxMessages?: number;
}
export interface IFaxMessage {
id: string;
boxId: string;
callerNumber?: string;
timestamp: number;
fileName: string;
completionCode?: number | null;
completionLabel?: string | null;
pageCount?: number;
bitRate?: number;
}
export class FaxBoxManager {
private boxes = new Map<string, IFaxBoxConfig>();
private readonly basePath: string;
private readonly log: (msg: string) => void;
constructor(log: (msg: string) => void) {
this.basePath = path.join(process.cwd(), '.nogit', 'fax', 'inboxes');
this.log = log;
}
init(faxBoxConfigs: IFaxBoxConfig[]): void {
this.boxes.clear();
for (const cfg of faxBoxConfigs) {
cfg.enabled ??= true;
cfg.maxMessages ??= 50;
this.boxes.set(cfg.id, cfg);
}
fs.mkdirSync(this.basePath, { recursive: true });
this.log(`[faxbox] initialized ${this.boxes.size} fax box(es)`);
}
getBox(boxId: string): IFaxBoxConfig | null {
return this.boxes.get(boxId) ?? null;
}
getBoxDir(boxId: string): string {
return path.join(this.basePath, boxId);
}
addMessage(
boxId: string,
info: {
callerNumber?: string;
fileName: string;
completionCode?: number | null;
completionLabel?: string | null;
pageCount?: number;
bitRate?: number;
},
): void {
const msg: IFaxMessage = {
id: crypto.randomUUID(),
boxId,
callerNumber: info.callerNumber,
timestamp: Date.now(),
fileName: path.basename(info.fileName),
completionCode: info.completionCode ?? null,
completionLabel: info.completionLabel ?? null,
pageCount: info.pageCount,
bitRate: info.bitRate,
};
this.saveMessage(msg);
}
saveMessage(msg: IFaxMessage): void {
const boxDir = this.getBoxDir(msg.boxId);
fs.mkdirSync(boxDir, { recursive: true });
const messages = this.loadMessages(msg.boxId);
messages.unshift(msg);
const box = this.boxes.get(msg.boxId);
const maxMessages = box?.maxMessages ?? 50;
while (messages.length > maxMessages) {
const old = messages.pop()!;
const oldPath = path.join(boxDir, old.fileName);
try {
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
} catch {}
}
this.writeMessages(msg.boxId, messages);
this.log(`[faxbox] saved fax ${msg.id} in box "${msg.boxId}" (${msg.fileName})`);
}
getMessages(boxId: string): IFaxMessage[] {
return this.loadMessages(boxId);
}
getMessage(boxId: string, messageId: string): IFaxMessage | null {
return this.loadMessages(boxId).find((m) => m.id === messageId) ?? null;
}
getMessageFilePath(boxId: string, messageId: string): string | null {
const msg = this.getMessage(boxId, messageId);
if (!msg) return null;
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
return fs.existsSync(filePath) ? filePath : null;
}
deleteMessage(boxId: string, messageId: string): boolean {
const messages = this.loadMessages(boxId);
const idx = messages.findIndex((m) => m.id === messageId);
if (idx === -1) return false;
const msg = messages[idx];
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
try {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch {}
messages.splice(idx, 1);
this.writeMessages(boxId, messages);
return true;
}
private messagesPath(boxId: string): string {
return path.join(this.getBoxDir(boxId), 'messages.json');
}
private loadMessages(boxId: string): IFaxMessage[] {
const filePath = this.messagesPath(boxId);
try {
if (!fs.existsSync(filePath)) return [];
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as IFaxMessage[];
} catch {
return [];
}
}
private writeMessages(boxId: string, messages: IFaxMessage[]): void {
const boxDir = this.getBoxDir(boxId);
fs.mkdirSync(boxDir, { recursive: true });
fs.writeFileSync(this.messagesPath(boxId), JSON.stringify(messages, null, 2), 'utf8');
}
}
+153
View File
@@ -0,0 +1,153 @@
import fs from 'node:fs';
import path from 'node:path';
import type {
IFaxCompletedEvent,
IFaxFailedEvent,
IFaxStartedEvent,
} from './shared/proxy-events.ts';
export interface IFaxJob {
id: string;
callId: string;
number?: string;
providerId?: string;
direction: 'outbound' | 'inbound';
status: 'dialing' | 'started' | 'completed' | 'failed';
transport?: 'audio' | 't38';
filePath?: string;
codec?: string;
remoteMedia?: string;
success?: boolean;
completionCode?: number | null;
completionLabel?: string | null;
error?: string;
stats?: IFaxCompletedEvent['stats'];
createdAt: number;
updatedAt: number;
}
export class FaxJobManager {
private readonly basePath: string;
private readonly jobsPath: string;
private readonly log: (msg: string) => void;
constructor(log: (msg: string) => void) {
this.basePath = path.join(process.cwd(), '.nogit', 'fax');
this.jobsPath = path.join(this.basePath, 'jobs.json');
this.log = log;
}
init(): void {
fs.mkdirSync(this.basePath, { recursive: true });
if (!fs.existsSync(this.jobsPath)) {
this.writeJobs([]);
}
}
noteDialing(callId: string, number: string, providerId: string): void {
const jobs = this.loadJobs();
const now = Date.now();
const existing = jobs.find((job) => job.callId === callId);
if (existing) {
existing.number = number;
existing.providerId = providerId;
existing.updatedAt = now;
} else {
jobs.unshift({
id: callId,
callId,
number,
providerId,
direction: 'outbound',
status: 'dialing',
createdAt: now,
updatedAt: now,
});
}
this.writeJobs(jobs);
}
noteStarted(event: IFaxStartedEvent): void {
const jobs = this.loadJobs();
const now = Date.now();
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now);
job.status = 'started';
job.transport = event.transport;
job.filePath = event.file_path;
job.codec = event.codec;
job.remoteMedia = event.remote_media;
job.updatedAt = now;
this.writeJobs(jobs);
}
noteCompleted(event: IFaxCompletedEvent): void {
const jobs = this.loadJobs();
const now = Date.now();
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now);
job.status = 'completed';
job.transport = event.transport;
job.filePath = event.file_path;
job.codec = event.codec;
job.success = event.success;
job.completionCode = event.completion_code ?? null;
job.completionLabel = event.completion_label ?? null;
job.stats = event.stats;
job.updatedAt = now;
this.writeJobs(jobs);
}
noteFailed(event: IFaxFailedEvent): void {
const jobs = this.loadJobs();
const now = Date.now();
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now);
job.status = 'failed';
job.transport = event.transport;
job.filePath = event.file_path;
job.error = event.error;
job.success = false;
job.updatedAt = now;
this.writeJobs(jobs);
}
getJobs(): IFaxJob[] {
return this.loadJobs();
}
private getOrCreateJob(
jobs: IFaxJob[],
callId: string,
direction: 'outbound' | 'inbound',
now: number,
): IFaxJob {
let job = jobs.find((entry) => entry.callId === callId);
if (!job) {
job = {
id: callId,
callId,
direction,
status: 'dialing',
createdAt: now,
updatedAt: now,
};
jobs.unshift(job);
}
return job;
}
private loadJobs(): IFaxJob[] {
try {
const content = fs.readFileSync(this.jobsPath, 'utf8');
const parsed = JSON.parse(content);
return Array.isArray(parsed) ? parsed as IFaxJob[] : [];
} catch {
return [];
}
}
private writeJobs(jobs: IFaxJob[]): void {
fs.mkdirSync(this.basePath, { recursive: true });
fs.writeFileSync(this.jobsPath, JSON.stringify(jobs, null, 2));
this.log(`[fax] persisted ${jobs.length} job(s)`);
}
}
+72 -4
View File
@@ -11,6 +11,8 @@ import path from 'node:path';
import http from 'node:http';
import https from 'node:https';
import { WebSocketServer, WebSocket } from 'ws';
import type { FaxBoxManager } from './faxbox.ts';
import type { FaxJobManager } from './faxjobs.ts';
import { handleWebRtcSignaling } from './webrtcbridge.ts';
import type { VoiceboxManager } from './voicebox.ts';
@@ -22,6 +24,8 @@ interface IHandleRequestContext {
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null;
onHangupCall: (callId: string) => boolean;
onConfigSaved?: () => void | Promise<void>;
faxBoxManager?: FaxBoxManager;
faxJobManager?: FaxJobManager;
voiceboxManager?: VoiceboxManager;
}
@@ -108,7 +112,7 @@ async function handleRequest(
res: http.ServerResponse,
context: IHandleRequestContext,
): Promise<void> {
const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager } = context;
const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager } = context;
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const method = req.method || 'GET';
@@ -147,6 +151,65 @@ async function handleRequest(
}
}
// API: send outbound fax.
if (url.pathname === '/api/fax' && method === 'POST') {
try {
const body = await readJsonBody(req);
const number = body?.number;
const filePath = body?.filePath;
if (!number || typeof number !== 'string') {
return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
}
if (!filePath || typeof filePath !== 'string') {
return sendJson(res, { ok: false, error: 'missing "filePath" field' }, 400);
}
const { sendFax } = await import('./proxybridge.ts');
const callId = await sendFax(number, filePath, body?.providerId);
if (callId) {
log(`[dashboard] fax started: ${callId} -> ${number} file=${filePath}`);
return sendJson(res, { ok: true, callId });
}
return sendJson(res, { ok: false, error: 'fax origination failed' }, 503);
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// API: fax jobs.
if (url.pathname === '/api/fax/jobs' && method === 'GET' && faxJobManager) {
return sendJson(res, { ok: true, jobs: faxJobManager.getJobs() });
}
// API: fax inbox - list messages.
const faxListMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)$/);
if (faxListMatch && method === 'GET' && faxBoxManager) {
const boxId = faxListMatch[1];
return sendJson(res, { ok: true, messages: faxBoxManager.getMessages(boxId) });
}
// API: fax inbox - stream TIFF.
const faxFileMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)\/file$/);
if (faxFileMatch && method === 'GET' && faxBoxManager) {
const [, boxId, msgId] = faxFileMatch;
const filePath = faxBoxManager.getMessageFilePath(boxId, msgId);
if (!filePath) return sendJson(res, { ok: false, error: 'not found' }, 404);
const stat = fs.statSync(filePath);
res.writeHead(200, {
'Content-Type': 'image/tiff',
'Content-Length': stat.size.toString(),
'Accept-Ranges': 'bytes',
});
fs.createReadStream(filePath).pipe(res);
return;
}
// API: fax inbox - delete message.
const faxDeleteMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)$/);
if (faxDeleteMatch && method === 'DELETE' && faxBoxManager) {
const [, boxId, msgId] = faxDeleteMatch;
return sendJson(res, { ok: faxBoxManager.deleteMessage(boxId, msgId) });
}
// API: add a SIP device to a call (mid-call INVITE to desk phone).
if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addleg') && method === 'POST') {
try {
@@ -273,6 +336,7 @@ async function handleRequest(
}
}
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
if (updates.faxboxes !== undefined) cfg.faxboxes = updates.faxboxes;
if (updates.voiceboxes !== undefined) cfg.voiceboxes = updates.voiceboxes;
if (updates.ivr !== undefined) cfg.ivr = updates.ivr;
@@ -368,6 +432,8 @@ export function initWebUi(
onStartCall,
onHangupCall,
onConfigSaved,
faxBoxManager,
faxJobManager,
voiceboxManager,
onWebRtcOffer,
onWebRtcIce,
@@ -387,12 +453,12 @@ export function initWebUi(
const cert = fs.readFileSync(certPath, 'utf8');
const key = fs.readFileSync(keyPath, 'utf8');
server = https.createServer({ cert, key }, (req, res) =>
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
);
useTls = true;
} catch {
server = http.createServer((req, res) =>
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
);
}
@@ -429,7 +495,9 @@ export function initWebUi(
}
} else if (msg.type?.startsWith('webrtc-')) {
msg._remoteIp = remoteIp;
handleWebRtcSignaling(socket, msg);
if (msg.type) {
handleWebRtcSignaling(socket, msg as IWebRtcSocketMessage & { type: string });
}
}
} catch { /* ignore */ }
});
+22
View File
@@ -17,6 +17,9 @@ export type {
ICallEndedEvent,
ICallRingingEvent,
IDeviceRegisteredEvent,
IFaxCompletedEvent,
IFaxFailedEvent,
IFaxStartedEvent,
IIncomingCallEvent,
ILegAddedEvent,
ILegRemovedEvent,
@@ -52,6 +55,10 @@ type TProxyCommands = {
params: { number: string; device_id?: string; provider_id?: string };
result: { call_id: string };
};
send_fax: {
params: { number: string; file_path: string; provider_id?: string };
result: { call_id: string; codec: 'PCMU' | 'PCMA' };
};
add_leg: {
params: { call_id: string; number: string; provider_id?: string };
result: { leg_id: string };
@@ -262,6 +269,21 @@ export async function makeCall(number: string, deviceId?: string, providerId?: s
}
}
export async function sendFax(number: string, filePath: string, providerId?: string): Promise<string | null> {
if (!bridge || !initialized) return null;
try {
const result = await sendProxyCommand('send_fax', {
number,
file_path: filePath,
provider_id: providerId,
});
return result.call_id || null;
} catch (error: unknown) {
logFn?.(`[proxy-engine] send_fax error: ${errorMessage(error)}`);
return null;
}
}
/**
* Send a hangup command.
*/
+56 -1
View File
@@ -1,4 +1,6 @@
import { onProxyEvent } from '../proxybridge.ts';
import { hangupCall, onProxyEvent } from '../proxybridge.ts';
import type { FaxBoxManager } from '../faxbox.ts';
import type { FaxJobManager } from '../faxjobs.ts';
import type { VoiceboxManager } from '../voicebox.ts';
import type { StatusStore } from './status-store.ts';
import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts';
@@ -6,6 +8,8 @@ import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts'
export interface IRegisterProxyEventHandlersOptions {
log: (msg: string) => void;
statusStore: StatusStore;
faxBoxManager: FaxBoxManager;
faxJobManager: FaxJobManager;
voiceboxManager: VoiceboxManager;
webRtcLinks: WebRtcLinkManager;
getBrowserDeviceIds: () => string[];
@@ -19,6 +23,8 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
const {
log,
statusStore,
faxBoxManager,
faxJobManager,
voiceboxManager,
webRtcLinks,
getBrowserDeviceIds,
@@ -30,6 +36,7 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
const legMediaDetails = (data: {
codec?: string | null;
mediaProtocol?: string | null;
remoteMedia?: string | null;
rtpPort?: number | null;
}): string => {
@@ -37,6 +44,9 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
if (data.codec) {
parts.push(`codec=${data.codec}`);
}
if (data.mediaProtocol) {
parts.push(`media=${data.mediaProtocol}`);
}
if (data.remoteMedia) {
parts.push(`remote=${data.remoteMedia}`);
}
@@ -91,6 +101,14 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
log(`[call] outbound started: ${data.call_id} -> ${data.number} via ${data.provider_id}`);
statusStore.noteOutboundCallStarted(data);
if (data.ring_browsers === false) {
faxJobManager.noteDialing(data.call_id, data.number, data.provider_id);
}
if (data.ring_browsers === false) {
return;
}
for (const deviceId of getBrowserDeviceIds()) {
sendToBrowserDevice(deviceId, {
type: 'webrtc-incoming',
@@ -110,6 +128,10 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
log(`[call] ${data.call_id} connected`);
}
if (data.media_protocol && data.media_protocol !== 'rtp') {
return;
}
if (!data.provider_media_addr || !data.provider_media_port) {
return;
}
@@ -207,4 +229,37 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
onProxyEvent('voicemail_error', (data) => {
log(`[voicemail] error: ${data.error} call=${data.call_id}`);
});
onProxyEvent('fax_started', (data) => {
faxJobManager.noteStarted(data);
log(`[fax] started: call=${data.call_id} leg=${data.leg_id} ${data.direction}/${data.transport} codec=${data.codec || '?'} file=${data.file_path}`);
});
onProxyEvent('fax_completed', (data) => {
faxJobManager.noteCompleted(data);
log(
`[fax] completed: call=${data.call_id} leg=${data.leg_id} success=${data.success} pagesTx=${data.stats.pages_tx} bitrate=${data.stats.bit_rate} completion=${data.completion_label || data.completion_code || 'unknown'}`,
);
if (data.direction === 'inbound' && data.success && data.fax_box_id) {
faxBoxManager.addMessage(data.fax_box_id, {
callerNumber: data.caller_number,
fileName: data.file_path,
completionCode: data.completion_code,
completionLabel: data.completion_label,
pageCount: data.stats.pages_rx || data.stats.pages_tx,
bitRate: data.stats.bit_rate,
});
}
if (data.direction === 'outbound' || data.fax_box_id) {
void hangupCall(data.call_id);
}
});
onProxyEvent('fax_failed', (data) => {
faxJobManager.noteFailed(data);
log(`[fax] failed: call=${data.call_id} leg=${data.leg_id} error=${data.error}`);
if (data.direction === 'outbound' || data.fax_box_id) {
void hangupCall(data.call_id);
}
});
}
+66 -64
View File
@@ -88,16 +88,12 @@ export class StatusStore {
}
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(),
});
const call = this.getOrCreateCall(callId, 'outbound');
call.direction = 'outbound';
call.callerNumber = null;
call.calleeNumber = number;
call.providerUsed = providerId || null;
call.state = 'setting-up';
}
noteProviderRegistered(data: IProviderRegisteredEvent): { wasRegistered: boolean } | null {
@@ -126,56 +122,39 @@ export class StatusStore {
}
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) {
const call = this.getOrCreateCall(data.call_id, 'inbound');
call.direction = 'inbound';
call.callerNumber = data.from_uri;
call.calleeNumber = data.to_number;
call.providerUsed = data.provider_id;
if (call.state === 'setting-up') {
call.state = 'ringing';
}
}
noteOutboundDeviceCall(data: IOutboundCallEvent): void {
const call = this.getOrCreateCall(data.call_id, 'outbound');
call.direction = 'outbound';
call.callerNumber = data.from_device;
call.calleeNumber = data.to_number;
call.providerUsed = null;
}
noteOutboundCallStarted(data: IOutboundCallStartedEvent): void {
const call = this.getOrCreateCall(data.call_id, 'outbound');
call.direction = 'outbound';
call.callerNumber = call.callerNumber ?? null;
call.calleeNumber = data.number;
call.providerUsed = data.provider_id;
}
noteCallRinging(data: ICallRingingEvent): void {
const call = this.getOrCreateCall(data.call_id);
call.state = 'ringing';
}
noteCallAnswered(data: ICallAnsweredEvent): boolean {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return false;
}
const call = this.getOrCreateCall(data.call_id);
call.state = 'connected';
@@ -186,7 +165,12 @@ export class StatusStore {
}
leg.remoteMedia = `${data.provider_media_addr}:${data.provider_media_port}`;
if (data.sip_pt !== undefined) {
if (data.media_protocol) {
leg.mediaProtocol = data.media_protocol;
}
if (data.media_protocol === 't38-udptl') {
leg.codec = 'T.38';
} else if (data.sip_pt !== undefined) {
leg.codec = CODEC_NAMES[data.sip_pt] || `PT${data.sip_pt}`;
}
break;
@@ -216,6 +200,7 @@ export class StatusStore {
state: leg.state,
codec: leg.codec,
rtpPort: leg.rtpPort,
mediaProtocol: leg.mediaProtocol,
remoteMedia: leg.remoteMedia,
metadata: leg.metadata || {},
})),
@@ -230,10 +215,7 @@ export class StatusStore {
}
noteLegAdded(data: ILegAddedEvent): void {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return;
}
const call = this.getOrCreateCall(data.call_id);
call.legs.set(data.leg_id, {
id: data.leg_id,
@@ -241,6 +223,7 @@ export class StatusStore {
state: data.state,
codec: data.codec ?? null,
rtpPort: data.rtpPort ?? null,
mediaProtocol: data.mediaProtocol ?? null,
remoteMedia: data.remoteMedia ?? null,
metadata: data.metadata || {},
});
@@ -251,10 +234,7 @@ export class StatusStore {
}
noteLegStateChanged(data: ILegStateChangedEvent): void {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return;
}
const call = this.getOrCreateCall(data.call_id);
const existingLeg = call.legs.get(data.leg_id);
if (existingLeg) {
@@ -265,6 +245,9 @@ export class StatusStore {
if (data.rtpPort !== undefined) {
existingLeg.rtpPort = data.rtpPort;
}
if (data.mediaProtocol !== undefined) {
existingLeg.mediaProtocol = data.mediaProtocol;
}
if (data.remoteMedia !== undefined) {
existingLeg.remoteMedia = data.remoteMedia;
}
@@ -280,6 +263,7 @@ export class StatusStore {
state: data.state,
codec: data.codec ?? null,
rtpPort: data.rtpPort ?? null,
mediaProtocol: data.mediaProtocol ?? null,
remoteMedia: data.remoteMedia ?? null,
metadata: data.metadata || {},
});
@@ -323,4 +307,22 @@ export class StatusStore {
}
return 'webrtc';
}
private getOrCreateCall(callId: string, direction: 'inbound' | 'outbound' = 'inbound'): IActiveCall {
let call = this.activeCalls.get(callId);
if (!call) {
call = {
id: callId,
direction,
callerNumber: null,
calleeNumber: null,
providerUsed: null,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
};
this.activeCalls.set(callId, call);
}
return call;
}
}
+57
View File
@@ -18,6 +18,7 @@ export interface IOutboundCallStartedEvent {
call_id: string;
number: string;
provider_id: string;
ring_browsers?: boolean;
}
export interface ICallRingingEvent {
@@ -28,6 +29,7 @@ export interface ICallAnsweredEvent {
call_id: string;
provider_media_addr?: string;
provider_media_port?: number;
media_protocol?: string;
sip_pt?: number;
}
@@ -67,6 +69,7 @@ export interface ILegAddedEvent {
state: string;
codec?: string | null;
rtpPort?: number | null;
mediaProtocol?: string | null;
remoteMedia?: string | null;
metadata?: Record<string, unknown>;
}
@@ -82,6 +85,7 @@ export interface ILegStateChangedEvent {
state: string;
codec?: string | null;
rtpPort?: number | null;
mediaProtocol?: string | null;
remoteMedia?: string | null;
metadata?: Record<string, unknown>;
}
@@ -128,6 +132,56 @@ export interface IVoicemailErrorEvent {
error: string;
}
export interface IFaxStartedEvent {
call_id: string;
leg_id: string;
direction: 'outbound' | 'inbound';
transport: 'audio' | 't38';
file_path: string;
fax_box_id?: string;
caller_number?: string;
codec?: string;
remote_media?: string;
}
export interface IFaxCompletedEvent {
call_id: string;
leg_id: string;
direction: 'outbound' | 'inbound';
transport: 'audio' | 't38';
file_path: string;
fax_box_id?: string;
caller_number?: string;
codec?: string;
success: boolean;
completion_code?: number | null;
completion_label?: string | null;
stats: {
bit_rate: number;
error_correcting_mode: boolean;
pages_tx: number;
pages_rx: number;
image_size: number;
bad_rows: number;
longest_bad_row_run: number;
ecm_retries: number;
current_status: number;
rtp_events: number;
rtn_events: number;
};
}
export interface IFaxFailedEvent {
call_id: string;
leg_id: string;
direction: 'outbound' | 'inbound';
transport: 'audio' | 't38';
file_path: string;
fax_box_id?: string;
caller_number?: string;
error: string;
}
export type TProxyEventMap = {
provider_registered: IProviderRegisteredEvent;
device_registered: IDeviceRegisteredEvent;
@@ -148,4 +202,7 @@ export type TProxyEventMap = {
voicemail_started: IVoicemailStartedEvent;
recording_done: IRecordingDoneEvent;
voicemail_error: IVoicemailErrorEvent;
fax_started: IFaxStartedEvent;
fax_completed: IFaxCompletedEvent;
fax_failed: IFaxFailedEvent;
};
+1
View File
@@ -26,6 +26,7 @@ export interface IActiveLeg {
state: string;
codec: string | null;
rtpPort: number | null;
mediaProtocol: string | null;
remoteMedia: string | null;
metadata: Record<string, unknown>;
}
+13 -1
View File
@@ -9,6 +9,8 @@ import fs from 'node:fs';
import path from 'node:path';
import { loadConfig, type IAppConfig } from './config.ts';
import { FaxBoxManager } from './faxbox.ts';
import { FaxJobManager } from './faxjobs.ts';
import { broadcastWs, initWebUi } from './frontend.ts';
import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.ts';
import { VoiceboxManager } from './voicebox.ts';
@@ -35,8 +37,12 @@ const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const statusStore = new StatusStore(appConfig);
const webRtcLinks = new WebRtcLinkManager();
const faxBoxManager = new FaxBoxManager(log);
const faxJobManager = new FaxJobManager(log);
const voiceboxManager = new VoiceboxManager(log);
faxBoxManager.init(appConfig.faxboxes ?? []);
faxJobManager.init();
voiceboxManager.init(appConfig.voiceboxes ?? []);
initWebRtcSignaling({ log });
@@ -61,6 +67,7 @@ function buildProxyConfig(config: IAppConfig): Record<string, unknown> {
providers: config.providers,
devices: config.devices,
routing: config.routing,
faxboxes: config.faxboxes ?? [],
voiceboxes: config.voiceboxes ?? [],
ivr: config.ivr,
};
@@ -93,6 +100,7 @@ async function reloadConfig(): Promise<void> {
appConfig = nextConfig;
statusStore.updateConfig(nextConfig);
faxBoxManager.init(nextConfig.faxboxes ?? []);
voiceboxManager.init(nextConfig.voiceboxes ?? []);
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
@@ -123,6 +131,8 @@ async function startProxyEngine(): Promise<void> {
registerProxyEventHandlers({
log,
statusStore,
faxBoxManager,
faxJobManager,
voiceboxManager,
webRtcLinks,
getBrowserDeviceIds: getAllBrowserDeviceIds,
@@ -167,6 +177,8 @@ initWebUi({
return true;
},
onConfigSaved: reloadConfig,
faxBoxManager,
faxJobManager,
voiceboxManager,
onWebRtcOffer: async (sessionId, sdp, ws) => {
log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`);
@@ -187,7 +199,7 @@ initWebUi({
log('[webrtc] ERROR: no answer SDP from Rust');
},
onWebRtcIce: async (sessionId, candidate) => {
await webrtcIce(sessionId, candidate);
await webrtcIce(sessionId, candidate as Parameters<typeof webrtcIce>[1]);
},
onWebRtcClose: async (sessionId) => {
webRtcLinks.removeSession(sessionId);