feat: keep VoIP history (#34004)

Co-authored-by: Diego Sampaio <chinello@gmail.com>
Co-authored-by: Kevin Aleman <kaleman960@gmail.com>
pull/34187/head
Pierre Lehnen 1 year ago committed by GitHub
parent 13932172a5
commit 475120dc19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 9
      .changeset/lemon-stingrays-invite.md
  2. 543
      apps/meteor/ee/server/local-services/voip-freeswitch/service.ts
  3. 14
      apps/meteor/server/lib/videoConfTypes.ts
  4. 6
      apps/meteor/server/models/FreeSwitchCall.ts
  5. 6
      apps/meteor/server/models/FreeSwitchEvent.ts
  6. 28
      apps/meteor/server/models/raw/FreeSwitchCall.ts
  7. 40
      apps/meteor/server/models/raw/FreeSwitchEvent.ts
  8. 9
      apps/meteor/server/models/raw/Users.js
  9. 8
      apps/meteor/server/models/raw/VideoConference.ts
  10. 2
      apps/meteor/server/models/startup.ts
  11. 37
      apps/meteor/server/services/video-conference/service.ts
  12. 3
      packages/core-services/src/types/IVideoConfService.ts
  13. 36
      packages/core-typings/src/IVideoConference.ts
  14. 8
      packages/core-typings/src/utils.ts
  15. 64
      packages/core-typings/src/voip/IFreeSwitchCall.ts
  16. 113
      packages/core-typings/src/voip/IFreeSwitchEvent.ts
  17. 2
      packages/core-typings/src/voip/index.ts
  18. 9
      packages/freeswitch/src/connect.ts
  19. 1
      packages/freeswitch/src/index.ts
  20. 37
      packages/freeswitch/src/listenToEvents.ts
  21. 2
      packages/model-typings/src/index.ts
  22. 9
      packages/model-typings/src/models/IFreeSwitchCallModel.ts
  23. 10
      packages/model-typings/src/models/IFreeSwitchEventModel.ts
  24. 1
      packages/model-typings/src/models/IUsersModel.ts
  25. 5
      packages/model-typings/src/models/IVideoConferenceModel.ts
  26. 4
      packages/models/src/index.ts
  27. 117
      packages/tools/src/convertSubObjectsIntoPaths.spec.ts
  28. 16
      packages/tools/src/convertSubObjectsIntoPaths.ts
  29. 2
      packages/tools/src/index.ts
  30. 93
      packages/tools/src/objectMap.spec.ts
  31. 35
      packages/tools/src/objectMap.ts

@ -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.

@ -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<void> => {
if (setting._id === 'VoIP_TeamCollab_Enabled' && setting.value === true) {
void this.serviceStarter.start();
}
});
}
private listening = false;
public async started(): Promise<void> {
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<string, string | undefined>): Promise<void> {
const uniqueId = data['Unique-ID'];
if (!uniqueId) {
return;
}
// Using a set to avoid duplicates
const callIds = new Set<string>(
[data['Channel-Call-UUID'], data.variable_call_uuid].filter((callId) => Boolean(callId) && callId !== '0') as string[],
);
const event = await this.parseEventData(eventName, data);
// If for some reason the event had different callIds, save a copy of it for each of them
if (callIds.size > 1) {
await Promise.all(
callIds.values().map((callId) =>
this.registerEvent({
...event,
call: {
...event.call,
UUID: callId,
},
}),
),
);
return;
}
await this.registerEvent(event);
}
private getDetailedEventName(eventName: string, eventData: Record<string, string>): string {
if (eventName === 'CHANNEL_STATE') {
return `CHANNEL_STATE=${eventData['Channel-State']}`;
}
if (eventName === 'CHANNEL_CALLSTATE') {
return `CHANNEL_CALLSTATE=${eventData['Channel-Call-State']}`;
}
return eventName;
}
private filterOutMissingData<T extends Record<string, any>>(data: T): DeepPartial<T> {
return objectMap(
data,
({ key, value }) => {
if (!value || value === '0') {
return;
}
if (typeof value === 'object' && !Object.keys(value).length) {
return;
}
return { key, value };
},
true,
) as DeepPartial<T>;
}
private async parseEventData(
eventName: string,
eventData: Record<string, string | undefined>,
): Promise<InsertionModel<WithoutId<IFreeSwitchEvent>>> {
const filteredData: Record<string, string> = Object.fromEntries(
Object.entries(eventData).filter(([_, value]) => value !== undefined),
) as Record<string, string>;
const detaildEventName = this.getDetailedEventName(eventName, filteredData);
const state = eventData['Channel-State'];
const sequence = eventData['Event-Sequence'];
const previousCallState = eventData['Original-Channel-Call-State'];
const callState = eventData['Channel-Call-State'];
const answerState = eventData['Answer-State'];
const hangupCause = eventData['Hangup-Cause'];
const direction = eventData['Call-Direction'];
const channelName = eventData['Channel-Name'];
const otherLegUniqueId = eventData['Other-Leg-Unique-ID'];
const loopbackLegUniqueId = eventData.variable_other_loopback_leg_uuid;
const loopbackFromUniqueId = eventData.variable_other_loopback_from_uuid;
const oldUniqueId = eventData['Old-Unique-ID'];
const channelUniqueId = eventData['Unique-ID'];
const referencedIds = [otherLegUniqueId, loopbackLegUniqueId, loopbackFromUniqueId, oldUniqueId].filter((id) =>
Boolean(id),
) as string[];
const timestamp = eventData['Event-Date-Timestamp'];
const firedAt = this.parseTimestamp(eventData['Event-Date-Timestamp']);
const durationStr = eventData.variable_duration;
const duration = (durationStr && parseInt(durationStr)) || 0;
const call: Partial<IFreeSwitchEventCall> = {
UUID: (eventData['Channel-Call-UUID'] !== '0' && eventData['Channel-Call-UUID']) || eventData.variable_call_uuid,
answerState,
state: callState,
previousState: previousCallState,
presenceId: eventData['Channel-Presence-ID'],
sipId: eventData.variable_sip_call_id,
authorized: eventData.variable_sip_authorized,
hangupCause,
duration,
from: {
user: eventData.variable_sip_from_user,
stripped: eventData.variable_sip_from_user_stripped,
port: eventData.variable_sip_from_port,
uri: eventData.variable_sip_from_uri,
host: eventData.variable_sip_from_host,
full: eventData.variable_sip_full_from,
},
req: {
user: eventData.variable_sip_req_user,
port: eventData.variable_sip_req_port,
uri: eventData.variable_sip_req_uri,
host: eventData.variable_sip_req_host,
},
to: {
user: eventData.variable_sip_to_user,
port: eventData.variable_sip_to_port,
uri: eventData.variable_sip_to_uri,
full: eventData.variable_sip_full_to,
dialedExtension: eventData.variable_dialed_extension,
dialedUser: eventData.variable_dialed_user,
},
contact: {
user: eventData.variable_sip_contact_user,
uri: eventData.variable_sip_contact_uri,
host: eventData.variable_sip_contact_host,
},
via: {
full: eventData.variable_sip_full_via,
host: eventData.variable_sip_via_host,
rport: eventData.variable_sip_via_rport,
},
};
const caller: Partial<IFreeSwitchEventCaller> = {
uniqueId: eventData['Caller-Unique-ID'],
direction: eventData['Caller-Direction'],
username: eventData['Caller-Username'],
networkAddr: eventData['Caller-Network-Addr'],
ani: eventData['Caller-ANI'],
destinationNumber: eventData['Caller-Destination-Number'],
source: eventData['Caller-Source'],
context: eventData['Caller-Context'],
name: eventData['Caller-Caller-ID-Name'],
number: eventData['Caller-Caller-ID-Number'],
originalCaller: {
name: eventData['Caller-Orig-Caller-ID-Name'],
number: eventData['Caller-Orig-Caller-ID-Number'],
},
privacy: {
hideName: eventData['Caller-Privacy-Hide-Name'],
hideNumber: eventData['Caller-Privacy-Hide-Number'],
},
channel: {
name: eventData['Caller-Channel-Name'],
createdTime: eventData['Caller-Channel-Created-Time'],
},
};
return this.filterOutMissingData({
channelUniqueId,
eventName,
detaildEventName,
sequence,
state,
previousCallState,
callState,
timestamp,
firedAt,
answerState,
hangupCause,
referencedIds,
receivedAt: new Date(),
channelName,
direction,
caller,
call,
eventData: filteredData,
}) as InsertionModel<WithoutId<IFreeSwitchEvent>>;
}
private parseTimestamp(timestamp: string | undefined): Date | undefined {
if (!timestamp || timestamp === '0') {
return undefined;
}
const value = parseInt(timestamp);
if (Number.isNaN(value)) {
return undefined;
}
const timeValue = Math.floor(value / 1000);
return new Date(timeValue);
}
private async registerEvent(event: InsertionModel<WithoutId<IFreeSwitchEvent>>): Promise<void> {
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<void> {
const allEvents = await FreeSwitchEvent.findAllByCallUUID(callUUID).toArray();
const call: InsertionModel<IFreeSwitchCall> = {
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<string>();
const toUser = new Set<string>();
let isVoicemailCall = false;
for (const event of sortedEvents) {
if (event.channelUniqueId && !call.channels.includes(event.channelUniqueId)) {
call.channels.push(event.channelUniqueId);
}
const eventType = this.getEventType(event);
fromUser.add(this.identifyCallerFromEvent(event));
toUser.add(this.identifyCalleeFromEvent(event));
// when a call enters the voicemail, we receive one/or many events with the channelName = loopback/voicemail-x
// where X appears to be a letter
isVoicemailCall = event.channelName?.includes('voicemail') || isVoicemailCall;
const hasUsefulCallData = this.isImportantEvent(event);
const callEvent = this.filterOutMissingData({
type: eventType,
caller: event.caller,
...(hasUsefulCallData && { call: event.call }),
otherType: event.eventData['Other-Type'],
otherChannelId: event.eventData['Other-Leg-Unique-ID'],
}) as AtLeast<IFreeSwitchCallEvent, 'type'>;
if (call.events[call.events.length - 1]?.type === eventType) {
const previousEvent = call.events.pop() as IFreeSwitchCallEvent;
call.events.push({
...previousEvent,
...callEvent,
caller: {
...previousEvent.caller,
...callEvent.caller,
},
...((previousEvent.call || callEvent.call) && {
call: {
...previousEvent.call,
...callEvent.call,
from: {
...previousEvent.call?.from,
...callEvent.call?.from,
},
req: {
...previousEvent.call?.req,
...callEvent.call?.req,
},
to: {
...previousEvent.call?.to,
...callEvent.call?.to,
},
contact: {
...previousEvent.call?.contact,
...callEvent.call?.contact,
},
via: {
...previousEvent.call?.via,
...callEvent.call?.via,
},
},
}),
});
continue;
}
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<IFreeSwitchCall>): number {
if (!call.events.length) {
return 0;
}
const channelAnswerEvent = call.events.find((e) => e.eventName === 'CHANNEL_ANSWER');
if (!channelAnswerEvent?.timestamp) {
return 0;
}
const answer = this.parseTimestamp(channelAnswerEvent.timestamp);
if (!answer) {
return 0;
}
const channelHangupEvent = call.events.find((e) => e.eventName === 'CHANNEL_HANGUP_COMPLETE');
if (!channelHangupEvent?.timestamp) {
// We dont have a hangup but we have an answer, assume hangup is === destroy time
return new Date().getTime() - answer.getTime();
}
const hangup = this.parseTimestamp(channelHangupEvent.timestamp);
if (!hangup) {
return 0;
}
return hangup.getTime() - answer.getTime();
}
async getDomain(): Promise<string> {
const options = this.getConnectionSettings();
return getDomain(options);

@ -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<IRoom, '_id' | 't'>;
type VideoConferenceTypeCondition = (room: RoomRequiredFields, allowRinging: boolean) => Promise<boolean>;
@ -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');

@ -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));

@ -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));

