feat(fax): add fax routing, job tracking, inbox management, and T.38/UDPTL media support
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user