314 lines
8.0 KiB
TypeScript
314 lines
8.0 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,
|
||
|
|
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';
|
||
|
|
}
|
||
|
|
}
|