From 475120dc19fb8cc400fd8af21559cd6f3cc17eb8 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Fri, 20 Dec 2024 11:29:53 -0300 Subject: [PATCH] feat: keep VoIP history (#34004) Co-authored-by: Diego Sampaio Co-authored-by: Kevin Aleman --- .changeset/lemon-stingrays-invite.md | 9 + .../local-services/voip-freeswitch/service.ts | 543 +++++++++++++++++- apps/meteor/server/lib/videoConfTypes.ts | 14 +- apps/meteor/server/models/FreeSwitchCall.ts | 6 + apps/meteor/server/models/FreeSwitchEvent.ts | 6 + .../server/models/raw/FreeSwitchCall.ts | 28 + .../server/models/raw/FreeSwitchEvent.ts | 40 ++ apps/meteor/server/models/raw/Users.js | 9 + .../server/models/raw/VideoConference.ts | 8 + apps/meteor/server/models/startup.ts | 2 + .../services/video-conference/service.ts | 37 +- .../src/types/IVideoConfService.ts | 3 + packages/core-typings/src/IVideoConference.ts | 36 +- packages/core-typings/src/utils.ts | 8 +- .../core-typings/src/voip/IFreeSwitchCall.ts | 64 +++ .../core-typings/src/voip/IFreeSwitchEvent.ts | 113 ++++ packages/core-typings/src/voip/index.ts | 2 + packages/freeswitch/src/connect.ts | 9 +- packages/freeswitch/src/index.ts | 1 + packages/freeswitch/src/listenToEvents.ts | 37 ++ packages/model-typings/src/index.ts | 2 + .../src/models/IFreeSwitchCallModel.ts | 9 + .../src/models/IFreeSwitchEventModel.ts | 10 + .../model-typings/src/models/IUsersModel.ts | 1 + .../src/models/IVideoConferenceModel.ts | 5 +- packages/models/src/index.ts | 4 + .../src/convertSubObjectsIntoPaths.spec.ts | 117 ++++ .../tools/src/convertSubObjectsIntoPaths.ts | 16 + packages/tools/src/index.ts | 2 + packages/tools/src/objectMap.spec.ts | 93 +++ packages/tools/src/objectMap.ts | 35 ++ 31 files changed, 1254 insertions(+), 15 deletions(-) create mode 100644 .changeset/lemon-stingrays-invite.md create mode 100644 apps/meteor/server/models/FreeSwitchCall.ts create mode 100644 apps/meteor/server/models/FreeSwitchEvent.ts create mode 100644 apps/meteor/server/models/raw/FreeSwitchCall.ts create mode 100644 apps/meteor/server/models/raw/FreeSwitchEvent.ts create mode 100644 packages/core-typings/src/voip/IFreeSwitchCall.ts create mode 100644 packages/core-typings/src/voip/IFreeSwitchEvent.ts create mode 100644 packages/freeswitch/src/listenToEvents.ts create mode 100644 packages/model-typings/src/models/IFreeSwitchCallModel.ts create mode 100644 packages/model-typings/src/models/IFreeSwitchEventModel.ts create mode 100644 packages/tools/src/convertSubObjectsIntoPaths.spec.ts create mode 100644 packages/tools/src/convertSubObjectsIntoPaths.ts create mode 100644 packages/tools/src/objectMap.spec.ts create mode 100644 packages/tools/src/objectMap.ts diff --git a/.changeset/lemon-stingrays-invite.md b/.changeset/lemon-stingrays-invite.md new file mode 100644 index 000000000000..eb57df68c636 --- /dev/null +++ b/.changeset/lemon-stingrays-invite.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/freeswitch': minor +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor +--- + +Allows Rocket.Chat to store call events. diff --git a/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts b/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts index 2eccecc96a00..41010f41ad55 100644 --- a/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts +++ b/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts @@ -1,14 +1,63 @@ -import { type IVoipFreeSwitchService, ServiceClassInternal } from '@rocket.chat/core-services'; -import type { FreeSwitchExtension } from '@rocket.chat/core-typings'; -import { getDomain, getUserPassword, getExtensionList, getExtensionDetails } from '@rocket.chat/freeswitch'; +import { type IVoipFreeSwitchService, ServiceClassInternal, ServiceStarter } from '@rocket.chat/core-services'; +import type { + DeepPartial, + IFreeSwitchEventCall, + IFreeSwitchEventCaller, + IFreeSwitchEvent, + FreeSwitchExtension, + IFreeSwitchCall, + IFreeSwitchCallEventType, + IFreeSwitchCallEvent, + AtLeast, +} from '@rocket.chat/core-typings'; +import { isKnownFreeSwitchEventType } from '@rocket.chat/core-typings'; +import { getDomain, getUserPassword, getExtensionList, getExtensionDetails, listenToEvents } from '@rocket.chat/freeswitch'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { FreeSwitchCall, FreeSwitchEvent, Users } from '@rocket.chat/models'; +import { objectMap, wrapExceptions } from '@rocket.chat/tools'; +import type { WithoutId } from 'mongodb'; +import { MongoError } from 'mongodb'; import { settings } from '../../../../app/settings/server'; export class VoipFreeSwitchService extends ServiceClassInternal implements IVoipFreeSwitchService { protected name = 'voip-freeswitch'; + private serviceStarter: ServiceStarter; + constructor() { super(); + + this.serviceStarter = new ServiceStarter(() => Promise.resolve(this.startEvents())); + this.onEvent('watch.settings', async ({ setting }): Promise => { + if (setting._id === 'VoIP_TeamCollab_Enabled' && setting.value === true) { + void this.serviceStarter.start(); + } + }); + } + + private listening = false; + + public async started(): Promise { + void this.serviceStarter.start(); + } + + private startEvents(): void { + if (this.listening) { + return; + } + + try { + // #ToDo: Reconnection + // #ToDo: Only connect from one rocket.chat instance + void listenToEvents( + async (...args) => wrapExceptions(() => this.onFreeSwitchEvent(...args)).suppress(), + this.getConnectionSettings(), + ); + this.listening = true; + } catch (_e) { + this.listening = false; + } } private getConnectionSettings(): { host: string; port: number; password: string; timeout: number } { @@ -33,6 +82,494 @@ export class VoipFreeSwitchService extends ServiceClassInternal implements IVoip }; } + private async onFreeSwitchEvent(eventName: string, data: Record): Promise { + const uniqueId = data['Unique-ID']; + if (!uniqueId) { + return; + } + + // Using a set to avoid duplicates + const callIds = new Set( + [data['Channel-Call-UUID'], data.variable_call_uuid].filter((callId) => Boolean(callId) && callId !== '0') as string[], + ); + const event = await this.parseEventData(eventName, data); + + // If for some reason the event had different callIds, save a copy of it for each of them + if (callIds.size > 1) { + await Promise.all( + callIds.values().map((callId) => + this.registerEvent({ + ...event, + call: { + ...event.call, + UUID: callId, + }, + }), + ), + ); + return; + } + + await this.registerEvent(event); + } + + private getDetailedEventName(eventName: string, eventData: Record): string { + if (eventName === 'CHANNEL_STATE') { + return `CHANNEL_STATE=${eventData['Channel-State']}`; + } + + if (eventName === 'CHANNEL_CALLSTATE') { + return `CHANNEL_CALLSTATE=${eventData['Channel-Call-State']}`; + } + + return eventName; + } + + private filterOutMissingData>(data: T): DeepPartial { + return objectMap( + data, + ({ key, value }) => { + if (!value || value === '0') { + return; + } + + if (typeof value === 'object' && !Object.keys(value).length) { + return; + } + + return { key, value }; + }, + true, + ) as DeepPartial; + } + + private async parseEventData( + eventName: string, + eventData: Record, + ): Promise>> { + const filteredData: Record = Object.fromEntries( + Object.entries(eventData).filter(([_, value]) => value !== undefined), + ) as Record; + + const detaildEventName = this.getDetailedEventName(eventName, filteredData); + const state = eventData['Channel-State']; + const sequence = eventData['Event-Sequence']; + const previousCallState = eventData['Original-Channel-Call-State']; + const callState = eventData['Channel-Call-State']; + const answerState = eventData['Answer-State']; + const hangupCause = eventData['Hangup-Cause']; + const direction = eventData['Call-Direction']; + const channelName = eventData['Channel-Name']; + + const otherLegUniqueId = eventData['Other-Leg-Unique-ID']; + const loopbackLegUniqueId = eventData.variable_other_loopback_leg_uuid; + const loopbackFromUniqueId = eventData.variable_other_loopback_from_uuid; + const oldUniqueId = eventData['Old-Unique-ID']; + + const channelUniqueId = eventData['Unique-ID']; + const referencedIds = [otherLegUniqueId, loopbackLegUniqueId, loopbackFromUniqueId, oldUniqueId].filter((id) => + Boolean(id), + ) as string[]; + const timestamp = eventData['Event-Date-Timestamp']; + const firedAt = this.parseTimestamp(eventData['Event-Date-Timestamp']); + + const durationStr = eventData.variable_duration; + const duration = (durationStr && parseInt(durationStr)) || 0; + + const call: Partial = { + UUID: (eventData['Channel-Call-UUID'] !== '0' && eventData['Channel-Call-UUID']) || eventData.variable_call_uuid, + answerState, + state: callState, + previousState: previousCallState, + presenceId: eventData['Channel-Presence-ID'], + sipId: eventData.variable_sip_call_id, + authorized: eventData.variable_sip_authorized, + hangupCause, + duration, + + from: { + user: eventData.variable_sip_from_user, + stripped: eventData.variable_sip_from_user_stripped, + port: eventData.variable_sip_from_port, + uri: eventData.variable_sip_from_uri, + host: eventData.variable_sip_from_host, + full: eventData.variable_sip_full_from, + }, + + req: { + user: eventData.variable_sip_req_user, + port: eventData.variable_sip_req_port, + uri: eventData.variable_sip_req_uri, + host: eventData.variable_sip_req_host, + }, + + to: { + user: eventData.variable_sip_to_user, + port: eventData.variable_sip_to_port, + uri: eventData.variable_sip_to_uri, + full: eventData.variable_sip_full_to, + dialedExtension: eventData.variable_dialed_extension, + dialedUser: eventData.variable_dialed_user, + }, + + contact: { + user: eventData.variable_sip_contact_user, + uri: eventData.variable_sip_contact_uri, + host: eventData.variable_sip_contact_host, + }, + + via: { + full: eventData.variable_sip_full_via, + host: eventData.variable_sip_via_host, + rport: eventData.variable_sip_via_rport, + }, + }; + + const caller: Partial = { + uniqueId: eventData['Caller-Unique-ID'], + direction: eventData['Caller-Direction'], + username: eventData['Caller-Username'], + networkAddr: eventData['Caller-Network-Addr'], + ani: eventData['Caller-ANI'], + destinationNumber: eventData['Caller-Destination-Number'], + source: eventData['Caller-Source'], + context: eventData['Caller-Context'], + name: eventData['Caller-Caller-ID-Name'], + number: eventData['Caller-Caller-ID-Number'], + originalCaller: { + name: eventData['Caller-Orig-Caller-ID-Name'], + number: eventData['Caller-Orig-Caller-ID-Number'], + }, + privacy: { + hideName: eventData['Caller-Privacy-Hide-Name'], + hideNumber: eventData['Caller-Privacy-Hide-Number'], + }, + channel: { + name: eventData['Caller-Channel-Name'], + createdTime: eventData['Caller-Channel-Created-Time'], + }, + }; + + return this.filterOutMissingData({ + channelUniqueId, + eventName, + detaildEventName, + sequence, + state, + previousCallState, + callState, + timestamp, + firedAt, + answerState, + hangupCause, + referencedIds, + receivedAt: new Date(), + channelName, + direction, + caller, + call, + eventData: filteredData, + }) as InsertionModel>; + } + + private parseTimestamp(timestamp: string | undefined): Date | undefined { + if (!timestamp || timestamp === '0') { + return undefined; + } + + const value = parseInt(timestamp); + if (Number.isNaN(value)) { + return undefined; + } + + const timeValue = Math.floor(value / 1000); + return new Date(timeValue); + } + + private async registerEvent(event: InsertionModel>): Promise { + try { + await FreeSwitchEvent.registerEvent(event); + if (event.eventName === 'CHANNEL_DESTROY' && event.call?.UUID) { + await this.computeCall(event.call?.UUID); + } + } catch (error) { + // avoid logging that an event was duplicated from mongo + if (error instanceof MongoError && error.code === 11000) { + return; + } + + throw error; + } + } + + private getEventType(event: IFreeSwitchEvent): IFreeSwitchCallEventType { + const { eventName, state, callState } = event; + + const modifiedEventName = eventName.toUpperCase().replace('CHANNEL_', '').replace('_COMPLETE', ''); + + if (isKnownFreeSwitchEventType(modifiedEventName)) { + return modifiedEventName; + } + + if (modifiedEventName === 'STATE') { + if (!state) { + return 'OTHER_STATE'; + } + + const modifiedState = state.toUpperCase().replace('CS_', ''); + if (isKnownFreeSwitchEventType(modifiedState)) { + return modifiedState; + } + } + + if (modifiedEventName === 'CALLSTATE') { + if (!callState) { + return 'OTHER_CALL_STATE'; + } + + const modifiedCallState = callState.toUpperCase().replace('CS_', ''); + if (isKnownFreeSwitchEventType(modifiedCallState)) { + return modifiedCallState; + } + } + + return 'OTHER'; + } + + private identifyCallerFromEvent(event: IFreeSwitchEvent): string { + if (event.call?.from?.user) { + return event.call.from.user; + } + + if (event.caller?.username) { + return event.caller.username; + } + + if (event.caller?.number) { + return event.caller.number; + } + + if (event.caller?.ani) { + return event.caller.ani; + } + + return ''; + } + + private identifyCalleeFromEvent(event: IFreeSwitchEvent): string { + if (event.call?.to?.dialedExtension) { + return event.call.to.dialedExtension; + } + + if (event.call?.to?.dialedUser) { + return event.call.to.dialedUser; + } + + return ''; + } + + private isImportantEvent(event: IFreeSwitchEvent): boolean { + return Object.keys(event).some((key) => key.startsWith('variable_')); + } + + private async computeCall(callUUID: string): Promise { + const allEvents = await FreeSwitchEvent.findAllByCallUUID(callUUID).toArray(); + const call: InsertionModel = { + UUID: callUUID, + channels: [], + events: [], + }; + + // Sort events by both sequence and timestamp, but only when they are present + const sortedEvents = allEvents.sort((event1: IFreeSwitchEvent, event2: IFreeSwitchEvent) => { + if (event1.sequence && event2.sequence) { + return event1.sequence.localeCompare(event2.sequence); + } + + if (event1.firedAt && event2.firedAt) { + return event1.firedAt.valueOf() - event2.firedAt.valueOf(); + } + + if (event1.sequence || event2.sequence) { + return (event1.sequence || '').localeCompare(event2.sequence || ''); + } + + return (event1.firedAt?.valueOf() || 0) - (event2.firedAt?.valueOf() || 0); + }); + + const fromUser = new Set(); + const toUser = new Set(); + let isVoicemailCall = false; + for (const event of sortedEvents) { + if (event.channelUniqueId && !call.channels.includes(event.channelUniqueId)) { + call.channels.push(event.channelUniqueId); + } + + const eventType = this.getEventType(event); + fromUser.add(this.identifyCallerFromEvent(event)); + toUser.add(this.identifyCalleeFromEvent(event)); + + // when a call enters the voicemail, we receive one/or many events with the channelName = loopback/voicemail-x + // where X appears to be a letter + isVoicemailCall = event.channelName?.includes('voicemail') || isVoicemailCall; + + const hasUsefulCallData = this.isImportantEvent(event); + + const callEvent = this.filterOutMissingData({ + type: eventType, + caller: event.caller, + ...(hasUsefulCallData && { call: event.call }), + + otherType: event.eventData['Other-Type'], + otherChannelId: event.eventData['Other-Leg-Unique-ID'], + }) as AtLeast; + + if (call.events[call.events.length - 1]?.type === eventType) { + const previousEvent = call.events.pop() as IFreeSwitchCallEvent; + + call.events.push({ + ...previousEvent, + ...callEvent, + caller: { + ...previousEvent.caller, + ...callEvent.caller, + }, + ...((previousEvent.call || callEvent.call) && { + call: { + ...previousEvent.call, + ...callEvent.call, + from: { + ...previousEvent.call?.from, + ...callEvent.call?.from, + }, + req: { + ...previousEvent.call?.req, + ...callEvent.call?.req, + }, + to: { + ...previousEvent.call?.to, + ...callEvent.call?.to, + }, + contact: { + ...previousEvent.call?.contact, + ...callEvent.call?.contact, + }, + via: { + ...previousEvent.call?.via, + ...callEvent.call?.via, + }, + }, + }), + }); + continue; + } + + call.events.push({ + ...callEvent, + eventName: event.eventName, + sequence: event.sequence, + channelUniqueId: event.channelUniqueId, + timestamp: event.timestamp, + firedAt: event.firedAt, + }); + } + + if (fromUser.size) { + const callerIds = [...fromUser].filter((e) => !!e); + const user = await Users.findOneByFreeSwitchExtensions(callerIds, { + projection: { _id: 1, username: 1, name: 1, avatarETag: 1, freeSwitchExtension: 1 }, + }); + + if (user) { + call.from = { + _id: user._id, + username: user.username, + name: user.name, + avatarETag: user.avatarETag, + freeSwitchExtension: user.freeSwitchExtension, + }; + } + } + + if (toUser.size) { + const calleeIds = [...toUser].filter((e) => !!e); + const user = await Users.findOneByFreeSwitchExtensions(calleeIds, { + projection: { _id: 1, username: 1, name: 1, avatarETag: 1, freeSwitchExtension: 1 }, + }); + if (user) { + call.to = { + _id: user._id, + username: user.username, + name: user.name, + avatarETag: user.avatarETag, + freeSwitchExtension: user.freeSwitchExtension, + }; + } + } + + // A call has 2 channels at max + // If it has 3 or more channels, it's a forwarded call + if (call.channels.length >= 3) { + const originalCalls = await FreeSwitchCall.findAllByChannelUniqueIds(call.channels, { projection: { events: 0 } }).toArray(); + if (originalCalls.length) { + call.forwardedFrom = originalCalls; + } + } + + // Call originated from us but destination and destination is another user = internal + if (call.from && call.to) { + call.direction = 'internal'; + } + + // Call originated from us but destination is not on server = external outbound + if (call.from && !call.to) { + call.direction = 'external_outbound'; + } + + // Call originated from a user outside server but received by a user in our side = external inbound + if (!call.from && call.to) { + call.direction = 'external_inbound'; + } + + // Call ended up in voicemail of another user = voicemail + if (isVoicemailCall) { + call.voicemail = true; + } + + call.duration = this.computeCallDuration(call); + + await FreeSwitchCall.registerCall(call); + } + + private computeCallDuration(call: InsertionModel): number { + if (!call.events.length) { + return 0; + } + + const channelAnswerEvent = call.events.find((e) => e.eventName === 'CHANNEL_ANSWER'); + if (!channelAnswerEvent?.timestamp) { + return 0; + } + + const answer = this.parseTimestamp(channelAnswerEvent.timestamp); + if (!answer) { + return 0; + } + + const channelHangupEvent = call.events.find((e) => e.eventName === 'CHANNEL_HANGUP_COMPLETE'); + if (!channelHangupEvent?.timestamp) { + // We dont have a hangup but we have an answer, assume hangup is === destroy time + return new Date().getTime() - answer.getTime(); + } + + const hangup = this.parseTimestamp(channelHangupEvent.timestamp); + if (!hangup) { + return 0; + } + + return hangup.getTime() - answer.getTime(); + } + async getDomain(): Promise { const options = this.getConnectionSettings(); return getDomain(options); diff --git a/apps/meteor/server/lib/videoConfTypes.ts b/apps/meteor/server/lib/videoConfTypes.ts index d899539c3ba7..7529115745d5 100644 --- a/apps/meteor/server/lib/videoConfTypes.ts +++ b/apps/meteor/server/lib/videoConfTypes.ts @@ -1,4 +1,11 @@ -import type { AtLeast, IRoom, VideoConferenceCreateData, VideoConferenceType } from '@rocket.chat/core-typings'; +import type { + AtLeast, + ExternalVideoConference, + IRoom, + VideoConference, + VideoConferenceCreateData, + VideoConferenceType, +} from '@rocket.chat/core-typings'; type RoomRequiredFields = AtLeast; type VideoConferenceTypeCondition = (room: RoomRequiredFields, allowRinging: boolean) => Promise; @@ -34,6 +41,11 @@ export const videoConfTypes = { return { type: 'videoconference' }; }, + + isCallManagedByApp(call: VideoConference): call is ExternalVideoConference { + return call.type !== 'voip'; + }, }; +videoConfTypes.registerVideoConferenceType('voip', async () => false); videoConfTypes.registerVideoConferenceType({ type: 'livechat' }, async ({ t }) => t === 'l'); diff --git a/apps/meteor/server/models/FreeSwitchCall.ts b/apps/meteor/server/models/FreeSwitchCall.ts new file mode 100644 index 000000000000..97b470538e80 --- /dev/null +++ b/apps/meteor/server/models/FreeSwitchCall.ts @@ -0,0 +1,6 @@ +import { registerModel } from '@rocket.chat/models'; + +import { db } from '../database/utils'; +import { FreeSwitchCallRaw } from './raw/FreeSwitchCall'; + +registerModel('IFreeSwitchCallModel', new FreeSwitchCallRaw(db)); diff --git a/apps/meteor/server/models/FreeSwitchEvent.ts b/apps/meteor/server/models/FreeSwitchEvent.ts new file mode 100644 index 000000000000..cab4d7daa2e4 --- /dev/null +++ b/apps/meteor/server/models/FreeSwitchEvent.ts @@ -0,0 +1,6 @@ +import { registerModel } from '@rocket.chat/models'; + +import { db } from '../database/utils'; +import { FreeSwitchEventRaw } from './raw/FreeSwitchEvent'; + +registerModel('IFreeSwitchEventModel', new FreeSwitchEventRaw(db)); diff --git a/apps/meteor/server/models/raw/FreeSwitchCall.ts b/apps/meteor/server/models/raw/FreeSwitchCall.ts new file mode 100644 index 000000000000..2be10e0d29d3 --- /dev/null +++ b/apps/meteor/server/models/raw/FreeSwitchCall.ts @@ -0,0 +1,28 @@ +import type { IFreeSwitchCall, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { IFreeSwitchCallModel, InsertionModel } from '@rocket.chat/model-typings'; +import type { Collection, Db, FindCursor, FindOptions, IndexDescription, WithoutId } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class FreeSwitchCallRaw extends BaseRaw implements IFreeSwitchCallModel { + constructor(db: Db, trash?: Collection>) { + super(db, 'freeswitch_calls', trash); + } + + protected modelIndexes(): IndexDescription[] { + return [{ key: { UUID: 1 } }, { key: { channels: 1 } }]; + } + + public async registerCall(call: WithoutId>): Promise { + await this.findOneAndUpdate({ UUID: call.UUID }, { $set: call }, { upsert: true }); + } + + public findAllByChannelUniqueIds(uniqueIds: string[], options?: FindOptions): FindCursor { + return this.find( + { + channels: { $in: uniqueIds }, + }, + options, + ); + } +} diff --git a/apps/meteor/server/models/raw/FreeSwitchEvent.ts b/apps/meteor/server/models/raw/FreeSwitchEvent.ts new file mode 100644 index 000000000000..236f891cee0d --- /dev/null +++ b/apps/meteor/server/models/raw/FreeSwitchEvent.ts @@ -0,0 +1,40 @@ +import type { IFreeSwitchEvent, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { IFreeSwitchEventModel, InsertionModel } from '@rocket.chat/model-typings'; +import type { IndexDescription, Collection, Db, FindOptions, FindCursor, WithoutId, InsertOneResult } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class FreeSwitchEventRaw extends BaseRaw implements IFreeSwitchEventModel { + constructor(db: Db, trash?: Collection>) { + super(db, 'freeswitch_events', trash); + } + + protected modelIndexes(): IndexDescription[] { + return [ + { key: { channelUniqueId: 1, sequence: 1 }, unique: true }, + { key: { 'call.UUID': 1 } }, + // Allow 15 days of events to be saved + { key: { _updatedAt: 1 }, expireAfterSeconds: 30 * 24 * 60 * 15 }, + ]; + } + + public async registerEvent(event: WithoutId>): Promise> { + return this.insertOne(event); + } + + public findAllByCallUUID(callUUID: string, options?: FindOptions): FindCursor { + return this.find({ 'call.UUID': callUUID }, options); + } + + public findAllByChannelUniqueIds( + uniqueIds: string[], + options?: FindOptions, + ): FindCursor { + return this.find( + { + channelUniqueId: { $in: uniqueIds }, + }, + options, + ); + } +} diff --git a/apps/meteor/server/models/raw/Users.js b/apps/meteor/server/models/raw/Users.js index 0e5832b2aad9..04df04af5939 100644 --- a/apps/meteor/server/models/raw/Users.js +++ b/apps/meteor/server/models/raw/Users.js @@ -2485,6 +2485,15 @@ export class UsersRaw extends BaseRaw { ); } + findOneByFreeSwitchExtensions(freeSwitchExtensions, options = {}) { + return this.findOne( + { + freeSwitchExtension: { $in: freeSwitchExtensions }, + }, + options, + ); + } + findAssignedFreeSwitchExtensions() { return this.findUsersWithAssignedFreeSwitchExtensions({ projection: { diff --git a/apps/meteor/server/models/raw/VideoConference.ts b/apps/meteor/server/models/raw/VideoConference.ts index 5d18d9892038..0f631cf2d301 100644 --- a/apps/meteor/server/models/raw/VideoConference.ts +++ b/apps/meteor/server/models/raw/VideoConference.ts @@ -5,6 +5,7 @@ import type { IUser, IRoom, RocketChatRecordDeleted, + IVoIPVideoConference, } from '@rocket.chat/core-typings'; import { VideoConferenceStatus } from '@rocket.chat/core-typings'; import type { FindPaginated, InsertionModel, IVideoConferenceModel } from '@rocket.chat/model-typings'; @@ -136,6 +137,13 @@ export class VideoConferenceRaw extends BaseRaw implements IVid return (await this.insertOne(call)).insertedId; } + public async createVoIP(call: InsertionModel): Promise { + const { externalId, ...data } = call; + + const doc = await this.findOneAndUpdate({ externalId }, { $set: data }, { upsert: true, returnDocument: 'after' }); + return doc.value?._id; + } + public updateOneById( _id: string, update: UpdateFilter | Partial, diff --git a/apps/meteor/server/models/startup.ts b/apps/meteor/server/models/startup.ts index c3ecc381f7f0..a03c2265c683 100644 --- a/apps/meteor/server/models/startup.ts +++ b/apps/meteor/server/models/startup.ts @@ -15,6 +15,8 @@ import './EmojiCustom'; import './ExportOperations'; import './FederationKeys'; import './FederationServers'; +import './FreeSwitchCall'; +import './FreeSwitchEvent'; import './ImportData'; import './InstanceStatus'; import './IntegrationHistory'; diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 1675029ef03b..694f92d014a6 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -21,6 +21,8 @@ import type { VideoConferenceCapabilities, VideoConferenceCreateData, Optional, + ExternalVideoConference, + IVoIPVideoConference, } from '@rocket.chat/core-typings'; import { VideoConferenceStatus, @@ -29,6 +31,7 @@ import { isLivechatVideoConference, } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; +import type { InsertionModel } from '@rocket.chat/model-typings'; import { Users, VideoConference as VideoConferenceModel, Rooms, Messages, Subscriptions } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import type { PaginatedResult } from '@rocket.chat/rest-typings'; @@ -140,7 +143,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf public async join(uid: IUser['_id'] | undefined, callId: VideoConference['_id'], options: VideoConferenceJoinOptions): Promise { return wrapExceptions(async () => { const call = await VideoConferenceModel.findOneById(callId); - if (!call || call.endedAt) { + if (!call || call.endedAt || !videoConfTypes.isCallManagedByApp(call)) { throw new Error('invalid-call'); } @@ -175,6 +178,10 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('invalid-call'); } + if (!videoConfTypes.isCallManagedByApp(call)) { + return []; + } + if (!videoConfProviders.isProviderAvailable(call.providerName)) { throw new Error('video-conf-provider-unavailable'); } @@ -454,6 +461,16 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return true; } + public async createVoIP(data: InsertionModel): Promise { + return wrapExceptions(async () => VideoConferenceModel.createVoIP(data)).catch((err) => { + logger.error({ + name: 'Error on VideoConf.createVoIP', + err, + }); + throw err; + }); + } + private notifyUser( userId: IUser['_id'], action: string, @@ -855,7 +872,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } private async joinCall( - call: VideoConference, + call: ExternalVideoConference, user: AtLeast | undefined, options: VideoConferenceJoinOptions, ): Promise { @@ -885,7 +902,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return room?.fname || room?.name || rid; } - private async generateNewUrl(call: VideoConference): Promise { + private async generateNewUrl(call: ExternalVideoConference): Promise { if (!videoConfProviders.isProviderAvailable(call.providerName)) { throw new Error('video-conf-provider-unavailable'); } @@ -944,7 +961,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } private async getUrl( - call: VideoConference, + call: ExternalVideoConference, user?: AtLeast, options: VideoConferenceJoinOptions = {}, ): Promise { @@ -987,6 +1004,10 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-data-not-found'); } + if (!videoConfTypes.isCallManagedByApp(call)) { + return; + } + if (!videoConfProviders.isProviderAvailable(call.providerName)) { throw new Error('video-conf-provider-unavailable'); } @@ -1001,6 +1022,10 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-data-not-found'); } + if (!videoConfTypes.isCallManagedByApp(call)) { + return; + } + if (!videoConfProviders.isProviderAvailable(call.providerName)) { throw new Error('video-conf-provider-unavailable'); } @@ -1015,6 +1040,10 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-data-not-found'); } + if (!videoConfTypes.isCallManagedByApp(call)) { + return; + } + if (!videoConfProviders.isProviderAvailable(call.providerName)) { throw new Error('video-conf-provider-unavailable'); } diff --git a/packages/core-services/src/types/IVideoConfService.ts b/packages/core-services/src/types/IVideoConfService.ts index 4f007229b98d..3c21db2f677b 100644 --- a/packages/core-services/src/types/IVideoConfService.ts +++ b/packages/core-services/src/types/IVideoConfService.ts @@ -2,11 +2,13 @@ import type { IRoom, IStats, IUser, + IVoIPVideoConference, VideoConference, VideoConferenceCapabilities, VideoConferenceCreateData, VideoConferenceInstructions, } from '@rocket.chat/core-typings'; +import type { InsertionModel } from '@rocket.chat/model-typings'; import type { PaginatedResult } from '@rocket.chat/rest-typings'; import type * as UiKit from '@rocket.chat/ui-kit'; @@ -41,4 +43,5 @@ export interface IVideoConfService { params: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] }, ): Promise; assignDiscussionToConference(callId: VideoConference['_id'], rid: IRoom['_id'] | undefined): Promise; + createVoIP(data: InsertionModel): Promise; } diff --git a/packages/core-typings/src/IVideoConference.ts b/packages/core-typings/src/IVideoConference.ts index 334e1fd6a0e2..c3c084865156 100644 --- a/packages/core-typings/src/IVideoConference.ts +++ b/packages/core-typings/src/IVideoConference.ts @@ -29,7 +29,7 @@ export type LivechatInstructions = { callId: string; }; -export type VideoConferenceType = DirectCallInstructions['type'] | ConferenceInstructions['type'] | LivechatInstructions['type']; +export type VideoConferenceType = DirectCallInstructions['type'] | ConferenceInstructions['type'] | LivechatInstructions['type'] | 'voip'; export interface IVideoConferenceUser extends Pick, '_id' | 'username' | 'name' | 'avatarETag'> { ts: Date; @@ -73,7 +73,32 @@ export interface ILivechatVideoConference extends IVideoConference { type: 'livechat'; } -export type VideoConference = IDirectVideoConference | IGroupVideoConference | ILivechatVideoConference; +export interface IVoIPVideoConferenceData {} + +export type IVoIPVideoConference = IVideoConference & { + type: 'voip'; + externalId: string; + + callerExtension?: string; + calleeExtension?: string; + external?: boolean; + transferred?: boolean; + duration?: number; + + events: { + outgoing?: boolean; + hold?: boolean; + park?: boolean; + bridge?: boolean; + answer?: boolean; + }; +}; + +export type ExternalVideoConference = IDirectVideoConference | IGroupVideoConference | ILivechatVideoConference; + +export type InternalVideoConference = IVoIPVideoConference; + +export type VideoConference = ExternalVideoConference | InternalVideoConference; export type VideoConferenceInstructions = DirectCallInstructions | ConferenceInstructions | LivechatInstructions; @@ -89,11 +114,16 @@ export const isLivechatVideoConference = (call: VideoConference | undefined | nu return call?.type === 'livechat'; }; +export const isVoIPVideoConference = (call: VideoConference | undefined | null): call is IVoIPVideoConference => { + return call?.type === 'voip'; +}; + type GroupVideoConferenceCreateData = Omit & { createdBy: IUser['_id'] }; type DirectVideoConferenceCreateData = Omit & { createdBy: IUser['_id'] }; type LivechatVideoConferenceCreateData = Omit & { createdBy: IUser['_id'] }; +type VoIPVideoConferenceCreateData = Omit & { createdBy: IUser['_id'] }; export type VideoConferenceCreateData = AtLeast< - DirectVideoConferenceCreateData | GroupVideoConferenceCreateData | LivechatVideoConferenceCreateData, + DirectVideoConferenceCreateData | GroupVideoConferenceCreateData | LivechatVideoConferenceCreateData | VoIPVideoConferenceCreateData, 'createdBy' | 'type' | 'rid' | 'providerName' | 'providerData' >; diff --git a/packages/core-typings/src/utils.ts b/packages/core-typings/src/utils.ts index c00d8c3f5a7d..2e20ebc48c84 100644 --- a/packages/core-typings/src/utils.ts +++ b/packages/core-typings/src/utils.ts @@ -40,5 +40,11 @@ export type ValueOfUnion> = T extends any ? (K extends export type ValueOfOptional> = T extends undefined ? undefined : T extends object ? ValueOfUnion : null; export type DeepPartial = { - [P in keyof T]?: T[P] extends (infer U)[] ? DeepPartial[] : T[P] extends object | undefined ? DeepPartial : T[P]; + [P in keyof T]?: T[P] extends (infer U)[] | undefined + ? DeepPartial[] + : T[P] extends Date | undefined + ? T[P] + : T[P] extends object | undefined + ? DeepPartial + : T[P]; }; diff --git a/packages/core-typings/src/voip/IFreeSwitchCall.ts b/packages/core-typings/src/voip/IFreeSwitchCall.ts new file mode 100644 index 000000000000..b0f8043f78fc --- /dev/null +++ b/packages/core-typings/src/voip/IFreeSwitchCall.ts @@ -0,0 +1,64 @@ +import type { IRocketChatRecord } from '../IRocketChatRecord'; +import type { IUser } from '../IUser'; +import type { IFreeSwitchEventCall, IFreeSwitchEventCaller } from './IFreeSwitchEvent'; + +export interface IFreeSwitchCall extends IRocketChatRecord { + UUID: string; + channels: string[]; + events: IFreeSwitchCallEvent[]; + from?: Pick; + to?: Pick; + forwardedFrom?: Omit[]; + direction?: 'internal' | 'external_inbound' | 'external_outbound'; + voicemail?: boolean; + duration?: number; +} + +const knownEventTypes = [ + 'NEW', + 'INIT', + 'CREATE', + 'DESTROY', + 'ANSWER', + 'HANGUP', + 'BRIDGE', + 'UNBRIDGE', + 'OUTGOING', + 'PARK', + 'UNPARK', + 'HOLD', + 'UNHOLD', + 'ORIGINATE', + 'UUID', + 'REPORTING', + 'ROUTING', + 'RINGING', + 'ACTIVE', + 'EARLY', + 'RING_WAIT', + 'EXECUTE', + 'CONSUME_MEDIA', + 'EXCHANGE_MEDIA', + 'OTHER', + 'OTHER_STATE', + 'OTHER_CALL_STATE', +] as const; + +export type IFreeSwitchCallEventType = (typeof knownEventTypes)[number]; + +export const isKnownFreeSwitchEventType = (eventName: string): eventName is IFreeSwitchCallEventType => + knownEventTypes.includes(eventName as any); + +export type IFreeSwitchCallEvent = { + eventName: string; + type: IFreeSwitchCallEventType; + sequence?: string; + channelUniqueId?: string; + timestamp?: string; + firedAt?: Date; + caller?: IFreeSwitchEventCaller; + call?: IFreeSwitchEventCall; + + otherType?: string; + otherChannelId?: string; +}; diff --git a/packages/core-typings/src/voip/IFreeSwitchEvent.ts b/packages/core-typings/src/voip/IFreeSwitchEvent.ts new file mode 100644 index 000000000000..a1cc3e7eafe9 --- /dev/null +++ b/packages/core-typings/src/voip/IFreeSwitchEvent.ts @@ -0,0 +1,113 @@ +import type { IRocketChatRecord } from '../IRocketChatRecord'; + +export interface IFreeSwitchEvent extends IRocketChatRecord { + channelUniqueId?: string; + eventName: string; + detaildEventName: string; + + sequence?: string; + state?: string; + previousCallState?: string; + callState?: string; + timestamp?: string; + + firedAt?: Date; + answerState?: string; + hangupCause?: string; + + referencedIds?: string[]; + receivedAt?: Date; + + channelName?: string; + direction?: string; + + caller?: IFreeSwitchEventCaller; + call?: IFreeSwitchEventCall; + + eventData: Record; +} + +export interface IFreeSwitchEventCall { + UUID?: string; + answerState?: string; + state?: string; + previousState?: string; + presenceId?: string; + sipId?: string; + authorized?: string; + hangupCause?: string; + duration?: number; + + from?: { + user?: string; + stripped?: string; + port?: string; + uri?: string; + host?: string; + full?: string; + + userId?: string; + }; + + req?: { + user?: string; + port?: string; + uri?: string; + host?: string; + + userId?: string; + }; + + to?: { + user?: string; + port?: string; + uri?: string; + full?: string; + dialedExtension?: string; + dialedUser?: string; + + userId?: string; + }; + + contact?: { + user?: string; + uri?: string; + host?: string; + + userId?: string; + }; + + via?: { + full?: string; + host?: string; + rport?: string; + + userId?: string; + }; +} + +export interface IFreeSwitchEventCaller { + uniqueId?: string; + direction?: string; + username?: string; + networkAddr?: string; + ani?: string; + destinationNumber?: string; + source?: string; + context?: string; + name?: string; + number?: string; + + originalCaller?: { + name?: string; + number?: string; + }; + privacy?: { + hideName?: string; + hideNumber?: string; + }; + channel?: { + name?: string; + createdTime?: string; + }; +} diff --git a/packages/core-typings/src/voip/index.ts b/packages/core-typings/src/voip/index.ts index 0a83a01d70bc..edede37e6cdc 100644 --- a/packages/core-typings/src/voip/index.ts +++ b/packages/core-typings/src/voip/index.ts @@ -17,3 +17,5 @@ export * from './IVoipClientEvents'; export * from './VoIPUserConfiguration'; export * from './VoIpCallerInfo'; export * from './ICallDetails'; +export * from './IFreeSwitchCall'; +export * from './IFreeSwitchEvent'; diff --git a/packages/freeswitch/src/connect.ts b/packages/freeswitch/src/connect.ts index 6ea3741edc42..2d6d74295af1 100644 --- a/packages/freeswitch/src/connect.ts +++ b/packages/freeswitch/src/connect.ts @@ -6,7 +6,12 @@ import { logger } from './logger'; const defaultPassword = 'ClueCon'; -export async function connect(options?: { host?: string; port?: number; password?: string }): Promise { +export type EventNames = Parameters; + +export async function connect( + options?: { host?: string; port?: number; password?: string }, + customEventNames: EventNames = [], +): Promise { const host = options?.host ?? '127.0.0.1'; const port = options?.port ?? 8021; const password = options?.password ?? defaultPassword; @@ -26,7 +31,7 @@ export async function connect(options?: { host?: string; port?: number; password await currentCall.onceAsync('freeswitch_auth_request', 20_000, 'FreeSwitchClient expected authentication request'); await currentCall.auth(password); currentCall.auto_cleanup(); - await currentCall.event_json('CHANNEL_EXECUTE_COMPLETE', 'BACKGROUND_JOB'); + await currentCall.event_json('CHANNEL_EXECUTE_COMPLETE', 'BACKGROUND_JOB', ...customEventNames); } catch (error) { logger.error('FreeSwitchClient: connect error', error); reject(error); diff --git a/packages/freeswitch/src/index.ts b/packages/freeswitch/src/index.ts index 30272ff42df9..6248f38c97d5 100644 --- a/packages/freeswitch/src/index.ts +++ b/packages/freeswitch/src/index.ts @@ -1 +1,2 @@ export * from './commands'; +export * from './listenToEvents'; diff --git a/packages/freeswitch/src/listenToEvents.ts b/packages/freeswitch/src/listenToEvents.ts new file mode 100644 index 000000000000..c108a9890baa --- /dev/null +++ b/packages/freeswitch/src/listenToEvents.ts @@ -0,0 +1,37 @@ +import type { FreeSwitchResponse } from 'esl'; + +import { connect, type EventNames } from './connect'; + +export async function listenToEvents( + callback: (eventName: string, data: Record) => Promise, + options?: { host?: string; port?: number; password?: string }, +): Promise { + const eventsToListen: EventNames = [ + 'CHANNEL_CALLSTATE', + 'CHANNEL_STATE', + 'CHANNEL_CREATE', + 'CHANNEL_DESTROY', + 'CHANNEL_ANSWER', + 'CHANNEL_HANGUP', + 'CHANNEL_HANGUP_COMPLETE', + 'CHANNEL_BRIDGE', + 'CHANNEL_UNBRIDGE', + 'CHANNEL_OUTGOING', + 'CHANNEL_PARK', + 'CHANNEL_UNPARK', + 'CHANNEL_HOLD', + 'CHANNEL_UNHOLD', + 'CHANNEL_ORIGINATE', + 'CHANNEL_UUID', + ]; + + const connection = await connect(options, eventsToListen); + + eventsToListen.forEach((eventName) => + connection.on(eventName, (event) => { + callback(eventName, event.body); + }), + ); + + return connection; +} diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 77fe8f012ec9..5482132e4e57 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -14,6 +14,8 @@ export * from './models/IEmojiCustomModel'; export * from './models/IExportOperationsModel'; export * from './models/IFederationKeysModel'; export * from './models/IFederationServersModel'; +export * from './models/IFreeSwitchCallModel'; +export * from './models/IFreeSwitchEventModel'; export * from './models/IInstanceStatusModel'; export * from './models/IIntegrationHistoryModel'; export * from './models/IIntegrationsModel'; diff --git a/packages/model-typings/src/models/IFreeSwitchCallModel.ts b/packages/model-typings/src/models/IFreeSwitchCallModel.ts new file mode 100644 index 000000000000..ef5b35860420 --- /dev/null +++ b/packages/model-typings/src/models/IFreeSwitchCallModel.ts @@ -0,0 +1,9 @@ +import type { IFreeSwitchCall } from '@rocket.chat/core-typings'; +import type { FindCursor, FindOptions, WithoutId } from 'mongodb'; + +import type { IBaseModel, InsertionModel } from './IBaseModel'; + +export interface IFreeSwitchCallModel extends IBaseModel { + registerCall(call: WithoutId>): Promise; + findAllByChannelUniqueIds(uniqueIds: string[], options?: FindOptions): FindCursor; +} diff --git a/packages/model-typings/src/models/IFreeSwitchEventModel.ts b/packages/model-typings/src/models/IFreeSwitchEventModel.ts new file mode 100644 index 000000000000..118a57f85410 --- /dev/null +++ b/packages/model-typings/src/models/IFreeSwitchEventModel.ts @@ -0,0 +1,10 @@ +import type { IFreeSwitchEvent } from '@rocket.chat/core-typings'; +import type { FindCursor, FindOptions, WithoutId, InsertOneResult } from 'mongodb'; + +import type { IBaseModel, InsertionModel } from './IBaseModel'; + +export interface IFreeSwitchEventModel extends IBaseModel { + registerEvent(event: WithoutId>): Promise>; + findAllByCallUUID(callUUID: string, options?: FindOptions): FindCursor; + findAllByChannelUniqueIds(uniqueIds: string[], options?: FindOptions): FindCursor; +} diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index a2863aba8fe5..407006596ba6 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -405,6 +405,7 @@ export interface IUsersModel extends IBaseModel { findAgentsAvailableWithoutBusinessHours(userIds: string[] | null): FindCursor>; updateLivechatStatusByAgentIds(userIds: string[], status: ILivechatAgentStatus): Promise; findOneByFreeSwitchExtension(extension: string, options?: FindOptions): Promise; + findOneByFreeSwitchExtensions(extensions: string[], options?: FindOptions): Promise; setFreeSwitchExtension(userId: string, extension: string | undefined): Promise; findAssignedFreeSwitchExtensions(): FindCursor; findUsersWithAssignedFreeSwitchExtensions(options?: FindOptions): FindCursor; diff --git a/packages/model-typings/src/models/IVideoConferenceModel.ts b/packages/model-typings/src/models/IVideoConferenceModel.ts index 8ef775fb6082..66a082af85d2 100644 --- a/packages/model-typings/src/models/IVideoConferenceModel.ts +++ b/packages/model-typings/src/models/IVideoConferenceModel.ts @@ -5,10 +5,11 @@ import type { IUser, VideoConference, VideoConferenceStatus, + IVoIPVideoConference, } from '@rocket.chat/core-typings'; import type { FindCursor, UpdateOptions, UpdateFilter, UpdateResult, FindOptions } from 'mongodb'; -import type { FindPaginated, IBaseModel } from './IBaseModel'; +import type { FindPaginated, IBaseModel, InsertionModel } from './IBaseModel'; export interface IVideoConferenceModel extends IBaseModel { findPaginatedByRoomId( @@ -67,4 +68,6 @@ export interface IVideoConferenceModel extends IBaseModel { setDiscussionRidById(callId: string, discussionRid: IRoom['_id']): Promise; unsetDiscussionRid(discussionRid: IRoom['_id']): Promise; + + createVoIP(call: InsertionModel): Promise; } diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 67bb4dfbcd47..7f13e4c9a079 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -13,6 +13,8 @@ import type { IExportOperationsModel, IFederationKeysModel, IFederationServersModel, + IFreeSwitchCallModel, + IFreeSwitchEventModel, IInstanceStatusModel, IIntegrationHistoryModel, IIntegrationsModel, @@ -111,6 +113,8 @@ export const ExportOperations = proxify('IExportOperatio export const FederationServers = proxify('IFederationServersModel'); export const FederationKeys = proxify('IFederationKeysModel'); export const FederationRoomEvents = proxify('IFederationRoomEventsModel'); +export const FreeSwitchCall = proxify('IFreeSwitchCallModel'); +export const FreeSwitchEvent = proxify('IFreeSwitchEventModel'); export const ImportData = proxify('IImportDataModel'); export const Imports = proxify('IImportsModel'); export const InstanceStatus = proxify('IInstanceStatusModel'); diff --git a/packages/tools/src/convertSubObjectsIntoPaths.spec.ts b/packages/tools/src/convertSubObjectsIntoPaths.spec.ts new file mode 100644 index 000000000000..c595a17ae70e --- /dev/null +++ b/packages/tools/src/convertSubObjectsIntoPaths.spec.ts @@ -0,0 +1,117 @@ +import { expect } from 'chai'; + +import { convertSubObjectsIntoPaths } from './convertSubObjectsIntoPaths'; + +describe('convertSubObjectsIntoPaths', () => { + it('should flatten a simple object with no nested structure', () => { + const input = { a: 1, b: 2, c: 3 }; + const expected = { a: 1, b: 2, c: 3 }; + + expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected); + }); + + it('should flatten a nested object into paths', () => { + const input = { + a: 1, + b: { + c: 2, + d: { + e: 3, + }, + }, + }; + const expected = { + 'a': 1, + 'b.c': 2, + 'b.d.e': 3, + }; + + expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected); + }); + + it('should handle objects with array values', () => { + const input = { + a: [1, 2, 3], + b: { + c: [4, 5], + }, + }; + const expected = { + 'a': [1, 2, 3], + 'b.c': [4, 5], + }; + + expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected); + }); + + it('should handle deeply nested objects', () => { + const input = { + a: { + b: { + c: { + d: { + e: { + f: 6, + }, + }, + }, + }, + }, + }; + const expected = { + 'a.b.c.d.e.f': 6, + }; + + expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected); + }); + + it('should handle an empty object', () => { + const input = {}; + const expected = {}; + + expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected); + }); + + it('should handle objects with mixed types of values', () => { + const input = { + a: 1, + b: 'string', + c: true, + d: { + e: null, + f: undefined, + g: { + h: 2, + }, + }, + }; + const expected = { + 'a': 1, + + 'b': 'string', + + 'c': true, + 'd.e': null, + 'd.f': undefined, + 'd.g.h': 2, + }; + + expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected); + }); + + it('should respect the parentPath parameter', () => { + const input = { + a: 1, + b: { + c: 2, + }, + }; + const parentPath = 'root'; + const expected = { + 'root.a': 1, + 'root.b.c': 2, + }; + + expect(convertSubObjectsIntoPaths(input, parentPath)).to.deep.equal(expected); + }); +}); diff --git a/packages/tools/src/convertSubObjectsIntoPaths.ts b/packages/tools/src/convertSubObjectsIntoPaths.ts new file mode 100644 index 000000000000..8c128aa19f2c --- /dev/null +++ b/packages/tools/src/convertSubObjectsIntoPaths.ts @@ -0,0 +1,16 @@ +export function convertSubObjectsIntoPaths(object: Record, parentPath?: string): Record { + return Object.fromEntries( + Object.keys(object).flatMap((key) => { + const value = object[key]; + const fullKey = parentPath ? `${parentPath}.${key}` : key; + + if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + const flattened = convertSubObjectsIntoPaths(value, fullKey); + + return Object.keys(flattened).map((newKey) => [newKey, flattened[newKey]]); + } + + return [[fullKey, value]]; + }) as [string, any][], + ); +} diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 96faa4d55969..410bd711d24a 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -1,5 +1,7 @@ +export * from './convertSubObjectsIntoPaths'; export * from './getObjectKeys'; export * from './normalizeLanguage'; +export * from './objectMap'; export * from './pick'; export * from './stream'; export * from './timezone'; diff --git a/packages/tools/src/objectMap.spec.ts b/packages/tools/src/objectMap.spec.ts new file mode 100644 index 000000000000..15299b9614a0 --- /dev/null +++ b/packages/tools/src/objectMap.spec.ts @@ -0,0 +1,93 @@ +import { expect } from 'chai'; + +import { objectMap } from './objectMap'; + +describe('objectMap', () => { + it('should map a simple object non-recursively', () => { + const input = { a: 1, b: 2, c: 3 }; + const callback = ({ key, value }) => ({ key: key.toUpperCase(), value: value * 2 }); + const expected = { A: 2, B: 4, C: 6 }; + expect(objectMap(input, callback)).to.deep.equal(expected); + }); + it('should filter out undefined results from callback', () => { + const input = { a: 1, b: 2, c: 3 }; + const callback = ({ key, value }) => (value > 1 ? { key, value } : undefined); + const expected = { b: 2, c: 3 }; + expect(objectMap(input, callback)).to.deep.equal(expected); + }); + it('should map a nested object recursively', () => { + const input = { + a: 1, + b: { + c: 2, + d: { + e: 3, + }, + }, + }; + const callback = ({ key, value }) => ({ key: `mapped_${key}`, value: typeof value === 'number' ? value * 10 : value }); + const expected = { + mapped_a: 10, + mapped_b: { + mapped_c: 20, + mapped_d: { + mapped_e: 30, + }, + }, + }; + expect(objectMap(input, callback, true)).to.deep.equal(expected); + }); + it('should handle an empty object', () => { + const input = {}; + const callback = ({ key, value }) => ({ key: `mapped_${key}`, value }); + const expected = {}; + expect(objectMap(input, callback)).to.deep.equal(expected); + }); + it('should handle mixed value types in non-recursive mode', () => { + const input = { + a: 1, + b: 'string', + c: true, + d: null, + }; + const callback = ({ key, value }) => ({ key: key.toUpperCase(), value: typeof value === 'number' ? value * 2 : value }); + const expected = { + A: 2, + B: 'string', + C: true, + D: null, + }; + expect(objectMap(input, callback)).to.deep.equal(expected); + }); + it('should handle nested objects with mixed types recursively', () => { + const input = { + a: 1, + b: { + c: 'string', + d: { + e: true, + f: null, + }, + }, + }; + const callback = ({ key, value }) => ({ key: key.toUpperCase(), value }); + const expected = { + A: 1, + B: { + C: 'string', + D: { + E: true, + F: null, + }, + }, + }; + expect(objectMap(input, callback, true)).to.deep.equal(expected); + }); + it('should not modify the original object', () => { + const input = { a: 1, b: 2 }; + const original = { ...input }; + const callback = ({ key, value }) => ({ key, value: value * 2 }); + objectMap(input, callback); + expect(input).to.deep.equal(original); + }); +}); diff --git a/packages/tools/src/objectMap.ts b/packages/tools/src/objectMap.ts new file mode 100644 index 000000000000..a28b37ed5048 --- /dev/null +++ b/packages/tools/src/objectMap.ts @@ -0,0 +1,35 @@ +export function objectMap = Record, K extends keyof TObject | string = keyof TObject>( + object: TObject, + cb: (value: { key: K; value: TObject[K] }) => { key: string | number | symbol; value: any } | undefined, + recursive?: false, +): Record; +export function objectMap = Record>( + object: TObject, + cb: (value: { key: string | number | symbol; value: any }) => { key: string | number | symbol; value: any } | undefined, + recursive: true, +): Record; +export function objectMap = Record, K extends keyof TObject | string = keyof TObject>( + object: TObject, + cb: (value: { key: K; value: any }) => { key: string | number | symbol; value: any } | undefined, + recursive: false, +): Record; +export function objectMap = Record, K extends keyof TObject | string = keyof TObject>( + object: TObject, + cb: (value: { key: K | string; value: any }) => { key: string | number | symbol; value: any } | undefined, + recursive = false, +): Record { + return Object.fromEntries( + Object.keys(object) + .map((key) => { + const value = object[key as K]; + if (recursive && value && typeof value === 'object' && !Array.isArray(value) && !((value as any) instanceof Date)) { + const newValue = objectMap(value, cb as any, true); + return cb({ key, value: newValue }); + } + + return cb({ key, value }); + }) + .filter((item) => !!item) + .map((item) => [item.key, item.value]), + ); +}