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
+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 */ }
});