('VideoConf_Default_Provider');
@@ -87,12 +89,7 @@ export const videoConfProviders = {
getProviderCapabilities(name: string): VideoConferenceCapabilities | undefined {
if (name === 'core.pexip') {
- return {
- mic: false,
- cam: false,
- title: true,
- persistentChat: true,
- };
+ return this.getPexipHandler().capabilities;
}
const key = name.toLowerCase();
diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts
index 332aea56d5eac..62d0511a44b1d 100644
--- a/apps/meteor/server/modules/listeners/listeners.module.ts
+++ b/apps/meteor/server/modules/listeners/listeners.module.ts
@@ -181,6 +181,10 @@ export class ListenersModule {
.catch((err) => logger.error({ msg: 'Failed to refresh status visibility', err, targets }));
});
+ service.onEvent('video-conference.discussionUpdated', ({ callId, discussionRid }) => {
+ notifications.notifyVideoConference(callId, 'discussionUpdated', { discussionRid });
+ });
+
service.onEvent('presence.status', ({ user }) => {
const { _id, username, name, status, statusText, statusSource, statusExpiresAt, roles } = user;
if (!status || !username) {
diff --git a/apps/meteor/server/modules/notifications/notifications.module.ts b/apps/meteor/server/modules/notifications/notifications.module.ts
index 65be495c191d9..1d53b460e4b1a 100644
--- a/apps/meteor/server/modules/notifications/notifications.module.ts
+++ b/apps/meteor/server/modules/notifications/notifications.module.ts
@@ -1,7 +1,7 @@
import { Authorization, MediaCall, VideoConf, Settings } from '@rocket.chat/core-services';
import type { ISubscription, IOmnichannelRoom, IUser, IUserDataEvent, PresenceSource, PresenceStatusCode } from '@rocket.chat/core-typings';
import type { StreamerCallbackArgs, StreamKeys, StreamNames } from '@rocket.chat/ddp-client';
-import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
+import { Rooms, Subscriptions, Users, VideoConference } from '@rocket.chat/models';
import type { ImporterProgress } from '../../lib/import/classes/ImporterProgress';
import { SystemLogger } from '../../lib/logger/system';
@@ -47,6 +47,8 @@ export class NotificationsModule {
public readonly streamPresence: IStreamer<'user-presence'>;
+ public readonly streamVideoConference: IStreamer<'video-conference'>;
+
constructor(private Streamer: IStreamerConstructor) {
this.streamAll = new this.Streamer('notify-all');
this.streamLogged = new this.Streamer('notify-logged');
@@ -91,6 +93,7 @@ export class NotificationsModule {
this.streamUser = new this.Streamer('notify-user');
this.streamLocal = new this.Streamer('local');
+ this.streamVideoConference = new this.Streamer('video-conference');
}
configure(): void {
@@ -459,6 +462,22 @@ export class NotificationsModule {
}
});
+ this.streamVideoConference.allowWrite('none');
+ this.streamVideoConference.allowRead(async function (eventName) {
+ const user = await getCachedUserForPublication(this);
+ if (!user) {
+ return false;
+ }
+
+ const [callId] = eventName.split('/');
+ const call = await VideoConference.findOneById(callId, { projection: { users: 1 } });
+ if (!call) {
+ return false;
+ }
+
+ return call.users.some(({ _id }) => _id === user._id);
+ });
+
this.streamLocal.serverOnly = true;
this.streamLocal.allowRead('none');
this.streamLocal.allowEmit('all');
@@ -527,6 +546,15 @@ export class NotificationsModule {
progressUpdated(progress: { rate: number } | ImporterProgress): void {
this.streamImporters.emit('progress', progress);
}
+
+ notifyVideoConference(
+ callId: P,
+ event: E extends ExtractNotifyUserEventName<'video-conference', P> ? E : never,
+ ...args: `${P}/${E}` extends StreamKeys<'video-conference'> ? StreamerCallbackArgs<'video-conference', `${P}/${E}`> : never
+ ): void {
+ // @ts-expect-error - as we currently only have one event for the 'video-conference' stream, typescript doesn't like the destructuring
+ return this.streamVideoConference.emit(`${callId}/${event}`, ...args);
+ }
}
type ExtractNotifyUserEventName<
diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts
index db1350eb299eb..4ba28817759e0 100644
--- a/apps/meteor/server/services/video-conference/service.ts
+++ b/apps/meteor/server/services/video-conference/service.ts
@@ -1,8 +1,11 @@
+import crypto from 'crypto';
+
import { Apps } from '@rocket.chat/apps';
import type { AppVideoConfProviderManager } from '@rocket.chat/apps/dist/server/managers/AppVideoConfProviderManager';
+import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit';
import type { VideoConfData, VideoConfDataExtended } from '@rocket.chat/apps-engine/definition/videoConfProviders';
import type { IVideoConfService, VideoConferenceJoinOptions } from '@rocket.chat/core-services';
-import { api, ServiceClassInternal, Room } from '@rocket.chat/core-services';
+import { api, ServiceClassInternal, Room, Message } from '@rocket.chat/core-services';
import type {
IDirectVideoConference,
ILivechatVideoConference,
@@ -23,6 +26,9 @@ import type {
Optional,
ExternalVideoConference,
IVoIPVideoConference,
+ RequiredField,
+ IRegisterUser,
+ IVideoConference,
} from '@rocket.chat/core-typings';
import {
VideoConferenceStatus,
@@ -59,12 +65,16 @@ import { getUserAvatarURL } from '../../lib/utils/getUserAvatarURL';
import { getUserPreference } from '../../lib/utils/lib/getUserPreference';
import { videoConfProviders } from '../../lib/videoConfProviders';
import { videoConfTypes } from '../../lib/videoConfTypes';
+import { addUsersToRoomMethod } from '../../meteor-methods/rooms/addUsersToRoom';
import { settings } from '../../settings';
const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;
const logger = new Logger('VideoConference');
+// temp fix for DMV project: skip Discussions when starting new conferences from rocket.chat
+const SKIP_DISCUSSIONS_ON_CHANNEL_CONFERENCES = true;
+
export class VideoConfService extends ServiceClassInternal implements IVideoConfService {
protected name = 'video-conference';
@@ -73,6 +83,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
{ type, rid, createdBy, providerName, ...data }: VideoConferenceCreateData,
useAppUser = true,
): Promise {
+ logger.debug({ msg: 'VideoConf.create', type, rid, providerName, createdBy });
+
return wrapExceptions(async () => {
const room = await Rooms.findOneById>(rid, {
projection: { t: 1, uids: 1, name: 1, fname: 1 },
@@ -116,6 +128,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
rid: string,
{ title, allowRinging }: { title?: string; allowRinging?: boolean },
): Promise {
+ logger.debug({ msg: 'VideoConf.start', rid, caller });
+
return wrapExceptions(async () => {
const providerName = await this.getValidatedProvider();
const initialData = await this.getTypeForNewVideoConference(rid, Boolean(allowRinging));
@@ -142,6 +156,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
}
public async join(uid: IUser['_id'] | undefined, callId: VideoConference['_id'], options: VideoConferenceJoinOptions): Promise {
+ logger.debug({ msg: 'VideoConf.join', callId, uid });
+
return wrapExceptions(async () => {
const call = await VideoConferenceModel.findOneById(callId);
if (!call || call.endedAt || !videoConfTypes.isCallManagedByApp(call)) {
@@ -174,96 +190,258 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
}
public async getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise {
- const call = await VideoConferenceModel.findOneById(callId);
- if (!call) {
- throw new Error('invalid-call');
- }
+ logger.debug({ msg: 'VideoConf.getInfo', callId, uid });
+ try {
+ const call = await VideoConferenceModel.findOneById(callId);
+ if (!call) {
+ throw new Error('invalid-call');
+ }
- if (!videoConfTypes.isCallManagedByApp(call)) {
- return [];
- }
+ if (!videoConfTypes.isCallManagedByApp(call)) {
+ return [];
+ }
- if (!videoConfProviders.isProviderAvailable(call.providerName)) {
- throw new Error('video-conf-provider-unavailable');
+ if (!videoConfProviders.isProviderAvailable(call.providerName)) {
+ throw new Error('video-conf-provider-unavailable');
+ }
+
+ let user: Pick, '_id' | 'username' | 'name' | 'avatarETag'> | null = null;
+
+ if (uid) {
+ user = await Users.findOneById, '_id' | 'username' | 'name' | 'avatarETag'>>(uid, {
+ projection: { name: 1, username: 1, avatarETag: 1 },
+ });
+ if (!user) {
+ throw new Error('failed-to-load-own-data');
+ }
+ }
+
+ const blocks = await this.getBlocks(call.providerName, call, user || undefined).catch((e) => {
+ throw new Error(e);
+ });
+
+ if (blocks?.length) {
+ return blocks as UiKit.ModalSurfaceLayout;
+ }
+
+ return [
+ {
+ blockId: 'videoconf-info',
+ type: 'section',
+ text: {
+ type: 'mrkdwn',
+ text: `**${i18n.t('Video_Conference_Url')}**: ${call.url}`,
+ },
+ },
+ ];
+ } catch (err) {
+ logger.error({
+ msg: 'Error on VideoConf.info',
+ err,
+ });
+ throw err;
}
+ }
- let user: Pick, '_id' | 'username' | 'name' | 'avatarETag'> | null = null;
+ public async initializeOrJoinScheduledConference(sipAlias: string, uid: IUser['_id']): Promise {
+ logger.debug({ msg: 'VideoConf.initializeOrJoinScheduledConference', sipAlias, uid });
- if (uid) {
- user = await Users.findOneById, '_id' | 'username' | 'name' | 'avatarETag'>>(uid, {
- projection: { name: 1, username: 1, avatarETag: 1 },
+ try {
+ if (!settings.get('Pexip_Integration_Enabled') || !settings.get('Pexip_Integration_SIP_AddAlias')) {
+ throw new Error('feature-disabled');
+ }
+ const providerName = 'core.pexip';
+
+ const existing = await VideoConferenceModel.findOneByProviderNameAndSipAlias('core.pexip', sipAlias, {
+ projection: { discussionRid: 1 },
});
+
+ if (existing) {
+ await this.addUserToConferenceDiscussion(existing as Pick, uid);
+ return existing._id;
+ }
+
+ const rid = await this.getRidForExternalConference();
+ if (!rid) {
+ throw new Error('invalid-room');
+ }
+
+ const user = await Users.findOneById(uid);
if (!user) {
- throw new Error('failed-to-load-own-data');
+ throw new Error('invalid-user');
}
- }
- const blocks = await this.getBlocks(call.providerName, call, user || undefined).catch((e) => {
- throw new Error(e);
- });
+ const discussionRid = await this.createDiscussionForConferenceData(this.getDiscussionDisplayName(), rid, user);
- if (blocks?.length) {
- return blocks as UiKit.ModalSurfaceLayout;
- }
+ const { name, username } = user;
- return [
- {
- blockId: 'videoconf-info',
- type: 'section',
- text: {
- type: 'mrkdwn',
- text: `**${i18n.t('Video_Conference_Url')}**: ${call.url}`,
+ const callId = await VideoConferenceModel.createGroup({
+ rid,
+ createdBy: {
+ _id: uid,
+ name,
+ username,
},
- },
- ];
- }
+ // TODO: custom title
+ title: sipAlias,
+ providerName,
+ sipAlias,
+ discussionRid,
+ });
- private async getBlocks(providerName: string, call: any, user?: any) {
- const provider = videoConfProviders.getVideoConfProviderHandler(providerName);
- if (provider) {
- return provider.getVideoConferenceInfo(call, user);
+ return callId;
+ } catch (err) {
+ logger.error({
+ msg: 'Error on VideoConf.initializeOrJoinScheduledConference',
+ err,
+ });
+ throw err;
}
+ }
+
+ public async makePersistentChatUrlForConference(conferenceId: string): Promise {
+ logger.debug({ msg: 'VideoConf.makePersistentChatUrlForConference', conferenceId });
- return (await this.getProviderManager()).getVideoConferenceInfo(call.providerName, call, user || undefined);
+ const baseUrl = settings.get('Site_Url');
+
+ return `${baseUrl}/conference/${conferenceId}`;
}
- public async cancel(uid: IUser['_id'], callId: VideoConference['_id']): Promise {
- const call = await VideoConferenceModel.findOneById(callId);
- if (!call || !isDirectVideoConference(call)) {
- throw new Error('invalid-call');
+ private async addUserToConferenceDiscussion(
+ conference: AtLeast,
+ uid: IUser['_id'],
+ ): Promise {
+ logger.debug({
+ msg: 'VideoConf.addUserToConferenceDiscussion',
+ uid,
+ conferenceId: conference._id,
+ discussionRid: conference.discussionRid,
+ });
+
+ if (!conference.discussionRid) {
+ return;
+ }
+
+ const { discussionRid } = conference;
+
+ try {
+ await Room.addUserToRoom(discussionRid, { _id: uid });
+ } catch (err) {
+ logger.error({ msg: `Failed to add user to conference's discussion`, discussionRid, uid, err });
}
+ }
+
+ public async getRidForExternalConference(): Promise {
+ const settingValue = settings.get('Pexip_Integration_PersistentChat_ExternalRoom');
+ if (!settingValue || typeof settingValue !== 'object' || !Array.isArray(settingValue) || !settingValue.length) {
+ logger.debug({
+ msg: 'No rid available for external conference',
+ method: 'VideoConf.getRidForExternalConference',
+ settingValue,
+ });
- if (call.status !== VideoConferenceStatus.CALLING || call.endedBy || call.endedAt) {
- throw new Error('invalid-call-status');
+ return null;
}
- const user = await Users.findOneById(uid);
- if (!user) {
- throw new Error('failed-to-load-own-data');
+ for (const value of settingValue) {
+ if (!value || typeof value !== 'object' || !value._id) {
+ continue;
+ }
+
+ logger.debug({
+ msg: 'Found rid for external conferences',
+ method: 'VideoConf.getRidForExternalConference',
+ rid: value._id,
+ });
+
+ return value._id;
}
- await VideoConferenceModel.setDataById(callId, {
- ringing: false,
- status: VideoConferenceStatus.DECLINED,
- endedAt: new Date(),
- endedBy: {
- _id: user._id,
- name: user.name as string,
- username: user.username as string,
- },
+ return null;
+ }
+
+ private async getBlocks(providerName: string, call: VideoConference, user?: any) {
+ logger.debug({
+ msg: 'VideoConf.getBlocks',
+ callId: call._id,
+ });
+
+ return wrapExceptions(async (): Promise | undefined> => {
+ const provider = videoConfProviders.getVideoConfProviderHandler(providerName);
+ if (provider) {
+ return provider.getVideoConferenceInfo(call, user);
+ }
+
+ return (await this.getProviderManager()).getVideoConferenceInfo(call.providerName, call as any, user || undefined);
+ }).catch((err) => {
+ logger.error({
+ msg: 'Error on VideoConf.getBlocks',
+ err,
+ });
+ throw err;
+ });
+ }
+
+ public async cancel(uid: IUser['_id'], callId: VideoConference['_id']): Promise {
+ logger.debug({
+ msg: 'VideoConf.cancel',
+ uid,
+ callId,
});
- await this.runVideoConferenceChangedEvent(callId);
- this.notifyVideoConfUpdate(call.rid, call._id);
+ try {
+ const call = await VideoConferenceModel.findOneById(callId);
+ if (!call || !isDirectVideoConference(call)) {
+ throw new Error('invalid-call');
+ }
+
+ if (call.status !== VideoConferenceStatus.CALLING || call.endedBy || call.endedAt) {
+ throw new Error('invalid-call-status');
+ }
+
+ const user = await Users.findOneById(uid);
+ if (!user) {
+ throw new Error('failed-to-load-own-data');
+ }
+
+ await VideoConferenceModel.setDataById(callId, {
+ ringing: false,
+ status: VideoConferenceStatus.DECLINED,
+ endedAt: new Date(),
+ endedBy: {
+ _id: user._id,
+ name: user.name as string,
+ username: user.username as string,
+ },
+ });
+
+ await this.runVideoConferenceChangedEvent(callId);
+ this.notifyVideoConfUpdate(call.rid, call._id);
- await this.sendAllPushNotifications(call._id);
+ await this.sendAllPushNotifications(call._id);
+ } catch (err) {
+ logger.error({
+ msg: 'Error on VideoConf.cancel',
+ err,
+ });
+ throw err;
+ }
}
public async get(callId: VideoConference['_id']): Promise | null> {
+ logger.debug({
+ msg: 'VideoConf.get',
+ callId,
+ });
+
return VideoConferenceModel.findOneById>(callId, { projection: { providerData: 0 } });
}
public async getUnfiltered(callId: VideoConference['_id']): Promise {
+ logger.debug({
+ msg: 'VideoConf.getUnfiltered',
+ callId,
+ });
return VideoConferenceModel.findOneById(callId);
}
@@ -271,6 +449,10 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
roomId: IRoom['_id'],
pagination: { offset?: number; count?: number } = {},
): Promise> {
+ logger.debug({
+ msg: 'VideoConf.list',
+ roomId,
+ });
const { cursor, totalCount } = VideoConferenceModel.findPaginatedByRoomId(roomId, pagination);
const [data, total] = await Promise.all([cursor.toArray(), totalCount]);
@@ -284,10 +466,19 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
}
public async setProviderData(callId: VideoConference['_id'], data: VideoConference['providerData'] | undefined): Promise {
+ logger.debug({
+ msg: 'VideoConf.setProviderData',
+ callId,
+ });
await VideoConferenceModel.setProviderDataById(callId, data);
}
public async setEndedBy(callId: VideoConference['_id'], endedBy: IUser['_id']): Promise {
+ logger.debug({
+ msg: 'VideoConf.setEndedBy',
+ callId,
+ endedBy,
+ });
const user = await Users.findOneById>>(endedBy, {
projection: { username: 1, name: 1 },
});
@@ -303,10 +494,20 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
}
public async setEndedAt(callId: VideoConference['_id'], endedAt: Date): Promise {
+ logger.debug({
+ msg: 'VideoConf.setEndedAt',
+ callId,
+ endedAt,
+ });
await VideoConferenceModel.setEndedById(callId, undefined, endedAt);
}
public async setStatus(callId: VideoConference['_id'], status: VideoConference['status']): Promise {
+ logger.debug({
+ msg: 'VideoConf.setStatus',
+ callId,
+ status,
+ });
switch (status) {
case VideoConferenceStatus.ENDED:
return this.endCall(callId);
@@ -318,44 +519,68 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
}
public async addUser(callId: VideoConference['_id'], userId?: IUser['_id'], ts?: Date): Promise {
- const call = await this.get(callId);
- if (!call) {
- throw new Error('Invalid video conference');
- }
+ logger.debug({
+ msg: 'VideoConf.addUser',
+ callId,
+ userId,
+ });
- if (!userId) {
- if (call.type === 'videoconference') {
- return this.addAnonymousUser(call as Omit);
+ try {
+ const call = await this.get(callId);
+ if (!call) {
+ throw new Error('Invalid video conference');
}
- throw new Error('Invalid User');
- }
+ if (!userId) {
+ if (call.type === 'videoconference') {
+ return this.addAnonymousUser(call as Omit);
+ }
- const user = await Users.findOneById>>(userId, {
- projection: { username: 1, name: 1, avatarETag: 1 },
- });
- if (!user) {
- throw new Error('Invalid User');
- }
+ throw new Error('Invalid User');
+ }
- await this.addUserToCall(call, {
- _id: user._id,
- username: user.username,
- name: user.name,
- avatarETag: user.avatarETag,
- ts: ts || new Date(),
- });
+ const user = await Users.findOneById>>(userId, {
+ projection: { username: 1, name: 1, avatarETag: 1 },
+ });
+ if (!user) {
+ throw new Error('Invalid User');
+ }
+
+ await this.addUserToCall(call, {
+ _id: user._id,
+ username: user.username,
+ name: user.name,
+ avatarETag: user.avatarETag,
+ ts: ts || new Date(),
+ });
+ } catch (err) {
+ logger.error({
+ msg: 'Error on VideoConf.addUser',
+ err,
+ });
+ throw err;
+ }
}
public async listProviders(): Promise<{ key: string; label: string }[]> {
+ logger.debug({
+ msg: 'VideoConf.listProviders',
+ });
return videoConfProviders.getAllProviders();
}
public async listProviderCapabilities(providerName: string): Promise {
+ logger.debug({
+ msg: 'VideoConf.listProviderCapabilities',
+ providerName,
+ });
return videoConfProviders.getProviderCapabilities(providerName) || {};
}
public async listCapabilities(): Promise<{ providerName: string; capabilities: VideoConferenceCapabilities }> {
+ logger.debug({
+ msg: 'VideoConf.listCapabilities',
+ });
const providerName = await this.getValidatedProvider();
return {
@@ -391,6 +616,12 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
}
public async diagnoseProvider(uid: string, rid: string, providerName?: string): Promise {
+ logger.debug({
+ msg: 'VideoConf.diagnoseProvider',
+ providerName,
+ uid,
+ rid,
+ });
try {
if (providerName) {
await this.validateProvider(providerName);
@@ -450,6 +681,15 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
caller: IUser['_id'],
{ callId, uid, rid }: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] },
): Promise {
+ logger.debug({
+ msg: 'VideoConf.validateAction',
+ action,
+ caller,
+ callId,
+ uid,
+ rid,
+ });
+
if (!callId || !uid || !rid) {
return false;
}
@@ -497,29 +737,57 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
action: string,
params: { uid: IUser['_id']; rid: IRoom['_id']; callId: VideoConference['_id'] },
): void {
+ logger.debug({
+ msg: 'VideoConf.notifyUser',
+ action,
+ params,
+ });
void api.broadcast('user.video-conference', { userId, action, params });
}
private notifyVideoConfUpdate(rid: IRoom['_id'], callId: VideoConference['_id']): void {
+ logger.debug({
+ msg: 'VideoConf.notifyVideoConfUpdate',
+ rid,
+ callId,
+ });
void api.broadcast('room.video-conference', { rid, callId });
}
private async endCall(callId: VideoConference['_id']): Promise {
- const call = await this.getUnfiltered(callId);
- if (!call) {
- return;
- }
+ logger.debug({
+ msg: 'VideoConf.endCall',
+ callId,
+ });
- await VideoConferenceModel.setDataById(call._id, { endedAt: new Date(), status: VideoConferenceStatus.ENDED });
- await this.runVideoConferenceChangedEvent(call._id);
- this.notifyVideoConfUpdate(call.rid, call._id);
+ return wrapExceptions(async () => {
+ const call = await this.getUnfiltered(callId);
+ if (!call) {
+ return;
+ }
- if (call.type === 'direct') {
- return this.endDirectCall(call);
- }
+ await VideoConferenceModel.setDataById(call._id, { endedAt: new Date(), status: VideoConferenceStatus.ENDED });
+ await this.runVideoConferenceChangedEvent(call._id);
+ this.notifyVideoConfUpdate(call.rid, call._id);
+
+ if (call.type === 'direct') {
+ return this.endDirectCall(call);
+ }
+ }).catch((err) => {
+ logger.error({
+ msg: 'Error on VideoConf.endCall',
+ err,
+ });
+ throw err;
+ });
}
private async expireCall(callId: VideoConference['_id']): Promise {
+ logger.debug({
+ msg: 'VideoConf.expireCall',
+ callId,
+ });
+
const call = await VideoConferenceModel.findOneById>(callId, { projection: { messages: 1 } });
if (!call) {
return;
@@ -529,23 +797,36 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
}
private async endDirectCall(call: IDirectVideoConference): Promise {
- const params = { rid: call.rid, uid: call.createdBy._id, callId: call._id };
+ logger.debug({
+ msg: 'VideoConf.endDirectCall',
+ callId: call._id,
+ });
+
+ try {
+ const params = { rid: call.rid, uid: call.createdBy._id, callId: call._id };
- // Notify the caller that the call was ended by the server
- this.notifyUser(call.createdBy._id, 'end', params);
+ // Notify the caller that the call was ended by the server
+ this.notifyUser(call.createdBy._id, 'end', params);
- // If the callee hasn't joined the call yet, notify them that it has already ended
- const subscriptions = await Subscriptions.findByRoomIdAndNotUserId(call.rid, call.createdBy._id, {
- projection: { 'u._id': 1, '_id': 0 },
- }).toArray();
+ // If the callee hasn't joined the call yet, notify them that it has already ended
+ const subscriptions = await Subscriptions.findByRoomIdAndNotUserId(call.rid, call.createdBy._id, {
+ projection: { 'u._id': 1, '_id': 0 },
+ }).toArray();
- for (const subscription of subscriptions) {
- // Skip notifying users that already joined the call
- if (call.users.find(({ _id }) => _id === subscription.u._id)) {
- continue;
- }
+ for (const subscription of subscriptions) {
+ // Skip notifying users that already joined the call
+ if (call.users.find(({ _id }) => _id === subscription.u._id)) {
+ continue;
+ }
- this.notifyUser(subscription.u._id, 'end', params);
+ this.notifyUser(subscription.u._id, 'end', params);
+ }
+ } catch (err) {
+ logger.error({
+ msg: 'Error on VideoConf.endDirectCall',
+ err,
+ });
+ throw err;
}
}
@@ -553,6 +834,12 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
rid: IRoom['_id'],
allowRinging: boolean,
): Promise> {
+ logger.debug({
+ msg: 'VideoConf.getTypeForNewVideoConference',
+ rid,
+ allowRinging,
+ });
+
const room = await Rooms.findOneById>(rid, {
projection: { t: 1 },
});
@@ -564,7 +851,17 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
return videoConfTypes.getTypeForRoom(room, allowRinging);
}
- private async createMessage(call: VideoConference, createdBy?: IUser, customBlocks?: IMessage['blocks']): Promise {
+ private async createMessage(
+ call: AtLeast,
+ createdBy?: IUser,
+ customBlocks?: IMessage['blocks'],
+ ): Promise {
+ logger.debug({
+ msg: 'VideoConf.createMessage',
+ callId: call._id,
+ rid: call.rid,
+ });
+
const record = {
t: 'videoconf',
msg: i18n.t('Video_Conference', {
@@ -755,63 +1052,81 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
{ _id: rid, uids }: AtLeast,
extraData?: Partial,
): Promise {
- const calleeId = uids?.filter((uid) => uid !== user._id).pop();
- if (!calleeId) {
- // Are you trying to call yourself?
- throw new Error('invalid-call-target');
- }
-
- const callId = await VideoConferenceModel.createDirect({
- ...extraData,
+ logger.debug({
+ msg: 'VideoConf.startDirect',
rid,
- createdBy: {
- _id: user._id,
- name: user.name as string,
- username: user.username as string,
- },
- providerName,
+ uids,
});
- await this.runNewVideoConferenceEvent(callId);
-
- await this.maybeCreateDiscussion(callId, user);
+ try {
+ const calleeId = uids?.filter((uid) => uid !== user._id).pop();
+ if (!calleeId) {
+ // Are you trying to call yourself?
+ throw new Error('invalid-call-target');
+ }
- const call = (await this.getUnfiltered(callId)) as IDirectVideoConference | null;
- if (!call) {
- throw new Error('failed-to-create-direct-call');
- }
- const url = await this.generateNewUrl(call);
- await VideoConferenceModel.setUrlById(callId, url);
+ const callId = await VideoConferenceModel.createDirect({
+ ...extraData,
+ rid,
+ createdBy: {
+ _id: user._id,
+ name: user.name as string,
+ username: user.username as string,
+ },
+ providerName,
+ });
- const messageId = await this.createMessage(call, user);
- call.messages.started = messageId;
- await VideoConferenceModel.setMessageById(callId, 'started', messageId);
+ await this.maybeAddSipAliasToCall(callId, providerName);
- // After 40 seconds if the status is still "calling", we cancel the call automatically.
- setTimeout(async () => {
- try {
- const call = await VideoConferenceModel.findOneById(callId);
+ await this.runNewVideoConferenceEvent(callId);
- if (call) {
- await this.endDirectCall(call);
- if (call.status !== VideoConferenceStatus.CALLING) {
- return;
- }
+ if (!SKIP_DISCUSSIONS_ON_CHANNEL_CONFERENCES) {
+ await this.maybeCreateDiscussion(callId, user);
+ }
- await this.cancel(user._id, callId);
- }
- } catch {
- // Ignore errors on this timeout
+ const call = (await this.getUnfiltered(callId)) as IDirectVideoConference | null;
+ if (!call) {
+ throw new Error('failed-to-create-direct-call');
}
- }, 40000);
+ const url = await this.generateNewUrl(call);
+ await VideoConferenceModel.setUrlById(callId, url);
- await this.sendPushNotification(call, calleeId);
+ const messageId = await this.createMessage(call, user);
+ call.messages.started = messageId;
+ await VideoConferenceModel.setMessageById(callId, 'started', messageId);
- return {
- type: 'direct',
- callId,
- calleeId,
- };
+ // After 40 seconds if the status is still "calling", we cancel the call automatically.
+ setTimeout(async () => {
+ try {
+ const call = await VideoConferenceModel.findOneById(callId);
+
+ if (call) {
+ await this.endDirectCall(call);
+ if (call.status !== VideoConferenceStatus.CALLING) {
+ return;
+ }
+
+ await this.cancel(user._id, callId);
+ }
+ } catch {
+ // Ignore errors on this timeout
+ }
+ }, 40000);
+
+ await this.sendPushNotification(call, calleeId);
+
+ return {
+ type: 'direct',
+ callId,
+ calleeId,
+ };
+ } catch (err) {
+ logger.error({
+ msg: 'Error on VideoConf.startDirect',
+ err,
+ });
+ throw err;
+ }
}
private async notifyUsersOfRoom(
@@ -827,6 +1142,84 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
await subscriptions.forEach((subscription) => this.notifyUser(subscription.u._id, action, params));
}
+ private makeSipAlias(): string {
+ logger.debug({
+ msg: 'VideoConf.makeSipAlias',
+ });
+
+ const result: number[] = [];
+ const buffer = new Uint8Array(16);
+ crypto.getRandomValues(buffer);
+
+ let bufferIndex = 0;
+
+ const nextByte = (): number => {
+ if (bufferIndex >= buffer.length) {
+ crypto.getRandomValues(buffer);
+ bufferIndex = 0;
+ }
+ return buffer[bufferIndex++];
+ };
+
+ while (result.length === 0) {
+ const value = nextByte();
+ if (value < 252) {
+ result.push((value % 9) + 1);
+ }
+ }
+
+ while (result.length < 8) {
+ const value = nextByte();
+ if (value < 250) {
+ result.push(value % 10);
+ }
+ }
+
+ return result.join('');
+ }
+
+ private async addSipAlias(callId: string, attempt = 0): Promise {
+ logger.debug({
+ msg: 'VideoConf.addSipAlias',
+ callId,
+ attempt,
+ });
+ const alias = this.makeSipAlias();
+
+ try {
+ await VideoConferenceModel.setSipAliasById(callId, alias);
+ return alias;
+ } catch (err) {
+ if (err && typeof err === 'object' && err instanceof Error && err.message.includes('E11000')) {
+ if (attempt >= 20) {
+ logger.error({ msg: 'Failed to generate a unique SIP alias for this conference.', err });
+ return null;
+ }
+ return this.addSipAlias(callId, attempt + 1);
+ }
+
+ logger.error({ msg: 'Failed to add Sip Alias to video conference', err });
+ return null;
+ }
+ }
+
+ private async maybeAddSipAliasToCall(callId: string, providerName: string): Promise {
+ logger.debug({
+ msg: 'VideoConf.maybeAddSipAliasToCall',
+ callId,
+ });
+
+ if (providerName !== 'core.pexip') {
+ return;
+ }
+
+ if (!settings.get('Pexip_Integration_SIP_AddAlias')) {
+ return;
+ }
+
+ await this.addSipAlias(callId);
+ }
+
private async startGroup(
providerName: string,
user: IUser,
@@ -835,45 +1228,63 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
extraData?: Partial,
useAppUser = true,
): Promise {
- const callId = await VideoConferenceModel.createGroup({
- ...extraData,
+ logger.debug({
+ msg: 'VideoConf.startGroup',
rid,
- title,
- createdBy: {
- _id: user._id,
- name: user.name as string,
- username: user.username as string,
- },
- providerName,
+ uid: user._id,
});
- await this.runNewVideoConferenceEvent(callId);
+ try {
+ const callId = await VideoConferenceModel.createGroup({
+ ...extraData,
+ rid,
+ title,
+ createdBy: {
+ _id: user._id,
+ name: user.name as string,
+ username: user.username as string,
+ },
+ providerName,
+ });
- await this.maybeCreateDiscussion(callId, user);
+ await this.maybeAddSipAliasToCall(callId, providerName);
- const call = (await this.getUnfiltered(callId)) as IGroupVideoConference | null;
- if (!call) {
- throw new Error('failed-to-create-group-call');
- }
+ await this.runNewVideoConferenceEvent(callId);
- const url = await this.generateNewUrl(call);
- await VideoConferenceModel.setUrlById(callId, url);
+ if (!SKIP_DISCUSSIONS_ON_CHANNEL_CONFERENCES) {
+ await this.maybeCreateDiscussion(callId, user);
+ }
- call.url = url;
+ const call = (await this.getUnfiltered(callId)) as IGroupVideoConference | null;
+ if (!call) {
+ throw new Error('failed-to-create-group-call');
+ }
- const messageId = await this.createMessage(call, useAppUser ? undefined : user);
- call.messages.started = messageId;
- await VideoConferenceModel.setMessageById(callId, 'started', messageId);
+ const url = await this.generateNewUrl(call);
+ await VideoConferenceModel.setUrlById(callId, url);
- if (call.ringing) {
- await this.notifyUsersOfRoom(rid, user._id, 'ring', { callId, rid, uid: call.createdBy._id });
- }
+ call.url = url;
- return {
- type: 'videoconference',
- callId,
- rid,
- };
+ const messageId = await this.createMessage(call, useAppUser ? undefined : user);
+ call.messages.started = messageId;
+ await VideoConferenceModel.setMessageById(callId, 'started', messageId);
+
+ if (call.ringing) {
+ await this.notifyUsersOfRoom(rid, user._id, 'ring', { callId, rid, uid: call.createdBy._id });
+ }
+
+ return {
+ type: 'videoconference',
+ callId,
+ rid,
+ };
+ } catch (err) {
+ logger.error({
+ msg: 'Error on VideoConf.startGroup',
+ err,
+ });
+ throw err;
+ }
}
private async startLivechat(providerName: string, user: IUser, rid: IRoom['_id']): Promise {
@@ -907,16 +1318,30 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
};
}
- private async joinCall(
+ public async joinCall(
call: ExternalVideoConference,
user: AtLeast | undefined,
options: VideoConferenceJoinOptions,
): Promise {
- void callbacks.runAsync('onJoinVideoConference', call._id, user?._id);
+ logger.debug({
+ msg: 'VideoConf.joinCall',
+ uid: user?._id,
+ });
+
+ try {
+ void callbacks.runAsync('onJoinVideoConference', call._id, user?._id);
- await this.runOnUserJoinEvent(call._id, user as IVideoConferenceUser);
+ await this.runOnUserJoinEvent(call._id, user as IVideoConferenceUser);
- return this.getUrl(call, user, options);
+ const url = await this.getUrl(call, user, options);
+ return url;
+ } catch (err) {
+ logger.error({
+ msg: 'Error on VideoConf.joinCall',
+ err,
+ });
+ throw err;
+ }
}
private async getProviderManager(): Promise {
@@ -939,6 +1364,11 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
}
private async generateNewUrl(call: ExternalVideoConference): Promise {
+ logger.debug({
+ msg: 'VideoConf.generateNewUrl',
+ callId: call._id,
+ });
+
if (!videoConfProviders.isProviderAvailable(call.providerName)) {
throw new Error('video-conf-provider-unavailable');
}
@@ -1002,72 +1432,106 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
return 'Rocket.Chat';
}
+ private requireCallUrl(call: ExternalVideoConference): asserts call is RequiredField {
+ if (!call.url) {
+ throw new Error('Call url is missing');
+ }
+ }
+
private async getUrl(
call: ExternalVideoConference,
user?: AtLeast,
options: VideoConferenceJoinOptions = {},
): Promise {
- if (!videoConfProviders.isProviderAvailable(call.providerName)) {
- throw new Error('video-conf-provider-unavailable');
- }
+ logger.debug({
+ msg: 'VideoConf.getUrl',
+ callId: call._id,
+ uid: user?._id,
+ });
- if (!call.url) {
- call.url = await this.generateNewUrl(call);
- await VideoConferenceModel.setUrlById(call._id, call.url);
- }
+ return wrapExceptions(async (): Promise => {
+ if (!videoConfProviders.isProviderAvailable(call.providerName)) {
+ throw new Error('video-conf-provider-unavailable');
+ }
- const userData = user && {
- _id: user._id,
- username: user.username as string,
- name: user.name as string,
- avatarETag: user.avatarETag || null,
- ts: new Date(),
- };
+ if (!call.url) {
+ call.url = await this.generateNewUrl(call);
+ await VideoConferenceModel.setUrlById(call._id, call.url);
+ }
+ this.requireCallUrl(call);
- const provider = videoConfProviders.getVideoConfProviderHandler(call.providerName);
- if (provider) {
- // TODO: compensate for the call title?
- return provider.customizeUrl(call, userData);
- }
+ 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, options);
+ }
- const callData: VideoConfDataExtended = {
- _id: call._id,
- type: call.type,
- rid: call.rid,
- url: call.url,
- createdBy: call.createdBy as Required,
- providerData: {
- ...(call.providerData || {}),
- ...{ customCallTitle: await this.getCallTitleForUser(call, user?._id) },
- },
- title: await this.getCallTitle(call),
- discussionRid: call.discussionRid,
- };
+ const callData: VideoConfDataExtended = {
+ _id: call._id,
+ type: call.type,
+ rid: call.rid,
+ url: call.url,
+ createdBy: call.createdBy,
+ providerData: {
+ ...(call.providerData || {}),
+ ...{ customCallTitle: await this.getCallTitleForUser(call, user?._id) },
+ },
+ title: await this.getCallTitle(call),
+ discussionRid: call.discussionRid,
+ };
- return (await this.getProviderManager()).customizeUrl(call.providerName, callData, userData, options);
+ return (await this.getProviderManager()).customizeUrl(call.providerName, callData, userData, options);
+ }).catch((err) => {
+ logger.error({
+ msg: 'Error on VideoConf.getUrl',
+ err,
+ });
+ throw err;
+ });
}
private async runNewVideoConferenceEvent(callId: VideoConference['_id']): Promise {
- const call = await VideoConferenceModel.findOneById(callId);
+ logger.debug({
+ msg: 'VideoConf.runNewVideoConferenceEvent',
+ callId,
+ });
- if (!call) {
- throw new Error('video-conf-data-not-found');
- }
+ return wrapExceptions(async (): Promise => {
+ const call = await VideoConferenceModel.findOneById(callId);
- if (!videoConfTypes.isCallManagedByApp(call)) {
- return;
- }
+ if (!call) {
+ throw new Error('video-conf-data-not-found');
+ }
- if (!videoConfProviders.isProviderAvailable(call.providerName)) {
- throw new Error('video-conf-provider-unavailable');
- }
+ if (!videoConfTypes.isCallManagedByApp(call)) {
+ return;
+ }
- const provider = videoConfProviders.getVideoConfProviderHandler(call.providerName);
- if (provider) {
- return provider.onNewVideoConference(call);
- }
+ if (!videoConfProviders.isProviderAvailable(call.providerName)) {
+ 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);
+ return (await this.getProviderManager()).onNewVideoConference(call.providerName, call);
+ }).catch((err) => {
+ logger.error({
+ msg: 'Error on VideoConf.runNewVideoConferenceEvent',
+ err,
+ });
+ throw err;
+ });
}
private async runVideoConferenceChangedEvent(callId: VideoConference['_id']): Promise {
@@ -1094,49 +1558,79 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
}
private async runOnUserJoinEvent(callId: VideoConference['_id'], user?: IVideoConferenceUser): Promise {
- const call = await VideoConferenceModel.findOneById(callId);
+ logger.debug({
+ msg: 'VideoConf.runOnUserJoinEvent',
+ callId,
+ uid: user?._id,
+ });
- if (!call) {
- throw new Error('video-conf-data-not-found');
- }
+ return wrapExceptions(async (): Promise => {
+ const call = await VideoConferenceModel.findOneById(callId);
- if (!videoConfTypes.isCallManagedByApp(call)) {
- return;
- }
+ if (!call) {
+ throw new Error('video-conf-data-not-found');
+ }
- if (!videoConfProviders.isProviderAvailable(call.providerName)) {
- throw new Error('video-conf-provider-unavailable');
- }
+ if (!videoConfTypes.isCallManagedByApp(call)) {
+ return;
+ }
- const provider = videoConfProviders.getVideoConfProviderHandler(call.providerName);
- if (provider) {
- return;
- }
+ if (!videoConfProviders.isProviderAvailable(call.providerName)) {
+ throw new Error('video-conf-provider-unavailable');
+ }
- return (await this.getProviderManager()).onUserJoin(call.providerName, call, user);
+ const provider = videoConfProviders.getVideoConfProviderHandler(call.providerName);
+ if (provider) {
+ return provider.onUserJoin(call, user);
+ }
+
+ return (await this.getProviderManager()).onUserJoin(call.providerName, call, user);
+ }).catch((err) => {
+ logger.error({
+ msg: 'Error on VideoConf.runOnUserJoinEvent',
+ err,
+ });
+ throw err;
+ });
}
private async addUserToCall(
call: Optional,
{ _id, username, name, avatarETag, ts }: AtLeast, '_id' | 'username' | 'name' | 'avatarETag'> & { ts?: Date },
): Promise {
- // If the call has a discussion, ensure the user is subscribed to it;
- // This is done even if the user has already joined the call before, so they can be added back if they had left the discussion.
- if (call.discussionRid) {
- await this.addUserToDiscussion(call.discussionRid, _id);
- }
+ logger.debug({
+ msg: 'VideoConf.addUserToCall',
+ callId: call._id,
+ uid: _id,
+ username,
+ name,
+ });
- if (call.users.find((user) => user._id === _id)) {
- return;
- }
+ return wrapExceptions(async () => {
+ // If the call has a discussion, ensure the user is subscribed to it;
+ // This is done even if the user has already joined the call before, so they can be added back if they had left the discussion.
+ if (call.discussionRid) {
+ await this.addUserToDiscussion(call.discussionRid, _id);
+ }
- await VideoConferenceModel.addUserById(call._id, { _id, username, name, avatarETag, ts });
+ if (call.users.find((user) => user._id === _id)) {
+ return;
+ }
- if (call.type === 'direct') {
- return this.updateDirectCall(call as IDirectVideoConference, _id);
- }
+ await VideoConferenceModel.addUserById(call._id, { _id, username, name, avatarETag, ts });
+
+ if (call.type === 'direct') {
+ return this.updateDirectCall(call, _id);
+ }
- this.notifyVideoConfUpdate(call.rid, call._id);
+ this.notifyVideoConfUpdate(call.rid, call._id);
+ }).catch((err) => {
+ logger.error({
+ msg: 'Error on VideoConf.addUserToCall',
+ err,
+ });
+ throw err;
+ });
}
private async addAnonymousUser(call: Optional): Promise {
@@ -1144,25 +1638,39 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
}
private async updateDirectCall(call: IDirectVideoConference, newUserId: IUser['_id']): Promise {
- // If it's an user that hasn't joined yet
- if (call.ringing && !call.users.find(({ _id }) => _id === newUserId)) {
- this.notifyUser(call.createdBy._id, 'join', { rid: call.rid, uid: newUserId, callId: call._id });
- if (newUserId !== call.createdBy._id) {
- this.notifyUser(newUserId, 'join', { rid: call.rid, uid: newUserId, callId: call._id });
- // If the callee joined the direct call, then we stopped ringing
- await VideoConferenceModel.setRingingById(call._id, false);
+ logger.debug({
+ msg: 'VideoConf.updateDirectCall',
+ callId: call._id,
+ newUserId,
+ });
+
+ try {
+ // If it's an user that hasn't joined yet
+ if (call.ringing && !call.users.find(({ _id }) => _id === newUserId)) {
+ this.notifyUser(call.createdBy._id, 'join', { rid: call.rid, uid: newUserId, callId: call._id });
+ if (newUserId !== call.createdBy._id) {
+ this.notifyUser(newUserId, 'join', { rid: call.rid, uid: newUserId, callId: call._id });
+ // If the callee joined the direct call, then we stopped ringing
+ await VideoConferenceModel.setRingingById(call._id, false);
+ }
}
- }
- if (call.status !== VideoConferenceStatus.CALLING) {
- return;
- }
+ if (call.status !== VideoConferenceStatus.CALLING) {
+ return;
+ }
- await VideoConferenceModel.setStatusById(call._id, VideoConferenceStatus.STARTED);
- this.notifyVideoConfUpdate(call.rid, call._id);
+ await VideoConferenceModel.setStatusById(call._id, VideoConferenceStatus.STARTED);
+ this.notifyVideoConfUpdate(call.rid, call._id);
- await this.runVideoConferenceChangedEvent(call._id);
- await this.sendAllPushNotifications(call._id);
+ await this.runVideoConferenceChangedEvent(call._id);
+ await this.sendAllPushNotifications(call._id);
+ } catch (err) {
+ logger.error({
+ msg: 'Error on VideoConf.updateDirectCall',
+ err,
+ });
+ throw err;
+ }
}
private isPersistentChatEnabled(): boolean {
@@ -1170,60 +1678,268 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
}
private async maybeCreateDiscussion(callId: VideoConference['_id'], createdBy?: IUser): Promise {
+ logger.debug({
+ msg: 'VideoConf.maybeCreateDiscussion',
+ callId,
+ createdBy: createdBy?._id,
+ });
if (!this.isPersistentChatEnabled()) {
return;
}
- const call = await VideoConferenceModel.findOneById(callId, {
- projection: { rid: 1, createdBy: 1, discussionRid: 1, providerName: 1 },
- });
- if (!call) {
- throw new Error('invalid-video-conference');
- }
+ try {
+ const call = await VideoConferenceModel.findOneById(callId, {
+ projection: { rid: 1, createdBy: 1, discussionRid: 1, providerName: 1 },
+ });
+ if (!call) {
+ throw new Error('invalid-video-conference');
+ }
- // If there's already a discussion assigned to it, do not create a new one
- if (call.discussionRid) {
- return;
- }
+ // If there's already a discussion assigned to it, do not create a new one
+ if (call.discussionRid) {
+ return;
+ }
- // If the call provider does not explicitly support persistent chat, do not create discussions
- if (!videoConfProviders.getProviderCapabilities(call.providerName)?.persistentChat) {
- return;
+ // If the call provider does not explicitly support persistent chat, do not create discussions
+ if (!videoConfProviders.getProviderCapabilities(call.providerName)?.persistentChat) {
+ return;
+ }
+
+ await this.createDiscussionForConference(this.getDiscussionDisplayName(), call, createdBy);
+ } catch (err) {
+ logger.error({
+ msg: 'Error on VideoConf.maybeCreateDiscussion',
+ err,
+ });
+ throw err;
}
+ }
+ private getDiscussionDisplayName(): string {
const name = settings.get('VideoConf_Persistent_Chat_Discussion_Name') || i18n.t('[date] Video Call Chat');
- let displayName;
const date = new Date().toISOString().substring(0, 10);
- if (name.includes('[date]')) {
- displayName = name.replace('[date]', date);
- } else {
- displayName = `${date} ${name}`;
+ return name.includes('[date]') ? name.replace('[date]', date) : `${date} ${name}`;
+ }
+
+ private async getUsernamesFromSubscriptionsByRoomId(roomId: string): Promise {
+ const cursor = Subscriptions.findByRoomIdWhenUsernameExists(roomId, { projection: { 'u.username': 1 } });
+ const usernames = (await cursor.toArray()).map((subscription) => subscription.u.username as string);
+
+ return usernames;
+ }
+
+ private async getUsernamesFromRoom(room: AtLeast): Promise {
+ if (room.t === 'd') {
+ return room.usernames || [];
}
- await this.createDiscussionForConference(displayName, call, createdBy);
+ return this.getUsernamesFromSubscriptionsByRoomId(room._id);
+ }
+
+ // Creates a discussion off the conference's room and repoints the conference's `rid` at it so the
+ // chat continues there without exposing the parent room's history to the new participants. For a
+ // DM (which can't grow past two people) the discussion keeps the DM members; for other rooms it
+ // keeps the current conference participants. In both cases the newly selected users are added.
+ public async createConferenceDiscussionWithParticipants(
+ uid: IUser['_id'],
+ conference: AtLeast,
+ usernames: NonNullable[],
+ ): Promise {
+ logger.debug({
+ msg: 'VideoConf.createConferenceDiscussionWithParticipants',
+ callId: conference._id,
+ rid: conference.rid,
+ discussionRid: conference.discussionRid,
+ uid,
+ usernames,
+ });
+
+ try {
+ const baseRoom = await Rooms.findOneById>(conference.rid, {
+ projection: { t: 1, usernames: 1 },
+ });
+ if (!baseRoom) {
+ throw new Error('invalid-room');
+ }
+
+ const user = await Users.findOneById(uid);
+ if (!user) {
+ throw new Error('invalid-user');
+ }
+
+ const parent = await this.getRoomForDiscussion(baseRoom._id);
+ const type = await roomCoordinator.getRoomDirectives(parent.t).getDiscussionType(parent);
+ if (!type) {
+ throw new Error('error-invalid-discussion-type');
+ }
+
+ // Carry over the current participants so they keep the chat: DMs expose them on the room doc,
+ // while channels/groups read them from the room's subscriptions (the conference's `users` list
+ // is only populated by app-based providers, so it's unreliable for the internal provider). The
+ // newly selected users are added on top.
+ const existingMembers = await this.getUsernamesFromRoom(baseRoom);
+ const discussionMembers = conference.discussionRid ? await this.getUsernamesFromSubscriptionsByRoomId(conference.discussionRid) : [];
+
+ const members = [...new Set([...existingMembers, ...discussionMembers, ...usernames])].filter(Boolean);
+
+ const name = this.getDiscussionDisplayName();
+
+ const discussion = await createRoom(
+ type,
+ Random.id(),
+ user,
+ members,
+ false,
+ false,
+ {
+ fname: name,
+ prid: parent._id,
+ encrypted: false,
+ },
+ {
+ creator: user._id,
+ },
+ );
+
+ // Leave a "discussion created" pointer in the original room so its members can follow along.
+ await Message.saveSystemMessage('discussion-created', parent._id, name, user, { drid: discussion._id });
+
+ // The conference's `rid` always stays the original room; the chat to display is driven by
+ // `discussionRid`. This sets it and broadcasts `discussionUpdated` so participants navigate.
+ await this.assignDiscussionToConference(conference._id, discussion._id);
+
+ // Let the newly invited users know with a desktop notification; clicking it opens the discussion.
+ await this.notifyUsersInvitedToConference(user, usernames, conference._id, discussion);
+
+ return discussion._id;
+ } catch (err) {
+ logger.error({
+ msg: 'Error on VideoConf.createConferenceDiscussionWithParticipants',
+ err,
+ });
+ throw err;
+ }
+ }
+
+ public async addUsersToConferenceRoom(
+ uid: IUser['_id'],
+ conference: AtLeast,
+ usernames: NonNullable[],
+ ): Promise {
+ logger.debug({
+ msg: 'VideoConf.addUsersToConferenceRoom',
+ callId: conference._id,
+ rid: conference.rid,
+ discussionRid: conference.discussionRid,
+ uid,
+ usernames,
+ });
+
+ try {
+ const user = await Users.findOneById(uid);
+ if (!user) {
+ throw new Error('invalid-user');
+ }
+
+ // The active conference room is the discussion when one was created, otherwise the original room.
+ const rid = conference.discussionRid || conference.rid;
+
+ const room = await Rooms.findOneById>(rid, {
+ projection: { t: 1, name: 1, fname: 1 },
+ });
+ if (!room) {
+ throw new Error('invalid-room');
+ }
+
+ // Add the users to the existing room (keeping its history) instead of spinning up a discussion.
+ await addUsersToRoomMethod(uid, { rid, users: usernames }, user);
+
+ // Let the added users know with a desktop notification; clicking it opens the room.
+ await this.notifyUsersInvitedToConference(user, usernames, conference._id, room);
+
+ return rid;
+ } catch (err) {
+ logger.error({
+ msg: 'Error on VideoConf.addUsersToConferenceRoom',
+ err,
+ });
+ throw err;
+ }
+ }
+
+ // Sends every added/invited user a desktop notification about the conference; clicking it opens the
+ // room, and the "Join call" action opens the conference directly.
+ private async notifyUsersInvitedToConference(
+ inviter: AtLeast,
+ usernames: NonNullable[],
+ callId: VideoConference['_id'],
+ room: AtLeast,
+ ): Promise {
+ const invitedUsers = await Users.find>(
+ { username: { $in: usernames } },
+ { projection: { language: 1 } },
+ ).toArray();
+
+ const displayName = room.fname || room.name || '';
+
+ for (const invited of invitedUsers) {
+ const text = i18n.t('You_were_invited_to_a_conference', { lng: invited.language });
+ void api.broadcast('notify.desktop', invited._id, {
+ title: displayName,
+ text,
+ // Keep the invite on screen until the user acts on it.
+ requireInteraction: true,
+ // "Join call" button opens the conference directly (desktop app); clicking the body opens the room.
+ actions: [{ action: 'join', title: i18n.t('Join_call', { lng: invited.language }) }],
+ payload: {
+ _id: room._id,
+ rid: room._id,
+ sender: { _id: inviter._id, username: inviter.username as string, name: inviter.name },
+ type: room.t,
+ name: room.name,
+ conferenceId: callId,
+ message: { msg: text },
+ audioNotificationValue: '',
+ },
+ });
+ }
}
private async getRoomForDiscussion(
baseRoom: IRoom['_id'],
childRoomIds: IRoom['_id'][] = [],
): Promise> {
- const room = await Rooms.findOneById>(baseRoom, {
- projection: { t: 1, teamId: 1, prid: 1 },
+ logger.debug({
+ msg: 'VideoConf.getRoomForDiscussion',
+ baseRoom,
+ childRoomIds,
});
- if (!room) {
- throw new Error('invalid-room');
- }
- if (room.prid) {
- if (childRoomIds.includes(room.prid)) {
- throw new Error('Room has circular reference.');
+ return wrapExceptions(async () => {
+ const room = await Rooms.findOneById>(baseRoom, {
+ projection: { t: 1, teamId: 1, prid: 1 },
+ });
+ if (!room) {
+ throw new Error('invalid-room');
}
- return this.getRoomForDiscussion(room.prid, [...childRoomIds, room._id]);
- }
+ if (room.prid) {
+ if (childRoomIds.includes(room.prid)) {
+ throw new Error('Room has circular reference.');
+ }
+
+ return this.getRoomForDiscussion(room.prid, [...childRoomIds, room._id]);
+ }
- return room;
+ return room;
+ }).catch((err) => {
+ logger.error({
+ msg: 'Error on VideoConf.getRoomForDiscussion',
+ err,
+ });
+ throw err;
+ });
}
private async createDiscussionForConference(
@@ -1231,18 +1947,43 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
call: AtLeast,
createdBy?: IUser,
): Promise {
- const room = await this.getRoomForDiscussion(call.rid);
+ logger.debug({
+ msg: 'VideoConf.createDiscussionForConference',
+ callId: call._id,
+ createdBy: createdBy?._id,
+ });
+
+ return wrapExceptions(async () => {
+ const user = call.createdBy._id === createdBy?._id ? createdBy : await Users.findOneById(call.createdBy._id);
+ if (!user) {
+ throw new Error('invalid-user');
+ }
+
+ const discussionRid = await this.createDiscussionForConferenceData(name, call.rid, user);
+ return this.assignDiscussionToConference(call._id, discussionRid);
+ }).catch((err) => {
+ logger.error({
+ msg: 'Error on VideoConf.createDiscussionForConference',
+ err,
+ });
+ throw err;
+ });
+ }
+
+ private async createDiscussionForConferenceData(name: string, rid: string, createdBy: IUser): Promise {
+ logger.debug({
+ msg: 'VideoConf.createDiscussionForConferenceData',
+ rid,
+ createdBy: createdBy?._id,
+ });
+ const room = await this.getRoomForDiscussion(rid);
const type = await roomCoordinator.getRoomDirectives(room.t).getDiscussionType(room);
- const user = call.createdBy._id === createdBy?._id ? createdBy : await Users.findOneById(call.createdBy._id);
- if (!user) {
- throw new Error('invalid-user');
- }
const discussion = await createRoom(
type,
Random.id(),
- user,
+ createdBy,
[],
false,
false,
@@ -1252,24 +1993,29 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
encrypted: false,
},
{
- creator: user._id,
+ creator: createdBy._id,
subscriptionExtra: {
open: false,
},
},
);
- return this.assignDiscussionToConference(call._id, discussion._id);
+ return discussion._id;
}
public async assignDiscussionToConference(callId: VideoConference['_id'], rid: IRoom['_id'] | undefined): Promise {
+ logger.debug({
+ msg: 'VideoConf.assignDiscussionToConference',
+ callId,
+ rid,
+ });
// Ensures the specified rid is a valid room
const room = rid ? await Rooms.findOneById>(rid, { projection: { prid: 1 } }) : null;
if (rid && !room) {
throw new Error('invalid-room-id');
}
- const call = await VideoConferenceModel.findOneById(callId, { projection: { users: 1, messages: 1 } });
+ const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, users: 1, messages: 1 } });
if (!call) {
return;
}
@@ -1280,12 +2026,25 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
await VideoConferenceModel.setDiscussionRidById(callId, rid);
}
- if (room) {
- await Promise.all(call.users.map(({ _id }) => this.addUserToDiscussion(room._id, _id)));
+ try {
+ if (room) {
+ await Promise.all(call.users.map(({ _id }) => this.addUserToDiscussion(room._id, _id)));
+ }
+ } finally {
+ void api.broadcast('video-conference.discussionUpdated', { callId, discussionRid: rid });
+ // Also refresh the in-room conference message block, which listens on `notify-room/videoconf`
+ // (the same channel used when users join), so its "Join discussion" button updates.
+ this.notifyVideoConfUpdate(call.rid, callId);
}
}
private async addUserToDiscussion(rid: IRoom['_id'], uid: IUser['_id']): Promise {
+ logger.debug({
+ msg: 'VideoConf.addUserToDiscussion',
+ rid,
+ uid,
+ });
+
try {
await Room.addUserToRoom(rid, { _id: uid }, undefined, { skipSystemMessage: true, createAsHidden: true });
} catch (err) {
diff --git a/apps/meteor/server/settings/pexip.ts b/apps/meteor/server/settings/pexip.ts
index 23297b7b5ad8f..9e17e810c9c4f 100644
--- a/apps/meteor/server/settings/pexip.ts
+++ b/apps/meteor/server/settings/pexip.ts
@@ -18,7 +18,7 @@ export function createPexipSettings(): Promise {
i18nDescription: `Pexip_Integration_Base_Url_Description`,
});
- await this.add('Pexip_Integration_Meeting_Url', '/webapp/conference?conference={callId}', {
+ await this.add('Pexip_Integration_Meeting_Url', '/webapp/conference?conference={callId}&join=1', {
type: 'string',
public: true,
invalidValue: '',
@@ -98,6 +98,38 @@ export function createPexipSettings(): Promise {
],
});
});
+
+ await this.section('Pexip_Integration_SIP', async function () {
+ await this.add('Pexip_Integration_SIP_AddAlias', false, {
+ type: 'boolean',
+ public: true,
+ invalidValue: '',
+ i18nDescription: `Pexip_Integration_SIP_AddAlias_Description`,
+ });
+
+ await this.add('Pexip_Integration_SIP_Host', '', {
+ type: 'string',
+ public: true,
+ invalidValue: '',
+ i18nDescription: `Pexip_Integration_SIP_Host_Description`,
+ });
+
+ await this.add('Pexip_Integration_SIP_Port', 5060, {
+ type: 'int',
+ public: true,
+ invalidValue: '',
+ i18nDescription: `Pexip_Integration_SIP_Port_Description`,
+ });
+ });
+
+ await this.section('Pexip_Integration_PersistentChat', async function () {
+ await this.add('Pexip_Integration_PersistentChat_ExternalRoom', '', {
+ type: 'roomPick',
+ public: true,
+ invalidValue: '',
+ i18nDescription: `Pexip_Integration_PersistentChat_ExternalRoom_Description`,
+ });
+ });
});
}
@@ -120,11 +152,15 @@ export function getPexipSettings(): PexipSettings {
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'),
},
+ sip: {
+ addAlias: settings.get('Pexip_Integration_SIP_AddAlias'),
+ host: settings.get('Pexip_Integration_SIP_Host'),
+ port: settings.get('Pexip_Integration_SIP_Port'),
+ },
};
}
diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts
index 7a1c60b52dbd5..4c3bbc574f407 100644
--- a/packages/core-services/src/events/Events.ts
+++ b/packages/core-services/src/events/Events.ts
@@ -162,6 +162,7 @@ export type EventSignatures = {
user: Pick;
previousStatus: UserStatus | undefined;
}): void;
+ 'video-conference.discussionUpdated'(data: { callId: VideoConference['_id']; discussionRid: IRoom['_id'] | undefined }): void;
'watch.messages'(data: { message: IMessage }): void;
'watch.roles'(
data:
diff --git a/packages/core-services/src/types/IVideoConfService.ts b/packages/core-services/src/types/IVideoConfService.ts
index 6d74413ccf211..5549c46db53c3 100644
--- a/packages/core-services/src/types/IVideoConfService.ts
+++ b/packages/core-services/src/types/IVideoConfService.ts
@@ -1,12 +1,16 @@
import type {
+ AtLeast,
+ ExternalVideoConference,
IRoom,
IStats,
IUser,
+ IVideoConference,
IVoIPVideoConference,
VideoConference,
VideoConferenceCapabilities,
VideoConferenceCreateData,
VideoConferenceInstructions,
+ VideoConferenceWithDiscussion,
} from '@rocket.chat/core-typings';
import type { InsertionModel } from '@rocket.chat/model-typings';
import type { PaginatedResult } from '@rocket.chat/rest-typings';
@@ -25,7 +29,10 @@ export interface IVideoConfService {
cancel(uid: IUser['_id'], callId: VideoConference['_id']): Promise;
get(callId: VideoConference['_id']): Promise | null>;
getUnfiltered(callId: VideoConference['_id']): Promise;
- list(roomId: IRoom['_id'], pagination?: { offset?: number; count?: number }): Promise>;
+ list(
+ roomId: IRoom['_id'],
+ pagination?: { offset?: number; count?: number },
+ ): Promise>;
setProviderData(callId: VideoConference['_id'], data: VideoConference['providerData'] | undefined): Promise;
setEndedBy(callId: VideoConference['_id'], endedBy: IUser['_id']): Promise;
setEndedAt(callId: VideoConference['_id'], endedAt: Date): Promise;
@@ -43,5 +50,23 @@ export interface IVideoConfService {
params: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] },
): Promise;
assignDiscussionToConference(callId: VideoConference['_id'], rid: IRoom['_id'] | undefined): Promise;
+ createConferenceDiscussionWithParticipants(
+ uid: IUser['_id'],
+ conference: AtLeast,
+ usernames: NonNullable[],
+ ): Promise;
+ addUsersToConferenceRoom(
+ uid: IUser['_id'],
+ conference: AtLeast,
+ usernames: NonNullable[],
+ ): Promise;
createVoIP(data: InsertionModel): Promise;
+ joinCall(
+ call: ExternalVideoConference,
+ user: AtLeast | undefined,
+ options: VideoConferenceJoinOptions,
+ ): Promise;
+ getRidForExternalConference(): Promise;
+ makePersistentChatUrlForConference(conferenceId: string): Promise;
+ initializeOrJoinScheduledConference(sipAlias: string, uid: IUser['_id']): Promise;
}
diff --git a/packages/core-typings/src/INotification.ts b/packages/core-typings/src/INotification.ts
index c9da1a8ae8090..8ec91731d8cef 100644
--- a/packages/core-typings/src/INotification.ts
+++ b/packages/core-typings/src/INotification.ts
@@ -54,6 +54,14 @@ export interface INotificationDesktop {
text: string;
icon?: string;
duration?: number;
+ // Force the notification to stay until the user interacts with it, regardless of the recipient's
+ // `desktopNotificationRequireInteraction` preference.
+ requireInteraction?: boolean;
+ // Optional action buttons rendered on the notification (desktop app only; ignored elsewhere).
+ actions?: {
+ action: string;
+ title: string;
+ }[];
payload: {
_id: IMessage['_id'];
rid: IMessage['rid'];
@@ -61,6 +69,8 @@ export interface INotificationDesktop {
sender: IMessage['u'];
type: IRoom['t'];
name: IRoom['name'];
+ // When set, the notification can offer a "Join" action that opens this conference directly.
+ conferenceId?: string;
message: {
msg: IMessage['msg'];
t?: IMessage['t'];
diff --git a/packages/core-typings/src/IVideoConference.ts b/packages/core-typings/src/IVideoConference.ts
index 74cb7fc04ee63..1a9d17153b2f2 100644
--- a/packages/core-typings/src/IVideoConference.ts
+++ b/packages/core-typings/src/IVideoConference.ts
@@ -79,6 +79,11 @@ export interface IVideoConference extends IRocketChatRecord {
ringing?: boolean;
discussionRid?: IRoom['_id'];
+
+ sipAlias?: string;
+
+ sipParticipantCount?: number;
+ webrtcParticipantCount?: number;
}
export interface IDirectVideoConference extends IVideoConference {
@@ -120,6 +125,11 @@ type InternalVideoConference = IVoIPVideoConference;
export type VideoConference = ExternalVideoConference | InternalVideoConference;
+export type VideoConferenceWithDiscussion = VideoConference & {
+ discussionTitle?: string;
+ discussionLastMessage?: IMessage;
+};
+
export type VideoConferenceInstructions = DirectCallInstructions | ConferenceInstructions | LivechatInstructions;
export const isDirectVideoConference = (call: VideoConference | undefined | null): call is IDirectVideoConference => {
diff --git a/packages/ddp-client/src/types/streams.ts b/packages/ddp-client/src/types/streams.ts
index 665368b13f654..d1d9a6b20c7ac 100644
--- a/packages/ddp-client/src/types/streams.ts
+++ b/packages/ddp-client/src/types/streams.ts
@@ -472,6 +472,9 @@ export interface StreamerEvents {
{ key: 'command/removed'; args: [string] },
{ key: 'actions/changed'; args: [] },
];
+
+ 'video-conference': [{ key: `${string}/discussionUpdated`; args: [{ discussionRid: IRoom['_id'] | undefined }] }];
+
'local': [
{
key: 'broadcast';
diff --git a/packages/desktop-api/src/index.ts b/packages/desktop-api/src/index.ts
index 1f1b45ea3a226..68053f96c52b3 100644
--- a/packages/desktop-api/src/index.ts
+++ b/packages/desktop-api/src/index.ts
@@ -55,6 +55,10 @@ export interface IRocketChatDesktop {
destroyNotification: (id: unknown) => void;
getInternalVideoChatWindowEnabled: () => boolean;
openInternalVideoChatWindow: (url: string, options: VideoChatWindowOptions) => void;
+ // Register a handler for navigation requests sent to the main app window (e.g. a link clicked in
+ // the internal video-chat window). The callback receives a server-relative route (e.g.
+ // "/channel/general"). Optional: only present in desktop builds that implement it.
+ onNavigateToRoute?: (cb: (path: string) => void) => void;
setGitCommitHash: (gitCommitHash: string) => void;
writeTextToClipboard: (text: string) => void;
getOutlookEvents: (date: Date) => Promise;
diff --git a/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx b/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx
index bfdb5d79a2cba..b96e1cb9e1c88 100644
--- a/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx
+++ b/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx
@@ -38,7 +38,7 @@ const VideoConferenceBlock = ({ block }: VideoConferenceBlockProps) => {
const displayAvatars = useUserPreference('displayAvatars');
const showRealName = useSetting('UI_Use_Real_Name', false);
- const { action, viewId = undefined, rid } = useContext(UiKitContext);
+ const { action, viewId = undefined, rid, videoConfJoinDisabled = false } = useContext(UiKitContext);
if (surfaceType !== 'message') {
throw new Error('VideoConferenceBlock cannot be rendered outside message');
@@ -134,7 +134,7 @@ const VideoConferenceBlock = ({ block }: VideoConferenceBlockProps) => {
const actions = (
- {data.discussionRid && }
+ {data.discussionRid && }
);
@@ -152,7 +152,9 @@ const VideoConferenceBlock = ({ block }: VideoConferenceBlockProps) => {
{data.type === 'direct' && (
<>
- {isUserCaller ? t('Call_again') : t('Call_back')}
+
+ {isUserCaller ? t('Call_again') : t('Call_back')}
+
{[VideoConferenceStatus.EXPIRED, VideoConferenceStatus.DECLINED].includes(data.status) && (
{t('Call_was_not_answered')}
)}
@@ -201,7 +203,7 @@ const VideoConferenceBlock = ({ block }: VideoConferenceBlockProps) => {
{actions}
-
+
{t('Join')}
{Boolean(data.users.length) && (
diff --git a/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts b/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts
index 3cf1b5efa341c..5d986b9df4a7c 100644
--- a/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts
+++ b/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts
@@ -22,6 +22,9 @@ type UiKitContextValue = {
values: Record;
viewId?: string;
rid?: string;
+ // When set, video conference join/call-back actions in message blocks are disabled (e.g. while
+ // the chat is rendered inside a conference window, to stop opening other conferences).
+ videoConfJoinDisabled?: boolean;
};
export const UiKitContext = createContext({
diff --git a/packages/i18n/src/locales/de.i18n.json b/packages/i18n/src/locales/de.i18n.json
index 9342627955e74..ba0263934634c 100644
--- a/packages/i18n/src/locales/de.i18n.json
+++ b/packages/i18n/src/locales/de.i18n.json
@@ -493,6 +493,7 @@
"Add_monitor": "Monitor hinzufügen",
"Add_more_users": "Weitere Benutzer hinzufügen",
"Add_number": "Nummer hinzufügen",
+ "Add_participants": "Teilnehmer hinzufügen",
"Add_people": "Personen hinzufügen",
"Add_phone": "Telefonnummer hinzufügen",
"Add_them": "Hinzufügen",
@@ -1047,6 +1048,7 @@
"Call_Information": "Anrufinformationen",
"Call_again": "Erneut anrufen",
"Call_back": "Rückruf",
+ "Call_chat": "Anruf-Chat",
"Call_declined": "Anruf abgelehnt!",
"Call_ended": "Anruf beendet",
"Call_ended_bold": "*Sprachanruf beendet*",
@@ -1128,6 +1130,7 @@
"Channels_are_where_your_team_communicate": "In Channels kommuniziert Ihr Team",
"Channels_list": "Liste der öffentlichen Channels",
"Chart": "Diagramm",
+ "Chat": "Chat",
"Chat_Duration": "Chat-Dauer",
"Chat_History": "Chat-Verlauf",
"Chat_Now": "Jetzt chatten",
@@ -1267,7 +1270,9 @@
"Condition": "Bedingung",
"Conference_call_apps": "Anwendungen für Telefonkonferenzen",
"Conference_call_has_ended": "_Anruf wurde beendet._",
+ "Conference_call_history": "Konferenzanruf-Verlauf",
"Conference_name": "Konferenzname",
+ "Conference_will_close_in_seconds": "Diese Konferenz wird in {{count}} Sekunden geschlossen.",
"Configuration_update": "Konfigurationsaktualisierung",
"Configuration_update_confirmed": "Konfigurationsaktualisierung bestätigt",
"Configure_Incoming_Mail_IMAP": "Konfigurieren des Posteingangs (IMAP)",
@@ -1283,6 +1288,7 @@
"Confirm_new_workspace": "Neuen Arbeitsbereich bestätigen",
"Confirm_new_workspace_description": "Identifikationsdaten und Cloud-Verbindungsdaten werden zurückgesetzt.
Warnung: Beim Ändern der Arbeitsbereichs-URL kann die Lizenz betroffen sein.",
"Confirm_password": "Bestätigen Sie Ihr Passwort",
+ "Conference_started_by__name__": "Konferenz gestartet von {{name}}",
"Confirm_your_password": "Bestätigen Sie Ihr Passwort",
"Confirm_contact_removal": "Entfernen des Kontakts bestätigen",
"Confirmation": "Bestätigung",
@@ -1619,6 +1625,7 @@
"Create_custom_field": "Benutzerdefiniertes Feld erstellen",
"Create_department": "Abteilung erstellen",
"Create_direct_message": "Neue Direktnachricht",
+ "Create_discussion": "Diskussion erstellen",
"Create_new": "Neu erstellen",
"Create_new_members": "Neue Mitglieder erstellen",
"Create_tag": "Tag erstellen",
@@ -2846,6 +2853,7 @@
"Join_default_channels": "Standard-Channels beitreten",
"Join_discussion": "Diskussion beitreten",
"Join_my_room_to_start_the_video_call": "Meinen Raum betreten, um den Videoanruf zu starten",
+ "Join_ongoing_call": "Laufendem Anruf beitreten",
"Join_rooms": "Kanälen beitreten",
"Join_the_Community": "Der Community beitreten",
"Join_the_given_channel": "Diesem Channel beitreten",
@@ -2864,6 +2872,8 @@
"Katex_Enabled_Description": "Erlauben von [KaTeX](http://khan.github.io/KaTeX/) für mathematische Ausdrücke in Nachrichten",
"Katex_Parenthesis_Syntax": "Klammer-Syntax erlauben",
"Katex_Parenthesis_Syntax_Description": "\\[KaTeX Block\\] und \\ (inline KaTeX \\) Syntax erlauben",
+ "Keep_chat_history": "Chat-Verlauf behalten",
+ "Keep_open": "Geöffnet lassen",
"Keep_default_user_settings": "Standardeinstellungen beibehalten",
"Keep_editing": "Weiter bearbeiten",
"Keyboard_Shortcut_Key_Alt": "Alt",
@@ -4062,6 +4072,7 @@
"On_Hold_Chats": "Angehalten",
"On_Hold_conversations": "Gespräche in der Warteschleife",
"Once": "Einmalig",
+ "Ongoing_calls": "Laufende Anrufe",
"Online": "Online",
"Only_Members_Selected_Department_Can_View_Channel": "Nur die Mitglieder der ausgewählten Abteilung können Chats in diesem Kanal anzeigen",
"Only_txt_license_files_are_supported": "Nur .txt-Lizenzdateien werden unterstützt",
@@ -4174,6 +4185,7 @@
"Password_must_meet_the_complexity_requirements": "Das Passwort muss die Komplexitätsanforderungen erfüllen.",
"Password_to_access": "Passwort",
"Passwords_do_not_match": "Passwörter stimmen nicht überein",
+ "Past_calls": "Vergangene Anrufe",
"Past_Chats": "Vergangene Chats",
"Paste": "Einfügen",
"Paste_error": "Fehler beim Lesen aus der Zwischenablage",
@@ -4226,9 +4238,19 @@
"Pexip_Integration_Meeting_Url_Description": "Der Pfad, der zur Basis-URL hinzugefügt wird, um die vollständige Meeting-URL zu bilden.",
"Pexip_Integration_Overlay_Text": "Overlay-Text mit Teilnehmernamen",
"Pexip_Integration_Overlay_Text_Description": "Die Anzeigenamen oder Aliase aller Teilnehmer werden in einem Text-Overlay am unteren Rand ihres Videobildes angezeigt.",
+ "Pexip_Integration_PersistentChat": "Persistenter Chat",
+ "Pexip_Integration_PersistentChat_ExternalRoom": "Übergeordneter Raum für externe Konferenzen",
+ "Pexip_Integration_PersistentChat_ExternalRoom_Description": "Legen Sie einen Raum fest, der als übergeordneter Raum verwendet wird, wenn eine Konferenz automatisch erstellt wird.",
"Pexip_Integration_Pins": "Statische PINs",
"Pexip_Integration_Theme_Name": "Theme-Name",
"Pexip_Integration_Theme_Name_Description": "Name des Pexip-Themes, das für Rocket.Chat-Anrufe verwendet werden soll",
+ "Pexip_Integration_SIP": "SIP",
+ "Pexip_Integration_SIP_AddAlias": "Numerischen Alias zu Konferenzen hinzufügen",
+ "Pexip_Integration_SIP_AddAlias_Description": "Erstellt für jede neue Konferenz einen Alias, der nur aus Ziffern besteht",
+ "Pexip_Integration_SIP_Host": "SIP-Host",
+ "Pexip_Integration_SIP_Host_Description": "Der Host, der beim Weiterleiten von Benutzern an eine Konferenz über SIP verwendet werden soll.",
+ "Pexip_Integration_SIP_Port": "SIP-Port",
+ "Pexip_Integration_SIP_Port_Description": "Der Port, der beim Weiterleiten von Benutzern an eine Konferenz über SIP verwendet werden soll.",
"Pharmaceutical": "Pharamzeutisch",
"Phone": "Telefon",
"Phone_Number": "Telefonnummer",
@@ -5609,6 +5631,7 @@
"Unable_to_load_active_connections": "Aktive Verbindungen können nicht geladen werden",
"Unable_to_make_calls_while_another_is_ongoing": "Während eines laufenden Anrufs können keine weiteren Anrufe getätigt werden",
"Unable_to_negotiate_call_params": "Anrufparameter konnten nicht ausgehandelt werden.",
+ "Unable_to_start_video_call": "Videoanruf kann nicht gestartet werden.",
"Unarchive": "Aus dem Archiv holen",
"Unassign_extension": "Durchwahl-Zuweisung aufheben",
"Unassigned": "Nicht zugewiesen",
@@ -6116,6 +6139,7 @@
"You_do_not_have_permission_to_execute_this_command": "Sie haben nicht genügend Berechtigungen, um den Befehl auszuführen: `/{{command}}`",
"You_followed_this_message": "Sie folgen dieser Nachricht",
"You_have_a_new_message": "Sie haben eine neue Nachricht",
+ "You_have_been_disconnected": "Sie wurden getrennt",
"You_have_been_muted": "Ihnen wurde das Chatten in diesem Raum verboten",
"You_have_been_removed_from__roomName_": "Sie wurden aus dem Raum {{roomName}} entfernt",
"You_have_created_user": "Sie haben 1 Benutzer erstellt",
@@ -6141,6 +6165,7 @@
"You_should_name_it_to_easily_manage_your_integrations": "Zur einfacheren Verwaltung der Integrationen empfehlen wir, der Integration einen Namen zu geben.",
"You_unfollowed_this_message": "Sie abonnieren diese Nachricht nicht mehr.",
"You_users_and_more_Reacted_with": "Du, {{users}} und {{counter}} haben mehr mit {{emoji}} reagiert",
+ "You_were_invited_to_a_conference": "Sie wurden zu einer Konferenz eingeladen",
"You_will_be_asked_for_permissions": "Sie werden nach Berechtigungen gefragt",
"You_will_not_be_able_to_recover": "Die Nachricht kann anschließend nicht wiederhergestellt werden",
"You_will_not_be_able_to_recover_email_inbox": "Sie werden dieses E-Mail-Postfach nicht wiederherstellen können",
@@ -7296,6 +7321,7 @@
"__count__without__department__": "{{count}} ohne Abteilung",
"__count__without__tags__": "{{count}} ohne Tags",
"__departments__departments_and__count__conversations__period__": "{{departments}} Abteilungen und {{count}} Konversationen, {{period}}",
+ "__param__Video_Call_Chat": "{{param}} Videoanruf-Chat",
"__roomName__encryption_keys_need_to_be_updated": "Die Verschlüsselungsschlüssel von {{roomName}} müssen aktualisiert werden, um Ihnen Zugriff zu geben. Dafür muss ein anderes Raummitglied online sein.",
"__roomName__is_encrypted": "{{roomName}} ist verschlüsselt",
"__roomName__was_added_to_favorites": "{{roomName}} wurde zu den Favoriten hinzugefügt",
diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json
index d303b49b05b0c..3c61e18a90489 100644
--- a/packages/i18n/src/locales/en.i18n.json
+++ b/packages/i18n/src/locales/en.i18n.json
@@ -572,6 +572,7 @@
"Add_monitor": "Add monitor",
"Add_more_users": "Add more users",
"Add_number": "Add number",
+ "Add_participants": "Add participants",
"Add_people": "Add people",
"Add_phone": "Add phone",
"Add_them": "Add them",
@@ -1126,6 +1127,7 @@
"Call_Information": "Call Information",
"Call_again": "Call again",
"Call_back": "Call back",
+ "Call_chat": "Call chat",
"Call_declined": "Call Declined!",
"Call_ended": "Call ended",
"Call_ended_bold": "*Voice call ended*",
@@ -1210,6 +1212,7 @@
"Channels_are_where_your_team_communicate": "Channels are where your team communicate",
"Channels_list": "List of public channels",
"Chart": "Chart",
+ "Chat": "Chat",
"Chat_Duration": "Chat Duration",
"Chat_History": "Chat History",
"Chat_Now": "Chat Now",
@@ -1349,7 +1352,9 @@
"Condition": "Condition",
"Conference_call_apps": "Conference call apps",
"Conference_call_has_ended": "_Call has ended._",
+ "Conference_call_history": "Conference call history",
"Conference_name": "Conference name",
+ "Conference_will_close_in_seconds": "This conference will close in {{count}} seconds.",
"Configuration_update": "Configuration update",
"Configuration_update_confirmed": "Configuration update confirmed",
"Configure_Incoming_Mail_IMAP": "Configure Incoming Mail (IMAP)",
@@ -1365,6 +1370,7 @@
"Confirm_new_workspace": "Confirm new workspace",
"Confirm_new_workspace_description": "Identification data and cloud connection data will be reset.
Warning: License can be affected if changing workspace URL.",
"Confirm_password": "Confirm password",
+ "Conference_started_by__name__": "Conference started by {{name}}",
"Confirm_your_password": "Confirm your password",
"Confirm_contact_removal": "Confirm Contact Removal",
"Confirmation": "Confirmation",
@@ -1704,6 +1710,7 @@
"Create_custom_field": "Create custom field",
"Create_department": "Create department",
"Create_direct_message": "New direct message",
+ "Create_discussion": "Create discussion",
"Create_new": "Create new",
"Create_new_members": "Create New Members",
"Create_tag": "Create tag",
@@ -2956,6 +2963,7 @@
"Join_default_channels": "Join default channels",
"Join_discussion": "Join discussion",
"Join_my_room_to_start_the_video_call": "Join my room to start the video call",
+ "Join_ongoing_call": "Join ongoing call",
"Join_rooms": "Join rooms",
"Join_the_Community": "Join the Community",
"Join_the_given_channel": "Join the given channel",
@@ -2974,6 +2982,8 @@
"Katex_Enabled_Description": "Allow using [katex](http://khan.github.io/KaTeX/) for math typesetting in messages",
"Katex_Parenthesis_Syntax": "Allow Parenthesis Syntax",
"Katex_Parenthesis_Syntax_Description": "Allow using \\[katex block\\] and \\(inline katex\\) syntaxes",
+ "Keep_chat_history": "Keep chat history",
+ "Keep_open": "Keep open",
"Keep_default_user_settings": "Keep the default settings",
"Keep_editing": "Keep editing",
"Keyboard_Shortcut_Key_Alt": "Alt",
@@ -4184,6 +4194,7 @@
"On_Hold_Chats": "On hold",
"On_Hold_conversations": "On hold conversations",
"Once": "Once",
+ "Ongoing_calls": "Ongoing calls",
"Online": "Online",
"Only_Members_Selected_Department_Can_View_Channel": "Only members of selected department can view chats on this channel",
"Only_txt_license_files_are_supported": "Only .txt license files are supported",
@@ -4296,6 +4307,7 @@
"Password_must_meet_the_complexity_requirements": "Password must meet the complexity requirements.",
"Password_to_access": "Password to access",
"Passwords_do_not_match": "Passwords do not match",
+ "Past_calls": "Past calls",
"Past_Chats": "Past Chats",
"Paste": "Paste",
"Paste_error": "Error reading from clipboard",
@@ -4348,9 +4360,19 @@
"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_PersistentChat": "Persistent Chat",
+ "Pexip_Integration_PersistentChat_ExternalRoom": "Parent Room for External Conferences",
+ "Pexip_Integration_PersistentChat_ExternalRoom_Description": "Define a room to use as parent when a conference is created automatically.",
"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",
+ "Pexip_Integration_SIP": "SIP",
+ "Pexip_Integration_SIP_AddAlias": "Add Numeric Alias to Conferences",
+ "Pexip_Integration_SIP_AddAlias_Description": "Creates an alias for every new conference, using only numeric digits",
+ "Pexip_Integration_SIP_Host": "SIP Host",
+ "Pexip_Integration_SIP_Host_Description": "The host that should be used when transferring users to a conference through SIP.",
+ "Pexip_Integration_SIP_Port": "SIP Port",
+ "Pexip_Integration_SIP_Port_Description": "The port that should be used when transferring users to a conference through SIP.",
"Pharmaceutical": "Pharmaceutical",
"Phone": "Phone",
"Phone_Number": "Phone Number",
@@ -5766,6 +5788,7 @@
"Unable_to_load_active_connections": "Unable to load active connections",
"Unable_to_make_calls_while_another_is_ongoing": "Unable to make calls while another call is ongoing",
"Unable_to_negotiate_call_params": "Unable to negotiate call params.",
+ "Unable_to_start_video_call": "Unable to start video call.",
"Unarchive": "Unarchive",
"Unassign_extension": "Unassign extension",
"Unassigned": "Unassigned",
@@ -6278,6 +6301,7 @@
"You_do_not_have_permission_to_execute_this_command": "You do not have enough permissions to execute command: `/{{command}}`",
"You_followed_this_message": "You followed this message.",
"You_have_a_new_message": "You have a new message",
+ "You_have_been_disconnected": "You have been disconnected",
"You_have_been_muted": "You have been muted and cannot speak in this room",
"You_have_been_removed_from__roomName_": "You've been removed from the room {{roomName}}",
"You_have_created_user": "You’ve created 1 user",
@@ -6303,6 +6327,7 @@
"You_should_name_it_to_easily_manage_your_integrations": "You should name it to easily manage your integrations.",
"You_unfollowed_this_message": "You unfollowed this message.",
"You_users_and_more_Reacted_with": "You, {{users}} and {{counter}} more reacted with {{emoji}}",
+ "You_were_invited_to_a_conference": "You were invited to a conference",
"You_will_be_asked_for_permissions": "You will be asked for permissions",
"You_will_not_be_able_to_recover": "You will not be able to recover this message!",
"You_will_not_be_able_to_recover_email_inbox": "You will not be able to recover this email inbox",
@@ -7463,6 +7488,7 @@
"__count__without__department__": "{{count}} without department",
"__count__without__tags__": "{{count}} without tags",
"__departments__departments_and__count__conversations__period__": "{{departments}} departments and {{count}} conversations, {{period}}",
+ "__param__Video_Call_Chat": "{{param}} Video Call Chat",
"__roomName__encryption_keys_need_to_be_updated": "{{roomName}} encryption keys need to be updated to give you access. Another room member needs to be online for this to happen.",
"__roomName__is_encrypted": "{{roomName}} is encrypted",
"__roomName__was_added_to_favorites": "{{roomName}} was added to favorites",
diff --git a/packages/model-typings/src/models/IVideoConferenceModel.ts b/packages/model-typings/src/models/IVideoConferenceModel.ts
index 66a082af85d2a..ac42496c7733d 100644
--- a/packages/model-typings/src/models/IVideoConferenceModel.ts
+++ b/packages/model-typings/src/models/IVideoConferenceModel.ts
@@ -5,9 +5,10 @@ import type {
IUser,
VideoConference,
VideoConferenceStatus,
+ VideoConferenceWithDiscussion,
IVoIPVideoConference,
} from '@rocket.chat/core-typings';
-import type { FindCursor, UpdateOptions, UpdateFilter, UpdateResult, FindOptions } from 'mongodb';
+import type { AggregationCursor, FindCursor, UpdateOptions, UpdateFilter, UpdateResult, FindOptions } from 'mongodb';
import type { FindPaginated, IBaseModel, InsertionModel } from './IBaseModel';
@@ -15,7 +16,7 @@ export interface IVideoConferenceModel extends IBaseModel {
findPaginatedByRoomId(
rid: IRoom['_id'],
{ offset, count }: { offset?: number; count?: number },
- ): FindPaginated>;
+ ): FindPaginated>;
findAllLongRunning(minDate: Date): Promise>>;
@@ -30,7 +31,8 @@ export interface IVideoConferenceModel extends IBaseModel {
createGroup({
providerName,
...callDetails
- }: Required>): Promise;
+ }: Required> &
+ Pick): Promise;
createLivechat({
providerName,
@@ -43,7 +45,7 @@ export interface IVideoConferenceModel extends IBaseModel {
options?: UpdateOptions,
): Promise;
- setDataById(callId: string, data: Partial>): Promise;
+ setDataById(callId: string, data: Partial>): Promise;
setEndedById(callId: string, endedBy?: { _id: string; name: string; username: string }, endedAt?: Date): Promise;
@@ -70,4 +72,17 @@ export interface IVideoConferenceModel extends IBaseModel {
unsetDiscussionRid(discussionRid: IRoom['_id']): Promise;
createVoIP(call: InsertionModel): Promise;
+
+ setSipAliasById(callId: string, sipAlias: string): Promise;
+
+ unsetSipAliasById(callId: string): Promise;
+ findOneByProviderNameAndSipAlias(
+ providerName: string,
+ sipAlias: string,
+ options?: FindOptions,
+ ): Promise;
+
+ increaseSipParticipantCount(sipAlias: string): Promise;
+
+ increaseWebRTCParticipantCount(conferenceId: string): Promise;
}
diff --git a/packages/models/src/models/VideoConference.ts b/packages/models/src/models/VideoConference.ts
index fbcad1529f959..7de39df2e3d56 100644
--- a/packages/models/src/models/VideoConference.ts
+++ b/packages/models/src/models/VideoConference.ts
@@ -6,10 +6,12 @@ import type {
IRoom,
RocketChatRecordDeleted,
IVoIPVideoConference,
+ VideoConferenceWithDiscussion,
} from '@rocket.chat/core-typings';
import { VideoConferenceStatus } from '@rocket.chat/core-typings';
import type { FindPaginated, InsertionModel, IVideoConferenceModel } from '@rocket.chat/model-typings';
import type {
+ AggregationCursor,
FindCursor,
UpdateOptions,
UpdateFilter,
@@ -18,6 +20,7 @@ import type {
Collection,
Db,
CountDocumentsOptions,
+ FindOptions,
} from 'mongodb';
import { BaseRaw } from './BaseRaw';
@@ -32,26 +35,47 @@ export class VideoConferenceRaw extends BaseRaw implements IVid
{ key: { rid: 1, createdAt: 1 }, unique: false },
{ key: { type: 1, status: 1 }, unique: false },
{ key: { discussionRid: 1 }, unique: false },
+ { key: { providerName: 1, sipAlias: 1 }, unique: true, partialFilterExpression: { sipAlias: { $exists: true } } },
];
}
public findPaginatedByRoomId(
rid: IRoom['_id'],
{ offset, count }: { offset?: number; count?: number } = {},
- ): FindPaginated> {
- // No data is lost — `providerData` is optional — but `Omit` over the `VideoConference` union collapses it into a single
- // object type, so the explicit type argument opts out of projection inference to preserve the discriminated union.
- return this.findPaginated(
- { rid },
+ ): FindPaginated> {
+ // Match conferences started in this room (`rid`) and those whose discussion is this room
+ // (`discussionRid`), so a discussion room resolves the conference it belongs to — its members
+ // may not have access to the parent room the conference originated in.
+ const matchFilter = { $or: [{ rid }, { discussionRid: rid }] };
+ const pipeline: object[] = [
+ { $match: matchFilter },
+ { $sort: { createdAt: -1 } },
+ ...(offset ? [{ $skip: offset }] : []),
+ ...(count ? [{ $limit: count }] : []),
{
- sort: { createdAt: -1 },
- skip: offset,
- limit: count,
- projection: {
- providerData: 0,
+ $lookup: {
+ from: 'rocketchat_room',
+ localField: 'discussionRid',
+ foreignField: '_id',
+ as: 'discussionRoom',
+ pipeline: [{ $project: { fname: 1, name: 1, lastMessage: 1 } }],
},
},
- );
+ {
+ $addFields: {
+ discussionTitle: {
+ $ifNull: [{ $first: '$discussionRoom.fname' }, { $first: '$discussionRoom.name' }],
+ },
+ discussionLastMessage: { $first: '$discussionRoom.lastMessage' },
+ },
+ },
+ { $project: { providerData: 0, discussionRoom: 0 } },
+ ];
+
+ return {
+ cursor: this.col.aggregate(pipeline),
+ totalCount: this.col.countDocuments(matchFilter),
+ };
}
public async findAllLongRunning(minDate: Date): Promise>> {
@@ -106,8 +130,11 @@ export class VideoConferenceRaw extends BaseRaw implements IVid
public async createGroup({
providerName,
+ sipAlias,
+ discussionRid,
...callDetails
- }: Required>): Promise {
+ }: Required> &
+ Pick): Promise {
const call: InsertionModel = {
type: 'videoconference',
users: [],
@@ -116,6 +143,8 @@ export class VideoConferenceRaw extends BaseRaw implements IVid
anonymousUsers: 0,
createdAt: new Date(),
providerName: providerName.toLowerCase(),
+ ...(sipAlias && { sipAlias }),
+ ...(discussionRid && { discussionRid }),
...callDetails,
};
@@ -160,12 +189,20 @@ export class VideoConferenceRaw extends BaseRaw implements IVid
endedBy,
endedAt: endedAt || new Date(),
},
+ $unset: {
+ sipAlias: true,
+ },
});
}
- public async setDataById(callId: string, data: Partial>): Promise {
+ public async setDataById(callId: string, data: Partial>): Promise {
+ const isOver =
+ data.status !== undefined &&
+ [VideoConferenceStatus.EXPIRED, VideoConferenceStatus.ENDED, VideoConferenceStatus.DECLINED].includes(data.status);
+
await this.updateOneById(callId, {
$set: data,
+ ...(isOver && { $unset: { sipAlias: true } }),
});
}
@@ -178,10 +215,15 @@ export class VideoConferenceRaw extends BaseRaw implements IVid
}
public async setStatusById(callId: string, status: VideoConference['status']): Promise {
+ const isOver = [VideoConferenceStatus.EXPIRED, VideoConferenceStatus.ENDED, VideoConferenceStatus.DECLINED].includes(status);
+
await this.updateOneById(callId, {
$set: {
status,
},
+ ...(isOver && {
+ $unset: { sipAlias: true },
+ }),
});
}
@@ -304,4 +346,54 @@ export class VideoConferenceRaw extends BaseRaw implements IVid
},
);
}
+
+ public async setSipAliasById(callId: string, sipAlias: string): Promise {
+ await this.updateOne({ _id: callId }, { $set: { sipAlias } });
+ }
+
+ public async unsetSipAliasById(callId: string): Promise {
+ await this.updateOne({ _id: callId }, { $unset: { sipAlias: true } });
+ }
+
+ public async findOneByProviderNameAndSipAlias(
+ providerName: string,
+ sipAlias: string,
+ options?: FindOptions,
+ ): Promise {
+ return this.findOne(
+ {
+ providerName,
+ sipAlias,
+ },
+ options || {},
+ );
+ }
+
+ public async increaseSipParticipantCount(sipAlias: string): Promise {
+ return this.findOneAndUpdate(
+ {
+ sipAlias,
+ },
+ {
+ $inc: { sipParticipantCount: 1 },
+ },
+ {
+ returnDocument: 'after',
+ },
+ );
+ }
+
+ public async increaseWebRTCParticipantCount(conferenceId: string): Promise {
+ return this.findOneAndUpdate(
+ {
+ _id: conferenceId,
+ },
+ {
+ $inc: { webrtcParticipantCount: 1 },
+ },
+ {
+ returnDocument: 'after',
+ },
+ );
+ }
}
diff --git a/packages/pexip/package.json b/packages/pexip/package.json
index cb80c4fba78c4..7ff7bb46c6b39 100644
--- a/packages/pexip/package.json
+++ b/packages/pexip/package.json
@@ -16,6 +16,7 @@
},
"dependencies": {
"@rocket.chat/core-services": "workspace:^",
+ "@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/logger": "workspace:^",
"@rocket.chat/models": "workspace:^",
"ajv": "^8.20.0"
diff --git a/packages/pexip/src/definition/PexipSettings.ts b/packages/pexip/src/definition/PexipSettings.ts
index 94631c0ef1442..773652a428770 100644
--- a/packages/pexip/src/definition/PexipSettings.ts
+++ b/packages/pexip/src/definition/PexipSettings.ts
@@ -23,4 +23,9 @@ export type PexipSettings = {
discussionsEnabled: boolean;
persistentChatEnabled: boolean;
};
+ sip: {
+ addAlias: boolean;
+ host: string;
+ port: number;
+ };
};
diff --git a/packages/pexip/src/endpoints/endpoint.ts b/packages/pexip/src/endpoints/endpoint.ts
new file mode 100644
index 0000000000000..2dd7d542bfba8
--- /dev/null
+++ b/packages/pexip/src/endpoints/endpoint.ts
@@ -0,0 +1,37 @@
+import type { VideoConference } from '@rocket.chat/core-typings';
+import { VideoConference as VideoConferenceModel } from '@rocket.chat/models';
+
+import type { Pexip } from '../Pexip';
+
+export class PexipEndpoint {
+ constructor(public readonly pexip: Pexip) {
+ //
+ }
+
+ protected getIdentificationFromAlias(alias: string): string {
+ if (!alias.startsWith('sip:') || !alias.includes('@')) {
+ return alias;
+ }
+
+ return alias.substring(0, alias.indexOf('@')).replace('sip:', '');
+ }
+
+ protected normalizeSipExtension(identification: string): string {
+ if (!identification.startsWith('+')) {
+ return identification;
+ }
+
+ return identification.substring(1, identification.length);
+ }
+
+ protected async getCallByIdentification(identification: string): Promise {
+ if (!identification.match(/\D/g)) {
+ const call = await VideoConferenceModel.findOneByProviderNameAndSipAlias('core.pexip', identification);
+ if (call) {
+ return call;
+ }
+ }
+
+ return VideoConferenceModel.findOneById(identification);
+ }
+}
diff --git a/packages/pexip/src/endpoints/eventSink.ts b/packages/pexip/src/endpoints/eventSink.ts
index 5f0761c6a7e84..2d85a8bf6c2cc 100644
--- a/packages/pexip/src/endpoints/eventSink.ts
+++ b/packages/pexip/src/endpoints/eventSink.ts
@@ -1,25 +1,89 @@
import { VideoConf } from '@rocket.chat/core-services';
import { VideoConferenceStatus } from '@rocket.chat/core-typings';
+import { VideoConference as VideoConferenceModel } from '@rocket.chat/models';
-import type { Pexip } from '../Pexip';
-import type { EventSinkRequest } from '../definition';
+import type { ConferenceEndedEventData, EventSinkRequest, ParticipantStatusEventData } from '../definition';
import { logger } from '../logger';
+import { PexipEndpoint } from './endpoint';
-export class EventSinkEndpoint {
- constructor(public readonly pexip: Pexip) {
- //
- }
-
+export class EventSinkEndpoint extends PexipEndpoint {
public async post(event: EventSinkRequest): Promise {
- if (event.event !== 'conference_ended') {
- return;
+ switch (event.event) {
+ case 'conference_ended':
+ return this.processConferenceEnded(event.data);
+ case 'participant_connected':
+ return this.processParticipantConnected(event.data);
}
+ }
+ protected async processConferenceEnded(data: ConferenceEndedEventData): Promise {
try {
- await VideoConf.setStatus(event.data.name, VideoConferenceStatus.ENDED);
+ // TODO: end call by sip alias
+ await VideoConf.setStatus(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.
+ // If the call was not found or we were unable to change the status, we probably received an alias instead of a callId
+ }
+ }
+
+ protected async processParticipantConnected(data: ParticipantStatusEventData): Promise {
+ logger.debug({ msg: 'Pexip Participant Connected', data });
+
+ const { destination_alias: conferenceUri, source_alias: participantUri, protocol, call_direction: direction } = data;
+ if (!conferenceUri || direction !== 'in') {
+ return;
+ }
+
+ const identification = this.getIdentificationFromAlias(conferenceUri);
+ if (!identification) {
+ return;
+ }
+
+ void this.confirmParticipantConnected(identification, protocol, participantUri).catch((err) => {
+ logger.error({
+ msg: 'Unexpected error while confirming call participant connected',
+ err,
+ method: 'EventSinkEndpoint.processParticipantConnected',
+ identification,
+ protocol,
+ participantUri,
+ });
+ });
+ }
+
+ protected async confirmParticipantConnected(
+ identification: string,
+ protocol: ParticipantStatusEventData['protocol'],
+ participantUri: string,
+ ): Promise {
+ switch (protocol) {
+ case 'WebRTC':
+ return this.confirmWebRTCParticipantConnected(identification);
+ case 'SIP':
+ return this.confirmSipParticipantConnected(identification, participantUri);
+ }
+ }
+
+ protected async confirmWebRTCParticipantConnected(identification: string): Promise {
+ const call = await VideoConferenceModel.increaseWebRTCParticipantCount(identification);
+ if (!call) {
+ logger.error({
+ msg: 'Failed to register WebRTC participant on conference',
+ identification,
+ method: 'EventSinkEndpoint.confirmWebRTCParticipantConnected',
+ });
+ }
+ }
+
+ protected async confirmSipParticipantConnected(identification: string, participantUri: string): Promise {
+ const call = await VideoConferenceModel.increaseSipParticipantCount(identification);
+ if (!call) {
+ logger.error({
+ msg: 'Failed to register SIP participant on conference',
+ identification,
+ participantUri,
+ method: 'EventSinkEndpoint.confirmSipParticipantConnected',
+ });
}
}
}
diff --git a/packages/pexip/src/endpoints/serviceConfiguration.ts b/packages/pexip/src/endpoints/serviceConfiguration.ts
index ca8af93d3bec4..5afda67e4b626 100644
--- a/packages/pexip/src/endpoints/serviceConfiguration.ts
+++ b/packages/pexip/src/endpoints/serviceConfiguration.ts
@@ -1,17 +1,13 @@
-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';
+import { PexipEndpoint } from './endpoint';
-export class ServerConfigurationEndpoint {
- constructor(public readonly pexip: Pexip) {
- //
- }
-
+export class ServerConfigurationEndpoint extends PexipEndpoint {
public async get(serviceRequest: SerializedServiceConfigurationRequest): Promise {
- const { local_alias: alias } = serviceRequest;
+ const { local_alias: alias, protocol = null } = serviceRequest;
+ logger.debug({ msg: 'Processing Pexip Policy Server Request', alias, protocol });
+
if (!alias) {
logger.error(`No call identification received in the request.`);
return null;
@@ -22,16 +18,8 @@ export class ServerConfigurationEndpoint {
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);
+ const call = await this.getCallByIdentification(identification);
if (!call) {
logger.error({ msg: 'Invalid call identification', identification });
return null;
diff --git a/packages/pexip/src/videoConfProvider.ts b/packages/pexip/src/videoConfProvider.ts
index a40846eadd9db..90ad57e8c68e4 100644
--- a/packages/pexip/src/videoConfProvider.ts
+++ b/packages/pexip/src/videoConfProvider.ts
@@ -1,8 +1,9 @@
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 { VideoConferenceJoinOptions } from '@rocket.chat/core-services';
+import type { VideoConference, IVideoConferenceUser, RequiredField } from '@rocket.chat/core-typings';
import type { Pexip } from './Pexip';
+import { logger } from './logger';
export class PexipVideoConfProvider {
public readonly name = 'Pexip';
@@ -42,83 +43,39 @@ export class PexipVideoConfProvider {
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('&');
+ return `${baseUrl}${relativeUrl}`;
}
- private joinUrlAndParams(baseUrl: string, params: Record): string {
- const joinedParams = this.joinUrlParams(params);
- return `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${joinedParams}`;
- }
+ public async customizeUrl(
+ call: RequiredField,
+ user: IVideoConferenceUser | undefined,
+ options?: VideoConferenceJoinOptions,
+ ): Promise {
+ logger.debug({ msg: 'Pexip.customizeUrl', options });
- 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 pin = await this.getPinForUser(call, user);
- const params = Object.keys(roomParams)
- .map((key) => `${key}=${roomParams[key as keyof typeof roomParams]}`)
- .join('&');
+ const { url: userUrl } = call;
- return `${roomUrl}${roomUrl.includes('?') ? '&' : '?'}${params}`;
- }
+ const url = new URL(userUrl);
+ if (user) {
+ const { name } = user;
- private getDiscussionRoute(room: AtLeast): string | undefined {
- switch (room.t) {
- case 'c':
- return `channel/${room.name}`;
- case 'p':
- return `group/${room.name}`;
- default:
- return undefined;
+ if (name) {
+ url.searchParams.set('name', name);
+ }
}
- }
-
- private async getBaseURLWithoutTrailingSlash(): Promise {
- const url = this.pexip.settings.workspace.siteUrl;
- if (url.endsWith('/')) {
- return url.substr(0, url.length - 1);
+ if (options?.mic === false) {
+ url.searchParams.set('muteMicrophone', 'true');
}
- 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}` : '';
+ if (options?.cam === false) {
+ url.searchParams.set('muteCamera', 'true');
+ }
- return `${url}&pin=${pin}${nameSuffix}`;
+ url.searchParams.set('pin', pin);
+ return url.toString();
}
public async onNewVideoConference(call: VideoConference): Promise {
@@ -129,7 +86,10 @@ export class PexipVideoConfProvider {
public async getVideoConferenceInfo(call: VideoConference, user: IVideoConferenceUser | undefined): Promise> {
const lines: Array = [];
- lines.push(`**URL:** ${call.url}`);
+ // Show the in-product conference address (the `/conference/:id` page) rather than the raw Pexip
+ // URL, so sharing it opens the internal conference experience.
+ const siteUrl = this.pexip.settings.workspace.siteUrl.replace(/\/+$/, '');
+ lines.push(`**URL:** ${siteUrl}/conference/${call._id}`);
const [hostPin, guestPin] = await this.pexip.createPinsForCall(call);
@@ -151,7 +111,13 @@ export class PexipVideoConfProvider {
];
}
+ public async onUserJoin(call: VideoConference, user?: IVideoConferenceUser): Promise {
+ logger.debug({ msg: 'Pexip.onUserJoin', conferenceId: call._id, userId: user?._id });
+ }
+
private async getPinForUser(call: VideoConference, user: IVideoConferenceUser | undefined): Promise