diff --git a/.changeset/shy-colts-appear.md b/.changeset/shy-colts-appear.md new file mode 100644 index 00000000000..74a8c27cd58 --- /dev/null +++ b/.changeset/shy-colts-appear.md @@ -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 diff --git a/apps/meteor/app/statistics/server/lib/getVoIPStatistics.ts b/apps/meteor/app/statistics/server/lib/getVoIPStatistics.ts index 765f23992aa..99d574cdeed 100644 --- a/apps/meteor/app/statistics/server/lib/getVoIPStatistics.ts +++ b/apps/meteor/app/statistics/server/lib/getVoIPStatistics.ts @@ -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 { + 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; }), ); diff --git a/apps/meteor/client/hooks/useVoipClient.ts b/apps/meteor/client/hooks/useVoipClient.ts index 995880f73c1..948fc7ce018 100644 --- a/apps/meteor/client/hooks/useVoipClient.ts +++ b/apps/meteor/client/hooks/useVoipClient.ts @@ -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)); diff --git a/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts b/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts index 742ea97fdf5..82cae0a9210 100644 --- a/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts +++ b/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts @@ -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): Promise { - const uniqueId = data['Unique-ID']; - if (!uniqueId) { - return; - } - - // Using a set to avoid duplicates - const callIds = new Set( - [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 { + const event = parseEventData(eventName, data); + if (!event) { return; } await this.registerEvent(event); } - private getDetailedEventName(eventName: string, eventData: Record): 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>(data: T): DeepPartial { - 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; - } - - private async parseEventData( - eventName: string, - eventData: Record, - ): Promise>> { - const filteredData: Record = Object.fromEntries( - Object.entries(eventData).filter(([_, value]) => value !== undefined), - ) as Record; - - 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 = { - 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 = { - 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>; - } - - 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>): Promise { + private async registerRecord(registerFn: () => Promise): Promise { 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>): Promise { + 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>): Promise { + 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>): Promise { + return this.registerRecord(() => FreeSwitchChannelEventDelta.registerDelta(record)); } - private async computeCall(callUUID: string): Promise { - const allEvents = await FreeSwitchEvent.findAllByCallUUID(callUUID).toArray(); - const call: InsertionModel = { - UUID: callUUID, - channels: [], - events: [], - }; + private async computeChannel(channelUniqueId: string): Promise { + 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(); - const toUser = new Set(); - 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; - - 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): 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 { diff --git a/apps/meteor/server/models.ts b/apps/meteor/server/models.ts index 22895e8ecdc..abe9f5d5bb2 100644 --- a/apps/meteor/server/models.ts +++ b/apps/meteor/server/models.ts @@ -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)); diff --git a/packages/core-typings/src/voip/IFreeSwitchCall.ts b/packages/core-typings/src/voip/IFreeSwitchCall.ts deleted file mode 100644 index 6771e967bac..00000000000 --- a/packages/core-typings/src/voip/IFreeSwitchCall.ts +++ /dev/null @@ -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; - to?: Pick; - forwardedFrom?: Omit[]; - 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; -}; diff --git a/packages/core-typings/src/voip/IFreeSwitchChannel.ts b/packages/core-typings/src/voip/IFreeSwitchChannel.ts new file mode 100644 index 00000000000..88a165b8667 --- /dev/null +++ b/packages/core-typings/src/voip/IFreeSwitchChannel.ts @@ -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; +} diff --git a/packages/core-typings/src/voip/IFreeSwitchChannelEvent.ts b/packages/core-typings/src/voip/IFreeSwitchChannelEvent.ts new file mode 100644 index 00000000000..f894b4485d6 --- /dev/null +++ b/packages/core-typings/src/voip/IFreeSwitchChannelEvent.ts @@ -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>; + + // variables should contain the same data you would get by running `uuid_dump` on fs_cli + variables?: Record; + + // raw will include fields we received from freeswitch but didn't read + raw: Record; + + 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; +} + +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; + + // 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; +} diff --git a/packages/core-typings/src/voip/IFreeSwitchChannelEventDelta.ts b/packages/core-typings/src/voip/IFreeSwitchChannelEventDelta.ts new file mode 100644 index 00000000000..8ed95c34fb0 --- /dev/null +++ b/packages/core-typings/src/voip/IFreeSwitchChannelEventDelta.ts @@ -0,0 +1,24 @@ +import type { IRocketChatRecord } from '../IRocketChatRecord'; +import type { DeepPartial } from '../utils'; +import type { + IFreeSwitchChannelEventHeader, + IFreeSwitchChannelEventMutable, + IFreeSwitchChannelEventStates, +} from './IFreeSwitchChannelEvent'; + +type DeepModified = { + [P in keyof T]?: T[P] extends Date | undefined + ? { oldValue: T[P]; newValue: T[P]; delta: number } | undefined + : T[P] extends object | undefined + ? DeepModified + : { oldValue: T[P]; newValue: T[P] } | undefined; +}; + +export interface IFreeSwitchChannelEventDeltaData extends IFreeSwitchChannelEventHeader { + newValues?: DeepPartial; + modifiedValues?: DeepModified; +} + +export interface IFreeSwitchChannelEventDelta extends IRocketChatRecord, IFreeSwitchChannelEventDeltaData, IFreeSwitchChannelEventStates { + channelUniqueId: string; +} diff --git a/packages/core-typings/src/voip/IFreeSwitchEvent.ts b/packages/core-typings/src/voip/IFreeSwitchEvent.ts deleted file mode 100644 index a1cc3e7eafe..00000000000 --- a/packages/core-typings/src/voip/IFreeSwitchEvent.ts +++ /dev/null @@ -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; -} - -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; - }; -} diff --git a/packages/core-typings/src/voip/VoIPUserConfiguration.ts b/packages/core-typings/src/voip/VoIPUserConfiguration.ts index 7bade79310c..e4f28d31bac 100644 --- a/packages/core-typings/src/voip/VoIPUserConfiguration.ts +++ b/packages/core-typings/src/voip/VoIPUserConfiguration.ts @@ -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 diff --git a/packages/core-typings/src/voip/index.ts b/packages/core-typings/src/voip/index.ts index edede37e6cd..d7f2f37c79d 100644 --- a/packages/core-typings/src/voip/index.ts +++ b/packages/core-typings/src/voip/index.ts @@ -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'; diff --git a/packages/freeswitch/src/esl/eventClient.ts b/packages/freeswitch/src/esl/eventClient.ts index 9555669c473..43b1c60049c 100644 --- a/packages/freeswitch/src/esl/eventClient.ts +++ b/packages/freeswitch/src/esl/eventClient.ts @@ -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 { diff --git a/packages/freeswitch/src/eventParser/computeChannelFromEvents.ts b/packages/freeswitch/src/eventParser/computeChannelFromEvents.ts new file mode 100644 index 00000000000..90b7470e748 --- /dev/null +++ b/packages/freeswitch/src/eventParser/computeChannelFromEvents.ts @@ -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; + 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, + ); + + 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, + }; +} diff --git a/packages/freeswitch/src/eventParser/computeChannelProfiles.ts b/packages/freeswitch/src/eventParser/computeChannelProfiles.ts new file mode 100644 index 00000000000..3dfe92e68ac --- /dev/null +++ b/packages/freeswitch/src/eventParser/computeChannelProfiles.ts @@ -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): 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, + }; +} diff --git a/packages/freeswitch/src/eventParser/extractChannelChangesFromEvent.ts b/packages/freeswitch/src/eventParser/extractChannelChangesFromEvent.ts new file mode 100644 index 00000000000..432660d98b2 --- /dev/null +++ b/packages/freeswitch/src/eventParser/extractChannelChangesFromEvent.ts @@ -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, + _eventName: string, + eventValues: Record, +): { + changedValues: Record; + newValues: Record; + changedExistingValues: Record; +} { + const changedValues: Record = {}; + const newValues: Record = {}; + const changedExistingValues: Record = {}; + + 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([...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 }; +} diff --git a/packages/freeswitch/src/eventParser/filterOutMissingData.ts b/packages/freeswitch/src/eventParser/filterOutMissingData.ts new file mode 100644 index 00000000000..ab81a6a61cd --- /dev/null +++ b/packages/freeswitch/src/eventParser/filterOutMissingData.ts @@ -0,0 +1,21 @@ +import { objectMap } from '@rocket.chat/tools'; + +export function filterOutMissingData>(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; +} diff --git a/packages/freeswitch/src/eventParser/filterStringList.ts b/packages/freeswitch/src/eventParser/filterStringList.ts new file mode 100644 index 00000000000..8c8ac397705 --- /dev/null +++ b/packages/freeswitch/src/eventParser/filterStringList.ts @@ -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); +} diff --git a/packages/freeswitch/src/eventParser/insertDataIntoEventProfile.ts b/packages/freeswitch/src/eventParser/insertDataIntoEventProfile.ts new file mode 100644 index 00000000000..5cbd954ea1a --- /dev/null +++ b/packages/freeswitch/src/eventParser/insertDataIntoEventProfile.ts @@ -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>, +): 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; +} diff --git a/packages/freeswitch/src/eventParser/parseChannelKind.ts b/packages/freeswitch/src/eventParser/parseChannelKind.ts new file mode 100644 index 00000000000..3d0018c8269 --- /dev/null +++ b/packages/freeswitch/src/eventParser/parseChannelKind.ts @@ -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'; +} diff --git a/packages/freeswitch/src/eventParser/parseChannelUsername.ts b/packages/freeswitch/src/eventParser/parseChannelUsername.ts new file mode 100644 index 00000000000..2c3bba63c76 --- /dev/null +++ b/packages/freeswitch/src/eventParser/parseChannelUsername.ts @@ -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); +} diff --git a/packages/freeswitch/src/eventParser/parseEventCallId.ts b/packages/freeswitch/src/eventParser/parseEventCallId.ts new file mode 100644 index 00000000000..47be95eda21 --- /dev/null +++ b/packages/freeswitch/src/eventParser/parseEventCallId.ts @@ -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']; +} diff --git a/packages/freeswitch/src/eventParser/parseEventData.ts b/packages/freeswitch/src/eventParser/parseEventData.ts new file mode 100644 index 00000000000..43426bb7650 --- /dev/null +++ b/packages/freeswitch/src/eventParser/parseEventData.ts @@ -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 & Record<`variable_${string}`, string | string[] | undefined>; + +export function parseEventData(eventName: string, eventData: EventData): Omit | 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; + const metadata = filterStringList(eventData, (key) => isMetadata(key)) as Record; + 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; + + const event: Omit = { + 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-'); +} diff --git a/packages/freeswitch/src/eventParser/parseEventExtensions.ts b/packages/freeswitch/src/eventParser/parseEventExtensions.ts new file mode 100644 index 00000000000..5516c200738 --- /dev/null +++ b/packages/freeswitch/src/eventParser/parseEventExtensions.ts @@ -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, +): AtLeast | 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, +): { 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]), + }; +} diff --git a/packages/freeswitch/src/eventParser/parseEventLeg.ts b/packages/freeswitch/src/eventParser/parseEventLeg.ts new file mode 100644 index 00000000000..bc7210740df --- /dev/null +++ b/packages/freeswitch/src/eventParser/parseEventLeg.ts @@ -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 | undefined { + const legData = filterStringList( + eventData, + (key) => key.startsWith(`${legName}-`), + ([key, value]) => { + return [key.replace(`${legName}-`, ''), value]; + }, + ) as Record; + + 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 = { + 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); +} diff --git a/packages/freeswitch/src/eventParser/parseTimestamp.ts b/packages/freeswitch/src/eventParser/parseTimestamp.ts new file mode 100644 index 00000000000..374fd5b5a00 --- /dev/null +++ b/packages/freeswitch/src/eventParser/parseTimestamp.ts @@ -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); +} diff --git a/packages/freeswitch/src/index.ts b/packages/freeswitch/src/index.ts index afe105211d1..cfd70023535 100644 --- a/packages/freeswitch/src/index.ts +++ b/packages/freeswitch/src/index.ts @@ -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'; diff --git a/packages/freeswitch/tests/eventParser/computeChannelFromEvents.test.ts b/packages/freeswitch/tests/eventParser/computeChannelFromEvents.test.ts new file mode 100644 index 00000000000..2ddeb19673c --- /dev/null +++ b/packages/freeswitch/tests/eventParser/computeChannelFromEvents.test.ts @@ -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: '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(); + }); +}); diff --git a/packages/freeswitch/tests/eventParser/computeChannelProfiles.test.ts b/packages/freeswitch/tests/eventParser/computeChannelProfiles.test.ts new file mode 100644 index 00000000000..571dd0df6b0 --- /dev/null +++ b/packages/freeswitch/tests/eventParser/computeChannelProfiles.test.ts @@ -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 => ({ + ...overrides, + }); + + it('should compute channel profiles with all timestamps', () => { + const profiles: Record = { + 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 = { + 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 = { + 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 = { + 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 = {}; + + 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 = { + 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 = { + 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 = { + 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), + }); + }); +}); diff --git a/packages/freeswitch/tests/eventParser/extractChannelChangesFromEvent.test.ts b/packages/freeswitch/tests/eventParser/extractChannelChangesFromEvent.test.ts new file mode 100644 index 00000000000..bcce540a247 --- /dev/null +++ b/packages/freeswitch/tests/eventParser/extractChannelChangesFromEvent.test.ts @@ -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({}); + }); +}); diff --git a/packages/freeswitch/tests/eventParser/filterOutMissingData.test.ts b/packages/freeswitch/tests/eventParser/filterOutMissingData.test.ts new file mode 100644 index 00000000000..0e8bc701074 --- /dev/null +++ b/packages/freeswitch/tests/eventParser/filterOutMissingData.test.ts @@ -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); + }); +}); diff --git a/packages/freeswitch/tests/eventParser/filterStringList.test.ts b/packages/freeswitch/tests/eventParser/filterStringList.test.ts new file mode 100644 index 00000000000..80fc47a5b07 --- /dev/null +++ b/packages/freeswitch/tests/eventParser/filterStringList.test.ts @@ -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): 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', + }); + }); +}); diff --git a/packages/freeswitch/tests/eventParser/insertDataIntoEventProfile.test.ts b/packages/freeswitch/tests/eventParser/insertDataIntoEventProfile.test.ts new file mode 100644 index 00000000000..c8aeed7df47 --- /dev/null +++ b/packages/freeswitch/tests/eventParser/insertDataIntoEventProfile.test.ts @@ -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', + }, + }, + }, + }, + }); + }); +}); diff --git a/packages/freeswitch/tests/eventParser/parseChannelKind.test.ts b/packages/freeswitch/tests/eventParser/parseChannelKind.test.ts new file mode 100644 index 00000000000..4dfce5960f1 --- /dev/null +++ b/packages/freeswitch/tests/eventParser/parseChannelKind.test.ts @@ -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'); + }); +}); diff --git a/packages/freeswitch/tests/eventParser/parseChannelUsername.test.ts b/packages/freeswitch/tests/eventParser/parseChannelUsername.test.ts new file mode 100644 index 00000000000..f000aaa0f3e --- /dev/null +++ b/packages/freeswitch/tests/eventParser/parseChannelUsername.test.ts @@ -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): 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(); + }); +}); diff --git a/packages/freeswitch/tests/eventParser/parseEventCallId.test.ts b/packages/freeswitch/tests/eventParser/parseEventCallId.test.ts new file mode 100644 index 00000000000..cfd45df41db --- /dev/null +++ b/packages/freeswitch/tests/eventParser/parseEventCallId.test.ts @@ -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 => ({ + ...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(); + }); +}); diff --git a/packages/freeswitch/tests/eventParser/parseEventData.test.ts b/packages/freeswitch/tests/eventParser/parseEventData.test.ts new file mode 100644 index 00000000000..46e074439d8 --- /dev/null +++ b/packages/freeswitch/tests/eventParser/parseEventData.test.ts @@ -0,0 +1,5078 @@ +import { describe, expect, it } from '@jest/globals'; + +import { parseEventData } from '../../src/eventParser/parseEventData'; +import type { EventData } from '../../src/eventParser/parseEventData'; + +function createEventData(data: Record): EventData { + return data as unknown as EventData; +} + +describe('parseEventData', () => { + describe('simple tests', () => { + it('should handle missing required fields', () => { + const eventName = 'CHANNEL_CREATE'; + const eventData = createEventData({ + 'Channel-Name': 'sofia/internal/1001@192.168.1.100', + 'Channel-State': 'CS_INIT', + }); + + const result = parseEventData(eventName, eventData); + + expect(result).toBeUndefined(); + }); + + it('should handle invalid sequence number', () => { + const eventName = 'CHANNEL_CREATE'; + const eventData = createEventData({ + 'Channel-Name': 'sofia/internal/1001@192.168.1.100', + 'Channel-State': 'CS_INIT', + 'Event-Sequence': 'invalid', + 'Unique-ID': 'test-channel-id', + }); + + const result = parseEventData(eventName, eventData); + + expect(result).toBeUndefined(); + }); + }); + + it('channel with no name, states or timestamp', () => { + const eventName = 'CHANNEL_DESTROY'; + const eventData = createEventData({ + 'Channel-State-Number': '9', + 'Channel-Call-State-Number': '9', + 'Event-Sequence': '1', + 'Unique-ID': 'test-channel-id', + 'Hangup-Cause': 'NORMAL_CLEARING', + 'variable_undefined': undefined, + }); + + const result = parseEventData(eventName, eventData); + + expect(result).toBeDefined(); + expect(result?.channelName).toBe(''); + expect(result?.channelState).toBe(''); + expect(result?.channelCallState).toBe(''); + expect(result?.hangupCause).toBe('NORMAL_CLEARING'); + expect(result?.firedAt).toBeInstanceOf(Date); + }); + + it('should parse CHANNEL_BRIDGE event', () => { + const eventName = 'CHANNEL_BRIDGE'; + const eventData = createEventData({ + 'Channel-Name': 'sofia/internal/1001@192.168.1.100', + 'Channel-State': 'CS_EXECUTE', + 'Channel-Call-State': 'CS_EXECUTE', + 'Channel-State-Number': '4', + 'Channel-Call-State-Number': '4', + 'Event-Sequence': '1', + 'Event-Date-Timestamp': '1749847568969044', + 'Unique-ID': 'test-channel-id', + 'Bridge-A-Unique-ID': 'bridge-a-id', + 'Bridge-B-Unique-ID': 'bridge-b-id', + 'Bridged-To': 'other-channel-id', + }); + + const result = parseEventData(eventName, eventData); + + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'test-channel-id', + eventName: 'CHANNEL_BRIDGE', + sequence: 1, + firedAt: new Date(1749847568969), + callUniqueId: 'test-channel-id', + channelName: 'sofia/internal/1001@192.168.1.100', + channelState: 'CS_EXECUTE', + channelStateNumber: '4', + channelCallStateNumber: '4', + channelCallState: 'CS_EXECUTE', + channelUsername: '1001', + bridgeUniqueIds: ['bridge-a-id', 'bridge-b-id'], + bridgedTo: 'other-channel-id', + metadata: { 'Event-Sequence': '1', 'Event-Date-Timestamp': '1749847568969044' }, + }); + }); + + it('should parse CHANNEL_HANGUP event', () => { + const eventName = 'CHANNEL_HANGUP'; + const eventData = createEventData({ + 'Channel-Name': 'sofia/internal/1001@192.168.1.100', + 'Channel-State': 'CS_HANGUP', + 'Channel-Call-State': 'CS_HANGUP', + 'Channel-State-Number': '6', + 'Channel-Call-State-Number': '6', + 'Event-Sequence': '1', + 'Event-Date-Timestamp': '1749847568969044', + 'Unique-ID': 'test-channel-id', + 'Hangup-Cause': 'NORMAL_CLEARING', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'test-channel-id', + eventName: 'CHANNEL_HANGUP', + sequence: 1, + firedAt: new Date(1749847568969), + callUniqueId: 'test-channel-id', + channelName: 'sofia/internal/1001@192.168.1.100', + channelState: 'CS_HANGUP', + channelStateNumber: '6', + channelCallStateNumber: '6', + channelCallState: 'CS_HANGUP', + channelUsername: '1001', + hangupCause: 'NORMAL_CLEARING', + metadata: { 'Event-Sequence': '1', 'Event-Date-Timestamp': '1749847568969044' }, + }); + }); + + it('should parse CHANNEL_ANSWER event', () => { + const eventName = 'CHANNEL_ANSWER'; + const eventData = createEventData({ + 'Channel-Name': 'sofia/internal/1001@192.168.1.100', + 'Channel-State': 'CS_EXECUTE', + 'Channel-Call-State': 'CS_EXECUTE', + 'Channel-State-Number': '4', + 'Channel-Call-State-Number': '4', + 'Event-Sequence': '1', + 'Event-Date-Timestamp': '1749847568969044', + 'Unique-ID': 'test-channel-id', + 'Answer-State': 'answered', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'test-channel-id', + eventName: 'CHANNEL_ANSWER', + sequence: 1, + firedAt: new Date(1749847568969), + callUniqueId: 'test-channel-id', + channelName: 'sofia/internal/1001@192.168.1.100', + channelState: 'CS_EXECUTE', + channelStateNumber: '4', + channelCallStateNumber: '4', + channelCallState: 'CS_EXECUTE', + channelUsername: '1001', + answerState: 'answered', + metadata: { 'Event-Sequence': '1', 'Event-Date-Timestamp': '1749847568969044' }, + }); + }); + + it('should parse CHANNEL_PROGRESS_MEDIA event', () => { + const eventName = 'CHANNEL_PROGRESS_MEDIA'; + const eventData = createEventData({ + 'Channel-Name': 'sofia/internal/1001@192.168.1.100', + 'Channel-State': 'CS_EXECUTE', + 'Channel-Call-State': 'CS_EXECUTE', + 'Channel-State-Number': '4', + 'Channel-Call-State-Number': '4', + 'Event-Sequence': '1', + 'Event-Date-Timestamp': '1749847568969044', + 'Unique-ID': 'test-channel-id', + 'Channel-Read-Codec-Name': 'PCMU', + 'Channel-Read-Codec-Rate': '8000', + 'Channel-Write-Codec-Name': 'PCMU', + 'Channel-Write-Codec-Rate': '8000', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'test-channel-id', + eventName: 'CHANNEL_PROGRESS_MEDIA', + sequence: 1, + firedAt: new Date(1749847568969), + callUniqueId: 'test-channel-id', + channelName: 'sofia/internal/1001@192.168.1.100', + channelState: 'CS_EXECUTE', + channelStateNumber: '4', + channelCallStateNumber: '4', + channelCallState: 'CS_EXECUTE', + channelUsername: '1001', + metadata: { 'Event-Sequence': '1', 'Event-Date-Timestamp': '1749847568969044' }, + codecs: { read: { name: 'PCMU', rate: '8000' }, write: { nme: 'PCMU', rate: '8000' } }, + }); + }); + + it('should parse CHANNEL_DESTROY event', () => { + const eventName = 'CHANNEL_DESTROY'; + const eventData = createEventData({ + 'Channel-Name': 'sofia/internal/1001@192.168.1.100', + 'Channel-State': 'CS_DESTROY', + 'Channel-Call-State': 'CS_DESTROY', + 'Channel-State-Number': '9', + 'Channel-Call-State-Number': '9', + 'Event-Sequence': '1', + 'Event-Date-Timestamp': '1749847568969044', + 'Unique-ID': 'test-channel-id', + 'Hangup-Cause': 'NORMAL_CLEARING', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'test-channel-id', + eventName: 'CHANNEL_DESTROY', + sequence: 1, + firedAt: new Date(1749847568969), + callUniqueId: 'test-channel-id', + channelName: 'sofia/internal/1001@192.168.1.100', + channelState: 'CS_DESTROY', + channelStateNumber: '9', + channelCallStateNumber: '9', + channelCallState: 'CS_DESTROY', + channelUsername: '1001', + hangupCause: 'NORMAL_CLEARING', + metadata: { 'Event-Sequence': '1', 'Event-Date-Timestamp': '1749847568969044' }, + }); + }); +}); + +describe('complete call', () => { + it('should parse CS_NEW CHANNEL_STATE event', () => { + const eventName = 'CHANNEL_STATE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39834', + 'Channel-State': 'CS_NEW', + 'Channel-Call-State': 'DOWN', + 'Channel-State-Number': '0', + 'Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Call-Direction': 'inbound', + 'Presence-Call-Direction': 'inbound', + 'Channel-HIT-Dialplan': 'true', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + }); + + const result = parseEventData(eventName, eventData); + + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + eventName: 'CHANNEL_STATE', + sequence: 39834, + firedAt: new Date(1749847568969), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + channelState: 'CS_NEW', + channelCallState: 'DOWN', + channelUsername: '1001', + answerState: 'ringing', + callDirection: 'inbound', + channelHitDialplan: 'true', + metadata: { + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39834', + }, + presenceCallDirection: 'inbound', + caller: '1001', + callee: undefined, + }); + expect(result?.legs).toBeUndefined(); + expect(result?.raw).toBeUndefined(); + }); + + it('should parse CS_INIT CHANNEL_STATE event', () => { + const eventName = 'CHANNEL_STATE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39835', + 'Channel-State': 'CS_INIT', + 'Channel-Call-State': 'DOWN', + 'Channel-State-Number': '1', + 'Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Call-Direction': 'inbound', + 'Presence-Call-Direction': 'inbound', + 'Channel-HIT-Dialplan': 'true', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'inbound', + 'Caller-Logical-Direction': 'inbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000', + 'Caller-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '0', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + }); + + const result = parseEventData(eventName, eventData); + + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + eventName: 'CHANNEL_STATE', + sequence: 39835, + firedAt: new Date(1749847568969), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + channelState: 'CS_INIT', + channelStateNumber: '1', + channelCallState: 'DOWN', + channelUsername: '1001', + answerState: 'ringing', + callDirection: 'inbound', + channelHitDialplan: 'true', + legs: { + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Caller', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39835', + }, + presenceCallDirection: 'inbound', + caller: '1001', + callee: '1000', + }); + expect(result?.raw).toBeUndefined(); + }); + + it('should parse CHANNEL_CREATE event', () => { + const eventName = 'CHANNEL_CREATE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_CREATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39836', + 'Channel-State': 'CS_INIT', + 'Channel-Call-State': 'DOWN', + 'Channel-State-Number': '2', + 'Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Call-Direction': 'inbound', + 'Presence-Call-Direction': 'inbound', + 'Channel-HIT-Dialplan': 'true', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'inbound', + 'Caller-Logical-Direction': 'inbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000', + 'Caller-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '0', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'variable_direction': 'inbound', + 'variable_ep_codec_string': + 'mod_opus.opus@48000h@20i@2c,mod_spandsp.G722@8000h@20i@64000b,CORE_PCM_MODULE.PCMU@8000h@20i@64000b,CORE_PCM_MODULE.PCMA@8000h@20i@64000b', + 'variable_endpoint_disposition': 'DELAYED NEGOTIATION', + }); + + const result = parseEventData(eventName, eventData); + + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + eventName: 'CHANNEL_CREATE', + sequence: 39836, + firedAt: new Date(1749847568969), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + channelState: 'CS_INIT', + channelStateNumber: '2', + channelCallState: 'DOWN', + channelUsername: '1001', + answerState: 'ringing', + callDirection: 'inbound', + channelHitDialplan: 'true', + legs: { + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Caller', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_CREATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39836', + }, + variables: { + direction: 'inbound', + ep_codec_string: + 'mod_opus.opus@48000h@20i@2c,mod_spandsp.G722@8000h@20i@64000b,CORE_PCM_MODULE.PCMU@8000h@20i@64000b,CORE_PCM_MODULE.PCMA@8000h@20i@64000b', + endpoint_disposition: 'DELAYED NEGOTIATION', + }, + presenceCallDirection: 'inbound', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse RINGING CHANNEL_CALLSTATE event', () => { + const eventName = 'CHANNEL_CALLSTATE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_CALLSTATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39837', + 'Original-Channel-Call-State': 'DOWN', + 'Channel-Call-State-Number': '2', + 'Channel-State': 'CS_ROUTING', + 'Channel-Call-State': 'RINGING', + 'Channel-State-Number': '2', + 'Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Call-Direction': 'inbound', + 'Presence-Call-Direction': 'inbound', + 'Channel-HIT-Dialplan': 'true', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'inbound', + 'Caller-Logical-Direction': 'inbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000', + 'Caller-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '0', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + }); + + const result = parseEventData(eventName, eventData); + + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + eventName: 'CHANNEL_CALLSTATE', + sequence: 39837, + firedAt: new Date(1749847568969), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + channelState: 'CS_ROUTING', + channelStateNumber: '2', + channelCallStateNumber: '2', + channelCallState: 'RINGING', + originalChannelCallState: 'DOWN', + channelUsername: '1001', + answerState: 'ringing', + callDirection: 'inbound', + channelHitDialplan: 'true', + legs: { + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Caller', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_CALLSTATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39837', + }, + presenceCallDirection: 'inbound', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse CS_ROUTING CHANNEL_STATE event', () => { + const eventName = 'CHANNEL_STATE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39838', + 'Channel-State': 'CS_ROUTING', + 'Channel-Call-State': 'RINGING', + 'Channel-State-Number': '2', + 'Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Call-Direction': 'inbound', + 'Presence-Call-Direction': 'inbound', + 'Channel-HIT-Dialplan': 'true', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'inbound', + 'Caller-Logical-Direction': 'inbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000', + 'Caller-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '0', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + eventName: 'CHANNEL_STATE', + sequence: 39838, + firedAt: new Date(1749847568969), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + channelState: 'CS_ROUTING', + channelStateNumber: '2', + channelCallState: 'RINGING', + channelUsername: '1001', + answerState: 'ringing', + callDirection: 'inbound', + channelHitDialplan: 'true', + legs: { + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Caller', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39838', + }, + presenceCallDirection: 'inbound', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse CS_EXECUTE CHANNEL_STATE event', () => { + const eventName = 'CHANNEL_STATE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39841', + 'Channel-State': 'CS_EXECUTE', + 'Channel-Call-State': 'RINGING', + 'Channel-State-Number': '4', + 'Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Call-Direction': 'inbound', + 'Presence-Call-Direction': 'inbound', + 'Channel-HIT-Dialplan': 'true', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'inbound', + 'Caller-Logical-Direction': 'inbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000', + 'Caller-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '0', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + eventName: 'CHANNEL_STATE', + sequence: 39841, + firedAt: new Date(1749847568969), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + channelState: 'CS_EXECUTE', + channelStateNumber: '4', + channelCallState: 'RINGING', + channelUsername: '1001', + answerState: 'ringing', + callDirection: 'inbound', + channelHitDialplan: 'true', + legs: { + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Caller', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39841', + }, + presenceCallDirection: 'inbound', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse CHANNEL_OUTGOING event', () => { + const eventName = 'CHANNEL_OUTGOING'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_OUTGOING', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39852', + 'Channel-State': 'CS_NONE', + 'Channel-Call-State': 'DOWN', + 'Channel-State-Number': '1', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Call-UUID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Answer-State': 'ringing', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '0', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '0', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CHANNEL_OUTGOING', + sequence: 39852, + firedAt: new Date(1749847568969), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_NONE', + channelStateNumber: '1', + channelCallState: 'DOWN', + channelUsername: '1000', + answerState: 'ringing', + callDirection: 'outbound', + channelHitDialplan: 'false', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { profileIndex: '1', profileCreatedTime: new Date(1749847568969), channelCreatedTime: new Date(1749847568969) }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { 'Event-Name': 'CHANNEL_OUTGOING', 'Event-Date-Timestamp': '1749847568969044', 'Event-Sequence': '39852' }, + raw: { 'Channel-Call-UUID': '31450e18-1531-46a0-9c10-4629110e3d23' }, + presenceCallDirection: 'outbound', + caller: '1001', + callee: '1000', + }); + }); + + it('should parse outbound CS_INIT CHANNEL_STATE event', () => { + const eventName = 'CHANNEL_STATE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39853', + 'Channel-State': 'CS_INIT', + 'Channel-Call-State': 'DOWN', + 'Channel-State-Number': '1', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Presence-ID': '1000@172.99.99.99', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '0', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '0', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CHANNEL_STATE', + sequence: 39853, + firedAt: new Date(1749847568969), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_INIT', + channelStateNumber: '1', + channelCallState: 'DOWN', + channelUsername: '1000', + answerState: 'ringing', + callDirection: 'outbound', + channelHitDialplan: 'false', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39853', + }, + presenceCallDirection: 'outbound', + channelPresenceId: '1000@172.99.99.99', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse outbound CHANNEL_CREATE event', () => { + const eventName = 'CHANNEL_CREATE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_CREATE', + 'Event-Date-Timestamp': '1749847569008164', + 'Event-Sequence': '39854', + 'Channel-State': 'CS_INIT', + 'Channel-Call-State': 'DOWN', + 'Channel-State-Number': '2', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Presence-ID': '1000@172.99.99.99', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '0', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '0', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + 'variable_direction': 'outbound', + 'variable_call_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_dialed_user': '1000', + 'variable_dialed_domain': '172.99.99.99', + 'variable_sip_invite_domain': '172.99.99.99', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CHANNEL_CREATE', + sequence: 39854, + firedAt: new Date(1749847569008), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_INIT', + channelStateNumber: '2', + channelCallState: 'DOWN', + channelUsername: '1000', + answerState: 'ringing', + callDirection: 'outbound', + channelHitDialplan: 'false', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_CREATE', + 'Event-Date-Timestamp': '1749847569008164', + 'Event-Sequence': '39854', + }, + variables: { + direction: 'outbound', + call_uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + dialed_user: '1000', + dialed_domain: '172.99.99.99', + sip_invite_domain: '172.99.99.99', + }, + presenceCallDirection: 'outbound', + channelPresenceId: '1000@172.99.99.99', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse CHANNEL_ORIGINATE event', () => { + const eventName = 'CHANNEL_ORIGINATE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_ORIGINATE', + 'Event-Date-Timestamp': '1749847569008164', + 'Event-Sequence': '39855', + 'Channel-State': 'CS_INIT', + 'Channel-Call-State': 'DOWN', + 'Channel-State-Number': '2', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Presence-ID': '1000@172.99.99.99', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '0', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '0', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + 'variable_direction': 'outbound', + 'variable_call_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_dialed_user': '1000', + 'variable_dialed_domain': '172.99.99.99', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CHANNEL_ORIGINATE', + sequence: 39855, + firedAt: new Date(1749847569008), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_INIT', + channelStateNumber: '2', + channelCallState: 'DOWN', + channelUsername: '1000', + answerState: 'ringing', + callDirection: 'outbound', + channelHitDialplan: 'false', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_ORIGINATE', + 'Event-Date-Timestamp': '1749847569008164', + 'Event-Sequence': '39855', + }, + variables: { + direction: 'outbound', + call_uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + dialed_user: '1000', + dialed_domain: '172.99.99.99', + }, + presenceCallDirection: 'outbound', + channelPresenceId: '1000@172.99.99.99', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse outbound CS_ROUTING CHANNEL_STATE event', () => { + const eventName = 'CHANNEL_STATE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847569008164', + 'Event-Sequence': '39856', + 'Channel-State': 'CS_ROUTING', + 'Channel-Call-State': 'DOWN', + 'Channel-State-Number': '2', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Presence-ID': '1000@172.99.99.99', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '0', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '0', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CHANNEL_STATE', + sequence: 39856, + firedAt: new Date(1749847569008), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_ROUTING', + channelStateNumber: '2', + channelCallState: 'DOWN', + channelUsername: '1000', + answerState: 'ringing', + callDirection: 'outbound', + channelHitDialplan: 'false', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847569008164', + 'Event-Sequence': '39856', + }, + presenceCallDirection: 'outbound', + channelPresenceId: '1000@172.99.99.99', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse outbound CS_CONSUME_MEDIA CHANNEL_STATE event', () => { + const eventName = 'CHANNEL_STATE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847569008164', + 'Event-Sequence': '39858', + 'Channel-State': 'CS_CONSUME_MEDIA', + 'Channel-Call-State': 'DOWN', + 'Channel-State-Number': '7', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Presence-ID': '1000@172.99.99.99', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '0', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '0', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CHANNEL_STATE', + sequence: 39858, + firedAt: new Date(1749847569008), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_CONSUME_MEDIA', + channelStateNumber: '7', + channelCallState: 'DOWN', + channelUsername: '1000', + answerState: 'ringing', + callDirection: 'outbound', + channelHitDialplan: 'false', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847569008164', + 'Event-Sequence': '39858', + }, + presenceCallDirection: 'outbound', + channelPresenceId: '1000@172.99.99.99', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse outbound CHANNEL_PROGRESS event', () => { + const eventName = 'CHANNEL_PROGRESS'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_PROGRESS', + 'Event-Date-Timestamp': '1749847569148194', + 'Event-Sequence': '39859', + 'Channel-State': 'CS_CONSUME_MEDIA', + 'Channel-Call-State': 'DOWN', + 'Channel-State-Number': '7', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Presence-ID': '1000@172.99.99.99', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '1749847569148194', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '1749847569148194', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + 'variable_direction': 'outbound', + 'variable_call_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_dialed_user': '1000', + 'variable_dialed_domain': '172.99.99.99', + 'variable_sip_invite_domain': '172.99.99.99', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CHANNEL_PROGRESS', + sequence: 39859, + firedAt: new Date(1749847569148), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_CONSUME_MEDIA', + channelStateNumber: '7', + channelCallState: 'DOWN', + channelUsername: '1000', + answerState: 'ringing', + callDirection: 'outbound', + channelHitDialplan: 'false', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelProgressTime: new Date(1749847569148), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { + channelProgressTime: new Date(1749847569148), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_PROGRESS', + 'Event-Date-Timestamp': '1749847569148194', + 'Event-Sequence': '39859', + }, + variables: { + direction: 'outbound', + call_uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + dialed_user: '1000', + dialed_domain: '172.99.99.99', + sip_invite_domain: '172.99.99.99', + }, + presenceCallDirection: 'outbound', + channelPresenceId: '1000@172.99.99.99', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse outbound RINGING CHANNEL_CALLSTATE event', () => { + const eventName = 'CHANNEL_CALLSTATE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_CALLSTATE', + 'Event-Date-Timestamp': '1749847569148194', + 'Event-Sequence': '39860', + 'Original-Channel-Call-State': 'DOWN', + 'Channel-Call-State-Number': '2', + 'Channel-State': 'CS_CONSUME_MEDIA', + 'Channel-Call-State': 'RINGING', + 'Channel-State-Number': '7', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Presence-ID': '1000@172.99.99.99', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '1749847569148194', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '1749847569148194', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CHANNEL_CALLSTATE', + sequence: 39860, + firedAt: new Date(1749847569148), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_CONSUME_MEDIA', + channelStateNumber: '7', + channelCallStateNumber: '2', + channelCallState: 'RINGING', + originalChannelCallState: 'DOWN', + channelUsername: '1000', + answerState: 'ringing', + callDirection: 'outbound', + channelHitDialplan: 'false', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelProgressTime: new Date(1749847569148), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { + channelProgressTime: new Date(1749847569148), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_CALLSTATE', + 'Event-Date-Timestamp': '1749847569148194', + 'Event-Sequence': '39860', + }, + presenceCallDirection: 'outbound', + channelPresenceId: '1000@172.99.99.99', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse CHANNEL_PROGRESS event', () => { + const eventName = 'CHANNEL_PROGRESS'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_PROGRESS', + 'Event-Date-Timestamp': '1749847569168181', + 'Event-Sequence': '39861', + 'Channel-State': 'CS_EXECUTE', + 'Channel-Call-State': 'RINGING', + 'Channel-State-Number': '4', + 'Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Call-Direction': 'inbound', + 'Presence-Call-Direction': 'inbound', + 'Channel-HIT-Dialplan': 'true', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'inbound', + 'Caller-Logical-Direction': 'inbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000', + 'Caller-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '1749847569148194', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'variable_direction': 'inbound', + 'variable_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_call_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_dialed_extension': '1000', + 'variable_call_timeout': '30', + 'variable_hangup_after_bridge': 'true', + 'variable_current_application_data': 'user/1000@172.99.99.99', + 'variable_current_application': 'bridge', + 'variable_dialed_user': '1000', + 'variable_dialed_domain': '172.99.99.99', + 'variable_originate_disposition': 'failure', + 'variable_DIALSTATUS': 'INVALIDARGS', + 'variable_originated_legs': '31450e18-1531-46a0-9c10-4629110e3d23;Outbound Call;1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'variable_sip_to_tag': 'rUtyD23mXy9Xj', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + eventName: 'CHANNEL_PROGRESS', + sequence: 39861, + firedAt: new Date(1749847569168), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + channelState: 'CS_EXECUTE', + channelStateNumber: '4', + channelCallState: 'RINGING', + channelUsername: '1001', + answerState: 'ringing', + callDirection: 'inbound', + channelHitDialplan: 'true', + legs: { + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Caller', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelProgressTime: new Date(1749847569148), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_PROGRESS', + 'Event-Date-Timestamp': '1749847569168181', + 'Event-Sequence': '39861', + }, + variables: { + direction: 'inbound', + uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + call_uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + dialed_extension: '1000', + call_timeout: '30', + hangup_after_bridge: 'true', + current_application_data: 'user/1000@172.99.99.99', + current_application: 'bridge', + dialed_user: '1000', + dialed_domain: '172.99.99.99', + originate_disposition: 'failure', + DIALSTATUS: 'INVALIDARGS', + originated_legs: '31450e18-1531-46a0-9c10-4629110e3d23;Outbound Call;1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + sip_to_tag: 'rUtyD23mXy9Xj', + }, + presenceCallDirection: 'inbound', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse outbound CHANNEL_ANSWER event', () => { + const eventName = 'CHANNEL_ANSWER'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_ANSWER', + 'Event-Date-Timestamp': '1749847572228165', + 'Event-Sequence': '39867', + 'Channel-State': 'CS_CONSUME_MEDIA', + 'Channel-Call-State': 'RINGING', + 'Channel-State-Number': '7', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Presence-ID': '1000@172.99.99.99', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'answered', + 'Channel-Read-Codec-Name': 'opus', + 'Channel-Read-Codec-Rate': '48000', + 'Channel-Read-Codec-Bit-Rate': '0', + 'Channel-Write-Codec-Name': 'opus', + 'Channel-Write-Codec-Rate': '48000', + 'Channel-Write-Codec-Bit-Rate': '0', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '1749847572228165', + 'Caller-Channel-Progress-Time': '1749847569148194', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '1749847569148194', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + 'variable_direction': 'outbound', + 'variable_is_outbound': 'true', + 'variable_call_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_dialed_user': '1000', + 'variable_dialed_domain': '172.99.99.99', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CHANNEL_ANSWER', + sequence: 39867, + firedAt: new Date(1749847572228), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_CONSUME_MEDIA', + channelStateNumber: '7', + channelCallState: 'RINGING', + channelUsername: '1000', + answerState: 'answered', + callDirection: 'outbound', + channelHitDialplan: 'false', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelProgressTime: new Date(1749847569148), + channelAnsweredTime: new Date(1749847572228), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { + channelProgressTime: new Date(1749847569148), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_ANSWER', + 'Event-Date-Timestamp': '1749847572228165', + 'Event-Sequence': '39867', + }, + variables: { + direction: 'outbound', + is_outbound: 'true', + call_uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + dialed_user: '1000', + dialed_domain: '172.99.99.99', + }, + codecs: { + read: { name: 'opus', rate: '48000' }, + write: { nme: 'opus', rate: '48000' }, + }, + presenceCallDirection: 'outbound', + channelPresenceId: '1000@172.99.99.99', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse CHANNEL_PROGRESS_MEDIA event', () => { + const eventName = 'CHANNEL_PROGRESS_MEDIA'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_PROGRESS_MEDIA', + 'Event-Date-Timestamp': '1749847572249573', + 'Event-Sequence': '39872', + 'Channel-State': 'CS_EXECUTE', + 'Channel-Call-State': 'RINGING', + 'Channel-State-Number': '4', + 'Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Call-Direction': 'inbound', + 'Presence-Call-Direction': 'inbound', + 'Channel-HIT-Dialplan': 'true', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'early', + 'Channel-Read-Codec-Name': 'opus', + 'Channel-Read-Codec-Rate': '48000', + 'Channel-Read-Codec-Bit-Rate': '0', + 'Channel-Write-Codec-Name': 'opus', + 'Channel-Write-Codec-Rate': '48000', + 'Channel-Write-Codec-Bit-Rate': '0', + 'Caller-Direction': 'inbound', + 'Caller-Logical-Direction': 'inbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000', + 'Caller-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '1749847569148194', + 'Caller-Channel-Progress-Media-Time': '1749847572249573', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'variable_direction': 'inbound', + 'variable_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_call_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_dialed_extension': '1000', + 'variable_call_timeout': '30', + 'variable_hangup_after_bridge': 'true', + 'variable_current_application_data': 'user/1000@172.99.99.99', + 'variable_current_application': 'bridge', + 'variable_dialed_user': '1000', + 'variable_dialed_domain': '172.99.99.99', + 'variable_originate_disposition': 'failure', + 'variable_DIALSTATUS': 'INVALIDARGS', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + eventName: 'CHANNEL_PROGRESS_MEDIA', + sequence: 39872, + firedAt: new Date(1749847572249), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + channelState: 'CS_EXECUTE', + channelStateNumber: '4', + channelCallState: 'RINGING', + channelUsername: '1001', + answerState: 'early', + callDirection: 'inbound', + channelHitDialplan: 'true', + legs: { + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Caller', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelProgressTime: new Date(1749847569148), + channelProgressMediaTime: new Date(1749847572249), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_PROGRESS_MEDIA', + 'Event-Date-Timestamp': '1749847572249573', + 'Event-Sequence': '39872', + }, + variables: { + direction: 'inbound', + uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + call_uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + dialed_extension: '1000', + call_timeout: '30', + hangup_after_bridge: 'true', + current_application_data: 'user/1000@172.99.99.99', + current_application: 'bridge', + dialed_user: '1000', + dialed_domain: '172.99.99.99', + originate_disposition: 'failure', + DIALSTATUS: 'INVALIDARGS', + }, + codecs: { + read: { name: 'opus', rate: '48000' }, + write: { nme: 'opus', rate: '48000' }, + }, + presenceCallDirection: 'inbound', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse outbound CHANNEL_OUTGOING event', () => { + const eventName = 'CHANNEL_OUTGOING'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_OUTGOING', + 'Event-Date-Timestamp': '1749847572928202', + 'Event-Sequence': '39881', + 'Channel-State': 'CS_CONSUME_MEDIA', + 'Channel-Call-State': 'ACTIVE', + 'Channel-State-Number': '7', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Presence-ID': '1000@172.99.99.99', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'answered', + 'Channel-Read-Codec-Name': 'opus', + 'Channel-Read-Codec-Rate': '48000', + 'Channel-Read-Codec-Bit-Rate': '0', + 'Channel-Write-Codec-Name': 'opus', + 'Channel-Write-Codec-Rate': '48000', + 'Channel-Write-Codec-Bit-Rate': '0', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '1749847572228165', + 'Caller-Channel-Progress-Time': '1749847569148194', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Callee-ID-Name': 'Outbound Call', + 'Other-Leg-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '0', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CHANNEL_OUTGOING', + sequence: 39881, + firedAt: new Date(1749847572928), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_CONSUME_MEDIA', + channelStateNumber: '7', + channelCallState: 'ACTIVE', + channelUsername: '1000', + answerState: 'answered', + callDirection: 'outbound', + channelHitDialplan: 'false', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelAnsweredTime: new Date(1749847572228), + channelProgressTime: new Date(1749847569148), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_OUTGOING', + 'Event-Date-Timestamp': '1749847572928202', + 'Event-Sequence': '39881', + }, + codecs: { + read: { name: 'opus', rate: '48000' }, + write: { nme: 'opus', rate: '48000' }, + }, + presenceCallDirection: 'outbound', + channelPresenceId: '1000@172.99.99.99', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse CHANNEL_BRIDGE event', () => { + const eventName = 'CHANNEL_BRIDGE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_BRIDGE', + 'Event-Date-Timestamp': '1749847572928202', + 'Event-Sequence': '39882', + 'Bridge-A-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Bridge-B-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Channel-State': 'CS_EXECUTE', + 'Channel-Call-State': 'ACTIVE', + 'Channel-State-Number': '4', + 'Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Call-Direction': 'inbound', + 'Presence-Call-Direction': 'inbound', + 'Channel-HIT-Dialplan': 'true', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'answered', + 'Channel-Read-Codec-Name': 'opus', + 'Channel-Read-Codec-Rate': '48000', + 'Channel-Read-Codec-Bit-Rate': '0', + 'Channel-Write-Codec-Name': 'opus', + 'Channel-Write-Codec-Rate': '48000', + 'Channel-Write-Codec-Bit-Rate': '0', + 'Caller-Direction': 'inbound', + 'Caller-Logical-Direction': 'inbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000', + 'Caller-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '1749847572928202', + 'Caller-Channel-Progress-Time': '1749847569148194', + 'Caller-Channel-Progress-Media-Time': '1749847572249573', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '1749847572928202', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originatee', + 'Other-Leg-Direction': 'outbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Callee-ID-Name': 'Outbound Call', + 'Other-Leg-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Other-Leg-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Other-Leg-Profile-Created-Time': '1749847568969044', + 'Other-Leg-Channel-Created-Time': '1749847568969044', + 'Other-Leg-Channel-Answered-Time': '1749847572228165', + 'Other-Leg-Channel-Progress-Time': '1749847569148194', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + 'variable_direction': 'inbound', + 'variable_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_session_id': '75', + 'variable_call_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_dialed_extension': '1000', + 'variable_call_timeout': '30', + 'variable_hangup_after_bridge': 'true', + 'variable_current_application_data': 'user/1000@172.99.99.99', + 'variable_current_application': 'bridge', + 'variable_dialed_user': '1000', + 'variable_dialed_domain': '172.99.99.99', + 'variable_originated_legs': [ + '31450e18-1531-46a0-9c10-4629110e3d23;Outbound Call;1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + '31450e18-1531-46a0-9c10-4629110e3d23;Outbound Call;1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + ], + 'variable_endpoint_disposition': 'ANSWER', + 'variable_originate_causes': ['31450e18-1531-46a0-9c10-4629110e3d23;NONE', '31450e18-1531-46a0-9c10-4629110e3d23;NONE'], + 'variable_originate_disposition': 'SUCCESS', + 'variable_DIALSTATUS': 'SUCCESS', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + eventName: 'CHANNEL_BRIDGE', + sequence: 39882, + firedAt: new Date(1749847572928), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + channelState: 'CS_EXECUTE', + channelStateNumber: '4', + channelCallState: 'ACTIVE', + channelUsername: '1001', + answerState: 'answered', + callDirection: 'inbound', + channelHitDialplan: 'true', + bridgeUniqueIds: ['ebc91302-1b79-4ff3-ac6b-841385e9ea03', '31450e18-1531-46a0-9c10-4629110e3d23'], + legs: { + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Caller', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelAnsweredTime: new Date(1749847572928), + channelBridgedTime: new Date(1749847572928), + channelProgressMediaTime: new Date(1749847572249), + channelProgressTime: new Date(1749847569148), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Other-Leg', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + type: 'originatee', + direction: 'outbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelAnsweredTime: new Date(1749847572228), + channelProgressTime: new Date(1749847569148), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_BRIDGE', + 'Event-Date-Timestamp': '1749847572928202', + 'Event-Sequence': '39882', + }, + variables: { + direction: 'inbound', + uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + session_id: '75', + call_uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + dialed_extension: '1000', + call_timeout: '30', + hangup_after_bridge: 'true', + current_application_data: 'user/1000@172.99.99.99', + current_application: 'bridge', + dialed_user: '1000', + dialed_domain: '172.99.99.99', + originated_legs: [ + '31450e18-1531-46a0-9c10-4629110e3d23;Outbound Call;1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + '31450e18-1531-46a0-9c10-4629110e3d23;Outbound Call;1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + ], + endpoint_disposition: 'ANSWER', + originate_causes: ['31450e18-1531-46a0-9c10-4629110e3d23;NONE', '31450e18-1531-46a0-9c10-4629110e3d23;NONE'], + originate_disposition: 'SUCCESS', + DIALSTATUS: 'SUCCESS', + }, + codecs: { + read: { name: 'opus', rate: '48000' }, + write: { nme: 'opus', rate: '48000' }, + }, + presenceCallDirection: 'inbound', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + it('should parse outbound CS_EXCHANGE_MEDIA CHANNEL_STATE event', () => { + const eventName = 'CHANNEL_STATE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847572928202', + 'Event-Sequence': '39883', + 'Channel-State': 'CS_EXCHANGE_MEDIA', + 'Channel-Call-State': 'ACTIVE', + 'Channel-State-Number': '5', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Presence-ID': '1000@172.99.99.99', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'answered', + 'Channel-Read-Codec-Name': 'opus', + 'Channel-Read-Codec-Rate': '48000', + 'Channel-Read-Codec-Bit-Rate': '0', + 'Channel-Write-Codec-Name': 'opus', + 'Channel-Write-Codec-Rate': '48000', + 'Channel-Write-Codec-Bit-Rate': '0', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '1749847572228165', + 'Caller-Channel-Progress-Time': '1749847569148194', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '1749847572928202', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Callee-ID-Name': 'Outbound Call', + 'Other-Leg-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '0', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CHANNEL_STATE', + sequence: 39883, + firedAt: new Date(1749847572928), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_EXCHANGE_MEDIA', + channelStateNumber: '5', + channelCallState: 'ACTIVE', + channelUsername: '1000', + answerState: 'answered', + callDirection: 'outbound', + channelHitDialplan: 'false', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelAnsweredTime: new Date(1749847572228), + channelBridgedTime: new Date(1749847572928), + channelProgressTime: new Date(1749847569148), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_STATE', + 'Event-Date-Timestamp': '1749847572928202', + 'Event-Sequence': '39883', + }, + codecs: { + read: { name: 'opus', rate: '48000' }, + write: { nme: 'opus', rate: '48000' }, + }, + presenceCallDirection: 'outbound', + channelPresenceId: '1000@172.99.99.99', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse outbound CALL_UPDATE event', () => { + const eventName = 'CALL_UPDATE'; + const eventData = createEventData({ + 'Event-Name': 'CALL_UPDATE', + 'Event-Date-Timestamp': '1749847572928202', + 'Event-Sequence': '39884', + 'Direction': 'SEND', + 'Sent-Callee-ID-Name': '1001', + 'Sent-Callee-ID-Number': '1001', + 'Bridged-To': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Channel-State': 'CS_EXCHANGE_MEDIA', + 'Channel-Call-State': 'ACTIVE', + 'Channel-State-Number': '5', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Presence-ID': '1000@172.99.99.99', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'answered', + 'Channel-Read-Codec-Name': 'opus', + 'Channel-Read-Codec-Rate': '48000', + 'Channel-Read-Codec-Bit-Rate': '0', + 'Channel-Write-Codec-Name': 'opus', + 'Channel-Write-Codec-Rate': '48000', + 'Channel-Write-Codec-Bit-Rate': '0', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '1749847572228165', + 'Caller-Channel-Progress-Time': '1749847569148194', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '1749847572928202', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Callee-ID-Name': 'Outbound Call', + 'Other-Leg-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '0', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + 'variable_direction': 'outbound', + 'variable_is_outbound': 'true', + 'variable_uuid': '31450e18-1531-46a0-9c10-4629110e3d23', + 'variable_dialed_user': '1000', + 'variable_dialed_domain': '172.99.99.99', + 'variable_sip_invite_domain': '172.99.99.99', + 'variable_effective_caller_id_name': 'Extension 1000', + 'variable_effective_caller_id_number': '1000', + 'variable_outbound_caller_id_name': 'FreeSWITCH', + 'variable_outbound_caller_id_number': '0000000000', + 'variable_originate_early_media': 'true', + 'variable_originating_leg_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_originate_endpoint': 'user', + 'variable_call_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_bridge_to': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_last_bridge_to': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_bridge_channel': 'sofia/internal/1001@99.219.99.169:7443', + 'variable_bridge_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_signal_bond': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_last_sent_callee_id_name': '1001', + 'variable_last_sent_callee_id_number': '1001', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CALL_UPDATE', + sequence: 39884, + firedAt: new Date(1749847572928), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_EXCHANGE_MEDIA', + channelStateNumber: '5', + channelCallState: 'ACTIVE', + channelUsername: '1000', + answerState: 'answered', + callDirection: 'outbound', + channelHitDialplan: 'false', + bridgedTo: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelAnsweredTime: new Date(1749847572228), + channelBridgedTime: new Date(1749847572928), + channelProgressTime: new Date(1749847569148), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CALL_UPDATE', + 'Event-Date-Timestamp': '1749847572928202', + 'Event-Sequence': '39884', + }, + variables: { + direction: 'outbound', + is_outbound: 'true', + uuid: '31450e18-1531-46a0-9c10-4629110e3d23', + dialed_user: '1000', + dialed_domain: '172.99.99.99', + sip_invite_domain: '172.99.99.99', + effective_caller_id_name: 'Extension 1000', + effective_caller_id_number: '1000', + outbound_caller_id_name: 'FreeSWITCH', + outbound_caller_id_number: '0000000000', + originate_early_media: 'true', + originating_leg_uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + originate_endpoint: 'user', + call_uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + bridge_to: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + last_bridge_to: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + bridge_channel: 'sofia/internal/1001@99.219.99.169:7443', + bridge_uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + signal_bond: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + last_sent_callee_id_name: '1001', + last_sent_callee_id_number: '1001', + }, + raw: { + 'Direction': 'SEND', + 'Sent-Callee-ID-Name': '1001', + 'Sent-Callee-ID-Number': '1001', + }, + codecs: { + read: { name: 'opus', rate: '48000' }, + write: { nme: 'opus', rate: '48000' }, + }, + presenceCallDirection: 'outbound', + channelPresenceId: '1000@172.99.99.99', + caller: '1001', + callee: '1000', + }); + }); + + it('should parse CALL_UPDATE event', () => { + const eventName = 'CALL_UPDATE'; + const eventData = createEventData({ + 'Event-Name': 'CALL_UPDATE', + 'Event-Date-Timestamp': '1749847572928202', + 'Event-Sequence': '39886', + 'Direction': 'SEND', + 'Sent-Callee-ID-Name': 'Outbound Call', + 'Sent-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Bridged-To': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Channel-State': 'CS_EXECUTE', + 'Channel-Call-State': 'ACTIVE', + 'Channel-State-Number': '4', + 'Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Call-Direction': 'inbound', + 'Presence-Call-Direction': 'inbound', + 'Channel-HIT-Dialplan': 'true', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'answered', + 'Channel-Read-Codec-Name': 'opus', + 'Channel-Read-Codec-Rate': '48000', + 'Channel-Read-Codec-Bit-Rate': '0', + 'Channel-Write-Codec-Name': 'opus', + 'Channel-Write-Codec-Rate': '48000', + 'Channel-Write-Codec-Bit-Rate': '0', + 'Caller-Direction': 'inbound', + 'Caller-Logical-Direction': 'inbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000', + 'Caller-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '1749847572928202', + 'Caller-Channel-Progress-Time': '1749847569148194', + 'Caller-Channel-Progress-Media-Time': '1749847572249573', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '1749847572928202', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originatee', + 'Other-Leg-Direction': 'outbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Callee-ID-Name': 'Outbound Call', + 'Other-Leg-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Other-Leg-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Other-Leg-Profile-Created-Time': '1749847568969044', + 'Other-Leg-Channel-Created-Time': '1749847568969044', + 'Other-Leg-Channel-Answered-Time': '1749847572228165', + 'Other-Leg-Channel-Progress-Time': '1749847569148194', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + 'variable_direction': 'inbound', + 'variable_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_session_id': '75', + 'variable_sip_from_user': '1001', + 'variable_sip_from_port': '7443', + 'variable_sip_from_uri': '1001@99.219.99.169:7443', + 'variable_sip_from_host': '99.219.99.169', + 'variable_channel_name': 'sofia/internal/1001@99.219.99.169:7443', + 'variable_sip_local_network_addr': '99.219.99.169', + 'variable_sip_network_ip': '177.99.99.183', + 'variable_sip_network_port': '60286', + 'variable_sip_invite_stamp': '1749847568969044', + 'variable_sip_received_ip': '177.99.99.183', + 'variable_sip_received_port': '60286', + 'variable_sip_via_protocol': 'wss', + 'variable_sip_authorized': 'true', + 'variable_sip_acl_authed_by': 'any_v4.auto', + 'variable_sip_from_user_stripped': '1001', + 'variable_sofia_profile_name': 'internal', + 'variable_sofia_profile_url': 'sip:mod_sofia@99.219.99.169:5060', + 'variable_recovery_profile_name': 'internal', + 'variable_sip_allow': 'ACK, CANCEL, INVITE, MESSAGE, BYE, OPTIONS, INFO, NOTIFY, REFER', + 'variable_sip_req_user': '1000', + 'variable_sip_req_port': '7443', + 'variable_sip_req_uri': '1000@99.219.99.169:7443', + 'variable_sip_req_host': '99.219.99.169', + 'variable_sip_to_user': '1000', + 'variable_sip_to_port': '7443', + 'variable_sip_to_uri': '1000@99.219.99.169:7443', + 'variable_sip_to_host': '99.219.99.169', + 'variable_sip_contact_params': 'transport=ws;ob', + 'variable_sip_contact_user': '1001-user1-j7a419rq', + 'variable_sip_contact_uri': '1001-user1-j7a419rq@localhost', + 'variable_sip_contact_host': 'localhost', + 'variable_sip_user_agent': 'SIP.js/0.21.1', + 'variable_sip_via_host': 'localhost', + 'variable_sip_via_rport': '60286', + 'variable_max_forwards': '70', + 'variable_ep_codec_string': + 'mod_opus.opus@48000h@20i@2c,mod_spandsp.G722@8000h@20i@64000b,CORE_PCM_MODULE.PCMU@8000h@20i@64000b,CORE_PCM_MODULE.PCMA@8000h@20i@64000b', + 'variable_call_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_dialed_extension': '1000', + 'variable_call_timeout': '30', + 'variable_hangup_after_bridge': 'true', + 'variable_current_application_data': 'user/1000@172.99.99.99', + 'variable_current_application': 'bridge', + 'variable_dialed_user': '1000', + 'variable_dialed_domain': '172.99.99.99', + 'variable_originated_legs': [ + '31450e18-1531-46a0-9c10-4629110e3d23;Outbound Call;1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + '31450e18-1531-46a0-9c10-4629110e3d23;Outbound Call;1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + ], + 'variable_video_media_flow': 'inactive', + 'variable_text_media_flow': 'inactive', + 'variable_rtp_use_codec_string': 'OPUS,G722,PCMU,PCMA,H264,VP8', + 'variable_remote_video_media_flow': 'inactive', + 'variable_remote_text_media_flow': 'inactive', + 'variable_remote_audio_media_flow': 'sendrecv', + 'variable_audio_media_flow': 'sendrecv', + 'variable_rtp_audio_recv_pt': '109', + 'variable_rtp_use_codec_name': 'opus', + 'variable_rtp_use_codec_fmtp': 'maxplaybackrate=48000;stereo=1;useinbandfec=1', + 'variable_rtp_use_codec_rate': '48000', + 'variable_rtp_use_codec_ptime': '20', + 'variable_rtp_use_codec_channels': '2', + 'variable_rtp_last_audio_codec_string': 'opus@48000h@20i@2c', + 'variable_read_codec': 'opus', + 'variable_original_read_codec': 'opus', + 'variable_read_rate': '48000', + 'variable_original_read_rate': '48000', + 'variable_write_codec': 'opus', + 'variable_write_rate': '48000', + 'variable_remote_audio_ip': '192.168.2.192', + 'variable_remote_audio_port': '54922', + 'variable_remote_audio_rtcp_ip': '192.168.2.192', + 'variable_remote_audio_rtcp_port': '54922', + 'variable_dtmf_type': 'rfc2833', + 'variable_local_media_ip': '172.99.99.99', + 'variable_local_media_port': '16388', + 'variable_advertised_media_ip': '99.219.99.169', + 'variable_rtp_use_timer_name': 'soft', + 'variable_rtp_use_pt': '109', + 'variable_rtp_use_ssrc': '1219061913', + 'variable_rtp_2833_send_payload': '101', + 'variable_rtp_2833_recv_payload': '101', + 'variable_remote_media_ip': '192.168.2.192', + 'variable_remote_media_port': '54922', + 'variable_sip_nat_detected': 'true', + 'variable_sip_to_tag': 'rUtyD23mXy9Xj', + 'variable_sip_from_tag': 'tgtj08fqre', + 'variable_sip_cseq': '1', + 'variable_sip_call_id': '8lapd1dj47tfei96vl6q', + 'variable_sip_full_via': 'SIP/2.0/WSS localhost;branch=z9hG4bK7118990;received=177.99.99.183;rport=60286', + 'variable_sip_full_from': ';tag=tgtj08fqre', + 'variable_sip_full_to': ';tag=rUtyD23mXy9Xj', + 'variable_send_silence_when_idle': '-1', + 'variable_rtp_has_crypto': 'srtp:dtls:AES_CM_128_HMAC_SHA1_80', + 'variable_endpoint_disposition': 'ANSWER', + 'variable_originate_causes': ['31450e18-1531-46a0-9c10-4629110e3d23;NONE', '31450e18-1531-46a0-9c10-4629110e3d23;NONE'], + 'variable_originate_disposition': 'SUCCESS', + 'variable_DIALSTATUS': 'SUCCESS', + 'variable_bridge_to': '31450e18-1531-46a0-9c10-4629110e3d23', + 'variable_last_bridge_to': '31450e18-1531-46a0-9c10-4629110e3d23', + 'variable_bridge_channel': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'variable_bridge_uuid': '31450e18-1531-46a0-9c10-4629110e3d23', + 'variable_signal_bond': '31450e18-1531-46a0-9c10-4629110e3d23', + 'variable_last_sent_callee_id_name': '1001', + 'variable_last_sent_callee_id_number': '1001', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + eventName: 'CALL_UPDATE', + sequence: 39886, + firedAt: new Date(1749847572928), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + channelState: 'CS_EXECUTE', + channelStateNumber: '4', + channelCallState: 'ACTIVE', + channelUsername: '1001', + answerState: 'answered', + callDirection: 'inbound', + channelHitDialplan: 'true', + bridgedTo: '31450e18-1531-46a0-9c10-4629110e3d23', + legs: { + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Caller', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelProgressTime: new Date(1749847569148), + channelAnsweredTime: new Date(1749847572928), + channelProgressMediaTime: new Date(1749847572249), + channelBridgedTime: new Date(1749847572928), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Other-Leg', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + type: 'originatee', + direction: 'outbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelProgressTime: new Date(1749847569148), + channelAnsweredTime: new Date(1749847572228), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CALL_UPDATE', + 'Event-Date-Timestamp': '1749847572928202', + 'Event-Sequence': '39886', + }, + variables: { + direction: 'inbound', + uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + session_id: '75', + sip_from_user: '1001', + sip_from_port: '7443', + sip_from_uri: '1001@99.219.99.169:7443', + sip_from_host: '99.219.99.169', + channel_name: 'sofia/internal/1001@99.219.99.169:7443', + sip_local_network_addr: '99.219.99.169', + sip_network_ip: '177.99.99.183', + sip_network_port: '60286', + sip_invite_stamp: '1749847568969044', + sip_received_ip: '177.99.99.183', + sip_received_port: '60286', + sip_via_protocol: 'wss', + sip_authorized: 'true', + sip_acl_authed_by: 'any_v4.auto', + sip_from_user_stripped: '1001', + sofia_profile_name: 'internal', + sofia_profile_url: 'sip:mod_sofia@99.219.99.169:5060', + recovery_profile_name: 'internal', + sip_allow: 'ACK, CANCEL, INVITE, MESSAGE, BYE, OPTIONS, INFO, NOTIFY, REFER', + sip_req_user: '1000', + sip_req_port: '7443', + sip_req_uri: '1000@99.219.99.169:7443', + sip_req_host: '99.219.99.169', + sip_to_user: '1000', + sip_to_port: '7443', + sip_to_uri: '1000@99.219.99.169:7443', + sip_to_host: '99.219.99.169', + sip_contact_params: 'transport=ws;ob', + sip_contact_user: '1001-user1-j7a419rq', + sip_contact_uri: '1001-user1-j7a419rq@localhost', + sip_contact_host: 'localhost', + sip_user_agent: 'SIP.js/0.21.1', + sip_via_host: 'localhost', + sip_via_rport: '60286', + max_forwards: '70', + ep_codec_string: + 'mod_opus.opus@48000h@20i@2c,mod_spandsp.G722@8000h@20i@64000b,CORE_PCM_MODULE.PCMU@8000h@20i@64000b,CORE_PCM_MODULE.PCMA@8000h@20i@64000b', + call_uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + dialed_extension: '1000', + call_timeout: '30', + hangup_after_bridge: 'true', + current_application_data: 'user/1000@172.99.99.99', + current_application: 'bridge', + dialed_user: '1000', + dialed_domain: '172.99.99.99', + originated_legs: [ + '31450e18-1531-46a0-9c10-4629110e3d23;Outbound Call;1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + '31450e18-1531-46a0-9c10-4629110e3d23;Outbound Call;1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + ], + video_media_flow: 'inactive', + text_media_flow: 'inactive', + rtp_use_codec_string: 'OPUS,G722,PCMU,PCMA,H264,VP8', + remote_video_media_flow: 'inactive', + remote_text_media_flow: 'inactive', + remote_audio_media_flow: 'sendrecv', + audio_media_flow: 'sendrecv', + rtp_audio_recv_pt: '109', + rtp_use_codec_name: 'opus', + rtp_use_codec_fmtp: 'maxplaybackrate=48000;stereo=1;useinbandfec=1', + rtp_use_codec_rate: '48000', + rtp_use_codec_ptime: '20', + rtp_use_codec_channels: '2', + rtp_last_audio_codec_string: 'opus@48000h@20i@2c', + read_codec: 'opus', + original_read_codec: 'opus', + read_rate: '48000', + original_read_rate: '48000', + write_codec: 'opus', + write_rate: '48000', + remote_audio_ip: '192.168.2.192', + remote_audio_port: '54922', + remote_audio_rtcp_ip: '192.168.2.192', + remote_audio_rtcp_port: '54922', + dtmf_type: 'rfc2833', + local_media_ip: '172.99.99.99', + local_media_port: '16388', + advertised_media_ip: '99.219.99.169', + rtp_use_timer_name: 'soft', + rtp_use_pt: '109', + rtp_use_ssrc: '1219061913', + rtp_2833_send_payload: '101', + rtp_2833_recv_payload: '101', + remote_media_ip: '192.168.2.192', + remote_media_port: '54922', + sip_nat_detected: 'true', + sip_to_tag: 'rUtyD23mXy9Xj', + sip_from_tag: 'tgtj08fqre', + sip_cseq: '1', + sip_call_id: '8lapd1dj47tfei96vl6q', + sip_full_via: 'SIP/2.0/WSS localhost;branch=z9hG4bK7118990;received=177.99.99.183;rport=60286', + sip_full_from: ';tag=tgtj08fqre', + sip_full_to: ';tag=rUtyD23mXy9Xj', + send_silence_when_idle: '-1', + rtp_has_crypto: 'srtp:dtls:AES_CM_128_HMAC_SHA1_80', + endpoint_disposition: 'ANSWER', + originate_causes: ['31450e18-1531-46a0-9c10-4629110e3d23;NONE', '31450e18-1531-46a0-9c10-4629110e3d23;NONE'], + originate_disposition: 'SUCCESS', + DIALSTATUS: 'SUCCESS', + bridge_to: '31450e18-1531-46a0-9c10-4629110e3d23', + last_bridge_to: '31450e18-1531-46a0-9c10-4629110e3d23', + bridge_channel: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + bridge_uuid: '31450e18-1531-46a0-9c10-4629110e3d23', + signal_bond: '31450e18-1531-46a0-9c10-4629110e3d23', + last_sent_callee_id_name: '1001', + last_sent_callee_id_number: '1001', + }, + raw: { + 'Direction': 'SEND', + 'Sent-Callee-ID-Name': 'Outbound Call', + 'Sent-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + }, + codecs: { + read: { name: 'opus', rate: '48000' }, + write: { nme: 'opus', rate: '48000' }, + }, + presenceCallDirection: 'inbound', + caller: '1001', + callee: '1000', + }); + }); + + it('should parse outbound CHANNEL_HANGUP event', () => { + const eventName = 'CHANNEL_HANGUP'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_HANGUP', + 'Event-Date-Timestamp': '1749847575568205', + 'Event-Sequence': '39893', + 'Channel-State': 'CS_EXCHANGE_MEDIA', + 'Channel-Call-State': 'ACTIVE', + 'Channel-State-Number': '10', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Presence-ID': '1000@172.99.99.99', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'hangup', + 'Hangup-Cause': 'NORMAL_CLEARING', + 'Channel-Read-Codec-Name': 'opus', + 'Channel-Read-Codec-Rate': '48000', + 'Channel-Read-Codec-Bit-Rate': '0', + 'Channel-Write-Codec-Name': 'opus', + 'Channel-Write-Codec-Rate': '48000', + 'Channel-Write-Codec-Bit-Rate': '0', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '1749847572228165', + 'Caller-Channel-Progress-Time': '1749847569148194', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '1749847572928202', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Callee-ID-Name': 'Outbound Call', + 'Other-Leg-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '0', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + 'variable_direction': 'outbound', + 'variable_is_outbound': 'true', + 'variable_uuid': '31450e18-1531-46a0-9c10-4629110e3d23', + 'variable_session_id': '76', + 'variable_dialed_user': '1000', + 'variable_dialed_domain': '172.99.99.99', + 'variable_sip_invite_domain': '172.99.99.99', + 'variable_user_context': 'default', + 'variable_effective_caller_id_name': 'Extension 1000', + 'variable_effective_caller_id_number': '1000', + 'variable_outbound_caller_id_name': 'FreeSWITCH', + 'variable_outbound_caller_id_number': '0000000000', + 'variable_callgroup': 'techsupport', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CHANNEL_HANGUP', + sequence: 39893, + firedAt: new Date(1749847575568), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_EXCHANGE_MEDIA', + channelStateNumber: '10', + channelCallState: 'ACTIVE', + channelUsername: '1000', + answerState: 'hangup', + callDirection: 'outbound', + channelHitDialplan: 'false', + hangupCause: 'NORMAL_CLEARING', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelProgressTime: new Date(1749847569148), + channelAnsweredTime: new Date(1749847572228), + channelBridgedTime: new Date(1749847572928), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_HANGUP', + 'Event-Date-Timestamp': '1749847575568205', + 'Event-Sequence': '39893', + }, + variables: { + direction: 'outbound', + is_outbound: 'true', + uuid: '31450e18-1531-46a0-9c10-4629110e3d23', + session_id: '76', + dialed_user: '1000', + dialed_domain: '172.99.99.99', + sip_invite_domain: '172.99.99.99', + user_context: 'default', + effective_caller_id_name: 'Extension 1000', + effective_caller_id_number: '1000', + outbound_caller_id_name: 'FreeSWITCH', + outbound_caller_id_number: '0000000000', + callgroup: 'techsupport', + }, + codecs: { + read: { name: 'opus', rate: '48000' }, + write: { nme: 'opus', rate: '48000' }, + }, + presenceCallDirection: 'outbound', + channelPresenceId: '1000@172.99.99.99', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse outbound CHANNEL_HANGUP_COMPLETE event', () => { + const eventName = 'CHANNEL_HANGUP_COMPLETE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_HANGUP_COMPLETE', + 'Event-Date-Timestamp': '1749847575568205', + 'Event-Sequence': '39898', + 'Hangup-Cause': 'NORMAL_CLEARING', + 'Channel-State': 'CS_REPORTING', + 'Channel-Call-State': 'HANGUP', + 'Channel-State-Number': '11', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Presence-ID': '1000@172.99.99.99', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'hangup', + 'Channel-Read-Codec-Name': 'opus', + 'Channel-Read-Codec-Rate': '48000', + 'Channel-Read-Codec-Bit-Rate': '0', + 'Channel-Write-Codec-Name': 'opus', + 'Channel-Write-Codec-Rate': '48000', + 'Channel-Write-Codec-Bit-Rate': '0', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '1749847572228165', + 'Caller-Channel-Progress-Time': '1749847569148194', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '1749847575568205', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '1749847572928202', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Callee-ID-Name': 'Outbound Call', + 'Other-Leg-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '0', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + 'variable_direction': 'outbound', + 'variable_is_outbound': 'true', + 'variable_uuid': '31450e18-1531-46a0-9c10-4629110e3d23', + 'variable_dialed_user': '1000', + 'variable_dialed_domain': '172.99.99.99', + 'variable_user_context': 'default', + 'variable_effective_caller_id_name': 'Extension 1000', + 'variable_effective_caller_id_number': '1000', + 'variable_outbound_caller_id_name': 'FreeSWITCH', + 'variable_outbound_caller_id_number': '0000000000', + 'variable_callgroup': 'techsupport', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CHANNEL_HANGUP_COMPLETE', + sequence: 39898, + firedAt: new Date(1749847575568), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_REPORTING', + channelStateNumber: '11', + channelCallState: 'HANGUP', + channelUsername: '1000', + answerState: 'hangup', + callDirection: 'outbound', + channelHitDialplan: 'false', + hangupCause: 'NORMAL_CLEARING', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelProgressTime: new Date(1749847569148), + channelAnsweredTime: new Date(1749847572228), + channelBridgedTime: new Date(1749847572928), + channelHangupTime: new Date(1749847575568), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_HANGUP_COMPLETE', + 'Event-Date-Timestamp': '1749847575568205', + 'Event-Sequence': '39898', + }, + variables: { + direction: 'outbound', + is_outbound: 'true', + uuid: '31450e18-1531-46a0-9c10-4629110e3d23', + dialed_user: '1000', + dialed_domain: '172.99.99.99', + user_context: 'default', + effective_caller_id_name: 'Extension 1000', + effective_caller_id_number: '1000', + outbound_caller_id_name: 'FreeSWITCH', + outbound_caller_id_number: '0000000000', + callgroup: 'techsupport', + }, + codecs: { + read: { name: 'opus', rate: '48000' }, + write: { nme: 'opus', rate: '48000' }, + }, + presenceCallDirection: 'outbound', + channelPresenceId: '1000@172.99.99.99', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + it('should parse CHANNEL_UNBRIDGE event', () => { + const eventName = 'CHANNEL_UNBRIDGE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_UNBRIDGE', + 'Event-Date-Timestamp': '1749847575588235', + 'Event-Sequence': '39899', + 'Bridge-A-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Bridge-B-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Channel-State': 'CS_EXECUTE', + 'Channel-Call-State': 'ACTIVE', + 'Channel-State-Number': '4', + 'Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Call-Direction': 'inbound', + 'Presence-Call-Direction': 'inbound', + 'Channel-HIT-Dialplan': 'true', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'answered', + 'Channel-Read-Codec-Name': 'opus', + 'Channel-Read-Codec-Rate': '48000', + 'Channel-Read-Codec-Bit-Rate': '0', + 'Channel-Write-Codec-Name': 'opus', + 'Channel-Write-Codec-Rate': '48000', + 'Channel-Write-Codec-Bit-Rate': '0', + 'Caller-Direction': 'inbound', + 'Caller-Logical-Direction': 'inbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000', + 'Caller-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '1749847572928202', + 'Caller-Channel-Progress-Time': '1749847569148194', + 'Caller-Channel-Progress-Media-Time': '1749847572249573', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '1749847572928202', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originatee', + 'Other-Leg-Direction': 'outbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Callee-ID-Name': 'Outbound Call', + 'Other-Leg-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Other-Leg-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Other-Leg-Profile-Created-Time': '1749847568969044', + 'Other-Leg-Channel-Created-Time': '1749847568969044', + 'Other-Leg-Channel-Answered-Time': '1749847572228165', + 'Other-Leg-Channel-Progress-Time': '1749847569148194', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + 'variable_direction': 'inbound', + 'variable_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_call_uuid': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'variable_dialed_extension': '1000', + 'variable_call_timeout': '30', + 'variable_hangup_after_bridge': 'true', + 'variable_current_application_data': 'user/1000@172.99.99.99', + 'variable_current_application': 'bridge', + 'variable_dialed_user': '1000', + 'variable_dialed_domain': '172.99.99.99', + 'variable_originated_legs': [ + '31450e18-1531-46a0-9c10-4629110e3d23;Outbound Call;1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + '31450e18-1531-46a0-9c10-4629110e3d23;Outbound Call;1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + ], + 'variable_endpoint_disposition': 'ANSWER', + 'variable_originate_causes': ['31450e18-1531-46a0-9c10-4629110e3d23;NONE', '31450e18-1531-46a0-9c10-4629110e3d23;NONE'], + 'variable_originate_disposition': 'SUCCESS', + 'variable_DIALSTATUS': 'SUCCESS', + 'variable_last_bridge_to': '31450e18-1531-46a0-9c10-4629110e3d23', + 'variable_bridge_channel': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'variable_bridge_uuid': '31450e18-1531-46a0-9c10-4629110e3d23', + 'variable_signal_bond': '31450e18-1531-46a0-9c10-4629110e3d23', + 'variable_last_sent_callee_id_name': 'Outbound Call', + 'variable_last_sent_callee_id_number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'variable_sip_hangup_phrase': 'OK', + 'variable_last_bridge_hangup_cause': 'NORMAL_CLEARING', + 'variable_last_bridge_proto_specific_hangup_cause': 'sip:200', + 'variable_bridge_hangup_cause': 'NORMAL_CLEARING', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + eventName: 'CHANNEL_UNBRIDGE', + sequence: 39899, + firedAt: new Date(1749847575588), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + channelState: 'CS_EXECUTE', + channelStateNumber: '4', + channelCallState: 'ACTIVE', + channelUsername: '1001', + answerState: 'answered', + callDirection: 'inbound', + channelHitDialplan: 'true', + bridgeUniqueIds: ['ebc91302-1b79-4ff3-ac6b-841385e9ea03', '31450e18-1531-46a0-9c10-4629110e3d23'], + legs: { + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Caller', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelProgressTime: new Date(1749847569148), + channelAnsweredTime: new Date(1749847572928), + channelBridgedTime: new Date(1749847572928), + channelProgressMediaTime: new Date(1749847572249), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Other-Leg', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + type: 'originatee', + direction: 'outbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelProgressTime: new Date(1749847569148), + channelAnsweredTime: new Date(1749847572228), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_UNBRIDGE', + 'Event-Date-Timestamp': '1749847575588235', + 'Event-Sequence': '39899', + }, + variables: { + direction: 'inbound', + uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + call_uuid: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + dialed_extension: '1000', + call_timeout: '30', + hangup_after_bridge: 'true', + current_application_data: 'user/1000@172.99.99.99', + current_application: 'bridge', + dialed_user: '1000', + dialed_domain: '172.99.99.99', + originated_legs: [ + '31450e18-1531-46a0-9c10-4629110e3d23;Outbound Call;1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + '31450e18-1531-46a0-9c10-4629110e3d23;Outbound Call;1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + ], + endpoint_disposition: 'ANSWER', + originate_causes: ['31450e18-1531-46a0-9c10-4629110e3d23;NONE', '31450e18-1531-46a0-9c10-4629110e3d23;NONE'], + originate_disposition: 'SUCCESS', + DIALSTATUS: 'SUCCESS', + last_bridge_to: '31450e18-1531-46a0-9c10-4629110e3d23', + bridge_channel: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + bridge_uuid: '31450e18-1531-46a0-9c10-4629110e3d23', + signal_bond: '31450e18-1531-46a0-9c10-4629110e3d23', + last_sent_callee_id_name: 'Outbound Call', + last_sent_callee_id_number: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + sip_hangup_phrase: 'OK', + last_bridge_hangup_cause: 'NORMAL_CLEARING', + last_bridge_proto_specific_hangup_cause: 'sip:200', + bridge_hangup_cause: 'NORMAL_CLEARING', + }, + codecs: { + read: { name: 'opus', rate: '48000' }, + write: { nme: 'opus', rate: '48000' }, + }, + presenceCallDirection: 'inbound', + caller: '1001', + callee: '1000', + }); + + expect(result?.raw).toBeUndefined(); + }); + + it('should parse CHANNEL_DESTROY event', () => { + const eventName = 'CHANNEL_DESTROY'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_DESTROY', + 'Event-Date-Timestamp': '1749847575588235', + 'Event-Sequence': '39904', + 'Channel-State': 'CS_REPORTING', + 'Channel-Call-State': 'HANGUP', + 'Channel-State-Number': '12', + 'Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Call-Direction': 'outbound', + 'Presence-Call-Direction': 'outbound', + 'Channel-HIT-Dialplan': 'false', + 'Channel-Presence-ID': '1000@172.99.99.99', + 'Channel-Call-UUID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Answer-State': 'hangup', + 'Hangup-Cause': 'NORMAL_CLEARING', + 'Channel-Read-Codec-Name': 'opus', + 'Channel-Read-Codec-Rate': '48000', + 'Channel-Read-Codec-Bit-Rate': '0', + 'Channel-Write-Codec-Name': 'opus', + 'Channel-Write-Codec-Rate': '48000', + 'Channel-Write-Codec-Bit-Rate': '0', + 'Caller-Direction': 'outbound', + 'Caller-Logical-Direction': 'outbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Callee-ID-Name': 'Outbound Call', + 'Caller-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Caller-Unique-ID': '31450e18-1531-46a0-9c10-4629110e3d23', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '1749847572228165', + 'Caller-Channel-Progress-Time': '1749847569148194', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '1749847575568205', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '1749847572928202', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'Other-Type': 'originator', + 'Other-Leg-Direction': 'inbound', + 'Other-Leg-Logical-Direction': 'inbound', + 'Other-Leg-Username': '1001', + 'Other-Leg-Dialplan': 'XML', + 'Other-Leg-Caller-ID-Name': '1001', + 'Other-Leg-Caller-ID-Number': '1001', + 'Other-Leg-Orig-Caller-ID-Name': '1001', + 'Other-Leg-Orig-Caller-ID-Number': '1001', + 'Other-Leg-Callee-ID-Name': 'Outbound Call', + 'Other-Leg-Callee-ID-Number': '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + 'Other-Leg-Network-Addr': '177.99.99.183', + 'Other-Leg-ANI': '1001', + 'Other-Leg-Destination-Number': '1000', + 'Other-Leg-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Other-Leg-Source': 'mod_sofia', + 'Other-Leg-Context': 'default', + 'Other-Leg-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Other-Leg-Profile-Created-Time': '0', + 'Other-Leg-Channel-Created-Time': '0', + 'Other-Leg-Channel-Answered-Time': '0', + 'Other-Leg-Channel-Progress-Time': '0', + 'Other-Leg-Channel-Progress-Media-Time': '0', + 'Other-Leg-Channel-Hangup-Time': '0', + 'Other-Leg-Channel-Transfer-Time': '0', + 'Other-Leg-Channel-Resurrect-Time': '0', + 'Other-Leg-Channel-Bridged-Time': '0', + 'Other-Leg-Channel-Last-Hold': '0', + 'Other-Leg-Channel-Hold-Accum': '0', + 'Other-Leg-Screen-Bit': 'true', + 'Other-Leg-Privacy-Hide-Name': 'false', + 'Other-Leg-Privacy-Hide-Number': 'false', + 'variable_direction': 'outbound', + 'variable_is_outbound': 'true', + 'variable_uuid': '31450e18-1531-46a0-9c10-4629110e3d23', + 'variable_dialed_user': '1000', + 'variable_dialed_domain': '172.99.99.99', + 'variable_user_context': 'default', + 'variable_effective_caller_id_name': 'Extension 1000', + 'variable_effective_caller_id_number': '1000', + 'variable_outbound_caller_id_name': 'FreeSWITCH', + 'variable_outbound_caller_id_number': '0000000000', + }); + + const result = parseEventData(eventName, eventData); + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + eventName: 'CHANNEL_DESTROY', + sequence: 39904, + firedAt: new Date(1749847575588), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + channelState: 'CS_REPORTING', + channelStateNumber: '12', + channelCallState: 'HANGUP', + channelUsername: '1000', + answerState: 'hangup', + callDirection: 'outbound', + channelHitDialplan: 'false', + hangupCause: 'NORMAL_CLEARING', + legs: { + '31450e18-1531-46a0-9c10-4629110e3d23': { + legName: 'Caller', + uniqueId: '31450e18-1531-46a0-9c10-4629110e3d23', + direction: 'outbound', + logicalDirection: 'outbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa@localhost', + profiles: { + '1': { + channelCreatedTime: new Date(1749847568969), + profileCreatedTime: new Date(1749847568969), + channelProgressTime: new Date(1749847569148), + channelAnsweredTime: new Date(1749847572228), + channelBridgedTime: new Date(1749847572928), + channelHangupTime: new Date(1749847575568), + profileIndex: '1', + }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Other-Leg', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + type: 'originator', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + calleeName: 'Outbound Call', + calleeNumber: '1000-LJZ8A9MhHv4Eh6ZQH-h7720gsa', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { + 'Event-Name': 'CHANNEL_DESTROY', + 'Event-Date-Timestamp': '1749847575588235', + 'Event-Sequence': '39904', + }, + variables: { + direction: 'outbound', + is_outbound: 'true', + uuid: '31450e18-1531-46a0-9c10-4629110e3d23', + dialed_user: '1000', + dialed_domain: '172.99.99.99', + user_context: 'default', + effective_caller_id_name: 'Extension 1000', + effective_caller_id_number: '1000', + outbound_caller_id_name: 'FreeSWITCH', + outbound_caller_id_number: '0000000000', + }, + raw: { 'Channel-Call-UUID': '31450e18-1531-46a0-9c10-4629110e3d23' }, + codecs: { + read: { name: 'opus', rate: '48000' }, + write: { nme: 'opus', rate: '48000' }, + }, + presenceCallDirection: 'outbound', + channelPresenceId: '1000@172.99.99.99', + caller: '1001', + callee: '1000', + }); + }); + + it('should parse CHANNEL_CALLSTATE event', () => { + const eventName = 'CHANNEL_CALLSTATE'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_CALLSTATE', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39836', + 'Channel-State': 'CS_INIT', + 'Channel-Call-State': 'DOWN', + 'Channel-State-Number': '2', + 'Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Call-Direction': 'inbound', + 'Presence-Call-Direction': 'inbound', + 'Channel-HIT-Dialplan': 'true', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'inbound', + 'Caller-Logical-Direction': 'inbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000', + 'Caller-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '0', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'variable_direction': 'inbound', + 'variable_ep_codec_string': + 'mod_opus.opus@48000h@20i@2c,mod_spandsp.G722@8000h@20i@64000b,CORE_PCM_MODULE.PCMU@8000h@20i@64000b,CORE_PCM_MODULE.PCMA@8000h@20i@64000b', + 'variable_endpoint_disposition': 'DELAYED NEGOTIATION', + }); + + const result = parseEventData(eventName, eventData); + + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + eventName: 'CHANNEL_CALLSTATE', + sequence: 39836, + firedAt: new Date(1749847568969), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + channelState: 'CS_INIT', + channelStateNumber: '2', + channelCallState: 'DOWN', + channelUsername: '1001', + answerState: 'ringing', + callDirection: 'inbound', + channelHitDialplan: 'true', + legs: { + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Caller', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { profileIndex: '1', profileCreatedTime: new Date(1749847568969), channelCreatedTime: new Date(1749847568969) }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { 'Event-Name': 'CHANNEL_CALLSTATE', 'Event-Date-Timestamp': '1749847568969044', 'Event-Sequence': '39836' }, + variables: { + direction: 'inbound', + ep_codec_string: + 'mod_opus.opus@48000h@20i@2c,mod_spandsp.G722@8000h@20i@64000b,CORE_PCM_MODULE.PCMU@8000h@20i@64000b,CORE_PCM_MODULE.PCMA@8000h@20i@64000b', + endpoint_disposition: 'DELAYED NEGOTIATION', + }, + presenceCallDirection: 'inbound', + caller: '1001', + callee: '1000', + }); + }); + + it('should parse CHANNEL_ANSWER event', () => { + const eventName = 'CHANNEL_ANSWER'; + const eventData = createEventData({ + 'Event-Name': 'CHANNEL_ANSWER', + 'Event-Date-Timestamp': '1749847568969044', + 'Event-Sequence': '39836', + 'Channel-State': 'CS_INIT', + 'Channel-Call-State': 'DOWN', + 'Channel-State-Number': '2', + 'Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Call-Direction': 'inbound', + 'Presence-Call-Direction': 'inbound', + 'Channel-HIT-Dialplan': 'true', + 'Channel-Call-UUID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Answer-State': 'ringing', + 'Caller-Direction': 'inbound', + 'Caller-Logical-Direction': 'inbound', + 'Caller-Username': '1001', + 'Caller-Dialplan': 'XML', + 'Caller-Caller-ID-Name': '1001', + 'Caller-Caller-ID-Number': '1001', + 'Caller-Orig-Caller-ID-Name': '1001', + 'Caller-Orig-Caller-ID-Number': '1001', + 'Caller-Network-Addr': '177.99.99.183', + 'Caller-ANI': '1001', + 'Caller-Destination-Number': '1000', + 'Caller-Unique-ID': 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + 'Caller-Source': 'mod_sofia', + 'Caller-Context': 'default', + 'Caller-Channel-Name': 'sofia/internal/1001@99.219.99.169:7443', + 'Caller-Profile-Index': '1', + 'Caller-Profile-Created-Time': '1749847568969044', + 'Caller-Channel-Created-Time': '1749847568969044', + 'Caller-Channel-Answered-Time': '0', + 'Caller-Channel-Progress-Time': '0', + 'Caller-Channel-Progress-Media-Time': '0', + 'Caller-Channel-Hangup-Time': '0', + 'Caller-Channel-Transfer-Time': '0', + 'Caller-Channel-Resurrect-Time': '0', + 'Caller-Channel-Bridged-Time': '0', + 'Caller-Channel-Last-Hold': '0', + 'Caller-Channel-Hold-Accum': '0', + 'Caller-Screen-Bit': 'true', + 'Caller-Privacy-Hide-Name': 'false', + 'Caller-Privacy-Hide-Number': 'false', + 'variable_direction': 'inbound', + 'variable_ep_codec_string': + 'mod_opus.opus@48000h@20i@2c,mod_spandsp.G722@8000h@20i@64000b,CORE_PCM_MODULE.PCMU@8000h@20i@64000b,CORE_PCM_MODULE.PCMA@8000h@20i@64000b', + 'variable_endpoint_disposition': 'DELAYED NEGOTIATION', + }); + + const result = parseEventData(eventName, eventData); + + expect(result).toBeDefined(); + expect(result?.receivedAt).toBeInstanceOf(Date); + + expect(result).toMatchObject({ + channelUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + eventName: 'CHANNEL_ANSWER', + sequence: 39836, + firedAt: new Date(1749847568969), + callUniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + channelState: 'CS_INIT', + channelStateNumber: '2', + channelCallState: 'DOWN', + channelUsername: '1001', + answerState: 'ringing', + callDirection: 'inbound', + channelHitDialplan: 'true', + legs: { + 'ebc91302-1b79-4ff3-ac6b-841385e9ea03': { + legName: 'Caller', + uniqueId: 'ebc91302-1b79-4ff3-ac6b-841385e9ea03', + direction: 'inbound', + logicalDirection: 'inbound', + username: '1001', + callerName: '1001', + callerNumber: '1001', + originalCallerName: '1001', + originalCallerNumber: '1001', + networkAddress: '177.99.99.183', + destinationNumber: '1000', + source: 'mod_sofia', + context: 'default', + channelName: 'sofia/internal/1001@99.219.99.169:7443', + profiles: { + '1': { profileIndex: '1', profileCreatedTime: new Date(1749847568969), channelCreatedTime: new Date(1749847568969) }, + }, + dialplan: 'XML', + ani: '1001', + screenBit: 'true', + privacyHideName: 'false', + privacyHideNumber: 'false', + }, + }, + metadata: { 'Event-Name': 'CHANNEL_ANSWER', 'Event-Date-Timestamp': '1749847568969044', 'Event-Sequence': '39836' }, + variables: { + direction: 'inbound', + ep_codec_string: + 'mod_opus.opus@48000h@20i@2c,mod_spandsp.G722@8000h@20i@64000b,CORE_PCM_MODULE.PCMU@8000h@20i@64000b,CORE_PCM_MODULE.PCMA@8000h@20i@64000b', + endpoint_disposition: 'DELAYED NEGOTIATION', + }, + presenceCallDirection: 'inbound', + caller: '1001', + callee: '1000', + }); + }); +}); diff --git a/packages/freeswitch/tests/eventParser/parseEventExtensions.test.ts b/packages/freeswitch/tests/eventParser/parseEventExtensions.test.ts new file mode 100644 index 00000000000..35c9c3bacd2 --- /dev/null +++ b/packages/freeswitch/tests/eventParser/parseEventExtensions.test.ts @@ -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, + ): Omit => ({ + ...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'); + }); +}); diff --git a/packages/freeswitch/tests/eventParser/parseEventLeg.test.ts b/packages/freeswitch/tests/eventParser/parseEventLeg.test.ts new file mode 100644 index 00000000000..736decbed75 --- /dev/null +++ b/packages/freeswitch/tests/eventParser/parseEventLeg.test.ts @@ -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(); + }); +}); diff --git a/packages/freeswitch/tests/eventParser/parseTimestamp.test.ts b/packages/freeswitch/tests/eventParser/parseTimestamp.test.ts new file mode 100644 index 00000000000..86cf941a1b5 --- /dev/null +++ b/packages/freeswitch/tests/eventParser/parseTimestamp.test.ts @@ -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(); + }); +}); diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 5482132e4e5..43f8b7de30c 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -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'; diff --git a/packages/model-typings/src/models/IFreeSwitchCallModel.ts b/packages/model-typings/src/models/IFreeSwitchCallModel.ts deleted file mode 100644 index a224cb5ceef..00000000000 --- a/packages/model-typings/src/models/IFreeSwitchCallModel.ts +++ /dev/null @@ -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 { - registerCall(call: WithoutId>): Promise; - findAllByChannelUniqueIds(uniqueIds: string[], options?: FindOptions): FindCursor; - countCallsByDirection(direction: IFreeSwitchCall['direction'], minDate?: Date, options?: CountDocumentsOptions): Promise; - sumCallsDuration(minDate?: Date, options?: AggregateOptions): Promise; - countCallsBySuccessState(success: boolean, minDate?: Date, options?: CountDocumentsOptions): Promise; -} diff --git a/packages/model-typings/src/models/IFreeSwitchChannelEventDeltaModel.ts b/packages/model-typings/src/models/IFreeSwitchChannelEventDeltaModel.ts new file mode 100644 index 00000000000..d460721c5d2 --- /dev/null +++ b/packages/model-typings/src/models/IFreeSwitchChannelEventDeltaModel.ts @@ -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 { + registerDelta(channel: InsertionModel>): Promise>; +} diff --git a/packages/model-typings/src/models/IFreeSwitchChannelEventModel.ts b/packages/model-typings/src/models/IFreeSwitchChannelEventModel.ts new file mode 100644 index 00000000000..5fbe805a081 --- /dev/null +++ b/packages/model-typings/src/models/IFreeSwitchChannelEventModel.ts @@ -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 { + registerEvent(event: WithoutId>): Promise>; + findAllByChannelUniqueId( + uniqueId: string, + options?: FindOptions, + ): FindCursor; +} diff --git a/packages/model-typings/src/models/IFreeSwitchChannelModel.ts b/packages/model-typings/src/models/IFreeSwitchChannelModel.ts new file mode 100644 index 00000000000..fe30b61dbf5 --- /dev/null +++ b/packages/model-typings/src/models/IFreeSwitchChannelModel.ts @@ -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 { + registerChannel(channel: WithoutId>): Promise>; + findAllByUniqueIds(uniqueIds: string[], options?: FindOptions): FindCursor; + + countChannelsByKind(kind: Required['kind'], minDate?: Date, options?: CountDocumentsOptions): Promise; + countChannelsByKindAndDirection( + kind: Required['kind'], + callDirection: Required['callDirection'], + minDate?: Date, + options?: CountDocumentsOptions, + ): Promise; + sumChannelsDurationByKind(kind: Required['kind'], minDate?: Date, options?: AggregateOptions): Promise; + countChannelsByKindAndSuccessState( + kind: Required['kind'], + success: boolean, + minDate?: Date, + options?: CountDocumentsOptions, + ): Promise; +} diff --git a/packages/model-typings/src/models/IFreeSwitchEventModel.ts b/packages/model-typings/src/models/IFreeSwitchEventModel.ts deleted file mode 100644 index 118a57f8541..00000000000 --- a/packages/model-typings/src/models/IFreeSwitchEventModel.ts +++ /dev/null @@ -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 { - registerEvent(event: WithoutId>): Promise>; - findAllByCallUUID(callUUID: string, options?: FindOptions): FindCursor; - findAllByChannelUniqueIds(uniqueIds: string[], options?: FindOptions): FindCursor; -} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 08e7274f495..727d9695888 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -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('IExportOperatio export const FederationServers = proxify('IFederationServersModel'); export const FederationKeys = proxify('IFederationKeysModel'); export const FederationRoomEvents = proxify('IFederationRoomEventsModel'); -export const FreeSwitchCall = proxify('IFreeSwitchCallModel'); -export const FreeSwitchEvent = proxify('IFreeSwitchEventModel'); +export const FreeSwitchChannel = proxify('IFreeSwitchChannelModel'); +export const FreeSwitchChannelEvent = proxify('IFreeSwitchChannelEventModel'); +export const FreeSwitchChannelEventDelta = proxify('IFreeSwitchChannelEventDeltaModel'); export const ImportData = proxify('IImportDataModel'); export const Imports = proxify('IImportsModel'); export const InstanceStatus = proxify('IInstanceStatusModel'); diff --git a/packages/models/src/modelClasses.ts b/packages/models/src/modelClasses.ts index 148ab141b53..429be82fd64 100644 --- a/packages/models/src/modelClasses.ts +++ b/packages/models/src/modelClasses.ts @@ -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'; diff --git a/packages/models/src/models/FreeSwitchCall.ts b/packages/models/src/models/FreeSwitchCall.ts deleted file mode 100644 index fe612fcebc0..00000000000 --- a/packages/models/src/models/FreeSwitchCall.ts +++ /dev/null @@ -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 implements IFreeSwitchCallModel { - constructor(db: Db, trash?: Collection>) { - 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>): Promise { - await this.findOneAndUpdate({ UUID: call.UUID }, { $set: call }, { upsert: true }); - } - - public findAllByChannelUniqueIds(uniqueIds: string[], options?: FindOptions): FindCursor { - return this.find( - { - channels: { $in: uniqueIds }, - }, - options, - ); - } - - public countCallsByDirection(direction: IFreeSwitchCall['direction'], minDate?: Date, options?: CountDocumentsOptions): Promise { - return this.countDocuments( - { - direction, - ...(minDate && { startedAt: { $gte: minDate } }), - }, - { readPreference: readSecondaryPreferred(), ...options }, - ); - } - - public async sumCallsDuration(minDate?: Date, options?: AggregateOptions): Promise { - 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 { - return this.countDocuments( - { - ...(success ? { duration: { $gte: 5 } } : { $or: [{ duration: { $exists: false } }, { duration: { $lt: 5 } }] }), - ...(minDate && { startedAt: { $gte: minDate } }), - }, - { readPreference: readSecondaryPreferred(), ...options }, - ); - } -} diff --git a/packages/models/src/models/FreeSwitchChannel.ts b/packages/models/src/models/FreeSwitchChannel.ts new file mode 100644 index 00000000000..d81ee27fc79 --- /dev/null +++ b/packages/models/src/models/FreeSwitchChannel.ts @@ -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 implements IFreeSwitchChannelModel { + constructor(db: Db, trash?: Collection>) { + 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>): Promise> { + return this.insertOne(channel); + } + + public findAllByUniqueIds(uniqueIds: string[], options?: FindOptions): FindCursor { + return this.find( + { + uniqueId: { $in: uniqueIds }, + }, + options, + ); + } + + public countChannelsByKind(kind: Required['kind'], minDate?: Date, options?: CountDocumentsOptions): Promise { + return this.countDocuments( + { + kind, + ...(minDate && { startedAt: { $gte: minDate } }), + }, + { readPreference: readSecondaryPreferred(), ...options }, + ); + } + + public countChannelsByKindAndDirection( + kind: Required['kind'], + callDirection: Required['callDirection'], + minDate?: Date, + options?: CountDocumentsOptions, + ): Promise { + return this.countDocuments( + { + kind, + callDirection, + ...(minDate && { startedAt: { $gte: minDate } }), + }, + { readPreference: readSecondaryPreferred(), ...options }, + ); + } + + public async sumChannelsDurationByKind( + kind: Required['kind'], + minDate?: Date, + options?: AggregateOptions, + ): Promise { + 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['kind'], + success: boolean, + minDate?: Date, + options?: CountDocumentsOptions, + ): Promise { + return this.countDocuments( + { + kind, + anyBridge: success, + ...(minDate && { startedAt: { $gte: minDate } }), + }, + { readPreference: readSecondaryPreferred(), ...options }, + ); + } +} diff --git a/packages/models/src/models/FreeSwitchChannelEvent.ts b/packages/models/src/models/FreeSwitchChannelEvent.ts new file mode 100644 index 00000000000..c598018a2c3 --- /dev/null +++ b/packages/models/src/models/FreeSwitchChannelEvent.ts @@ -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 implements IFreeSwitchChannelEventModel { + constructor(db: Db, trash?: Collection>) { + 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>): Promise> { + return this.insertOne(event); + } + + public findAllByChannelUniqueId( + channelUniqueId: string, + options?: FindOptions, + ): FindCursor { + const theOptions: FindOptions = { + sort: { + sequence: 1, + }, + ...options, + }; + + return this.find( + { + channelUniqueId, + }, + theOptions, + ); + } +} diff --git a/packages/models/src/models/FreeSwitchChannelEventDelta.ts b/packages/models/src/models/FreeSwitchChannelEventDelta.ts new file mode 100644 index 00000000000..5fc99735186 --- /dev/null +++ b/packages/models/src/models/FreeSwitchChannelEventDelta.ts @@ -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 implements IFreeSwitchChannelEventDeltaModel { + constructor(db: Db, trash?: Collection>) { + 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>, + ): Promise> { + return this.insertOne(delta); + } +} diff --git a/packages/models/src/models/FreeSwitchEvent.ts b/packages/models/src/models/FreeSwitchEvent.ts deleted file mode 100644 index 236f891cee0..00000000000 --- a/packages/models/src/models/FreeSwitchEvent.ts +++ /dev/null @@ -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 implements IFreeSwitchEventModel { - constructor(db: Db, trash?: Collection>) { - 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>): Promise> { - return this.insertOne(event); - } - - public findAllByCallUUID(callUUID: string, options?: FindOptions): FindCursor { - return this.find({ 'call.UUID': callUUID }, options); - } - - public findAllByChannelUniqueIds( - uniqueIds: string[], - options?: FindOptions, - ): FindCursor { - return this.find( - { - channelUniqueId: { $in: uniqueIds }, - }, - options, - ); - } -} diff --git a/packages/tools/src/convertPathsIntoSubObjects.ts b/packages/tools/src/convertPathsIntoSubObjects.ts new file mode 100644 index 00000000000..bc9c613e8b9 --- /dev/null +++ b/packages/tools/src/convertPathsIntoSubObjects.ts @@ -0,0 +1,36 @@ +export function convertPathsIntoSubObjects(object: Record): Record { + const newObject: Record = {}; + + 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; +} diff --git a/packages/tools/src/convertSubObjectsIntoPaths.spec.ts b/packages/tools/src/convertSubObjectsIntoPaths.spec.ts index c595a17ae70..01d4f63cc31 100644 --- a/packages/tools/src/convertSubObjectsIntoPaths.spec.ts +++ b/packages/tools/src/convertSubObjectsIntoPaths.spec.ts @@ -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'); + }); }); diff --git a/packages/tools/src/convertSubObjectsIntoPaths.ts b/packages/tools/src/convertSubObjectsIntoPaths.ts index 8c128aa19f2..c5a5faad0ca 100644 --- a/packages/tools/src/convertSubObjectsIntoPaths.ts +++ b/packages/tools/src/convertSubObjectsIntoPaths.ts @@ -1,15 +1,16 @@ +import { isRecord } from './isRecord'; + export function convertSubObjectsIntoPaths(object: Record, parentPath?: string): Record { 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][], ); diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 7e40272b0bb..ddde6d93c76 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -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'; diff --git a/packages/tools/src/isRecord.spec.ts b/packages/tools/src/isRecord.spec.ts new file mode 100644 index 00000000000..287dee334e8 --- /dev/null +++ b/packages/tools/src/isRecord.spec.ts @@ -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); + }); +}); diff --git a/packages/tools/src/isRecord.ts b/packages/tools/src/isRecord.ts new file mode 100644 index 00000000000..32ade7ac5f9 --- /dev/null +++ b/packages/tools/src/isRecord.ts @@ -0,0 +1,7 @@ +export function isRecord(record: unknown): record is Record { + if (!record || typeof record !== 'object') { + return false; + } + + return Object.getPrototypeOf(record).constructor === Object; +} diff --git a/packages/ui-voip/src/hooks/useVoipClient.tsx b/packages/ui-voip/src/hooks/useVoipClient.tsx index fdda0587b7d..c494063c91c 100644 --- a/packages/ui-voip/src/hooks/useVoipClient.tsx +++ b/packages/ui-voip/src/hooks/useVoipClient.tsx @@ -18,6 +18,7 @@ type VoipClientResult = { export const useVoipClient = ({ enabled = true, autoRegister = true }: VoipClientParams = {}): VoipClientResult => { const { _id: userId } = useUser() || {}; const voipClientRef = useRef(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, }; diff --git a/packages/ui-voip/src/lib/VoipClient.ts b/packages/ui-voip/src/lib/VoipClient.ts index 9cc12798052..24b4227ad43 100644 --- a/packages/ui-voip/src/lib/VoipClient.ts +++ b/packages/ui-voip/src/lib/VoipClient.ts @@ -50,6 +50,8 @@ class VoipClient extends Emitter { private reconnecting = false; + private contactName: string | null = null; + constructor(private readonly config: VoIPUserConfiguration) { super(); @@ -74,6 +76,8 @@ class VoipClient extends Emitter { 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 { 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 { 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;