fix: voip call history doesn't always log the correct call information (#35006)
parent
ba0cbd3265
commit
fd478a7d45
@ -0,0 +1,11 @@ |
||||
--- |
||||
'@rocket.chat/model-typings': patch |
||||
'@rocket.chat/core-typings': patch |
||||
'@rocket.chat/freeswitch': patch |
||||
'@rocket.chat/ui-voip': patch |
||||
'@rocket.chat/models': patch |
||||
'@rocket.chat/tools': patch |
||||
'@rocket.chat/meteor': patch |
||||
--- |
||||
|
||||
Fixes the parsing of FreeSwitch events to properly generate a history of calls on Rocket.Chat |
||||
@ -1,65 +0,0 @@ |
||||
import type { IRocketChatRecord } from '../IRocketChatRecord'; |
||||
import type { IUser } from '../IUser'; |
||||
import type { IFreeSwitchEventCall, IFreeSwitchEventCaller } from './IFreeSwitchEvent'; |
||||
|
||||
export interface IFreeSwitchCall extends IRocketChatRecord { |
||||
UUID: string; |
||||
channels: string[]; |
||||
events: IFreeSwitchCallEvent[]; |
||||
from?: Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag' | 'freeSwitchExtension'>; |
||||
to?: Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag' | 'freeSwitchExtension'>; |
||||
forwardedFrom?: Omit<IFreeSwitchCall, 'events'>[]; |
||||
direction?: 'internal' | 'external_inbound' | 'external_outbound'; |
||||
voicemail?: boolean; |
||||
duration?: number; |
||||
startedAt?: Date; |
||||
} |
||||
|
||||
const knownEventTypes = [ |
||||
'NEW', |
||||
'INIT', |
||||
'CREATE', |
||||
'DESTROY', |
||||
'ANSWER', |
||||
'HANGUP', |
||||
'BRIDGE', |
||||
'UNBRIDGE', |
||||
'OUTGOING', |
||||
'PARK', |
||||
'UNPARK', |
||||
'HOLD', |
||||
'UNHOLD', |
||||
'ORIGINATE', |
||||
'UUID', |
||||
'REPORTING', |
||||
'ROUTING', |
||||
'RINGING', |
||||
'ACTIVE', |
||||
'EARLY', |
||||
'RING_WAIT', |
||||
'EXECUTE', |
||||
'CONSUME_MEDIA', |
||||
'EXCHANGE_MEDIA', |
||||
'OTHER', |
||||
'OTHER_STATE', |
||||
'OTHER_CALL_STATE', |
||||
] as const; |
||||
|
||||
export type IFreeSwitchCallEventType = (typeof knownEventTypes)[number]; |
||||
|
||||
export const isKnownFreeSwitchEventType = (eventName: string): eventName is IFreeSwitchCallEventType => |
||||
knownEventTypes.includes(eventName as any); |
||||
|
||||
export type IFreeSwitchCallEvent = { |
||||
eventName: string; |
||||
type: IFreeSwitchCallEventType; |
||||
sequence?: string; |
||||
channelUniqueId?: string; |
||||
timestamp?: string; |
||||
firedAt?: Date; |
||||
caller?: IFreeSwitchEventCaller; |
||||
call?: IFreeSwitchEventCall; |
||||
|
||||
otherType?: string; |
||||
otherChannelId?: string; |
||||
}; |
||||
@ -0,0 +1,41 @@ |
||||
import type { IRocketChatRecord } from '../IRocketChatRecord'; |
||||
import type { |
||||
FreeSwitchChannelEventHeaderWithStates, |
||||
IFreeSwitchChannelEventLegProfile, |
||||
IFreeSwitchChannelEventMutable, |
||||
} from './IFreeSwitchChannelEvent'; |
||||
|
||||
export interface IFreeSwitchChannel extends IRocketChatRecord { |
||||
uniqueId: string; |
||||
name: string; |
||||
|
||||
freeSwitchUser?: string; |
||||
callers?: string[]; |
||||
callees?: string[]; |
||||
bridgedTo: string[]; |
||||
callDirection?: string; |
||||
|
||||
profiles: IFreeSwitchChannelProfile[]; |
||||
|
||||
startedAt: Date; |
||||
anyMedia: boolean; |
||||
anyAnswer: boolean; |
||||
anyBridge: boolean; |
||||
durationSum: number; |
||||
totalDuration: number; |
||||
|
||||
kind: 'internal' | 'external' | 'voicemail' | 'unknown'; |
||||
|
||||
finalState: IFreeSwitchChannelEventMutable; |
||||
events: FreeSwitchChannelEventHeaderWithStates[]; |
||||
} |
||||
|
||||
export interface IFreeSwitchChannelProfile extends IFreeSwitchChannelEventLegProfile { |
||||
// This value is pulled from the next profile
|
||||
nextProfileCreatedTime?: Date; |
||||
|
||||
callDuration?: number; |
||||
answered?: boolean; |
||||
media?: boolean; |
||||
bridged?: boolean; |
||||
} |
||||
@ -0,0 +1,177 @@ |
||||
import type { IRocketChatRecord } from '../IRocketChatRecord'; |
||||
import type { AtLeast } from '../utils'; |
||||
|
||||
export interface IFreeSwitchChannelEventHeader { |
||||
sequence: number; |
||||
eventName: string; |
||||
firedAt: Date; |
||||
receivedAt: Date; |
||||
|
||||
caller?: string; |
||||
callee?: string; |
||||
} |
||||
|
||||
export interface IFreeSwitchChannelEventStates { |
||||
channelState: string; |
||||
channelCallState: string; |
||||
originalChannelCallState?: string; |
||||
answerState?: string; |
||||
} |
||||
|
||||
export type FreeSwitchChannelEventHeaderWithStates = IFreeSwitchChannelEventHeader & IFreeSwitchChannelEventStates; |
||||
|
||||
export interface IFreeSwitchChannelEventMutable { |
||||
// uniqueId of the main channel in the active call, might not actually be unique between different calls.
|
||||
// We overwrite this value in some events to ensure it always refer to the same uniqueId even while the call is not active.
|
||||
callUniqueId: string; |
||||
|
||||
// the name of this channel on sofia, might reference the user or contact depending on which process created it
|
||||
channelName: string; |
||||
|
||||
// For valid calls this should be parsed to a valid freeswitch username that is represented by this channel
|
||||
// Parsing might fail for voicemail, spam bots and more advanced features we implement in the future.
|
||||
channelUsername?: string; |
||||
|
||||
// Caller and Callee are calculated based on everything else, it's not any specific event attribute
|
||||
caller?: string; |
||||
callee?: string; |
||||
|
||||
// Expected: CS_NEW, CS_INIT, CS_ROUTING, CS_EXECUTE, CS_EXCHANGE_MEDIA, CS_CONSUME_MEDIA, CS_HANGUP, CS_REPORTING, CS_DESTROY
|
||||
// Not Expected: CS_SOFT_EXECUTE, CS_PARK, CS_HIBERNATE, CS_RESET, CS_NONE
|
||||
channelState: string; |
||||
// Multiple state numbers may map to the same state name
|
||||
channelStateNumber?: string; |
||||
// DOWN, DIALING, RINGING, EARLY, ACTIVE, HELD, RING_WAIT, HANGUP, UNHELD
|
||||
channelCallState: string; |
||||
channelCallStateNumber?: string; |
||||
// The previous value of channelCallState
|
||||
originalChannelCallState?: string; |
||||
// early, ringing, confirmed, answered, hangup, terminated
|
||||
answerState?: string; |
||||
|
||||
// 'inbound' for the channel that initiated the call ("in" to freeswitch)
|
||||
// 'outbound' for the channel(s) that are receiving the call ("out" from freeswitch)
|
||||
callDirection?: string; |
||||
// will usually be true for the channel that initiated the call, if it is using the dialplan
|
||||
channelHitDialplan?: string; |
||||
|
||||
hangupCause?: string; |
||||
bridgeUniqueIds?: string[]; |
||||
// Sent only by CALL_UPDATE events, when two channels are bridged together
|
||||
bridgedTo?: string; |
||||
|
||||
legs: Record<string, AtLeast<IFreeSwitchChannelEventLeg, 'legName' | 'uniqueId' | 'raw'>>; |
||||
|
||||
// variables should contain the same data you would get by running `uuid_dump` on fs_cli
|
||||
variables?: Record<string, string | string[]>; |
||||
|
||||
// raw will include fields we received from freeswitch but didn't read
|
||||
raw: Record<string, string>; |
||||
|
||||
codecs?: { |
||||
read?: { |
||||
name?: string; |
||||
rate?: string; |
||||
}; |
||||
write?: { |
||||
name?: string; |
||||
rate?: string; |
||||
}; |
||||
}; |
||||
|
||||
// Presence is something I'm trying to not depend on, but the info here could be useful for identifying users if there's no other reliable field.
|
||||
channelPresenceId?: string; |
||||
presenceCallDirection?: string; |
||||
} |
||||
|
||||
export interface IFreeSwitchChannelEvent extends IRocketChatRecord, IFreeSwitchChannelEventHeader, IFreeSwitchChannelEventMutable { |
||||
channelUniqueId: string; |
||||
|
||||
metadata: Record<string, string>; |
||||
} |
||||
|
||||
export interface IFreeSwitchChannelEventLegProfile { |
||||
// If profileIndex is a number higher than 1, then the channel is being reused for a second call
|
||||
profileIndex?: string; |
||||
|
||||
profileCreatedTime?: Date; |
||||
channelCreatedTime?: Date; |
||||
channelAnsweredTime?: Date; |
||||
channelProgressTime?: Date; |
||||
channelBridgedTime?: Date; |
||||
channelProgressMediaTime?: Date; |
||||
channelHangupTime?: Date; |
||||
channelTransferTime?: Date; |
||||
channelRessurectTime?: Date; |
||||
channelLastHold?: Date; |
||||
|
||||
// Those are pulled from other places so that the profile can be mapped to specific calls
|
||||
// They'll never be present on the raw events, only on the channel.events and channel.finalState.events
|
||||
bridgedTo?: string; |
||||
caller?: string; |
||||
callee?: string; |
||||
} |
||||
|
||||
export interface IFreeSwitchChannelEventLeg { |
||||
// 'Caller' or 'Other-Leg'; Worthless information
|
||||
legName: string; |
||||
// 'originator' or 'originatee', will be undefined if legName !== 'Other-Leg'
|
||||
type?: string; |
||||
|
||||
// 'inbound' for the leg that initiated the call
|
||||
// 'outbound' for the leg(s) that are receiving the call
|
||||
direction: string; |
||||
// Logical direction is what the other leg would expect the direction of this leg to be
|
||||
// If `direction` and `logicalDirection` are different, then the call was probably not started by either side (eg. server called both users)
|
||||
logicalDirection: string; |
||||
|
||||
// Unreliable; Always the username of the user who initiated the first call in the chain, even if they are no longer involved.
|
||||
username: string; |
||||
// Unreliable, same as username.
|
||||
callerName: string; |
||||
// Unreliable, same as username.
|
||||
callerNumber: string; |
||||
|
||||
// Unreliable, but not as much as username.
|
||||
originalCallerName: string; |
||||
// Unreliable, but not as much as username.
|
||||
originalCallerNumber: string; |
||||
|
||||
// Unreliable, same as username.
|
||||
calleeName: string; |
||||
// Unreliable, same as username.
|
||||
calleeNumber: string; |
||||
|
||||
networkAddress: string; |
||||
// Kinda reliable, but it can be so many different things depending on the event type, that it's not worth using
|
||||
destinationNumber: string; |
||||
|
||||
// very reliable, always a channel's unique id
|
||||
uniqueId: string; |
||||
// very reliable, always a valid channel name
|
||||
channelName: string; |
||||
|
||||
// always 'mod_sofia' in our current use case
|
||||
source: string; |
||||
// should match the context from the sip_profile ('default' for internal, 'public' for external), but I haven't tested it with external calls yet
|
||||
context: string; |
||||
|
||||
transferSource?: string; |
||||
|
||||
profiles: Record<string, IFreeSwitchChannelEventLegProfile>; |
||||
|
||||
// always 'XML' in our current use case
|
||||
dialplan?: string; |
||||
// Unreliable, same value as username;
|
||||
ani?: string; |
||||
|
||||
// rdnis is present on transfered calls; Might be an username or a contact name.
|
||||
rdnis?: string; |
||||
|
||||
// No use for those atm
|
||||
screenBit?: string; |
||||
privacyHideName?: string; |
||||
privacyHideNumber?: string; |
||||
|
||||
raw?: Record<string, string>; |
||||
} |
||||
@ -0,0 +1,24 @@ |
||||
import type { IRocketChatRecord } from '../IRocketChatRecord'; |
||||
import type { DeepPartial } from '../utils'; |
||||
import type { |
||||
IFreeSwitchChannelEventHeader, |
||||
IFreeSwitchChannelEventMutable, |
||||
IFreeSwitchChannelEventStates, |
||||
} from './IFreeSwitchChannelEvent'; |
||||
|
||||
type DeepModified<T> = { |
||||
[P in keyof T]?: T[P] extends Date | undefined |
||||
? { oldValue: T[P]; newValue: T[P]; delta: number } | undefined |
||||
: T[P] extends object | undefined |
||||
? DeepModified<T[P]> |
||||
: { oldValue: T[P]; newValue: T[P] } | undefined; |
||||
}; |
||||
|
||||
export interface IFreeSwitchChannelEventDeltaData extends IFreeSwitchChannelEventHeader { |
||||
newValues?: DeepPartial<IFreeSwitchChannelEventMutable>; |
||||
modifiedValues?: DeepModified<IFreeSwitchChannelEventMutable>; |
||||
} |
||||
|
||||
export interface IFreeSwitchChannelEventDelta extends IRocketChatRecord, IFreeSwitchChannelEventDeltaData, IFreeSwitchChannelEventStates { |
||||
channelUniqueId: string; |
||||
} |
||||
@ -1,113 +0,0 @@ |
||||
import type { IRocketChatRecord } from '../IRocketChatRecord'; |
||||
|
||||
export interface IFreeSwitchEvent extends IRocketChatRecord { |
||||
channelUniqueId?: string; |
||||
eventName: string; |
||||
detaildEventName: string; |
||||
|
||||
sequence?: string; |
||||
state?: string; |
||||
previousCallState?: string; |
||||
callState?: string; |
||||
timestamp?: string; |
||||
|
||||
firedAt?: Date; |
||||
answerState?: string; |
||||
hangupCause?: string; |
||||
|
||||
referencedIds?: string[]; |
||||
receivedAt?: Date; |
||||
|
||||
channelName?: string; |
||||
direction?: string; |
||||
|
||||
caller?: IFreeSwitchEventCaller; |
||||
call?: IFreeSwitchEventCall; |
||||
|
||||
eventData: Record<string, string>; |
||||
} |
||||
|
||||
export interface IFreeSwitchEventCall { |
||||
UUID?: string; |
||||
answerState?: string; |
||||
state?: string; |
||||
previousState?: string; |
||||
presenceId?: string; |
||||
sipId?: string; |
||||
authorized?: string; |
||||
hangupCause?: string; |
||||
duration?: number; |
||||
|
||||
from?: { |
||||
user?: string; |
||||
stripped?: string; |
||||
port?: string; |
||||
uri?: string; |
||||
host?: string; |
||||
full?: string; |
||||
|
||||
userId?: string; |
||||
}; |
||||
|
||||
req?: { |
||||
user?: string; |
||||
port?: string; |
||||
uri?: string; |
||||
host?: string; |
||||
|
||||
userId?: string; |
||||
}; |
||||
|
||||
to?: { |
||||
user?: string; |
||||
port?: string; |
||||
uri?: string; |
||||
full?: string; |
||||
dialedExtension?: string; |
||||
dialedUser?: string; |
||||
|
||||
userId?: string; |
||||
}; |
||||
|
||||
contact?: { |
||||
user?: string; |
||||
uri?: string; |
||||
host?: string; |
||||
|
||||
userId?: string; |
||||
}; |
||||
|
||||
via?: { |
||||
full?: string; |
||||
host?: string; |
||||
rport?: string; |
||||
|
||||
userId?: string; |
||||
}; |
||||
} |
||||
|
||||
export interface IFreeSwitchEventCaller { |
||||
uniqueId?: string; |
||||
direction?: string; |
||||
username?: string; |
||||
networkAddr?: string; |
||||
ani?: string; |
||||
destinationNumber?: string; |
||||
source?: string; |
||||
context?: string; |
||||
name?: string; |
||||
number?: string; |
||||
|
||||
originalCaller?: { |
||||
name?: string; |
||||
number?: string; |
||||
}; |
||||
privacy?: { |
||||
hideName?: string; |
||||
hideNumber?: string; |
||||
}; |
||||
channel?: { |
||||
name?: string; |
||||
createdTime?: string; |
||||
}; |
||||
} |
||||
@ -0,0 +1,153 @@ |
||||
import type { |
||||
IFreeSwitchChannel, |
||||
IFreeSwitchChannelEvent, |
||||
IFreeSwitchChannelEventDeltaData, |
||||
IFreeSwitchChannelEventHeader, |
||||
IFreeSwitchChannelEventMutable, |
||||
FreeSwitchChannelEventHeaderWithStates, |
||||
IFreeSwitchChannelEventStates, |
||||
} from '@rocket.chat/core-typings'; |
||||
import { convertPathsIntoSubObjects, convertSubObjectsIntoPaths } from '@rocket.chat/tools'; |
||||
|
||||
import { computeChannelProfiles } from './computeChannelProfiles'; |
||||
import { extractChannelChangesFromEvent } from './extractChannelChangesFromEvent'; |
||||
import { filterOutMissingData } from './filterOutMissingData'; |
||||
import { insertDataIntoEventProfile } from './insertDataIntoEventProfile'; |
||||
import { parseChannelKind } from './parseChannelKind'; |
||||
|
||||
function splitEventDataSections(event: IFreeSwitchChannelEvent): { |
||||
header: IFreeSwitchChannelEventHeader; |
||||
eventData: IFreeSwitchChannelEventMutable; |
||||
channelUniqueId: string; |
||||
} { |
||||
const { _id, channelUniqueId, _updatedAt, metadata, eventName, sequence, firedAt, receivedAt, callee, caller, ...eventData } = event; |
||||
|
||||
return { |
||||
channelUniqueId, |
||||
header: { |
||||
sequence, |
||||
eventName, |
||||
firedAt, |
||||
receivedAt, |
||||
callee, |
||||
caller, |
||||
}, |
||||
eventData, |
||||
}; |
||||
} |
||||
|
||||
export async function computeChannelFromEvents(allEvents: IFreeSwitchChannelEvent[]): Promise< |
||||
| { |
||||
channel: Omit<IFreeSwitchChannel, '_id' | '_updatedAt'>; |
||||
deltas: (IFreeSwitchChannelEventDeltaData & IFreeSwitchChannelEventStates)[]; |
||||
} |
||||
| undefined |
||||
> { |
||||
if (!allEvents.length) { |
||||
return; |
||||
} |
||||
|
||||
const deltas: (IFreeSwitchChannelEventDeltaData & IFreeSwitchChannelEventStates)[] = []; |
||||
const { channelUniqueId: uniqueId, firedAt: firstEvent } = allEvents[0]; |
||||
const callDirections: string[] = []; |
||||
const callers: string[] = []; |
||||
const callees: string[] = []; |
||||
const bridgedTo: string[] = []; |
||||
const headers: FreeSwitchChannelEventHeaderWithStates[] = []; |
||||
|
||||
for (const event of allEvents) { |
||||
const { callee, caller, bridgeUniqueIds, bridgedTo: eventBridgedTo } = event; |
||||
|
||||
if (event.callDirection && !callDirections.includes(event.callDirection)) { |
||||
callDirections.push(event.callDirection); |
||||
} |
||||
if (callee && !callees.includes(callee)) { |
||||
callees.push(callee); |
||||
} |
||||
if (caller && !callers.includes(caller)) { |
||||
callers.push(caller); |
||||
} |
||||
|
||||
if (bridgeUniqueIds) { |
||||
for (const bridgeUniqueId of bridgeUniqueIds) { |
||||
if (bridgeUniqueId && !bridgedTo.includes(bridgeUniqueId) && bridgeUniqueId !== uniqueId) { |
||||
bridgedTo.push(bridgeUniqueId); |
||||
} |
||||
} |
||||
} |
||||
if (eventBridgedTo && !bridgedTo.includes(eventBridgedTo)) { |
||||
bridgedTo.push(eventBridgedTo); |
||||
} |
||||
} |
||||
|
||||
const flattened = allEvents.reduce( |
||||
(state, nextEvent: IFreeSwitchChannelEvent) => { |
||||
const { header, eventData, channelUniqueId } = splitEventDataSections(nextEvent); |
||||
const { caller, callee, eventName, sequence } = header; |
||||
|
||||
// Inserts the callee and bridgedTo attributes into the profile of this event
|
||||
const eventDataEx = insertDataIntoEventProfile(eventData, { caller, callee, bridgedTo: eventData.bridgedTo }); |
||||
|
||||
// Make a list with every value from the event, except for the headers;
|
||||
const eventValues = convertSubObjectsIntoPaths(eventDataEx); |
||||
|
||||
// Compare the event's list of values with the full list from all past events
|
||||
const { changedValues, newValues, changedExistingValues } = extractChannelChangesFromEvent(state, eventName, eventValues); |
||||
|
||||
const { channelState, channelCallState, originalChannelCallState, answerState } = eventData; |
||||
const headerWithStates: FreeSwitchChannelEventHeaderWithStates = { |
||||
...header, |
||||
channelState, |
||||
channelCallState, |
||||
originalChannelCallState, |
||||
answerState, |
||||
}; |
||||
|
||||
// Generate a "delta" entry with the data that has changed in this event
|
||||
const delta: IFreeSwitchChannelEventDeltaData & IFreeSwitchChannelEventStates = { |
||||
...headerWithStates, |
||||
|
||||
newValues: convertPathsIntoSubObjects(newValues), |
||||
modifiedValues: convertPathsIntoSubObjects(changedExistingValues), |
||||
}; |
||||
|
||||
// Store this delta in a list
|
||||
deltas.push(filterOutMissingData(delta)); |
||||
headers.push(headerWithStates); |
||||
|
||||
return { |
||||
channelUniqueId, |
||||
...state, |
||||
eventName, |
||||
sequence, |
||||
...changedValues, |
||||
}; |
||||
}, |
||||
{} as Record<string, any>, |
||||
); |
||||
|
||||
const finalState = convertPathsIntoSubObjects(flattened) as IFreeSwitchChannelEventMutable; |
||||
|
||||
const computedProfiles = computeChannelProfiles(finalState?.legs?.[uniqueId]?.profiles || {}); |
||||
|
||||
return { |
||||
channel: { |
||||
uniqueId, |
||||
name: finalState.channelName, |
||||
callDirection: callDirections.join('||'), |
||||
freeSwitchUser: finalState.channelUsername, |
||||
callers, |
||||
callees, |
||||
bridgedTo, |
||||
...{ |
||||
...computedProfiles, |
||||
// If we couldn't parse a startedAt, use the time of the first event
|
||||
startedAt: computedProfiles.startedAt || firstEvent, |
||||
}, |
||||
kind: parseChannelKind(finalState.channelName), |
||||
finalState, |
||||
events: headers, |
||||
}, |
||||
deltas, |
||||
}; |
||||
} |
||||
@ -0,0 +1,159 @@ |
||||
/* eslint-disable complexity */ |
||||
import type { IFreeSwitchChannelEventLegProfile, IFreeSwitchChannelProfile } from '@rocket.chat/core-typings'; |
||||
|
||||
function adjustProfileTimestamps(profile: IFreeSwitchChannelEventLegProfile): IFreeSwitchChannelEventLegProfile { |
||||
const { profileIndex, profileCreatedTime, channelCreatedTime, bridgedTo, caller, callee, ...timestamps } = profile; |
||||
|
||||
// Don't mutate anything if it's the first profile
|
||||
if (!profileIndex || profileIndex === '1') { |
||||
return { ...profile }; |
||||
} |
||||
|
||||
const newProfile: IFreeSwitchChannelEventLegProfile = { |
||||
channelCreatedTime, |
||||
profileIndex, |
||||
bridgedTo, |
||||
callee, |
||||
caller, |
||||
}; |
||||
|
||||
// If we don't know when the profile was created, drop every other timestamp
|
||||
if (!profileCreatedTime) { |
||||
return newProfile; |
||||
} |
||||
|
||||
newProfile.profileCreatedTime = profileCreatedTime; |
||||
|
||||
for (const key of Object.keys(timestamps)) { |
||||
const value = timestamps[key as keyof typeof timestamps]; |
||||
if (!value || typeof value === 'string') { |
||||
continue; |
||||
} |
||||
|
||||
if (value < profileCreatedTime) { |
||||
continue; |
||||
} |
||||
|
||||
newProfile[key as keyof typeof timestamps] = value; |
||||
} |
||||
|
||||
return newProfile; |
||||
} |
||||
|
||||
type ProfileListAndSummary = { |
||||
profiles: IFreeSwitchChannelProfile[]; |
||||
anyMedia: boolean; |
||||
anyAnswer: boolean; |
||||
anyBridge: boolean; |
||||
durationSum: number; |
||||
totalDuration: number; |
||||
|
||||
startedAt?: Date; |
||||
}; |
||||
|
||||
export function computeChannelProfiles(legProfiles: Record<string, IFreeSwitchChannelEventLegProfile>): ProfileListAndSummary { |
||||
const profiles: IFreeSwitchChannelProfile[] = Object.values(legProfiles).map((profile) => adjustProfileTimestamps(profile)); |
||||
|
||||
// Sort profiles by createdTime, temporarily filter out the ones that do not have one:
|
||||
const sortedProfiles = profiles |
||||
.filter( |
||||
({ profileCreatedTime, channelCreatedTime, profileIndex }) => profileCreatedTime || (profileIndex === '1' && channelCreatedTime), |
||||
) |
||||
.sort( |
||||
({ profileCreatedTime: profile1, channelCreatedTime: channel1 }, { profileCreatedTime: profile2, channelCreatedTime: channel2 }) => |
||||
(profile1?.valueOf() || (channel1 as Date).valueOf()) - (profile2?.valueOf() || (channel2 as Date).valueOf()), |
||||
); |
||||
|
||||
const adjustedProfiles: IFreeSwitchChannelProfile[] = []; |
||||
let anyAnswer = false; |
||||
let anyMedia = false; |
||||
let anyBridge = false; |
||||
let durationSum = 0; |
||||
let firstProfileCreate: Date | undefined; |
||||
// "first" because it's an array, but it's the same channel for all so there should only be one value
|
||||
let firstChannelCreate: Date | undefined; |
||||
|
||||
for (let i = 0; i < sortedProfiles.length; i++) { |
||||
const nextProfileCreatedTime = sortedProfiles[i + 1]?.profileCreatedTime || undefined; |
||||
|
||||
const profile = sortedProfiles[i]; |
||||
|
||||
const { |
||||
channelBridgedTime, |
||||
channelAnsweredTime, |
||||
channelProgressMediaTime, |
||||
channelHangupTime, |
||||
bridgedTo, |
||||
profileCreatedTime, |
||||
channelCreatedTime, |
||||
} = profile; |
||||
|
||||
const callEnd = channelHangupTime || nextProfileCreatedTime; |
||||
|
||||
if (channelCreatedTime && (!firstChannelCreate || firstChannelCreate > channelCreatedTime)) { |
||||
firstChannelCreate = channelCreatedTime; |
||||
} |
||||
|
||||
if (profileCreatedTime && (!firstProfileCreate || firstProfileCreate > profileCreatedTime)) { |
||||
firstProfileCreate = profileCreatedTime; |
||||
} |
||||
|
||||
const callDuration = callEnd && channelBridgedTime ? callEnd.valueOf() - channelBridgedTime.valueOf() : 0; |
||||
const media = Boolean(channelProgressMediaTime) || sortedProfiles.length > 1; |
||||
const answered = Boolean(channelAnsweredTime) || media; |
||||
const bridged = Boolean(channelBridgedTime) || Boolean(bridgedTo); |
||||
|
||||
anyMedia ||= media; |
||||
anyAnswer ||= answered; |
||||
anyBridge ||= bridged; |
||||
durationSum += callDuration; |
||||
|
||||
adjustedProfiles.push({ |
||||
...profile, |
||||
...{ |
||||
nextProfileCreatedTime, |
||||
callDuration, |
||||
answered, |
||||
media, |
||||
bridged, |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
// Look for bridge and hangup on every channel, even if they didn't have a profile timestamp (in theory every profile will always have a created timestamp)
|
||||
let firstBridge: Date | undefined; |
||||
let lastCallEnd: Date | undefined; |
||||
for (const profile of profiles) { |
||||
const { channelBridgedTime, channelHangupTime, nextProfileCreatedTime, bridgedTo } = profile; |
||||
const callEnd = channelHangupTime || nextProfileCreatedTime; |
||||
|
||||
if (channelBridgedTime && (!firstBridge || firstBridge > channelBridgedTime)) { |
||||
firstBridge = channelBridgedTime; |
||||
} |
||||
|
||||
if ((callEnd || 0) > (lastCallEnd || 0)) { |
||||
lastCallEnd = callEnd; |
||||
} |
||||
|
||||
// If this profile was filtered out from the list used by the first process, add it back to the final list here
|
||||
if (!sortedProfiles.includes(profile)) { |
||||
const bridged = Boolean(channelBridgedTime) || Boolean(bridgedTo); |
||||
anyBridge ||= bridged; |
||||
|
||||
adjustedProfiles.push({ ...profile, ...{ bridged } }); |
||||
} |
||||
} |
||||
|
||||
const firstCallStart = firstBridge || firstProfileCreate || firstChannelCreate; |
||||
const totalDuration = lastCallEnd && firstCallStart ? lastCallEnd.valueOf() - firstCallStart.valueOf() : 0; |
||||
|
||||
return { |
||||
profiles: adjustedProfiles, |
||||
anyMedia, |
||||
anyAnswer, |
||||
anyBridge, |
||||
durationSum, |
||||
totalDuration, |
||||
startedAt: firstCallStart, |
||||
}; |
||||
} |
||||
@ -0,0 +1,72 @@ |
||||
// Make an object with a list of changes from the previous accumulated channel state and the state of the next event
|
||||
export function extractChannelChangesFromEvent( |
||||
channelState: Record<string, any>, |
||||
_eventName: string, |
||||
eventValues: Record<string, any>, |
||||
): { |
||||
changedValues: Record<string, any>; |
||||
newValues: Record<string, any>; |
||||
changedExistingValues: Record<string, { oldValue: any; newValue: any; delta?: number }>; |
||||
} { |
||||
const changedValues: Record<string, any> = {}; |
||||
const newValues: Record<string, any> = {}; |
||||
const changedExistingValues: Record<string, any> = {}; |
||||
|
||||
const getValues = (key: string): { oldValue: any; newValue: any } => { |
||||
const oldValue = channelState[key]; |
||||
const newValue = eventValues[key]; |
||||
|
||||
if (key !== 'bridgeUniqueIds') { |
||||
return { oldValue, newValue }; |
||||
} |
||||
|
||||
// For the bridgeUniqueIds field specifically, only add new values, never remove
|
||||
const oldList = Array.isArray(oldValue) ? oldValue : [oldValue]; |
||||
const newList = Array.isArray(newValue) ? newValue : [newValue]; |
||||
const fullList = [...new Set<string>([...oldList, ...newList])].filter((id) => id); |
||||
|
||||
return { oldValue, newValue: fullList }; |
||||
}; |
||||
|
||||
for (const key of Object.keys(eventValues)) { |
||||
const { oldValue, newValue } = getValues(key); |
||||
|
||||
if (newValue === undefined || oldValue === newValue) { |
||||
continue; |
||||
} |
||||
|
||||
const isDate = typeof newValue === 'object' && newValue instanceof Date && typeof oldValue === 'object' && oldValue instanceof Date; |
||||
|
||||
if (isDate && newValue.toISOString() === oldValue.toISOString()) { |
||||
continue; |
||||
} |
||||
|
||||
if (Array.isArray(oldValue) || Array.isArray(newValue)) { |
||||
const oldList = Array.isArray(oldValue) ? oldValue : [oldValue]; |
||||
const newList = Array.isArray(newValue) ? newValue : [newValue]; |
||||
|
||||
const isEqual = !oldList.some((item) => !newList.includes(item)) && !newList.some((item) => !oldList.includes(item)); |
||||
|
||||
if (key.startsWith('variables.') && isEqual) { |
||||
continue; |
||||
} |
||||
if (key === 'bridgeUniqueIds' && newList.length <= oldList.length) { |
||||
continue; |
||||
} |
||||
} |
||||
|
||||
if (oldValue === undefined) { |
||||
newValues[key] = newValue; |
||||
} else { |
||||
changedExistingValues[key] = { |
||||
oldValue, |
||||
newValue, |
||||
...(isDate && { delta: newValue.valueOf() - oldValue.valueOf() }), |
||||
}; |
||||
} |
||||
|
||||
changedValues[key] = newValue; |
||||
} |
||||
|
||||
return { changedValues, newValues, changedExistingValues }; |
||||
} |
||||
@ -0,0 +1,21 @@ |
||||
import { objectMap } from '@rocket.chat/tools'; |
||||
|
||||
export function filterOutMissingData<T extends Record<string, any>>(data: T): T { |
||||
return objectMap( |
||||
data, |
||||
({ key, value }) => { |
||||
if (typeof value !== 'boolean') { |
||||
if (!value || value === '0') { |
||||
return; |
||||
} |
||||
} |
||||
|
||||
if (typeof value === 'object' && !(value instanceof Date) && !Array.isArray(value) && !Object.keys(value).length) { |
||||
return; |
||||
} |
||||
|
||||
return { key, value }; |
||||
}, |
||||
true, |
||||
) as T; |
||||
} |
||||
@ -0,0 +1,16 @@ |
||||
import type { EventData } from './parseEventData'; |
||||
|
||||
export function filterStringList( |
||||
object: EventData, |
||||
filterFn: (key: string) => boolean, |
||||
mapFn?: (data: [string, string | string[] | undefined]) => [string, string | string[] | undefined] | undefined, |
||||
): EventData { |
||||
const filteredEntries = Object.entries(object).filter(([key]) => filterFn(key)); |
||||
|
||||
if (!mapFn) { |
||||
return Object.fromEntries(filteredEntries) as EventData; |
||||
} |
||||
|
||||
const mappedEntries = filteredEntries.map(mapFn).filter((entry) => entry) as [string, string][]; |
||||
return Object.fromEntries(mappedEntries); |
||||
} |
||||
@ -0,0 +1,44 @@ |
||||
import type { IFreeSwitchChannelEventMutable, IFreeSwitchChannelEventLegProfile } from '@rocket.chat/core-typings'; |
||||
import { isRecord } from '@rocket.chat/tools'; |
||||
|
||||
/** |
||||
* Returns a soft-copy of the eventData, with the specified data inserted into the profile of the channel's main leg, if it exists. |
||||
* While this returns a new object and the original is not mutated, the result is not a complete hard copy and may still include references to the original |
||||
*/ |
||||
export function insertDataIntoEventProfile( |
||||
eventData: IFreeSwitchChannelEventMutable, |
||||
dataToInsertIntoProfile: Partial<Pick<IFreeSwitchChannelEventLegProfile, 'bridgedTo' | 'callee' | 'caller'>>, |
||||
): IFreeSwitchChannelEventMutable { |
||||
if (!isRecord(eventData.legs)) { |
||||
return eventData; |
||||
} |
||||
|
||||
const clonedData = { |
||||
...eventData, |
||||
// Clone each leg individually, as we might mutate it
|
||||
legs: Object.fromEntries( |
||||
Object.entries(eventData.legs).map(([key, leg]) => [ |
||||
key, |
||||
{ ...leg, ...(isRecord(leg.profiles) && { profiles: { ...leg.profiles } }) }, |
||||
]), |
||||
), |
||||
}; |
||||
|
||||
for (const leg of Object.values(clonedData.legs)) { |
||||
if (!isRecord(leg?.profiles)) { |
||||
continue; |
||||
} |
||||
|
||||
// The raw event can never have more than one profile at the same time, it's only a record because the key for the profile can change between events
|
||||
const legProfileKey = Object.keys(leg.profiles).pop(); |
||||
|
||||
if (legProfileKey && isRecord(leg.profiles[legProfileKey])) { |
||||
leg.profiles[legProfileKey] = { |
||||
...leg.profiles[legProfileKey], |
||||
...dataToInsertIntoProfile, |
||||
}; |
||||
} |
||||
} |
||||
|
||||
return clonedData; |
||||
} |
||||
@ -0,0 +1,21 @@ |
||||
import type { IFreeSwitchChannel } from '@rocket.chat/core-typings'; |
||||
|
||||
export function parseChannelKind(channelName?: string): IFreeSwitchChannel['kind'] { |
||||
if (!channelName) { |
||||
return 'unknown'; |
||||
} |
||||
|
||||
if (channelName.startsWith('sofia/internal/')) { |
||||
return 'internal'; |
||||
} |
||||
|
||||
if (channelName.startsWith('sofia/external/')) { |
||||
return 'external'; |
||||
} |
||||
|
||||
if (channelName.startsWith('loopback/voicemail')) { |
||||
return 'voicemail'; |
||||
} |
||||
|
||||
return 'unknown'; |
||||
} |
||||
@ -0,0 +1,44 @@ |
||||
/** |
||||
* Gets the FreeSwitch username associated with the channel that an event was triggered for |
||||
* |
||||
* By design of our integration, FreeSwitch usernames are always equal to the User's Extension Number. |
||||
* So effectively this returns an extension number. |
||||
*/ |
||||
|
||||
import { logger } from '../logger'; |
||||
import type { EventData } from './parseEventData'; |
||||
|
||||
export function parseChannelUsername(channelName?: string): string | undefined { |
||||
if (!channelName || channelName.startsWith('loopback/')) { |
||||
return; |
||||
} |
||||
|
||||
// If it's not a sofia internal/external channel, don't even try to parse it
|
||||
// It's most likely a voicemail or maybe some spam bots trying different stuff
|
||||
// If we implement other kinds of channels in the future we should look into how their names are generated so that we may parse them here too.
|
||||
// The format for external channels may depend on what the external service is, but extension@host should be quite standard
|
||||
if ((!channelName.startsWith('sofia/internal/') && !channelName.startsWith('sofia/external/')) || !channelName.includes('@')) { |
||||
logger.info({ msg: 'FreeSwitch event triggered with something other than a sofia or loopback channel.', channelName }); |
||||
return; |
||||
} |
||||
|
||||
// Originator channels will have the format 'sofia/internal/username@freeswitch_host', assigned by freeswitch itself
|
||||
// Example: sofia/internal/1001@voip.open.rocket.chat:9999
|
||||
|
||||
// Originatee channels will have the format 'sofia/internal/contact_uri', assigned by freeswitch itself
|
||||
// Example: sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-spo254ol@open.rocket.chat
|
||||
|
||||
return channelName.match(/sofia\/(?:in|ex)ternal\/(\d+)[\@\-]/)?.[1]; |
||||
} |
||||
|
||||
export function parseContactUsername(contactNameOrUri: string): string | undefined { |
||||
// Contact URI format is 'username-rocketchat_userid-random_key@rocketchat_hostname', assigned by the rocket.chat client on the REGISTER request
|
||||
// Non-rocket.chat sessions will likely have a different format
|
||||
return contactNameOrUri.match(/^(\d+)\-/)?.[1]; |
||||
} |
||||
|
||||
export function parseEventUsername(eventData: EventData): string | undefined { |
||||
const { 'Channel-Name': channelName } = eventData; |
||||
|
||||
return parseChannelUsername(channelName); |
||||
} |
||||
@ -0,0 +1,39 @@ |
||||
import type { EventData } from './parseEventData'; |
||||
|
||||
function shouldUseOtherLegId(eventData: EventData): boolean { |
||||
// If the call ID is from a different channel, then it should be correct
|
||||
if (eventData['Channel-Call-UUID'] !== eventData['Unique-ID']) { |
||||
return false; |
||||
} |
||||
|
||||
// If we don't have an originator ID, then we don't have anything to overwrite with
|
||||
if (eventData['Other-Type'] !== 'originator' || !eventData['Other-Leg-Unique-ID']) { |
||||
return false; |
||||
} |
||||
|
||||
// #ToDo: Confirm if these conditions hold up on calls with extra legs (eg. voicemail)
|
||||
if ( |
||||
eventData['Caller-Direction'] !== 'outbound' || |
||||
(eventData['Other-Leg-Direction'] === 'outbound' && eventData['Other-Leg-Logical-Direction']) |
||||
) { |
||||
return false; |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
|
||||
/** |
||||
* Gets the call id from the event data. |
||||
* For most cases the call id will be the value that freeswitch sends on 'Channel-Call-UUID', |
||||
* but on the callee leg of a call that variable will only have the correct value on events triggered while the call is ongoing |
||||
* so for the callee leg we sometimes pick it from a different attribute. |
||||
* |
||||
* This function doesn't validate if an id was actually received, so it might return undefined, but FreeSwitch SHOULD always be sending one. |
||||
*/ |
||||
export function parseEventCallId(eventData: EventData): string | undefined { |
||||
if (shouldUseOtherLegId(eventData)) { |
||||
return eventData['Other-Leg-Unique-ID']; |
||||
} |
||||
|
||||
return eventData['Channel-Call-UUID']; |
||||
} |
||||
@ -0,0 +1,168 @@ |
||||
import type { IFreeSwitchChannelEvent } from '@rocket.chat/core-typings'; |
||||
|
||||
import { filterOutMissingData } from './filterOutMissingData'; |
||||
import { filterStringList } from './filterStringList'; |
||||
import { parseChannelUsername } from './parseChannelUsername'; |
||||
import { parseEventCallId } from './parseEventCallId'; |
||||
import { parseEventLeg } from './parseEventLeg'; |
||||
import { parseTimestamp } from './parseTimestamp'; |
||||
import { logger } from '../logger'; |
||||
import { parseEventExtensions } from './parseEventExtensions'; |
||||
|
||||
export type EventData = Record<string, string | undefined> & Record<`variable_${string}`, string | string[] | undefined>; |
||||
|
||||
export function parseEventData(eventName: string, eventData: EventData): Omit<IFreeSwitchChannelEvent, '_id' | '_updatedAt'> | undefined { |
||||
const { |
||||
'Channel-Name': channelName = '', |
||||
'Channel-State': channelState = '', |
||||
'Channel-Call-State': channelCallState = '', |
||||
'Channel-State-Number': channelStateNumber, |
||||
'Channel-Call-State-Number': channelCallStateNumber, |
||||
'Original-Channel-Call-State': originalChannelCallState, |
||||
'Event-Sequence': sequenceStr, |
||||
'Event-Date-Timestamp': timestamp, |
||||
'Unique-ID': channelUniqueId, |
||||
|
||||
'Call-Direction': callDirection, |
||||
'Channel-HIT-Dialplan': channelHitDialplan, |
||||
'Answer-State': answerState, |
||||
|
||||
'Hangup-Cause': hangupCause, |
||||
|
||||
'Bridge-A-Unique-ID': bridgeA, |
||||
'Bridge-B-Unique-ID': bridgeB, |
||||
'Bridged-To': bridgedTo, |
||||
|
||||
'Presence-Call-Direction': presenceCallDirection, |
||||
'Channel-Presence-ID': channelPresenceId, |
||||
|
||||
'Channel-Read-Codec-Name': codecReadName, |
||||
'Channel-Read-Codec-Rate': codecReadRate, |
||||
'Channel-Write-Codec-Name': codecWriteName, |
||||
'Channel-Write-Codec-Rate': codecWriteRate, |
||||
|
||||
...rawEventData |
||||
} = eventData; |
||||
|
||||
if (!channelUniqueId || !sequenceStr) { |
||||
logger.error({ msg: 'Channel Event is missing either the Unique-ID or Event-Sequence', eventData }); |
||||
return; |
||||
} |
||||
|
||||
const sequence = parseInt(sequenceStr); |
||||
if (!sequence || typeof sequence !== 'number' || !Number.isInteger(sequence)) { |
||||
logger.error({ msg: 'Failed to parse Event-Sequence', eventData }); |
||||
return; |
||||
} |
||||
|
||||
const callUniqueId = parseEventCallId(eventData) || channelUniqueId; |
||||
const channelUsername = parseChannelUsername(channelName); |
||||
const firedAt = parseTimestamp(timestamp) || new Date(); |
||||
|
||||
const callerLeg = parseEventLeg('Caller', eventData); |
||||
const otherLeg = parseEventLeg('Other-Leg', eventData); |
||||
const bridgeUniqueIds = [bridgeA, bridgeB].filter((bridgeId) => bridgeId) as string[]; |
||||
|
||||
const legs: IFreeSwitchChannelEvent['legs'] = { |
||||
...(callerLeg?.uniqueId && { [callerLeg.uniqueId]: callerLeg }), |
||||
...(otherLeg?.uniqueId && { [otherLeg.uniqueId]: otherLeg }), |
||||
}; |
||||
|
||||
const variables = filterStringList( |
||||
eventData, |
||||
(key) => key.startsWith('variable_'), |
||||
([key, value]) => { |
||||
return [key.replace('variable_', ''), value || '']; |
||||
}, |
||||
) as Record<string, string | string[]>; |
||||
const metadata = filterStringList(eventData, (key) => isMetadata(key)) as Record<string, string>; |
||||
const unusedRawData = filterStringList(rawEventData, (key) => { |
||||
if (isMetadata(key)) { |
||||
return false; |
||||
} |
||||
if (key.startsWith('variable_')) { |
||||
return false; |
||||
} |
||||
|
||||
for (const { legName } of Object.values(legs)) { |
||||
if (key.startsWith(`${legName}-`)) { |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
if (otherLeg && key === 'Other-Type') { |
||||
return false; |
||||
} |
||||
|
||||
if (key === 'Channel-Call-UUID') { |
||||
return rawEventData['Channel-Call-UUID'] !== callUniqueId; |
||||
} |
||||
|
||||
return true; |
||||
}) as Record<string, string>; |
||||
|
||||
const event: Omit<IFreeSwitchChannelEvent, '_id' | '_updatedAt'> = { |
||||
channelUniqueId, |
||||
eventName, |
||||
sequence, |
||||
firedAt, |
||||
receivedAt: new Date(), |
||||
callUniqueId, |
||||
channelName, |
||||
channelState, |
||||
channelStateNumber, |
||||
channelCallStateNumber, |
||||
channelCallState, |
||||
|
||||
originalChannelCallState, |
||||
channelUsername, |
||||
answerState, |
||||
callDirection, |
||||
channelHitDialplan, |
||||
hangupCause, |
||||
|
||||
...(bridgeUniqueIds.length && { bridgeUniqueIds }), |
||||
bridgedTo, |
||||
legs, |
||||
metadata: filterOutMissingData(metadata), |
||||
...(Object.keys(variables).length && { variables }), |
||||
raw: filterOutMissingData(unusedRawData), |
||||
|
||||
codecs: { |
||||
...{ |
||||
read: { |
||||
...{ |
||||
name: codecReadName, |
||||
rate: codecReadRate, |
||||
}, |
||||
}, |
||||
write: { |
||||
...{ |
||||
nme: codecWriteName, |
||||
rate: codecWriteRate, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
|
||||
presenceCallDirection, |
||||
channelPresenceId, |
||||
}; |
||||
|
||||
const filteredEvent = { |
||||
...filterOutMissingData(event), |
||||
channelName, |
||||
channelCallState, |
||||
channelState, |
||||
}; |
||||
const extensions = parseEventExtensions(filteredEvent); |
||||
|
||||
return { |
||||
...filteredEvent, |
||||
...extensions, |
||||
}; |
||||
} |
||||
|
||||
function isMetadata(key: string): boolean { |
||||
return key.startsWith('Event-') || key.startsWith('FreeSWITCH-') || key.startsWith('Core-'); |
||||
} |
||||
@ -0,0 +1,113 @@ |
||||
import type { AtLeast, IFreeSwitchChannelEvent, IFreeSwitchChannelEventLeg } from '@rocket.chat/core-typings'; |
||||
|
||||
import { parseChannelUsername, parseContactUsername } from './parseChannelUsername'; |
||||
|
||||
function normalizeUsername(value?: string): string | undefined { |
||||
if (!value) { |
||||
return undefined; |
||||
} |
||||
|
||||
if (value.startsWith('sofia/internal') || value.startsWith('sofia/external')) { |
||||
return parseChannelUsername(value); |
||||
} |
||||
|
||||
if (value.match(/^\d+$/)) { |
||||
return value; |
||||
} |
||||
|
||||
return parseContactUsername(value); |
||||
} |
||||
|
||||
function getMostLikelyUsername(valueList: (string | undefined)[]): string | undefined { |
||||
const parsedValues: string[] = []; |
||||
|
||||
for (const value of valueList) { |
||||
const parsedValue = normalizeUsername(value); |
||||
if (!parsedValue) { |
||||
continue; |
||||
} |
||||
|
||||
if (parsedValue === value) { |
||||
return parsedValue; |
||||
} |
||||
|
||||
parsedValues.push(parsedValue); |
||||
} |
||||
|
||||
return parsedValues.shift(); |
||||
} |
||||
|
||||
function getOriginatorLeg( |
||||
event: Omit<IFreeSwitchChannelEvent, '_id' | '_updatedAt'>, |
||||
): AtLeast<IFreeSwitchChannelEventLeg, 'legName' | 'uniqueId' | 'raw'> | undefined { |
||||
const legs = event.legs && Object.values(event.legs); |
||||
if (!legs?.length) { |
||||
return undefined; |
||||
} |
||||
|
||||
const selfLeg = event.legs[event.channelUniqueId]; |
||||
const originator = legs.find((leg) => leg.type === 'originator'); |
||||
|
||||
if (event.callDirection === 'inbound') { |
||||
return originator || selfLeg; |
||||
} |
||||
|
||||
if (originator) { |
||||
return originator; |
||||
} |
||||
|
||||
const originatee = legs.find((leg) => leg.type === 'originatee'); |
||||
if (originatee && selfLeg && selfLeg.type !== 'originatee') { |
||||
return selfLeg; |
||||
} |
||||
|
||||
return undefined; |
||||
} |
||||
|
||||
export function parseEventExtensions( |
||||
event: Omit<IFreeSwitchChannelEvent, '_id' | '_updatedAt'>, |
||||
): { caller?: string; callee?: string } | undefined { |
||||
const legs = event.legs && Object.values(event.legs); |
||||
const selfLeg = event.legs?.[event.channelUniqueId]; |
||||
|
||||
const allDestinationNumbers = legs?.map(({ destinationNumber }) => destinationNumber) || []; |
||||
const allCallerNumbers = legs?.map(({ callerNumber }) => callerNumber) || []; |
||||
|
||||
// The dialed_extension variable is only available in a few specific events, but when it's there, it's ALWAYS right.
|
||||
// It won't ever be an array, but just to be type-safe
|
||||
const dialedExtension = Array.isArray(event.variables?.dialed_extension) |
||||
? event.variables.dialed_extension.shift() |
||||
: event.variables?.dialed_extension; |
||||
|
||||
const originator = getOriginatorLeg(event); |
||||
if (event.callDirection === 'outbound' && originator) { |
||||
// If we have an originator, use it as the source of truth
|
||||
return { |
||||
caller: getMostLikelyUsername([originator.channelName]), |
||||
callee: getMostLikelyUsername([dialedExtension, originator.destinationNumber]), |
||||
}; |
||||
} |
||||
|
||||
// If the channel is inbound, then it has never received any call, only initiated.
|
||||
if (event.callDirection === 'inbound') { |
||||
// The username of every leg is always the original caller
|
||||
const anyUsername = legs |
||||
?.map(({ username }) => username) |
||||
.filter((username) => username) |
||||
.pop(); |
||||
|
||||
return { |
||||
caller: getMostLikelyUsername([event.channelUsername, selfLeg?.username, anyUsername]), |
||||
// Callee might not be available at all if the state is still CS_NEW
|
||||
callee: getMostLikelyUsername([dialedExtension, selfLeg?.destinationNumber, ...allDestinationNumbers]), |
||||
}; |
||||
} |
||||
|
||||
// Caller-Number and Destination-Number always have some sort of identification of the right caller/destination
|
||||
// For rocket.chat internal calls, we'll always be able to parse it into an extension number
|
||||
// For external calls, this might not be identifying the extension at all.
|
||||
return { |
||||
caller: getMostLikelyUsername([...allCallerNumbers]), |
||||
callee: getMostLikelyUsername([dialedExtension, ...allDestinationNumbers]), |
||||
}; |
||||
} |
||||
@ -0,0 +1,117 @@ |
||||
import type { AtLeast, IFreeSwitchChannelEventLeg, IFreeSwitchChannelEventLegProfile } from '@rocket.chat/core-typings'; |
||||
|
||||
import { filterOutMissingData } from './filterOutMissingData'; |
||||
import { filterStringList } from './filterStringList'; |
||||
import type { EventData } from './parseEventData'; |
||||
import { parseTimestamp } from './parseTimestamp'; |
||||
|
||||
export function parseEventLeg( |
||||
legName: string, |
||||
eventData: EventData, |
||||
): AtLeast<IFreeSwitchChannelEventLeg, 'legName' | 'uniqueId' | 'raw'> | undefined { |
||||
const legData = filterStringList( |
||||
eventData, |
||||
(key) => key.startsWith(`${legName}-`), |
||||
([key, value]) => { |
||||
return [key.replace(`${legName}-`, ''), value]; |
||||
}, |
||||
) as Record<string, string>; |
||||
|
||||
const legType = legName === 'Other-Leg' ? eventData['Other-Type'] : undefined; |
||||
|
||||
const { |
||||
'Direction': direction, |
||||
'Logical-Direction': logicalDirection, |
||||
'Username': username, |
||||
'Caller-ID-Name': callerName, |
||||
'Caller-ID-Number': callerNumber, |
||||
'Orig-Caller-ID-Name': originalCallerName, |
||||
'Orig-Caller-ID-Number': originalCallerNumber, |
||||
'Callee-ID-Name': calleeName, |
||||
'Callee-ID-Number': calleeNumber, |
||||
'Network-Addr': networkAddress, |
||||
'Destination-Number': destinationNumber, |
||||
'Unique-ID': uniqueId, |
||||
'Source': source, |
||||
'Context': context, |
||||
'Channel-Name': channelName, |
||||
|
||||
'Dialplan': dialplan, |
||||
'Profile-Index': profileIndex, |
||||
'ANI': ani, |
||||
'RDNIS': rdnis, |
||||
'Transfer-Source': transferSource, |
||||
'Screen-Bit': screenBit, |
||||
'Privacy-Hide-Name': privacyHideName, |
||||
'Privacy-Hide-Number': privacyHideNumber, |
||||
|
||||
'Profile-Created-Time': profileCreatedTime, |
||||
'Channel-Created-Time': channelCreatedTime, |
||||
'Channel-Answered-Time': channelAnsweredTime, |
||||
'Channel-Progress-Time': channelProgressTime, |
||||
'Channel-Bridged-Time': channelBridgedTime, |
||||
'Channel-Progress-Media-Time': channelProgressMediaTime, |
||||
'Channel-Hangup-Time': channelHangupTime, |
||||
'Channel-Transfer-Time': channelTransferTime, |
||||
'Channel-Resurrect-Time': channelRessurectTime, |
||||
'Channel-Last-Hold': channelLastHold, |
||||
...rawLegData |
||||
} = legData; |
||||
|
||||
if (!uniqueId) { |
||||
return; |
||||
} |
||||
|
||||
const profile: IFreeSwitchChannelEventLegProfile = { |
||||
...filterOutMissingData({ |
||||
profileIndex, |
||||
profileCreatedTime: parseTimestamp(profileCreatedTime), |
||||
channelCreatedTime: parseTimestamp(channelCreatedTime), |
||||
channelAnsweredTime: parseTimestamp(channelAnsweredTime), |
||||
channelProgressTime: parseTimestamp(channelProgressTime), |
||||
channelBridgedTime: parseTimestamp(channelBridgedTime), |
||||
channelProgressMediaTime: parseTimestamp(channelProgressMediaTime), |
||||
channelHangupTime: parseTimestamp(channelHangupTime), |
||||
channelTransferTime: parseTimestamp(channelTransferTime), |
||||
channelRessurectTime: parseTimestamp(channelRessurectTime), |
||||
channelLastHold: parseTimestamp(channelLastHold), |
||||
}), |
||||
}; |
||||
|
||||
const effectiveProfileIndex = profileIndex || '1'; |
||||
|
||||
const leg: AtLeast<IFreeSwitchChannelEventLeg, 'legName' | 'uniqueId' | 'raw'> = { |
||||
legName, |
||||
uniqueId, |
||||
type: legType, |
||||
direction, |
||||
logicalDirection, |
||||
username, |
||||
callerName, |
||||
callerNumber, |
||||
originalCallerName, |
||||
originalCallerNumber, |
||||
calleeName, |
||||
calleeNumber, |
||||
networkAddress, |
||||
destinationNumber, |
||||
source, |
||||
context, |
||||
channelName, |
||||
transferSource, |
||||
|
||||
// If there's no profileIndex, default to '1', but do not save a profile if there's nothing in it
|
||||
...(Object.keys(profile).length > 0 && { profiles: { [effectiveProfileIndex]: { ...profile, profileIndex: effectiveProfileIndex } } }), |
||||
|
||||
dialplan, |
||||
ani, |
||||
rdnis, |
||||
screenBit, |
||||
privacyHideName, |
||||
privacyHideNumber, |
||||
|
||||
raw: filterOutMissingData(rawLegData), |
||||
}; |
||||
|
||||
return filterOutMissingData(leg); |
||||
} |
||||
@ -0,0 +1,13 @@ |
||||
export function parseTimestamp(timestamp: string | undefined): Date | undefined { |
||||
if (!timestamp || timestamp === '0') { |
||||
return undefined; |
||||
} |
||||
|
||||
const value = parseInt(timestamp); |
||||
if (Number.isNaN(value) || value < 0) { |
||||
return undefined; |
||||
} |
||||
|
||||
const timeValue = Math.floor(value / 1000); |
||||
return new Date(timeValue); |
||||
} |
||||
@ -1,4 +1,6 @@ |
||||
export * from './commands'; |
||||
export * from './esl'; |
||||
export * from './logger'; |
||||
export * from './eventParser/parseEventData'; |
||||
export * from './eventParser/computeChannelFromEvents'; |
||||
export * from './esl'; |
||||
export * from './FreeSwitchOptions'; |
||||
|
||||
@ -0,0 +1,706 @@ |
||||
import { describe, expect, it } from '@jest/globals'; |
||||
import type { IFreeSwitchChannelEvent } from '@rocket.chat/core-typings'; |
||||
|
||||
import { computeChannelFromEvents } from '../../src/eventParser/computeChannelFromEvents'; |
||||
|
||||
describe('computeChannelFromEvents', () => { |
||||
const createTestEvent = (overrides: Omit<IFreeSwitchChannelEvent, '_id' | '_updatedAt'>): IFreeSwitchChannelEvent => ({ |
||||
_id: 'event-123', |
||||
_updatedAt: new Date(), |
||||
...overrides, |
||||
}); |
||||
|
||||
it('should compute channel from events', async () => { |
||||
const events = [ |
||||
createTestEvent({ |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
metadata: {}, |
||||
firedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
callDirection: 'outbound', |
||||
caller: '2001', |
||||
callee: '2002', |
||||
raw: {}, |
||||
legs: { |
||||
'Caller-Leg': { |
||||
legName: 'Caller-Leg', |
||||
uniqueId: 'channel-123', |
||||
direction: 'outbound', |
||||
logicalDirection: 'outbound', |
||||
username: '1001', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
destinationNumber: '1002', |
||||
raw: {}, |
||||
profiles: { |
||||
1: { |
||||
profileIndex: '1', |
||||
channelCreatedTime: new Date('2024-02-28T12:00:00.000Z'), |
||||
profileCreatedTime: new Date('2024-02-28T12:00:00.000Z'), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}), |
||||
createTestEvent({ |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_ANSWER', |
||||
sequence: 2, |
||||
metadata: {}, |
||||
firedAt: new Date('2024-02-28T12:00:01.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:01.100Z'), |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
channelState: 'CS_EXECUTE', |
||||
channelCallState: 'RINGING', |
||||
callDirection: 'outbound', |
||||
channelUsername: '1001', |
||||
raw: {}, |
||||
legs: { |
||||
'Caller-Leg': { |
||||
legName: 'Caller-Leg', |
||||
uniqueId: 'channel-123', |
||||
direction: 'outbound', |
||||
logicalDirection: 'outbound', |
||||
username: '1001', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
destinationNumber: '1002', |
||||
raw: {}, |
||||
profiles: { |
||||
1: { |
||||
profileIndex: '1', |
||||
channelCreatedTime: new Date('2024-02-28T12:00:00.000Z'), |
||||
profileCreatedTime: new Date('2024-02-28T12:00:00.000Z'), |
||||
channelAnsweredTime: new Date('2024-02-28T12:00:01.000Z'), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}), |
||||
createTestEvent({ |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_BRIDGE', |
||||
sequence: 3, |
||||
metadata: {}, |
||||
firedAt: new Date('2024-02-28T12:00:02.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:02.100Z'), |
||||
bridgeUniqueIds: ['channel-123', 'channel-456'], |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
channelState: 'CS_EXECUTE', |
||||
channelCallState: 'ACTIVE', |
||||
raw: {}, |
||||
legs: {}, |
||||
}), |
||||
]; |
||||
|
||||
const result = await computeChannelFromEvents(events); |
||||
|
||||
expect(result).toEqual({ |
||||
channel: { |
||||
uniqueId: 'channel-123', |
||||
name: 'sofia/internal/1001@192.168.1.100', |
||||
callDirection: 'outbound', |
||||
freeSwitchUser: '1001', |
||||
callers: ['2001'], |
||||
callees: ['2002'], |
||||
bridgedTo: ['channel-456'], |
||||
profiles: [], |
||||
anyMedia: false, |
||||
anyAnswer: false, |
||||
anyBridge: false, |
||||
durationSum: 0, |
||||
totalDuration: 0, |
||||
startedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
kind: 'internal', |
||||
finalState: { |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_BRIDGE', |
||||
sequence: 3, |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
channelState: 'CS_EXECUTE', |
||||
channelCallState: 'ACTIVE', |
||||
callDirection: 'outbound', |
||||
bridgeUniqueIds: ['channel-123', 'channel-456'], |
||||
legs: { |
||||
'Caller-Leg': { |
||||
legName: 'Caller-Leg', |
||||
uniqueId: 'channel-123', |
||||
direction: 'outbound', |
||||
logicalDirection: 'outbound', |
||||
username: '1001', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
destinationNumber: '1002', |
||||
profiles: { |
||||
1: { |
||||
profileIndex: '1', |
||||
channelCreatedTime: new Date('2024-02-28T12:00:00.000Z'), |
||||
profileCreatedTime: new Date('2024-02-28T12:00:00.000Z'), |
||||
channelAnsweredTime: new Date('2024-02-28T12:00:01.000Z'), |
||||
caller: '2001', |
||||
callee: '2002', |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
channelUsername: '1001', |
||||
}, |
||||
events: [ |
||||
{ |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
caller: '2001', |
||||
callee: '2002', |
||||
answerState: undefined, |
||||
originalChannelCallState: undefined, |
||||
}, |
||||
{ |
||||
eventName: 'CHANNEL_ANSWER', |
||||
sequence: 2, |
||||
firedAt: new Date('2024-02-28T12:00:01.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:01.100Z'), |
||||
channelState: 'CS_EXECUTE', |
||||
channelCallState: 'RINGING', |
||||
callee: undefined, |
||||
caller: undefined, |
||||
answerState: undefined, |
||||
originalChannelCallState: undefined, |
||||
}, |
||||
{ |
||||
eventName: 'CHANNEL_BRIDGE', |
||||
sequence: 3, |
||||
firedAt: new Date('2024-02-28T12:00:02.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:02.100Z'), |
||||
channelState: 'CS_EXECUTE', |
||||
channelCallState: 'ACTIVE', |
||||
callee: undefined, |
||||
caller: undefined, |
||||
answerState: undefined, |
||||
originalChannelCallState: undefined, |
||||
}, |
||||
], |
||||
}, |
||||
deltas: [ |
||||
{ |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
callee: '2002', |
||||
caller: '2001', |
||||
channelCallState: 'DOWN', |
||||
channelState: 'CS_NEW', |
||||
newValues: { |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
callDirection: 'outbound', |
||||
legs: { |
||||
'Caller-Leg': { |
||||
legName: 'Caller-Leg', |
||||
uniqueId: 'channel-123', |
||||
direction: 'outbound', |
||||
logicalDirection: 'outbound', |
||||
profiles: { |
||||
1: { |
||||
profileIndex: '1', |
||||
channelCreatedTime: new Date('2024-02-28T12:00:00.000Z'), |
||||
profileCreatedTime: new Date('2024-02-28T12:00:00.000Z'), |
||||
caller: '2001', |
||||
callee: '2002', |
||||
}, |
||||
}, |
||||
username: '1001', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
destinationNumber: '1002', |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
{ |
||||
eventName: 'CHANNEL_ANSWER', |
||||
sequence: 2, |
||||
firedAt: new Date('2024-02-28T12:00:01.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:01.100Z'), |
||||
channelCallState: 'RINGING', |
||||
channelState: 'CS_EXECUTE', |
||||
newValues: { |
||||
channelUsername: '1001', |
||||
legs: { |
||||
'Caller-Leg': { |
||||
profiles: { |
||||
1: { |
||||
channelAnsweredTime: new Date('2024-02-28T12:00:01.000Z'), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
modifiedValues: { |
||||
channelState: { oldValue: 'CS_NEW', newValue: 'CS_EXECUTE' }, |
||||
channelCallState: { oldValue: 'DOWN', newValue: 'RINGING' }, |
||||
}, |
||||
}, |
||||
{ |
||||
eventName: 'CHANNEL_BRIDGE', |
||||
sequence: 3, |
||||
firedAt: new Date('2024-02-28T12:00:02.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:02.100Z'), |
||||
channelCallState: 'ACTIVE', |
||||
channelState: 'CS_EXECUTE', |
||||
newValues: { |
||||
bridgeUniqueIds: ['channel-123', 'channel-456'], |
||||
}, |
||||
modifiedValues: { |
||||
channelCallState: { oldValue: 'RINGING', newValue: 'ACTIVE' }, |
||||
}, |
||||
}, |
||||
], |
||||
}); |
||||
}); |
||||
|
||||
it('should handle missing legs', async () => { |
||||
const events = [ |
||||
createTestEvent({ |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
metadata: {}, |
||||
firedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
raw: {}, |
||||
legs: {}, |
||||
bridgedTo: 'channel-456', |
||||
}), |
||||
]; |
||||
|
||||
const result = await computeChannelFromEvents(events); |
||||
|
||||
expect(result).toEqual({ |
||||
channel: { |
||||
uniqueId: 'channel-123', |
||||
name: 'sofia/internal/1001@192.168.1.100', |
||||
callDirection: '', |
||||
callers: [], |
||||
callees: [], |
||||
bridgedTo: ['channel-456'], |
||||
profiles: [], |
||||
anyMedia: false, |
||||
anyAnswer: false, |
||||
anyBridge: false, |
||||
durationSum: 0, |
||||
totalDuration: 0, |
||||
startedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
kind: 'internal', |
||||
finalState: { |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
bridgedTo: 'channel-456', |
||||
}, |
||||
events: [ |
||||
{ |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
callee: undefined, |
||||
caller: undefined, |
||||
answerState: undefined, |
||||
originalChannelCallState: undefined, |
||||
}, |
||||
], |
||||
}, |
||||
deltas: [ |
||||
{ |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
channelCallState: 'DOWN', |
||||
channelState: 'CS_NEW', |
||||
newValues: { |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
bridgedTo: 'channel-456', |
||||
}, |
||||
}, |
||||
], |
||||
}); |
||||
}); |
||||
|
||||
it('should handle missing caller leg', async () => { |
||||
const events = [ |
||||
createTestEvent({ |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
metadata: {}, |
||||
firedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
callUniqueId: 'call-123', |
||||
channelName: '', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
raw: {}, |
||||
legs: { |
||||
'Other-Leg': { |
||||
legName: 'Other-Leg', |
||||
uniqueId: 'channel-456', |
||||
direction: 'inbound', |
||||
logicalDirection: 'inbound', |
||||
username: '1002', |
||||
channelName: 'sofia/internal/1002@192.168.1.101', |
||||
destinationNumber: '1001', |
||||
}, |
||||
}, |
||||
}), |
||||
]; |
||||
|
||||
const result = await computeChannelFromEvents(events); |
||||
|
||||
expect(result).toEqual({ |
||||
channel: { |
||||
uniqueId: 'channel-123', |
||||
callDirection: '', |
||||
callers: [], |
||||
callees: [], |
||||
bridgedTo: [], |
||||
profiles: [], |
||||
anyMedia: false, |
||||
anyAnswer: false, |
||||
anyBridge: false, |
||||
durationSum: 0, |
||||
totalDuration: 0, |
||||
startedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
kind: 'unknown', |
||||
finalState: { |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
callUniqueId: 'call-123', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
legs: { |
||||
'Other-Leg': { |
||||
legName: 'Other-Leg', |
||||
uniqueId: 'channel-456', |
||||
direction: 'inbound', |
||||
logicalDirection: 'inbound', |
||||
username: '1002', |
||||
channelName: 'sofia/internal/1002@192.168.1.101', |
||||
destinationNumber: '1001', |
||||
}, |
||||
}, |
||||
}, |
||||
events: [ |
||||
{ |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
callee: undefined, |
||||
caller: undefined, |
||||
answerState: undefined, |
||||
originalChannelCallState: undefined, |
||||
}, |
||||
], |
||||
}, |
||||
deltas: [ |
||||
{ |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
newValues: { |
||||
callUniqueId: 'call-123', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
legs: { |
||||
'Other-Leg': { |
||||
legName: 'Other-Leg', |
||||
uniqueId: 'channel-456', |
||||
direction: 'inbound', |
||||
logicalDirection: 'inbound', |
||||
username: '1002', |
||||
channelName: 'sofia/internal/1002@192.168.1.101', |
||||
destinationNumber: '1001', |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
], |
||||
}); |
||||
}); |
||||
|
||||
it('should handle missing call ID', async () => { |
||||
const events = [ |
||||
createTestEvent({ |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
metadata: {}, |
||||
firedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
callUniqueId: '', |
||||
channelName: '', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
raw: {}, |
||||
legs: {}, |
||||
}), |
||||
]; |
||||
|
||||
const result = await computeChannelFromEvents(events); |
||||
|
||||
expect(result).toEqual({ |
||||
channel: { |
||||
uniqueId: 'channel-123', |
||||
callDirection: '', |
||||
callers: [], |
||||
callees: [], |
||||
bridgedTo: [], |
||||
profiles: [], |
||||
anyMedia: false, |
||||
anyAnswer: false, |
||||
anyBridge: false, |
||||
durationSum: 0, |
||||
totalDuration: 0, |
||||
startedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
kind: 'unknown', |
||||
finalState: { |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
}, |
||||
events: [ |
||||
{ |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
callee: undefined, |
||||
caller: undefined, |
||||
answerState: undefined, |
||||
originalChannelCallState: undefined, |
||||
}, |
||||
], |
||||
}, |
||||
deltas: [ |
||||
{ |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date('2024-02-28T12:00:00.000Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
newValues: { channelState: 'CS_NEW', channelCallState: 'DOWN' }, |
||||
}, |
||||
], |
||||
}); |
||||
}); |
||||
|
||||
it('should handle missing event timestamps', async () => { |
||||
const events = [ |
||||
createTestEvent({ |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
metadata: {}, |
||||
firedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1007@host', |
||||
channelUsername: '1007', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
raw: {}, |
||||
legs: {}, |
||||
}), |
||||
]; |
||||
|
||||
const result = await computeChannelFromEvents(events); |
||||
|
||||
expect(result).toEqual({ |
||||
channel: { |
||||
uniqueId: 'channel-123', |
||||
callDirection: '', |
||||
callers: [], |
||||
callees: [], |
||||
bridgedTo: [], |
||||
profiles: [], |
||||
anyMedia: false, |
||||
anyAnswer: false, |
||||
anyBridge: false, |
||||
durationSum: 0, |
||||
totalDuration: 0, |
||||
startedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
kind: 'internal', |
||||
name: 'sofia/internal/1007@host', |
||||
freeSwitchUser: '1007', |
||||
finalState: { |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
callUniqueId: 'call-123', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
channelName: 'sofia/internal/1007@host', |
||||
channelUsername: '1007', |
||||
}, |
||||
events: [ |
||||
{ |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
callee: undefined, |
||||
caller: undefined, |
||||
answerState: undefined, |
||||
originalChannelCallState: undefined, |
||||
}, |
||||
], |
||||
}, |
||||
deltas: [ |
||||
{ |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
receivedAt: new Date('2024-02-28T12:00:00.100Z'), |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
newValues: { |
||||
callUniqueId: 'call-123', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
channelName: 'sofia/internal/1007@host', |
||||
channelUsername: '1007', |
||||
}, |
||||
}, |
||||
], |
||||
}); |
||||
}); |
||||
|
||||
it('validate fallbacks for unlikely scenarios', async () => { |
||||
// Force missing
|
||||
const firedAt = undefined as unknown as Date; |
||||
const receivedAt = undefined as unknown as Date; |
||||
|
||||
const events = [ |
||||
createTestEvent({ |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
metadata: {}, |
||||
firedAt, |
||||
receivedAt, |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1007@host', |
||||
channelUsername: '1007', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
raw: {}, |
||||
legs: {}, |
||||
}), |
||||
]; |
||||
|
||||
const result = await computeChannelFromEvents(events); |
||||
|
||||
expect(result).toEqual({ |
||||
channel: { |
||||
uniqueId: 'channel-123', |
||||
callDirection: '', |
||||
callers: [], |
||||
callees: [], |
||||
bridgedTo: [], |
||||
profiles: [], |
||||
anyMedia: false, |
||||
anyAnswer: false, |
||||
anyBridge: false, |
||||
durationSum: 0, |
||||
totalDuration: 0, |
||||
kind: 'internal', |
||||
name: 'sofia/internal/1007@host', |
||||
freeSwitchUser: '1007', |
||||
finalState: { |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
callUniqueId: 'call-123', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
channelName: 'sofia/internal/1007@host', |
||||
channelUsername: '1007', |
||||
}, |
||||
events: [ |
||||
{ |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt, |
||||
receivedAt, |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
callee: undefined, |
||||
caller: undefined, |
||||
answerState: undefined, |
||||
originalChannelCallState: undefined, |
||||
}, |
||||
], |
||||
}, |
||||
deltas: [ |
||||
{ |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
newValues: { |
||||
callUniqueId: 'call-123', |
||||
channelState: 'CS_NEW', |
||||
channelCallState: 'DOWN', |
||||
channelName: 'sofia/internal/1007@host', |
||||
channelUsername: '1007', |
||||
}, |
||||
}, |
||||
], |
||||
}); |
||||
}); |
||||
|
||||
it('should return undefined for empty events array', async () => { |
||||
const result = await computeChannelFromEvents([]); |
||||
|
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,385 @@ |
||||
import { describe, expect, it } from '@jest/globals'; |
||||
import type { IFreeSwitchChannelEventLegProfile } from '@rocket.chat/core-typings'; |
||||
|
||||
import { computeChannelProfiles } from '../../src/eventParser/computeChannelProfiles'; |
||||
|
||||
describe('computeChannelProfiles', () => { |
||||
const createTestProfile = (overrides: Partial<IFreeSwitchChannelEventLegProfile>): IFreeSwitchChannelEventLegProfile => ({ |
||||
...overrides, |
||||
}); |
||||
|
||||
it('should compute channel profiles with all timestamps', () => { |
||||
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = { |
||||
profile1: createTestProfile({ |
||||
profileIndex: '1', |
||||
profileCreatedTime: new Date(1709123456789), |
||||
channelCreatedTime: new Date(1709123456790), |
||||
channelAnsweredTime: new Date(1709123457789), |
||||
channelProgressTime: new Date(1709123457889), |
||||
channelProgressMediaTime: new Date(1709123457989), |
||||
channelBridgedTime: new Date(1709123458789), |
||||
channelHangupTime: new Date(1709123466789), |
||||
channelTransferTime: new Date(1709123456791), |
||||
channelRessurectTime: new Date(1709123456792), |
||||
channelLastHold: new Date(1709123456793), |
||||
}), |
||||
}; |
||||
|
||||
const result = computeChannelProfiles(profiles); |
||||
|
||||
expect(result).toEqual({ |
||||
profiles: [ |
||||
{ |
||||
profileIndex: '1', |
||||
profileCreatedTime: new Date(1709123456789), |
||||
channelCreatedTime: new Date(1709123456790), |
||||
channelAnsweredTime: new Date(1709123457789), |
||||
channelProgressTime: new Date(1709123457889), |
||||
channelProgressMediaTime: new Date(1709123457989), |
||||
channelBridgedTime: new Date(1709123458789), |
||||
channelHangupTime: new Date(1709123466789), |
||||
channelTransferTime: new Date(1709123456791), |
||||
channelRessurectTime: new Date(1709123456792), |
||||
channelLastHold: new Date(1709123456793), |
||||
callDuration: 8000, |
||||
answered: true, |
||||
media: true, |
||||
bridged: true, |
||||
}, |
||||
], |
||||
anyMedia: true, |
||||
anyAnswer: true, |
||||
anyBridge: true, |
||||
durationSum: 8000, |
||||
totalDuration: 8000, |
||||
startedAt: new Date(1709123458789), |
||||
}); |
||||
}); |
||||
|
||||
it('should handle missing timestamps', () => { |
||||
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = { |
||||
profile1: createTestProfile({ |
||||
profileIndex: '1', |
||||
}), |
||||
}; |
||||
|
||||
const result = computeChannelProfiles(profiles); |
||||
expect(result).toEqual({ |
||||
profiles: [{ profileIndex: '1', bridged: false }], |
||||
anyMedia: false, |
||||
anyAnswer: false, |
||||
anyBridge: false, |
||||
durationSum: 0, |
||||
totalDuration: 0, |
||||
startedAt: undefined, |
||||
}); |
||||
}); |
||||
|
||||
it('should handle multiple profiles', () => { |
||||
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = { |
||||
profile1: createTestProfile({ |
||||
profileIndex: '1', |
||||
profileCreatedTime: new Date(1709123456789), |
||||
channelCreatedTime: new Date(1709123456790), |
||||
}), |
||||
profile2: createTestProfile({ |
||||
profileIndex: '2', |
||||
profileCreatedTime: new Date(1709123456791), |
||||
channelCreatedTime: new Date(1709123456792), |
||||
}), |
||||
}; |
||||
|
||||
const result = computeChannelProfiles(profiles); |
||||
expect(result).toEqual({ |
||||
profiles: [ |
||||
{ |
||||
profileIndex: '1', |
||||
profileCreatedTime: new Date(1709123456789), |
||||
channelCreatedTime: new Date(1709123456790), |
||||
nextProfileCreatedTime: new Date(1709123456791), |
||||
callDuration: 0, |
||||
answered: true, |
||||
media: true, |
||||
bridged: false, |
||||
}, |
||||
{ |
||||
channelCreatedTime: new Date(1709123456792), |
||||
profileIndex: '2', |
||||
profileCreatedTime: new Date(1709123456791), |
||||
callDuration: 0, |
||||
answered: true, |
||||
media: true, |
||||
bridged: false, |
||||
}, |
||||
], |
||||
anyMedia: true, |
||||
anyAnswer: true, |
||||
anyBridge: false, |
||||
durationSum: 0, |
||||
totalDuration: 0, |
||||
startedAt: new Date(1709123456789), |
||||
}); |
||||
}); |
||||
|
||||
it('should handle multiple full profiles', () => { |
||||
const forcedInvalidAttributes = { |
||||
unknownAttribute: 'value', |
||||
unsetAttribute: false, |
||||
} as any; |
||||
|
||||
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = { |
||||
profile1: createTestProfile({ |
||||
profileIndex: '1', |
||||
profileCreatedTime: new Date(1709123456789), |
||||
channelCreatedTime: new Date(1709123456790), |
||||
channelAnsweredTime: new Date(1709123456789), |
||||
channelProgressTime: new Date(1709123456889), |
||||
channelProgressMediaTime: new Date(1709123456989), |
||||
channelBridgedTime: new Date(1709123456789), |
||||
channelLastHold: new Date(1709123456789), |
||||
}), |
||||
profile2: createTestProfile({ |
||||
profileIndex: '2', |
||||
profileCreatedTime: new Date(1709123656789), |
||||
channelAnsweredTime: new Date(1709123657789), |
||||
channelProgressTime: new Date(1709123657889), |
||||
channelProgressMediaTime: new Date(1709123657989), |
||||
channelBridgedTime: new Date(1709123658789), |
||||
channelHangupTime: new Date(1709123666789), |
||||
channelTransferTime: new Date(1709123656791), |
||||
channelRessurectTime: new Date(1709123656792), |
||||
channelLastHold: new Date(1709123456789), |
||||
...forcedInvalidAttributes, |
||||
}), |
||||
}; |
||||
|
||||
const result = computeChannelProfiles(profiles); |
||||
expect(result).toEqual({ |
||||
profiles: [ |
||||
{ |
||||
profileIndex: '1', |
||||
profileCreatedTime: new Date(1709123456789), |
||||
channelCreatedTime: new Date(1709123456790), |
||||
channelAnsweredTime: new Date(1709123456789), |
||||
channelProgressTime: new Date(1709123456889), |
||||
channelProgressMediaTime: new Date(1709123456989), |
||||
channelBridgedTime: new Date(1709123456789), |
||||
channelLastHold: new Date(1709123456789), |
||||
callDuration: 200000, |
||||
answered: true, |
||||
media: true, |
||||
bridged: true, |
||||
nextProfileCreatedTime: new Date(1709123656789), |
||||
}, |
||||
{ |
||||
profileIndex: '2', |
||||
profileCreatedTime: new Date(1709123656789), |
||||
channelAnsweredTime: new Date(1709123657789), |
||||
channelProgressTime: new Date(1709123657889), |
||||
channelProgressMediaTime: new Date(1709123657989), |
||||
channelBridgedTime: new Date(1709123658789), |
||||
channelHangupTime: new Date(1709123666789), |
||||
channelTransferTime: new Date(1709123656791), |
||||
channelRessurectTime: new Date(1709123656792), |
||||
// should not be carried over due to being lower than the profile create time
|
||||
channelLastHold: undefined, |
||||
callDuration: 8000, |
||||
answered: true, |
||||
media: true, |
||||
bridged: true, |
||||
}, |
||||
], |
||||
anyMedia: true, |
||||
anyAnswer: true, |
||||
anyBridge: true, |
||||
durationSum: 208000, |
||||
totalDuration: 210000, |
||||
startedAt: new Date(1709123456789), |
||||
}); |
||||
}); |
||||
|
||||
it('should handle empty profiles object', () => { |
||||
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = {}; |
||||
|
||||
const result = computeChannelProfiles(profiles); |
||||
expect(result).toEqual({ profiles: [], anyMedia: false, anyAnswer: false, anyBridge: false, durationSum: 0, totalDuration: 0 }); |
||||
}); |
||||
|
||||
it('should handle profiles with no creation time', () => { |
||||
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = { |
||||
profile2: createTestProfile({ |
||||
profileIndex: '2', |
||||
channelAnsweredTime: new Date(1709123457789), |
||||
}), |
||||
}; |
||||
|
||||
const result = computeChannelProfiles(profiles); |
||||
expect(result).toEqual({ |
||||
profiles: [{ profileIndex: '2', bridged: false }], |
||||
anyMedia: false, |
||||
anyAnswer: false, |
||||
anyBridge: false, |
||||
durationSum: 0, |
||||
totalDuration: 0, |
||||
}); |
||||
}); |
||||
|
||||
it('validate fallback for unlikely data', () => { |
||||
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = { |
||||
profile1: createTestProfile({ |
||||
profileIndex: '2', |
||||
profileCreatedTime: new Date(1709123656789), |
||||
channelAnsweredTime: new Date(1709123657789), |
||||
channelProgressTime: new Date(1709123657889), |
||||
channelProgressMediaTime: new Date(1709123657989), |
||||
channelBridgedTime: new Date(1709123658789), |
||||
channelHangupTime: new Date(1709123666789), |
||||
}), |
||||
profile3: createTestProfile({ |
||||
profileIndex: '1', |
||||
channelCreatedTime: new Date(1709123456790), |
||||
channelAnsweredTime: new Date(1709123456789), |
||||
}), |
||||
profile2: createTestProfile({ |
||||
profileIndex: '1', |
||||
channelCreatedTime: new Date(1709123456790), |
||||
channelAnsweredTime: new Date(1709123456789), |
||||
}), |
||||
}; |
||||
|
||||
const result = computeChannelProfiles(profiles); |
||||
|
||||
expect(result).toEqual({ |
||||
profiles: [ |
||||
{ |
||||
profileIndex: '1', |
||||
channelCreatedTime: new Date(1709123456790), |
||||
channelAnsweredTime: new Date(1709123456789), |
||||
callDuration: 0, |
||||
answered: true, |
||||
media: true, |
||||
bridged: false, |
||||
}, |
||||
{ |
||||
profileIndex: '1', |
||||
channelCreatedTime: new Date(1709123456790), |
||||
channelAnsweredTime: new Date(1709123456789), |
||||
nextProfileCreatedTime: new Date(1709123656789), |
||||
callDuration: 0, |
||||
answered: true, |
||||
media: true, |
||||
bridged: false, |
||||
}, |
||||
{ |
||||
profileIndex: '2', |
||||
profileCreatedTime: new Date(1709123656789), |
||||
channelAnsweredTime: new Date(1709123657789), |
||||
channelProgressTime: new Date(1709123657889), |
||||
channelProgressMediaTime: new Date(1709123657989), |
||||
channelBridgedTime: new Date(1709123658789), |
||||
channelHangupTime: new Date(1709123666789), |
||||
callDuration: 8000, |
||||
answered: true, |
||||
media: true, |
||||
bridged: true, |
||||
}, |
||||
], |
||||
anyMedia: true, |
||||
anyAnswer: true, |
||||
anyBridge: true, |
||||
durationSum: 8000, |
||||
totalDuration: 8000, |
||||
startedAt: new Date(1709123658789), |
||||
}); |
||||
}); |
||||
|
||||
it('should handle a mix of profiles with and without creation time', () => { |
||||
const profiles: Record<string, IFreeSwitchChannelEventLegProfile> = { |
||||
profile1: createTestProfile({ |
||||
profileIndex: '1', |
||||
profileCreatedTime: new Date(1709123456789), |
||||
channelCreatedTime: new Date(1709123456790), |
||||
channelAnsweredTime: new Date(1709123456789), |
||||
channelProgressTime: new Date(1709123456889), |
||||
channelProgressMediaTime: new Date(1709123456989), |
||||
channelBridgedTime: new Date(1709123456789), |
||||
channelLastHold: new Date(1709123456789), |
||||
}), |
||||
profile2: createTestProfile({ |
||||
profileIndex: '2', |
||||
channelCreatedTime: new Date(1709123456790), |
||||
channelAnsweredTime: new Date(1709123656785), |
||||
}), |
||||
profile3: createTestProfile({ |
||||
profileIndex: '3', |
||||
channelCreatedTime: new Date(1709123456790), |
||||
channelAnsweredTime: new Date(1709123656785), |
||||
}), |
||||
profile4: createTestProfile({ |
||||
profileIndex: '4', |
||||
profileCreatedTime: new Date(1709123656789), |
||||
channelAnsweredTime: new Date(1709123657789), |
||||
channelProgressTime: new Date(1709123657889), |
||||
channelProgressMediaTime: new Date(1709123657989), |
||||
channelBridgedTime: new Date(1709123658789), |
||||
channelHangupTime: new Date(1709123666789), |
||||
channelTransferTime: new Date(1709123656791), |
||||
channelRessurectTime: new Date(1709123656792), |
||||
channelLastHold: new Date(1709123456789), |
||||
}), |
||||
}; |
||||
|
||||
const result = computeChannelProfiles(profiles); |
||||
|
||||
expect(result).toEqual({ |
||||
profiles: [ |
||||
{ |
||||
profileIndex: '1', |
||||
profileCreatedTime: new Date(1709123456789), |
||||
channelCreatedTime: new Date(1709123456790), |
||||
channelAnsweredTime: new Date(1709123456789), |
||||
channelProgressTime: new Date(1709123456889), |
||||
channelProgressMediaTime: new Date(1709123456989), |
||||
channelBridgedTime: new Date(1709123456789), |
||||
channelLastHold: new Date(1709123456789), |
||||
nextProfileCreatedTime: new Date(1709123656789), |
||||
callDuration: 200000, |
||||
answered: true, |
||||
media: true, |
||||
bridged: true, |
||||
}, |
||||
{ |
||||
profileIndex: '4', |
||||
profileCreatedTime: new Date(1709123656789), |
||||
channelAnsweredTime: new Date(1709123657789), |
||||
channelProgressTime: new Date(1709123657889), |
||||
channelProgressMediaTime: new Date(1709123657989), |
||||
channelBridgedTime: new Date(1709123658789), |
||||
channelHangupTime: new Date(1709123666789), |
||||
channelTransferTime: new Date(1709123656791), |
||||
channelRessurectTime: new Date(1709123656792), |
||||
callDuration: 8000, |
||||
answered: true, |
||||
media: true, |
||||
bridged: true, |
||||
}, |
||||
{ |
||||
channelCreatedTime: new Date(1709123456790), |
||||
profileIndex: '2', |
||||
bridged: false, |
||||
}, |
||||
{ |
||||
channelCreatedTime: new Date(1709123456790), |
||||
profileIndex: '3', |
||||
bridged: false, |
||||
}, |
||||
], |
||||
anyMedia: true, |
||||
anyAnswer: true, |
||||
anyBridge: true, |
||||
durationSum: 208000, |
||||
totalDuration: 210000, |
||||
startedAt: new Date(1709123456789), |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,228 @@ |
||||
import { extractChannelChangesFromEvent } from '../../src/eventParser/extractChannelChangesFromEvent'; |
||||
|
||||
describe('extractChannelChangesFromEvent', () => { |
||||
it('should detect new values', () => { |
||||
const channelState = {}; |
||||
const eventValues = { |
||||
simple: 'value', |
||||
number: 42, |
||||
date: new Date('2024-01-01'), |
||||
}; |
||||
|
||||
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues); |
||||
|
||||
expect(result.newValues).toEqual({ |
||||
simple: 'value', |
||||
number: 42, |
||||
date: new Date('2024-01-01'), |
||||
}); |
||||
expect(result.changedValues).toEqual({ |
||||
simple: 'value', |
||||
number: 42, |
||||
date: new Date('2024-01-01'), |
||||
}); |
||||
expect(result.changedExistingValues).toEqual({}); |
||||
}); |
||||
|
||||
it('should detect changed values', () => { |
||||
const channelState = { |
||||
simple: 'old', |
||||
number: 42, |
||||
date: new Date('2024-01-01'), |
||||
}; |
||||
const eventValues = { |
||||
simple: 'new', |
||||
number: 43, |
||||
date: new Date('2024-01-02'), |
||||
}; |
||||
|
||||
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues); |
||||
|
||||
expect(result.newValues).toEqual({}); |
||||
expect(result.changedValues).toEqual({ |
||||
simple: 'new', |
||||
number: 43, |
||||
date: new Date('2024-01-02'), |
||||
}); |
||||
expect(result.changedExistingValues).toEqual({ |
||||
simple: { |
||||
oldValue: 'old', |
||||
newValue: 'new', |
||||
}, |
||||
number: { |
||||
oldValue: 42, |
||||
newValue: 43, |
||||
}, |
||||
date: { |
||||
oldValue: new Date('2024-01-01'), |
||||
newValue: new Date('2024-01-02'), |
||||
delta: 24 * 60 * 60 * 1000, // 1 day in milliseconds
|
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('should handle arrays', () => { |
||||
const channelState = { |
||||
simple: ['a', 'b'], |
||||
variables: ['x', 'y'], |
||||
}; |
||||
const eventValues = { |
||||
simple: ['b', 'c'], |
||||
variables: ['y', 'z'], |
||||
}; |
||||
|
||||
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues); |
||||
|
||||
expect(result.newValues).toEqual({}); |
||||
expect(result.changedValues).toEqual({ |
||||
simple: ['b', 'c'], |
||||
variables: ['y', 'z'], |
||||
}); |
||||
expect(result.changedExistingValues).toEqual({ |
||||
simple: { |
||||
oldValue: ['a', 'b'], |
||||
newValue: ['b', 'c'], |
||||
}, |
||||
variables: { |
||||
newValue: ['y', 'z'], |
||||
oldValue: ['x', 'y'], |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('should handle bridgeUniqueIds specially', () => { |
||||
const channelState = { |
||||
bridgeUniqueIds: ['bridge1', 'bridge2'], |
||||
}; |
||||
const eventValues = { |
||||
bridgeUniqueIds: ['bridge2', 'bridge3'], |
||||
}; |
||||
|
||||
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues); |
||||
|
||||
expect(result.newValues).toEqual({}); |
||||
expect(result.changedValues).toEqual({ |
||||
bridgeUniqueIds: ['bridge1', 'bridge2', 'bridge3'], |
||||
}); |
||||
expect(result.changedExistingValues).toEqual({ |
||||
bridgeUniqueIds: { |
||||
oldValue: ['bridge1', 'bridge2'], |
||||
newValue: ['bridge1', 'bridge2', 'bridge3'], |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('should handle single bridgeUniqueId values', () => { |
||||
const channelState = { |
||||
bridgeUniqueIds: 'bridge1', |
||||
}; |
||||
const eventValues = { |
||||
bridgeUniqueIds: 'bridge2', |
||||
}; |
||||
|
||||
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues); |
||||
|
||||
expect(result.newValues).toEqual({}); |
||||
expect(result.changedValues).toEqual({ |
||||
bridgeUniqueIds: ['bridge1', 'bridge2'], |
||||
}); |
||||
expect(result.changedExistingValues).toEqual({ |
||||
bridgeUniqueIds: { |
||||
oldValue: 'bridge1', |
||||
newValue: ['bridge1', 'bridge2'], |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('should ignore unchanged values', () => { |
||||
const channelState = { |
||||
'simple': 'value', |
||||
'number': 42, |
||||
'date': new Date('2024-01-01'), |
||||
'variables.array': ['a', 'b'], |
||||
}; |
||||
const eventValues = { |
||||
'simple': 'value', |
||||
'number': 42, |
||||
'date': new Date('2024-01-01'), |
||||
'variables.array': ['a', 'b'], |
||||
}; |
||||
|
||||
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues); |
||||
|
||||
expect(result.newValues).toEqual({}); |
||||
expect(result.changedValues).toEqual({}); |
||||
expect(result.changedExistingValues).toEqual({}); |
||||
}); |
||||
|
||||
it('should ignore missing values in the bridgeUniqueIds param', () => { |
||||
const channelState = { |
||||
simple: 'value', |
||||
bridgeUniqueIds: ['1', '2', '3'], |
||||
}; |
||||
const eventValues = { |
||||
simple: 'value', |
||||
bridgeUniqueIds: ['1', '3'], |
||||
}; |
||||
|
||||
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues); |
||||
|
||||
expect(result.newValues).toEqual({}); |
||||
expect(result.changedValues).toEqual({}); |
||||
expect(result.changedExistingValues).toEqual({}); |
||||
}); |
||||
|
||||
it('should handle mixed arrays and values', () => { |
||||
const channelState = { |
||||
'variables.array': ['1', '3'], |
||||
}; |
||||
const eventValues = { |
||||
'variables.array': '1', |
||||
}; |
||||
|
||||
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues); |
||||
|
||||
expect(result.newValues).toEqual({}); |
||||
expect(result.changedValues).toEqual({ |
||||
'variables.array': '1', |
||||
}); |
||||
expect(result.changedExistingValues).toEqual({ |
||||
'variables.array': { |
||||
newValue: '1', |
||||
oldValue: ['1', '3'], |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('should handle undefined values', () => { |
||||
const channelState = { |
||||
simple: 'value', |
||||
number: 42, |
||||
}; |
||||
const eventValues = { |
||||
simple: undefined, |
||||
number: 42, |
||||
}; |
||||
|
||||
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues); |
||||
|
||||
expect(result.newValues).toEqual({}); |
||||
expect(result.changedValues).toEqual({}); |
||||
expect(result.changedExistingValues).toEqual({}); |
||||
}); |
||||
|
||||
it('should handle variables arrays specially', () => { |
||||
const channelState = { |
||||
'variables.array': ['a', 'b'], |
||||
}; |
||||
const eventValues = { |
||||
'variables.array': ['b', 'a'], |
||||
}; |
||||
|
||||
const result = extractChannelChangesFromEvent(channelState, 'test-event', eventValues); |
||||
|
||||
expect(result.newValues).toEqual({}); |
||||
expect(result.changedValues).toEqual({}); |
||||
expect(result.changedExistingValues).toEqual({}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,14 @@ |
||||
import { filterOutMissingData } from '../../src/eventParser/filterOutMissingData'; |
||||
|
||||
describe('filterOutMissingData', () => { |
||||
test.each([ |
||||
['case-1', { a: '1', b: '', c: '0', d: undefined }, { a: '1' }], |
||||
['case-2', { a: true, b: false, c: '0' }, { a: true, b: false }], |
||||
['case-3', { a: { b: {} }, c: { d: '1' } }, { c: { d: '1' } }], |
||||
['case-4', { a: [], b: [1], c: {} }, { a: [], b: [1] }], |
||||
['case-5', { a: new Date(), b: {} }, { a: expect.any(Date) }], |
||||
['case-6', {}, {}], |
||||
])('should filter out missing data for test %# (%s)', (_caseId, input, expected) => { |
||||
expect(filterOutMissingData(input)).toEqual(expected); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,112 @@ |
||||
import { describe, expect, it } from '@jest/globals'; |
||||
|
||||
import { filterStringList } from '../../src/eventParser/filterStringList'; |
||||
import type { EventData } from '../../src/eventParser/parseEventData'; |
||||
|
||||
function createEventData(data: Record<string, string | string[] | undefined>): EventData { |
||||
return data as unknown as EventData; |
||||
} |
||||
|
||||
describe('filterStringList', () => { |
||||
it('should filter entries based on key filter function', () => { |
||||
const eventData = createEventData({ |
||||
'Channel-Name': 'test-channel', |
||||
'Channel-State': 'CS_INIT', |
||||
'variable_test': 'test-value', |
||||
'variable_array': ['value1', 'value2'], |
||||
}); |
||||
|
||||
const result = filterStringList(eventData, (key) => key.startsWith('variable_')); |
||||
|
||||
expect(result).toEqual({ |
||||
variable_test: 'test-value', |
||||
variable_array: ['value1', 'value2'], |
||||
}); |
||||
}); |
||||
|
||||
it('should return empty object when no entries match filter', () => { |
||||
const eventData = createEventData({ |
||||
'Channel-Name': 'test-channel', |
||||
'Channel-State': 'CS_INIT', |
||||
}); |
||||
|
||||
const result = filterStringList(eventData, (key) => key.startsWith('variable_')); |
||||
|
||||
expect(result).toEqual({}); |
||||
}); |
||||
|
||||
it('should apply map function to filtered entries', () => { |
||||
const eventData = createEventData({ |
||||
variable_test1: 'value1', |
||||
variable_test2: 'value2', |
||||
variable_test3: 'value3', |
||||
}); |
||||
|
||||
const result = filterStringList( |
||||
eventData, |
||||
(key) => key.startsWith('variable_'), |
||||
([key, value]) => [key.replace('variable_', ''), value], |
||||
); |
||||
|
||||
expect(result).toEqual({ |
||||
test1: 'value1', |
||||
test2: 'value2', |
||||
test3: 'value3', |
||||
}); |
||||
}); |
||||
|
||||
it('should filter out undefined mapped entries', () => { |
||||
const eventData = createEventData({ |
||||
variable_test1: 'value1', |
||||
variable_test2: 'value2', |
||||
variable_test3: 'value3', |
||||
}); |
||||
|
||||
const result = filterStringList( |
||||
eventData, |
||||
(key) => key.startsWith('variable_'), |
||||
([key, value]) => (key === 'variable_test2' ? undefined : [key.replace('variable_', ''), value]), |
||||
); |
||||
|
||||
expect(result).toEqual({ |
||||
test1: 'value1', |
||||
test3: 'value3', |
||||
}); |
||||
}); |
||||
|
||||
it('should handle array values', () => { |
||||
const eventData = createEventData({ |
||||
variable_array1: ['value1', 'value2'], |
||||
variable_array2: ['value3', 'value4'], |
||||
}); |
||||
|
||||
const result = filterStringList( |
||||
eventData, |
||||
(key) => key.startsWith('variable_'), |
||||
([key, value]) => [key.replace('variable_', ''), value], |
||||
); |
||||
|
||||
expect(result).toEqual({ |
||||
array1: ['value1', 'value2'], |
||||
array2: ['value3', 'value4'], |
||||
}); |
||||
}); |
||||
|
||||
it('should handle undefined values', () => { |
||||
const eventData = createEventData({ |
||||
variable_test1: undefined, |
||||
variable_test2: 'value2', |
||||
}); |
||||
|
||||
const result = filterStringList( |
||||
eventData, |
||||
(key) => key.startsWith('variable_'), |
||||
([key, value]) => [key.replace('variable_', ''), value], |
||||
); |
||||
|
||||
expect(result).toEqual({ |
||||
test1: undefined, |
||||
test2: 'value2', |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,281 @@ |
||||
import { insertDataIntoEventProfile } from '../../src/eventParser/insertDataIntoEventProfile'; |
||||
|
||||
describe('insertDataIntoEventProfile', () => { |
||||
const baseEventData = { |
||||
callUniqueId: 'test-call-123', |
||||
channelName: 'test-channel', |
||||
channelState: 'CS_EXECUTE', |
||||
channelCallState: 'CS_EXECUTE', |
||||
raw: {}, |
||||
}; |
||||
|
||||
it('should handle simple object', () => { |
||||
const eventData: any = { |
||||
simple: '15', |
||||
}; |
||||
|
||||
const result = insertDataIntoEventProfile(eventData, {}); |
||||
|
||||
expect(result).toEqual({ |
||||
simple: '15', |
||||
}); |
||||
}); |
||||
|
||||
it('should handle simple object with legs', () => { |
||||
const eventData: any = { |
||||
simple: '15', |
||||
legs: { |
||||
one: { |
||||
simple: '20', |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const result = insertDataIntoEventProfile(eventData, {}); |
||||
|
||||
expect(result).toEqual({ |
||||
simple: '15', |
||||
legs: { |
||||
one: { |
||||
simple: '20', |
||||
}, |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('should handle simple object with legs and profiles', () => { |
||||
const channelUniqueId = 'test-channel-123'; |
||||
const eventData: any = { |
||||
simple: '15', |
||||
legs: { |
||||
one: { |
||||
profiles: { |
||||
simple: '30', |
||||
}, |
||||
simple: '20', |
||||
}, |
||||
[channelUniqueId]: { |
||||
profiles: '40', |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const result = insertDataIntoEventProfile(eventData, {}); |
||||
|
||||
expect(result).toEqual({ |
||||
simple: '15', |
||||
legs: { |
||||
one: { |
||||
profiles: { |
||||
simple: '30', |
||||
}, |
||||
simple: '20', |
||||
}, |
||||
[channelUniqueId]: { |
||||
profiles: '40', |
||||
}, |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('should not break when handling invalid data', () => { |
||||
const channelUniqueId = 'test-channel-123'; |
||||
const date = new Date(); |
||||
|
||||
const eventData: any = { |
||||
legs: { |
||||
[channelUniqueId]: { |
||||
profiles: date, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const result = insertDataIntoEventProfile(eventData, {}); |
||||
expect(result).toEqual({ |
||||
legs: { |
||||
[channelUniqueId]: { |
||||
profiles: date, |
||||
}, |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('should not break when handling invalid data [2]', () => { |
||||
const channelUniqueId = 'test-channel-123'; |
||||
const eventData: any = { |
||||
legs: { |
||||
[channelUniqueId]: { |
||||
profiles: { |
||||
first: false, |
||||
last: 0, |
||||
}, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const result = insertDataIntoEventProfile(eventData, { callee: '20' }); |
||||
expect(result).toEqual({ |
||||
legs: { |
||||
[channelUniqueId]: { |
||||
profiles: { |
||||
first: false, |
||||
last: 0, |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('should convert event data into paths with basic structure', () => { |
||||
const channelUniqueId = 'test-channel-123'; |
||||
const eventData: any = { |
||||
...baseEventData, |
||||
legs: { |
||||
[channelUniqueId]: { |
||||
raw: {}, |
||||
legName: `leg-${channelUniqueId}`, |
||||
uniqueId: channelUniqueId, |
||||
profiles: { |
||||
'profile-1': { |
||||
bridgedTo: 'original-bridge', |
||||
callee: 'original-callee', |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const dataToInsertIntoProfile = { |
||||
bridgedTo: 'new-bridge', |
||||
callee: 'new-callee', |
||||
}; |
||||
|
||||
const result = insertDataIntoEventProfile(eventData, dataToInsertIntoProfile); |
||||
|
||||
expect(result).toEqual({ |
||||
...baseEventData, |
||||
legs: { |
||||
[channelUniqueId]: { |
||||
raw: {}, |
||||
legName: `leg-${channelUniqueId}`, |
||||
uniqueId: channelUniqueId, |
||||
profiles: { |
||||
'profile-1': { |
||||
bridgedTo: 'new-bridge', |
||||
callee: 'new-callee', |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('should handle event data without legs', () => { |
||||
const eventData: any = { |
||||
...baseEventData, |
||||
}; |
||||
const dataToInsertIntoProfile = { |
||||
bridgedTo: 'new-bridge', |
||||
}; |
||||
|
||||
const result = insertDataIntoEventProfile(eventData, dataToInsertIntoProfile); |
||||
|
||||
expect(result).toEqual({ |
||||
...baseEventData, |
||||
}); |
||||
}); |
||||
|
||||
it('should handle event data with legs but no profiles', () => { |
||||
const channelUniqueId = 'test-channel-123'; |
||||
const eventData: any = { |
||||
...baseEventData, |
||||
legs: { |
||||
[channelUniqueId]: { |
||||
raw: {}, |
||||
legName: `leg-${channelUniqueId}`, |
||||
uniqueId: channelUniqueId, |
||||
}, |
||||
}, |
||||
}; |
||||
const dataToInsertIntoProfile = { |
||||
bridgedTo: 'new-bridge', |
||||
}; |
||||
|
||||
const result = insertDataIntoEventProfile(eventData, dataToInsertIntoProfile); |
||||
|
||||
expect(result).toEqual({ |
||||
...baseEventData, |
||||
legs: { |
||||
[channelUniqueId]: { |
||||
raw: {}, |
||||
legName: `leg-${channelUniqueId}`, |
||||
uniqueId: channelUniqueId, |
||||
}, |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
it('should handle event data with multiple legs', () => { |
||||
const channelUniqueId = 'test-channel-123'; |
||||
const otherChannelId = 'other-channel-456'; |
||||
const eventData: any = { |
||||
...baseEventData, |
||||
legs: { |
||||
[channelUniqueId]: { |
||||
raw: {}, |
||||
legName: `leg-${channelUniqueId}`, |
||||
uniqueId: channelUniqueId, |
||||
profiles: { |
||||
'profile-1': { |
||||
profileIndex: 'profile-1', |
||||
}, |
||||
}, |
||||
}, |
||||
[otherChannelId]: { |
||||
raw: {}, |
||||
legName: `leg-${otherChannelId}`, |
||||
uniqueId: otherChannelId, |
||||
profiles: { |
||||
'profile-2': { |
||||
profileIndex: 'profile-2', |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const dataToInsertIntoProfile = { |
||||
bridgedTo: 'new-bridge', |
||||
}; |
||||
|
||||
const result = insertDataIntoEventProfile(eventData, dataToInsertIntoProfile); |
||||
|
||||
expect(result).toEqual({ |
||||
...baseEventData, |
||||
legs: { |
||||
[channelUniqueId]: { |
||||
raw: {}, |
||||
legName: `leg-${channelUniqueId}`, |
||||
uniqueId: channelUniqueId, |
||||
profiles: { |
||||
'profile-1': { |
||||
profileIndex: 'profile-1', |
||||
bridgedTo: 'new-bridge', |
||||
}, |
||||
}, |
||||
}, |
||||
[otherChannelId]: { |
||||
raw: {}, |
||||
legName: `leg-${otherChannelId}`, |
||||
uniqueId: otherChannelId, |
||||
profiles: { |
||||
'profile-2': { |
||||
profileIndex: 'profile-2', |
||||
bridgedTo: 'new-bridge', |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,51 @@ |
||||
import { describe, expect, it } from '@jest/globals'; |
||||
|
||||
import { parseChannelKind } from '../../src/eventParser/parseChannelKind'; |
||||
|
||||
describe('parseChannelKind', () => { |
||||
it('should parse kind from originator channel', () => { |
||||
const channelName = 'sofia/internal/1001@voip.open.rocket.chat:9999'; |
||||
const result = parseChannelKind(channelName); |
||||
expect(result).toBe('internal'); |
||||
}); |
||||
|
||||
it('should parse kind from external channel', () => { |
||||
const channelName = 'sofia/external/1001@voip.open.rocket.chat:9999'; |
||||
const result = parseChannelKind(channelName); |
||||
expect(result).toBe('external'); |
||||
}); |
||||
|
||||
it('should parse kind from voicemail channel', () => { |
||||
const channelName = 'loopback/voicemail-a'; |
||||
const result = parseChannelKind(channelName); |
||||
expect(result).toBe('voicemail'); |
||||
}); |
||||
|
||||
it('should parse kind from originatee channel', () => { |
||||
const channelName = 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-spo254ol@open.rocket.chat'; |
||||
const result = parseChannelKind(channelName); |
||||
expect(result).toBe('internal'); |
||||
}); |
||||
|
||||
it('should return unknown for non-sofia channel', () => { |
||||
const channelName = 'voicemail/default/1001'; |
||||
const result = parseChannelKind(channelName); |
||||
expect(result).toBe('unknown'); |
||||
}); |
||||
|
||||
it('should return unknown for invalid channel format', () => { |
||||
const channelName = 'sofia/invalid'; |
||||
const result = parseChannelKind(channelName); |
||||
expect(result).toBe('unknown'); |
||||
}); |
||||
|
||||
it('should return unknown for undefined input', () => { |
||||
const result = parseChannelKind(undefined); |
||||
expect(result).toBe('unknown'); |
||||
}); |
||||
|
||||
it('should return unknown for empty input', () => { |
||||
const result = parseChannelKind(''); |
||||
expect(result).toBe('unknown'); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,98 @@ |
||||
import { describe, expect, it } from '@jest/globals'; |
||||
|
||||
import { parseChannelUsername, parseContactUsername, parseEventUsername } from '../../src/eventParser/parseChannelUsername'; |
||||
import type { EventData } from '../../src/eventParser/parseEventData'; |
||||
|
||||
function createEventData(data: Record<string, string | string[] | undefined>): EventData { |
||||
return data as unknown as EventData; |
||||
} |
||||
|
||||
describe('parseChannelUsername', () => { |
||||
it('should parse username from originator channel', () => { |
||||
const channelName = 'sofia/internal/1001@voip.open.rocket.chat:9999'; |
||||
const result = parseChannelUsername(channelName); |
||||
expect(result).toBe('1001'); |
||||
}); |
||||
|
||||
it('should parse username from external channel', () => { |
||||
const channelName = 'sofia/external/1001@voip.open.rocket.chat:9999'; |
||||
const result = parseChannelUsername(channelName); |
||||
expect(result).toBe('1001'); |
||||
}); |
||||
|
||||
it('should parse username from originatee channel', () => { |
||||
const channelName = 'sofia/internal/1000-LJZ8A9MhHv4Eh6ZQH-spo254ol@open.rocket.chat'; |
||||
const result = parseChannelUsername(channelName); |
||||
expect(result).toBe('1000'); |
||||
}); |
||||
|
||||
it('should return undefined for non-sofia channel', () => { |
||||
const channelName = 'voicemail/default/1001'; |
||||
const result = parseChannelUsername(channelName); |
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should return undefined for invalid channel format', () => { |
||||
const channelName = 'sofia/internal/invalid'; |
||||
const result = parseChannelUsername(channelName); |
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should return undefined if username is not number only', () => { |
||||
const channelName = 'sofia/internal/10AB@voip-open.rocket.chat:9999'; |
||||
const result = parseChannelUsername(channelName); |
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should return undefined for undefined input', () => { |
||||
const result = parseChannelUsername(undefined); |
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
}); |
||||
|
||||
describe('parseContactUsername', () => { |
||||
it('should parse username from contact URI', () => { |
||||
const contactUri = '1001-abc123-xyz789@rocket.chat'; |
||||
const result = parseContactUsername(contactUri); |
||||
expect(result).toBe('1001'); |
||||
}); |
||||
|
||||
it('should return undefined for invalid contact URI', () => { |
||||
const contactUri = 'invalid-contact-uri'; |
||||
const result = parseContactUsername(contactUri); |
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should return undefined for non-numeric username', () => { |
||||
const contactUri = 'user123-abc123-xyz789@rocket.chat'; |
||||
const result = parseContactUsername(contactUri); |
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
}); |
||||
|
||||
describe('parseEventUsername', () => { |
||||
it('should parse username from event data', () => { |
||||
const eventData = createEventData({ |
||||
'Channel-Name': 'sofia/internal/1001@voip.open.rocket.chat:9999', |
||||
}); |
||||
|
||||
const result = parseEventUsername(eventData); |
||||
expect(result).toBe('1001'); |
||||
}); |
||||
|
||||
it('should return undefined when channel name is missing', () => { |
||||
const eventData = createEventData({}); |
||||
|
||||
const result = parseEventUsername(eventData); |
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should return undefined for non-sofia channel', () => { |
||||
const eventData = createEventData({ |
||||
'Channel-Name': 'voicemail/default/1001', |
||||
}); |
||||
|
||||
const result = parseEventUsername(eventData); |
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,103 @@ |
||||
import { describe, expect, it } from '@jest/globals'; |
||||
|
||||
import { parseEventCallId } from '../../src/eventParser/parseEventCallId'; |
||||
import type { EventData } from '../../src/eventParser/parseEventData'; |
||||
|
||||
describe('parseEventCallId', () => { |
||||
const createTestEvent = (overrides: Partial<EventData>): EventData => ({ |
||||
...overrides, |
||||
}); |
||||
|
||||
it('should use Channel-Call-UUID when IDs match and type is not originator', () => { |
||||
const event = createTestEvent({ |
||||
'Channel-Call-UUID': 'call-123', |
||||
'Unique-ID': 'call-123', |
||||
'Other-Type': 'not-originator', |
||||
}); |
||||
|
||||
const result = parseEventCallId(event); |
||||
|
||||
expect(result).toBe('call-123'); |
||||
}); |
||||
|
||||
it('should use Channel-Call-UUID when IDs differ', () => { |
||||
const event = createTestEvent({ |
||||
'Channel-Call-UUID': 'call-123', |
||||
'Unique-ID': 'channel-456', |
||||
'Other-Type': 'originator', |
||||
'Other-Leg-Unique-ID': 'other-789', |
||||
}); |
||||
|
||||
const result = parseEventCallId(event); |
||||
|
||||
expect(result).toBe('call-123'); |
||||
}); |
||||
|
||||
it('should use Other-Leg-Unique-ID for outbound callers with originator', () => { |
||||
const event = createTestEvent({ |
||||
'Channel-Call-UUID': 'call-123', |
||||
'Unique-ID': 'call-123', |
||||
'Other-Type': 'originator', |
||||
'Other-Leg-Unique-ID': 'other-789', |
||||
'Caller-Direction': 'outbound', |
||||
'Other-Leg-Direction': 'inbound', |
||||
}); |
||||
|
||||
const result = parseEventCallId(event); |
||||
|
||||
expect(result).toBe('other-789'); |
||||
}); |
||||
|
||||
it('should use Channel-Call-UUID when Other-Leg-Unique-ID is missing', () => { |
||||
const event = createTestEvent({ |
||||
'Channel-Call-UUID': 'call-123', |
||||
'Unique-ID': 'call-123', |
||||
'Other-Type': 'originator', |
||||
'Caller-Direction': 'outbound', |
||||
'Other-Leg-Direction': 'inbound', |
||||
}); |
||||
|
||||
const result = parseEventCallId(event); |
||||
|
||||
expect(result).toBe('call-123'); |
||||
}); |
||||
|
||||
it('should use Channel-Call-UUID for inbound callers', () => { |
||||
const event = createTestEvent({ |
||||
'Channel-Call-UUID': 'call-123', |
||||
'Unique-ID': 'call-123', |
||||
'Other-Type': 'originator', |
||||
'Other-Leg-Unique-ID': 'other-789', |
||||
'Caller-Direction': 'inbound', |
||||
'Other-Leg-Direction': 'outbound', |
||||
}); |
||||
|
||||
const result = parseEventCallId(event); |
||||
|
||||
expect(result).toBe('call-123'); |
||||
}); |
||||
|
||||
it('should use Channel-Call-UUID when Other-Leg-Direction is outbound with logical direction', () => { |
||||
const event = createTestEvent({ |
||||
'Channel-Call-UUID': 'call-123', |
||||
'Unique-ID': 'call-123', |
||||
'Other-Type': 'originator', |
||||
'Other-Leg-Unique-ID': 'other-789', |
||||
'Caller-Direction': 'outbound', |
||||
'Other-Leg-Direction': 'outbound', |
||||
'Other-Leg-Logical-Direction': 'inbound', |
||||
}); |
||||
|
||||
const result = parseEventCallId(event); |
||||
|
||||
expect(result).toBe('call-123'); |
||||
}); |
||||
|
||||
it('should return undefined when no call ID is present', () => { |
||||
const event = createTestEvent({}); |
||||
|
||||
const result = parseEventCallId(event); |
||||
|
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
}); |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,286 @@ |
||||
import { describe, expect, it } from '@jest/globals'; |
||||
import type { IFreeSwitchChannelEvent } from '@rocket.chat/core-typings'; |
||||
|
||||
import { parseEventExtensions } from '../../src/eventParser/parseEventExtensions'; |
||||
|
||||
describe('parseEventExtensions', () => { |
||||
const createTestEvent = ( |
||||
overrides: Omit<IFreeSwitchChannelEvent, '_id' | '_updatedAt'>, |
||||
): Omit<IFreeSwitchChannelEvent, '_id' | '_updatedAt'> => ({ |
||||
...overrides, |
||||
}); |
||||
|
||||
it('should parse call extensions', () => { |
||||
const event = createTestEvent({ |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date(), |
||||
receivedAt: new Date(), |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
channelState: 'CS_INIT', |
||||
channelCallState: 'DOWN', |
||||
channelStateNumber: '1', |
||||
channelCallStateNumber: '1', |
||||
callDirection: 'outbound', |
||||
channelHitDialplan: 'true', |
||||
answerState: 'early', |
||||
hangupCause: 'NORMAL_CLEARING', |
||||
bridgeUniqueIds: ['bridge-1', 'bridge-2'], |
||||
bridgedTo: 'bridge-1', |
||||
legs: { |
||||
'channel-123': { |
||||
legName: 'Caller-Leg', |
||||
uniqueId: 'channel-123', |
||||
direction: 'outbound', |
||||
logicalDirection: 'outbound', |
||||
username: '1001', |
||||
callerName: 'John Doe', |
||||
callerNumber: '1001', |
||||
networkAddress: '192.168.1.100', |
||||
destinationNumber: '1002', |
||||
source: 'mod_sofia', |
||||
context: 'default', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
raw: {}, |
||||
}, |
||||
}, |
||||
metadata: {}, |
||||
raw: {}, |
||||
}); |
||||
|
||||
const result = parseEventExtensions(event); |
||||
|
||||
expect(result).toBeDefined(); |
||||
expect(result?.caller).toBe('1001'); |
||||
expect(result?.callee).toBe('1002'); |
||||
}); |
||||
|
||||
it('should handle contact names', () => { |
||||
const event = createTestEvent({ |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date(), |
||||
receivedAt: new Date(), |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
channelState: 'CS_INIT', |
||||
channelCallState: 'DOWN', |
||||
channelStateNumber: '1', |
||||
channelCallStateNumber: '1', |
||||
callDirection: 'outbound', |
||||
channelHitDialplan: 'true', |
||||
answerState: 'early', |
||||
hangupCause: 'NORMAL_CLEARING', |
||||
bridgeUniqueIds: ['bridge-1', 'bridge-2'], |
||||
bridgedTo: 'bridge-1', |
||||
legs: { |
||||
'channel-123': { |
||||
legName: 'Caller-Leg', |
||||
uniqueId: 'channel-123', |
||||
direction: 'outbound', |
||||
logicalDirection: 'outbound', |
||||
username: '1001', |
||||
callerName: 'John Doe', |
||||
callerNumber: '1001', |
||||
networkAddress: '192.168.1.100', |
||||
destinationNumber: '1002-userid-random@something', |
||||
source: 'mod_sofia', |
||||
context: 'default', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
raw: {}, |
||||
}, |
||||
}, |
||||
metadata: {}, |
||||
raw: {}, |
||||
}); |
||||
|
||||
const result = parseEventExtensions(event); |
||||
|
||||
expect(result).toBeDefined(); |
||||
expect(result?.caller).toBe('1001'); |
||||
expect(result?.callee).toBe('1002'); |
||||
}); |
||||
|
||||
it('should handle missing variables', () => { |
||||
const event = createTestEvent({ |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date(), |
||||
receivedAt: new Date(), |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
channelState: 'CS_INIT', |
||||
channelCallState: 'DOWN', |
||||
channelStateNumber: '1', |
||||
channelCallStateNumber: '1', |
||||
callDirection: 'outbound', |
||||
channelHitDialplan: 'true', |
||||
answerState: 'early', |
||||
hangupCause: 'NORMAL_CLEARING', |
||||
bridgeUniqueIds: ['bridge-1', 'bridge-2'], |
||||
bridgedTo: 'bridge-1', |
||||
legs: { |
||||
'channel-123': { |
||||
legName: 'Caller-Leg', |
||||
uniqueId: 'channel-123', |
||||
direction: 'outbound', |
||||
logicalDirection: 'outbound', |
||||
username: '1001', |
||||
callerName: 'John Doe', |
||||
callerNumber: '1001', |
||||
networkAddress: '192.168.1.100', |
||||
destinationNumber: '1002', |
||||
source: 'mod_sofia', |
||||
context: 'default', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
raw: {}, |
||||
}, |
||||
}, |
||||
metadata: {}, |
||||
raw: {}, |
||||
variables: {}, |
||||
}); |
||||
|
||||
const result = parseEventExtensions(event); |
||||
|
||||
expect(result).toBeDefined(); |
||||
expect(result?.caller).toBe('1001'); |
||||
expect(result?.callee).toBe('1002'); |
||||
}); |
||||
|
||||
it('should handle calls initiated by outbound channels', () => { |
||||
const event = createTestEvent({ |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date(), |
||||
receivedAt: new Date(), |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
channelState: 'CS_INIT', |
||||
channelCallState: 'DOWN', |
||||
channelStateNumber: '1', |
||||
channelCallStateNumber: '1', |
||||
callDirection: 'outbound', |
||||
channelHitDialplan: 'true', |
||||
answerState: 'early', |
||||
hangupCause: 'NORMAL_CLEARING', |
||||
bridgeUniqueIds: ['bridge-1', 'bridge-2'], |
||||
bridgedTo: 'bridge-1', |
||||
legs: { |
||||
'channel-123': { |
||||
legName: 'Caller-Leg', |
||||
uniqueId: 'channel-123', |
||||
direction: 'outbound', |
||||
logicalDirection: 'outbound', |
||||
username: '1000', |
||||
callerName: 'John Doe', |
||||
callerNumber: '1000', |
||||
networkAddress: '192.168.1.100', |
||||
destinationNumber: '1003', |
||||
source: 'mod_sofia', |
||||
context: 'default', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
raw: {}, |
||||
}, |
||||
'channel-456': { |
||||
legName: 'Other-Leg', |
||||
type: 'originatee', |
||||
uniqueId: 'channel-456', |
||||
direction: 'outbound', |
||||
logicalDirection: 'inbound', |
||||
username: '1000', |
||||
callerName: 'Jane Doe', |
||||
callerNumber: '1001', |
||||
originalCallerName: 'John Doe', |
||||
originalCallerNumber: '1000', |
||||
networkAddress: '192.168.1.100', |
||||
destinationNumber: '1003-userId-random', |
||||
source: 'mod_sofia', |
||||
context: 'default', |
||||
channelName: 'sofia/internal/1003-userId-random@host', |
||||
}, |
||||
}, |
||||
metadata: {}, |
||||
raw: {}, |
||||
variables: {}, |
||||
}); |
||||
|
||||
const result = parseEventExtensions(event); |
||||
|
||||
expect(result).toBeDefined(); |
||||
expect(result?.caller).toBe('1001'); |
||||
expect(result?.callee).toBe('1003'); |
||||
}); |
||||
|
||||
it('should handle missing legs', () => { |
||||
const legs: any = undefined; |
||||
|
||||
const event = createTestEvent({ |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date(), |
||||
receivedAt: new Date(), |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
channelState: 'CS_INIT', |
||||
channelCallState: 'DOWN', |
||||
channelStateNumber: '1', |
||||
channelCallStateNumber: '1', |
||||
callDirection: 'outbound', |
||||
channelHitDialplan: 'true', |
||||
answerState: 'early', |
||||
hangupCause: 'NORMAL_CLEARING', |
||||
bridgeUniqueIds: ['bridge-1', 'bridge-2'], |
||||
bridgedTo: 'bridge-1', |
||||
legs, |
||||
metadata: {}, |
||||
raw: {}, |
||||
}); |
||||
|
||||
const result = parseEventExtensions(event); |
||||
|
||||
expect(result).toBeDefined(); |
||||
expect(result?.caller).toBeUndefined(); |
||||
expect(result?.callee).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should handle variables', () => { |
||||
const event = createTestEvent({ |
||||
channelUniqueId: 'channel-123', |
||||
eventName: 'CHANNEL_CREATE', |
||||
sequence: 1, |
||||
firedAt: new Date(), |
||||
receivedAt: new Date(), |
||||
callUniqueId: 'call-123', |
||||
channelName: 'sofia/internal/1001@192.168.1.100', |
||||
channelState: 'CS_INIT', |
||||
channelCallState: 'DOWN', |
||||
channelStateNumber: '1', |
||||
channelCallStateNumber: '1', |
||||
callDirection: 'outbound', |
||||
channelHitDialplan: 'true', |
||||
answerState: 'early', |
||||
hangupCause: 'NORMAL_CLEARING', |
||||
bridgeUniqueIds: ['bridge-1', 'bridge-2'], |
||||
bridgedTo: 'bridge-1', |
||||
legs: {}, |
||||
metadata: {}, |
||||
raw: {}, |
||||
variables: { |
||||
dialed_extension: ['1002'], |
||||
}, |
||||
}); |
||||
|
||||
const result = parseEventExtensions(event); |
||||
|
||||
expect(result).toBeDefined(); |
||||
expect(result?.caller).toBeUndefined(); |
||||
expect(result?.callee).toBe('1002'); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,155 @@ |
||||
import { describe, expect, it } from '@jest/globals'; |
||||
|
||||
import type { EventData } from '../../src/eventParser/parseEventData'; |
||||
import { parseEventLeg } from '../../src/eventParser/parseEventLeg'; |
||||
|
||||
describe('parseEventLeg', () => { |
||||
const createTestEvent = (overrides: EventData): EventData => ({ |
||||
...overrides, |
||||
}); |
||||
|
||||
it('should parse caller leg data', () => { |
||||
const event = createTestEvent({ |
||||
'Caller-Leg-Direction': 'outbound', |
||||
'Caller-Leg-Logical-Direction': 'outbound', |
||||
'Caller-Leg-Username': '1001', |
||||
'Caller-Leg-Caller-ID-Name': 'John Doe', |
||||
'Caller-Leg-Caller-ID-Number': '1001', |
||||
'Caller-Leg-Network-Addr': '192.168.1.100', |
||||
'Caller-Leg-Destination-Number': '1002', |
||||
'Caller-Leg-Unique-ID': 'channel-123', |
||||
'Caller-Leg-Source': 'mod_sofia', |
||||
'Caller-Leg-Context': 'default', |
||||
'Caller-Leg-Channel-Name': 'sofia/internal/1001@192.168.1.100', |
||||
'Caller-Leg-Profile-Index': '1', |
||||
'Caller-Leg-Profile-Created-Time': '1709123456789000', |
||||
'Caller-Leg-Channel-Created-Time': '1709123457123000', |
||||
'Caller-Leg-Channel-Answered-Time': '1709123458456000', |
||||
'Caller-Leg-Channel-Hangup-Time': '1709123459789000', |
||||
}); |
||||
|
||||
const result = parseEventLeg('Caller-Leg', event); |
||||
|
||||
expect(result).toBeDefined(); |
||||
expect(result?.legName).toBe('Caller-Leg'); |
||||
expect(result?.uniqueId).toBe('channel-123'); |
||||
expect(result?.direction).toBe('outbound'); |
||||
expect(result?.logicalDirection).toBe('outbound'); |
||||
expect(result?.username).toBe('1001'); |
||||
expect(result?.callerName).toBe('John Doe'); |
||||
expect(result?.callerNumber).toBe('1001'); |
||||
expect(result?.networkAddress).toBe('192.168.1.100'); |
||||
expect(result?.destinationNumber).toBe('1002'); |
||||
expect(result?.source).toBe('mod_sofia'); |
||||
expect(result?.context).toBe('default'); |
||||
expect(result?.channelName).toBe('sofia/internal/1001@192.168.1.100'); |
||||
expect(result?.profiles).toBeDefined(); |
||||
expect(result?.profiles?.['1']).toBeDefined(); |
||||
expect(result?.profiles?.['1'].profileIndex).toBe('1'); |
||||
expect(result?.profiles?.['1'].profileCreatedTime?.getTime()).toBe(1709123456789); |
||||
expect(result?.profiles?.['1'].channelCreatedTime?.getTime()).toBe(1709123457123); |
||||
expect(result?.profiles?.['1'].channelAnsweredTime?.getTime()).toBe(1709123458456); |
||||
expect(result?.profiles?.['1'].channelHangupTime?.getTime()).toBe(1709123459789); |
||||
expect(result?.raw).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should parse other leg data with type', () => { |
||||
const event = createTestEvent({ |
||||
'Other-Leg-Direction': 'inbound', |
||||
'Other-Leg-Logical-Direction': 'inbound', |
||||
'Other-Leg-Username': '1002', |
||||
'Other-Leg-Caller-ID-Name': 'Jane Smith', |
||||
'Other-Leg-Caller-ID-Number': '1002', |
||||
'Other-Leg-Network-Addr': '192.168.1.101', |
||||
'Other-Leg-Destination-Number': '1001', |
||||
'Other-Leg-Unique-ID': 'channel-456', |
||||
'Other-Leg-Source': 'mod_sofia', |
||||
'Other-Leg-Context': 'default', |
||||
'Other-Leg-Channel-Name': 'sofia/internal/1002@192.168.1.101', |
||||
'Other-Type': 'originator', |
||||
}); |
||||
|
||||
const result = parseEventLeg('Other-Leg', event); |
||||
|
||||
expect(result).toBeDefined(); |
||||
expect(result?.legName).toBe('Other-Leg'); |
||||
expect(result?.uniqueId).toBe('channel-456'); |
||||
expect(result?.direction).toBe('inbound'); |
||||
expect(result?.logicalDirection).toBe('inbound'); |
||||
expect(result?.username).toBe('1002'); |
||||
expect(result?.callerName).toBe('Jane Smith'); |
||||
expect(result?.callerNumber).toBe('1002'); |
||||
expect(result?.networkAddress).toBe('192.168.1.101'); |
||||
expect(result?.destinationNumber).toBe('1001'); |
||||
expect(result?.source).toBe('mod_sofia'); |
||||
expect(result?.context).toBe('default'); |
||||
expect(result?.channelName).toBe('sofia/internal/1002@192.168.1.101'); |
||||
expect(result?.type).toBe('originator'); |
||||
expect(result?.raw).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should return undefined when unique ID is missing', () => { |
||||
const event = createTestEvent({ |
||||
'Caller-Leg-Direction': 'outbound', |
||||
'Caller-Leg-Logical-Direction': 'outbound', |
||||
'Caller-Leg-Username': '1001', |
||||
'Caller-Leg-Caller-ID-Name': 'John Doe', |
||||
'Caller-Leg-Caller-ID-Number': '1001', |
||||
'Caller-Leg-Network-Addr': '192.168.1.100', |
||||
'Caller-Leg-Destination-Number': '1002', |
||||
'Caller-Leg-Source': 'mod_sofia', |
||||
'Caller-Leg-Context': 'default', |
||||
'Caller-Leg-Channel-Name': 'sofia/internal/1001@192.168.1.100', |
||||
}); |
||||
|
||||
const result = parseEventLeg('Caller-Leg', event); |
||||
|
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should handle missing optional fields', () => { |
||||
const event = createTestEvent({ |
||||
'Caller-Leg-Direction': 'outbound', |
||||
'Caller-Leg-Logical-Direction': 'outbound', |
||||
'Caller-Leg-Username': '1001', |
||||
'Caller-Leg-Unique-ID': 'channel-123', |
||||
}); |
||||
|
||||
const result = parseEventLeg('Caller-Leg', event); |
||||
|
||||
expect(result).toBeDefined(); |
||||
expect(result?.legName).toBe('Caller-Leg'); |
||||
expect(result?.uniqueId).toBe('channel-123'); |
||||
expect(result?.direction).toBe('outbound'); |
||||
expect(result?.logicalDirection).toBe('outbound'); |
||||
expect(result?.username).toBe('1001'); |
||||
expect(result?.callerName).toBeUndefined(); |
||||
expect(result?.callerNumber).toBeUndefined(); |
||||
expect(result?.networkAddress).toBeUndefined(); |
||||
expect(result?.destinationNumber).toBeUndefined(); |
||||
expect(result?.source).toBeUndefined(); |
||||
expect(result?.context).toBeUndefined(); |
||||
expect(result?.channelName).toBeUndefined(); |
||||
expect(result?.profiles).toBeUndefined(); |
||||
expect(result?.raw).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should filter out empty values from raw data', () => { |
||||
const event = createTestEvent({ |
||||
'Caller-Direction': 'outbound', |
||||
'Caller-Logical-Direction': 'outbound', |
||||
'Caller-Username': '1001', |
||||
'Caller-Unique-ID': 'channel-123', |
||||
'Caller-Empty-Value': '', |
||||
'Caller-Undefined-Value': undefined, |
||||
'Caller-Unused-Variable': 'unused-value', |
||||
}); |
||||
|
||||
const result = parseEventLeg('Caller', event); |
||||
|
||||
expect(result).toBeDefined(); |
||||
expect(result?.raw).toBeDefined(); |
||||
expect(result?.raw['Empty-Value']).toBeUndefined(); |
||||
expect(result?.raw['Undefined-Value']).toBeUndefined(); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,37 @@ |
||||
import { describe, expect, it } from '@jest/globals'; |
||||
|
||||
import { parseTimestamp } from '../../src/eventParser/parseTimestamp'; |
||||
|
||||
describe('parseTimestamp', () => { |
||||
it('should parse a valid timestamp', () => { |
||||
const timestamp = '1709123456789123'; |
||||
const result = parseTimestamp(timestamp); |
||||
|
||||
expect(result).toBeInstanceOf(Date); |
||||
expect(result?.getTime()).toBe(1709123456789); |
||||
}); |
||||
|
||||
it('should return undefined for undefined input', () => { |
||||
const result = parseTimestamp(undefined); |
||||
|
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should return undefined for zero timestamp', () => { |
||||
const result = parseTimestamp('0'); |
||||
|
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should return undefined for invalid timestamp', () => { |
||||
const result = parseTimestamp('invalid'); |
||||
|
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should return undefined for negative timestamp', () => { |
||||
const result = parseTimestamp('-1709123456789'); |
||||
|
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
}); |
||||
@ -1,12 +0,0 @@ |
||||
import type { IFreeSwitchCall } from '@rocket.chat/core-typings'; |
||||
import type { AggregateOptions, CountDocumentsOptions, FindCursor, FindOptions, WithoutId } from 'mongodb'; |
||||
|
||||
import type { IBaseModel, InsertionModel } from './IBaseModel'; |
||||
|
||||
export interface IFreeSwitchCallModel extends IBaseModel<IFreeSwitchCall> { |
||||
registerCall(call: WithoutId<InsertionModel<IFreeSwitchCall>>): Promise<void>; |
||||
findAllByChannelUniqueIds<T extends IFreeSwitchCall>(uniqueIds: string[], options?: FindOptions<IFreeSwitchCall>): FindCursor<T>; |
||||
countCallsByDirection(direction: IFreeSwitchCall['direction'], minDate?: Date, options?: CountDocumentsOptions): Promise<number>; |
||||
sumCallsDuration(minDate?: Date, options?: AggregateOptions): Promise<number>; |
||||
countCallsBySuccessState(success: boolean, minDate?: Date, options?: CountDocumentsOptions): Promise<number>; |
||||
} |
||||
@ -0,0 +1,8 @@ |
||||
import type { IFreeSwitchChannelEventDelta } from '@rocket.chat/core-typings'; |
||||
import type { WithoutId, InsertOneResult } from 'mongodb'; |
||||
|
||||
import type { IBaseModel, InsertionModel } from './IBaseModel'; |
||||
|
||||
export interface IFreeSwitchChannelEventDeltaModel extends IBaseModel<IFreeSwitchChannelEventDelta> { |
||||
registerDelta(channel: InsertionModel<WithoutId<IFreeSwitchChannelEventDelta>>): Promise<InsertOneResult<IFreeSwitchChannelEventDelta>>; |
||||
} |
||||
@ -0,0 +1,12 @@ |
||||
import type { IFreeSwitchChannelEvent } from '@rocket.chat/core-typings'; |
||||
import type { FindCursor, FindOptions, WithoutId, InsertOneResult } from 'mongodb'; |
||||
|
||||
import type { IBaseModel, InsertionModel } from './IBaseModel'; |
||||
|
||||
export interface IFreeSwitchChannelEventModel extends IBaseModel<IFreeSwitchChannelEvent> { |
||||
registerEvent(event: WithoutId<InsertionModel<IFreeSwitchChannelEvent>>): Promise<InsertOneResult<IFreeSwitchChannelEvent>>; |
||||
findAllByChannelUniqueId<T extends IFreeSwitchChannelEvent>( |
||||
uniqueId: string, |
||||
options?: FindOptions<IFreeSwitchChannelEvent>, |
||||
): FindCursor<T>; |
||||
} |
||||
@ -0,0 +1,24 @@ |
||||
import type { IFreeSwitchChannel } from '@rocket.chat/core-typings'; |
||||
import type { AggregateOptions, CountDocumentsOptions, FindCursor, FindOptions, WithoutId, InsertOneResult } from 'mongodb'; |
||||
|
||||
import type { IBaseModel, InsertionModel } from './IBaseModel'; |
||||
|
||||
export interface IFreeSwitchChannelModel extends IBaseModel<IFreeSwitchChannel> { |
||||
registerChannel(channel: WithoutId<InsertionModel<IFreeSwitchChannel>>): Promise<InsertOneResult<IFreeSwitchChannel>>; |
||||
findAllByUniqueIds<T extends IFreeSwitchChannel>(uniqueIds: string[], options?: FindOptions<IFreeSwitchChannel>): FindCursor<T>; |
||||
|
||||
countChannelsByKind(kind: Required<IFreeSwitchChannel>['kind'], minDate?: Date, options?: CountDocumentsOptions): Promise<number>; |
||||
countChannelsByKindAndDirection( |
||||
kind: Required<IFreeSwitchChannel>['kind'], |
||||
callDirection: Required<IFreeSwitchChannel>['callDirection'], |
||||
minDate?: Date, |
||||
options?: CountDocumentsOptions, |
||||
): Promise<number>; |
||||
sumChannelsDurationByKind(kind: Required<IFreeSwitchChannel>['kind'], minDate?: Date, options?: AggregateOptions): Promise<number>; |
||||
countChannelsByKindAndSuccessState( |
||||
kind: Required<IFreeSwitchChannel>['kind'], |
||||
success: boolean, |
||||
minDate?: Date, |
||||
options?: CountDocumentsOptions, |
||||
): Promise<number>; |
||||
} |
||||
@ -1,10 +0,0 @@ |
||||
import type { IFreeSwitchEvent } from '@rocket.chat/core-typings'; |
||||
import type { FindCursor, FindOptions, WithoutId, InsertOneResult } from 'mongodb'; |
||||
|
||||
import type { IBaseModel, InsertionModel } from './IBaseModel'; |
||||
|
||||
export interface IFreeSwitchEventModel extends IBaseModel<IFreeSwitchEvent> { |
||||
registerEvent(event: WithoutId<InsertionModel<IFreeSwitchEvent>>): Promise<InsertOneResult<IFreeSwitchEvent>>; |
||||
findAllByCallUUID<T extends IFreeSwitchEvent>(callUUID: string, options?: FindOptions<IFreeSwitchEvent>): FindCursor<T>; |
||||
findAllByChannelUniqueIds<T extends IFreeSwitchEvent>(uniqueIds: string[], options?: FindOptions<IFreeSwitchEvent>): FindCursor<T>; |
||||
} |
||||
@ -1,76 +0,0 @@ |
||||
import type { IFreeSwitchCall, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; |
||||
import type { IFreeSwitchCallModel, InsertionModel } from '@rocket.chat/model-typings'; |
||||
import type { |
||||
AggregateOptions, |
||||
Collection, |
||||
CountDocumentsOptions, |
||||
Db, |
||||
FindCursor, |
||||
FindOptions, |
||||
IndexDescription, |
||||
WithoutId, |
||||
} from 'mongodb'; |
||||
|
||||
import { BaseRaw } from './BaseRaw'; |
||||
import { readSecondaryPreferred } from '../readSecondaryPreferred'; |
||||
|
||||
export class FreeSwitchCallRaw extends BaseRaw<IFreeSwitchCall> implements IFreeSwitchCallModel { |
||||
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IFreeSwitchCall>>) { |
||||
super(db, 'freeswitch_calls', trash); |
||||
} |
||||
|
||||
protected modelIndexes(): IndexDescription[] { |
||||
return [{ key: { UUID: 1 } }, { key: { channels: 1 } }, { key: { direction: 1, startedAt: 1 } }]; |
||||
} |
||||
|
||||
public async registerCall(call: WithoutId<InsertionModel<IFreeSwitchCall>>): Promise<void> { |
||||
await this.findOneAndUpdate({ UUID: call.UUID }, { $set: call }, { upsert: true }); |
||||
} |
||||
|
||||
public findAllByChannelUniqueIds<T extends IFreeSwitchCall>(uniqueIds: string[], options?: FindOptions<IFreeSwitchCall>): FindCursor<T> { |
||||
return this.find<T>( |
||||
{ |
||||
channels: { $in: uniqueIds }, |
||||
}, |
||||
options, |
||||
); |
||||
} |
||||
|
||||
public countCallsByDirection(direction: IFreeSwitchCall['direction'], minDate?: Date, options?: CountDocumentsOptions): Promise<number> { |
||||
return this.countDocuments( |
||||
{ |
||||
direction, |
||||
...(minDate && { startedAt: { $gte: minDate } }), |
||||
}, |
||||
{ readPreference: readSecondaryPreferred(), ...options }, |
||||
); |
||||
} |
||||
|
||||
public async sumCallsDuration(minDate?: Date, options?: AggregateOptions): Promise<number> { |
||||
return this.col |
||||
.aggregate( |
||||
[ |
||||
...(minDate ? [{ $match: { startedAt: { $gte: minDate } } }] : []), |
||||
{ |
||||
$group: { |
||||
_id: '1', |
||||
calls: { $sum: '$duration' }, |
||||
}, |
||||
}, |
||||
], |
||||
{ readPreference: readSecondaryPreferred(), ...options }, |
||||
) |
||||
.toArray() |
||||
.then(([{ calls }]) => calls); |
||||
} |
||||
|
||||
public countCallsBySuccessState(success: boolean, minDate?: Date, options?: CountDocumentsOptions): Promise<number> { |
||||
return this.countDocuments( |
||||
{ |
||||
...(success ? { duration: { $gte: 5 } } : { $or: [{ duration: { $exists: false } }, { duration: { $lt: 5 } }] }), |
||||
...(minDate && { startedAt: { $gte: minDate } }), |
||||
}, |
||||
{ readPreference: readSecondaryPreferred(), ...options }, |
||||
); |
||||
} |
||||
} |
||||
@ -0,0 +1,113 @@ |
||||
import type { IFreeSwitchChannel, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; |
||||
import type { IFreeSwitchChannelModel, InsertionModel } from '@rocket.chat/model-typings'; |
||||
import type { |
||||
AggregateOptions, |
||||
Collection, |
||||
CountDocumentsOptions, |
||||
Db, |
||||
FindCursor, |
||||
FindOptions, |
||||
IndexDescription, |
||||
WithoutId, |
||||
InsertOneResult, |
||||
} from 'mongodb'; |
||||
|
||||
import { BaseRaw } from './BaseRaw'; |
||||
import { readSecondaryPreferred } from '../readSecondaryPreferred'; |
||||
|
||||
export class FreeSwitchChannelRaw extends BaseRaw<IFreeSwitchChannel> implements IFreeSwitchChannelModel { |
||||
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IFreeSwitchChannel>>) { |
||||
super(db, 'freeswitch_channels', trash); |
||||
} |
||||
|
||||
protected modelIndexes(): IndexDescription[] { |
||||
return [ |
||||
{ key: { uniqueId: 1 }, unique: true }, |
||||
{ key: { kind: 1, startedAt: -1 } }, |
||||
{ key: { kind: 1, callDirection: 1, startedAt: -1 } }, |
||||
{ key: { kind: 1, anyBridge: 1, startedAt: -1 } }, |
||||
]; |
||||
} |
||||
|
||||
public async registerChannel(channel: WithoutId<InsertionModel<IFreeSwitchChannel>>): Promise<InsertOneResult<IFreeSwitchChannel>> { |
||||
return this.insertOne(channel); |
||||
} |
||||
|
||||
public findAllByUniqueIds<T extends IFreeSwitchChannel>(uniqueIds: string[], options?: FindOptions<IFreeSwitchChannel>): FindCursor<T> { |
||||
return this.find<T>( |
||||
{ |
||||
uniqueId: { $in: uniqueIds }, |
||||
}, |
||||
options, |
||||
); |
||||
} |
||||
|
||||
public countChannelsByKind(kind: Required<IFreeSwitchChannel>['kind'], minDate?: Date, options?: CountDocumentsOptions): Promise<number> { |
||||
return this.countDocuments( |
||||
{ |
||||
kind, |
||||
...(minDate && { startedAt: { $gte: minDate } }), |
||||
}, |
||||
{ readPreference: readSecondaryPreferred(), ...options }, |
||||
); |
||||
} |
||||
|
||||
public countChannelsByKindAndDirection( |
||||
kind: Required<IFreeSwitchChannel>['kind'], |
||||
callDirection: Required<IFreeSwitchChannel>['callDirection'], |
||||
minDate?: Date, |
||||
options?: CountDocumentsOptions, |
||||
): Promise<number> { |
||||
return this.countDocuments( |
||||
{ |
||||
kind, |
||||
callDirection, |
||||
...(minDate && { startedAt: { $gte: minDate } }), |
||||
}, |
||||
{ readPreference: readSecondaryPreferred(), ...options }, |
||||
); |
||||
} |
||||
|
||||
public async sumChannelsDurationByKind( |
||||
kind: Required<IFreeSwitchChannel>['kind'], |
||||
minDate?: Date, |
||||
options?: AggregateOptions, |
||||
): Promise<number> { |
||||
return this.col |
||||
.aggregate( |
||||
[ |
||||
{ |
||||
$match: { |
||||
kind, |
||||
...(minDate && { startedAt: { $gte: minDate } }), |
||||
}, |
||||
}, |
||||
{ |
||||
$group: { |
||||
_id: '1', |
||||
calls: { $sum: '$totalDuration' }, |
||||
}, |
||||
}, |
||||
], |
||||
{ readPreference: readSecondaryPreferred(), ...options }, |
||||
) |
||||
.toArray() |
||||
.then(([{ calls }]) => calls); |
||||
} |
||||
|
||||
public countChannelsByKindAndSuccessState( |
||||
kind: Required<IFreeSwitchChannel>['kind'], |
||||
success: boolean, |
||||
minDate?: Date, |
||||
options?: CountDocumentsOptions, |
||||
): Promise<number> { |
||||
return this.countDocuments( |
||||
{ |
||||
kind, |
||||
anyBridge: success, |
||||
...(minDate && { startedAt: { $gte: minDate } }), |
||||
}, |
||||
{ readPreference: readSecondaryPreferred(), ...options }, |
||||
); |
||||
} |
||||
} |
||||
@ -0,0 +1,42 @@ |
||||
import type { IFreeSwitchChannelEvent, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; |
||||
import type { IFreeSwitchChannelEventModel, InsertionModel } from '@rocket.chat/model-typings'; |
||||
import type { IndexDescription, Collection, Db, FindOptions, FindCursor, WithoutId, InsertOneResult } from 'mongodb'; |
||||
|
||||
import { BaseRaw } from './BaseRaw'; |
||||
|
||||
export class FreeSwitchChannelEventRaw extends BaseRaw<IFreeSwitchChannelEvent> implements IFreeSwitchChannelEventModel { |
||||
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IFreeSwitchChannelEvent>>) { |
||||
super(db, 'freeswitch_channel_events', trash); |
||||
} |
||||
|
||||
protected modelIndexes(): IndexDescription[] { |
||||
return [ |
||||
{ key: { channelUniqueId: 1, sequence: 1 }, unique: true }, |
||||
// Allow 3 days of events to be saved
|
||||
{ key: { receivedAt: 1 }, expireAfterSeconds: 3 * 24 * 60 * 60 }, |
||||
]; |
||||
} |
||||
|
||||
public async registerEvent(event: WithoutId<InsertionModel<IFreeSwitchChannelEvent>>): Promise<InsertOneResult<IFreeSwitchChannelEvent>> { |
||||
return this.insertOne(event); |
||||
} |
||||
|
||||
public findAllByChannelUniqueId<T extends IFreeSwitchChannelEvent>( |
||||
channelUniqueId: string, |
||||
options?: FindOptions<IFreeSwitchChannelEvent>, |
||||
): FindCursor<T> { |
||||
const theOptions: FindOptions<IFreeSwitchChannelEvent> = { |
||||
sort: { |
||||
sequence: 1, |
||||
}, |
||||
...options, |
||||
}; |
||||
|
||||
return this.find<T>( |
||||
{ |
||||
channelUniqueId, |
||||
}, |
||||
theOptions, |
||||
); |
||||
} |
||||
} |
||||
@ -0,0 +1,26 @@ |
||||
import type { IFreeSwitchChannelEventDelta, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; |
||||
import type { IFreeSwitchChannelEventDeltaModel, InsertionModel } from '@rocket.chat/model-typings'; |
||||
import type { IndexDescription, Collection, Db, WithoutId, InsertOneResult } from 'mongodb'; |
||||
|
||||
import { BaseRaw } from './BaseRaw'; |
||||
|
||||
export class FreeSwitchChannelEventDeltaRaw extends BaseRaw<IFreeSwitchChannelEventDelta> implements IFreeSwitchChannelEventDeltaModel { |
||||
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IFreeSwitchChannelEventDelta>>) { |
||||
super(db, 'freeswitch_channel_event_deltas', trash); |
||||
} |
||||
|
||||
protected modelIndexes(): IndexDescription[] { |
||||
return [ |
||||
{ key: { channelUniqueId: 1, sequence: 1 }, unique: true }, |
||||
|
||||
// Keep event deltas for 30 days
|
||||
{ key: { _updatedAt: 1 }, expireAfterSeconds: 30 * 24 * 60 * 60 }, |
||||
]; |
||||
} |
||||
|
||||
public async registerDelta( |
||||
delta: WithoutId<InsertionModel<IFreeSwitchChannelEventDelta>>, |
||||
): Promise<InsertOneResult<IFreeSwitchChannelEventDelta>> { |
||||
return this.insertOne(delta); |
||||
} |
||||
} |
||||
@ -1,40 +0,0 @@ |
||||
import type { IFreeSwitchEvent, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; |
||||
import type { IFreeSwitchEventModel, InsertionModel } from '@rocket.chat/model-typings'; |
||||
import type { IndexDescription, Collection, Db, FindOptions, FindCursor, WithoutId, InsertOneResult } from 'mongodb'; |
||||
|
||||
import { BaseRaw } from './BaseRaw'; |
||||
|
||||
export class FreeSwitchEventRaw extends BaseRaw<IFreeSwitchEvent> implements IFreeSwitchEventModel { |
||||
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IFreeSwitchEvent>>) { |
||||
super(db, 'freeswitch_events', trash); |
||||
} |
||||
|
||||
protected modelIndexes(): IndexDescription[] { |
||||
return [ |
||||
{ key: { channelUniqueId: 1, sequence: 1 }, unique: true }, |
||||
{ key: { 'call.UUID': 1 } }, |
||||
// Allow 15 days of events to be saved
|
||||
{ key: { _updatedAt: 1 }, expireAfterSeconds: 30 * 24 * 60 * 15 }, |
||||
]; |
||||
} |
||||
|
||||
public async registerEvent(event: WithoutId<InsertionModel<IFreeSwitchEvent>>): Promise<InsertOneResult<IFreeSwitchEvent>> { |
||||
return this.insertOne(event); |
||||
} |
||||
|
||||
public findAllByCallUUID<T extends IFreeSwitchEvent>(callUUID: string, options?: FindOptions<IFreeSwitchEvent>): FindCursor<T> { |
||||
return this.find<T>({ 'call.UUID': callUUID }, options); |
||||
} |
||||
|
||||
public findAllByChannelUniqueIds<T extends IFreeSwitchEvent>( |
||||
uniqueIds: string[], |
||||
options?: FindOptions<IFreeSwitchEvent>, |
||||
): FindCursor<T> { |
||||
return this.find<T>( |
||||
{ |
||||
channelUniqueId: { $in: uniqueIds }, |
||||
}, |
||||
options, |
||||
); |
||||
} |
||||
} |
||||
@ -0,0 +1,36 @@ |
||||
export function convertPathsIntoSubObjects(object: Record<string, any>): Record<string, any> { |
||||
const newObject: Record<string, any> = {}; |
||||
|
||||
for (const key of Object.keys(object)) { |
||||
const value = object[key]; |
||||
if (!value && typeof value !== 'boolean') { |
||||
continue; |
||||
} |
||||
const keyProperties = key.split('.'); |
||||
if (!keyProperties.length) { |
||||
continue; |
||||
} |
||||
|
||||
let current = newObject; |
||||
|
||||
const finalProperty = keyProperties.pop() as string; |
||||
for (const property of keyProperties) { |
||||
if (!(property in current) || typeof current[property] !== 'object') { |
||||
current[property] = {}; |
||||
} |
||||
|
||||
current = current[property]; |
||||
} |
||||
|
||||
if (current[finalProperty]) { |
||||
current[finalProperty] = { |
||||
...(typeof value === 'object' && value), |
||||
...current[finalProperty], |
||||
}; |
||||
} else { |
||||
current[finalProperty] = value; |
||||
} |
||||
} |
||||
|
||||
return newObject; |
||||
} |
||||
@ -0,0 +1,39 @@ |
||||
import { isRecord } from './isRecord'; |
||||
|
||||
describe('isRecord', () => { |
||||
test.each([ |
||||
['case-1', undefined, false], |
||||
['case-2', null, false], |
||||
['case-3', 0, false], |
||||
['case-4', false, false], |
||||
['case-5', '', false], |
||||
])('should return false for falsy values %# (%s)', (_caseId, input, expected) => { |
||||
expect(isRecord(input)).toEqual(expected); |
||||
}); |
||||
|
||||
test.each([ |
||||
['case-1', undefined, false], |
||||
['case-2', null, false], |
||||
['case-3', 20, false], |
||||
['case-4', true, false], |
||||
['case-5', 'string', false], |
||||
])('should return false for non-objects %# (%s)', (_caseId, input, expected) => { |
||||
expect(isRecord(input)).toEqual(expected); |
||||
}); |
||||
|
||||
test.each([ |
||||
['case-1', {}, true], |
||||
['case-2', { prop: 'value' }, true], |
||||
['case-2', Object.fromEntries([]), true], |
||||
])('should return true for records %# (%s)', (_caseId, input, expected) => { |
||||
expect(isRecord(input)).toEqual(expected); |
||||
}); |
||||
|
||||
test.each([ |
||||
['case-1', new Date(), false], |
||||
['case-2', [], false], |
||||
['case-2', [{}], false], |
||||
])('should return false for objects that are not records %# (%s)', (_caseId, input, expected) => { |
||||
expect(isRecord(input)).toEqual(expected); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,7 @@ |
||||
export function isRecord(record: unknown): record is Record<string | number | symbol, unknown> { |
||||
if (!record || typeof record !== 'object') { |
||||
return false; |
||||
} |
||||
|
||||
return Object.getPrototypeOf(record).constructor === Object; |
||||
} |
||||
Loading…
Reference in new issue