@ -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<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 } }];
}
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,
);
}
}

@ -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<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,
);
}
}

@ -2485,6 +2485,15 @@ export class UsersRaw extends BaseRaw {
);
}
findOneByFreeSwitchExtensions(freeSwitchExtensions, options = {}) {
return this.findOne(
{
freeSwitchExtension: { $in: freeSwitchExtensions },
},
options,
);
}
findAssignedFreeSwitchExtensions() {
return this.findUsersWithAssignedFreeSwitchExtensions({
projection: {

@ -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<VideoConference> implements IVid
return (await this.insertOne(call)).insertedId;
}
public async createVoIP(call: InsertionModel<IVoIPVideoConference>): Promise<string | undefined> {
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<VideoConference> | Partial<VideoConference>,

@ -15,6 +15,8 @@ import './EmojiCustom';
import './ExportOperations';
import './FederationKeys';
import './FederationServers';
import './FreeSwitchCall';
import './FreeSwitchEvent';
import './ImportData';
import './InstanceStatus';
import './IntegrationHistory';

@ -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<string> {
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<IVoIPVideoConference>): Promise<IVoIPVideoConference['_id'] | undefined> {
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<IUser, '_id' | 'username' | 'name' | 'avatarETag'> | undefined,
options: VideoConferenceJoinOptions,
): Promise<string> {
@ -885,7 +902,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
return room?.fname || room?.name || rid;
}
private async generateNewUrl(call: VideoConference): Promise<string> {
private async generateNewUrl(call: ExternalVideoConference): Promise<string> {
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<IUser, '_id' | 'username' | 'name'>,
options: VideoConferenceJoinOptions = {},
): Promise<string> {
@ -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');
}

@ -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<boolean>;
assignDiscussionToConference(callId: VideoConference['_id'], rid: IRoom['_id'] | undefined): Promise<void>;
createVoIP(data: InsertionModel<IVoIPVideoConference>): Promise<IVoIPVideoConference['_id'] | undefined>;
}

@ -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<Required<IUser>, '_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<IGroupVideoConference, 'createdBy'> & { createdBy: IUser['_id'] };
type DirectVideoConferenceCreateData = Omit<IDirectVideoConference, 'createdBy'> & { createdBy: IUser['_id'] };
type LivechatVideoConferenceCreateData = Omit<ILivechatVideoConference, 'createdBy'> & { createdBy: IUser['_id'] };
type VoIPVideoConferenceCreateData = Omit<IVoIPVideoConference, 'createdBy'> & { createdBy: IUser['_id'] };
export type VideoConferenceCreateData = AtLeast<
DirectVideoConferenceCreateData | GroupVideoConferenceCreateData | LivechatVideoConferenceCreateData,
DirectVideoConferenceCreateData | GroupVideoConferenceCreateData | LivechatVideoConferenceCreateData | VoIPVideoConferenceCreateData,
'createdBy' | 'type' | 'rid' | 'providerName' | 'providerData'
>;

@ -40,5 +40,11 @@ export type ValueOfUnion<T, K extends KeyOfEach<T>> = T extends any ? (K extends
export type ValueOfOptional<T, K extends KeyOfEach<T>> = T extends undefined ? undefined : T extends object ? ValueOfUnion<T, K> : null;
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[] ? DeepPartial<U>[] : T[P] extends object | undefined ? DeepPartial<T[P]> : T[P];
[P in keyof T]?: T[P] extends (infer U)[] | undefined
? DeepPartial<U>[]
: T[P] extends Date | undefined
? T[P]
: T[P] extends object | undefined
? DeepPartial<T[P]>
: T[P];
};

@ -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<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;
}
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,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<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;
};
}

@ -17,3 +17,5 @@ export * from './IVoipClientEvents';
export * from './VoIPUserConfiguration';
export * from './VoIpCallerInfo';
export * from './ICallDetails';
export * from './IFreeSwitchCall';
export * from './IFreeSwitchEvent';

@ -6,7 +6,12 @@ import { logger } from './logger';
const defaultPassword = 'ClueCon';
export async function connect(options?: { host?: string; port?: number; password?: string }): Promise<FreeSwitchResponse> {
export type EventNames = Parameters<FreeSwitchResponse['event_json']>;
export async function connect(
options?: { host?: string; port?: number; password?: string },
customEventNames: EventNames = [],
): Promise<FreeSwitchResponse> {
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);

@ -1 +1,2 @@
export * from './commands';
export * from './listenToEvents';

@ -0,0 +1,37 @@
import type { FreeSwitchResponse } from 'esl';
import { connect, type EventNames } from './connect';
export async function listenToEvents(
callback: (eventName: string, data: Record<string, string | undefined>) => Promise<void>,
options?: { host?: string; port?: number; password?: string },
): Promise<FreeSwitchResponse> {
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;
}

@ -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';

@ -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<IFreeSwitchCall> {
registerCall(call: WithoutId<InsertionModel<IFreeSwitchCall>>): Promise<void>;
findAllByChannelUniqueIds<T extends IFreeSwitchCall>(uniqueIds: string[], options?: FindOptions<IFreeSwitchCall>): FindCursor<T>;
}

@ -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<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>;
}

@ -405,6 +405,7 @@ export interface IUsersModel extends IBaseModel<IUser> {
findAgentsAvailableWithoutBusinessHours(userIds: string[] | null): FindCursor<Pick<ILivechatAgent, '_id' | 'openBusinessHours'>>;
updateLivechatStatusByAgentIds(userIds: string[], status: ILivechatAgentStatus): Promise<UpdateResult>;
findOneByFreeSwitchExtension<T = IUser>(extension: string, options?: FindOptions<IUser>): Promise<T | null>;
findOneByFreeSwitchExtensions<T = IUser>(extensions: string[], options?: FindOptions<IUser>): Promise<T | null>;
setFreeSwitchExtension(userId: string, extension: string | undefined): Promise<UpdateResult>;
findAssignedFreeSwitchExtensions(): FindCursor<string>;
findUsersWithAssignedFreeSwitchExtensions<T = IUser>(options?: FindOptions<IUser>): FindCursor<T>;

@ -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<VideoConference> {
findPaginatedByRoomId(
@ -67,4 +68,6 @@ export interface IVideoConferenceModel extends IBaseModel<VideoConference> {
setDiscussionRidById(callId: string, discussionRid: IRoom['_id']): Promise<void>;
unsetDiscussionRid(discussionRid: IRoom['_id']): Promise<void>;
createVoIP(call: InsertionModel<IVoIPVideoConference>): Promise<string | undefined>;
}

@ -13,6 +13,8 @@ import type {
IExportOperationsModel,
IFederationKeysModel,
IFederationServersModel,
IFreeSwitchCallModel,
IFreeSwitchEventModel,
IInstanceStatusModel,
IIntegrationHistoryModel,
IIntegrationsModel,
@ -111,6 +113,8 @@ export const ExportOperations = proxify<IExportOperationsModel>('IExportOperatio
export const FederationServers = proxify<IFederationServersModel>('IFederationServersModel');
export const FederationKeys = proxify<IFederationKeysModel>('IFederationKeysModel');
export const FederationRoomEvents = proxify<IFederationRoomEventsModel>('IFederationRoomEventsModel');
export const FreeSwitchCall = proxify<IFreeSwitchCallModel>('IFreeSwitchCallModel');
export const FreeSwitchEvent = proxify<IFreeSwitchEventModel>('IFreeSwitchEventModel');
export const ImportData = proxify<IImportDataModel>('IImportDataModel');
export const Imports = proxify<IImportsModel>('IImportsModel');
export const InstanceStatus = proxify<IInstanceStatusModel>('IInstanceStatusModel');

@ -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);
});
});

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

@ -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';

@ -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);
});
});

@ -0,0 +1,35 @@
export function objectMap<TObject extends Record<string, any> = Record<string, any>, 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<string, any>;
export function objectMap<TObject extends Record<string, any> = Record<string, any>>(
object: TObject,
cb: (value: { key: string | number | symbol; value: any }) => { key: string | number | symbol; value: any } | undefined,
recursive: true,
): Record<string, any>;
export function objectMap<TObject extends Record<string, any> = Record<string, any>, 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<string, any>;
export function objectMap<TObject extends Record<string, any> = Record<string, any>, 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<string, any> {
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]),
);
}
Loading…
Cancel
Save