diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 58634287ed865..c5d775921a5e3 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -142,6 +142,7 @@ "@rocket.chat/password-policies": "workspace:^", "@rocket.chat/patch-injection": "workspace:^", "@rocket.chat/pdf-worker": "workspace:^", + "@rocket.chat/pexip": "workspace:^", "@rocket.chat/poplib": "workspace:^", "@rocket.chat/presence": "workspace:^", "@rocket.chat/random": "workspace:^", diff --git a/apps/meteor/server/api/api.ts b/apps/meteor/server/api/api.ts index 87387c579a4b4..8a94b82d2595f 100644 --- a/apps/meteor/server/api/api.ts +++ b/apps/meteor/server/api/api.ts @@ -43,6 +43,7 @@ export const API: { api: Router<'/api', any, APIActionHandler>; v1: APIClass<'/v1'>; default: APIClass; + pexip: APIClass; ApiClass: typeof APIClass; channels?: { create: { @@ -74,6 +75,9 @@ export const API: { useDefaultAuth: true, }), default: createApi({}), + pexip: createApi({ + version: 'pexip', + }), }; settings.watch('Accounts_CustomFields', (value) => { @@ -118,6 +122,7 @@ export const startRestAPI = () => { .use(cors(settings)) .use(loggerMiddleware(logger)) .use(API.v1.router) + .use(API.pexip.router) .use(API.default.router).router, ); }; diff --git a/apps/meteor/server/api/index.ts b/apps/meteor/server/api/index.ts index 7f6720c661537..a5bfd204206d4 100644 --- a/apps/meteor/server/api/index.ts +++ b/apps/meteor/server/api/index.ts @@ -45,6 +45,7 @@ import './v1/mailer'; import './v1/teams'; import './v1/moderation'; import './v1/uploads'; +import './pexip'; // This has to come last so all endpoints are registered before generating the OpenAPI documentation import './default/openApi'; diff --git a/apps/meteor/server/api/pexip/eventSink.ts b/apps/meteor/server/api/pexip/eventSink.ts new file mode 100644 index 0000000000000..fc6b6a0f3bd17 --- /dev/null +++ b/apps/meteor/server/api/pexip/eventSink.ts @@ -0,0 +1,47 @@ +import { isEventSinkRequestProps, Pexip } from '@rocket.chat/pexip'; +import { ajv, validateNotFoundErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; + +import { getPexipSettings } from '../../settings/pexip'; +import { API } from '../api'; + +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +API.pexip.post( + 'events', + { + response: { + 200: successResponseSchema, + 401: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + body: isEventSinkRequestProps, + authRequired: false, + rateLimiterOptions: false, + }, + async function action() { + const { + bodyParams: event, + request: { headers }, + } = this; + + const settings = getPexipSettings(); + const pexip = new Pexip(settings); + + try { + const authHeader = headers.get('authorization'); + + pexip.validateRequestCredentials(authHeader); + } catch (err) { + return API.pexip.unauthorized(); + } + + void pexip.processEvent(event).catch(() => true); + + return API.pexip.success(); + }, +); diff --git a/apps/meteor/server/api/pexip/index.ts b/apps/meteor/server/api/pexip/index.ts new file mode 100644 index 0000000000000..390587d4df845 --- /dev/null +++ b/apps/meteor/server/api/pexip/index.ts @@ -0,0 +1,2 @@ +import './eventSink'; +import './policyServer'; diff --git a/apps/meteor/server/api/pexip/policyServer.ts b/apps/meteor/server/api/pexip/policyServer.ts new file mode 100644 index 0000000000000..db67b0f0f500f --- /dev/null +++ b/apps/meteor/server/api/pexip/policyServer.ts @@ -0,0 +1,50 @@ +import { isServiceConfigurationRequestProps, isPolicyServerResponse, Pexip } from '@rocket.chat/pexip'; +import { validateNotFoundErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; + +import { getPexipSettings } from '../../settings/pexip'; +import { API } from '../api'; + +API.pexip.get( + 'policy/v1/service/configuration', + { + response: { + 200: isPolicyServerResponse, + 401: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + query: isServiceConfigurationRequestProps, + authRequired: false, + rateLimiterOptions: false, + }, + async function action() { + const { + queryParams: serviceRequest, + request: { headers }, + } = this; + + const settings = getPexipSettings(); + const pexip = new Pexip(settings); + + try { + const authHeader = headers.get('authorization'); + + pexip.validateRequestCredentials(authHeader); + } catch (err) { + return API.pexip.unauthorized(); + } + + const result = await pexip.getServiceConfiguration(serviceRequest); + if (!result) { + return API.pexip.notFound(); + } + + const status = 'success' as const; + const action = 'continue' as const; + + return API.pexip.success({ + status, + action, + result, + }); + }, +); diff --git a/apps/meteor/server/lib/videoConfProviders.ts b/apps/meteor/server/lib/videoConfProviders.ts index 74374822e0527..42f172d17f67b 100644 --- a/apps/meteor/server/lib/videoConfProviders.ts +++ b/apps/meteor/server/lib/videoConfProviders.ts @@ -1,9 +1,13 @@ import type { VideoConferenceCapabilities } from '@rocket.chat/core-typings'; +import { Pexip, PexipVideoConfProvider } from '@rocket.chat/pexip'; import { settings } from '../../app/settings/server'; +import { getPexipSettings } from '../settings/pexip'; const providers = new Map(); +type Provider = { key: string; label: string }; + export const videoConfProviders = { registerProvider(providerName: string, capabilities: VideoConferenceCapabilities, appId: string): void { providers.set(providerName.toLowerCase(), { capabilities, label: providerName, appId }); @@ -18,17 +22,20 @@ export const videoConfProviders = { }, getActiveProvider(): string | undefined { - if (providers.size === 0) { - return; - } - const defaultProvider = settings.get('VideoConf_Default_Provider'); + if (providers.size !== 0) { + const defaultProvider = settings.get('VideoConf_Default_Provider'); + + if (defaultProvider) { + if (this.isProviderAvailable(defaultProvider)) { + return defaultProvider; + } - if (defaultProvider) { - if (providers.has(defaultProvider)) { - return defaultProvider; + return; } + } - return; + if (this.isProviderAvailable('core.pexip')) { + return 'core.pexip'; } if (providers.size === 1) { @@ -38,18 +45,56 @@ export const videoConfProviders = { }, hasAnyProvider(): boolean { - return providers.size > 0; + return providers.size > 0 || settings.get('Pexip_Integration_Enabled'); }, - getProviderList(): { key: string; label: string }[] { + getRegisteredProviders(): Provider[] { return [...providers.keys()].map((key) => ({ key, label: providers.get(key)?.label || key })); }, + getInternalProviders(): Provider[] { + const pexip = this.getPexipProvider(); + + if (pexip) { + return [pexip]; + } + + return []; + }, + + getPexipProvider(): Provider | null { + if (!settings.get('Pexip_Integration_Enabled')) { + return null; + } + + return { key: 'core.pexip', label: 'Pexip_Integration' }; + }, + + getAllProviders(): Provider[] { + const registeredProviders = [...providers.keys()].map((key) => ({ key, label: providers.get(key)?.label || key })); + const internalProviders = this.getInternalProviders(); + + return [...registeredProviders, ...internalProviders]; + }, + isProviderAvailable(name: string): boolean { + if (name === 'core.pexip') { + return settings.get('Pexip_Integration_Enabled'); + } + return providers.has(name); }, getProviderCapabilities(name: string): VideoConferenceCapabilities | undefined { + if (name === 'core.pexip') { + return { + mic: false, + cam: false, + title: true, + persistentChat: true, + }; + } + const key = name.toLowerCase(); if (!providers.has(key)) { return; @@ -60,6 +105,9 @@ export const videoConfProviders = { getProviderAppId(name: string): string | undefined { const key = name.toLowerCase(); + if (key === 'core.pexip') { + return undefined; + } if (!providers.has(key)) { return; @@ -67,4 +115,19 @@ export const videoConfProviders = { return providers.get(key)?.appId; }, + + getVideoConfProviderHandler(providerName: string): PexipVideoConfProvider | null { + if (providerName === 'core.pexip') { + return this.getPexipHandler(); + } + + return null; + }, + + getPexipHandler() { + const pexipSettings = getPexipSettings(); + + const pexip = new Pexip(pexipSettings); + return new PexipVideoConfProvider(pexip); + }, }; diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 7c49214f31b46..8c491f8435072 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -198,7 +198,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } } - const blocks = await (await this.getProviderManager()).getVideoConferenceInfo(call.providerName, call, user || undefined).catch((e) => { + const blocks = await this.getBlocks(call.providerName, call, user || undefined).catch((e) => { throw new Error(e); }); @@ -218,6 +218,15 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf ]; } + private async getBlocks(providerName: string, call: any, user?: any) { + const provider = videoConfProviders.getVideoConfProviderHandler(providerName); + if (provider) { + return provider.getVideoConferenceInfo(call, user); + } + + return (await this.getProviderManager()).getVideoConferenceInfo(call.providerName, call, user || undefined); + } + public async cancel(uid: IUser['_id'], callId: VideoConference['_id']): Promise { const call = await VideoConferenceModel.findOneById(callId); if (!call || !isDirectVideoConference(call)) { @@ -339,7 +348,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } public async listProviders(): Promise<{ key: string; label: string }[]> { - return videoConfProviders.getProviderList(); + return videoConfProviders.getAllProviders(); } public async listProviderCapabilities(providerName: string): Promise { @@ -579,13 +588,24 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } private async validateProvider(providerName: string): Promise { - const manager = await this.getProviderManager(); - const configured = await manager.isFullyConfigured(providerName).catch(() => false); + const configured = await this.isFullyConfigured(providerName); if (!configured) { throw new Error(availabilityErrors.NOT_CONFIGURED); } } + private async isFullyConfigured(providerName: string): Promise { + const provider = videoConfProviders.getVideoConfProviderHandler(providerName); + if (provider) { + return provider.isFullyConfigured(); + } + + const manager = await this.getProviderManager(); + const configured = await manager.isFullyConfigured(providerName).catch(() => false); + + return configured; + } + private async getValidatedProvider(): Promise { if (!videoConfProviders.hasAnyProvider()) { throw new Error(availabilityErrors.NO_APP); @@ -923,6 +943,12 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-provider-unavailable'); } + const provider = videoConfProviders.getVideoConfProviderHandler(call.providerName); + if (provider) { + // TODO: compensate for the getRoomName? + return provider.generateUrl(call); + } + const title = isGroupVideoConference(call) ? call.title || (await this.getRoomName(call.rid)) : ''; const callData: VideoConfData = { _id: call._id, @@ -978,7 +1004,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf private async getUrl( call: ExternalVideoConference, - user?: AtLeast, + user?: AtLeast, options: VideoConferenceJoinOptions = {}, ): Promise { if (!videoConfProviders.isProviderAvailable(call.providerName)) { @@ -990,6 +1016,20 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await VideoConferenceModel.setUrlById(call._id, call.url); } + const userData = user && { + _id: user._id, + username: user.username as string, + name: user.name as string, + avatarETag: user.avatarETag || null, + ts: new Date(), + }; + + const provider = videoConfProviders.getVideoConfProviderHandler(call.providerName); + if (provider) { + // TODO: compensate for the call title? + return provider.customizeUrl(call, userData); + } + const callData: VideoConfDataExtended = { _id: call._id, type: call.type, @@ -1004,12 +1044,6 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf discussionRid: call.discussionRid, }; - const userData = user && { - _id: user._id, - username: user.username as string, - name: user.name as string, - }; - return (await this.getProviderManager()).customizeUrl(call.providerName, callData, userData, options); } @@ -1028,6 +1062,11 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-provider-unavailable'); } + const provider = videoConfProviders.getVideoConfProviderHandler(call.providerName); + if (provider) { + return provider.onNewVideoConference(call); + } + return (await this.getProviderManager()).onNewVideoConference(call.providerName, call); } @@ -1046,6 +1085,11 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-provider-unavailable'); } + const provider = videoConfProviders.getVideoConfProviderHandler(call.providerName); + if (provider) { + return; + } + return (await this.getProviderManager()).onVideoConferenceChanged(call.providerName, call); } @@ -1064,6 +1108,11 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-provider-unavailable'); } + const provider = videoConfProviders.getVideoConfProviderHandler(call.providerName); + if (provider) { + return; + } + return (await this.getProviderManager()).onUserJoin(call.providerName, call, user); } diff --git a/apps/meteor/server/settings/index.ts b/apps/meteor/server/settings/index.ts index 83d341552e634..f6a45db37d8cf 100644 --- a/apps/meteor/server/settings/index.ts +++ b/apps/meteor/server/settings/index.ts @@ -23,6 +23,7 @@ import { createMiscSettings } from './misc'; import { createMobileSettings } from './mobile'; import { createOauthSettings } from './oauth'; import { createOmniSettings } from './omnichannel'; +import { createPexipSettings } from './pexip'; import { createPushSettings } from './push'; import { createRateLimitSettings } from './rate'; import { createRetentionSettings } from './retention-policy'; @@ -61,6 +62,7 @@ await Promise.all([ createMobileSettings(), createOauthSettings(), createOmniSettings(), + createPexipSettings(), createPushSettings(), createRateLimitSettings(), createRetentionSettings(), diff --git a/apps/meteor/server/settings/pexip.ts b/apps/meteor/server/settings/pexip.ts new file mode 100644 index 0000000000000..9684aaa35ae19 --- /dev/null +++ b/apps/meteor/server/settings/pexip.ts @@ -0,0 +1,130 @@ +import type { PexipLayout, PexipSettings } from '@rocket.chat/pexip'; + +import { settingsRegistry, settings } from '../../app/settings/server'; + +export function createPexipSettings(): Promise { + return settingsRegistry.addGroup('Pexip_Integration', async function () { + await this.add('Pexip_Integration_Enabled', false, { + type: 'boolean', + public: true, + invalidValue: false, + i18nDescription: `Pexip_Integration_Enabled_Description`, + }); + + await this.add('Pexip_Integration_Base_Url', '', { + type: 'string', + public: true, + invalidValue: '', + i18nDescription: `Pexip_Integration_Base_Url_Description`, + }); + + await this.add('Pexip_Integration_Meeting_Url', '/webapp/conference?conference={callId}', { + type: 'string', + public: true, + invalidValue: '', + i18nDescription: `Pexip_Integration_Meeting_Url_Description`, + }); + + await this.section('Pexip_Integration_API', async function () { + await this.add('Pexip_Integration_API_Username', '', { + type: 'string', + public: false, + invalidValue: '', + i18nDescription: `Pexip_Integration_API_Username_Description`, + }); + + await this.add('Pexip_Integration_API_Password', '', { + type: 'password', + public: false, + invalidValue: '', + i18nDescription: `Pexip_Integration_API_Password_Description`, + }); + }); + + await this.section('Pexip_Integration_Pins', async function () { + await this.add('Pexip_Integration_Host_Pin', '', { + type: 'string', + public: false, + invalidValue: '', + i18nDescription: `Pexip_Integration_Host_Pin_Description`, + }); + + await this.add('Pexip_Integration_Guest_Pin', '', { + type: 'string', + public: false, + invalidValue: '', + i18nDescription: `Pexip_Integration_Guest_Pin_Description`, + }); + }); + + await this.section('Pexip_Integration_Customization', async function () { + await this.add('Pexip_Integration_Theme_Name', 'rocket.chat', { + type: 'string', + public: true, + invalidValue: '', + i18nDescription: `Pexip_Integration_Theme_Name_Description`, + }); + + await this.add('Pexip_Integration_Locked', true, { + type: 'boolean', + public: true, + invalidValue: true, + i18nDescription: `Pexip_Integration_Locked_Description`, + }); + + await this.add('Pexip_Integration_Overlay_Text', true, { + type: 'boolean', + public: true, + invalidValue: true, + i18nDescription: `Pexip_Integration_Overlay_Text_Description`, + }); + + await this.add('Pexip_Integration_Meeting_Layout', 'one_main_seven_pips', { + type: 'select', + public: true, + invalidValue: 'one_main_seven_pips', + i18nDescription: `Pexip_Integration_Meeting_Layout_Description`, + values: [ + { key: 'five_mains_seven_pips', i18nLabel: 'Pexip_Integration_Meeting_Layout_five_mains_seven_pips' }, + { key: 'one_main_zero_pips', i18nLabel: 'Pexip_Integration_Meeting_Layout_one_main_zero_pips' }, + { key: 'one_main_seven_pips', i18nLabel: 'Pexip_Integration_Meeting_Layout_one_main_seven_pips' }, + { key: 'one_main_twentyone_pips', i18nLabel: 'Pexip_Integration_Meeting_Layout_one_main_twentyone_pips' }, + { key: 'one_main_thirtythree_pips', i18nLabel: 'Pexip_Integration_Meeting_Layout_one_main_thirtythree_pips' }, + { key: 'two_mains_twentyone_pips', i18nLabel: 'Pexip_Integration_Meeting_Layout_two_mains_twentyone_pips' }, + { key: 'four_mains_zero_pips', i18nLabel: 'Pexip_Integration_Meeting_Layout_four_mains_zero_pips' }, + { key: 'nine_mains_zero_pips', i18nLabel: 'Pexip_Integration_Meeting_Layout_nine_mains_zero_pips' }, + { key: 'sixteen_mains_zero_pips', i18nLabel: 'Pexip_Integration_Meeting_Layout_sixteen_mains_zero_pips' }, + { key: 'twentyfive_mains_zero_pips', i18nLabel: 'Pexip_Integration_Meeting_Layout_twentyfive_mains_zero_pips' }, + ], + }); + }); + }); +} + +export function getPexipSettings(): PexipSettings { + return { + enabled: settings.get('Pexip_Integration_Enabled'), + baseUrl: settings.get('Pexip_Integration_Base_Url'), + meetingUrl: settings.get('Pexip_Integration_Meeting_Url'), + api: { + username: settings.get('Pexip_Integration_API_Username'), + password: settings.get('Pexip_Integration_API_Password'), + }, + pins: { + host: settings.get('Pexip_Integration_Host_Pin'), + guest: settings.get('Pexip_Integration_Guest_Pin'), + }, + customization: { + themeName: settings.get('Pexip_Integration_Theme_Name'), + locked: settings.get('Pexip_Integration_Locked'), + overlayText: settings.get('Pexip_Integration_Overlay_Text'), + meetingLayout: settings.get('Pexip_Integration_Meeting_Layout'), + }, + + workspace: { + siteUrl: settings.get('Site_Url'), + discussionsEnabled: settings.get('Discussion_enabled'), + persistentChatEnabled: settings.get('VideoConf_Enable_Persistent_Chat'), + }, + }; +} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 7827b6c0eb75f..5be442aeb1952 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4211,6 +4211,42 @@ "Personal_Access_Tokens": "Personal Access Tokens", "Pexip_Enterprise_only": "Pexip (Enterprise only)", "Pexip_Premium_only": "Pexip (Premium only)", + "Pexip_Integration": "Pexip Integration", + "Pexip_Integration_API": "API", + "Pexip_Integration_API_Username": "API Username", + "Pexip_Integration_API_Username_Description": "If specified, Rocket.Chat will ignore any API calls that do not include this username and password.", + "Pexip_Integration_API_Password": "API Password", + "Pexip_Integration_API_Password_Description": "The password to use along with the API Username.", + "Pexip_Integration_Base_Url": "Base URL", + "Pexip_Integration_Base_Url_Description": "The URL where Pexip is running.", + "Pexip_Integration_Customization": "Customization", + "Pexip_Integration_Enabled": "Enable Pexip Integration", + "Pexip_Integration_Enabled_Description": "When enabled, Pexip will be registered as a Conference Call provider. You may need to set it as your default provider on the conference settings if you have multiple providers.", + "Pexip_Integration_Guest_Pin": "Static Guest Pin", + "Pexip_Integration_Guest_Pin_Description": "A pin code to grant meeting access to guests. Leave empty to generate a new unique pin for every meeting.", + "Pexip_Integration_Host_Pin": "Static Host Pin", + "Pexip_Integration_Host_Pin_Description": "A pin code to identify meeting hosts. Leave empty to generate a new unique pin for every meeting.", + "Pexip_Integration_Locked": "Lock Conferences", + "Pexip_Integration_Locked_Description": "When enabled, call owners must accept each participant.", + "Pexip_Integration_Meeting_Layout": "Meeting layout", + "Pexip_Integration_Meeting_Layout_Description": "Changes meeting layout only. Does not limit total number of call participants.", + "Pexip_Integration_Meeting_Layout_five_mains_seven_pips": "Adaptive Composition layout", + "Pexip_Integration_Meeting_Layout_one_main_zero_pips": "Show 1 main speaker", + "Pexip_Integration_Meeting_Layout_one_main_seven_pips": "Show 1 main speaker and up to 7 participants", + "Pexip_Integration_Meeting_Layout_one_main_twentyone_pips": "Show 1 main speaker and up to 21 participants", + "Pexip_Integration_Meeting_Layout_one_main_thirtythree_pips": "Show 1 main speaker and up to 33 participants", + "Pexip_Integration_Meeting_Layout_two_mains_twentyone_pips": "Show 2 main speakers and up to 21 participants", + "Pexip_Integration_Meeting_Layout_four_mains_zero_pips": "2 x 2 layout, up to 4 speakers", + "Pexip_Integration_Meeting_Layout_nine_mains_zero_pips": "3 x 3 layout, up to 9 speakers", + "Pexip_Integration_Meeting_Layout_sixteen_mains_zero_pips": "4 x 4 layout, up to 16 speakers", + "Pexip_Integration_Meeting_Layout_twentyfive_mains_zero_pips": "5 x 5 layout, up to 25 speakers", + "Pexip_Integration_Meeting_Url": "Meeting URL", + "Pexip_Integration_Meeting_Url_Description": "The path added to the base URL to form the full meeting URL.", + "Pexip_Integration_Overlay_Text": "Participant name overlay text", + "Pexip_Integration_Overlay_Text_Description": "The display names or aliases of all participants are shown in a text overlay along the bottom of their video image.", + "Pexip_Integration_Pins": "Static Pins", + "Pexip_Integration_Theme_Name": "Theme Name", + "Pexip_Integration_Theme_Name_Description": "Name of the pexip theme to be used on Rocket.Chat calls", "Pharmaceutical": "Pharmaceutical", "Phone": "Phone", "Phone_Number": "Phone Number", diff --git a/packages/pexip/jest.config.ts b/packages/pexip/jest.config.ts new file mode 100644 index 0000000000000..2dcf9d55723f1 --- /dev/null +++ b/packages/pexip/jest.config.ts @@ -0,0 +1,7 @@ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { + preset: server.preset, + testMatch: ['/tests/**/*.spec.(ts|js|mjs)'], +} satisfies Config; diff --git a/packages/pexip/package.json b/packages/pexip/package.json new file mode 100644 index 0000000000000..e1ebb8ee4a1c2 --- /dev/null +++ b/packages/pexip/package.json @@ -0,0 +1,34 @@ +{ + "name": "@rocket.chat/pexip", + "version": "0.0.1", + "private": true, + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "/dist" + ], + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@rocket.chat/core-services": "workspace:^", + "@rocket.chat/logger": "workspace:^", + "@rocket.chat/models": "workspace:^", + "ajv": "^8.17.1" + }, + "devDependencies": { + "@rocket.chat/jest-presets": "workspace:~", + "@rocket.chat/tsconfig": "workspace:*", + "@types/jest": "~30.0.0", + "eslint": "~9.39.4", + "jest": "~30.2.0", + "typescript": "~5.9.3" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/pexip/src/Pexip.ts b/packages/pexip/src/Pexip.ts new file mode 100644 index 0000000000000..50b217937bcf3 --- /dev/null +++ b/packages/pexip/src/Pexip.ts @@ -0,0 +1,132 @@ +import crypto from 'crypto'; + +import type { IRoom, IUser, VideoConference, IVideoConferenceUser } from '@rocket.chat/core-typings'; +import { VideoConference as VideoConferenceModel } from '@rocket.chat/models'; + +import type { EventSinkRequest, SerializedServiceConfigurationRequest, ServiceConfiguration } from './definition'; +import type { PexipSettings } from './definition/PexipSettings'; +import { ServerConfigurationEndpoint } from './endpoints'; +import { EventSinkEndpoint } from './endpoints/eventSink'; +import { logger } from './logger'; + +export class Pexip { + constructor(readonly settings: PexipSettings) { + // + } + + public validateRequestCredentials(authHeader?: string | null): void { + const { api } = this.settings; + + if (!api.username) { + return; + } + + if (!authHeader?.startsWith('Basic ')) { + throw new Error('Unauthorized'); + } + + const authorization = authHeader.replace('Basic ', ''); + + const credentials = Buffer.from(authorization, 'base64').toString('ascii'); + const [username, password] = credentials.split(':'); + + if (username !== api.username || password !== api.password) { + throw new Error('Unauthorized'); + } + } + + public getServiceConfiguration(serviceRequest: SerializedServiceConfigurationRequest): Promise { + const serviceConfiguration = new ServerConfigurationEndpoint(this); + return serviceConfiguration.get(serviceRequest); + } + + public async processEvent(event: EventSinkRequest): Promise { + try { + logger.debug({ msg: 'Processing Event from Pexip Event Sink', event }); + const eventSink = new EventSinkEndpoint(this); + await eventSink.post(event); + } catch (err) { + logger.error({ msg: 'Failed to process event sink notification', err }); + } + } + + public async createAndStorePinsForCall(call: VideoConference): Promise<[string, string]> { + if (call.providerData?.hostPin !== undefined && call.providerData.guestPin !== undefined) { + return [call.providerData.hostPin, call.providerData.guestPin]; + } + + const [hostPin, guestPin] = await this.createPinsForCall(call); + + const providerData = { hostPin, guestPin, ...(call.providerData || {}) }; + await VideoConferenceModel.setProviderDataById(call._id, providerData); + + return [hostPin, guestPin]; + } + + public async createPinsForCall(call: VideoConference | undefined, room?: IRoom, user?: IUser): Promise<[string, string]> { + // If we have pins saved in the call data, reuse them. + if (call?.providerData?.hostPin !== undefined && call.providerData.guestPin !== undefined) { + return [call.providerData.hostPin, call.providerData.guestPin]; + } + + const hostPin = await this.createHostPin(call, room, user); + const guestPin = this.createGuestPin(call); + + return [hostPin, guestPin]; + } + + public async isUserCallHost(call: VideoConference, user?: IVideoConferenceUser): Promise { + if (!user) { + return false; + } + + return user._id === call.createdBy._id; + } + + public getPinFromString(identifier: string): string { + const hash = crypto.createHash('sha256').update(identifier).digest('hex'); + return this.getPinFromHash(hash); + } + + public async createHostPin(call: VideoConference | undefined, room: IRoom | undefined, user: IUser | undefined): Promise { + const { pins } = this.settings; + + if (pins.host) { + return pins.host; + } + + // TODO: secure pins + + if (call) { + return this.getPinFromString(`${call._id}${call.createdBy._id}`); + } + + if (room) { + return this.getPinFromString(`${room._id}_hosts`); + } + + if (user) { + return this.getPinFromString(`${user._id}_host`); + } + + return ''; + } + + public createGuestPin(call: VideoConference | undefined): string { + const { pins } = this.settings; + + if (pins.guest) { + return pins.guest; + } + + if (call) { + return this.getPinFromString(`${call._id}${call.rid}`); + } + + return ''; + } + + public getPinFromHash(hash: string): string { + return String(BigInt(`0x${hash}`)).slice(-6); + } +} diff --git a/packages/pexip/src/definition/AvatarRequest.ts b/packages/pexip/src/definition/AvatarRequest.ts new file mode 100644 index 0000000000000..e00445f00161c --- /dev/null +++ b/packages/pexip/src/definition/AvatarRequest.ts @@ -0,0 +1,30 @@ +export type AvatarRequest = { + 'bandwidth': unknown; + 'call_direction': 'dial_in' | 'dial_out' | 'non_dial'; + 'call_tag': unknown; + 'height': number; + 'local_alias': string; + 'location': string; + 'ms-subnet'?: unknown; + 'node_ip': string; + 'p_Asserted-Identity'?: unknown; + 'protocol': 'api' | 'webrtc' | 'sip' | 'rtmp' | 'h323' | 'mssip'; + 'proxy_node_address': unknown; + 'proxy_node_location': string; + 'pseudo_version_id': unknown; + 'registered': boolean; + 'remote_address': string; + 'remote_alias': string; + 'remote_display_name': string; + 'remote_port': unknown; + 'role': 'chair' | 'guest' | 'unknown'; + 'service_name'?: unknown; + 'service_tag'?: unknown; + 'trigger': 'web' | 'web_avatar_fetch' | 'invite' | 'options' | 'subscribe' | 'setup' | 'arq' | 'irq' | 'unspecified'; + 'unique_service_name'?: unknown; + 'vendor': unknown; + 'version_id': unknown; + 'width': number; +}; + +export type SerializedAvatarRequest = { [K in keyof AvatarRequest]: string | undefined }; diff --git a/packages/pexip/src/definition/EventSinkRequest.ts b/packages/pexip/src/definition/EventSinkRequest.ts new file mode 100644 index 0000000000000..f3e093ad16d92 --- /dev/null +++ b/packages/pexip/src/definition/EventSinkRequest.ts @@ -0,0 +1,116 @@ +import { ajvQuery } from '@rocket.chat/rest-typings'; + +export type ConferenceEventData = { + guests_muted: boolean; + is_locked: boolean; + is_started: boolean; + name: string; + service_type: 'conference' | 'lecture' | 'two_stage_dialing' | 'media_playback' | 'test_call' | 'gateway'; + start_time: number; + tag: string; +}; + +export type ConferenceEndedEventData = ConferenceEventData & { + end_time: number; +}; + +export type ParticipantEventData = { + uuid: string; +}; + +export type ParticipantStatusEventData = ParticipantEventData & { + call_direction: 'in' | 'out'; + call_id: string; + call_tag?: string; + conference: string; + connect_time: number; + conversation_id: string; + destination_alias: string; + display_name: string; + encryption: 'On' | 'Off'; + has_media: boolean; + is_muted: boolean; + is_presenting: boolean; + is_streaming: boolean; + media_node: string; + protocol: 'WebRTC' | 'SIP' | 'H323' | 'TEAMS' | 'MSSIP' | 'GHM' | 'RTMP' | 'API'; + proxy_node: string; + related_uuids: string[]; + remote_address: string; + role: 'chair' | 'guest' | 'unknown'; + rx_bandwidth: number; + service_tag: string; + service_type: 'connecting' | 'conference' | 'lecture' | 'two_stage_dialing' | 'media_playback' | 'test_call' | 'ivr' | 'waiting_room'; + signaling_node: string; + source_alias: string; + system_location: string; + tx_bandwidth: number; + vendor: string; +}; + +export type ParticipantDisconnectedEventData = ParticipantStatusEventData & { + disconnect_reason: string; + duration: number; + end_time: number; +}; + +export type EventSinkRequest = { + node: string; + seq: number; + version: number; + time: number; +} & ( + | { + event: 'conference_started' | 'conference_updated'; + data: ConferenceEventData; + } + | { + event: 'conference_ended'; + data: ConferenceEndedEventData; + } + | { + event: 'participant_connected' | 'participant_updated'; + data: ParticipantStatusEventData; + } + | { + event: 'participant_disconnected'; + data: ParticipantDisconnectedEventData; + } + | { + event: 'participant_media_stream_window'; + data: ParticipantEventData & { + packet_loss_history: Record[]; + recent_quality: Record[]; + call_quality_was: '0_unknown' | '1_good' | '2_ok' | '3_bad' | '4_terrible'; + call_quality_now: '0_unknown' | '1_good' | '2_ok' | '3_bad' | '4_terrible'; + }; + } + | { + event: 'participant_media_streams_destroyed'; + data: ParticipantEventData & { + media_streams: Record[]; + }; + } + | { + event: 'eventsink_started' | 'eventsink_updated' | 'eventsink_ended'; + data: {}; + } +); + +const eventSinkRequestSchema = { + type: 'object', + properties: { + event: { + type: 'string', + nullable: false, + }, + data: { + type: 'object', + nullable: false, + }, + }, + required: ['event', 'data'], + additionalProperties: true, +}; + +export const isEventSinkRequestProps = ajvQuery.compile(eventSinkRequestSchema); diff --git a/packages/pexip/src/definition/PexipLayout.ts b/packages/pexip/src/definition/PexipLayout.ts new file mode 100644 index 0000000000000..e59dee09d595b --- /dev/null +++ b/packages/pexip/src/definition/PexipLayout.ts @@ -0,0 +1,16 @@ +export const pexipLayoutValues = [ + 'one_main_zero_pips', + 'one_main_seven_pips', + 'one_main_twentyone_pips', + 'two_mains_twentyone_pips', + 'one_main_thirtythree_pips', + 'four_mains_zero_pips', + 'nine_mains_zero_pips', + 'sixteen_mains_zero_pips', + 'twentyfive_mains_zero_pips', + 'five_mains_seven_pips', +] as const; + +export type PexipLayout = (typeof pexipLayoutValues)[number]; + +export const isPexipLayout = (value: string): value is PexipLayout => pexipLayoutValues.includes(value as PexipLayout); diff --git a/packages/pexip/src/definition/PexipSettings.ts b/packages/pexip/src/definition/PexipSettings.ts new file mode 100644 index 0000000000000..94631c0ef1442 --- /dev/null +++ b/packages/pexip/src/definition/PexipSettings.ts @@ -0,0 +1,26 @@ +import type { PexipLayout } from './PexipLayout'; + +export type PexipSettings = { + enabled: boolean; + baseUrl: string; + meetingUrl: string; + api: { + username: string; + password: string; + }; + pins: { + host: string; + guest: string; + }; + customization: { + themeName: string; + locked: boolean; + overlayText: boolean; + meetingLayout: PexipLayout; + }; + workspace: { + siteUrl: string; + discussionsEnabled: boolean; + persistentChatEnabled: boolean; + }; +}; diff --git a/packages/pexip/src/definition/PolicyServerResponse.ts b/packages/pexip/src/definition/PolicyServerResponse.ts new file mode 100644 index 0000000000000..f7f8ad707a017 --- /dev/null +++ b/packages/pexip/src/definition/PolicyServerResponse.ts @@ -0,0 +1,31 @@ +import { ajvQuery } from '@rocket.chat/rest-typings'; + +import type { ServiceConfiguration } from './ServiceConfiguration'; + +export type PolicyServerResponse = { + status: 'success' | 'failure'; + action?: 'reject' | 'continue'; + result: ServiceConfiguration; +}; + +const policyServerResponseSchema = { + type: 'object', + properties: { + status: { + type: 'string', + nullable: false, + }, + action: { + type: 'string', + nullable: true, + }, + result: { + type: 'object', + additionalProperties: true, + }, + }, + required: ['status', 'result'], + additionalProperties: false, +}; + +export const isPolicyServerResponse = ajvQuery.compile(policyServerResponseSchema); diff --git a/packages/pexip/src/definition/ServiceConfiguration.ts b/packages/pexip/src/definition/ServiceConfiguration.ts new file mode 100644 index 0000000000000..7cedd6e1902bf --- /dev/null +++ b/packages/pexip/src/definition/ServiceConfiguration.ts @@ -0,0 +1,163 @@ +import { ajvQuery } from '@rocket.chat/rest-typings'; + +import type { PexipLayout } from './PexipLayout'; + +type Toggle = 'default' | 'yes' | 'no'; +type CallType = 'video' | 'video-only' | 'audio'; + +type AutomaticParticipant = { + local_alias: string; + protocol: 'h323' | 'sip' | 'mssip' | 'rtmp'; + remote_alias: string; + role: 'chair' | 'guest'; + call_type?: 'video' | 'video-only' | 'audio'; + dtmf_sequence?: string; + keep_conference_alive?: 'keep_conference_alive' | 'keep_conference_alive_if_multiple' | 'keep_conference_alive_never'; + local_display_name?: string; + presentation_url?: string; + remote_display_name?: string; + routing?: 'manual' | 'routing_rule'; + streaming?: boolean; + system_location_name?: string; +}; + +// Attributes common to all services +type BaseServiceConfiguration = { + name: string; + service_tag: string; + description?: string; + ivr_theme_name?: string; + max_callrate_in?: number; + max_callrate_out?: number; + max_pixels_per_second?: 'sd' | 'hd' | 'fullhd' | null; +}; + +// Attributes common to all calls (everything but media playback) +type BaseCallConfiguration = BaseServiceConfiguration & { + bypass_proxy?: boolean; + call_type?: CallType; + crypto_mode?: 'besteffort' | 'on' | 'off' | null; + local_display_name?: string; +}; + +// Attributes common to both meeting room types (conferences and lectures) +type BaseMeetingRoom = BaseCallConfiguration & { + allow_guests?: boolean; + automatic_participants?: AutomaticParticipant[]; + enable_chat?: Toggle; + enable_active_speaker_indication?: boolean; + enable_overlay_text?: boolean; + guest_pin?: string; + guests_can_present?: boolean; + guest_identity_provider_group?: string; + host_identity_provider_group?: string; + locked?: boolean; + non_idp_participants?: 'disallow_all' | 'allow_if_trusted'; + participant_limit?: number; + pin?: string; + prefer_ipv6?: Toggle; + primary_owner_email_address?: string; +}; + +// Virtual Meeting Room (VMR) Service Configuration +export type MeetingRoom = BaseMeetingRoom & { + service_type: 'conference'; + view?: PexipLayout; +}; + +// Virtual Auditorium Service Configuration +export type Auditorium = BaseMeetingRoom & { + service_type: 'lecture'; + force_presenter_into_main?: boolean; + guest_view?: PexipLayout; + host_view?: PexipLayout; + mute_all_guests?: boolean; +}; + +// Gateway Service Configuration (Integration with third parties) +export type Gateway = BaseCallConfiguration & { + service_type: 'gateway'; + local_alias: string; + outgoing_protocol: 'h323' | 'sip' | 'mssip' | 'rtmp' | 'gms' | 'teams'; + remote_alias: string; + call_type?: CallType | 'auto'; + called_device_type?: + | 'external' + | 'registration' + | 'mssip_conference_id' + | 'mssip_server' + | 'gms_conference' + | 'teams_conference' + | 'telehealth_profile'; + enable_active_speaker_indication?: boolean; + enable_overlay_text?: boolean; + external_participant_avatar_lookup?: Toggle; + gms_access_token_name?: string; + h323_gatekeeper_name?: string; + mssip_proxy_name?: string; + outgoing_location_name?: string; + prefer_ipv6?: Toggle; + sip_proxy_name?: string; + stun_server_name?: string; + teams_proxy_name?: string; + treat_as_trusted?: boolean; + turn_server_name?: string; + view?: PexipLayout; +}; + +// Virtual Reception Service Configuration +export type Reception = BaseCallConfiguration & { + service_type: 'two_stage_dialing'; + gms_access_token_name?: string; + match_string?: string; + mssip_proxy_name?: string; + post_match_string?: string; + post_replace_string?: string; + replace_string?: string; + system_location_name?: string; + teams_proxy_name?: string; + two_stage_dial_type?: 'regular' | 'mssip' | 'gms' | 'teams'; +}; + +// Media Playback Service Configuration +export type MediaPlayback = BaseServiceConfiguration & { + service_type: 'media_playback'; + allow_guests?: boolean; + guest_pin?: string; + guest_identity_provider_group?: string; + host_identity_provider_group?: string; + media_playlist_name?: string; + non_idp_participants?: 'disallow_all' | 'allow_if_trusted'; + on_completion?: string; + pin?: string; +}; + +// Test Call Service Configuration +export type TestCall = Omit & { + service_type: 'test_call'; +}; + +export type ServiceConfiguration = MeetingRoom | Auditorium | Gateway | Reception | MediaPlayback | TestCall; + +const serviceConfigurationSchema = { + type: 'object', + properties: { + service_type: { type: 'string', nullable: false }, + name: { type: 'string', nullable: false }, + service_tag: { type: 'string', nullable: false }, + description: { type: 'string', nullable: true }, + pin: { type: 'string', nullable: true }, + allow_guests: { type: 'boolean', nullable: true }, + guest_pin: { type: 'string', nullable: true }, + locked: { type: 'boolean', nullable: true }, + ivr_theme_name: { type: 'string', nullable: true }, + call_type: { type: 'string', nullable: true }, + view: { type: 'string', nullable: true }, + local_display_name: { type: 'string', nullable: true }, + enable_overlay_text: { type: 'boolean', nullable: true }, + }, + required: ['service_type', 'name', 'service_tag'], + additionalProperties: true, +}; + +export const isServiceConfiguration = ajvQuery.compile(serviceConfigurationSchema); diff --git a/packages/pexip/src/definition/ServiceConfigurationRequest.ts b/packages/pexip/src/definition/ServiceConfigurationRequest.ts new file mode 100644 index 0000000000000..746ea86335e0c --- /dev/null +++ b/packages/pexip/src/definition/ServiceConfigurationRequest.ts @@ -0,0 +1,43 @@ +import { ajvQuery } from '@rocket.chat/rest-typings'; + +export type ServiceConfigurationRequest = { + 'bandwidth': unknown; + 'call_direction': 'dial_in' | 'dial_out' | 'non_dial'; + 'call_tag': string; + 'local_alias': string; + 'location': string; + 'ms-subnet'?: unknown; + 'node_ip': string; + 'p_Asserted-Identity'?: unknown; + 'protocol': 'api' | 'webrtc' | 'sip' | 'rtmp' | 'h323' | 'mssip'; + 'pseudo_version_id': unknown; + 'registered': boolean; + 'remote_address': string; + 'remote_alias': string; + 'remote_display_name': string; + 'remote_port': unknown; + 'service_name'?: unknown; + 'service_tag'?: unknown; + 'telehealth_request_id'?: unknown; + 'trigger': 'web' | 'web_avatar_fetch' | 'invite' | 'options' | 'subscribe' | 'setup' | 'arq' | 'irq' | 'unspecified'; + 'unique_service_name'?: unknown; + 'vendor': unknown; + 'version_id': unknown; +}; + +export type SerializedServiceConfigurationRequest = { [K in keyof ServiceConfigurationRequest]: string | undefined }; + +const serviceConfigurationRequestSchema = { + type: 'object', + properties: { + local_alias: { + type: 'string', + nullable: false, + }, + }, + required: ['local_alias'], + additionalProperties: true, +}; + +export const isServiceConfigurationRequestProps = + ajvQuery.compile(serviceConfigurationRequestSchema); diff --git a/packages/pexip/src/definition/index.ts b/packages/pexip/src/definition/index.ts new file mode 100644 index 0000000000000..7944a42e62f00 --- /dev/null +++ b/packages/pexip/src/definition/index.ts @@ -0,0 +1,7 @@ +export type * from './AvatarRequest'; +export * from './EventSinkRequest'; +export * from './PexipLayout'; +export type * from './PexipSettings'; +export * from './PolicyServerResponse'; +export * from './ServiceConfiguration'; +export * from './ServiceConfigurationRequest'; diff --git a/packages/pexip/src/endpoints/eventSink.ts b/packages/pexip/src/endpoints/eventSink.ts new file mode 100644 index 0000000000000..5f0761c6a7e84 --- /dev/null +++ b/packages/pexip/src/endpoints/eventSink.ts @@ -0,0 +1,25 @@ +import { VideoConf } from '@rocket.chat/core-services'; +import { VideoConferenceStatus } from '@rocket.chat/core-typings'; + +import type { Pexip } from '../Pexip'; +import type { EventSinkRequest } from '../definition'; +import { logger } from '../logger'; + +export class EventSinkEndpoint { + constructor(public readonly pexip: Pexip) { + // + } + + public async post(event: EventSinkRequest): Promise { + if (event.event !== 'conference_ended') { + return; + } + + try { + await VideoConf.setStatus(event.data.name, VideoConferenceStatus.ENDED); + } catch (err) { + logger.error({ msg: 'Failed to flag conference as ended', err }); + // If the call was not found or we were unable to change the status, we must have received an alias instead of a callId and there's nothing we can do with it, so ignore any errors. + } + } +} diff --git a/packages/pexip/src/endpoints/index.ts b/packages/pexip/src/endpoints/index.ts new file mode 100644 index 0000000000000..abed5f8c6323d --- /dev/null +++ b/packages/pexip/src/endpoints/index.ts @@ -0,0 +1 @@ +export * from './serviceConfiguration'; diff --git a/packages/pexip/src/endpoints/serviceConfiguration.ts b/packages/pexip/src/endpoints/serviceConfiguration.ts new file mode 100644 index 0000000000000..ca8af93d3bec4 --- /dev/null +++ b/packages/pexip/src/endpoints/serviceConfiguration.ts @@ -0,0 +1,67 @@ +import { VideoConference as VideoConferenceModel } from '@rocket.chat/models'; + +import type { Pexip } from '../Pexip'; +import type { ServiceConfiguration } from '../definition/ServiceConfiguration'; +import type { SerializedServiceConfigurationRequest } from '../definition/ServiceConfigurationRequest'; +import { logger } from '../logger'; + +export class ServerConfigurationEndpoint { + constructor(public readonly pexip: Pexip) { + // + } + + public async get(serviceRequest: SerializedServiceConfigurationRequest): Promise { + const { local_alias: alias } = serviceRequest; + if (!alias) { + logger.error(`No call identification received in the request.`); + return null; + } + + const identification = this.getIdentificationFromAlias(alias); + + return this.getServiceConfigurationForIdentification(identification); + } + + private getIdentificationFromAlias(alias: string): string { + if (!alias.startsWith('sip:') || !alias.includes('@')) { + return alias; + } + + return alias.substring(0, alias.indexOf('@')).replace('sip:', ''); + } + + private async getServiceConfigurationForIdentification(identification: string): Promise { + const call = await VideoConferenceModel.findOneById(identification); + if (!call) { + logger.error({ msg: 'Invalid call identification', identification }); + return null; + } + + const conferenceTitle = 'title' in call && call.title; + const title = conferenceTitle || 'Rocket.Chat'; + + const [hostPin, guestPin] = await this.pexip.createAndStorePinsForCall(call); + + return this.makeServiceConfiguration(call._id, title, hostPin, guestPin); + } + + private makeServiceConfiguration(name: string, title: string, hostPin: string, guestPin: string): ServiceConfiguration { + const { customization } = this.pexip.settings; + + return { + service_type: 'conference', + name, + service_tag: 'rocket.chat', + description: title, + ...(hostPin ? { pin: hostPin } : {}), + allow_guests: true, + ...(guestPin ? { guest_pin: guestPin } : {}), + locked: customization.locked, + ivr_theme_name: customization.themeName, + call_type: 'video', + view: customization.meetingLayout, + local_display_name: title, + enable_overlay_text: customization.overlayText, + }; + } +} diff --git a/packages/pexip/src/index.ts b/packages/pexip/src/index.ts new file mode 100644 index 0000000000000..f4d9e833f71b9 --- /dev/null +++ b/packages/pexip/src/index.ts @@ -0,0 +1,3 @@ +export * from './definition'; +export * from './Pexip'; +export * from './videoConfProvider'; diff --git a/packages/pexip/src/logger.ts b/packages/pexip/src/logger.ts new file mode 100644 index 0000000000000..af409b45b3260 --- /dev/null +++ b/packages/pexip/src/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('Pexip'); diff --git a/packages/pexip/src/videoConfProvider.ts b/packages/pexip/src/videoConfProvider.ts new file mode 100644 index 0000000000000..a40846eadd9db --- /dev/null +++ b/packages/pexip/src/videoConfProvider.ts @@ -0,0 +1,163 @@ +import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit'; +import type { VideoConference, AtLeast, IRoom, IVideoConferenceUser } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; + +import type { Pexip } from './Pexip'; + +export class PexipVideoConfProvider { + public readonly name = 'Pexip'; + + public readonly capabilities = { + mic: false, + cam: false, + title: true, + persistentChat: true, + }; + + constructor(public readonly pexip: Pexip) { + // + } + + public async isFullyConfigured(): Promise { + const { baseUrl, pins } = this.pexip.settings; + + if (!baseUrl) { + return false; + } + + // If both host and guest pins are set to the same value, it's an invalid configuration + if (pins.host && pins.host === pins.guest) { + return false; + } + + return true; + } + + public async generateUrl(call: VideoConference): Promise { + const { baseUrl, meetingUrl } = this.pexip.settings; + + if (!baseUrl) { + throw new Error('Pexip URL is not configured'); + } + + const relativeUrl = meetingUrl.replace('{callId}', call._id); + + const meetingParams = { + rid: call.discussionRid && (await this.getDiscussionUrl(call.discussionRid)), + }; + + const encodedParams = { + ...meetingParams, + rid: meetingParams.rid && encodeURIComponent(meetingParams.rid), + }; + + return this.joinUrlAndParams(`${baseUrl}${relativeUrl}`, encodedParams); + } + + private joinUrlParams(params: Record): string { + return Object.keys(params) + .filter((key) => params[key] !== undefined && params[key] !== null) + .map((key) => `${key}=${params[key]}`) + .join('&'); + } + + private joinUrlAndParams(baseUrl: string, params: Record): string { + const joinedParams = this.joinUrlParams(params); + return `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${joinedParams}`; + } + + private async getDiscussionUrl(rid: string): Promise { + const room = await Rooms.findOneById>(rid, { projection: { t: 1, name: 1 } }); + if (!room) { + return; + } + + const roomRoute = this.getDiscussionRoute(room); + if (!roomRoute) { + return; + } + + const baseUrl = await this.getBaseURLWithoutTrailingSlash(); + const roomUrl = `${baseUrl}/${roomRoute}`; + + const roomParams = { + layout: 'embedded', + }; + + const params = Object.keys(roomParams) + .map((key) => `${key}=${roomParams[key as keyof typeof roomParams]}`) + .join('&'); + + return `${roomUrl}${roomUrl.includes('?') ? '&' : '?'}${params}`; + } + + private getDiscussionRoute(room: AtLeast): string | undefined { + switch (room.t) { + case 'c': + return `channel/${room.name}`; + case 'p': + return `group/${room.name}`; + default: + return undefined; + } + } + + private async getBaseURLWithoutTrailingSlash(): Promise { + const url = this.pexip.settings.workspace.siteUrl; + + if (url.endsWith('/')) { + return url.substr(0, url.length - 1); + } + return url; + } + + public async customizeUrl(call: VideoConference, user: IVideoConferenceUser | undefined): Promise { + const pin = await this.getPinForUser(call, user); + + const { url } = call; + + const nameSuffix = user?.name ? `&name=${user.name}` : ''; + + return `${url}&pin=${pin}${nameSuffix}`; + } + + public async onNewVideoConference(call: VideoConference): Promise { + // Generate pins for this call and keep them stored in the providerData. + await this.pexip.createAndStorePinsForCall(call); + } + + public async getVideoConferenceInfo(call: VideoConference, user: IVideoConferenceUser | undefined): Promise> { + const lines: Array = []; + + lines.push(`**URL:** ${call.url}`); + + const [hostPin, guestPin] = await this.pexip.createPinsForCall(call); + + if (await this.pexip.isUserCallHost(call, user)) { + lines.push(`**Host Pin:** ${hostPin}`); + } + + lines.push(`**Guest Pin:** ${guestPin}`); + + return [ + { + blockId: 'videoconf-info', + type: 'section', + text: { + type: 'mrkdwn', + text: lines.join('\n'), + }, + } as IBlock, + ]; + } + + private async getPinForUser(call: VideoConference, user: IVideoConferenceUser | undefined): Promise { + const [hostPin, guestPin] = await this.pexip.createPinsForCall(call); + + if (await this.pexip.isUserCallHost(call, user)) { + return hostPin; + } + + return guestPin; + } +} diff --git a/packages/pexip/tsconfig.build.json b/packages/pexip/tsconfig.build.json new file mode 100644 index 0000000000000..f7fdc83684bce --- /dev/null +++ b/packages/pexip/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + }, + "include": ["./src/**/*"], + "exclude": ["**/*.spec.ts", "**/*.test.ts", "./tests/**/*"] +} diff --git a/packages/pexip/tsconfig.json b/packages/pexip/tsconfig.json new file mode 100644 index 0000000000000..4d25986407a74 --- /dev/null +++ b/packages/pexip/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@rocket.chat/tsconfig/server.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declaration": true, + }, + "include": ["./src/**/*", "./tests/**/*"], +} diff --git a/yarn.lock b/yarn.lock index 9d72b70f34708..08ead76eadb3c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9777,6 +9777,7 @@ __metadata: "@rocket.chat/password-policies": "workspace:^" "@rocket.chat/patch-injection": "workspace:^" "@rocket.chat/pdf-worker": "workspace:^" + "@rocket.chat/pexip": "workspace:^" "@rocket.chat/poplib": "workspace:^" "@rocket.chat/presence": "workspace:^" "@rocket.chat/random": "workspace:^" @@ -10410,6 +10411,23 @@ __metadata: languageName: unknown linkType: soft +"@rocket.chat/pexip@workspace:^, @rocket.chat/pexip@workspace:packages/pexip": + version: 0.0.0-use.local + resolution: "@rocket.chat/pexip@workspace:packages/pexip" + dependencies: + "@rocket.chat/core-services": "workspace:^" + "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/logger": "workspace:^" + "@rocket.chat/models": "workspace:^" + "@rocket.chat/tsconfig": "workspace:*" + "@types/jest": "npm:~30.0.0" + ajv: "npm:^8.17.1" + eslint: "npm:~9.39.4" + jest: "npm:~30.2.0" + typescript: "npm:~5.9.3" + languageName: unknown + linkType: soft + "@rocket.chat/poplib@workspace:^, @rocket.chat/poplib@workspace:packages/node-poplib": version: 0.0.0-use.local resolution: "@rocket.chat/poplib@workspace:packages/node-poplib" @@ -15834,7 +15852,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:8.20.0, ajv@npm:^8.18.0, ajv@npm:^8.20.0": +"ajv@npm:8.20.0, ajv@npm:^8.17.1, ajv@npm:^8.18.0, ajv@npm:^8.20.0": version: 8.20.0 resolution: "ajv@npm:8.20.0" dependencies: