Files
siprouter/ts/runtime/status-store.ts

327 lines
8.5 KiB
TypeScript

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,
state: leg.state,
codec: leg.codec,
rtpPort: leg.rtpPort,
remoteMedia: leg.remoteMedia,
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.codec !== undefined) {
existingLeg.codec = data.codec;
}
if (data.rtpPort !== undefined) {
existingLeg.rtpPort = data.rtpPort;
}
if (data.remoteMedia !== undefined) {
existingLeg.remoteMedia = data.remoteMedia;
}
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: data.codec ?? null,
rtpPort: data.rtpPort ?? null,
remoteMedia: data.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';
}
}