From e7c3afa407420366737934f392791e03a55fc7df Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:16:16 -0300 Subject: [PATCH 01/10] feat: VoIP Backend for FreeSwitch (#33093) * feat: VoIP Backend for FreeSwitch --------- Co-authored-by: Aleksander Nicacio da Silva --- apps/meteor/app/api/server/lib/users.ts | 1 + .../ee/app/api-enterprise/server/index.ts | 4 + .../api-enterprise/server/voip-freeswitch.ts | 150 ++++++++++++ apps/meteor/ee/server/configuration/index.ts | 1 + apps/meteor/ee/server/configuration/voip.ts | 10 + .../local-services/voip-freeswitch/service.ts | 53 +++++ apps/meteor/ee/server/settings/voip.ts | 49 ++++ apps/meteor/ee/server/startup/services.ts | 3 + apps/meteor/package.json | 2 + apps/meteor/server/models/raw/Users.js | 43 +++- .../tests/unit/server/lib/freeswitch.tests.ts | 40 ++++ packages/core-services/src/index.ts | 3 + .../src/types/IVoipFreeSwitchService.ts | 8 + packages/core-typings/src/IUser.ts | 1 + .../src/voip/FreeSwitchExtension.ts | 11 + packages/core-typings/src/voip/index.ts | 1 + packages/freeswitch/.eslintrc.json | 4 + packages/freeswitch/jest.config.ts | 6 + packages/freeswitch/package.json | 30 +++ packages/freeswitch/src/FreeSwitchOptions.ts | 1 + packages/freeswitch/src/commands/getDomain.ts | 25 ++ .../src/commands/getExtensionDetails.ts | 30 +++ .../src/commands/getExtensionList.ts | 17 ++ .../src/commands/getUserPassword.ts | 31 +++ packages/freeswitch/src/commands/index.ts | 4 + packages/freeswitch/src/connect.ts | 82 +++++++ packages/freeswitch/src/getCommandResponse.ts | 12 + packages/freeswitch/src/index.ts | 1 + packages/freeswitch/src/logger.ts | 3 + packages/freeswitch/src/runCommand.ts | 30 +++ packages/freeswitch/src/utils/mapUserData.ts | 33 +++ .../freeswitch/src/utils/parseUserList.ts | 54 +++++ .../freeswitch/src/utils/parseUserStatus.ts | 17 ++ packages/freeswitch/tests/mapUserData.test.ts | 37 +++ .../freeswitch/tests/parseUserList.test.ts | 214 ++++++++++++++++++ .../freeswitch/tests/parseUserStatus.test.ts | 14 ++ .../tests/utils/makeFreeSwitchResponse.ts | 5 + packages/freeswitch/tsconfig.json | 9 + packages/i18n/src/locales/en.i18n.json | 7 + .../model-typings/src/models/IUsersModel.ts | 4 + packages/rest-typings/src/index.ts | 3 + packages/rest-typings/src/v1/users.ts | 13 +- .../VoipFreeSwitchExtensionAssignProps.ts | 24 ++ .../VoipFreeSwitchExtensionGetDetailsProps.ts | 27 +++ .../VoipFreeSwitchExtensionGetInfoProps.ts | 22 ++ .../VoipFreeSwitchExtensionListProps.ts | 28 +++ .../src/v1/voip-freeswitch/index.ts | 26 +++ yarn.lock | 63 ++++++ 48 files changed, 1254 insertions(+), 2 deletions(-) create mode 100644 apps/meteor/ee/app/api-enterprise/server/voip-freeswitch.ts create mode 100644 apps/meteor/ee/server/configuration/voip.ts create mode 100644 apps/meteor/ee/server/local-services/voip-freeswitch/service.ts create mode 100644 apps/meteor/ee/server/settings/voip.ts create mode 100644 apps/meteor/tests/unit/server/lib/freeswitch.tests.ts create mode 100644 packages/core-services/src/types/IVoipFreeSwitchService.ts create mode 100644 packages/core-typings/src/voip/FreeSwitchExtension.ts create mode 100644 packages/freeswitch/.eslintrc.json create mode 100644 packages/freeswitch/jest.config.ts create mode 100644 packages/freeswitch/package.json create mode 100644 packages/freeswitch/src/FreeSwitchOptions.ts create mode 100644 packages/freeswitch/src/commands/getDomain.ts create mode 100644 packages/freeswitch/src/commands/getExtensionDetails.ts create mode 100644 packages/freeswitch/src/commands/getExtensionList.ts create mode 100644 packages/freeswitch/src/commands/getUserPassword.ts create mode 100644 packages/freeswitch/src/commands/index.ts create mode 100644 packages/freeswitch/src/connect.ts create mode 100644 packages/freeswitch/src/getCommandResponse.ts create mode 100644 packages/freeswitch/src/index.ts create mode 100644 packages/freeswitch/src/logger.ts create mode 100644 packages/freeswitch/src/runCommand.ts create mode 100644 packages/freeswitch/src/utils/mapUserData.ts create mode 100644 packages/freeswitch/src/utils/parseUserList.ts create mode 100644 packages/freeswitch/src/utils/parseUserStatus.ts create mode 100644 packages/freeswitch/tests/mapUserData.test.ts create mode 100644 packages/freeswitch/tests/parseUserList.test.ts create mode 100644 packages/freeswitch/tests/parseUserStatus.test.ts create mode 100644 packages/freeswitch/tests/utils/makeFreeSwitchResponse.ts create mode 100644 packages/freeswitch/tsconfig.json create mode 100644 packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionAssignProps.ts create mode 100644 packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetDetailsProps.ts create mode 100644 packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetInfoProps.ts create mode 100644 packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionListProps.ts create mode 100644 packages/rest-typings/src/v1/voip-freeswitch/index.ts diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index f8e3d528f163..1d7371c38659 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -155,6 +155,7 @@ export async function findPaginatedUsersByStatus({ type: 1, reason: 1, federated: 1, + freeSwitchExtension: 1, }; const actualSort: Record = sort || { username: 1 }; diff --git a/apps/meteor/ee/app/api-enterprise/server/index.ts b/apps/meteor/ee/app/api-enterprise/server/index.ts index 7a528a4ec2f4..1c48d592d33e 100644 --- a/apps/meteor/ee/app/api-enterprise/server/index.ts +++ b/apps/meteor/ee/app/api-enterprise/server/index.ts @@ -3,3 +3,7 @@ import { License } from '@rocket.chat/license'; await License.onLicense('canned-responses', async () => { await import('./canned-responses'); }); + +await License.onLicense('voip-enterprise', async () => { + await import('./voip-freeswitch'); +}); diff --git a/apps/meteor/ee/app/api-enterprise/server/voip-freeswitch.ts b/apps/meteor/ee/app/api-enterprise/server/voip-freeswitch.ts new file mode 100644 index 000000000000..8094c31981f7 --- /dev/null +++ b/apps/meteor/ee/app/api-enterprise/server/voip-freeswitch.ts @@ -0,0 +1,150 @@ +import { VoipFreeSwitch } from '@rocket.chat/core-services'; +import { Users } from '@rocket.chat/models'; +import { + isVoipFreeSwitchExtensionAssignProps, + isVoipFreeSwitchExtensionGetDetailsProps, + isVoipFreeSwitchExtensionGetInfoProps, + isVoipFreeSwitchExtensionListProps, +} from '@rocket.chat/rest-typings'; +import { wrapExceptions } from '@rocket.chat/tools'; + +import { API } from '../../../../app/api/server'; +import { settings } from '../../../../app/settings/server/cached'; + +API.v1.addRoute( + 'voip-freeswitch.extension.list', + { authRequired: true, permissionsRequired: ['manage-voip-call-settings'], validateParams: isVoipFreeSwitchExtensionListProps }, + { + async get() { + const { username, type = 'all' } = this.queryParams; + + const extensions = await wrapExceptions(() => VoipFreeSwitch.getExtensionList()).catch(() => { + throw new Error('Failed to load extension list.'); + }); + + if (type === 'all') { + return API.v1.success({ extensions }); + } + + const assignedExtensions = await Users.findAssignedFreeSwitchExtensions().toArray(); + + switch (type) { + case 'free': + const freeExtensions = extensions.filter(({ extension }) => !assignedExtensions.includes(extension)); + return API.v1.success({ extensions: freeExtensions }); + case 'allocated': + // Extensions that are already assigned to some user + const allocatedExtensions = extensions.filter(({ extension }) => assignedExtensions.includes(extension)); + return API.v1.success({ extensions: allocatedExtensions }); + case 'available': + // Extensions that are free or assigned to the specified user + const user = (username && (await Users.findOneByUsername(username, { projection: { freeSwitchExtension: 1 } }))) || undefined; + const currentExtension = user?.freeSwitchExtension; + + const availableExtensions = extensions.filter( + ({ extension }) => extension === currentExtension || !assignedExtensions.includes(extension), + ); + + return API.v1.success({ extensions: availableExtensions }); + } + + return API.v1.success({ extensions }); + }, + }, +); + +API.v1.addRoute( + 'voip-freeswitch.extension.assign', + { authRequired: true, permissionsRequired: ['manage-voip-call-settings'], validateParams: isVoipFreeSwitchExtensionAssignProps }, + { + async post() { + const { extension, username } = this.bodyParams; + + if (!username) { + return API.v1.notFound(); + } + + const user = await Users.findOneByUsername(username, { projection: { freeSwitchExtension: 1 } }); + if (!user) { + return API.v1.notFound(); + } + + const existingUser = extension && (await Users.findOneByFreeSwitchExtension(extension, { projection: { _id: 1 } })); + if (existingUser && existingUser._id !== user._id) { + throw new Error('Extension not available.'); + } + + if (extension && user.freeSwitchExtension === extension) { + return API.v1.success(); + } + + await Users.setFreeSwitchExtension(user._id, extension); + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'voip-freeswitch.extension.getDetails', + { authRequired: true, permissionsRequired: ['manage-voip-call-settings'], validateParams: isVoipFreeSwitchExtensionGetDetailsProps }, + { + async get() { + const { extension, group } = this.queryParams; + + if (!extension) { + throw new Error('Invalid params'); + } + + const extensionData = await wrapExceptions(() => VoipFreeSwitch.getExtensionDetails({ extension, group })).suppress(() => undefined); + if (!extensionData) { + return API.v1.notFound(); + } + + const existingUser = await Users.findOneByFreeSwitchExtension(extensionData.extension, { projection: { username: 1 } }); + + return API.v1.success({ + ...extensionData, + ...(existingUser && { userId: existingUser._id, username: existingUser.username }), + }); + }, + }, +); + +API.v1.addRoute( + 'voip-freeswitch.extension.getRegistrationInfoByUserId', + { authRequired: true, permissionsRequired: ['manage-voip-call-settings'], validateParams: isVoipFreeSwitchExtensionGetInfoProps }, + { + async get() { + const { userId } = this.queryParams; + + if (!userId) { + throw new Error('Invalid params.'); + } + + const user = await Users.findOneById(userId, { projection: { freeSwitchExtension: 1 } }); + if (!user) { + throw new Error('User not found.'); + } + + const { freeSwitchExtension: extension } = user; + + if (!extension) { + throw new Error('Extension not assigned.'); + } + + const extensionData = await wrapExceptions(() => VoipFreeSwitch.getExtensionDetails({ extension })).suppress(() => undefined); + if (!extensionData) { + return API.v1.notFound(); + } + const password = await wrapExceptions(() => VoipFreeSwitch.getUserPassword(extension)).suppress(() => undefined); + + return API.v1.success({ + extension: extensionData, + credentials: { + websocketPath: settings.get('VoIP_TeamCollab_FreeSwitch_WebSocket_Path'), + password, + }, + }); + }, + }, +); diff --git a/apps/meteor/ee/server/configuration/index.ts b/apps/meteor/ee/server/configuration/index.ts index 9a7738b23e4a..09160ef39a26 100644 --- a/apps/meteor/ee/server/configuration/index.ts +++ b/apps/meteor/ee/server/configuration/index.ts @@ -3,3 +3,4 @@ import './oauth'; import './outlookCalendar'; import './saml'; import './videoConference'; +import './voip'; diff --git a/apps/meteor/ee/server/configuration/voip.ts b/apps/meteor/ee/server/configuration/voip.ts new file mode 100644 index 000000000000..b265ca900cdb --- /dev/null +++ b/apps/meteor/ee/server/configuration/voip.ts @@ -0,0 +1,10 @@ +import { License } from '@rocket.chat/license'; +import { Meteor } from 'meteor/meteor'; + +import { addSettings } from '../settings/voip'; + +Meteor.startup(async () => { + await License.onLicense('voip-enterprise', async () => { + await addSettings(); + }); +}); diff --git a/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts b/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts new file mode 100644 index 000000000000..8bc0f7c9b4fe --- /dev/null +++ b/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts @@ -0,0 +1,53 @@ +import { type IVoipFreeSwitchService, ServiceClassInternal } from '@rocket.chat/core-services'; +import type { FreeSwitchExtension, ISetting, SettingValue } from '@rocket.chat/core-typings'; +import { getDomain, getUserPassword, getExtensionList, getExtensionDetails } from '@rocket.chat/freeswitch'; + +export class VoipFreeSwitchService extends ServiceClassInternal implements IVoipFreeSwitchService { + protected name = 'voip-freeswitch'; + + constructor(private getSetting: (id: ISetting['_id']) => T) { + super(); + } + + private getConnectionSettings(): { host: string; port: number; password: string; timeout: number } { + if (!this.getSetting('VoIP_TeamCollab_Enabled') && !process.env.FREESWITCHIP) { + throw new Error('VoIP is disabled.'); + } + + const host = process.env.FREESWITCHIP || this.getSetting('VoIP_TeamCollab_FreeSwitch_Host'); + if (!host) { + throw new Error('VoIP is not properly configured.'); + } + + const port = this.getSetting('VoIP_TeamCollab_FreeSwitch_Port') || 8021; + const timeout = this.getSetting('VoIP_TeamCollab_FreeSwitch_Timeout') || 3000; + const password = this.getSetting('VoIP_TeamCollab_FreeSwitch_Password'); + + return { + host, + port, + password, + timeout, + }; + } + + async getDomain(): Promise { + const options = this.getConnectionSettings(); + return getDomain(options); + } + + async getUserPassword(user: string): Promise { + const options = this.getConnectionSettings(); + return getUserPassword(options, user); + } + + async getExtensionList(): Promise { + const options = this.getConnectionSettings(); + return getExtensionList(options); + } + + async getExtensionDetails(requestParams: { extension: string; group?: string }): Promise { + const options = this.getConnectionSettings(); + return getExtensionDetails(options, requestParams); + } +} diff --git a/apps/meteor/ee/server/settings/voip.ts b/apps/meteor/ee/server/settings/voip.ts new file mode 100644 index 000000000000..90d951ead3f4 --- /dev/null +++ b/apps/meteor/ee/server/settings/voip.ts @@ -0,0 +1,49 @@ +import { settingsRegistry } from '../../../app/settings/server'; + +export function addSettings(): Promise { + return settingsRegistry.addGroup('VoIP_TeamCollab', async function () { + await this.with( + { + enterprise: true, + modules: ['voip-enterprise'], + }, + async function () { + await this.add('VoIP_TeamCollab_Enabled', false, { + type: 'boolean', + public: true, + invalidValue: false, + }); + + await this.add('VoIP_TeamCollab_FreeSwitch_Host', '', { + type: 'string', + public: true, + invalidValue: '', + }); + + await this.add('VoIP_TeamCollab_FreeSwitch_Port', 8021, { + type: 'int', + public: true, + invalidValue: 8021, + }); + + await this.add('VoIP_TeamCollab_FreeSwitch_Password', '', { + type: 'password', + public: true, + invalidValue: '', + }); + + await this.add('VoIP_TeamCollab_FreeSwitch_Timeout', 3000, { + type: 'int', + public: true, + invalidValue: 3000, + }); + + await this.add('VoIP_TeamCollab_FreeSwitch_WebSocket_Path', '', { + type: 'string', + public: true, + invalidValue: '', + }); + }, + ); + }); +} diff --git a/apps/meteor/ee/server/startup/services.ts b/apps/meteor/ee/server/startup/services.ts index 2f63ddba42a8..dc072fb1d9b0 100644 --- a/apps/meteor/ee/server/startup/services.ts +++ b/apps/meteor/ee/server/startup/services.ts @@ -1,6 +1,7 @@ import { api } from '@rocket.chat/core-services'; import { License } from '@rocket.chat/license'; +import { settings } from '../../../app/settings/server/cached'; import { isRunningMs } from '../../../server/lib/isRunningMs'; import { FederationService } from '../../../server/services/federation/service'; import { LicenseService } from '../../app/license/server/license.internalService'; @@ -10,6 +11,7 @@ import { FederationServiceEE } from '../local-services/federation/service'; import { InstanceService } from '../local-services/instance/service'; import { LDAPEEService } from '../local-services/ldap/service'; import { MessageReadsService } from '../local-services/message-reads/service'; +import { VoipFreeSwitchService } from '../local-services/voip-freeswitch/service'; // TODO consider registering these services only after a valid license is added api.registerService(new EnterpriseSettings()); @@ -17,6 +19,7 @@ api.registerService(new LDAPEEService()); api.registerService(new LicenseService()); api.registerService(new MessageReadsService()); api.registerService(new OmnichannelEE()); +api.registerService(new VoipFreeSwitchService((id) => settings.get(id))); // when not running micro services we want to start up the instance intercom if (!isRunningMs()) { diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 43b62def0819..721814b8f14f 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -241,6 +241,7 @@ "@rocket.chat/favicon": "workspace:^", "@rocket.chat/forked-matrix-appservice-bridge": "^4.0.2", "@rocket.chat/forked-matrix-bot-sdk": "^0.6.0-beta.3", + "@rocket.chat/freeswitch": "workspace:^", "@rocket.chat/fuselage": "^0.59.0", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/fuselage-polyfills": "~0.31.25", @@ -331,6 +332,7 @@ "emailreplyparser": "^0.0.5", "emoji-toolkit": "^7.0.1", "emojione": "^4.5.0", + "esl": "github:pierre-lehnen-rc/esl", "eventemitter3": "^4.0.7", "exif-be-gone": "^1.3.2", "express": "^4.17.3", diff --git a/apps/meteor/server/models/raw/Users.js b/apps/meteor/server/models/raw/Users.js index 0b78f0f9e454..ae4673d5c7fe 100644 --- a/apps/meteor/server/models/raw/Users.js +++ b/apps/meteor/server/models/raw/Users.js @@ -713,8 +713,10 @@ export class UsersRaw extends BaseRaw { $nin: exceptions, }, }, + { + ...conditions, + }, ], - ...conditions, }; return this.find(query, options); @@ -2432,6 +2434,34 @@ export class UsersRaw extends BaseRaw { }); } + findOneByFreeSwitchExtension(freeSwitchExtension, options = {}) { + return this.findOne( + { + freeSwitchExtension, + }, + options, + ); + } + + findAssignedFreeSwitchExtensions() { + return this.findUsersWithAssignedFreeSwitchExtensions({ + projection: { + freeSwitchExtension: 1, + }, + }).map(({ freeSwitchExtension }) => freeSwitchExtension); + } + + findUsersWithAssignedFreeSwitchExtensions(options = {}) { + return this.find( + { + freeSwitchExtension: { + $exists: 1, + }, + }, + options, + ); + } + // UPDATE addImportIds(_id, importIds) { importIds = [].concat(importIds); @@ -2905,6 +2935,17 @@ export class UsersRaw extends BaseRaw { ); } + async setFreeSwitchExtension(_id, extension) { + return this.updateOne( + { + _id, + }, + { + ...(extension ? { $set: { freeSwitchExtension: extension } } : { $unset: { freeSwitchExtension: 1 } }), + }, + ); + } + // INSERT create(data) { const user = { diff --git a/apps/meteor/tests/unit/server/lib/freeswitch.tests.ts b/apps/meteor/tests/unit/server/lib/freeswitch.tests.ts new file mode 100644 index 000000000000..bb020995c7f8 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/freeswitch.tests.ts @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import { describe } from 'mocha'; + +import { settings } from '../../../../app/settings/server/cached'; +import { VoipFreeSwitchService } from '../../../../ee/server/local-services/voip-freeswitch/service'; + +const VoipFreeSwitch = new VoipFreeSwitchService((id) => settings.get(id)); + +// Those tests still need a proper freeswitch environment configured in order to run +// So for now they are being deliberately skipped on CI +describe.skip('VoIP', () => { + describe('FreeSwitch', () => { + it('should get a list of users from FreeSwitch', async () => { + const result = await VoipFreeSwitch.getExtensionList(); + + expect(result).to.be.an('array'); + expect(result[0]).to.be.an('object'); + expect(result[0].extension).to.be.a('string'); + }); + + it('should get a specific user from FreeSwitch', async () => { + const result = await VoipFreeSwitch.getExtensionDetails({ extension: '1001' }); + + expect(result).to.be.an('object'); + expect(result.extension).to.be.equal('1001'); + }); + + it('Should load user domain from FreeSwitch', async () => { + const result = await VoipFreeSwitch.getDomain(); + + expect(result).to.be.a('string').equal('rocket.chat'); + }); + + it('Should load user password from FreeSwitch', async () => { + const result = await VoipFreeSwitch.getUserPassword('1000'); + + expect(result).to.be.a('string'); + }); + }); +}); diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index 8eea19ea7405..6d1c96c71aad 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -47,6 +47,7 @@ import type { UiKitCoreAppPayload, IUiKitCoreApp, IUiKitCoreAppService } from '. import type { ISendFileLivechatMessageParams, ISendFileMessageParams, IUploadFileParams, IUploadService } from './types/IUploadService'; import type { IUserService } from './types/IUserService'; import type { IVideoConfService, VideoConferenceJoinOptions } from './types/IVideoConfService'; +import type { IVoipFreeSwitchService } from './types/IVoipFreeSwitchService'; import type { IVoipService } from './types/IVoipService'; export { asyncLocalStorage } from './lib/asyncLocalStorage'; @@ -117,6 +118,7 @@ export { IUiKitCoreAppService, IVideoConfService, IVoipService, + IVoipFreeSwitchService, NPSCreatePayload, NPSVotePayload, proxifyWithWait, @@ -164,6 +166,7 @@ export const MessageReads = proxifyWithWait('message-reads export const Room = proxifyWithWait('room'); export const Media = proxifyWithWait('media'); export const VoipAsterisk = proxifyWithWait('voip-asterisk'); +export const VoipFreeSwitch = proxifyWithWait('voip-freeswitch'); export const LivechatVoip = proxifyWithWait('omnichannel-voip'); export const Analytics = proxifyWithWait('analytics'); export const LDAP = proxifyWithWait('ldap'); diff --git a/packages/core-services/src/types/IVoipFreeSwitchService.ts b/packages/core-services/src/types/IVoipFreeSwitchService.ts new file mode 100644 index 000000000000..575cdb157969 --- /dev/null +++ b/packages/core-services/src/types/IVoipFreeSwitchService.ts @@ -0,0 +1,8 @@ +import type { FreeSwitchExtension } from '@rocket.chat/core-typings'; + +export interface IVoipFreeSwitchService { + getExtensionList(): Promise; + getExtensionDetails(requestParams: { extension: string; group?: string }): Promise; + getUserPassword(user: string): Promise; + getDomain(): Promise; +} diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index d6854bef7243..76d31704bc6e 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -189,6 +189,7 @@ export interface IUser extends IRocketChatRecord { defaultRoom?: string; ldap?: boolean; extension?: string; + freeSwitchExtension?: string; inviteToken?: string; canViewAllInfo?: boolean; phone?: string; diff --git a/packages/core-typings/src/voip/FreeSwitchExtension.ts b/packages/core-typings/src/voip/FreeSwitchExtension.ts new file mode 100644 index 000000000000..66e40f57fb66 --- /dev/null +++ b/packages/core-typings/src/voip/FreeSwitchExtension.ts @@ -0,0 +1,11 @@ +export type FreeSwitchExtension = { + extension: string; + context?: string; + domain?: string; + groups: string[]; + status: 'UNKNOWN' | 'REGISTERED' | 'UNREGISTERED'; + contact?: string; + callGroup?: string; + callerName?: string; + callerNumber?: string; +}; diff --git a/packages/core-typings/src/voip/index.ts b/packages/core-typings/src/voip/index.ts index 49aa0d367a8e..0a83a01d70bc 100644 --- a/packages/core-typings/src/voip/index.ts +++ b/packages/core-typings/src/voip/index.ts @@ -1,5 +1,6 @@ export * from './CallStates'; export * from './ConnectionState'; +export * from './FreeSwitchExtension'; export * from './ICallerInfo'; export * from './IConnectionDelegate'; export * from './IEvents'; diff --git a/packages/freeswitch/.eslintrc.json b/packages/freeswitch/.eslintrc.json new file mode 100644 index 000000000000..a83aeda48e66 --- /dev/null +++ b/packages/freeswitch/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": ["@rocket.chat/eslint-config"], + "ignorePatterns": ["**/dist"] +} diff --git a/packages/freeswitch/jest.config.ts b/packages/freeswitch/jest.config.ts new file mode 100644 index 000000000000..c18c8ae02465 --- /dev/null +++ b/packages/freeswitch/jest.config.ts @@ -0,0 +1,6 @@ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { + preset: server.preset, +} satisfies Config; diff --git a/packages/freeswitch/package.json b/packages/freeswitch/package.json new file mode 100644 index 000000000000..aed92472c4ba --- /dev/null +++ b/packages/freeswitch/package.json @@ -0,0 +1,30 @@ +{ + "name": "@rocket.chat/freeswitch", + "version": "0.0.1", + "private": true, + "devDependencies": { + "@rocket.chat/jest-presets": "workspace:~", + "@types/jest": "~29.5.12", + "eslint": "~8.45.0", + "jest": "~29.7.0", + "typescript": "~5.3.3" + }, + "scripts": { + "lint": "eslint --ext .js,.jsx,.ts,.tsx .", + "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", + "test": "jest", + "build": "rm -rf dist && tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" + }, + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "/dist" + ], + "dependencies": { + "@rocket.chat/core-typings": "workspace:^", + "@rocket.chat/logger": "workspace:^", + "@rocket.chat/tools": "workspace:^", + "esl": "github:pierre-lehnen-rc/esl" + } +} diff --git a/packages/freeswitch/src/FreeSwitchOptions.ts b/packages/freeswitch/src/FreeSwitchOptions.ts new file mode 100644 index 000000000000..20b68f61f0ed --- /dev/null +++ b/packages/freeswitch/src/FreeSwitchOptions.ts @@ -0,0 +1 @@ +export type FreeSwitchOptions = { host?: string; port?: number; password?: string; timeout?: number }; diff --git a/packages/freeswitch/src/commands/getDomain.ts b/packages/freeswitch/src/commands/getDomain.ts new file mode 100644 index 000000000000..a1ad0f29f38d --- /dev/null +++ b/packages/freeswitch/src/commands/getDomain.ts @@ -0,0 +1,25 @@ +import type { StringMap } from 'esl'; + +import type { FreeSwitchOptions } from '../FreeSwitchOptions'; +import { logger } from '../logger'; +import { runCommand } from '../runCommand'; + +export function getCommandGetDomain(): string { + return 'eval ${domain}'; +} + +export function parseDomainResponse(response: StringMap): string { + const { _body: domain } = response; + + if (typeof domain !== 'string') { + logger.error({ msg: 'Failed to load user domain', response }); + throw new Error('Failed to load user domain from FreeSwitch.'); + } + + return domain; +} + +export async function getDomain(options: FreeSwitchOptions): Promise { + const response = await runCommand(options, getCommandGetDomain()); + return parseDomainResponse(response); +} diff --git a/packages/freeswitch/src/commands/getExtensionDetails.ts b/packages/freeswitch/src/commands/getExtensionDetails.ts new file mode 100644 index 000000000000..4df2bf64a8ee --- /dev/null +++ b/packages/freeswitch/src/commands/getExtensionDetails.ts @@ -0,0 +1,30 @@ +import type { FreeSwitchExtension } from '@rocket.chat/core-typings'; + +import type { FreeSwitchOptions } from '../FreeSwitchOptions'; +import { runCommand } from '../runCommand'; +import { mapUserData } from '../utils/mapUserData'; +import { parseUserList } from '../utils/parseUserList'; + +export function getCommandListFilteredUser(user: string, group = 'default'): string { + return `list_users group ${group} user ${user}`; +} + +export async function getExtensionDetails( + options: FreeSwitchOptions, + requestParams: { extension: string; group?: string }, +): Promise { + const { extension, group } = requestParams; + const response = await runCommand(options, getCommandListFilteredUser(extension, group)); + + const users = parseUserList(response); + + if (!users.length) { + throw new Error('Extension not found.'); + } + + if (users.length >= 2) { + throw new Error('Multiple extensions were found.'); + } + + return mapUserData(users[0]); +} diff --git a/packages/freeswitch/src/commands/getExtensionList.ts b/packages/freeswitch/src/commands/getExtensionList.ts new file mode 100644 index 000000000000..5f5325698fc3 --- /dev/null +++ b/packages/freeswitch/src/commands/getExtensionList.ts @@ -0,0 +1,17 @@ +import type { FreeSwitchExtension } from '@rocket.chat/core-typings'; + +import type { FreeSwitchOptions } from '../FreeSwitchOptions'; +import { runCommand } from '../runCommand'; +import { mapUserData } from '../utils/mapUserData'; +import { parseUserList } from '../utils/parseUserList'; + +export function getCommandListUsers(): string { + return 'list_users'; +} + +export async function getExtensionList(options: FreeSwitchOptions): Promise { + const response = await runCommand(options, getCommandListUsers()); + const users = parseUserList(response); + + return users.map((item) => mapUserData(item)); +} diff --git a/packages/freeswitch/src/commands/getUserPassword.ts b/packages/freeswitch/src/commands/getUserPassword.ts new file mode 100644 index 000000000000..5388dce6e142 --- /dev/null +++ b/packages/freeswitch/src/commands/getUserPassword.ts @@ -0,0 +1,31 @@ +import type { StringMap } from 'esl'; + +import type { FreeSwitchOptions } from '../FreeSwitchOptions'; +import { logger } from '../logger'; +import { runCallback } from '../runCommand'; +import { getCommandGetDomain, parseDomainResponse } from './getDomain'; + +export function getCommandGetUserPassword(user: string, domain = 'rocket.chat'): string { + return `user_data ${user}@${domain} param password`; +} + +export function parsePasswordResponse(response: StringMap): string { + const { _body: password } = response; + + if (password === undefined) { + logger.error({ msg: 'Failed to load user password', response }); + throw new Error('Failed to load user password from FreeSwitch.'); + } + + return password; +} + +export async function getUserPassword(options: FreeSwitchOptions, user: string): Promise { + return runCallback(options, async (runCommand) => { + const domainResponse = await runCommand(getCommandGetDomain()); + const domain = parseDomainResponse(domainResponse); + + const response = await runCommand(getCommandGetUserPassword(user, domain)); + return parsePasswordResponse(response); + }); +} diff --git a/packages/freeswitch/src/commands/index.ts b/packages/freeswitch/src/commands/index.ts new file mode 100644 index 000000000000..ac33235067db --- /dev/null +++ b/packages/freeswitch/src/commands/index.ts @@ -0,0 +1,4 @@ +export * from './getDomain'; +export * from './getExtensionDetails'; +export * from './getExtensionList'; +export * from './getUserPassword'; diff --git a/packages/freeswitch/src/connect.ts b/packages/freeswitch/src/connect.ts new file mode 100644 index 000000000000..6ea3741edc42 --- /dev/null +++ b/packages/freeswitch/src/connect.ts @@ -0,0 +1,82 @@ +import { Socket, type SocketConnectOpts } from 'node:net'; + +import { FreeSwitchResponse } from 'esl'; + +import { logger } from './logger'; + +const defaultPassword = 'ClueCon'; + +export async function connect(options?: { host?: string; port?: number; password?: string }): Promise { + const host = options?.host ?? '127.0.0.1'; + const port = options?.port ?? 8021; + const password = options?.password ?? defaultPassword; + + return new Promise((resolve, reject) => { + logger.debug({ msg: 'FreeSwitchClient::connect', options: { host, port } }); + + const socket = new Socket(); + const currentCall = new FreeSwitchResponse(socket, logger); + let connecting = true; + + socket.once('connect', () => { + void (async (): Promise => { + connecting = false; + try { + // Normally when the client connects, FreeSwitch will first send us an authentication request. We use it to trigger the remainder of the stack. + 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'); + } catch (error) { + logger.error('FreeSwitchClient: connect error', error); + reject(error); + } + + if (currentCall) { + resolve(currentCall); + } + })(); + }); + + socket.once('error', (error) => { + if (!connecting) { + return; + } + + logger.error({ msg: 'failed to connect to freeswitch server', error }); + connecting = false; + reject(error); + }); + + socket.once('end', () => { + if (!connecting) { + return; + } + + logger.debug('FreeSwitchClient::connect: client received `end` event (remote end sent a FIN packet)'); + connecting = false; + reject(new Error('connection-ended')); + }); + + socket.on('warning', (data) => { + if (!connecting) { + return; + } + + logger.warn({ msg: 'FreeSwitchClient: warning', data }); + }); + + try { + logger.debug('FreeSwitchClient::connect: socket.connect', { options: { host, port } }); + socket.connect({ + host, + port, + password, + } as unknown as SocketConnectOpts); + } catch (error) { + logger.error('FreeSwitchClient::connect: socket.connect error', { error }); + connecting = false; + reject(error); + } + }); +} diff --git a/packages/freeswitch/src/getCommandResponse.ts b/packages/freeswitch/src/getCommandResponse.ts new file mode 100644 index 000000000000..b71618b37ec2 --- /dev/null +++ b/packages/freeswitch/src/getCommandResponse.ts @@ -0,0 +1,12 @@ +import type { FreeSwitchEventData, StringMap } from 'esl'; + +import { logger } from './logger'; + +export async function getCommandResponse(response: FreeSwitchEventData, command?: string): Promise { + if (!response?.body) { + logger.error('No response from FreeSwitch server', command, response); + throw new Error('No response from FreeSwitch server.'); + } + + return response.body; +} diff --git a/packages/freeswitch/src/index.ts b/packages/freeswitch/src/index.ts new file mode 100644 index 000000000000..30272ff42df9 --- /dev/null +++ b/packages/freeswitch/src/index.ts @@ -0,0 +1 @@ +export * from './commands'; diff --git a/packages/freeswitch/src/logger.ts b/packages/freeswitch/src/logger.ts new file mode 100644 index 000000000000..4c025069d8ad --- /dev/null +++ b/packages/freeswitch/src/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('FreeSwitch'); diff --git a/packages/freeswitch/src/runCommand.ts b/packages/freeswitch/src/runCommand.ts new file mode 100644 index 000000000000..25b87a4d2b80 --- /dev/null +++ b/packages/freeswitch/src/runCommand.ts @@ -0,0 +1,30 @@ +import { wrapExceptions } from '@rocket.chat/tools'; +import { FreeSwitchResponse, type StringMap } from 'esl'; + +import type { FreeSwitchOptions } from './FreeSwitchOptions'; +import { connect } from './connect'; +import { getCommandResponse } from './getCommandResponse'; + +export async function runCallback( + options: FreeSwitchOptions, + cb: (runCommand: (command: string) => Promise) => Promise, +): Promise { + const { host, port, password, timeout } = options; + + const call = await connect({ host, port, password }); + try { + // Await result so it runs within the try..finally scope + const result = await cb(async (command) => { + const response = await call.bgapi(command, timeout ?? FreeSwitchResponse.default_command_timeout); + return getCommandResponse(response, command); + }); + + return result; + } finally { + await wrapExceptions(async () => call.end()).suppress(); + } +} + +export async function runCommand(options: FreeSwitchOptions, command: string): Promise { + return runCallback(options, async (runCommand) => runCommand(command)); +} diff --git a/packages/freeswitch/src/utils/mapUserData.ts b/packages/freeswitch/src/utils/mapUserData.ts new file mode 100644 index 000000000000..265cf1f6946e --- /dev/null +++ b/packages/freeswitch/src/utils/mapUserData.ts @@ -0,0 +1,33 @@ +import type { FreeSwitchExtension } from '@rocket.chat/core-typings'; +import type { StringMap } from 'esl'; + +import { parseUserStatus } from './parseUserStatus'; + +export function mapUserData(user: StringMap): FreeSwitchExtension { + const { + userid: extension, + context, + domain, + groups, + contact, + callgroup: callGroup, + effective_caller_id_name: callerName, + effective_caller_id_number: callerNumber, + } = user; + + if (!extension) { + throw new Error('Invalid user identification.'); + } + + return { + extension, + context, + domain, + groups: groups?.split('|') || [], + status: parseUserStatus(contact), + contact, + callGroup, + callerName, + callerNumber, + }; +} diff --git a/packages/freeswitch/src/utils/parseUserList.ts b/packages/freeswitch/src/utils/parseUserList.ts new file mode 100644 index 000000000000..21ed42ef1ac2 --- /dev/null +++ b/packages/freeswitch/src/utils/parseUserList.ts @@ -0,0 +1,54 @@ +import type { StringMap } from 'esl'; + +export function parseUserList(commandResponse: StringMap): Record[] { + const { _body: text } = commandResponse; + + if (!text || typeof text !== 'string') { + throw new Error('Invalid response from FreeSwitch server.'); + } + + const lines = text.split('\n'); + const columnsLine = lines.shift(); + if (!columnsLine) { + throw new Error('Invalid response from FreeSwitch server.'); + } + + const columns = columnsLine.split('|'); + + const users = new Map>(); + + for (const line of lines) { + const values = line.split('|'); + if (!values.length || !values[0]) { + continue; + } + const user = Object.fromEntries( + values.map((value, index) => { + return [(columns.length > index && columns[index]) || `column${index}`, value]; + }), + ); + + if (!user.userid || user.userid === '+OK') { + continue; + } + + const { group, ...newUserData } = user; + + const existingUser = users.get(user.userid); + const groups = (existingUser?.groups || []) as string[]; + + if (group && !groups.includes(group)) { + groups.push(group); + } + + users.set(user.userid, { + ...(users.get(user.userid) || newUserData), + groups, + }); + } + + return [...users.values()].map((user) => ({ + ...user, + groups: (user.groups as string[]).join('|'), + })); +} diff --git a/packages/freeswitch/src/utils/parseUserStatus.ts b/packages/freeswitch/src/utils/parseUserStatus.ts new file mode 100644 index 000000000000..48cb9b32474a --- /dev/null +++ b/packages/freeswitch/src/utils/parseUserStatus.ts @@ -0,0 +1,17 @@ +import type { FreeSwitchExtension } from '@rocket.chat/core-typings'; + +export function parseUserStatus(status: string | undefined): FreeSwitchExtension['status'] { + if (!status) { + return 'UNKNOWN'; + } + + if (status === 'error/user_not_registered') { + return 'UNREGISTERED'; + } + + if (status.startsWith('sofia/')) { + return 'REGISTERED'; + } + + return 'UNKNOWN'; +} diff --git a/packages/freeswitch/tests/mapUserData.test.ts b/packages/freeswitch/tests/mapUserData.test.ts new file mode 100644 index 000000000000..44b55db492c2 --- /dev/null +++ b/packages/freeswitch/tests/mapUserData.test.ts @@ -0,0 +1,37 @@ +import { mapUserData } from '../src/utils/mapUserData'; + +expect(() => mapUserData(undefined as unknown as any)).toThrow(); +expect(() => mapUserData({ extension: '15' })).toThrow('Invalid user identification.'); + +test.each([ + [{ userid: '1' }, { extension: '1', groups: [], status: 'UNKNOWN' }], + [ + { userid: '1', context: 'default' }, + { extension: '1', context: 'default', groups: [], status: 'UNKNOWN' }, + ], + [ + { + userid: '1', + context: 'default', + domain: 'domainName', + groups: 'default|employee', + contact: 'no', + callgroup: 'call group', + effective_caller_id_name: 'caller_id_name', + effective_caller_id_number: 'caller_id_number', + }, + { + extension: '1', + context: 'default', + domain: 'domainName', + groups: ['default', 'employee'], + contact: 'no', + callGroup: 'call group', + callerName: 'caller_id_name', + callerNumber: 'caller_id_number', + status: 'UNKNOWN', + }, + ], +])('parse user status: %p', (input, output) => { + expect(mapUserData(input)).toMatchObject(output); +}); diff --git a/packages/freeswitch/tests/parseUserList.test.ts b/packages/freeswitch/tests/parseUserList.test.ts new file mode 100644 index 000000000000..e1db317f24fa --- /dev/null +++ b/packages/freeswitch/tests/parseUserList.test.ts @@ -0,0 +1,214 @@ +import { parseUserList } from '../src/utils/parseUserList'; +import { makeFreeSwitchResponse } from './utils/makeFreeSwitchResponse'; + +test.each(['', undefined, 200 as unknown as any, '\nsomething'])('Invalid FreeSwitch responses', (input) => { + expect(() => parseUserList({ _body: input })).toThrow('Invalid response from FreeSwitch server.'); +}); + +test('Should return an empty list when there is no userid column', () => { + expect( + parseUserList( + makeFreeSwitchResponse([ + ['aaa', 'bbb', 'ccc'], + ['AData', 'BData', 'CData'], + ['AData2', 'BData2', 'CData2'], + ['AData3', 'BData3', 'CData3'], + ]), + ), + ).toMatchObject([]); +}); + +test('Should return an empty list when there is no lowercase userid column', () => { + expect( + parseUserList( + makeFreeSwitchResponse([ + ['aaa', 'bbb', 'ccc', 'USERID'], + [], + ['AData', 'BData', 'CData', '15'], + ['AData2', 'BData2', 'CData2', '20'], + ['AData3', 'BData3', 'CData3', '30'], + ]), + ), + ).toMatchObject([]); +}); + +test(`Should return an empty list when all records' userid is either missing or equal to +OK`, () => { + expect( + parseUserList( + makeFreeSwitchResponse([ + ['aaa', 'bbb', 'ccc', 'userid'], + ['AData', 'BData', 'CData'], + ['AData2', 'BData2', 'CData2', ''], + ['AData3', 'BData3', 'CData3', '+OK'], + ]), + ), + ).toMatchObject([]); +}); + +test.each([ + [ + [['userid'], ['1']], + [ + { + userid: '1', + }, + ], + ], + [ + [['userid'], ['1'], ['2'], ['3']], + [ + { + userid: '1', + }, + { + userid: '2', + }, + { + userid: '3', + }, + ], + ], + [ + [ + ['userid', 'group', 'name'], + ['1', 'default', 'User 1'], + ['2', 'default', 'User 2'], + ['3', 'default', 'User 3'], + ], + [ + { + userid: '1', + groups: 'default', + name: 'User 1', + }, + { + userid: '2', + groups: 'default', + name: 'User 2', + }, + { + userid: '3', + groups: 'default', + name: 'User 3', + }, + ], + ], + [ + [ + ['userid', 'name'], + ['1', 'User 1', 'AnotherValue'], + ['2', 'User 2'], + ['3', 'User 3'], + ], + [ + { + userid: '1', + name: 'User 1', + column2: 'AnotherValue', + }, + { + userid: '2', + name: 'User 2', + }, + { + userid: '3', + name: 'User 3', + }, + ], + ], +])('parse valid user list: %p', (input, output) => { + expect(parseUserList(makeFreeSwitchResponse(input))).toMatchObject(output); +}); + +test.each([ + [ + [['userid'], ['1'], ['1']], + [ + { + userid: '1', + }, + ], + ], + [ + [['userid'], ['1'], ['2'], ['1'], ['2'], ['3'], ['3']], + [ + { + userid: '1', + }, + { + userid: '2', + }, + { + userid: '3', + }, + ], + ], + [ + [ + ['userid', 'group'], + ['1', 'default'], + ['1', 'employee'], + ], + [ + { + userid: '1', + groups: 'default|employee', + }, + ], + ], + [ + // When there's multiple records for the same user, join the group names from all of them into the data from the first record + [ + ['userid', 'group'], + ['1', 'default'], + ['2', 'default'], + ['1', 'employee'], + ['2', 'manager'], + ['3', 'default'], + ['3', 'owner'], + ], + [ + { + userid: '1', + groups: 'default|employee', + }, + { + userid: '2', + groups: 'default|manager', + }, + { + userid: '3', + groups: 'default|owner', + }, + ], + ], + [ + // When there's multiple records for the same user without group names, use only the data from the first of them + [ + ['userid', 'something_else'], + ['1', '1.1'], + ['1', '1.2'], + ['2', '2.1'], + ['2', '2.2', 'extra_value'], + ['3', ''], + ['3', '3.2'], + ['3', '3.3'], + ], + [ + { + userid: '1', + something_else: '1.1', + }, + { + userid: '2', + something_else: '2.1', + }, + { + userid: '3', + something_else: '', + }, + ], + ], +])('parse user list with duplicate userids: %p', (input, output) => { + expect(parseUserList(makeFreeSwitchResponse(input))).toMatchObject(output); +}); diff --git a/packages/freeswitch/tests/parseUserStatus.test.ts b/packages/freeswitch/tests/parseUserStatus.test.ts new file mode 100644 index 000000000000..73e2f0599097 --- /dev/null +++ b/packages/freeswitch/tests/parseUserStatus.test.ts @@ -0,0 +1,14 @@ +import { parseUserStatus } from '../src/utils/parseUserStatus'; + +test.each([ + ['', 'UNKNOWN'], + [undefined, 'UNKNOWN'], + ['error/user_not_registered', 'UNREGISTERED'], + ['ERROR/user_not_registered', 'UNKNOWN'], + ['sofia/user_data', 'REGISTERED'], + ['sofia/', 'REGISTERED'], + ['SOFIA/', 'UNKNOWN'], + ['luana/', 'UNKNOWN'], +])('parse user status: %p', (input, output) => { + expect(parseUserStatus(input)).toBe(output); +}); diff --git a/packages/freeswitch/tests/utils/makeFreeSwitchResponse.ts b/packages/freeswitch/tests/utils/makeFreeSwitchResponse.ts new file mode 100644 index 000000000000..58c2da59a1fe --- /dev/null +++ b/packages/freeswitch/tests/utils/makeFreeSwitchResponse.ts @@ -0,0 +1,5 @@ +import type { StringMap } from 'esl'; + +export const makeFreeSwitchResponse = (lines: string[][]): StringMap => ({ + _body: lines.map((columns) => columns.join('|')).join('\n'), +}); diff --git a/packages/freeswitch/tsconfig.json b/packages/freeswitch/tsconfig.json new file mode 100644 index 000000000000..d2a7e3ee7c8b --- /dev/null +++ b/packages/freeswitch/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.server.json", + "compilerOptions": { + "declaration": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src/**/*"], +} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 849582f934b7..1f3da25698ff 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -5950,6 +5950,13 @@ "VoIP_JWT_Secret_description": "Set a secret key for sharing extension details from server to client as JWT instead of plain text. Extension registration details will be sent as plain text if a secret key has not been set.", "Voip_is_disabled": "VoIP is disabled", "Voip_is_disabled_description": "To view the list of extensions it is necessary to activate VoIP, do so in the Settings tab.", + "VoIP_TeamCollab": "VoIP for Team Collaboration", + "VoIP_TeamCollab_Enabled": "Enabled", + "VoIP_TeamCollab_FreeSwitch_Host": "FreeSwitch Host", + "VoIP_TeamCollab_FreeSwitch_Port": "FreeSwitch Port", + "VoIP_TeamCollab_FreeSwitch_Password": "FreeSwitch Password", + "VoIP_TeamCollab_FreeSwitch_Timeout": "FreeSwitch Request Timeout", + "VoIP_TeamCollab_FreeSwitch_WebSocket_Path": "WebSocket Path", "VoIP_Toggle": "Enable/Disable VoIP", "Chat_opened_by_visitor": "Chat opened by the visitor", "Wait_activation_warning": "Before you can login, your account must be manually activated by an administrator.", diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index bd660c05191d..83c499a1356e 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -402,4 +402,8 @@ export interface IUsersModel extends IBaseModel { findOnlineButNotAvailableAgents(userIds: string[] | null): FindCursor>; findAgentsAvailableWithoutBusinessHours(userIds: string[] | null): FindCursor>; updateLivechatStatusByAgentIds(userIds: string[], status: ILivechatAgentStatus): Promise; + findOneByFreeSwitchExtension(extension: string, options?: FindOptions): Promise; + setFreeSwitchExtension(userId: string, extension: string | undefined): Promise; + findAssignedFreeSwitchExtensions(): FindCursor; + findUsersWithAssignedFreeSwitchExtensions(options?: FindOptions): FindCursor; } diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 94ee32f2f5be..eda85b03dbc8 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -47,6 +47,7 @@ import type { TeamsEndpoints } from './v1/teams'; import type { UsersEndpoints } from './v1/users'; import type { VideoConferenceEndpoints } from './v1/videoConference'; import type { VoipEndpoints } from './v1/voip'; +import type { VoipFreeSwitchEndpoints } from './v1/voip-freeswitch'; import type { WebdavEndpoints } from './v1/webdav'; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -99,6 +100,7 @@ export interface Endpoints CalendarEndpoints, AuthEndpoints, ImportEndpoints, + VoipFreeSwitchEndpoints, DefaultEndpoints {} type OperationsByPathPatternAndMethod< @@ -260,6 +262,7 @@ export * from './v1/e2e/e2eUpdateGroupKeyParamsPOST'; export * from './v1/e2e'; export * from './v1/import'; export * from './v1/voip'; +export * from './v1/voip-freeswitch'; export * from './v1/email-inbox'; export * from './v1/calendar'; export * from './v1/federation'; diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 28c7ad52337b..6d4e62331bcf 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -114,7 +114,18 @@ export type UserPersonalTokens = Pick; export type UsersEndpoints = { diff --git a/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionAssignProps.ts b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionAssignProps.ts new file mode 100644 index 000000000000..7fcaf6c6a9a1 --- /dev/null +++ b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionAssignProps.ts @@ -0,0 +1,24 @@ +import type { JSONSchemaType } from 'ajv'; +import Ajv from 'ajv'; + +const ajv = new Ajv(); + +export type VoipFreeSwitchExtensionAssignProps = { username: string; extension?: string }; + +const voipFreeSwitchExtensionAssignPropsSchema: JSONSchemaType = { + type: 'object', + properties: { + username: { + type: 'string', + nullable: false, + }, + extension: { + type: 'string', + nullable: true, + }, + }, + required: ['username'], + additionalProperties: false, +}; + +export const isVoipFreeSwitchExtensionAssignProps = ajv.compile(voipFreeSwitchExtensionAssignPropsSchema); diff --git a/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetDetailsProps.ts b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetDetailsProps.ts new file mode 100644 index 000000000000..a41ab7cd562a --- /dev/null +++ b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetDetailsProps.ts @@ -0,0 +1,27 @@ +import type { JSONSchemaType } from 'ajv'; +import Ajv from 'ajv'; + +const ajv = new Ajv(); + +export type VoipFreeSwitchExtensionGetDetailsProps = { + extension: string; + group?: string; +}; + +const voipFreeSwitchExtensionGetDetailsPropsSchema: JSONSchemaType = { + type: 'object', + properties: { + extension: { + type: 'string', + nullable: false, + }, + group: { + type: 'string', + nullable: true, + }, + }, + required: ['extension'], + additionalProperties: false, +}; + +export const isVoipFreeSwitchExtensionGetDetailsProps = ajv.compile(voipFreeSwitchExtensionGetDetailsPropsSchema); diff --git a/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetInfoProps.ts b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetInfoProps.ts new file mode 100644 index 000000000000..1ff871296757 --- /dev/null +++ b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetInfoProps.ts @@ -0,0 +1,22 @@ +import type { JSONSchemaType } from 'ajv'; +import Ajv from 'ajv'; + +const ajv = new Ajv(); + +export type VoipFreeSwitchExtensionGetInfoProps = { + userId: string; +}; + +const voipFreeSwitchExtensionGetInfoPropsSchema: JSONSchemaType = { + type: 'object', + properties: { + userId: { + type: 'string', + nullable: false, + }, + }, + required: ['userId'], + additionalProperties: false, +}; + +export const isVoipFreeSwitchExtensionGetInfoProps = ajv.compile(voipFreeSwitchExtensionGetInfoPropsSchema); diff --git a/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionListProps.ts b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionListProps.ts new file mode 100644 index 000000000000..e1c010afb326 --- /dev/null +++ b/packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionListProps.ts @@ -0,0 +1,28 @@ +import type { JSONSchemaType } from 'ajv'; +import Ajv from 'ajv'; + +const ajv = new Ajv(); + +export type VoipFreeSwitchExtensionListProps = { + username?: string; + type?: 'available' | 'free' | 'allocated' | 'all'; +}; + +const voipFreeSwitchExtensionListPropsSchema: JSONSchemaType = { + type: 'object', + properties: { + username: { + type: 'string', + nullable: true, + }, + type: { + type: 'string', + enum: ['available', 'free', 'allocated', 'all'], + nullable: true, + }, + }, + required: [], + additionalProperties: false, +}; + +export const isVoipFreeSwitchExtensionListProps = ajv.compile(voipFreeSwitchExtensionListPropsSchema); diff --git a/packages/rest-typings/src/v1/voip-freeswitch/index.ts b/packages/rest-typings/src/v1/voip-freeswitch/index.ts new file mode 100644 index 000000000000..a021a6f18b87 --- /dev/null +++ b/packages/rest-typings/src/v1/voip-freeswitch/index.ts @@ -0,0 +1,26 @@ +import type { FreeSwitchExtension } from '@rocket.chat/core-typings'; + +import type { VoipFreeSwitchExtensionAssignProps } from './VoipFreeSwitchExtensionAssignProps'; +import type { VoipFreeSwitchExtensionGetDetailsProps } from './VoipFreeSwitchExtensionGetDetailsProps'; +import type { VoipFreeSwitchExtensionGetInfoProps } from './VoipFreeSwitchExtensionGetInfoProps'; +import type { VoipFreeSwitchExtensionListProps } from './VoipFreeSwitchExtensionListProps'; + +export * from './VoipFreeSwitchExtensionAssignProps'; +export * from './VoipFreeSwitchExtensionGetDetailsProps'; +export * from './VoipFreeSwitchExtensionGetInfoProps'; +export * from './VoipFreeSwitchExtensionListProps'; + +export type VoipFreeSwitchEndpoints = { + '/v1/voip-freeswitch.extension.list': { + GET: (params: VoipFreeSwitchExtensionListProps) => { extensions: FreeSwitchExtension[] }; + }; + '/v1/voip-freeswitch.extension.getDetails': { + GET: (params: VoipFreeSwitchExtensionGetDetailsProps) => FreeSwitchExtension & { userId?: string; username?: string }; + }; + '/v1/voip-freeswitch.extension.assign': { + POST: (params: VoipFreeSwitchExtensionAssignProps) => void; + }; + '/v1/voip-freeswitch.extension.getRegistrationInfoByUserId': { + GET: (params: VoipFreeSwitchExtensionGetInfoProps) => { extension: FreeSwitchExtension; credentials: { password: string } }; + }; +}; diff --git a/yarn.lock b/yarn.lock index 65ebae84019b..ca8350b7dfe6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8823,6 +8823,22 @@ __metadata: languageName: node linkType: hard +"@rocket.chat/freeswitch@workspace:^, @rocket.chat/freeswitch@workspace:packages/freeswitch": + version: 0.0.0-use.local + resolution: "@rocket.chat/freeswitch@workspace:packages/freeswitch" + dependencies: + "@rocket.chat/core-typings": "workspace:^" + "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/logger": "workspace:^" + "@rocket.chat/tools": "workspace:^" + "@types/jest": ~29.5.12 + esl: "github:pierre-lehnen-rc/esl" + eslint: ~8.45.0 + jest: ~29.7.0 + typescript: ~5.3.3 + languageName: unknown + linkType: soft + "@rocket.chat/fuselage-hooks@npm:^0.33.1": version: 0.33.1 resolution: "@rocket.chat/fuselage-hooks@npm:0.33.1" @@ -8992,6 +9008,7 @@ __metadata: "@storybook/react": ~6.5.16 "@storybook/testing-library": ~0.0.13 "@testing-library/react": ~16.0.0 + "@types/dompurify": ^3.0.5 "@types/jest": ~29.5.12 "@types/katex": ~0.16.5 "@types/react": ~17.0.69 @@ -9000,6 +9017,7 @@ __metadata: "@typescript-eslint/parser": ~5.60.1 babel-loader: ^8.3.0 date-fns: ^3.3.1 + dompurify: ^3.1.6 eslint: ~8.45.0 eslint-plugin-anti-trojan-source: ~1.1.1 eslint-plugin-react: ~7.32.2 @@ -9342,6 +9360,7 @@ __metadata: "@rocket.chat/favicon": "workspace:^" "@rocket.chat/forked-matrix-appservice-bridge": ^4.0.2 "@rocket.chat/forked-matrix-bot-sdk": ^0.6.0-beta.3 + "@rocket.chat/freeswitch": "workspace:^" "@rocket.chat/fuselage": ^0.59.0 "@rocket.chat/fuselage-hooks": ^0.33.1 "@rocket.chat/fuselage-polyfills": ~0.31.25 @@ -9528,6 +9547,7 @@ __metadata: emoji-toolkit: ^7.0.1 emojione: ^4.5.0 emojione-assets: ^4.5.0 + esl: "github:pierre-lehnen-rc/esl" eslint: ~8.45.0 eslint-config-prettier: ~8.8.0 eslint-plugin-anti-trojan-source: ~1.1.1 @@ -13382,6 +13402,15 @@ __metadata: languageName: node linkType: hard +"@types/dompurify@npm:^3.0.5": + version: 3.0.5 + resolution: "@types/dompurify@npm:3.0.5" + dependencies: + "@types/trusted-types": "*" + checksum: ffc34eca6a4536e1c8c16a47cce2623c5a118a9785492e71230052d92933ff096d14326ff449031e8dfaac509413222372d8f2b28786a13159de6241df716185 + languageName: node + linkType: hard + "@types/ejson@npm:^2.2.1": version: 2.2.1 resolution: "@types/ejson@npm:2.2.1" @@ -21370,6 +21399,13 @@ __metadata: languageName: node linkType: hard +"dompurify@npm:^3.1.6": + version: 3.1.6 + resolution: "dompurify@npm:3.1.6" + checksum: cc4fc4ccd9261fbceb2a1627a985c70af231274a26ddd3f643fd0616a0a44099bd9e4480940ce3655612063be4a1fe9f5e9309967526f8c0a99f931602323866 + languageName: node + linkType: hard + "domutils@npm:^1.7.0": version: 1.7.0 resolution: "domutils@npm:1.7.0" @@ -22327,6 +22363,13 @@ __metadata: languageName: node linkType: hard +"esl@github:pierre-lehnen-rc/esl": + version: 11.1.1 + resolution: "esl@https://github.com/pierre-lehnen-rc/esl.git#commit=22f2167a4acaa129d214592cfb0d46a419d08663" + checksum: 1e24a130650d916980ba3d332bc3fbac90a78ec9b13db7f7300318de3094e178d5c851c4b1c36d99c8a8ea800279ca07137d6a0c6335168cec01870cf61d820c + languageName: node + linkType: hard + "eslint-config-prettier@npm:~8.8.0": version: 8.8.0 resolution: "eslint-config-prettier@npm:8.8.0" @@ -40564,6 +40607,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:~5.3.3": + version: 5.3.3 + resolution: "typescript@npm:5.3.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 2007ccb6e51bbbf6fde0a78099efe04dc1c3dfbdff04ca3b6a8bc717991862b39fd6126c0c3ebf2d2d98ac5e960bcaa873826bb2bb241f14277034148f41f6a2 + languageName: node + linkType: hard + "typescript@npm:~5.5.4": version: 5.5.4 resolution: "typescript@npm:5.5.4" @@ -40574,6 +40627,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@~5.3.3#~builtin": + version: 5.3.3 + resolution: "typescript@patch:typescript@npm%3A5.3.3#~builtin::version=5.3.3&hash=85af82" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: f61375590b3162599f0f0d5b8737877ac0a7bc52761dbb585d67e7b8753a3a4c42d9a554c4cc929f591ffcf3a2b0602f65ae3ce74714fd5652623a816862b610 + languageName: node + linkType: hard + "typescript@patch:typescript@~5.5.4#~builtin": version: 5.5.4 resolution: "typescript@patch:typescript@npm%3A5.5.4#~builtin::version=5.5.4&hash=85af82" From ae54a183c77cd58b3c17e877e4c855af9eb0318a Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Wed, 18 Sep 2024 13:03:58 -0300 Subject: [PATCH 02/10] feat: add new permission for freeswitch voip endpoints (#33310) --- apps/meteor/app/api/server/lib/users.ts | 31 ++++++++++--------- .../server/constant/permissions.ts | 7 +++++ .../api-enterprise/server/voip-freeswitch.ts | 8 ++--- packages/i18n/src/locales/en.i18n.json | 6 ++++ 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index 1d7371c38659..0289f1fe5ff5 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -143,21 +143,6 @@ export async function findPaginatedUsersByStatus({ hasLoggedIn, type, }: FindPaginatedUsersByStatusProps) { - const projection = { - name: 1, - username: 1, - emails: 1, - roles: 1, - status: 1, - active: 1, - avatarETag: 1, - lastLogin: 1, - type: 1, - reason: 1, - federated: 1, - freeSwitchExtension: 1, - }; - const actualSort: Record = sort || { username: 1 }; if (sort?.status) { actualSort.active = sort.status; @@ -184,6 +169,22 @@ export async function findPaginatedUsersByStatus({ } const canSeeAllUserInfo = await hasPermissionAsync(uid, 'view-full-other-user-info'); + const canSeeExtension = canSeeAllUserInfo || (await hasPermissionAsync(uid, 'view-user-voip-extension')); + + const projection = { + name: 1, + username: 1, + emails: 1, + roles: 1, + status: 1, + active: 1, + avatarETag: 1, + lastLogin: 1, + type: 1, + reason: 1, + federated: 1, + ...(canSeeExtension ? { freeSwitchExtension: 1 } : {}), + }; match.$or = [ ...(canSeeAllUserInfo ? [{ 'emails.address': { $regex: escapeRegExp(searchTerm || ''), $options: 'i' } }] : []), diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index 46d40713bad1..f57943412fb4 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -208,6 +208,13 @@ export const permissions = [ // allows to receive a voip call { _id: 'inbound-voip-calls', roles: ['livechat-agent'] }, + // Allow managing team collab voip extensions + { _id: 'manage-voip-extensions', roles: ['admin'] }, + // Allow viewing the extension number of other users + { _id: 'view-user-voip-extension', roles: ['admin', 'user'] }, + // Allow viewing details of an extension + { _id: 'view-voip-extension-details', roles: ['admin', 'user'] }, + { _id: 'remove-livechat-department', roles: ['livechat-manager', 'admin'] }, { _id: 'manage-apps', roles: ['admin'] }, { _id: 'post-readonly', roles: ['admin', 'owner', 'moderator'] }, diff --git a/apps/meteor/ee/app/api-enterprise/server/voip-freeswitch.ts b/apps/meteor/ee/app/api-enterprise/server/voip-freeswitch.ts index 8094c31981f7..dc2a108989fd 100644 --- a/apps/meteor/ee/app/api-enterprise/server/voip-freeswitch.ts +++ b/apps/meteor/ee/app/api-enterprise/server/voip-freeswitch.ts @@ -13,7 +13,7 @@ import { settings } from '../../../../app/settings/server/cached'; API.v1.addRoute( 'voip-freeswitch.extension.list', - { authRequired: true, permissionsRequired: ['manage-voip-call-settings'], validateParams: isVoipFreeSwitchExtensionListProps }, + { authRequired: true, permissionsRequired: ['manage-voip-extensions'], validateParams: isVoipFreeSwitchExtensionListProps }, { async get() { const { username, type = 'all' } = this.queryParams; @@ -55,7 +55,7 @@ API.v1.addRoute( API.v1.addRoute( 'voip-freeswitch.extension.assign', - { authRequired: true, permissionsRequired: ['manage-voip-call-settings'], validateParams: isVoipFreeSwitchExtensionAssignProps }, + { authRequired: true, permissionsRequired: ['manage-voip-extensions'], validateParams: isVoipFreeSwitchExtensionAssignProps }, { async post() { const { extension, username } = this.bodyParams; @@ -86,7 +86,7 @@ API.v1.addRoute( API.v1.addRoute( 'voip-freeswitch.extension.getDetails', - { authRequired: true, permissionsRequired: ['manage-voip-call-settings'], validateParams: isVoipFreeSwitchExtensionGetDetailsProps }, + { authRequired: true, permissionsRequired: ['view-voip-extension-details'], validateParams: isVoipFreeSwitchExtensionGetDetailsProps }, { async get() { const { extension, group } = this.queryParams; @@ -112,7 +112,7 @@ API.v1.addRoute( API.v1.addRoute( 'voip-freeswitch.extension.getRegistrationInfoByUserId', - { authRequired: true, permissionsRequired: ['manage-voip-call-settings'], validateParams: isVoipFreeSwitchExtensionGetInfoProps }, + { authRequired: true, permissionsRequired: ['view-user-voip-extension'], validateParams: isVoipFreeSwitchExtensionGetInfoProps }, { async get() { const { userId } = this.queryParams; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index d5f6ea820fc5..b5183f2afac2 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3467,6 +3467,8 @@ "manage-user-status_description": "Permission to manage the server custom user statuses", "manage-voip-call-settings": "Manage Voip Call Settings", "manage-voip-call-settings_description": "Permission to manage voip call settings", + "manage-voip-extensions": "Manage Voip Extensions", + "manage-voip-extensions_description": "Permission to manage voip extensions assigned to users", "manage-voip-contact-center-settings": "Manage Voip Contact Center Settings", "manage-voip-contact-center-settings_description": "Permission to manage voip contact center settings", "Manage_Omnichannel": "Manage Omnichannel", @@ -5904,6 +5906,10 @@ "view-statistics_description": "Permission to view system statistics such as number of users logged in, number of rooms, operating system information", "view-user-administration": "View User Administration", "view-user-administration_description": "Permission to partial, read-only list view of other user accounts currently logged into the system. No user account information is accessible with this permission", + "view-user-voip-extension": "View User VoIP Extension", + "view-user-voip-extension_description": "Permission to view user's assigned VoIP Extension", + "view-voip-extension-details": "View VoIP Extension Details", + "view-voip-extension-details_description": "Permission to view the details associated with VoIP extensions", "Viewing_room_administration": "Viewing room administration", "Visibility": "Visibility", "Visible": "Visible", From 8389ec14e832c2bce1ac32bee47ff892d7a63f22 Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Mon, 23 Sep 2024 10:46:09 -0300 Subject: [PATCH 03/10] feat: VoIP freeswitch UI admin (#33004) * feat: Add options to view and assign voice call extensions on the admin's user's list * chore: changed Roles import to not use index * chore: added ee/views to unit test build * test: added VoIP unit tests for UsersTable * test: added VoIP unit tests for UsersPageHeader * test: added VoIP unit tests for AssignExtensionModal * test: added legacyRoot to tests * test: added await to userEvent.click * chore: added meteor imports coming from global namespace * test: added mock for meteor methods * test: removed module mocks from UsersTable * test: get by role instead of testId on AssignExtensionModal * chore: imported Meteor.users not compatible with global Meteor.users type * Use fake data helpers in unit tests * feat: improved the user interface * test: adjusted UserTable unit tests * Adjust type names * Replace `voiceCall` with `voip` --------- Co-authored-by: Tasso --- apps/meteor/app/models/client/models/Users.ts | 3 +- apps/meteor/app/utils/client/lib/SDKClient.ts | 1 + .../admin/users/AdminUserInfoActions.tsx | 4 +- .../admin/users/AdminUserInfoWithData.tsx | 4 +- .../views/admin/users/AdminUsersPage.tsx | 10 +- ...UserPageHeaderContentWithSeatsCap.spec.tsx | 24 +++ .../UserPageHeaderContentWithSeatsCap.tsx | 11 +- .../users/UsersTable/UsersTable.spec.tsx | 98 ++++++++++++ .../admin/users/UsersTable/UsersTable.tsx | 26 ++- .../admin/users/UsersTable/UsersTableRow.tsx | 37 ++++- .../admin/users/hooks/useFilteredUsers.ts | 8 +- .../users/hooks/useVoipExtensionAction.tsx | 37 +++++ .../users/voip/AssignExtensionButton.tsx | 26 +++ .../users/voip/AssignExtensionModal.spec.tsx | 129 +++++++++++++++ .../admin/users/voip/AssignExtensionModal.tsx | 148 ++++++++++++++++++ .../users/voip/RemoveExtensionModal.spec.tsx | 54 +++++++ .../admin/users/voip/RemoveExtensionModal.tsx | 81 ++++++++++ apps/meteor/jest.config.ts | 1 + apps/meteor/tests/mocks/client/meteor.ts | 21 ++- packages/i18n/src/locales/en.i18n.json | 9 ++ packages/i18n/src/locales/pt-BR.i18n.json | 7 +- .../src/MockedAppRootBuilder.tsx | 9 +- 22 files changed, 719 insertions(+), 29 deletions(-) create mode 100644 apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.spec.tsx create mode 100644 apps/meteor/client/views/admin/users/UsersTable/UsersTable.spec.tsx create mode 100644 apps/meteor/client/views/admin/users/hooks/useVoipExtensionAction.tsx create mode 100644 apps/meteor/client/views/admin/users/voip/AssignExtensionButton.tsx create mode 100644 apps/meteor/client/views/admin/users/voip/AssignExtensionModal.spec.tsx create mode 100644 apps/meteor/client/views/admin/users/voip/AssignExtensionModal.tsx create mode 100644 apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.spec.tsx create mode 100644 apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.tsx diff --git a/apps/meteor/app/models/client/models/Users.ts b/apps/meteor/app/models/client/models/Users.ts index e2d8c7856752..3fd1016e528b 100644 --- a/apps/meteor/app/models/client/models/Users.ts +++ b/apps/meteor/app/models/client/models/Users.ts @@ -1,4 +1,5 @@ import type { IRole, IUser } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; import { Mongo } from 'meteor/mongo'; class UsersCollection extends Mongo.Collection { @@ -39,4 +40,4 @@ Object.assign(Meteor.users, { }); /** @deprecated new code refer to Minimongo collections like this one; prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ -export const Users = Meteor.users as UsersCollection; +export const Users = Meteor.users as unknown as UsersCollection; diff --git a/apps/meteor/app/utils/client/lib/SDKClient.ts b/apps/meteor/app/utils/client/lib/SDKClient.ts index 3c7e43c85f7c..5ca4e112ad69 100644 --- a/apps/meteor/app/utils/client/lib/SDKClient.ts +++ b/apps/meteor/app/utils/client/lib/SDKClient.ts @@ -1,6 +1,7 @@ import type { RestClientInterface } from '@rocket.chat/api-client'; import type { SDK, ClientStream, StreamKeys, StreamNames, StreamerCallbackArgs, ServerMethods } from '@rocket.chat/ddp-client'; import { Emitter } from '@rocket.chat/emitter'; +import { Accounts } from 'meteor/accounts-base'; import { DDPCommon } from 'meteor/ddp-common'; import { Meteor } from 'meteor/meteor'; diff --git a/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx b/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx index 800222282054..31ff9a96f842 100644 --- a/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx @@ -6,7 +6,7 @@ import React, { useCallback, useMemo } from 'react'; import { UserInfoAction } from '../../../components/UserInfo'; import { useActionSpread } from '../../hooks/useActionSpread'; -import type { AdminUserTab } from './AdminUsersPage'; +import type { AdminUsersTab } from './AdminUsersPage'; import { useChangeAdminStatusAction } from './hooks/useChangeAdminStatusAction'; import { useChangeUserStatusAction } from './hooks/useChangeUserStatusAction'; import { useDeleteUserAction } from './hooks/useDeleteUserAction'; @@ -19,7 +19,7 @@ type AdminUserInfoActionsProps = { isFederatedUser: IUser['federated']; isActive: boolean; isAdmin: boolean; - tab: AdminUserTab; + tab: AdminUsersTab; onChange: () => void; onReload: () => void; }; diff --git a/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx b/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx index 2318e5ae1dc1..59d91ce5ada6 100644 --- a/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx @@ -14,12 +14,12 @@ import { UserInfo } from '../../../components/UserInfo'; import { UserStatus } from '../../../components/UserStatus'; import { getUserEmailVerified } from '../../../lib/utils/getUserEmailVerified'; import AdminUserInfoActions from './AdminUserInfoActions'; -import type { AdminUserTab } from './AdminUsersPage'; +import type { AdminUsersTab } from './AdminUsersPage'; type AdminUserInfoWithDataProps = { uid: IUser['_id']; onReload: () => void; - tab: AdminUserTab; + tab: AdminUsersTab; }; const AdminUserInfoWithData = ({ uid, onReload, tab }: AdminUserInfoWithDataProps): ReactElement => { diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index 56641f8959d0..78950c0fe22a 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -39,9 +39,9 @@ export type UsersFilters = { roles: OptionProp[]; }; -export type AdminUserTab = 'all' | 'active' | 'deactivated' | 'pending'; +export type AdminUsersTab = 'all' | 'active' | 'deactivated' | 'pending'; -export type UsersTableSortingOptions = 'name' | 'username' | 'emails.address' | 'status' | 'active'; +export type UsersTableSortingOption = 'name' | 'username' | 'emails.address' | 'status' | 'active' | 'freeSwitchExtension'; const AdminUsersPage = (): ReactElement => { const t = useTranslation(); @@ -65,9 +65,9 @@ const AdminUsersPage = (): ReactElement => { const { data, error } = useQuery(['roles'], async () => getRoles()); const paginationData = usePagination(); - const sortData = useSort('name'); + const sortData = useSort('name'); - const [tab, setTab] = useState('all'); + const [tab, setTab] = useState('all'); const [userFilters, setUserFilters] = useState({ text: '', roles: [] }); const searchTerm = useDebouncedValue(userFilters.text, 500); @@ -89,7 +89,7 @@ const AdminUsersPage = (): ReactElement => { filteredUsersQueryResult?.refetch(); }; - const handleTabChange = (tab: AdminUserTab) => { + const handleTabChange = (tab: AdminUsersTab) => { setTab(tab); paginationData.setCurrent(0); diff --git a/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.spec.tsx b/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.spec.tsx new file mode 100644 index 000000000000..b11ca46ce40d --- /dev/null +++ b/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.spec.tsx @@ -0,0 +1,24 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import '@testing-library/jest-dom'; + +import UserPageHeaderContent from './UserPageHeaderContentWithSeatsCap'; + +it('should render "Associate Extension" button when VoIP_TeamCollab_Enabled setting is enabled', async () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot().withJohnDoe().withSetting('VoIP_TeamCollab_Enabled', true).build(), + }); + + expect(screen.getByRole('button', { name: 'Assign_extension' })).toBeEnabled(); +}); + +it('should not render "Associate Extension" button when VoIP_TeamCollab_Enabled setting is disabled', async () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot().withJohnDoe().withSetting('VoIP_TeamCollab_Enabled', false).build(), + }); + + expect(screen.queryByRole('button', { name: 'Assign_extension' })).not.toBeInTheDocument(); +}); diff --git a/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx b/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx index 3000b4f51a5a..a023f5229816 100644 --- a/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx +++ b/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx @@ -1,11 +1,12 @@ import { Button, ButtonGroup, Margins } from '@rocket.chat/fuselage'; -import { useTranslation, useRouter } from '@rocket.chat/ui-contexts'; +import { useSetModal, useTranslation, useRouter, useSetting } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; import { useExternalLink } from '../../../hooks/useExternalLink'; import { useCheckoutUrl } from '../subscription/hooks/useCheckoutUrl'; import SeatsCapUsage from './SeatsCapUsage'; +import AssignExtensionModal from './voip/AssignExtensionModal'; type UserPageHeaderContentWithSeatsCapProps = { activeUsers: number; @@ -20,6 +21,9 @@ const UserPageHeaderContentWithSeatsCap = ({ }: UserPageHeaderContentWithSeatsCapProps): ReactElement => { const t = useTranslation(); const router = useRouter(); + const setModal = useSetModal(); + + const canRegisterExtension = useSetting('VoIP_TeamCollab_Enabled'); const manageSubscriptionUrl = useCheckoutUrl()({ target: 'user-page', action: 'buy_more' }); const openExternalLink = useExternalLink(); @@ -38,6 +42,11 @@ const UserPageHeaderContentWithSeatsCap = ({ + {canRegisterExtension && ( + + )} diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.spec.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.spec.tsx new file mode 100644 index 000000000000..a1a572ad23a6 --- /dev/null +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.spec.tsx @@ -0,0 +1,98 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { createFakeUser } from '../../../../../tests/mocks/data'; +import UsersTable from './UsersTable'; + +const createFakeAdminUser = (freeSwitchExtension?: string) => + createFakeUser({ + active: true, + roles: ['admin'], + type: 'user', + freeSwitchExtension, + }); + +it('should not render "Voice call extension" column when voice call is disabled', async () => { + const user = createFakeAdminUser('1000'); + + render( + undefined} + tab='all' + onReload={() => undefined} + paginationData={{} as any} + sortData={{} as any} + isSeatsCapExceeded={false} + roleData={undefined} + />, + { + legacyRoot: true, + wrapper: mockAppRoot().withUser(user).withSetting('VoIP_TeamCollab_Enabled', false).build(), + }, + ); + + expect(screen.queryByText('Voice_call_extension')).not.toBeInTheDocument(); + + screen.getByRole('button', { name: 'More_actions' }).click(); + expect(await screen.findByRole('listbox')).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Assign_extension/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Unassign_extension/ })).not.toBeInTheDocument(); +}); + +it('should render "Unassign_extension" button when user has a associated extension', async () => { + const user = createFakeAdminUser('1000'); + + render( + undefined} + tab='all' + onReload={() => undefined} + paginationData={{} as any} + sortData={{} as any} + isSeatsCapExceeded={false} + roleData={undefined} + />, + { + legacyRoot: true, + wrapper: mockAppRoot().withUser(user).withSetting('VoIP_TeamCollab_Enabled', true).build(), + }, + ); + + expect(screen.getByText('Voice_call_extension')).toBeInTheDocument(); + + screen.getByRole('button', { name: 'More_actions' }).click(); + expect(await screen.findByRole('listbox')).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Assign_extension/ })).not.toBeInTheDocument(); + expect(screen.getByRole('option', { name: /Unassign_extension/ })).toBeInTheDocument(); +}); + +it('should render "Assign_extension" button when user has no associated extension', async () => { + const user = createFakeAdminUser(); + + render( + undefined} + tab='all' + onReload={() => undefined} + paginationData={{} as any} + sortData={{} as any} + isSeatsCapExceeded={false} + roleData={undefined} + />, + { + legacyRoot: true, + wrapper: mockAppRoot().withUser(user).withSetting('VoIP_TeamCollab_Enabled', true).build(), + }, + ); + + expect(screen.getByText('Voice_call_extension')).toBeInTheDocument(); + + screen.getByRole('button', { name: 'More_actions' }).click(); + expect(await screen.findByRole('listbox')).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /Assign_extension/ })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Unassign_extension/ })).not.toBeInTheDocument(); +}); diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index abdf8cb787c3..531669ca584a 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -3,7 +3,7 @@ import { Pagination } from '@rocket.chat/fuselage'; import { useEffectEvent, useBreakpoints } from '@rocket.chat/fuselage-hooks'; import type { PaginatedResult, DefaultUserInfo } from '@rocket.chat/rest-typings'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; +import { useRouter, useTranslation, useSetting } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import type { ReactElement, Dispatch, SetStateAction } from 'react'; import React, { useMemo } from 'react'; @@ -18,18 +18,18 @@ import { } from '../../../../components/GenericTable'; import type { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; import type { useSort } from '../../../../components/GenericTable/hooks/useSort'; -import type { AdminUserTab, UsersFilters, UsersTableSortingOptions } from '../AdminUsersPage'; +import type { AdminUsersTab, UsersFilters, UsersTableSortingOption } from '../AdminUsersPage'; import UsersTableFilters from './UsersTableFilters'; import UsersTableRow from './UsersTableRow'; type UsersTableProps = { - tab: AdminUserTab; + tab: AdminUsersTab; roleData: { roles: IRole[] } | undefined; onReload: () => void; setUserFilters: Dispatch>; filteredUsersQueryResult: UseQueryResult[] }>>; paginationData: ReturnType; - sortData: ReturnType>; + sortData: ReturnType>; isSeatsCapExceeded: boolean; }; @@ -49,6 +49,7 @@ const UsersTable = ({ const isMobile = !breakpoints.includes('xl'); const isLaptop = !breakpoints.includes('xxl'); + const isVoIPEnabled = useSetting('VoIP_TeamCollab_Enabled') || false; const { data, isLoading, isError, isSuccess } = filteredUsersQueryResult; @@ -111,9 +112,21 @@ const UsersTable = ({ {t('Pending_action')} ), - , + tab === 'all' && isVoIPEnabled && ( + + {t('Voice_call_extension')} + + ), + , ], - [isLaptop, isMobile, setSort, sortBy, sortDirection, t, tab], + [isLaptop, isMobile, setSort, sortBy, sortDirection, t, tab, isVoIPEnabled], ); return ( @@ -156,6 +169,7 @@ const UsersTable = ({ onReload={onReload} tab={tab} isSeatsCapExceeded={isSeatsCapExceeded} + showVoipExtension={isVoIPEnabled} /> ))} diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index c8fa1eae7704..8dc5b4472e1b 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -7,16 +7,17 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Roles } from '../../../../../app/models/client'; +import { Roles } from '../../../../../app/models/client/models/Roles'; import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable'; import { UserStatus } from '../../../../components/UserStatus'; -import type { AdminUserTab } from '../AdminUsersPage'; +import type { AdminUsersTab } from '../AdminUsersPage'; import { useChangeAdminStatusAction } from '../hooks/useChangeAdminStatusAction'; import { useChangeUserStatusAction } from '../hooks/useChangeUserStatusAction'; import { useDeleteUserAction } from '../hooks/useDeleteUserAction'; import { useResetE2EEKeyAction } from '../hooks/useResetE2EEKeyAction'; import { useResetTOTPAction } from '../hooks/useResetTOTPAction'; import { useSendWelcomeEmailMutation } from '../hooks/useSendWelcomeEmailMutation'; +import { useVoipExtensionAction } from '../hooks/useVoipExtensionAction'; type UsersTableRowProps = { user: Serialized; @@ -24,14 +25,24 @@ type UsersTableRowProps = { isMobile: boolean; isLaptop: boolean; onReload: () => void; - tab: AdminUserTab; + tab: AdminUsersTab; isSeatsCapExceeded: boolean; + showVoipExtension: boolean; }; -const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSeatsCapExceeded }: UsersTableRowProps): ReactElement => { +const UsersTableRow = ({ + user, + onClick, + onReload, + isMobile, + isLaptop, + tab, + isSeatsCapExceeded, + showVoipExtension, +}: UsersTableRowProps): ReactElement => { const { t } = useTranslation(); - const { _id, emails, username, name, roles, status, active, avatarETag, lastLogin, type } = user; + const { _id, emails, username = '', name = '', roles, status, active, avatarETag, lastLogin, type, freeSwitchExtension } = user; const registrationStatusText = useMemo(() => { const usersExcludedFromPending = ['bot', 'app']; @@ -64,10 +75,17 @@ const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSea const resetTOTPAction = useResetTOTPAction(userId); const resetE2EKeyAction = useResetE2EEKeyAction(userId); const resendWelcomeEmail = useSendWelcomeEmailMutation(); + const voipExtensionAction = useVoipExtensionAction({ extension: freeSwitchExtension, username, name }); const isNotPendingDeactivatedNorFederated = tab !== 'pending' && tab !== 'deactivated' && !isFederatedUser; const menuOptions = useMemo( () => ({ + ...(voipExtensionAction && { + voipExtensionAction: { + label: { label: voipExtensionAction.label, icon: voipExtensionAction.icon }, + action: voipExtensionAction.action, + }, + }), ...(isNotPendingDeactivatedNorFederated && changeAdminStatusAction && { makeAdmin: { @@ -102,6 +120,7 @@ const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSea isNotPendingDeactivatedNorFederated, resetE2EKeyAction, resetTOTPAction, + voipExtensionAction, ], ); @@ -154,6 +173,12 @@ const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSea )} + {tab === 'all' && showVoipExtension && username && ( + + {freeSwitchExtension || t('Not_assigned')} + + )} + { e.stopPropagation(); @@ -179,6 +204,8 @@ const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSea placement='bottom-start' flexShrink={0} key='menu' + aria-label={t('More_actions')} + title={t('More_actions')} renderItem={({ label: { label, icon }, ...props }): ReactElement => (