fix: voip call history doesn't always log the correct call information (#35006)

pull/36311/head
Pierre Lehnen 6 months ago committed by GitHub
parent ba0cbd3265
commit fd478a7d45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      .changeset/shy-colts-appear.md
  2. 16
      apps/meteor/app/statistics/server/lib/getVoIPStatistics.ts
  3. 2
      apps/meteor/client/hooks/useVoipClient.ts
  4. 508
      apps/meteor/ee/server/local-services/voip-freeswitch/service.ts
  5. 10
      apps/meteor/server/models.ts
  6. 65
      packages/core-typings/src/voip/IFreeSwitchCall.ts
  7. 41
      packages/core-typings/src/voip/IFreeSwitchChannel.ts
  8. 177
      packages/core-typings/src/voip/IFreeSwitchChannelEvent.ts
  9. 24
      packages/core-typings/src/voip/IFreeSwitchChannelEventDelta.ts
  10. 113
      packages/core-typings/src/voip/IFreeSwitchEvent.ts
  11. 10
      packages/core-typings/src/voip/VoIPUserConfiguration.ts
  12. 5
      packages/core-typings/src/voip/index.ts
  13. 4
      packages/freeswitch/src/esl/eventClient.ts
  14. 153
      packages/freeswitch/src/eventParser/computeChannelFromEvents.ts
  15. 159
      packages/freeswitch/src/eventParser/computeChannelProfiles.ts
  16. 72
      packages/freeswitch/src/eventParser/extractChannelChangesFromEvent.ts
  17. 21
      packages/freeswitch/src/eventParser/filterOutMissingData.ts
  18. 16
      packages/freeswitch/src/eventParser/filterStringList.ts
  19. 44
      packages/freeswitch/src/eventParser/insertDataIntoEventProfile.ts
  20. 21
      packages/freeswitch/src/eventParser/parseChannelKind.ts
  21. 44
      packages/freeswitch/src/eventParser/parseChannelUsername.ts
  22. 39
      packages/freeswitch/src/eventParser/parseEventCallId.ts
  23. 168
      packages/freeswitch/src/eventParser/parseEventData.ts
  24. 113
      packages/freeswitch/src/eventParser/parseEventExtensions.ts
  25. 117
      packages/freeswitch/src/eventParser/parseEventLeg.ts
  26. 13
      packages/freeswitch/src/eventParser/parseTimestamp.ts
  27. 4
      packages/freeswitch/src/index.ts
  28. 706
      packages/freeswitch/tests/eventParser/computeChannelFromEvents.test.ts
  29. 385
      packages/freeswitch/tests/eventParser/computeChannelProfiles.test.ts
  30. 228
      packages/freeswitch/tests/eventParser/extractChannelChangesFromEvent.test.ts
  31. 14
      packages/freeswitch/tests/eventParser/filterOutMissingData.test.ts
  32. 112
      packages/freeswitch/tests/eventParser/filterStringList.test.ts
  33. 281
      packages/freeswitch/tests/eventParser/insertDataIntoEventProfile.test.ts
  34. 51
      packages/freeswitch/tests/eventParser/parseChannelKind.test.ts
  35. 98
      packages/freeswitch/tests/eventParser/parseChannelUsername.test.ts
  36. 103
      packages/freeswitch/tests/eventParser/parseEventCallId.test.ts
  37. 5078
      packages/freeswitch/tests/eventParser/parseEventData.test.ts
  38. 286
      packages/freeswitch/tests/eventParser/parseEventExtensions.test.ts
  39. 155
      packages/freeswitch/tests/eventParser/parseEventLeg.test.ts
  40. 37
      packages/freeswitch/tests/eventParser/parseTimestamp.test.ts
  41. 5
      packages/model-typings/src/index.ts
  42. 12
      packages/model-typings/src/models/IFreeSwitchCallModel.ts
  43. 8
      packages/model-typings/src/models/IFreeSwitchChannelEventDeltaModel.ts
  44. 12
      packages/model-typings/src/models/IFreeSwitchChannelEventModel.ts
  45. 24
      packages/model-typings/src/models/IFreeSwitchChannelModel.ts
  46. 10
      packages/model-typings/src/models/IFreeSwitchEventModel.ts
  47. 10
      packages/models/src/index.ts
  48. 5
      packages/models/src/modelClasses.ts
  49. 76
      packages/models/src/models/FreeSwitchCall.ts
  50. 113
      packages/models/src/models/FreeSwitchChannel.ts
  51. 42
      packages/models/src/models/FreeSwitchChannelEvent.ts
  52. 26
      packages/models/src/models/FreeSwitchChannelEventDelta.ts
  53. 40
      packages/models/src/models/FreeSwitchEvent.ts
  54. 36
      packages/tools/src/convertPathsIntoSubObjects.ts
  55. 16
      packages/tools/src/convertSubObjectsIntoPaths.spec.ts
  56. 5
      packages/tools/src/convertSubObjectsIntoPaths.ts
  57. 2
      packages/tools/src/index.ts
  58. 39
      packages/tools/src/isRecord.spec.ts
  59. 7
      packages/tools/src/isRecord.ts
  60. 3
      packages/ui-voip/src/hooks/useVoipClient.tsx
  61. 34
      packages/ui-voip/src/lib/VoipClient.ts

@ -0,0 +1,11 @@
---
'@rocket.chat/model-typings': patch
'@rocket.chat/core-typings': patch
'@rocket.chat/freeswitch': patch
'@rocket.chat/ui-voip': patch
'@rocket.chat/models': patch
'@rocket.chat/tools': patch
'@rocket.chat/meteor': patch
---
Fixes the parsing of FreeSwitch events to properly generate a history of calls on Rocket.Chat

