feat(runtime): refactor runtime state and proxy event handling for typed WebRTC linking and shared status models
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user