feat(fax): add fax routing, job tracking, inbox management, and T.38/UDPTL media support
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.25.2',
|
||||
version: '1.26.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 */ }
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user