@ -1,7 +1,7 @@
import { log } from 'console';
import type { IStats, IVoIPPeriodStats } from '@rocket.chat/core-typings';
import { FreeSwitchCall } from '@rocket.chat/models';
import { FreeSwitchChannel } from '@rocket.chat/models';
import { MongoInternals } from 'meteor/mongo';
import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred';
@ -30,35 +30,37 @@ async function getVoIPStatisticsForPeriod(days?: number): Promise<IVoIPPeriodSta
const statistics: IVoIPPeriodStats = {};
promises.push(
FreeSwitchCall.countCallsByDirection('internal', minDate, options).then((count) => {
FreeSwitchChannel.countChannelsByKind('internal', minDate, options).then((count) => {
statistics.internalCalls = count;
}),
);
promises.push(
FreeSwitchCall.countCallsByDirection('external_inbound', minDate, options).then((count) => {
FreeSwitchChannel.countChannelsByKindAndDirection('external', 'inbound', minDate, options).then((count) => {
statistics.externalInboundCalls = count;
}),
);
promises.push(
FreeSwitchCall.countCallsByDirection('external_outbound', minDate, options).then((count) => {
FreeSwitchChannel.countChannelsByKindAndDirection('external', 'outbound', minDate, options).then((count) => {
statistics.externalOutboundCalls = count;
}),
);
promises.push(
FreeSwitchCall.sumCallsDuration(minDate, options).then((callsDuration) => {
FreeSwitchChannel.sumChannelsDurationByKind('internal', minDate, options).then((callsDuration) => {
statistics.callsDuration = callsDuration;
}),
);
promises.push(
FreeSwitchCall.countCallsBySuccessState(true, minDate, options).then((count) => {
FreeSwitchChannel.countChannelsByKindAndSuccessState('internal', true, minDate, options).then((count) => {
statistics.successfulCalls = count;
}),
);
promises.push(
FreeSwitchCall.countCallsBySuccessState(false, minDate, options).then((count) => {
FreeSwitchChannel.countChannelsByKindAndSuccessState('internal', false, minDate, options).then((count) => {
statistics.failedCalls = count;
}),
);

@ -85,6 +85,8 @@ export const useVoipClient = (): UseVoipClientResult => {
iceServers,
connectionRetryCount: Number(voipRetryCount),
enableKeepAliveUsingOptionsForUnstableNetworks: Boolean(enableKeepAlive),
userId: uid || '',
siteUrl: '',
};
client = await (isEE ? EEVoipClient.create(config) : VoIPUser.create(config));

@ -1,28 +1,26 @@
import { type IVoipFreeSwitchService, ServiceClassInternal, ServiceStarter } from '@rocket.chat/core-services';
import type {
DeepPartial,
IFreeSwitchEventCall,
IFreeSwitchEventCaller,
IFreeSwitchEvent,
FreeSwitchExtension,
IFreeSwitchCall,
IFreeSwitchCallEventType,
IFreeSwitchCallEvent,
AtLeast,
IFreeSwitchChannelEvent,
IFreeSwitchChannel,
IFreeSwitchChannelEventDelta,
} from '@rocket.chat/core-typings';
import { isKnownFreeSwitchEventType } from '@rocket.chat/core-typings';
import {
getDomain,
getUserPassword,
getExtensionList,
getExtensionDetails,
parseEventData,
computeChannelFromEvents,
logger,
FreeSwitchEventClient,
type EventData,
type FreeSwitchOptions,
} from '@rocket.chat/freeswitch';
import type { InsertionModel } from '@rocket.chat/model-typings';
import { FreeSwitchCall, FreeSwitchEvent, Users } from '@rocket.chat/models';
import { objectMap, wrapExceptions } from '@rocket.chat/tools';
import type { WithoutId } from 'mongodb';
import { FreeSwitchChannel, FreeSwitchChannelEvent, FreeSwitchChannelEventDelta } from '@rocket.chat/models';
import { wrapExceptions } from '@rocket.chat/tools';
import type { InsertOneResult, WithoutId } from 'mongodb';
import { MongoError } from 'mongodb';
import { settings } from '../../../../app/settings/server';
@ -179,495 +177,63 @@ export class VoipFreeSwitchService extends ServiceClassInternal implements IVoip
};
}
public async onFreeSwitchEvent(eventName: string, data: Record<string, string | undefined>): Promise<void> {
const uniqueId = data['Unique-ID'];
if (!uniqueId) {
return;
}
// Using a set to avoid duplicates
const callIds = new Set<string>(
[data['Channel-Call-UUID'], data.variable_call_uuid].filter((callId) => Boolean(callId) && callId !== '0') as string[],
);
const event = await this.parseEventData(eventName, data);
// If for some reason the event had different callIds, save a copy of it for each of them
if (callIds.size > 1) {
await Promise.all(
callIds.values().map((callId) =>
this.registerEvent({
...event,
call: {
...event.call,
UUID: callId,
},
}),
),
);
private async onFreeSwitchEvent(eventName: string, data: EventData): Promise<void> {
const event = parseEventData(eventName, data);
if (!event) {
return;
}
await this.registerEvent(event);
}
private getDetailedEventName(eventName: string, eventData: Record<string, string>): string {
if (eventName === 'CHANNEL_STATE') {
return `CHANNEL_STATE=${eventData['Channel-State']}`;
}
if (eventName === 'CHANNEL_CALLSTATE') {
return `CHANNEL_CALLSTATE=${eventData['Channel-Call-State']}`;
}
return eventName;
}
private filterOutMissingData<T extends Record<string, any>>(data: T): DeepPartial<T> {
return objectMap(
data,
({ key, value }) => {
if (!value || value === '0') {
return;
}
if (typeof value === 'object' && !Object.keys(value).length) {
return;
}
return { key, value };
},
true,
) as DeepPartial<T>;
}
private async parseEventData(
eventName: string,
eventData: Record<string, string | undefined>,
): Promise<InsertionModel<WithoutId<IFreeSwitchEvent>>> {
const filteredData: Record<string, string> = Object.fromEntries(
Object.entries(eventData).filter(([_, value]) => value !== undefined),
) as Record<string, string>;
const detaildEventName = this.getDetailedEventName(eventName, filteredData);
const state = eventData['Channel-State'];
const sequence = eventData['Event-Sequence'];
const previousCallState = eventData['Original-Channel-Call-State'];
const callState = eventData['Channel-Call-State'];
const answerState = eventData['Answer-State'];
const hangupCause = eventData['Hangup-Cause'];
const direction = eventData['Call-Direction'];
const channelName = eventData['Channel-Name'];
const otherLegUniqueId = eventData['Other-Leg-Unique-ID'];
const loopbackLegUniqueId = eventData.variable_other_loopback_leg_uuid;
const loopbackFromUniqueId = eventData.variable_other_loopback_from_uuid;
const oldUniqueId = eventData['Old-Unique-ID'];
const channelUniqueId = eventData['Unique-ID'];
const referencedIds = [otherLegUniqueId, loopbackLegUniqueId, loopbackFromUniqueId, oldUniqueId].filter((id) =>
Boolean(id),
) as string[];
const timestamp = eventData['Event-Date-Timestamp'];
const firedAt = this.parseTimestamp(eventData['Event-Date-Timestamp']);
const durationStr = eventData.variable_duration;
const duration = (durationStr && parseInt(durationStr)) || 0;
const call: Partial<IFreeSwitchEventCall> = {
UUID: (eventData['Channel-Call-UUID'] !== '0' && eventData['Channel-Call-UUID']) || eventData.variable_call_uuid,
answerState,
state: callState,
previousState: previousCallState,
presenceId: eventData['Channel-Presence-ID'],
sipId: eventData.variable_sip_call_id,
authorized: eventData.variable_sip_authorized,
hangupCause,
duration,
from: {
user: eventData.variable_sip_from_user,
stripped: eventData.variable_sip_from_user_stripped,
port: eventData.variable_sip_from_port,
uri: eventData.variable_sip_from_uri,
host: eventData.variable_sip_from_host,
full: eventData.variable_sip_full_from,
},
req: {
user: eventData.variable_sip_req_user,
port: eventData.variable_sip_req_port,
uri: eventData.variable_sip_req_uri,
host: eventData.variable_sip_req_host,
},
to: {
user: eventData.variable_sip_to_user,
port: eventData.variable_sip_to_port,
uri: eventData.variable_sip_to_uri,
full: eventData.variable_sip_full_to,
dialedExtension: eventData.variable_dialed_extension,
dialedUser: eventData.variable_dialed_user,
},
contact: {
user: eventData.variable_sip_contact_user,
uri: eventData.variable_sip_contact_uri,
host: eventData.variable_sip_contact_host,
},
via: {
full: eventData.variable_sip_full_via,
host: eventData.variable_sip_via_host,
rport: eventData.variable_sip_via_rport,
},
};
const caller: Partial<IFreeSwitchEventCaller> = {
uniqueId: eventData['Caller-Unique-ID'],
direction: eventData['Caller-Direction'],
username: eventData['Caller-Username'],
networkAddr: eventData['Caller-Network-Addr'],
ani: eventData['Caller-ANI'],
destinationNumber: eventData['Caller-Destination-Number'],
source: eventData['Caller-Source'],
context: eventData['Caller-Context'],
name: eventData['Caller-Caller-ID-Name'],
number: eventData['Caller-Caller-ID-Number'],
originalCaller: {
name: eventData['Caller-Orig-Caller-ID-Name'],
number: eventData['Caller-Orig-Caller-ID-Number'],
},
privacy: {
hideName: eventData['Caller-Privacy-Hide-Name'],
hideNumber: eventData['Caller-Privacy-Hide-Number'],
},
channel: {
name: eventData['Caller-Channel-Name'],
createdTime: eventData['Caller-Channel-Created-Time'],
},
};
return this.filterOutMissingData({
channelUniqueId,
eventName,
detaildEventName,
sequence,
state,
previousCallState,
callState,
timestamp,
firedAt,
answerState,
hangupCause,
referencedIds,
receivedAt: new Date(),
channelName,
direction,
caller,
call,
eventData: filteredData,
}) as InsertionModel<WithoutId<IFreeSwitchEvent>>;
}
private parseTimestamp(timestamp: string | undefined): Date | undefined {
if (!timestamp || timestamp === '0') {
return undefined;
}
const value = parseInt(timestamp);
if (Number.isNaN(value)) {
return undefined;
}
const timeValue = Math.floor(value / 1000);
return new Date(timeValue);
}
private async registerEvent(event: InsertionModel<WithoutId<IFreeSwitchEvent>>): Promise<void> {
private async registerRecord(registerFn: () => Promise<void | InsertOneResult>): Promise<void> {
try {
await FreeSwitchEvent.registerEvent(event);
if (event.eventName === 'CHANNEL_DESTROY' && event.call?.UUID) {
await this.computeCall(event.call?.UUID);
}
await registerFn();
} catch (error) {
// avoid logging that an event was duplicated from mongo
if (error instanceof MongoError && error.code === 11000) {
return;
}
logger.error(error);
throw error;
}
}
private getEventType(event: IFreeSwitchEvent): IFreeSwitchCallEventType {
const { eventName, state, callState } = event;
const modifiedEventName = eventName.toUpperCase().replace('CHANNEL_', '').replace('_COMPLETE', '');
if (isKnownFreeSwitchEventType(modifiedEventName)) {
return modifiedEventName;
}
if (modifiedEventName === 'STATE') {
if (!state) {
return 'OTHER_STATE';
}
const modifiedState = state.toUpperCase().replace('CS_', '');
if (isKnownFreeSwitchEventType(modifiedState)) {
return modifiedState;
}
}
if (modifiedEventName === 'CALLSTATE') {
if (!callState) {
return 'OTHER_CALL_STATE';
}
const modifiedCallState = callState.toUpperCase().replace('CS_', '');
if (isKnownFreeSwitchEventType(modifiedCallState)) {
return modifiedCallState;
}
}
return 'OTHER';
}
private identifyCallerFromEvent(event: IFreeSwitchEvent): string {
if (event.call?.from?.user) {
return event.call.from.user;
}
if (event.caller?.username) {
return event.caller.username;
}
if (event.caller?.number) {
return event.caller.number;
}
private async registerEvent(event: InsertionModel<WithoutId<IFreeSwitchChannelEvent>>): Promise<void> {
const { channelUniqueId, eventName } = event;
if (event.caller?.ani) {
return event.caller.ani;
if (eventName === 'CHANNEL_DESTROY' && channelUniqueId) {
// #TODO: Replace with a proper background process, also make it not rely on the CHANNEL_DESTROY event.
setTimeout(() => {
this.computeChannel(channelUniqueId).catch((reason) => {
logger.error({ msg: 'Failed to compute channel data ', reason, channelUniqueId });
});
}, 2000);
}
return '';
return this.registerRecord(() => FreeSwitchChannelEvent.registerEvent(event));
}
private identifyCalleeFromEvent(event: IFreeSwitchEvent): string {
if (event.call?.to?.dialedExtension) {
return event.call.to.dialedExtension;
}
if (event.call?.to?.dialedUser) {
return event.call.to.dialedUser;
}
return '';
private async registerChannel(channel: InsertionModel<WithoutId<IFreeSwitchChannel>>): Promise<void> {
return this.registerRecord(() => FreeSwitchChannel.registerChannel(channel));
}
private isImportantEvent(event: IFreeSwitchEvent): boolean {
return Object.keys(event).some((key) => key.startsWith('variable_'));
private async registerChannelDelta(record: InsertionModel<WithoutId<IFreeSwitchChannelEventDelta>>): Promise<void> {
return this.registerRecord(() => FreeSwitchChannelEventDelta.registerDelta(record));
}
private async computeCall(callUUID: string): Promise<void> {
const allEvents = await FreeSwitchEvent.findAllByCallUUID(callUUID).toArray();
const call: InsertionModel<IFreeSwitchCall> = {
UUID: callUUID,
channels: [],
events: [],
};
private async computeChannel(channelUniqueId: string): Promise<void> {
const allEvents = await FreeSwitchChannelEvent.findAllByChannelUniqueId(channelUniqueId).toArray();
// Sort events by both sequence and timestamp, but only when they are present
const sortedEvents = allEvents.sort((event1: IFreeSwitchEvent, event2: IFreeSwitchEvent) => {
if (event1.sequence && event2.sequence) {
return event1.sequence.localeCompare(event2.sequence);
}
const result = await computeChannelFromEvents(allEvents);
if (result?.channel) {
const { channel, deltas } = result;
if (event1.firedAt && event2.firedAt) {
return event1.firedAt.valueOf() - event2.firedAt.valueOf();
}
if (event1.sequence || event2.sequence) {
return (event1.sequence || '').localeCompare(event2.sequence || '');
}
return (event1.firedAt?.valueOf() || 0) - (event2.firedAt?.valueOf() || 0);
});
const fromUser = new Set<string>();
const toUser = new Set<string>();
let isVoicemailCall = false;
for (const event of sortedEvents) {
if (event.channelUniqueId && !call.channels.includes(event.channelUniqueId)) {
call.channels.push(event.channelUniqueId);
}
if (!call.startedAt || (event.firedAt && event.firedAt < call.startedAt)) {
call.startedAt = event.firedAt;
}
const eventType = this.getEventType(event);
fromUser.add(this.identifyCallerFromEvent(event));
toUser.add(this.identifyCalleeFromEvent(event));
// when a call enters the voicemail, we receive one/or many events with the channelName = loopback/voicemail-x
// where X appears to be a letter
isVoicemailCall = event.channelName?.includes('voicemail') || isVoicemailCall;
const hasUsefulCallData = this.isImportantEvent(event);
const callEvent = this.filterOutMissingData({
type: eventType,
caller: event.caller,
...(hasUsefulCallData && { call: event.call }),
otherType: event.eventData['Other-Type'],
otherChannelId: event.eventData['Other-Leg-Unique-ID'],
}) as AtLeast<IFreeSwitchCallEvent, 'type'>;
if (call.events[call.events.length - 1]?.type === eventType) {
const previousEvent = call.events.pop() as IFreeSwitchCallEvent;
call.events.push({
...previousEvent,
...callEvent,
caller: {
...previousEvent.caller,
...callEvent.caller,
},
...((previousEvent.call || callEvent.call) && {
call: {
...previousEvent.call,
...callEvent.call,
from: {
...previousEvent.call?.from,
...callEvent.call?.from,
},
req: {
...previousEvent.call?.req,
...callEvent.call?.req,
},
to: {
...previousEvent.call?.to,
...callEvent.call?.to,
},
contact: {
...previousEvent.call?.contact,
...callEvent.call?.contact,
},
via: {
...previousEvent.call?.via,
...callEvent.call?.via,
},
},
}),
});
continue;
}
await this.registerChannel(channel);
call.events.push({
...callEvent,
eventName: event.eventName,
sequence: event.sequence,
channelUniqueId: event.channelUniqueId,
timestamp: event.timestamp,
firedAt: event.firedAt,
});
}
if (fromUser.size) {
const callerIds = [...fromUser].filter((e) => !!e);
const user = await Users.findOneByFreeSwitchExtensions(callerIds, {
projection: { _id: 1, username: 1, name: 1, avatarETag: 1, freeSwitchExtension: 1 },
});
if (user) {
call.from = {
_id: user._id,
username: user.username,
name: user.name,
avatarETag: user.avatarETag,
freeSwitchExtension: user.freeSwitchExtension,
};
}
await Promise.allSettled(deltas.map(async (delta) => this.registerChannelDelta({ channelUniqueId: channel.uniqueId, ...delta })));
}
if (toUser.size) {
const calleeIds = [...toUser].filter((e) => !!e);
const user = await Users.findOneByFreeSwitchExtensions(calleeIds, {
projection: { _id: 1, username: 1, name: 1, avatarETag: 1, freeSwitchExtension: 1 },
});
if (user) {
call.to = {
_id: user._id,
username: user.username,
name: user.name,
avatarETag: user.avatarETag,
freeSwitchExtension: user.freeSwitchExtension,
};
}
}
// A call has 2 channels at max
// If it has 3 or more channels, it's a forwarded call
if (call.channels.length >= 3) {
const originalCalls = await FreeSwitchCall.findAllByChannelUniqueIds(call.channels, { projection: { events: 0 } }).toArray();
if (originalCalls.length) {
call.forwardedFrom = originalCalls;
}
}
// Call originated from us but destination and destination is another user = internal
if (call.from && call.to) {
call.direction = 'internal';
}
// Call originated from us but destination is not on server = external outbound
if (call.from && !call.to) {
call.direction = 'external_outbound';
}
// Call originated from a user outside server but received by a user in our side = external inbound
if (!call.from && call.to) {
call.direction = 'external_inbound';
}
// Call ended up in voicemail of another user = voicemail
if (isVoicemailCall) {
call.voicemail = true;
}
call.duration = this.computeCallDuration(call);
await FreeSwitchCall.registerCall(call);
}
private computeCallDuration(call: InsertionModel<IFreeSwitchCall>): number {
if (!call.events.length) {
return 0;
}
const channelAnswerEvent = call.events.find((e) => e.eventName === 'CHANNEL_ANSWER');
if (!channelAnswerEvent?.timestamp) {
return 0;
}
const answer = this.parseTimestamp(channelAnswerEvent.timestamp);
if (!answer) {
return 0;
}
const channelHangupEvent = call.events.find((e) => e.eventName === 'CHANNEL_HANGUP_COMPLETE');
if (!channelHangupEvent?.timestamp) {
// We dont have a hangup but we have an answer, assume hangup is === destroy time
return new Date().getTime() - answer.getTime();
}
const hangup = this.parseTimestamp(channelHangupEvent.timestamp);
if (!hangup) {
return 0;
}
return hangup.getTime() - answer.getTime();
}
async getDomain(): Promise<string> {

@ -20,8 +20,9 @@ import {
FederationKeysRaw,
FederationRoomEventsRaw,
FederationServersRaw,
FreeSwitchCallRaw,
FreeSwitchEventRaw,
FreeSwitchChannelRaw,
FreeSwitchChannelEventRaw,
FreeSwitchChannelEventDeltaRaw,
ImportDataRaw,
ImportsModel,
InstanceStatusRaw,
@ -104,8 +105,9 @@ registerModel('IExportOperationsModel', new ExportOperationsRaw(db));
registerModel('IFederationKeysModel', new FederationKeysRaw(db));
registerModel('IFederationRoomEventsModel', new FederationRoomEventsRaw(db));
registerModel('IFederationServersModel', new FederationServersRaw(db));
registerModel('IFreeSwitchCallModel', new FreeSwitchCallRaw(db));
registerModel('IFreeSwitchEventModel', new FreeSwitchEventRaw(db));
registerModel('IFreeSwitchChannelModel', new FreeSwitchChannelRaw(db));
registerModel('IFreeSwitchChannelEventModel', new FreeSwitchChannelEventRaw(db));
registerModel('IFreeSwitchChannelEventDeltaModel', new FreeSwitchChannelEventDeltaRaw(db));
registerModel('IImportDataModel', new ImportDataRaw(db));
registerModel('IImportsModel', new ImportsModel(db));
registerModel('IInstanceStatusModel', new InstanceStatusRaw(db));

@ -1,65 +0,0 @@
import type { IRocketChatRecord } from '../IRocketChatRecord';
import type { IUser } from '../IUser';
import type { IFreeSwitchEventCall, IFreeSwitchEventCaller } from './IFreeSwitchEvent';
export interface IFreeSwitchCall extends IRocketChatRecord {
UUID: string;
channels: string[];
events: IFreeSwitchCallEvent[];
from?: Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag' | 'freeSwitchExtension'>;
to?: Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag' | 'freeSwitchExtension'>;
forwardedFrom?: Omit<IFreeSwitchCall, 'events'>[];
direction?: 'internal' | 'external_inbound' | 'external_outbound';
voicemail?: boolean;
duration?: number;
startedAt?: Date;
}
const knownEventTypes = [
'NEW',
'INIT',
'CREATE',
'DESTROY',
'ANSWER',
'HANGUP',
'BRIDGE',
'UNBRIDGE',
'OUTGOING',
'PARK',
'UNPARK',
'HOLD',
'UNHOLD',
'ORIGINATE',
'UUID',
'REPORTING',
'ROUTING',
'RINGING',
'ACTIVE',
'EARLY',
'RING_WAIT',
'EXECUTE',
'CONSUME_MEDIA',
'EXCHANGE_MEDIA',
'OTHER',
'OTHER_STATE',
'OTHER_CALL_STATE',
] as const;
export type IFreeSwitchCallEventType = (typeof knownEventTypes)[number];
export const isKnownFreeSwitchEventType = (eventName: string): eventName is IFreeSwitchCallEventType =>
knownEventTypes.includes(eventName as any);
export type IFreeSwitchCallEvent = {
eventName: string;
type: IFreeSwitchCallEventType;
sequence?: string;
channelUniqueId?: string;
timestamp?: string;
firedAt?: Date;
caller?: IFreeSwitchEventCaller;
call?: IFreeSwitchEventCall;
otherType?: string;
otherChannelId?: string;
};

@ -0,0 +1,41 @@
import type { IRocketChatRecord } from '../IRocketChatRecord';
import type {
FreeSwitchChannelEventHeaderWithStates,
IFreeSwitchChannelEventLegProfile,
IFreeSwitchChannelEventMutable,
} from './IFreeSwitchChannelEvent';
export interface IFreeSwitchChannel extends IRocketChatRecord {
uniqueId: string;
name: string;
freeSwitchUser?: string;
callers?: string[];
callees?: string[];
bridgedTo: string[];
callDirection?: string;
profiles: IFreeSwitchChannelProfile[];
startedAt: Date;
anyMedia: boolean;
anyAnswer: boolean;
anyBridge: boolean;
durationSum: number;
totalDuration: number;
kind: 'internal' | 'external' | 'voicemail' | 'unknown';
finalState: IFreeSwitchChannelEventMutable;
events: FreeSwitchChannelEventHeaderWithStates[];
}
export interface IFreeSwitchChannelProfile extends IFreeSwitchChannelEventLegProfile {
// This value is pulled from the next profile
nextProfileCreatedTime?: Date;
callDuration?: number;
answered?: boolean;
media?: boolean;
bridged?: boolean;
}

@ -0,0 +1,177 @@
import type { IRocketChatRecord } from '../IRocketChatRecord';
import type { AtLeast } from '../utils';
export interface IFreeSwitchChannelEventHeader {
sequence: number;
eventName: string;
firedAt: Date;
receivedAt: Date;
caller?: string;
callee?: string;
}
export interface IFreeSwitchChannelEventStates {
channelState: string;
channelCallState: string;
originalChannelCallState?: string;
answerState?: string;
}
export type FreeSwitchChannelEventHeaderWithStates = IFreeSwitchChannelEventHeader & IFreeSwitchChannelEventStates;
export interface IFreeSwitchChannelEventMutable {
// uniqueId of the main channel in the active call, might not actually be unique between different calls.
// We overwrite this value in some events to ensure it always refer to the same uniqueId even while the call is not active.
callUniqueId: string;
// the name of this channel on sofia, might reference the user or contact depending on which process created it
channelName: string;
// For valid calls this should be parsed to a valid freeswitch username that is represented by this channel
// Parsing might fail for voicemail, spam bots and more advanced features we implement in the future.
channelUsername?: string;
// Caller and Callee are calculated based on everything else, it's not any specific event attribute
caller?: string;
callee?: string;
// Expected: CS_NEW, CS_INIT, CS_ROUTING, CS_EXECUTE, CS_EXCHANGE_MEDIA, CS_CONSUME_MEDIA, CS_HANGUP, CS_REPORTING, CS_DESTROY
// Not Expected: CS_SOFT_EXECUTE, CS_PARK, CS_HIBERNATE, CS_RESET, CS_NONE
channelState: string;
// Multiple state numbers may map to the same state name
channelStateNumber?: string;
// DOWN, DIALING, RINGING, EARLY, ACTIVE, HELD, RING_WAIT, HANGUP, UNHELD
channelCallState: string;
channelCallStateNumber?: string;
// The previous value of channelCallState
originalChannelCallState?: string;
// early, ringing, confirmed, answered, hangup, terminated
answerState?: string;
// 'inbound' for the channel that initiated the call ("in" to freeswitch)
// 'outbound' for the channel(s) that are receiving the call ("out" from freeswitch)
callDirection?: string;
// will usually be true for the channel that initiated the call, if it is using the dialplan
channelHitDialplan?: string;
hangupCause?: string;
bridgeUniqueIds?: string[];
// Sent only by CALL_UPDATE events, when two channels are bridged together
bridgedTo?: string;
legs: Record<string, AtLeast<IFreeSwitchChannelEventLeg, 'legName' | 'uniqueId' | 'raw'>>;
// variables should contain the same data you would get by running `uuid_dump` on fs_cli
variables?: Record<string, string | string[]>;
// raw will include fields we received from freeswitch but didn't read
raw: Record<string, string>;
codecs?: {
read?: {
name?: string;
rate?: string;
};
write?: {
name?: string;
rate?: string;
};
};
// Presence is something I'm trying to not depend on, but the info here could be useful for identifying users if there's no other reliable field.
channelPresenceId?: string;
presenceCallDirection?: string;
}
export interface IFreeSwitchChannelEvent extends IRocketChatRecord, IFreeSwitchChannelEventHeader, IFreeSwitchChannelEventMutable {
channelUniqueId: string;
metadata: Record<string, string>;
}
export interface IFreeSwitchChannelEventLegProfile {
// If profileIndex is a number higher than 1, then the channel is being reused for a second call
profileIndex?: string;
profileCreatedTime?: Date;
channelCreatedTime?: Date;
channelAnsweredTime?: Date;
channelProgressTime?: Date;
channelBridgedTime?: Date;
channelProgressMediaTime?: Date;
channelHangupTime?: Date;
channelTransferTime?: Date;
channelRessurectTime?: Date;
channelLastHold?: Date;
// Those are pulled from other places so that the profile can be mapped to specific calls
// They'll never be present on the raw events, only on the channel.events and channel.finalState.events
bridgedTo?: string;
caller?: string;
callee?: string;
}
export interface IFreeSwitchChannelEventLeg {
// 'Caller' or 'Other-Leg'; Worthless information
legName: string;
// 'originator' or 'originatee', will be undefined if legName !== 'Other-Leg'
type?: string;
// 'inbound' for the leg that initiated the call
// 'outbound' for the leg(s) that are receiving the call
direction: string;
// Logical direction is what the other leg would expect the direction of this leg to be
// If `direction` and `logicalDirection` are different, then the call was probably not started by either side (eg. server called both users)
logicalDirection: string;
// Unreliable; Always the username of the user who initiated the first call in the chain, even if they are no longer involved.
username: string;
// Unreliable, same as username.
callerName: string;
// Unreliable, same as username.
callerNumber: string;
// Unreliable, but not as much as username.
originalCallerName: string;
// Unreliable, but not as much as username.
originalCallerNumber: string;
// Unreliable, same as username.
calleeName: string;
// Unreliable, same as username.
calleeNumber: string;
networkAddress: string;
// Kinda reliable, but it can be so many different things depending on the event type, that it's not worth using
destinationNumber: string;
// very reliable, always a channel's unique id
uniqueId: string;
// very reliable, always a valid channel name
channelName: string;
// always 'mod_sofia' in our current use case
source: string;
// should match the context from the sip_profile ('default' for internal, 'public' for external), but I haven't tested it with external calls yet
context: string;
transferSource?: string;
profiles: Record<string, IFreeSwitchChannelEventLegProfile>;
// always 'XML' in our current use case
dialplan?: string;
// Unreliable, same value as username;
ani?: string;
// rdnis is present on transfered calls; Might be an username or a contact name.
rdnis?: string;
// No use for those atm
screenBit?: string;
privacyHideName?: string;
privacyHideNumber?: string;
raw?: Record<string, string>;
}

@ -0,0 +1,24 @@
import type { IRocketChatRecord } from '../IRocketChatRecord';
import type { DeepPartial } from '../utils';
import type {
IFreeSwitchChannelEventHeader,
IFreeSwitchChannelEventMutable,
IFreeSwitchChannelEventStates,
} from './IFreeSwitchChannelEvent';
type DeepModified<T> = {
[P in keyof T]?: T[P] extends Date | undefined
? { oldValue: T[P]; newValue: T[P]; delta: number } | undefined
: T[P] extends object | undefined
? DeepModified<T[P]>
: { oldValue: T[P]; newValue: T[P] } | undefined;
};
export interface IFreeSwitchChannelEventDeltaData extends IFreeSwitchChannelEventHeader {
newValues?: DeepPartial<IFreeSwitchChannelEventMutable>;
modifiedValues?: DeepModified<IFreeSwitchChannelEventMutable>;
}
export interface IFreeSwitchChannelEventDelta extends IRocketChatRecord, IFreeSwitchChannelEventDeltaData, IFreeSwitchChannelEventStates {
channelUniqueId: string;
}

@ -1,113 +0,0 @@
import type { IRocketChatRecord } from '../IRocketChatRecord';
export interface IFreeSwitchEvent extends IRocketChatRecord {
channelUniqueId?: string;
eventName: string;
detaildEventName: string;
sequence?: string;
state?: string;
previousCallState?: string;
callState?: string;
timestamp?: string;
firedAt?: Date;
answerState?: string;
hangupCause?: string;
referencedIds?: string[];
receivedAt?: Date;
channelName?: string;
direction?: string;
caller?: IFreeSwitchEventCaller;
call?: IFreeSwitchEventCall;
eventData: Record<string, string>;
}
export interface IFreeSwitchEventCall {
UUID?: string;
answerState?: string;
state?: string;
previousState?: string;
presenceId?: string;
sipId?: string;
authorized?: string;
hangupCause?: string;
duration?: number;
from?: {
user?: string;
stripped?: string;
port?: string;
uri?: string;
host?: string;
full?: string;
userId?: string;
};
req?: {
user?: string;
port?: string;
uri?: string;
host?: string;
userId?: string;
};
to?: {
user?: string;
port?: string;
uri?: string;
full?: string;
dialedExtension?: string;
dialedUser?: string;
userId?: string;
};
contact?: {
user?: string;
uri?: string;
host?: string;
userId?: string;
};
via?: {
full?: string;
host?: string;
rport?: string;
userId?: string;
};
}
export interface IFreeSwitchEventCaller {
uniqueId?: string;
direction?: string;
username?: string;
networkAddr?: string;
ani?: string;
destinationNumber?: string;
source?: string;
context?: string;
name?: string;
number?: string;
originalCaller?: {
name?: string;
number?: string;
};
privacy?: {
hideName?: string;
hideNumber?: string;
};
channel?: {
name?: string;
createdTime?: string;
};
}

@ -46,6 +46,16 @@ export interface VoIPUserConfiguration {
*/
enableKeepAliveUsingOptionsForUnstableNetworks: boolean;
/**
* The id of the rocket.chat user that is using this extension
*/
userId: string;
/**
* The url of the rocket.chat workspace this user is connecting from
*/
siteUrl: string;
/**
* Time to wait for Ice Gathering to complete
* @defaultValue 5000

@ -17,5 +17,6 @@ export * from './IVoipClientEvents';
export * from './VoIPUserConfiguration';
export * from './VoIpCallerInfo';
export * from './ICallDetails';
export * from './IFreeSwitchCall';
export * from './IFreeSwitchEvent';
export * from './IFreeSwitchChannel';
export * from './IFreeSwitchChannelEvent';
export * from './IFreeSwitchChannelEventDelta';

@ -18,6 +18,10 @@ const eventsToListen: EventNames = [
'CHANNEL_UNHOLD',
'CHANNEL_ORIGINATE',
'CHANNEL_UUID',
'CHANNEL_APPLICATION',
'CHANNEL_PROGRESS',
'CHANNEL_PROGRESS_MEDIA',
'CALL_UPDATE',
];
export class FreeSwitchEventClient extends FreeSwitchESLClient {

@ -0,0 +1,153 @@
import type {
IFreeSwitchChannel,
IFreeSwitchChannelEvent,
IFreeSwitchChannelEventDeltaData,
IFreeSwitchChannelEventHeader,
IFreeSwitchChannelEventMutable,
FreeSwitchChannelEventHeaderWithStates,
IFreeSwitchChannelEventStates,
} from '@rocket.chat/core-typings';
import { convertPathsIntoSubObjects, convertSubObjectsIntoPaths } from '@rocket.chat/tools';
import { computeChannelProfiles } from './computeChannelProfiles';
import { extractChannelChangesFromEvent } from './extractChannelChangesFromEvent';
import { filterOutMissingData } from './filterOutMissingData';
import { insertDataIntoEventProfile } from './insertDataIntoEventProfile';
import { parseChannelKind } from './parseChannelKind';
function splitEventDataSections(event: IFreeSwitchChannelEvent): {
header: IFreeSwitchChannelEventHeader;
eventData: IFreeSwitchChannelEventMutable;
channelUniqueId: string;
} {
const { _id, channelUniqueId, _updatedAt, metadata, eventName, sequence, firedAt, receivedAt, callee, caller, ...eventData } = event;
return {
channelUniqueId,
header: {
sequence,
eventName,
firedAt,
receivedAt,
callee,
caller,
},
eventData,
};
}
export async function computeChannelFromEvents(allEvents: IFreeSwitchChannelEvent[]): Promise<
| {
channel: Omit<IFreeSwitchChannel, '_id' | '_updatedAt'>;
deltas: (IFreeSwitchChannelEventDeltaData & IFreeSwitchChannelEventStates)[];
}
| undefined
> {
if (!allEvents.length) {
return;
}
const deltas: (IFreeSwitchChannelEventDeltaData & IFreeSwitchChannelEventStates)[] = [];
const { channelUniqueId: uniqueId, firedAt: firstEvent } = allEvents[0];
const callDirections: string[] = [];
const callers: string[] = [];
const callees: string[] = [];
const bridgedTo: string[] = [];
const headers: FreeSwitchChannelEventHeaderWithStates[] = [];
for (const event of allEvents) {
const { callee, caller, bridgeUniqueIds, bridgedTo: eventBridgedTo } = event;
if (event.callDirection && !callDirections.includes(event.callDirection)) {
callDirections.push(event.callDirection);
}
if (callee && !callees.includes(callee)) {
callees.push(callee);
}
if (caller && !callers.includes(caller)) {
callers.push(caller);
}
if (bridgeUniqueIds) {
for (const bridgeUniqueId of bridgeUniqueIds) {
if (bridgeUniqueId && !bridgedTo.includes(bridgeUniqueId) && bridgeUniqueId !== uniqueId) {
bridgedTo.push(bridgeUniqueId);
}
}
}
if (eventBridgedTo && !bridgedTo.includes(eventBridgedTo)) {
bridgedTo.push(eventBridgedTo);
}
}
const flattened = allEvents.reduce(
(state, nextEvent: IFreeSwitchChannelEvent) => {
const { header, eventData, channelUniqueId } = splitEventDataSections(nextEvent);
const { caller, callee, eventName, sequence } = header;
// Inserts the callee and bridgedTo attributes into the profile of this event
const eventDataEx = insertDataIntoEventProfile(eventData, { caller, callee, bridgedTo: eventData.bridgedTo });
// Make a list with every value from the event, except for the headers;
const eventValues = convertSubObjectsIntoPaths(eventDataEx);
// Compare the event's list of values with the full list from all past events
const { changedValues, newValues, changedExistingValues } = extractChannelChangesFromEvent(state, eventName, eventValues);
const { channelState, channelCallState, originalChannelCallState, answerState } = eventData;
const headerWithStates: FreeSwitchChannelEventHeaderWithStates = {
...header,
channelState,
channelCallState,
originalChannelCallState,
answerState,
};
// Generate a "delta" entry with the data that has changed in this event
const delta: IFreeSwitchChannelEventDeltaData & IFreeSwitchChannelEventStates = {
...headerWithStates,
newValues: convertPathsIntoSubObjects(newValues),
modifiedValues: convertPathsIntoSubObjects(changedExistingValues),
};
// Store this delta in a list
deltas.push(filterOutMissingData(delta));
headers.push(headerWithStates);
return {
channelUniqueId,
...state,
eventName,
sequence,
...changedValues,
};
},
{} as Record<string, any>,
);
const finalState = convertPathsIntoSubObjects(flattened) as IFreeSwitchChannelEventMutable;
const computedProfiles = computeChannelProfiles(finalState?.legs?.[uniqueId]?.profiles || {});
return {
channel: {
uniqueId,
name: finalState.channelName,
callDirection: callDirections.join('||'),
freeSwitchUser: finalState.channelUsername,
callers,
callees,
bridgedTo,
...{
...computedProfiles,
// If we couldn't parse a startedAt, use the time of the first event
startedAt: computedProfiles.startedAt || firstEvent,
},
kind: parseChannelKind(finalState.channelName),
finalState,
events: headers,
},
deltas,
};
}

@ -0,0 +1,159 @@
/* eslint-disable complexity */
import type { IFreeSwitchChannelEventLegProfile, IFreeSwitchChannelProfile } from '@rocket.chat/core-typings';
function adjustProfileTimestamps(profile: IFreeSwitchChannelEventLegProfile): IFreeSwitchChannelEventLegProfile {
const { profileIndex, profileCreatedTime, channelCreatedTime, bridgedTo, caller, callee, ...timestamps } = profile;
// Don't mutate anything if it's the first profile
if (!profileIndex || profileIndex === '1') {
return { ...profile };
}
const newProfile: IFreeSwitchChannelEventLegProfile = {
channelCreatedTime,
profileIndex,
bridgedTo,
callee,
caller,
};
// If we don't know when the profile was created, drop every other timestamp
if (!profileCreatedTime) {
return newProfile;
}
newProfile.profileCreatedTime = profileCreatedTime;
for (const key of Object.keys(timestamps)) {
const value = timestamps[key as keyof typeof timestamps];
if (!value || typeof value === 'string') {
continue;
}
if (value < profileCreatedTime) {
continue;
}
newProfile[key as keyof typeof timestamps] = value;
}
return newProfile;
}
type ProfileListAndSummary = {
profiles: IFreeSwitchChannelProfile[];
anyMedia: boolean;
anyAnswer: boolean;
anyBridge: boolean;
durationSum: number;
totalDuration: number;
startedAt?: Date;
};
export function computeChannelProfiles(legProfiles: Record<string, IFreeSwitchChannelEventLegProfile>): ProfileListAndSummary {
const profiles: IFreeSwitchChannelProfile[] = Object.values(legProfiles).map((profile) => adjustProfileTimestamps(profile));
// Sort profiles by createdTime, temporarily filter out the ones that do not have one:
const sortedProfiles = profiles
.filter(
({ profileCreatedTime, channelCreatedTime, profileIndex }) => profileCreatedTime || (profileIndex === '1' && channelCreatedTime),
)
.sort(
({ profileCreatedTime: profile1, channelCreatedTime: channel1 }, { profileCreatedTime: profile2, channelCreatedTime: channel2 }) =>
(profile1?.valueOf() || (channel1 as Date).valueOf()) - (profile2?.valueOf() || (channel2 as Date).valueOf()),
);
const adjustedProfiles: IFreeSwitchChannelProfile[] = [];
let anyAnswer = false;
let anyMedia = false;
let anyBridge = false;
let durationSum = 0;
let firstProfileCreate: Date | undefined;
// "first" because it's an array, but it's the same channel for all so there should only be one value
let firstChannelCreate: Date | undefined;
for (let i = 0; i < sortedProfiles.length; i++) {
const nextProfileCreatedTime = sortedProfiles[i + 1]?.profileCreatedTime || undefined;
const profile = sortedProfiles[i];
const {
channelBridgedTime,
channelAnsweredTime,
channelProgressMediaTime,
channelHangupTime,
bridgedTo,
profileCreatedTime,
channelCreatedTime,
} = profile;
const callEnd = channelHangupTime || nextProfileCreatedTime;
if (channelCreatedTime && (!firstChannelCreate || firstChannelCreate > channelCreatedTime)) {
firstChannelCreate = channelCreatedTime;
}
if (profileCreatedTime && (!firstProfileCreate || firstProfileCreate > profileCreatedTime)) {
firstProfileCreate = profileCreatedTime;
}
const callDuration = callEnd && channelBridgedTime ? callEnd.valueOf() - channelBridgedTime.valueOf() : 0;
const media = Boolean(channelProgressMediaTime) || sortedProfiles.length > 1;
const answered = Boolean(channelAnsweredTime) || media;
const bridged = Boolean(channelBridgedTime) || Boolean(bridgedTo);
anyMedia ||= media;
anyAnswer ||= answered;
anyBridge ||= bridged;
durationSum += callDuration;
adjustedProfiles.push({
...profile,
...{
nextProfileCreatedTime,
callDuration,
answered,
media,
bridged,
},
});
}
// Look for bridge and hangup on every channel, even if they didn't have a profile timestamp (in theory every profile will always have a created timestamp)
let firstBridge: Date | undefined;
let lastCallEnd: Date | undefined;
for (const profile of profiles) {
const { channelBridgedTime, channelHangupTime, nextProfileCreatedTime, bridgedTo } = profile;
const callEnd = channelHangupTime || nextProfileCreatedTime;
if (channelBridgedTime && (!firstBridge || firstBridge > channelBridgedTime)) {
firstBridge = channelBridgedTime;
}
if ((callEnd || 0) > (lastCallEnd || 0)) {
lastCallEnd = callEnd;
}
// If this profile was filtered out from the list used by the first process, add it back to the final list here
if (!sortedProfiles.includes(profile)) {
const bridged = Boolean(channelBridgedTime) || Boolean(bridgedTo);
anyBridge ||= bridged;
adjustedProfiles.push({ ...profile, ...{ bridged } });
}
}
const firstCallStart = firstBridge || firstProfileCreate || firstChannelCreate;
const totalDuration = lastCallEnd && firstCallStart ? lastCallEnd.valueOf() - firstCallStart.valueOf() : 0;
return {
profiles: adjustedProfiles,
anyMedia,
anyAnswer,
anyBridge,
durationSum,
totalDuration,
startedAt: firstCallStart,
};
}

@ -0,0 +1,72 @@
// Make an object with a list of changes from the previous accumulated channel state and the state of the next event
export function extractChannelChangesFromEvent(
channelState: Record<string, any>,
_eventName: string,
eventValues: Record<string, any>,
): {
changedValues: Record<string, any>;
newValues: Record<string, any>;
changedExistingValues: Record<string, { oldValue: any; newValue: any; delta?: number }>;
} {
const changedValues: Record<string, any> = {};
const newValues: Record<string, any> = {};
const changedExistingValues: Record<string, any> = {};
const getValues = (key: string): { oldValue: any; newValue: any } => {
const oldValue = channelState[key];
const newValue = eventValues[key];
if (key !== 'bridgeUniqueIds') {
return { oldValue, newValue };
}
// For the bridgeUniqueIds field specifically, only add new values, never remove
const oldList = Array.isArray(oldValue) ? oldValue : [oldValue];
const newList = Array.isArray(newValue) ? newValue : [newValue];
const fullList = [...new Set<string>([...oldList, ...newList])].filter((id) => id);
return { oldValue, newValue: fullList };
};
for (const key of Object.keys(eventValues)) {
const { oldValue, newValue } = getValues(key);
if (newValue === undefined || oldValue === newValue) {
continue;
}
const isDate = typeof newValue === 'object' && newValue instanceof Date && typeof oldValue === 'object' && oldValue instanceof Date;
if (isDate && newValue.toISOString() === oldValue.toISOString()) {
continue;
}
if (Array.isArray(oldValue) || Array.isArray(newValue)) {
const oldList = Array.isArray(oldValue) ? oldValue : [oldValue];
const newList = Array.isArray(newValue) ? newValue : [newValue];
const isEqual = !oldList.some((item) => !newList.includes(item)) && !newList.some((item) => !oldList.includes(item));
if (key.startsWith('variables.') && isEqual) {
continue;
}
if (key === 'bridgeUniqueIds' && newList.length <= oldList.length) {
continue;
}
}
if (oldValue === undefined) {
newValues[key] = newValue;
} else {
changedExistingValues[key] = {
oldValue,
newValue,
...(isDate && { delta: newValue.valueOf() - oldValue.valueOf() }),
};
}
changedValues[key] = newValue;
}
return { changedValues, newValues, changedExistingValues };
}

@ -0,0 +1,21 @@
import { objectMap } from '@rocket.chat/tools';
export function filterOutMissingData<T extends Record<string, any>>(data: T): T {
return objectMap(
data,
({ key, value }) => {
if (typeof value !== 'boolean') {
if (!value || value === '0') {
return;
}
}
if (typeof value === 'object' && !(value instanceof Date) && !Array.isArray(value) && !Object.keys(value).length) {
return;
}
return { key, value };
},
true,
) as T;
}

@ -0,0 +1,16 @@
import type { EventData } from './parseEventData';
export function filterStringList(
object: EventData,
filterFn: (key: string) => boolean,
mapFn?: (data: [string, string | string[] | undefined]) => [string, string | string[] | undefined] | undefined,
): EventData {
const filteredEntries = Object.entries(object).filter(([key]) => filterFn(key));
if (!mapFn) {
return Object.fromEntries(filteredEntries) as EventData;
}
const mappedEntries = filteredEntries.map(mapFn).filter((entry) => entry) as [string, string][];
return Object.fromEntries(mappedEntries);
}

@ -0,0 +1,44 @@
import type { IFreeSwitchChannelEventMutable, IFreeSwitchChannelEventLegProfile } from '@rocket.chat/core-typings';
import { isRecord } from '@rocket.chat/tools';
/**
* Returns a soft-copy of the eventData, with the specified data inserted into the profile of the channel's main leg, if it exists.
* While this returns a new object and the original is not mutated, the result is not a complete hard copy and may still include references to the original
*/
export function insertDataIntoEventProfile(
eventData: IFreeSwitchChannelEventMutable,
dataToInsertIntoProfile: Partial<Pick<IFreeSwitchChannelEventLegProfile, 'bridgedTo' | 'callee' | 'caller'>>,
): IFreeSwitchChannelEventMutable {
if (!isRecord(eventData.legs)) {
return eventData;
}
const clonedData = {
...eventData,
// Clone each leg individually, as we might mutate it
legs: Object.fromEntries(
Object.entries(eventData.legs).map(([key, leg]) => [
key,
{ ...leg, ...(isRecord(leg.profiles) && { profiles: { ...leg.profiles } }) },
]),
),
};
for (const leg of Object.values(clonedData.legs)) {
if (!isRecord(leg?.profiles)) {
continue;
}
// The raw event can never have more than one profile at the same time, it's only a record because the key for the profile can change between events
const legProfileKey = Object.keys(leg.profiles).pop();
if (legProfileKey && isRecord(leg.profiles[legProfileKey])) {
leg.profiles[legProfileKey] = {
...leg.profiles[legProfileKey],
...dataToInsertIntoProfile,
};
}
}
return clonedData;
}

@ -0,0 +1,21 @@
import type { IFreeSwitchChannel } from '@rocket.chat/core-typings';
export function parseChannelKind(channelName?: string): IFreeSwitchChannel['kind'] {
if (!channelName) {
return 'unknown';
}
if (channelName.startsWith('sofia/internal/')) {
return 'internal';
}
if (channelName.startsWith('sofia/external/')) {
return 'external';
}
if (channelName.startsWith('loopback/voicemail')) {
return 'voicemail';
}
return 'unknown';
}

@ -0,0 +1,44 @@
/**
* Gets the FreeSwitch username associated with the channel that an event was triggered for
*
* By design of our integration, FreeSwitch usernames are always equal to the User's Extension Number.
* So effectively this returns an extension number.
*/
import { logger } from '../logger';
import type { EventData } from './parseEventData';
export function parseChannelUsername(channelName?: string): string | undefined {
if (!channelName || channelName.startsWith('loopback/')) {
return;
}
// If it's not a sofia internal/external channel, don't even try to parse it
// It's most likely a voicemail or maybe some spam bots trying different stuff
// If we implement other kinds of channels in the future we should look into how their names are generated so that we may parse them here too.
// The format for external channels may depend on what the external service is, but extension@host should be quite standard
if ((!channelName.startsWith('sofia/internal/') && !channelName.startsWith('sofia/external/')) || !channelName.includes('@')) {
logger.info({ msg: 'FreeSwitch event triggered with something other than a sofia or loopback channel.', channelName });
return;
}
// Originator channels will have the format 'sofia/internal/username@freeswitch_host', assigned by freeswitch itself
// Example: sofia/internal/1001@voip.open.rocket.chat:9999
// Originatee channels will have the format 'sofia/internal/contact_uri', assigned by freeswitch itself
// Example: sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-spo254ol@open.rocket.chat
return channelName.match(/sofia\/(?:in|ex)ternal\/(\d+)[\@\-]/)?.[1];
}
export function parseContactUsername(contactNameOrUri: string): string | undefined {
// Contact URI format is 'username-rocketchat_userid-random_key@rocketchat_hostname', assigned by the rocket.chat client on the REGISTER request
// Non-rocket.chat sessions will likely have a different format
return contactNameOrUri.match(/^(\d+)\-/)?.[1];
}
export function parseEventUsername(eventData: EventData): string | undefined {
const { 'Channel-Name': channelName } = eventData;
return parseChannelUsername(channelName);
}

@ -0,0 +1,39 @@
import type { EventData } from './parseEventData';
function shouldUseOtherLegId(eventData: EventData): boolean {
// If the call ID is from a different channel, then it should be correct
if (eventData['Channel-Call-UUID'] !== eventData['Unique-ID']) {
return false;
}
// If we don't have an originator ID, then we don't have anything to overwrite with
if (eventData['Other-Type'] !== 'originator' || !eventData['Other-Leg-Unique-ID']) {
return false;
}
// #ToDo: Confirm if these conditions hold up on calls with extra legs (eg. voicemail)
if (
eventData['Caller-Direction'] !== 'outbound' ||
(eventData['Other-Leg-Direction'] === 'outbound' && eventData['Other-Leg-Logical-Direction'])
) {
return false;
}
return true;
}
/**
* Gets the call id from the event data.
* For most cases the call id will be the value that freeswitch sends on 'Channel-Call-UUID',
* but on the callee leg of a call that variable will only have the correct value on events triggered while the call is ongoing
* so for the callee leg we sometimes pick it from a different attribute.
*
* This function doesn't validate if an id was actually received, so it might return undefined, but FreeSwitch SHOULD always be sending one.
*/
export function parseEventCallId(eventData: EventData): string | undefined {
if (shouldUseOtherLegId(eventData)) {
return eventData['Other-Leg-Unique-ID'];
}
return eventData['Channel-Call-UUID'];
}

@ -0,0 +1,168 @@
import type { IFreeSwitchChannelEvent } from '@rocket.chat/core-typings';
import { filterOutMissingData } from './filterOutMissingData';
import { filterStringList } from './filterStringList';
import { parseChannelUsername } from './parseChannelUsername';
import { parseEventCallId } from './parseEventCallId';
import { parseEventLeg } from './parseEventLeg';
import { parseTimestamp } from './parseTimestamp';
import { logger } from '../logger';
import { parseEventExtensions } from './parseEventExtensions';
export type EventData = Record<string, string | undefined> & Record<`variable_${string}`, string | string[] | undefined>;
export function parseEventData(eventName: string, eventData: EventData): Omit<IFreeSwitchChannelEvent, '_id' | '_updatedAt'> | undefined {
const {
'Channel-Name': channelName = '',
'Channel-State': channelState = '',
'Channel-Call-State': channelCallState = '',
'Channel-State-Number': channelStateNumber,
'Channel-Call-State-Number': channelCallStateNumber,
'Original-Channel-Call-State': originalChannelCallState,
'Event-Sequence': sequenceStr,
'Event-Date-Timestamp': timestamp,
'Unique-ID': channelUniqueId,
'Call-Direction': callDirection,
'Channel-HIT-Dialplan': channelHitDialplan,
'Answer-State': answerState,
'Hangup-Cause': hangupCause,
'Bridge-A-Unique-ID': bridgeA,
'Bridge-B-Unique-ID': bridgeB,
'Bridged-To': bridgedTo,
'Presence-Call-Direction': presenceCallDirection,
'Channel-Presence-ID': channelPresenceId,
'Channel-Read-Codec-Name': codecReadName,
'Channel-Read-Codec-Rate': codecReadRate,
'Channel-Write-Codec-Name': codecWriteName,
'Channel-Write-Codec-Rate': codecWriteRate,
...rawEventData
} = eventData;
if (!channelUniqueId || !sequenceStr) {
logger.error({ msg: 'Channel Event is missing either the Unique-ID or Event-Sequence', eventData });
return;
}
const sequence = parseInt(sequenceStr);
if (!sequence || typeof sequence !== 'number' || !Number.isInteger(sequence)) {
logger.error({ msg: 'Failed to parse Event-Sequence', eventData });
return;
}
const callUniqueId = parseEventCallId(eventData) || channelUniqueId;
const channelUsername = parseChannelUsername(channelName);
const firedAt = parseTimestamp(timestamp) || new Date();
const callerLeg = parseEventLeg('Caller', eventData);
const otherLeg = parseEventLeg('Other-Leg', eventData);
const bridgeUniqueIds = [bridgeA, bridgeB].filter((bridgeId) => bridgeId) as string[];
const legs: IFreeSwitchChannelEvent['legs'] = {
...(callerLeg?.uniqueId && { [callerLeg.uniqueId]: callerLeg }),
...(otherLeg?.uniqueId && { [otherLeg.uniqueId]: otherLeg }),
};
const variables = filterStringList(
eventData,
(key) => key.startsWith('variable_'),
([key, value]) => {
return [key.replace('variable_', ''), value || ''];
},
) as Record<string, string | string[]>;
const metadata = filterStringList(eventData, (key) => isMetadata(key)) as Record<string, string>;
const unusedRawData = filterStringList(rawEventData, (key) => {
if (isMetadata(key)) {
return false;
}
if (key.startsWith('variable_')) {
return false;
}
for (const { legName } of Object.values(legs)) {
if (key.startsWith(`${legName}-`)) {
return false;
}
}
if (otherLeg && key === 'Other-Type') {
return false;
}
if (key === 'Channel-Call-UUID') {
return rawEventData['Channel-Call-UUID'] !== callUniqueId;
}
return true;
}) as Record<string, string>;
const event: Omit<IFreeSwitchChannelEvent, '_id' | '_updatedAt'> = {
channelUniqueId,
eventName,
sequence,
firedAt,
receivedAt: new Date(),
callUniqueId,
channelName,
channelState,
channelStateNumber,
channelCallStateNumber,
channelCallState,
originalChannelCallState,
channelUsername,
answerState,
callDirection,
channelHitDialplan,
hangupCause,
...(bridgeUniqueIds.length && { bridgeUniqueIds }),
bridgedTo,
legs,
metadata: filterOutMissingData(metadata),
...(Object.keys(variables).length && { variables }),
raw: filterOutMissingData(unusedRawData),
codecs: {
...{
read: {
...{
name: codecReadName,
rate: codecReadRate,
},
},
write: {
...{
nme: codecWriteName,
rate: codecWriteRate,
},
},
},
},
presenceCallDirection,
channelPresenceId,
};
const filteredEvent = {
...filterOutMissingData(event),
channelName,
channelCallState,
channelState,
};
const extensions = parseEventExtensions(filteredEvent);
return {
...filteredEvent,
...extensions,
};
}
function isMetadata(key: string): boolean {
return key.startsWith('Event-') || key.startsWith('FreeSWITCH-') || key.startsWith('Core-');
}

@ -0,0 +1,113 @@
import type { AtLeast, IFreeSwitchChannelEvent, IFreeSwitchChannelEventLeg } from '@rocket.chat/core-typings';
import { parseChannelUsername, parseContactUsername } from './parseChannelUsername';
function normalizeUsername(value?: string): string | undefined {
if (!value) {
return undefined;
}
if (value.startsWith('sofia/internal') || value.startsWith('sofia/external')) {
return parseChannelUsername(value);
}
if (value.match(/^\d+$/)) {
return value;
}
return parseContactUsername(value);
}
function getMostLikelyUsername(valueList: (string | undefined)[]): string | undefined {
const parsedValues: string[] = [];
for (const value of valueList) {
const parsedValue = normalizeUsername(value);
if (!parsedValue) {
continue;
}
if (parsedValue === value) {
return parsedValue;
}
parsedValues.push(parsedValue);
}
return parsedValues.shift();
}
function getOriginatorLeg(
event: Omit<IFreeSwitchChannelEvent, '_id' | '_updatedAt'>,
): AtLeast<IFreeSwitchChannelEventLeg, 'legName' | 'uniqueId' | 'raw'> | undefined {
const legs = event.legs && Object.values(event.legs);
if (!legs?.length) {
return undefined;
}
const selfLeg = event.legs[event.channelUniqueId];
const originator = legs.find((leg) => leg.type === 'originator');
if (event.callDirection === 'inbound') {
return originator || selfLeg;
}
if (originator) {
return originator;
}
const originatee = legs.find((leg) => leg.type === 'originatee');
if (originatee && selfLeg && selfLeg.type !== 'originatee') {
return selfLeg;
}
return undefined;
}
export function parseEventExtensions(
event: Omit<IFreeSwitchChannelEvent, '_id' | '_updatedAt'>,
): { caller?: string; callee?: string } | undefined {
const legs = event.legs && Object.values(event.legs);
const selfLeg = event.legs?.[event.channelUniqueId];
const allDestinationNumbers = legs?.map(({ destinationNumber }) => destinationNumber) || [];
const allCallerNumbers = legs?.map(({ callerNumber }) => callerNumber) || [];
// The dialed_extension variable is only available in a few specific events, but when it's there, it's ALWAYS right.
// It won't ever be an array, but just to be type-safe
const dialedExtension = Array.isArray(event.variables?.dialed_extension)
? event.variables.dialed_extension.shift()
: event.variables?.dialed_extension;
const originator = getOriginatorLeg(event);
if (event.callDirection === 'outbound' && originator) {
// If we have an originator, use it as the source of truth
return {
caller: getMostLikelyUsername([originator.channelName]),
callee: getMostLikelyUsername([dialedExtension, originator.destinationNumber]),
};
}
// If the channel is inbound, then it has never received any call, only initiated.
if (event.callDirection === 'inbound') {
// The username of every leg is always the original caller
const anyUsername = legs
?.map(({ username }) => username)
.filter((username) => username)
.pop();
return {
caller: getMostLikelyUsername([event.channelUsername, selfLeg?.username, anyUsername]),
// Callee might not be available at all if the state is still CS_NEW
callee: getMostLikelyUsername([dialedExtension, selfLeg?.destinationNumber, ...allDestinationNumbers]),
};
}
// Caller-Number and Destination-Number always have some sort of identification of the right caller/destination
// For rocket.chat internal calls, we'll always be able to parse it into an extension number
// For external calls, this might not be identifying the extension at all.
return {
caller: getMostLikelyUsername([...allCallerNumbers]),
callee: getMostLikelyUsername([dialedExtension, ...allDestinationNumbers]),
};
}

@ -0,0 +1,117 @@
import type { AtLeast, IFreeSwitchChannelEventLeg, IFreeSwitchChannelEventLegProfile } from '@rocket.chat/core-typings';
import { filterOutMissingData } from './filterOutMissingData';
import { filterStringList } from './filterStringList';
import type { EventData } from './parseEventData';
import { parseTimestamp } from './parseTimestamp';
export function parseEventLeg(
legName: string,
eventData: EventData,
): AtLeast<IFreeSwitchChannelEventLeg, 'legName' | 'uniqueId' | 'raw'> | undefined {
const legData = filterStringList(
eventData,
(key) => key.startsWith(`${legName}-`),
([key, value]) => {
return [key.replace(`${legName}-`, ''), value];
},
) as Record<string, string>;
const legType = legName === 'Other-Leg' ? eventData['Other-Type'] : undefined;
const {
'Direction': direction,
'Logical-Direction': logicalDirection,
'Username': username,
'Caller-ID-Name': callerName,
'Caller-ID-Number': callerNumber,
'Orig-Caller-ID-Name': originalCallerName,
'Orig-Caller-ID-Number': originalCallerNumber,
'Callee-ID-Name': calleeName,
'Callee-ID-Number': calleeNumber,
'Network-Addr': networkAddress,
'Destination-Number': destinationNumber,
'Unique-ID': uniqueId,
'Source': source,
'Context': context,
'Channel-Name': channelName,
'Dialplan': dialplan,
'Profile-Index': profileIndex,
'ANI': ani,
'RDNIS': rdnis,
'Transfer-Source': transferSource,
'Screen-Bit': screenBit,
'Privacy-Hide-Name': privacyHideName,
'Privacy-Hide-Number': privacyHideNumber,
'Profile-Created-Time': profileCreatedTime,
'Channel-Created-Time': channelCreatedTime,
'Channel-Answered-Time': channelAnsweredTime,
'Channel-Progress-Time': channelProgressTime,
'Channel-Bridged-Time': channelBridgedTime,
'Channel-Progress-Media-Time': channelProgressMediaTime,
'Channel-Hangup-Time': channelHangupTime,
'Channel-Transfer-Time': channelTransferTime,
'Channel-Resurrect-Time': channelRessurectTime,
'Channel-Last-Hold': channelLastHold,
...rawLegData
} = legData;
if (!uniqueId) {
return;
}
const profile: IFreeSwitchChannelEventLegProfile = {
...filterOutMissingData({
profileIndex,
profileCreatedTime: parseTimestamp(profileCreatedTime),
channelCreatedTime: parseTimestamp(channelCreatedTime),
channelAnsweredTime: parseTimestamp(channelAnsweredTime),
channelProgressTime: parseTimestamp(channelProgressTime),
channelBridgedTime: parseTimestamp(channelBridgedTime),
channelProgressMediaTime: parseTimestamp(channelProgressMediaTime),
channelHangupTime: parseTimestamp(channelHangupTime),
channelTransferTime: parseTimestamp(channelTransferTime),
channelRessurectTime: parseTimestamp(channelRessurectTime),
channelLastHold: parseTimestamp(channelLastHold),
}),
};
const effectiveProfileIndex = profileIndex || '1';
const leg: AtLeast<IFreeSwitchChannelEventLeg, 'legName' | 'uniqueId' | 'raw'> = {
legName,
uniqueId,
type: legType,
direction,
logicalDirection,
username,
callerName,
callerNumber,
originalCallerName,
originalCallerNumber,
calleeName,
calleeNumber,
networkAddress,
destinationNumber,
source,
context,
channelName,
transferSource,
// If there's no profileIndex, default to '1', but do not save a profile if there's nothing in it
...(Object.keys(profile).length > 0 && { profiles: { [effectiveProfileIndex]: { ...profile, profileIndex: effectiveProfileIndex } } }),
dialplan,
ani,
rdnis,
screenBit,
privacyHideName,
privacyHideNumber,
raw: filterOutMissingData(rawLegData),
};
return filterOutMissingData(leg);
}

@ -0,0 +1,13 @@
export function parseTimestamp(timestamp: string | undefined): Date | undefined {
if (!timestamp || timestamp === '0') {
return undefined;
}
const value = parseInt(timestamp);
if (Number.isNaN(value) || value < 0) {
return undefined;
}
const timeValue = Math.floor(value / 1000);
return new Date(timeValue);
}

@ -1,4 +1,6 @@
export * from './commands';
export * from './esl';
export * from './logger';
export * from './eventParser/parseEventData';
export * from './eventParser/computeChannelFromEvents';
export * from './esl';
export * from './FreeSwitchOptions';

@ -0,0 +1,706 @@
import { describe, expect, it } from '@jest/globals';
import type { IFreeSwitchChannelEvent } from '@rocket.chat/core-typings';
import { computeChannelFromEvents } from '../../src/eventParser/computeChannelFromEvents';
describe('computeChannelFromEvents', () => {
const createTestEvent = (overrides: Omit<IFreeSwitchChannelEvent, '_id' | '_updatedAt'>): IFreeSwitchChannelEvent => ({
_id: 'event-123',
_updatedAt: new Date(),
...overrides,
});
it('should compute channel from events', async () => {
const events = [
createTestEvent({
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
metadata: {},
firedAt: new Date('2024-02-28T12:00:00.000Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
callUniqueId: 'call-123',
channelName: 'sofia/internal/1001@192.168.1.100',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
callDirection: 'outbound',
caller: '2001',
callee: '2002',
raw: {},
legs: {
'Caller-Leg': {
legName: 'Caller-Leg',
uniqueId: 'channel-123',
direction: 'outbound',
logicalDirection: 'outbound',
username: '1001',
channelName: 'sofia/internal/1001@192.168.1.100',
destinationNumber: '1002',
raw: {},
profiles: {
1: {
profileIndex: '1',
channelCreatedTime: new Date('2024-02-28T12:00:00.000Z'),
profileCreatedTime: new Date('2024-02-28T12:00:00.000Z'),
},
},
},
},
}),
createTestEvent({
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_ANSWER',
sequence: 2,
metadata: {},
firedAt: new Date('2024-02-28T12:00:01.000Z'),
receivedAt: new Date('2024-02-28T12:00:01.100Z'),
callUniqueId: 'call-123',
channelName: 'sofia/internal/1001@192.168.1.100',
channelState: 'CS_EXECUTE',
channelCallState: 'RINGING',
callDirection: 'outbound',
channelUsername: '1001',
raw: {},
legs: {
'Caller-Leg': {
legName: 'Caller-Leg',
uniqueId: 'channel-123',
direction: 'outbound',
logicalDirection: 'outbound',
username: '1001',
channelName: 'sofia/internal/1001@192.168.1.100',
destinationNumber: '1002',
raw: {},
profiles: {
1: {
profileIndex: '1',
channelCreatedTime: new Date('2024-02-28T12:00:00.000Z'),
profileCreatedTime: new Date('2024-02-28T12:00:00.000Z'),
channelAnsweredTime: new Date('2024-02-28T12:00:01.000Z'),
},
},
},
},
}),
createTestEvent({
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_BRIDGE',
sequence: 3,
metadata: {},
firedAt: new Date('2024-02-28T12:00:02.000Z'),
receivedAt: new Date('2024-02-28T12:00:02.100Z'),
bridgeUniqueIds: ['channel-123', 'channel-456'],
callUniqueId: 'call-123',
channelName: 'sofia/internal/1001@192.168.1.100',
channelState: 'CS_EXECUTE',
channelCallState: 'ACTIVE',
raw: {},
legs: {},
}),
];
const result = await computeChannelFromEvents(events);
expect(result).toEqual({
channel: {
uniqueId: 'channel-123',
name: 'sofia/internal/1001@192.168.1.100',
callDirection: 'outbound',
freeSwitchUser: '1001',
callers: ['2001'],
callees: ['2002'],
bridgedTo: ['channel-456'],
profiles: [],
anyMedia: false,
anyAnswer: false,
anyBridge: false,
durationSum: 0,
totalDuration: 0,
startedAt: new Date('2024-02-28T12:00:00.000Z'),
kind: 'internal',
finalState: {
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_BRIDGE',
sequence: 3,
callUniqueId: 'call-123',
channelName: 'sofia/internal/1001@192.168.1.100',
channelState: 'CS_EXECUTE',
channelCallState: 'ACTIVE',
callDirection: 'outbound',
bridgeUniqueIds: ['channel-123', 'channel-456'],
legs: {
'Caller-Leg': {
legName: 'Caller-Leg',
uniqueId: 'channel-123',
direction: 'outbound',
logicalDirection: 'outbound',
username: '1001',
channelName: 'sofia/internal/1001@192.168.1.100',
destinationNumber: '1002',
profiles: {
1: {
profileIndex: '1',
channelCreatedTime: new Date('2024-02-28T12:00:00.000Z'),
profileCreatedTime: new Date('2024-02-28T12:00:00.000Z'),
channelAnsweredTime: new Date('2024-02-28T12:00:01.000Z'),
caller: '2001',
callee: '2002',
},
},
},
},
channelUsername: '1001',
},
events: [
{
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date('2024-02-28T12:00:00.000Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
channelState: 'CS_NEW',
channelCallState: 'DOWN',
caller: '2001',
callee: '2002',
answerState: undefined,
originalChannelCallState: undefined,
},
{
eventName: 'CHANNEL_ANSWER',
sequence: 2,
firedAt: new Date('2024-02-28T12:00:01.000Z'),
receivedAt: new Date('2024-02-28T12:00:01.100Z'),
channelState: 'CS_EXECUTE',
channelCallState: 'RINGING',
callee: undefined,
caller: undefined,
answerState: undefined,
originalChannelCallState: undefined,
},
{
eventName: 'CHANNEL_BRIDGE',
sequence: 3,
firedAt: new Date('2024-02-28T12:00:02.000Z'),
receivedAt: new Date('2024-02-28T12:00:02.100Z'),
channelState: 'CS_EXECUTE',
channelCallState: 'ACTIVE',
callee: undefined,
caller: undefined,
answerState: undefined,
originalChannelCallState: undefined,
},
],
},
deltas: [
{
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date('2024-02-28T12:00:00.000Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
callee: '2002',
caller: '2001',
channelCallState: 'DOWN',
channelState: 'CS_NEW',
newValues: {
callUniqueId: 'call-123',
channelName: 'sofia/internal/1001@192.168.1.100',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
callDirection: 'outbound',
legs: {
'Caller-Leg': {
legName: 'Caller-Leg',
uniqueId: 'channel-123',
direction: 'outbound',
logicalDirection: 'outbound',
profiles: {
1: {
profileIndex: '1',
channelCreatedTime: new Date('2024-02-28T12:00:00.000Z'),
profileCreatedTime: new Date('2024-02-28T12:00:00.000Z'),
caller: '2001',
callee: '2002',
},
},
username: '1001',
channelName: 'sofia/internal/1001@192.168.1.100',
destinationNumber: '1002',
},
},
},
},
{
eventName: 'CHANNEL_ANSWER',
sequence: 2,
firedAt: new Date('2024-02-28T12:00:01.000Z'),
receivedAt: new Date('2024-02-28T12:00:01.100Z'),
channelCallState: 'RINGING',
channelState: 'CS_EXECUTE',
newValues: {
channelUsername: '1001',
legs: {
'Caller-Leg': {
profiles: {
1: {
channelAnsweredTime: new Date('2024-02-28T12:00:01.000Z'),
},
},
},
},
},
modifiedValues: {
channelState: { oldValue: 'CS_NEW', newValue: 'CS_EXECUTE' },
channelCallState: { oldValue: 'DOWN', newValue: 'RINGING' },
},
},
{
eventName: 'CHANNEL_BRIDGE',
sequence: 3,
firedAt: new Date('2024-02-28T12:00:02.000Z'),
receivedAt: new Date('2024-02-28T12:00:02.100Z'),
channelCallState: 'ACTIVE',
channelState: 'CS_EXECUTE',
newValues: {
bridgeUniqueIds: ['channel-123', 'channel-456'],
},
modifiedValues: {
channelCallState: { oldValue: 'RINGING', newValue: 'ACTIVE' },
},
},
],
});
});
it('should handle missing legs', async () => {
const events = [
createTestEvent({
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
metadata: {},
firedAt: new Date('2024-02-28T12:00:00.000Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
callUniqueId: 'call-123',
channelName: 'sofia/internal/1001@192.168.1.100',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
raw: {},
legs: {},
bridgedTo: 'channel-456',
}),
];
const result = await computeChannelFromEvents(events);
expect(result).toEqual({
channel: {
uniqueId: 'channel-123',
name: 'sofia/internal/1001@192.168.1.100',
callDirection: '',
callers: [],
callees: [],
bridgedTo: ['channel-456'],
profiles: [],
anyMedia: false,
anyAnswer: false,
anyBridge: false,
durationSum: 0,
totalDuration: 0,
startedAt: new Date('2024-02-28T12:00:00.000Z'),
kind: 'internal',
finalState: {
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
callUniqueId: 'call-123',
channelName: 'sofia/internal/1001@192.168.1.100',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
bridgedTo: 'channel-456',
},
events: [
{
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date('2024-02-28T12:00:00.000Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
channelState: 'CS_NEW',
channelCallState: 'DOWN',
callee: undefined,
caller: undefined,
answerState: undefined,
originalChannelCallState: undefined,
},
],
},
deltas: [
{
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date('2024-02-28T12:00:00.000Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
channelCallState: 'DOWN',
channelState: 'CS_NEW',
newValues: {
callUniqueId: 'call-123',
channelName: 'sofia/internal/1001@192.168.1.100',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
bridgedTo: 'channel-456',
},
},
],
});
});
it('should handle missing caller leg', async () => {
const events = [
createTestEvent({
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
metadata: {},
firedAt: new Date('2024-02-28T12:00:00.000Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
callUniqueId: 'call-123',
channelName: '',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
raw: {},
legs: {
'Other-Leg': {
legName: 'Other-Leg',
uniqueId: 'channel-456',
direction: 'inbound',
logicalDirection: 'inbound',
username: '1002',
channelName: 'sofia/internal/1002@192.168.1.101',
destinationNumber: '1001',
},
},
}),
];
const result = await computeChannelFromEvents(events);
expect(result).toEqual({
channel: {
uniqueId: 'channel-123',
callDirection: '',
callers: [],
callees: [],
bridgedTo: [],
profiles: [],
anyMedia: false,
anyAnswer: false,
anyBridge: false,
durationSum: 0,
totalDuration: 0,
startedAt: new Date('2024-02-28T12:00:00.000Z'),
kind: 'unknown',
finalState: {
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
callUniqueId: 'call-123',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
legs: {
'Other-Leg': {
legName: 'Other-Leg',
uniqueId: 'channel-456',
direction: 'inbound',
logicalDirection: 'inbound',
username: '1002',
channelName: 'sofia/internal/1002@192.168.1.101',
destinationNumber: '1001',
},
},
},
events: [
{
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date('2024-02-28T12:00:00.000Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
channelState: 'CS_NEW',
channelCallState: 'DOWN',
callee: undefined,
caller: undefined,
answerState: undefined,
originalChannelCallState: undefined,
},
],
},
deltas: [
{
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date('2024-02-28T12:00:00.000Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
channelState: 'CS_NEW',
channelCallState: 'DOWN',
newValues: {
callUniqueId: 'call-123',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
legs: {
'Other-Leg': {
legName: 'Other-Leg',
uniqueId: 'channel-456',
direction: 'inbound',
logicalDirection: 'inbound',
username: '1002',
channelName: 'sofia/internal/1002@192.168.1.101',
destinationNumber: '1001',
},
},
},
},
],
});
});
it('should handle missing call ID', async () => {
const events = [
createTestEvent({
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
metadata: {},
firedAt: new Date('2024-02-28T12:00:00.000Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
callUniqueId: '',
channelName: '',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
raw: {},
legs: {},
}),
];
const result = await computeChannelFromEvents(events);
expect(result).toEqual({
channel: {
uniqueId: 'channel-123',
callDirection: '',
callers: [],
callees: [],
bridgedTo: [],
profiles: [],
anyMedia: false,
anyAnswer: false,
anyBridge: false,
durationSum: 0,
totalDuration: 0,
startedAt: new Date('2024-02-28T12:00:00.000Z'),
kind: 'unknown',
finalState: {
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
channelState: 'CS_NEW',
channelCallState: 'DOWN',
},
events: [
{
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date('2024-02-28T12:00:00.000Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
channelState: 'CS_NEW',
channelCallState: 'DOWN',
callee: undefined,
caller: undefined,
answerState: undefined,
originalChannelCallState: undefined,
},
],
},
deltas: [
{
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date('2024-02-28T12:00:00.000Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
channelState: 'CS_NEW',
channelCallState: 'DOWN',
newValues: { channelState: 'CS_NEW', channelCallState: 'DOWN' },
},
],
});
});
it('should handle missing event timestamps', async () => {
const events = [
createTestEvent({
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
metadata: {},
firedAt: new Date('2024-02-28T12:00:00.100Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
callUniqueId: 'call-123',
channelName: 'sofia/internal/1007@host',
channelUsername: '1007',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
raw: {},
legs: {},
}),
];
const result = await computeChannelFromEvents(events);
expect(result).toEqual({
channel: {
uniqueId: 'channel-123',
callDirection: '',
callers: [],
callees: [],
bridgedTo: [],
profiles: [],
anyMedia: false,
anyAnswer: false,
anyBridge: false,
durationSum: 0,
totalDuration: 0,
startedAt: new Date('2024-02-28T12:00:00.100Z'),
kind: 'internal',
name: 'sofia/internal/1007@host',
freeSwitchUser: '1007',
finalState: {
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
callUniqueId: 'call-123',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
channelName: 'sofia/internal/1007@host',
channelUsername: '1007',
},
events: [
{
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date('2024-02-28T12:00:00.100Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
channelState: 'CS_NEW',
channelCallState: 'DOWN',
callee: undefined,
caller: undefined,
answerState: undefined,
originalChannelCallState: undefined,
},
],
},
deltas: [
{
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date('2024-02-28T12:00:00.100Z'),
receivedAt: new Date('2024-02-28T12:00:00.100Z'),
channelState: 'CS_NEW',
channelCallState: 'DOWN',
newValues: {
callUniqueId: 'call-123',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
channelName: 'sofia/internal/1007@host',
channelUsername: '1007',
},
},
],
});
});
it('validate fallbacks for unlikely scenarios', async () => {
// Force missing
const firedAt = undefined as unknown as Date;
const receivedAt = undefined as unknown as Date;
const events = [
createTestEvent({
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
metadata: {},
firedAt,
receivedAt,
callUniqueId: 'call-123',
channelName: 'sofia/internal/1007@host',
channelUsername: '1007',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
raw: {},
legs: {},
}),
];
const result = await computeChannelFromEvents(events);
expect(result).toEqual({
channel: {
uniqueId: 'channel-123',
callDirection: '',
callers: [],
callees: [],
bridgedTo: [],
profiles: [],
anyMedia: false,
anyAnswer: false,
anyBridge: false,
durationSum: 0,
totalDuration: 0,
kind: 'internal',
name: 'sofia/internal/1007@host',
freeSwitchUser: '1007',
finalState: {
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
callUniqueId: 'call-123',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
channelName: 'sofia/internal/1007@host',
channelUsername: '1007',
},
events: [
{
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt,
receivedAt,
channelState: 'CS_NEW',
channelCallState: 'DOWN',
callee: undefined,
caller: undefined,
answerState: undefined,
originalChannelCallState: undefined,
},
],
},
deltas: [
{
eventName: 'CHANNEL_CREATE',
sequence: 1,
channelState: 'CS_NEW',
channelCallState: 'DOWN',
newValues: {
callUniqueId: 'call-123',
channelState: 'CS_NEW',
channelCallState: 'DOWN',
channelName: 'sofia/internal/1007@host',
channelUsername: '1007',
},
},
],
});
});
it('should return undefined for empty events array', async () => {
const result = await computeChannelFromEvents([]);
expect(result).toBeUndefined();
});
});

@ -0,0 +1,385 @@
import { describe, expect, it } from '@jest/globals';
import type { IFreeSwitchChannelEventLegProfile } from '@rocket.chat/core-typings';
import { computeChannelProfiles } from '../../src/eventParser/computeChannelProfiles';
describe('computeChannelProfiles', () => {
const createTestProfile = (overrides: Partial<IFreeSwitchChannelEventLegProfile>): IFreeSwitchChannelEventLegProfile => ({
...overrides,
});
it('should compute channel profiles with all timestamps', () => {
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = {
profile1: createTestProfile({
profileIndex: '1',
profileCreatedTime: new Date(1709123456789),
channelCreatedTime: new Date(1709123456790),
channelAnsweredTime: new Date(1709123457789),
channelProgressTime: new Date(1709123457889),
channelProgressMediaTime: new Date(1709123457989),
channelBridgedTime: new Date(1709123458789),
channelHangupTime: new Date(1709123466789),
channelTransferTime: new Date(1709123456791),
channelRessurectTime: new Date(1709123456792),
channelLastHold: new Date(1709123456793),
}),
};
const result = computeChannelProfiles(profiles);
expect(result).toEqual({
profiles: [
{
profileIndex: '1',
profileCreatedTime: new Date(1709123456789),
channelCreatedTime: new Date(1709123456790),
channelAnsweredTime: new Date(1709123457789),
channelProgressTime: new Date(1709123457889),
channelProgressMediaTime: new Date(1709123457989),
channelBridgedTime: new Date(1709123458789),
channelHangupTime: new Date(1709123466789),
channelTransferTime: new Date(1709123456791),
channelRessurectTime: new Date(1709123456792),
channelLastHold: new Date(1709123456793),
callDuration: 8000,
answered: true,
media: true,
bridged: true,
},
],
anyMedia: true,
anyAnswer: true,
anyBridge: true,
durationSum: 8000,
totalDuration: 8000,
startedAt: new Date(1709123458789),
});
});
it('should handle missing timestamps', () => {
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = {
profile1: createTestProfile({
profileIndex: '1',
}),
};
const result = computeChannelProfiles(profiles);
expect(result).toEqual({
profiles: [{ profileIndex: '1', bridged: false }],
anyMedia: false,
anyAnswer: false,
anyBridge: false,
durationSum: 0,
totalDuration: 0,
startedAt: undefined,
});
});
it('should handle multiple profiles', () => {
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = {
profile1: createTestProfile({
profileIndex: '1',
profileCreatedTime: new Date(1709123456789),
channelCreatedTime: new Date(1709123456790),
}),
profile2: createTestProfile({
profileIndex: '2',
profileCreatedTime: new Date(1709123456791),
channelCreatedTime: new Date(1709123456792),
}),
};
const result = computeChannelProfiles(profiles);
expect(result).toEqual({
profiles: [
{
profileIndex: '1',
profileCreatedTime: new Date(1709123456789),
channelCreatedTime: new Date(1709123456790),
nextProfileCreatedTime: new Date(1709123456791),
callDuration: 0,
answered: true,
media: true,
bridged: false,
},
{
channelCreatedTime: new Date(1709123456792),
profileIndex: '2',
profileCreatedTime: new Date(1709123456791),
callDuration: 0,
answered: true,
media: true,
bridged: false,
},
],
anyMedia: true,
anyAnswer: true,
anyBridge: false,
durationSum: 0,
totalDuration: 0,
startedAt: new Date(1709123456789),
});
});
it('should handle multiple full profiles', () => {
const forcedInvalidAttributes = {
unknownAttribute: 'value',
unsetAttribute: false,
} as any;
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = {
profile1: createTestProfile({
profileIndex: '1',
profileCreatedTime: new Date(1709123456789),
channelCreatedTime: new Date(1709123456790),
channelAnsweredTime: new Date(1709123456789),
channelProgressTime: new Date(1709123456889),
channelProgressMediaTime: new Date(1709123456989),
channelBridgedTime: new Date(1709123456789),
channelLastHold: new Date(1709123456789),
}),
profile2: createTestProfile({
profileIndex: '2',
profileCreatedTime: new Date(1709123656789),
channelAnsweredTime: new Date(1709123657789),
channelProgressTime: new Date(1709123657889),
channelProgressMediaTime: new Date(1709123657989),
channelBridgedTime: new Date(1709123658789),
channelHangupTime: new Date(1709123666789),
channelTransferTime: new Date(1709123656791),
channelRessurectTime: new Date(1709123656792),
channelLastHold: new Date(1709123456789),
...forcedInvalidAttributes,
}),
};
const result = computeChannelProfiles(profiles);
expect(result).toEqual({
profiles: [
{
profileIndex: '1',
profileCreatedTime: new Date(1709123456789),
channelCreatedTime: new Date(1709123456790),
channelAnsweredTime: new Date(1709123456789),
channelProgressTime: new Date(1709123456889),
channelProgressMediaTime: new Date(1709123456989),
channelBridgedTime: new Date(1709123456789),
channelLastHold: new Date(1709123456789),
callDuration: 200000,
answered: true,
media: true,
bridged: true,
nextProfileCreatedTime: new Date(1709123656789),
},
{
profileIndex: '2',
profileCreatedTime: new Date(1709123656789),
channelAnsweredTime: new Date(1709123657789),
channelProgressTime: new Date(1709123657889),
channelProgressMediaTime: new Date(1709123657989),
channelBridgedTime: new Date(1709123658789),
channelHangupTime: new Date(1709123666789),
channelTransferTime: new Date(1709123656791),
channelRessurectTime: new Date(1709123656792),
// should not be carried over due to being lower than the profile create time
channelLastHold: undefined,
callDuration: 8000,
answered: true,
media: true,
bridged: true,
},
],
anyMedia: true,
anyAnswer: true,
anyBridge: true,
durationSum: 208000,
totalDuration: 210000,
startedAt: new Date(1709123456789),
});
});
it('should handle empty profiles object', () => {
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = {};
const result = computeChannelProfiles(profiles);
expect(result).toEqual({ profiles: [], anyMedia: false, anyAnswer: false, anyBridge: false, durationSum: 0, totalDuration: 0 });
});
it('should handle profiles with no creation time', () => {
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = {
profile2: createTestProfile({
profileIndex: '2',
channelAnsweredTime: new Date(1709123457789),
}),
};
const result = computeChannelProfiles(profiles);
expect(result).toEqual({
profiles: [{ profileIndex: '2', bridged: false }],
anyMedia: false,
anyAnswer: false,
anyBridge: false,
durationSum: 0,
totalDuration: 0,
});
});
it('validate fallback for unlikely data', () => {
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = {
profile1: createTestProfile({
profileIndex: '2',
profileCreatedTime: new Date(1709123656789),
channelAnsweredTime: new Date(1709123657789),
channelProgressTime: new Date(1709123657889),
channelProgressMediaTime: new Date(1709123657989),
channelBridgedTime: new Date(1709123658789),
channelHangupTime: new Date(1709123666789),
}),
profile3: createTestProfile({
profileIndex: '1',
channelCreatedTime: new Date(1709123456790),
channelAnsweredTime: new Date(1709123456789),
}),
profile2: createTestProfile({
profileIndex: '1',
channelCreatedTime: new Date(1709123456790),
channelAnsweredTime: new Date(1709123456789),
}),
};
const result = computeChannelProfiles(profiles);
expect(result).toEqual({
profiles: [
{
profileIndex: '1',
channelCreatedTime: new Date(1709123456790),
channelAnsweredTime: new Date(1709123456789),
callDuration: 0,
answered: true,
media: true,
bridged: false,
},
{
profileIndex: '1',
channelCreatedTime: new Date(1709123456790),
channelAnsweredTime: new Date(1709123456789),
nextProfileCreatedTime: new Date(1709123656789),
callDuration: 0,
answered: true,
media: true,
bridged: false,
},
{
profileIndex: '2',
profileCreatedTime: new Date(1709123656789),
channelAnsweredTime: new Date(1709123657789),
channelProgressTime: new Date(1709123657889),
channelProgressMediaTime: new Date(1709123657989),
channelBridgedTime: new Date(1709123658789),
channelHangupTime: new Date(1709123666789),
callDuration: 8000,
answered: true,
media: true,
bridged: true,
},
],
anyMedia: true,
anyAnswer: true,
anyBridge: true,
durationSum: 8000,
totalDuration: 8000,
startedAt: new Date(1709123658789),
});
});
it('should handle a mix of profiles with and without creation time', () => {
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = {
profile1: createTestProfile({
profileIndex: '1',
profileCreatedTime: new Date(1709123456789),
channelCreatedTime: new Date(1709123456790),
channelAnsweredTime: new Date(1709123456789),
channelProgressTime: new Date(1709123456889),
channelProgressMediaTime: new Date(1709123456989),
channelBridgedTime: new Date(1709123456789),
channelLastHold: new Date(1709123456789),
}),
profile2: createTestProfile({
profileIndex: '2',
channelCreatedTime: new Date(1709123456790),
channelAnsweredTime: new Date(1709123656785),
}),
profile3: createTestProfile({
profileIndex: '3',
channelCreatedTime: new Date(1709123456790),
channelAnsweredTime: new Date(1709123656785),
}),
profile4: createTestProfile({
profileIndex: '4',
profileCreatedTime: new Date(1709123656789),
channelAnsweredTime: new Date(1709123657789),
channelProgressTime: new Date(1709123657889),
channelProgressMediaTime: new Date(1709123657989),
channelBridgedTime: new Date(1709123658789),
channelHangupTime: new Date(1709123666789),
channelTransferTime: new Date(1709123656791),
channelRessurectTime: new Date(1709123656792),
channelLastHold: new Date(1709123456789),
}),
};
const result = computeChannelProfiles(profiles);
expect(result).toEqual({
profiles: [
{
profileIndex: '1',
profileCreatedTime: new Date(1709123456789),
channelCreatedTime: new Date(1709123456790),
channelAnsweredTime: new Date(1709123456789),
channelProgressTime: new Date(1709123456889),
channelProgressMediaTime: new Date(1709123456989),
channelBridgedTime: new Date(1709123456789),
channelLastHold: new Date(1709123456789),
nextProfileCreatedTime: new Date(1709123656789),
callDuration: 200000,
answered: true,
media: true,
bridged: true,
},
{
profileIndex: '4',
profileCreatedTime: new Date(1709123656789),
channelAnsweredTime: new Date(1709123657789),
channelProgressTime: new Date(1709123657889),
channelProgressMediaTime: new Date(1709123657989),
channelBridgedTime: new Date(1709123658789),
channelHangupTime: new Date(1709123666789),
channelTransferTime: new Date(1709123656791),
channelRessurectTime: new Date(1709123656792),
callDuration: 8000,
answered: true,
media: true,
bridged: true,
},
{
channelCreatedTime: new Date(1709123456790),
profileIndex: '2',
bridged: false,
},
{
channelCreatedTime: new Date(1709123456790),
profileIndex: '3',
bridged: false,
},
],
anyMedia: true,
anyAnswer: true,
anyBridge: true,
durationSum: 208000,
totalDuration: 210000,
startedAt: new Date(1709123456789),
});
});
});

@ -0,0 +1,228 @@
import { extractChannelChangesFromEvent } from '../../src/eventParser/extractChannelChangesFromEvent';
describe('extractChannelChangesFromEvent', () => {
it('should detect new values', () => {
const channelState = {};
const eventValues = {
simple: 'value',
number: 42,
date: new Date('2024-01-01'),
};
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues);
expect(result.newValues).toEqual({
simple: 'value',
number: 42,
date: new Date('2024-01-01'),
});
expect(result.changedValues).toEqual({
simple: 'value',
number: 42,
date: new Date('2024-01-01'),
});
expect(result.changedExistingValues).toEqual({});
});
it('should detect changed values', () => {
const channelState = {
simple: 'old',
number: 42,
date: new Date('2024-01-01'),
};
const eventValues = {
simple: 'new',
number: 43,
date: new Date('2024-01-02'),
};
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues);
expect(result.newValues).toEqual({});
expect(result.changedValues).toEqual({
simple: 'new',
number: 43,
date: new Date('2024-01-02'),
});
expect(result.changedExistingValues).toEqual({
simple: {
oldValue: 'old',
newValue: 'new',
},
number: {
oldValue: 42,
newValue: 43,
},
date: {
oldValue: new Date('2024-01-01'),
newValue: new Date('2024-01-02'),
delta: 24 * 60 * 60 * 1000, // 1 day in milliseconds
},
});
});
it('should handle arrays', () => {
const channelState = {
simple: ['a', 'b'],
variables: ['x', 'y'],
};
const eventValues = {
simple: ['b', 'c'],
variables: ['y', 'z'],
};
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues);
expect(result.newValues).toEqual({});
expect(result.changedValues).toEqual({
simple: ['b', 'c'],
variables: ['y', 'z'],
});
expect(result.changedExistingValues).toEqual({
simple: {
oldValue: ['a', 'b'],
newValue: ['b', 'c'],
},
variables: {
newValue: ['y', 'z'],
oldValue: ['x', 'y'],
},
});
});
it('should handle bridgeUniqueIds specially', () => {
const channelState = {
bridgeUniqueIds: ['bridge1', 'bridge2'],
};
const eventValues = {
bridgeUniqueIds: ['bridge2', 'bridge3'],
};
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues);
expect(result.newValues).toEqual({});
expect(result.changedValues).toEqual({
bridgeUniqueIds: ['bridge1', 'bridge2', 'bridge3'],
});
expect(result.changedExistingValues).toEqual({
bridgeUniqueIds: {
oldValue: ['bridge1', 'bridge2'],
newValue: ['bridge1', 'bridge2', 'bridge3'],
},
});
});
it('should handle single bridgeUniqueId values', () => {
const channelState = {
bridgeUniqueIds: 'bridge1',
};
const eventValues = {
bridgeUniqueIds: 'bridge2',
};
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues);
expect(result.newValues).toEqual({});
expect(result.changedValues).toEqual({
bridgeUniqueIds: ['bridge1', 'bridge2'],
});
expect(result.changedExistingValues).toEqual({
bridgeUniqueIds: {
oldValue: 'bridge1',
newValue: ['bridge1', 'bridge2'],
},
});
});
it('should ignore unchanged values', () => {
const channelState = {
'simple': 'value',
'number': 42,
'date': new Date('2024-01-01'),
'variables.array': ['a', 'b'],
};
const eventValues = {
'simple': 'value',
'number': 42,
'date': new Date('2024-01-01'),
'variables.array': ['a', 'b'],
};
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues);
expect(result.newValues).toEqual({});
expect(result.changedValues).toEqual({});
expect(result.changedExistingValues).toEqual({});
});
it('should ignore missing values in the bridgeUniqueIds param', () => {
const channelState = {
simple: 'value',
bridgeUniqueIds: ['1', '2', '3'],
};
const eventValues = {
simple: 'value',
bridgeUniqueIds: ['1', '3'],
};
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues);
expect(result.newValues).toEqual({});
expect(result.changedValues).toEqual({});
expect(result.changedExistingValues).toEqual({});
});
it('should handle mixed arrays and values', () => {
const channelState = {
'variables.array': ['1', '3'],
};
const eventValues = {
'variables.array': '1',
};
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues);
expect(result.newValues).toEqual({});
expect(result.changedValues).toEqual({
'variables.array': '1',
});
expect(result.changedExistingValues).toEqual({
'variables.array': {
newValue: '1',
oldValue: ['1', '3'],
},
});
});
it('should handle undefined values', () => {
const channelState = {
simple: 'value',
number: 42,
};
const eventValues = {
simple: undefined,
number: 42,
};
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues);
expect(result.newValues).toEqual({});
expect(result.changedValues).toEqual({});
expect(result.changedExistingValues).toEqual({});
});
it('should handle variables arrays specially', () => {
const channelState = {
'variables.array': ['a', 'b'],
};
const eventValues = {
'variables.array': ['b', 'a'],
};
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues);
expect(result.newValues).toEqual({});
expect(result.changedValues).toEqual({});
expect(result.changedExistingValues).toEqual({});
});
});

@ -0,0 +1,14 @@
import { filterOutMissingData } from '../../src/eventParser/filterOutMissingData';
describe('filterOutMissingData', () => {
test.each([
['case-1', { a: '1', b: '', c: '0', d: undefined }, { a: '1' }],
['case-2', { a: true, b: false, c: '0' }, { a: true, b: false }],
['case-3', { a: { b: {} }, c: { d: '1' } }, { c: { d: '1' } }],
['case-4', { a: [], b: [1], c: {} }, { a: [], b: [1] }],
['case-5', { a: new Date(), b: {} }, { a: expect.any(Date) }],
['case-6', {}, {}],
])('should filter out missing data for test %# (%s)', (_caseId, input, expected) => {
expect(filterOutMissingData(input)).toEqual(expected);
});
});

@ -0,0 +1,112 @@
import { describe, expect, it } from '@jest/globals';
import { filterStringList } from '../../src/eventParser/filterStringList';
import type { EventData } from '../../src/eventParser/parseEventData';
function createEventData(data: Record<string, string | string[] | undefined>): EventData {
return data as unknown as EventData;
}
describe('filterStringList', () => {
it('should filter entries based on key filter function', () => {
const eventData = createEventData({
'Channel-Name': 'test-channel',
'Channel-State': 'CS_INIT',
'variable_test': 'test-value',
'variable_array': ['value1', 'value2'],
});
const result = filterStringList(eventData, (key) => key.startsWith('variable_'));
expect(result).toEqual({
variable_test: 'test-value',
variable_array: ['value1', 'value2'],
});
});
it('should return empty object when no entries match filter', () => {
const eventData = createEventData({
'Channel-Name': 'test-channel',
'Channel-State': 'CS_INIT',
});
const result = filterStringList(eventData, (key) => key.startsWith('variable_'));
expect(result).toEqual({});
});
it('should apply map function to filtered entries', () => {
const eventData = createEventData({
variable_test1: 'value1',
variable_test2: 'value2',
variable_test3: 'value3',
});
const result = filterStringList(
eventData,
(key) => key.startsWith('variable_'),
([key, value]) => [key.replace('variable_', ''), value],
);
expect(result).toEqual({
test1: 'value1',
test2: 'value2',
test3: 'value3',
});
});
it('should filter out undefined mapped entries', () => {
const eventData = createEventData({
variable_test1: 'value1',
variable_test2: 'value2',
variable_test3: 'value3',
});
const result = filterStringList(
eventData,
(key) => key.startsWith('variable_'),
([key, value]) => (key === 'variable_test2' ? undefined : [key.replace('variable_', ''), value]),
);
expect(result).toEqual({
test1: 'value1',
test3: 'value3',
});
});
it('should handle array values', () => {
const eventData = createEventData({
variable_array1: ['value1', 'value2'],
variable_array2: ['value3', 'value4'],
});
const result = filterStringList(
eventData,
(key) => key.startsWith('variable_'),
([key, value]) => [key.replace('variable_', ''), value],
);
expect(result).toEqual({
array1: ['value1', 'value2'],
array2: ['value3', 'value4'],
});
});
it('should handle undefined values', () => {
const eventData = createEventData({
variable_test1: undefined,
variable_test2: 'value2',
});
const result = filterStringList(
eventData,
(key) => key.startsWith('variable_'),
([key, value]) => [key.replace('variable_', ''), value],
);
expect(result).toEqual({
test1: undefined,
test2: 'value2',
});
});
});

@ -0,0 +1,281 @@
import { insertDataIntoEventProfile } from '../../src/eventParser/insertDataIntoEventProfile';
describe('insertDataIntoEventProfile', () => {
const baseEventData = {
callUniqueId: 'test-call-123',
channelName: 'test-channel',
channelState: 'CS_EXECUTE',
channelCallState: 'CS_EXECUTE',
raw: {},
};
it('should handle simple object', () => {
const eventData: any = {
simple: '15',
};
const result = insertDataIntoEventProfile(eventData, {});
expect(result).toEqual({
simple: '15',
});
});
it('should handle simple object with legs', () => {
const eventData: any = {
simple: '15',
legs: {
one: {
simple: '20',
},
},
};
const result = insertDataIntoEventProfile(eventData, {});
expect(result).toEqual({
simple: '15',
legs: {
one: {
simple: '20',
},
},
});
});
it('should handle simple object with legs and profiles', () => {
const channelUniqueId = 'test-channel-123';
const eventData: any = {
simple: '15',
legs: {
one: {
profiles: {
simple: '30',
},
simple: '20',
},
[channelUniqueId]: {
profiles: '40',
},
},
};
const result = insertDataIntoEventProfile(eventData, {});
expect(result).toEqual({
simple: '15',
legs: {
one: {
profiles: {
simple: '30',
},
simple: '20',
},
[channelUniqueId]: {
profiles: '40',
},
},
});
});
it('should not break when handling invalid data', () => {
const channelUniqueId = 'test-channel-123';
const date = new Date();
const eventData: any = {
legs: {
[channelUniqueId]: {
profiles: date,
},
},
};
const result = insertDataIntoEventProfile(eventData, {});
expect(result).toEqual({
legs: {
[channelUniqueId]: {
profiles: date,
},
},
});
});
it('should not break when handling invalid data [2]', () => {
const channelUniqueId = 'test-channel-123';
const eventData: any = {
legs: {
[channelUniqueId]: {
profiles: {
first: false,
last: 0,
},
},
},
};
const result = insertDataIntoEventProfile(eventData, { callee: '20' });
expect(result).toEqual({
legs: {
[channelUniqueId]: {
profiles: {
first: false,
last: 0,
},
},
},
});
});
it('should convert event data into paths with basic structure', () => {
const channelUniqueId = 'test-channel-123';
const eventData: any = {
...baseEventData,
legs: {
[channelUniqueId]: {
raw: {},
legName: `leg-${channelUniqueId}`,
uniqueId: channelUniqueId,
profiles: {
'profile-1': {
bridgedTo: 'original-bridge',
callee: 'original-callee',
},
},
},
},
};
const dataToInsertIntoProfile = {
bridgedTo: 'new-bridge',
callee: 'new-callee',
};
const result = insertDataIntoEventProfile(eventData, dataToInsertIntoProfile);
expect(result).toEqual({
...baseEventData,
legs: {
[channelUniqueId]: {
raw: {},
legName: `leg-${channelUniqueId}`,
uniqueId: channelUniqueId,
profiles: {
'profile-1': {
bridgedTo: 'new-bridge',
callee: 'new-callee',
},
},
},
},
});
});
it('should handle event data without legs', () => {
const eventData: any = {
...baseEventData,
};
const dataToInsertIntoProfile = {
bridgedTo: 'new-bridge',
};
const result = insertDataIntoEventProfile(eventData, dataToInsertIntoProfile);
expect(result).toEqual({
...baseEventData,
});
});
it('should handle event data with legs but no profiles', () => {
const channelUniqueId = 'test-channel-123';
const eventData: any = {
...baseEventData,
legs: {
[channelUniqueId]: {
raw: {},
legName: `leg-${channelUniqueId}`,
uniqueId: channelUniqueId,
},
},
};
const dataToInsertIntoProfile = {
bridgedTo: 'new-bridge',
};
const result = insertDataIntoEventProfile(eventData, dataToInsertIntoProfile);
expect(result).toEqual({
...baseEventData,
legs: {
[channelUniqueId]: {
raw: {},
legName: `leg-${channelUniqueId}`,
uniqueId: channelUniqueId,
},
},
});
});
it('should handle event data with multiple legs', () => {
const channelUniqueId = 'test-channel-123';
const otherChannelId = 'other-channel-456';
const eventData: any = {
...baseEventData,
legs: {
[channelUniqueId]: {
raw: {},
legName: `leg-${channelUniqueId}`,
uniqueId: channelUniqueId,
profiles: {
'profile-1': {
profileIndex: 'profile-1',
},
},
},
[otherChannelId]: {
raw: {},
legName: `leg-${otherChannelId}`,
uniqueId: otherChannelId,
profiles: {
'profile-2': {
profileIndex: 'profile-2',
},
},
},
},
};
const dataToInsertIntoProfile = {
bridgedTo: 'new-bridge',
};
const result = insertDataIntoEventProfile(eventData, dataToInsertIntoProfile);
expect(result).toEqual({
...baseEventData,
legs: {
[channelUniqueId]: {
raw: {},
legName: `leg-${channelUniqueId}`,
uniqueId: channelUniqueId,
profiles: {
'profile-1': {
profileIndex: 'profile-1',
bridgedTo: 'new-bridge',
},
},
},
[otherChannelId]: {
raw: {},
legName: `leg-${otherChannelId}`,
uniqueId: otherChannelId,
profiles: {
'profile-2': {
profileIndex: 'profile-2',
bridgedTo: 'new-bridge',
},
},
},
},
});
});
});

@ -0,0 +1,51 @@
import { describe, expect, it } from '@jest/globals';
import { parseChannelKind } from '../../src/eventParser/parseChannelKind';
describe('parseChannelKind', () => {
it('should parse kind from originator channel', () => {
const channelName = 'sofia/internal/1001@voip.open.rocket.chat:9999';
const result = parseChannelKind(channelName);
expect(result).toBe('internal');
});
it('should parse kind from external channel', () => {
const channelName = 'sofia/external/1001@voip.open.rocket.chat:9999';
const result = parseChannelKind(channelName);
expect(result).toBe('external');
});
it('should parse kind from voicemail channel', () => {
const channelName = 'loopback/voicemail-a';
const result = parseChannelKind(channelName);
expect(result).toBe('voicemail');
});
it('should parse kind from originatee channel', () => {
const channelName = 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-spo254ol@open.rocket.chat';
const result = parseChannelKind(channelName);
expect(result).toBe('internal');
});
it('should return unknown for non-sofia channel', () => {
const channelName = 'voicemail/default/1001';
const result = parseChannelKind(channelName);
expect(result).toBe('unknown');
});
it('should return unknown for invalid channel format', () => {
const channelName = 'sofia/invalid';
const result = parseChannelKind(channelName);
expect(result).toBe('unknown');
});
it('should return unknown for undefined input', () => {
const result = parseChannelKind(undefined);
expect(result).toBe('unknown');
});
it('should return unknown for empty input', () => {
const result = parseChannelKind('');
expect(result).toBe('unknown');
});
});

@ -0,0 +1,98 @@
import { describe, expect, it } from '@jest/globals';
import { parseChannelUsername, parseContactUsername, parseEventUsername } from '../../src/eventParser/parseChannelUsername';
import type { EventData } from '../../src/eventParser/parseEventData';
function createEventData(data: Record<string, string | string[] | undefined>): EventData {
return data as unknown as EventData;
}
describe('parseChannelUsername', () => {
it('should parse username from originator channel', () => {
const channelName = 'sofia/internal/1001@voip.open.rocket.chat:9999';
const result = parseChannelUsername(channelName);
expect(result).toBe('1001');
});
it('should parse username from external channel', () => {
const channelName = 'sofia/external/1001@voip.open.rocket.chat:9999';
const result = parseChannelUsername(channelName);
expect(result).toBe('1001');
});
it('should parse username from originatee channel', () => {
const channelName = 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-spo254ol@open.rocket.chat';
const result = parseChannelUsername(channelName);
expect(result).toBe('1000');
});
it('should return undefined for non-sofia channel', () => {
const channelName = 'voicemail/default/1001';
const result = parseChannelUsername(channelName);
expect(result).toBeUndefined();
});
it('should return undefined for invalid channel format', () => {
const channelName = 'sofia/internal/invalid';
const result = parseChannelUsername(channelName);
expect(result).toBeUndefined();
});
it('should return undefined if username is not number only', () => {
const channelName = 'sofia/internal/10AB@voip-open.rocket.chat:9999';
const result = parseChannelUsername(channelName);
expect(result).toBeUndefined();
});
it('should return undefined for undefined input', () => {
const result = parseChannelUsername(undefined);
expect(result).toBeUndefined();
});
});
describe('parseContactUsername', () => {
it('should parse username from contact URI', () => {
const contactUri = '1001-abc123-xyz789@rocket.chat';
const result = parseContactUsername(contactUri);
expect(result).toBe('1001');
});
it('should return undefined for invalid contact URI', () => {
const contactUri = 'invalid-contact-uri';
const result = parseContactUsername(contactUri);
expect(result).toBeUndefined();
});
it('should return undefined for non-numeric username', () => {
const contactUri = 'user123-abc123-xyz789@rocket.chat';
const result = parseContactUsername(contactUri);
expect(result).toBeUndefined();
});
});
describe('parseEventUsername', () => {
it('should parse username from event data', () => {
const eventData = createEventData({
'Channel-Name': 'sofia/internal/1001@voip.open.rocket.chat:9999',
});
const result = parseEventUsername(eventData);
expect(result).toBe('1001');
});
it('should return undefined when channel name is missing', () => {
const eventData = createEventData({});
const result = parseEventUsername(eventData);
expect(result).toBeUndefined();
});
it('should return undefined for non-sofia channel', () => {
const eventData = createEventData({
'Channel-Name': 'voicemail/default/1001',
});
const result = parseEventUsername(eventData);
expect(result).toBeUndefined();
});
});

@ -0,0 +1,103 @@
import { describe, expect, it } from '@jest/globals';
import { parseEventCallId } from '../../src/eventParser/parseEventCallId';
import type { EventData } from '../../src/eventParser/parseEventData';
describe('parseEventCallId', () => {
const createTestEvent = (overrides: Partial<EventData>): EventData => ({
...overrides,
});
it('should use Channel-Call-UUID when IDs match and type is not originator', () => {
const event = createTestEvent({
'Channel-Call-UUID': 'call-123',
'Unique-ID': 'call-123',
'Other-Type': 'not-originator',
});
const result = parseEventCallId(event);
expect(result).toBe('call-123');
});
it('should use Channel-Call-UUID when IDs differ', () => {
const event = createTestEvent({
'Channel-Call-UUID': 'call-123',
'Unique-ID': 'channel-456',
'Other-Type': 'originator',
'Other-Leg-Unique-ID': 'other-789',
});
const result = parseEventCallId(event);
expect(result).toBe('call-123');
});
it('should use Other-Leg-Unique-ID for outbound callers with originator', () => {
const event = createTestEvent({
'Channel-Call-UUID': 'call-123',
'Unique-ID': 'call-123',
'Other-Type': 'originator',
'Other-Leg-Unique-ID': 'other-789',
'Caller-Direction': 'outbound',
'Other-Leg-Direction': 'inbound',
});
const result = parseEventCallId(event);
expect(result).toBe('other-789');
});
it('should use Channel-Call-UUID when Other-Leg-Unique-ID is missing', () => {
const event = createTestEvent({
'Channel-Call-UUID': 'call-123',
'Unique-ID': 'call-123',
'Other-Type': 'originator',
'Caller-Direction': 'outbound',
'Other-Leg-Direction': 'inbound',
});
const result = parseEventCallId(event);
expect(result).toBe('call-123');
});
it('should use Channel-Call-UUID for inbound callers', () => {
const event = createTestEvent({
'Channel-Call-UUID': 'call-123',
'Unique-ID': 'call-123',
'Other-Type': 'originator',
'Other-Leg-Unique-ID': 'other-789',
'Caller-Direction': 'inbound',
'Other-Leg-Direction': 'outbound',
});
const result = parseEventCallId(event);
expect(result).toBe('call-123');
});
it('should use Channel-Call-UUID when Other-Leg-Direction is outbound with logical direction', () => {
const event = createTestEvent({
'Channel-Call-UUID': 'call-123',
'Unique-ID': 'call-123',
'Other-Type': 'originator',
'Other-Leg-Unique-ID': 'other-789',
'Caller-Direction': 'outbound',
'Other-Leg-Direction': 'outbound',
'Other-Leg-Logical-Direction': 'inbound',
});
const result = parseEventCallId(event);
expect(result).toBe('call-123');
});
it('should return undefined when no call ID is present', () => {
const event = createTestEvent({});
const result = parseEventCallId(event);
expect(result).toBeUndefined();
});
});

File diff suppressed because it is too large Load Diff

@ -0,0 +1,286 @@
import { describe, expect, it } from '@jest/globals';
import type { IFreeSwitchChannelEvent } from '@rocket.chat/core-typings';
import { parseEventExtensions } from '../../src/eventParser/parseEventExtensions';
describe('parseEventExtensions', () => {
const createTestEvent = (
overrides: Omit<IFreeSwitchChannelEvent, '_id' | '_updatedAt'>,
): Omit<IFreeSwitchChannelEvent, '_id' | '_updatedAt'> => ({
...overrides,
});
it('should parse call extensions', () => {
const event = createTestEvent({
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date(),
receivedAt: new Date(),
callUniqueId: 'call-123',
channelName: 'sofia/internal/1001@192.168.1.100',
channelState: 'CS_INIT',
channelCallState: 'DOWN',
channelStateNumber: '1',
channelCallStateNumber: '1',
callDirection: 'outbound',
channelHitDialplan: 'true',
answerState: 'early',
hangupCause: 'NORMAL_CLEARING',
bridgeUniqueIds: ['bridge-1', 'bridge-2'],
bridgedTo: 'bridge-1',
legs: {
'channel-123': {
legName: 'Caller-Leg',
uniqueId: 'channel-123',
direction: 'outbound',
logicalDirection: 'outbound',
username: '1001',
callerName: 'John Doe',
callerNumber: '1001',
networkAddress: '192.168.1.100',
destinationNumber: '1002',
source: 'mod_sofia',
context: 'default',
channelName: 'sofia/internal/1001@192.168.1.100',
raw: {},
},
},
metadata: {},
raw: {},
});
const result = parseEventExtensions(event);
expect(result).toBeDefined();
expect(result?.caller).toBe('1001');
expect(result?.callee).toBe('1002');
});
it('should handle contact names', () => {
const event = createTestEvent({
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date(),
receivedAt: new Date(),
callUniqueId: 'call-123',
channelName: 'sofia/internal/1001@192.168.1.100',
channelState: 'CS_INIT',
channelCallState: 'DOWN',
channelStateNumber: '1',
channelCallStateNumber: '1',
callDirection: 'outbound',
channelHitDialplan: 'true',
answerState: 'early',
hangupCause: 'NORMAL_CLEARING',
bridgeUniqueIds: ['bridge-1', 'bridge-2'],
bridgedTo: 'bridge-1',
legs: {
'channel-123': {
legName: 'Caller-Leg',
uniqueId: 'channel-123',
direction: 'outbound',
logicalDirection: 'outbound',
username: '1001',
callerName: 'John Doe',
callerNumber: '1001',
networkAddress: '192.168.1.100',
destinationNumber: '1002-userid-random@something',
source: 'mod_sofia',
context: 'default',
channelName: 'sofia/internal/1001@192.168.1.100',
raw: {},
},
},
metadata: {},
raw: {},
});
const result = parseEventExtensions(event);
expect(result).toBeDefined();
expect(result?.caller).toBe('1001');
expect(result?.callee).toBe('1002');
});
it('should handle missing variables', () => {
const event = createTestEvent({
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date(),
receivedAt: new Date(),
callUniqueId: 'call-123',
channelName: 'sofia/internal/1001@192.168.1.100',
channelState: 'CS_INIT',
channelCallState: 'DOWN',
channelStateNumber: '1',
channelCallStateNumber: '1',
callDirection: 'outbound',
channelHitDialplan: 'true',
answerState: 'early',
hangupCause: 'NORMAL_CLEARING',
bridgeUniqueIds: ['bridge-1', 'bridge-2'],
bridgedTo: 'bridge-1',
legs: {
'channel-123': {
legName: 'Caller-Leg',
uniqueId: 'channel-123',
direction: 'outbound',
logicalDirection: 'outbound',
username: '1001',
callerName: 'John Doe',
callerNumber: '1001',
networkAddress: '192.168.1.100',
destinationNumber: '1002',
source: 'mod_sofia',
context: 'default',
channelName: 'sofia/internal/1001@192.168.1.100',
raw: {},
},
},
metadata: {},
raw: {},
variables: {},
});
const result = parseEventExtensions(event);
expect(result).toBeDefined();
expect(result?.caller).toBe('1001');
expect(result?.callee).toBe('1002');
});
it('should handle calls initiated by outbound channels', () => {
const event = createTestEvent({
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date(),
receivedAt: new Date(),
callUniqueId: 'call-123',
channelName: 'sofia/internal/1001@192.168.1.100',
channelState: 'CS_INIT',
channelCallState: 'DOWN',
channelStateNumber: '1',
channelCallStateNumber: '1',
callDirection: 'outbound',
channelHitDialplan: 'true',
answerState: 'early',
hangupCause: 'NORMAL_CLEARING',
bridgeUniqueIds: ['bridge-1', 'bridge-2'],
bridgedTo: 'bridge-1',
legs: {
'channel-123': {
legName: 'Caller-Leg',
uniqueId: 'channel-123',
direction: 'outbound',
logicalDirection: 'outbound',
username: '1000',
callerName: 'John Doe',
callerNumber: '1000',
networkAddress: '192.168.1.100',
destinationNumber: '1003',
source: 'mod_sofia',
context: 'default',
channelName: 'sofia/internal/1001@192.168.1.100',
raw: {},
},
'channel-456': {
legName: 'Other-Leg',
type: 'originatee',
uniqueId: 'channel-456',
direction: 'outbound',
logicalDirection: 'inbound',
username: '1000',
callerName: 'Jane Doe',
callerNumber: '1001',
originalCallerName: 'John Doe',
originalCallerNumber: '1000',
networkAddress: '192.168.1.100',
destinationNumber: '1003-userId-random',
source: 'mod_sofia',
context: 'default',
channelName: 'sofia/internal/1003-userId-random@host',
},
},
metadata: {},
raw: {},
variables: {},
});
const result = parseEventExtensions(event);
expect(result).toBeDefined();
expect(result?.caller).toBe('1001');
expect(result?.callee).toBe('1003');
});
it('should handle missing legs', () => {
const legs: any = undefined;
const event = createTestEvent({
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date(),
receivedAt: new Date(),
callUniqueId: 'call-123',
channelName: 'sofia/internal/1001@192.168.1.100',
channelState: 'CS_INIT',
channelCallState: 'DOWN',
channelStateNumber: '1',
channelCallStateNumber: '1',
callDirection: 'outbound',
channelHitDialplan: 'true',
answerState: 'early',
hangupCause: 'NORMAL_CLEARING',
bridgeUniqueIds: ['bridge-1', 'bridge-2'],
bridgedTo: 'bridge-1',
legs,
metadata: {},
raw: {},
});
const result = parseEventExtensions(event);
expect(result).toBeDefined();
expect(result?.caller).toBeUndefined();
expect(result?.callee).toBeUndefined();
});
it('should handle variables', () => {
const event = createTestEvent({
channelUniqueId: 'channel-123',
eventName: 'CHANNEL_CREATE',
sequence: 1,
firedAt: new Date(),
receivedAt: new Date(),
callUniqueId: 'call-123',
channelName: 'sofia/internal/1001@192.168.1.100',
channelState: 'CS_INIT',
channelCallState: 'DOWN',
channelStateNumber: '1',
channelCallStateNumber: '1',
callDirection: 'outbound',
channelHitDialplan: 'true',
answerState: 'early',
hangupCause: 'NORMAL_CLEARING',
bridgeUniqueIds: ['bridge-1', 'bridge-2'],
bridgedTo: 'bridge-1',
legs: {},
metadata: {},
raw: {},
variables: {
dialed_extension: ['1002'],
},
});
const result = parseEventExtensions(event);
expect(result).toBeDefined();
expect(result?.caller).toBeUndefined();
expect(result?.callee).toBe('1002');
});
});

@ -0,0 +1,155 @@
import { describe, expect, it } from '@jest/globals';
import type { EventData } from '../../src/eventParser/parseEventData';
import { parseEventLeg } from '../../src/eventParser/parseEventLeg';
describe('parseEventLeg', () => {
const createTestEvent = (overrides: EventData): EventData => ({
...overrides,
});
it('should parse caller leg data', () => {
const event = createTestEvent({
'Caller-Leg-Direction': 'outbound',
'Caller-Leg-Logical-Direction': 'outbound',
'Caller-Leg-Username': '1001',
'Caller-Leg-Caller-ID-Name': 'John Doe',
'Caller-Leg-Caller-ID-Number': '1001',
'Caller-Leg-Network-Addr': '192.168.1.100',
'Caller-Leg-Destination-Number': '1002',
'Caller-Leg-Unique-ID': 'channel-123',
'Caller-Leg-Source': 'mod_sofia',
'Caller-Leg-Context': 'default',
'Caller-Leg-Channel-Name': 'sofia/internal/1001@192.168.1.100',
'Caller-Leg-Profile-Index': '1',
'Caller-Leg-Profile-Created-Time': '1709123456789000',
'Caller-Leg-Channel-Created-Time': '1709123457123000',
'Caller-Leg-Channel-Answered-Time': '1709123458456000',
'Caller-Leg-Channel-Hangup-Time': '1709123459789000',
});
const result = parseEventLeg('Caller-Leg', event);
expect(result).toBeDefined();
expect(result?.legName).toBe('Caller-Leg');
expect(result?.uniqueId).toBe('channel-123');
expect(result?.direction).toBe('outbound');
expect(result?.logicalDirection).toBe('outbound');
expect(result?.username).toBe('1001');
expect(result?.callerName).toBe('John Doe');
expect(result?.callerNumber).toBe('1001');
expect(result?.networkAddress).toBe('192.168.1.100');
expect(result?.destinationNumber).toBe('1002');
expect(result?.source).toBe('mod_sofia');
expect(result?.context).toBe('default');
expect(result?.channelName).toBe('sofia/internal/1001@192.168.1.100');
expect(result?.profiles).toBeDefined();
expect(result?.profiles?.['1']).toBeDefined();
expect(result?.profiles?.['1'].profileIndex).toBe('1');
expect(result?.profiles?.['1'].profileCreatedTime?.getTime()).toBe(1709123456789);
expect(result?.profiles?.['1'].channelCreatedTime?.getTime()).toBe(1709123457123);
expect(result?.profiles?.['1'].channelAnsweredTime?.getTime()).toBe(1709123458456);
expect(result?.profiles?.['1'].channelHangupTime?.getTime()).toBe(1709123459789);
expect(result?.raw).toBeUndefined();
});
it('should parse other leg data with type', () => {
const event = createTestEvent({
'Other-Leg-Direction': 'inbound',
'Other-Leg-Logical-Direction': 'inbound',
'Other-Leg-Username': '1002',
'Other-Leg-Caller-ID-Name': 'Jane Smith',
'Other-Leg-Caller-ID-Number': '1002',
'Other-Leg-Network-Addr': '192.168.1.101',
'Other-Leg-Destination-Number': '1001',
'Other-Leg-Unique-ID': 'channel-456',
'Other-Leg-Source': 'mod_sofia',
'Other-Leg-Context': 'default',
'Other-Leg-Channel-Name': 'sofia/internal/1002@192.168.1.101',
'Other-Type': 'originator',
});
const result = parseEventLeg('Other-Leg', event);
expect(result).toBeDefined();
expect(result?.legName).toBe('Other-Leg');
expect(result?.uniqueId).toBe('channel-456');
expect(result?.direction).toBe('inbound');
expect(result?.logicalDirection).toBe('inbound');
expect(result?.username).toBe('1002');
expect(result?.callerName).toBe('Jane Smith');
expect(result?.callerNumber).toBe('1002');
expect(result?.networkAddress).toBe('192.168.1.101');
expect(result?.destinationNumber).toBe('1001');
expect(result?.source).toBe('mod_sofia');
expect(result?.context).toBe('default');
expect(result?.channelName).toBe('sofia/internal/1002@192.168.1.101');
expect(result?.type).toBe('originator');
expect(result?.raw).toBeUndefined();
});
it('should return undefined when unique ID is missing', () => {
const event = createTestEvent({
'Caller-Leg-Direction': 'outbound',
'Caller-Leg-Logical-Direction': 'outbound',
'Caller-Leg-Username': '1001',
'Caller-Leg-Caller-ID-Name': 'John Doe',
'Caller-Leg-Caller-ID-Number': '1001',
'Caller-Leg-Network-Addr': '192.168.1.100',
'Caller-Leg-Destination-Number': '1002',
'Caller-Leg-Source': 'mod_sofia',
'Caller-Leg-Context': 'default',
'Caller-Leg-Channel-Name': 'sofia/internal/1001@192.168.1.100',
});
const result = parseEventLeg('Caller-Leg', event);
expect(result).toBeUndefined();
});
it('should handle missing optional fields', () => {
const event = createTestEvent({
'Caller-Leg-Direction': 'outbound',
'Caller-Leg-Logical-Direction': 'outbound',
'Caller-Leg-Username': '1001',
'Caller-Leg-Unique-ID': 'channel-123',
});
const result = parseEventLeg('Caller-Leg', event);
expect(result).toBeDefined();
expect(result?.legName).toBe('Caller-Leg');
expect(result?.uniqueId).toBe('channel-123');
expect(result?.direction).toBe('outbound');
expect(result?.logicalDirection).toBe('outbound');
expect(result?.username).toBe('1001');
expect(result?.callerName).toBeUndefined();
expect(result?.callerNumber).toBeUndefined();
expect(result?.networkAddress).toBeUndefined();
expect(result?.destinationNumber).toBeUndefined();
expect(result?.source).toBeUndefined();
expect(result?.context).toBeUndefined();
expect(result?.channelName).toBeUndefined();
expect(result?.profiles).toBeUndefined();
expect(result?.raw).toBeUndefined();
});
it('should filter out empty values from raw data', () => {
const event = createTestEvent({
'Caller-Direction': 'outbound',
'Caller-Logical-Direction': 'outbound',
'Caller-Username': '1001',
'Caller-Unique-ID': 'channel-123',
'Caller-Empty-Value': '',
'Caller-Undefined-Value': undefined,
'Caller-Unused-Variable': 'unused-value',
});
const result = parseEventLeg('Caller', event);
expect(result).toBeDefined();
expect(result?.raw).toBeDefined();
expect(result?.raw['Empty-Value']).toBeUndefined();
expect(result?.raw['Undefined-Value']).toBeUndefined();
});
});

@ -0,0 +1,37 @@
import { describe, expect, it } from '@jest/globals';
import { parseTimestamp } from '../../src/eventParser/parseTimestamp';
describe('parseTimestamp', () => {
it('should parse a valid timestamp', () => {
const timestamp = '1709123456789123';
const result = parseTimestamp(timestamp);
expect(result).toBeInstanceOf(Date);
expect(result?.getTime()).toBe(1709123456789);
});
it('should return undefined for undefined input', () => {
const result = parseTimestamp(undefined);
expect(result).toBeUndefined();
});
it('should return undefined for zero timestamp', () => {
const result = parseTimestamp('0');
expect(result).toBeUndefined();
});
it('should return undefined for invalid timestamp', () => {
const result = parseTimestamp('invalid');
expect(result).toBeUndefined();
});
it('should return undefined for negative timestamp', () => {
const result = parseTimestamp('-1709123456789');
expect(result).toBeUndefined();
});
});

@ -14,8 +14,9 @@ export * from './models/IEmojiCustomModel';
export * from './models/IExportOperationsModel';
export * from './models/IFederationKeysModel';
export * from './models/IFederationServersModel';
export * from './models/IFreeSwitchCallModel';
export * from './models/IFreeSwitchEventModel';
export * from './models/IFreeSwitchChannelModel';
export * from './models/IFreeSwitchChannelEventModel';
export * from './models/IFreeSwitchChannelEventDeltaModel';
export * from './models/IInstanceStatusModel';
export * from './models/IIntegrationHistoryModel';
export * from './models/IIntegrationsModel';

@ -1,12 +0,0 @@
import type { IFreeSwitchCall } from '@rocket.chat/core-typings';
import type { AggregateOptions, CountDocumentsOptions, FindCursor, FindOptions, WithoutId } from 'mongodb';
import type { IBaseModel, InsertionModel } from './IBaseModel';
export interface IFreeSwitchCallModel extends IBaseModel<IFreeSwitchCall> {
registerCall(call: WithoutId<InsertionModel<IFreeSwitchCall>>): Promise<void>;
findAllByChannelUniqueIds<T extends IFreeSwitchCall>(uniqueIds: string[], options?: FindOptions<IFreeSwitchCall>): FindCursor<T>;
countCallsByDirection(direction: IFreeSwitchCall['direction'], minDate?: Date, options?: CountDocumentsOptions): Promise<number>;
sumCallsDuration(minDate?: Date, options?: AggregateOptions): Promise<number>;
countCallsBySuccessState(success: boolean, minDate?: Date, options?: CountDocumentsOptions): Promise<number>;
}

@ -0,0 +1,8 @@
import type { IFreeSwitchChannelEventDelta } from '@rocket.chat/core-typings';
import type { WithoutId, InsertOneResult } from 'mongodb';
import type { IBaseModel, InsertionModel } from './IBaseModel';
export interface IFreeSwitchChannelEventDeltaModel extends IBaseModel<IFreeSwitchChannelEventDelta> {
registerDelta(channel: InsertionModel<WithoutId<IFreeSwitchChannelEventDelta>>): Promise<InsertOneResult<IFreeSwitchChannelEventDelta>>;
}

@ -0,0 +1,12 @@
import type { IFreeSwitchChannelEvent } from '@rocket.chat/core-typings';
import type { FindCursor, FindOptions, WithoutId, InsertOneResult } from 'mongodb';
import type { IBaseModel, InsertionModel } from './IBaseModel';
export interface IFreeSwitchChannelEventModel extends IBaseModel<IFreeSwitchChannelEvent> {
registerEvent(event: WithoutId<InsertionModel<IFreeSwitchChannelEvent>>): Promise<InsertOneResult<IFreeSwitchChannelEvent>>;
findAllByChannelUniqueId<T extends IFreeSwitchChannelEvent>(
uniqueId: string,
options?: FindOptions<IFreeSwitchChannelEvent>,
): FindCursor<T>;
}

@ -0,0 +1,24 @@
import type { IFreeSwitchChannel } from '@rocket.chat/core-typings';
import type { AggregateOptions, CountDocumentsOptions, FindCursor, FindOptions, WithoutId, InsertOneResult } from 'mongodb';
import type { IBaseModel, InsertionModel } from './IBaseModel';
export interface IFreeSwitchChannelModel extends IBaseModel<IFreeSwitchChannel> {
registerChannel(channel: WithoutId<InsertionModel<IFreeSwitchChannel>>): Promise<InsertOneResult<IFreeSwitchChannel>>;
findAllByUniqueIds<T extends IFreeSwitchChannel>(uniqueIds: string[], options?: FindOptions<IFreeSwitchChannel>): FindCursor<T>;
countChannelsByKind(kind: Required<IFreeSwitchChannel>['kind'], minDate?: Date, options?: CountDocumentsOptions): Promise<number>;
countChannelsByKindAndDirection(
kind: Required<IFreeSwitchChannel>['kind'],
callDirection: Required<IFreeSwitchChannel>['callDirection'],
minDate?: Date,
options?: CountDocumentsOptions,
): Promise<number>;
sumChannelsDurationByKind(kind: Required<IFreeSwitchChannel>['kind'], minDate?: Date, options?: AggregateOptions): Promise<number>;
countChannelsByKindAndSuccessState(
kind: Required<IFreeSwitchChannel>['kind'],
success: boolean,
minDate?: Date,
options?: CountDocumentsOptions,
): Promise<number>;
}

@ -1,10 +0,0 @@
import type { IFreeSwitchEvent } from '@rocket.chat/core-typings';
import type { FindCursor, FindOptions, WithoutId, InsertOneResult } from 'mongodb';
import type { IBaseModel, InsertionModel } from './IBaseModel';
export interface IFreeSwitchEventModel extends IBaseModel<IFreeSwitchEvent> {
registerEvent(event: WithoutId<InsertionModel<IFreeSwitchEvent>>): Promise<InsertOneResult<IFreeSwitchEvent>>;
findAllByCallUUID<T extends IFreeSwitchEvent>(callUUID: string, options?: FindOptions<IFreeSwitchEvent>): FindCursor<T>;
findAllByChannelUniqueIds<T extends IFreeSwitchEvent>(uniqueIds: string[], options?: FindOptions<IFreeSwitchEvent>): FindCursor<T>;
}

@ -20,8 +20,8 @@ import type {
IExportOperationsModel,
IFederationKeysModel,
IFederationServersModel,
IFreeSwitchCallModel,
IFreeSwitchEventModel,
IFreeSwitchChannelModel,
IFreeSwitchChannelEventModel,
IInstanceStatusModel,
IIntegrationHistoryModel,
IIntegrationsModel,
@ -89,6 +89,7 @@ import type {
IMigrationsModel,
IModerationReportsModel,
IWorkspaceCredentialsModel,
IFreeSwitchChannelEventDeltaModel,
} from '@rocket.chat/model-typings';
import type { Collection, Db } from 'mongodb';
@ -154,8 +155,9 @@ export const ExportOperations = proxify<IExportOperationsModel>('IExportOperatio
export const FederationServers = proxify<IFederationServersModel>('IFederationServersModel');
export const FederationKeys = proxify<IFederationKeysModel>('IFederationKeysModel');
export const FederationRoomEvents = proxify<IFederationRoomEventsModel>('IFederationRoomEventsModel');
export const FreeSwitchCall = proxify<IFreeSwitchCallModel>('IFreeSwitchCallModel');
export const FreeSwitchEvent = proxify<IFreeSwitchEventModel>('IFreeSwitchEventModel');
export const FreeSwitchChannel = proxify<IFreeSwitchChannelModel>('IFreeSwitchChannelModel');
export const FreeSwitchChannelEvent = proxify<IFreeSwitchChannelEventModel>('IFreeSwitchChannelEventModel');
export const FreeSwitchChannelEventDelta = proxify<IFreeSwitchChannelEventDeltaModel>('IFreeSwitchChannelEventDeltaModel');
export const ImportData = proxify<IImportDataModel>('IImportDataModel');
export const Imports = proxify<IImportsModel>('IImportsModel');
export const InstanceStatus = proxify<IInstanceStatusModel>('IInstanceStatusModel');

@ -14,8 +14,9 @@ export * from './models/EmailInbox';
export * from './models/EmailMessageHistory';
export * from './models/EmojiCustom';
export * from './models/ExportOperations';
export * from './models/FreeSwitchCall';
export * from './models/FreeSwitchEvent';
export * from './models/FreeSwitchChannel';
export * from './models/FreeSwitchChannelEvent';
export * from './models/FreeSwitchChannelEventDelta';
export * from './models/FederationKeys';
export * from './models/FederationRoomEvents';
export * from './models/FederationServers';

@ -1,76 +0,0 @@
import type { IFreeSwitchCall, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IFreeSwitchCallModel, InsertionModel } from '@rocket.chat/model-typings';
import type {
AggregateOptions,
Collection,
CountDocumentsOptions,
Db,
FindCursor,
FindOptions,
IndexDescription,
WithoutId,
} from 'mongodb';
import { BaseRaw } from './BaseRaw';
import { readSecondaryPreferred } from '../readSecondaryPreferred';
export class FreeSwitchCallRaw extends BaseRaw<IFreeSwitchCall> implements IFreeSwitchCallModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IFreeSwitchCall>>) {
super(db, 'freeswitch_calls', trash);
}
protected modelIndexes(): IndexDescription[] {
return [{ key: { UUID: 1 } }, { key: { channels: 1 } }, { key: { direction: 1, startedAt: 1 } }];
}
public async registerCall(call: WithoutId<InsertionModel<IFreeSwitchCall>>): Promise<void> {
await this.findOneAndUpdate({ UUID: call.UUID }, { $set: call }, { upsert: true });
}
public findAllByChannelUniqueIds<T extends IFreeSwitchCall>(uniqueIds: string[], options?: FindOptions<IFreeSwitchCall>): FindCursor<T> {
return this.find<T>(
{
channels: { $in: uniqueIds },
},
options,
);
}
public countCallsByDirection(direction: IFreeSwitchCall['direction'], minDate?: Date, options?: CountDocumentsOptions): Promise<number> {
return this.countDocuments(
{
direction,
...(minDate && { startedAt: { $gte: minDate } }),
},
{ readPreference: readSecondaryPreferred(), ...options },
);
}
public async sumCallsDuration(minDate?: Date, options?: AggregateOptions): Promise<number> {
return this.col
.aggregate(
[
...(minDate ? [{ $match: { startedAt: { $gte: minDate } } }] : []),
{
$group: {
_id: '1',
calls: { $sum: '$duration' },
},
},
],
{ readPreference: readSecondaryPreferred(), ...options },
)
.toArray()
.then(([{ calls }]) => calls);
}
public countCallsBySuccessState(success: boolean, minDate?: Date, options?: CountDocumentsOptions): Promise<number> {
return this.countDocuments(
{
...(success ? { duration: { $gte: 5 } } : { $or: [{ duration: { $exists: false } }, { duration: { $lt: 5 } }] }),
...(minDate && { startedAt: { $gte: minDate } }),
},
{ readPreference: readSecondaryPreferred(), ...options },
);
}
}

@ -0,0 +1,113 @@
import type { IFreeSwitchChannel, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IFreeSwitchChannelModel, InsertionModel } from '@rocket.chat/model-typings';
import type {
AggregateOptions,
Collection,
CountDocumentsOptions,
Db,
FindCursor,
FindOptions,
IndexDescription,
WithoutId,
InsertOneResult,
} from 'mongodb';
import { BaseRaw } from './BaseRaw';
import { readSecondaryPreferred } from '../readSecondaryPreferred';
export class FreeSwitchChannelRaw extends BaseRaw<IFreeSwitchChannel> implements IFreeSwitchChannelModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IFreeSwitchChannel>>) {
super(db, 'freeswitch_channels', trash);
}
protected modelIndexes(): IndexDescription[] {
return [
{ key: { uniqueId: 1 }, unique: true },
{ key: { kind: 1, startedAt: -1 } },
{ key: { kind: 1, callDirection: 1, startedAt: -1 } },
{ key: { kind: 1, anyBridge: 1, startedAt: -1 } },
];
}
public async registerChannel(channel: WithoutId<InsertionModel<IFreeSwitchChannel>>): Promise<InsertOneResult<IFreeSwitchChannel>> {
return this.insertOne(channel);
}
public findAllByUniqueIds<T extends IFreeSwitchChannel>(uniqueIds: string[], options?: FindOptions<IFreeSwitchChannel>): FindCursor<T> {
return this.find<T>(
{
uniqueId: { $in: uniqueIds },
},
options,
);
}
public countChannelsByKind(kind: Required<IFreeSwitchChannel>['kind'], minDate?: Date, options?: CountDocumentsOptions): Promise<number> {
return this.countDocuments(
{
kind,
...(minDate && { startedAt: { $gte: minDate } }),
},
{ readPreference: readSecondaryPreferred(), ...options },
);
}
public countChannelsByKindAndDirection(
kind: Required<IFreeSwitchChannel>['kind'],
callDirection: Required<IFreeSwitchChannel>['callDirection'],
minDate?: Date,
options?: CountDocumentsOptions,
): Promise<number> {
return this.countDocuments(
{
kind,
callDirection,
...(minDate && { startedAt: { $gte: minDate } }),
},
{ readPreference: readSecondaryPreferred(), ...options },
);
}
public async sumChannelsDurationByKind(
kind: Required<IFreeSwitchChannel>['kind'],
minDate?: Date,
options?: AggregateOptions,
): Promise<number> {
return this.col
.aggregate(
[
{
$match: {
kind,
...(minDate && { startedAt: { $gte: minDate } }),
},
},
{
$group: {
_id: '1',
calls: { $sum: '$totalDuration' },
},
},
],
{ readPreference: readSecondaryPreferred(), ...options },
)
.toArray()
.then(([{ calls }]) => calls);
}
public countChannelsByKindAndSuccessState(
kind: Required<IFreeSwitchChannel>['kind'],
success: boolean,
minDate?: Date,
options?: CountDocumentsOptions,
): Promise<number> {
return this.countDocuments(
{
kind,
anyBridge: success,
...(minDate && { startedAt: { $gte: minDate } }),
},
{ readPreference: readSecondaryPreferred(), ...options },
);
}
}

@ -0,0 +1,42 @@
import type { IFreeSwitchChannelEvent, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IFreeSwitchChannelEventModel, InsertionModel } from '@rocket.chat/model-typings';
import type { IndexDescription, Collection, Db, FindOptions, FindCursor, WithoutId, InsertOneResult } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class FreeSwitchChannelEventRaw extends BaseRaw<IFreeSwitchChannelEvent> implements IFreeSwitchChannelEventModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IFreeSwitchChannelEvent>>) {
super(db, 'freeswitch_channel_events', trash);
}
protected modelIndexes(): IndexDescription[] {
return [
{ key: { channelUniqueId: 1, sequence: 1 }, unique: true },
// Allow 3 days of events to be saved
{ key: { receivedAt: 1 }, expireAfterSeconds: 3 * 24 * 60 * 60 },
];
}
public async registerEvent(event: WithoutId<InsertionModel<IFreeSwitchChannelEvent>>): Promise<InsertOneResult<IFreeSwitchChannelEvent>> {
return this.insertOne(event);
}
public findAllByChannelUniqueId<T extends IFreeSwitchChannelEvent>(
channelUniqueId: string,
options?: FindOptions<IFreeSwitchChannelEvent>,
): FindCursor<T> {
const theOptions: FindOptions<IFreeSwitchChannelEvent> = {
sort: {
sequence: 1,
},
...options,
};
return this.find<T>(
{
channelUniqueId,
},
theOptions,
);
}
}

@ -0,0 +1,26 @@
import type { IFreeSwitchChannelEventDelta, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IFreeSwitchChannelEventDeltaModel, InsertionModel } from '@rocket.chat/model-typings';
import type { IndexDescription, Collection, Db, WithoutId, InsertOneResult } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class FreeSwitchChannelEventDeltaRaw extends BaseRaw<IFreeSwitchChannelEventDelta> implements IFreeSwitchChannelEventDeltaModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IFreeSwitchChannelEventDelta>>) {
super(db, 'freeswitch_channel_event_deltas', trash);
}
protected modelIndexes(): IndexDescription[] {
return [
{ key: { channelUniqueId: 1, sequence: 1 }, unique: true },
// Keep event deltas for 30 days
{ key: { _updatedAt: 1 }, expireAfterSeconds: 30 * 24 * 60 * 60 },
];
}
public async registerDelta(
delta: WithoutId<InsertionModel<IFreeSwitchChannelEventDelta>>,
): Promise<InsertOneResult<IFreeSwitchChannelEventDelta>> {
return this.insertOne(delta);
}
}

@ -1,40 +0,0 @@
import type { IFreeSwitchEvent, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IFreeSwitchEventModel, InsertionModel } from '@rocket.chat/model-typings';
import type { IndexDescription, Collection, Db, FindOptions, FindCursor, WithoutId, InsertOneResult } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class FreeSwitchEventRaw extends BaseRaw<IFreeSwitchEvent> implements IFreeSwitchEventModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IFreeSwitchEvent>>) {
super(db, 'freeswitch_events', trash);
}
protected modelIndexes(): IndexDescription[] {
return [
{ key: { channelUniqueId: 1, sequence: 1 }, unique: true },
{ key: { 'call.UUID': 1 } },
// Allow 15 days of events to be saved
{ key: { _updatedAt: 1 }, expireAfterSeconds: 30 * 24 * 60 * 15 },
];
}
public async registerEvent(event: WithoutId<InsertionModel<IFreeSwitchEvent>>): Promise<InsertOneResult<IFreeSwitchEvent>> {
return this.insertOne(event);
}
public findAllByCallUUID<T extends IFreeSwitchEvent>(callUUID: string, options?: FindOptions<IFreeSwitchEvent>): FindCursor<T> {
return this.find<T>({ 'call.UUID': callUUID }, options);
}
public findAllByChannelUniqueIds<T extends IFreeSwitchEvent>(
uniqueIds: string[],
options?: FindOptions<IFreeSwitchEvent>,
): FindCursor<T> {
return this.find<T>(
{
channelUniqueId: { $in: uniqueIds },
},
options,
);
}
}

@ -0,0 +1,36 @@
export function convertPathsIntoSubObjects(object: Record<string, any>): Record<string, any> {
const newObject: Record<string, any> = {};
for (const key of Object.keys(object)) {
const value = object[key];
if (!value && typeof value !== 'boolean') {
continue;
}
const keyProperties = key.split('.');
if (!keyProperties.length) {
continue;
}
let current = newObject;
const finalProperty = keyProperties.pop() as string;
for (const property of keyProperties) {
if (!(property in current) || typeof current[property] !== 'object') {
current[property] = {};
}
current = current[property];
}
if (current[finalProperty]) {
current[finalProperty] = {
...(typeof value === 'object' && value),
...current[finalProperty],
};
} else {
current[finalProperty] = value;
}
}
return newObject;
}

@ -114,4 +114,20 @@ describe('convertSubObjectsIntoPaths', () => {
expect(convertSubObjectsIntoPaths(input, parentPath)).to.deep.equal(expected);
});
it('should not try to convert class instances', () => {
const input = {
a: new Date(),
b: {
c: new Date(),
},
};
const converted = convertSubObjectsIntoPaths(input);
expect(converted).not.to.be.undefined;
expect(converted).to.have.keys(['a', 'b.c']);
expect(converted.a).to.be.a('Date');
expect(converted['b.c']).to.be.a('Date');
});
});

@ -1,15 +1,16 @@
import { isRecord } from './isRecord';
export function convertSubObjectsIntoPaths(object: Record<string, any>, parentPath?: string): Record<string, any> {
return Object.fromEntries(
Object.keys(object).flatMap((key) => {
const value = object[key];
const fullKey = parentPath ? `${parentPath}.${key}` : key;
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
if (isRecord(value)) {
const flattened = convertSubObjectsIntoPaths(value, fullKey);
return Object.keys(flattened).map((newKey) => [newKey, flattened[newKey]]);
}
return [[fullKey, value]];
}) as [string, any][],
);

@ -1,4 +1,5 @@
export * from './convertSubObjectsIntoPaths';
export * from './convertPathsIntoSubObjects';
export * from './getObjectKeys';
export * from './normalizeLanguage';
export * from './objectMap';
@ -10,3 +11,4 @@ export * from './getLoginExpiration';
export * from './converter';
export * from './removeEmpty';
export * from './isObject';
export * from './isRecord';

@ -0,0 +1,39 @@
import { isRecord } from './isRecord';
describe('isRecord', () => {
test.each([
['case-1', undefined, false],
['case-2', null, false],
['case-3', 0, false],
['case-4', false, false],
['case-5', '', false],
])('should return false for falsy values %# (%s)', (_caseId, input, expected) => {
expect(isRecord(input)).toEqual(expected);
});
test.each([
['case-1', undefined, false],
['case-2', null, false],
['case-3', 20, false],
['case-4', true, false],
['case-5', 'string', false],
])('should return false for non-objects %# (%s)', (_caseId, input, expected) => {
expect(isRecord(input)).toEqual(expected);
});
test.each([
['case-1', {}, true],
['case-2', { prop: 'value' }, true],
['case-2', Object.fromEntries([]), true],
])('should return true for records %# (%s)', (_caseId, input, expected) => {
expect(isRecord(input)).toEqual(expected);
});
test.each([
['case-1', new Date(), false],
['case-2', [], false],
['case-2', [{}], false],
])('should return false for objects that are not records %# (%s)', (_caseId, input, expected) => {
expect(isRecord(input)).toEqual(expected);
});
});

@ -0,0 +1,7 @@
export function isRecord(record: unknown): record is Record<string | number | symbol, unknown> {
if (!record || typeof record !== 'object') {
return false;
}
return Object.getPrototypeOf(record).constructor === Object;
}

@ -18,6 +18,7 @@ type VoipClientResult = {
export const useVoipClient = ({ enabled = true, autoRegister = true }: VoipClientParams = {}): VoipClientResult => {
const { _id: userId } = useUser() || {};
const voipClientRef = useRef<VoipClient | null>(null);
const siteUrl = useSetting('Site_Url') as string;
const getRegistrationInfo = useEndpoint('GET', '/v1/voip-freeswitch.extension.getRegistrationInfoByUserId');
const iceGatheringTimeout = useSetting('VoIP_TeamCollab_Ice_Gathering_Timeout', 5000);
@ -60,6 +61,8 @@ export const useVoipClient = ({ enabled = true, autoRegister = true }: VoipClien
webSocketURI: websocketPath,
connectionRetryCount: Number(10), // TODO: get from settings
enableKeepAliveUsingOptionsForUnstableNetworks: true, // TODO: get from settings
userId,
siteUrl,
iceGatheringTimeout,
};

@ -50,6 +50,8 @@ class VoipClient extends Emitter<VoipEvents> {
private reconnecting = false;
private contactName: string | null = null;
constructor(private readonly config: VoIPUserConfiguration) {
super();
@ -74,6 +76,8 @@ class VoipClient extends Emitter<VoipEvents> {
const debug = Boolean(searchParams.get('debug') || searchParams.get('debug-voip'));
this.userAgent = new UserAgent({
contactName: this.getContactName(),
viaHost: this.getContactHostName(),
authorizationPassword: authPassword,
authorizationUsername: authUserName,
uri: UserAgent.makeURI(`sip:${authUserName}@${sipRegistrarHostnameOrIP}`),
@ -264,7 +268,10 @@ class VoipClient extends Emitter<VoipEvents> {
case SessionState.Established:
return this.session.bye();
case SessionState.Terminating:
console.warn('Trying to end a call that is already Terminating.');
break;
case SessionState.Terminated:
console.warn('Trying to end a call that is already Terminated.');
break;
default:
throw new Error('Unknown state');
@ -911,6 +918,33 @@ class VoipClient extends Emitter<VoipEvents> {
this.networkEmitter.emit('localnetworkoffline');
this.emit('stateChanged');
};
private getContactHostName(): string | undefined {
try {
const url = new URL(this.config.siteUrl);
return url.hostname;
} catch {
return undefined;
}
}
private createRandomToken(size: number): string {
let token = '';
for (let i = 0; i < size; i++) {
const r = Math.floor(Math.random() * 32);
token += r.toString(32);
}
return token;
}
private getContactName(): string {
if (!this.contactName) {
const randomName = this.createRandomToken(8);
this.contactName = `${this.config.authUserName}-${this.config.userId}-${randomName}`;
}
return this.contactName;
}
}
export default VoipClient;

Loading…
Cancel
Save