Files
upstream-rocketchat/apps/meteor/server/services/video-conference/service.ts
upstream-mirror 30810eecd7
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

1257 lines
38 KiB
TypeScript

import { Apps } from '@rocket.chat/apps';
import type { AppVideoConfProviderManager } from '@rocket.chat/apps/dist/server/managers/AppVideoConfProviderManager';
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 type {
IDirectVideoConference,
ILivechatVideoConference,
IRoom,
IUser,
VideoConferenceInstructions,
DirectCallInstructions,
ConferenceInstructions,
LivechatInstructions,
AtLeast,
IGroupVideoConference,
IVideoConferenceUser,
IMessage,
IStats,
VideoConference,
VideoConferenceCapabilities,
VideoConferenceCreateData,
Optional,
ExternalVideoConference,
IVoIPVideoConference,
} from '@rocket.chat/core-typings';
import {
VideoConferenceStatus,
isDirectVideoConference,
isGroupVideoConference,
isLivechatVideoConference,
} from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import type { InsertionModel } from '@rocket.chat/model-typings';
import { Users, VideoConference as VideoConferenceModel, Rooms, Messages, Subscriptions } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import type { PaginatedResult } from '@rocket.chat/rest-typings';
import { wrapExceptions } from '@rocket.chat/tools';
import type * as UiKit from '@rocket.chat/ui-kit';
import { Meteor } from 'meteor/meteor';
import { MongoInternals } from 'meteor/mongo';
import { availabilityErrors } from '../../../lib/videoConference/constants';
import { readSecondaryPreferred } from '../../database/readSecondaryPreferred';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { callbacks } from '../../lib/callbacks';
import { i18n } from '../../lib/i18n';
import { isRoomCompatibleWithVideoConfRinging } from '../../lib/isRoomCompatibleWithVideoConfRinging';
import { RocketChatAssets } from '../../lib/media/assets';
import { sendMessage } from '../../lib/messages/sendMessage';
import { metrics } from '../../lib/metrics/lib/metrics';
import { Push } from '../../lib/notifications/push/push';
import PushNotification from '../../lib/notifications/push-config/lib/PushNotification';
import { notifyOnMessageChange } from '../../lib/notifyListener';
import { createRoom } from '../../lib/rooms/createRoom';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
import { updateCounter } from '../../lib/statistics/functions/updateStatsCounter';
import { getUserAvatarURL } from '../../lib/utils/getUserAvatarURL';
import { getUserPreference } from '../../lib/utils/lib/getUserPreference';
import { videoConfProviders } from '../../lib/videoConfProviders';
import { videoConfTypes } from '../../lib/videoConfTypes';
import { settings } from '../../settings';
const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;
const logger = new Logger('VideoConference');
export class VideoConfService extends ServiceClassInternal implements IVideoConfService {
protected name = 'video-conference';
// VideoConference.create: Start a video conference using the type and provider specified as arguments
public async create(
{ type, rid, createdBy, providerName, ...data }: VideoConferenceCreateData,
useAppUser = true,
): Promise<VideoConferenceInstructions> {
return wrapExceptions(async () => {
const room = await Rooms.findOneById<Pick<IRoom, '_id' | 't' | 'uids' | 'name' | 'fname'>>(rid, {
projection: { t: 1, uids: 1, name: 1, fname: 1 },
});
if (!room) {
throw new Error('invalid-room');
}
const user = await Users.findOneById<IUser>(createdBy);
if (!user) {
throw new Error('failed-to-load-own-data');
}
if (type === 'direct') {
if (!isRoomCompatibleWithVideoConfRinging(room.t, room.uids)) {
throw new Error('type-and-room-not-compatible');
}
return this.startDirect(providerName, user, room, data);
}
if (type === 'livechat') {
return this.startLivechat(providerName, user, rid);
}
const title = (data as Partial<IGroupVideoConference>).title || room.fname || room.name || '';
return this.startGroup(providerName, user, room._id, title, data, useAppUser);
}).catch((err) => {
logger.error({
name: 'Error on VideoConf.create',
err,
});
throw err;
});
}
// VideoConference.start: Detect the desired type and provider then start a video conference using them
public async start(
caller: IUser['_id'],
rid: string,
{ title, allowRinging }: { title?: string; allowRinging?: boolean },
): Promise<VideoConferenceInstructions> {
return wrapExceptions(async () => {
const providerName = await this.getValidatedProvider();
const initialData = await this.getTypeForNewVideoConference(rid, Boolean(allowRinging));
const data = {
...initialData,
createdBy: caller,
rid,
providerName,
} as VideoConferenceCreateData;
if (data.type === 'videoconference') {
data.title = title;
}
return this.create(data, false);
}).catch((err) => {
logger.error({
name: 'Error on VideoConf.start',
err,
});
throw err;
});
}
public async join(uid: IUser['_id'] | undefined, callId: VideoConference['_id'], options: VideoConferenceJoinOptions): Promise<string> {
return wrapExceptions(async () => {
const call = await VideoConferenceModel.findOneById(callId);
if (!call || call.endedAt || !videoConfTypes.isCallManagedByApp(call)) {
throw new Error('invalid-call');
}
let user: Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag'> | null = null;
if (uid) {
user = await Users.findOneById<Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag'>>(uid, {
projection: { name: 1, username: 1, avatarETag: 1 },
});
if (!user) {
throw new Error('failed-to-load-own-data');
}
}
if (call.providerName === 'jitsi') {
updateCounter({ settingsId: 'Jitsi_Click_To_Join_Count' });
}
return this.joinCall(call, user || undefined, options);
}).catch((err) => {
logger.error({
name: 'Error on VideoConf.join',
err,
});
throw err;
});
}
public async getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise<UiKit.ModalSurfaceLayout> {
const call = await VideoConferenceModel.findOneById(callId);
if (!call) {
throw new Error('invalid-call');
}
if (!videoConfTypes.isCallManagedByApp(call)) {
return [];
}
if (!videoConfProviders.isProviderAvailable(call.providerName)) {
throw new Error('video-conf-provider-unavailable');
}
let user: Pick<Required<IUser>, '_id' | 'username' | 'name' | 'avatarETag'> | null = null;
if (uid) {
user = await Users.findOneById<Pick<Required<IUser>, '_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 (await this.getProviderManager()).getVideoConferenceInfo(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}`,
},
},
];
}
public async cancel(uid: IUser['_id'], callId: VideoConference['_id']): Promise<void> {
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);
}
public async get(callId: VideoConference['_id']): Promise<Omit<VideoConference, 'providerData'> | null> {
return VideoConferenceModel.findOneById<Omit<VideoConference, 'providerData'>>(callId, { projection: { providerData: 0 } });
}
public async getUnfiltered(callId: VideoConference['_id']): Promise<VideoConference | null> {
return VideoConferenceModel.findOneById(callId);
}
public async list(
roomId: IRoom['_id'],
pagination: { offset?: number; count?: number } = {},
): Promise<PaginatedResult<{ data: VideoConference[] }>> {
const { cursor, totalCount } = VideoConferenceModel.findPaginatedByRoomId(roomId, pagination);
const [data, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
data,
offset: pagination.offset || 0,
count: data.length,
total,
};
}
public async setProviderData(callId: VideoConference['_id'], data: VideoConference['providerData'] | undefined): Promise<void> {
await VideoConferenceModel.setProviderDataById(callId, data);
}
public async setEndedBy(callId: VideoConference['_id'], endedBy: IUser['_id']): Promise<void> {
const user = await Users.findOneById<Required<Pick<IUser, '_id' | 'username' | 'name'>>>(endedBy, {
projection: { username: 1, name: 1 },
});
if (!user) {
throw new Error('Invalid User');
}
await VideoConferenceModel.setEndedById(callId, {
_id: user._id,
username: user.username,
name: user.name,
});
}
public async setEndedAt(callId: VideoConference['_id'], endedAt: Date): Promise<void> {
await VideoConferenceModel.setEndedById(callId, undefined, endedAt);
}
public async setStatus(callId: VideoConference['_id'], status: VideoConference['status']): Promise<void> {
switch (status) {
case VideoConferenceStatus.ENDED:
return this.endCall(callId);
case VideoConferenceStatus.EXPIRED:
return this.expireCall(callId);
}
await VideoConferenceModel.setStatusById(callId, status);
}
public async addUser(callId: VideoConference['_id'], userId?: IUser['_id'], ts?: Date): Promise<void> {
const call = await this.get(callId);
if (!call) {
throw new Error('Invalid video conference');
}
if (!userId) {
if (call.type === 'videoconference') {
return this.addAnonymousUser(call as Omit<IGroupVideoConference, 'providerData'>);
}
throw new Error('Invalid User');
}
const user = await Users.findOneById<Required<Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag'>>>(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(),
});
}
public async listProviders(): Promise<{ key: string; label: string }[]> {
return videoConfProviders.getProviderList();
}
public async listProviderCapabilities(providerName: string): Promise<VideoConferenceCapabilities> {
return videoConfProviders.getProviderCapabilities(providerName) || {};
}
public async listCapabilities(): Promise<{ providerName: string; capabilities: VideoConferenceCapabilities }> {
const providerName = await this.getValidatedProvider();
return {
providerName,
capabilities: videoConfProviders.getProviderCapabilities(providerName) || {},
};
}
public async declineLivechatCall(callId: VideoConference['_id']): Promise<boolean> {
const call = await this.getUnfiltered(callId);
if (!isLivechatVideoConference(call)) {
return false;
}
if (call.messages.started) {
const name =
(settings.get<boolean>('UI_Use_Real_Name') ? call.createdBy.name : call.createdBy.username) || call.createdBy.username || '';
const text = i18n.t('video_livechat_missed', { username: name });
await Messages.setBlocksById(call.messages.started, [this.buildMessageBlock(text)]);
await notifyOnMessageChange({
id: call.messages.started,
});
}
await VideoConferenceModel.setDataById(call._id, {
status: VideoConferenceStatus.DECLINED,
endedAt: new Date(),
});
return true;
}
public async diagnoseProvider(uid: string, rid: string, providerName?: string): Promise<string | undefined> {
try {
if (providerName) {
await this.validateProvider(providerName);
} else {
await this.getValidatedProvider();
}
} catch (error: unknown) {
if (error instanceof Error) {
await this.createEphemeralMessage(uid, rid, error.message);
return error.message;
}
}
}
public async getStatistics(): Promise<IStats['videoConf']> {
const options = {
readPreference: readSecondaryPreferred(db),
};
const [videoConferenceStarted, videoConferenceEnded, directCalling, directStarted, directEnded, livechatStarted, livechatEnded] =
await Promise.all([
VideoConferenceModel.countByTypeAndStatus('videoconference', VideoConferenceStatus.STARTED, options),
VideoConferenceModel.countByTypeAndStatus('videoconference', VideoConferenceStatus.ENDED, options),
VideoConferenceModel.countByTypeAndStatus('direct', VideoConferenceStatus.CALLING, options),
VideoConferenceModel.countByTypeAndStatus('direct', VideoConferenceStatus.STARTED, options),
VideoConferenceModel.countByTypeAndStatus('direct', VideoConferenceStatus.ENDED, options),
VideoConferenceModel.countByTypeAndStatus('livechat', VideoConferenceStatus.STARTED, options),
VideoConferenceModel.countByTypeAndStatus('livechat', VideoConferenceStatus.ENDED, options),
]);
return {
videoConference: {
started: videoConferenceStarted,
ended: videoConferenceEnded,
},
direct: {
calling: directCalling,
started: directStarted,
ended: directEnded,
},
livechat: {
started: livechatStarted,
ended: livechatEnded,
},
settings: {
provider: settings.get<string>('VideoConf_Default_Provider'),
dms: settings.get<boolean>('VideoConf_Enable_DMs'),
channels: settings.get<boolean>('VideoConf_Enable_Channels'),
groups: settings.get<boolean>('VideoConf_Enable_Groups'),
teams: settings.get<boolean>('VideoConf_Enable_Teams'),
},
};
}
public async validateAction(
action: string,
caller: IUser['_id'],
{ callId, uid, rid }: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] },
): Promise<boolean> {
if (!callId || !uid || !rid) {
return false;
}
if (!(await canAccessRoomIdAsync(rid, caller)) || (caller !== uid && !(await canAccessRoomIdAsync(rid, uid)))) {
return false;
}
const call = await VideoConferenceModel.findOneById<Pick<VideoConference, '_id' | 'status' | 'endedAt' | 'createdBy'>>(callId, {
projection: { status: 1, endedAt: 1, createdBy: 1 },
});
if (!call) {
return false;
}
if (action === 'end') {
return true;
}
if (call.endedAt || call.status > VideoConferenceStatus.STARTED) {
// If the caller is still calling about a call that has already ended, notify it
if (action === 'call' && caller === call.createdBy._id) {
this.notifyUser(call.createdBy._id, 'end', { rid, uid, callId });
}
return false;
}
return true;
}
public async createVoIP(data: InsertionModel<IVoIPVideoConference>): Promise<IVoIPVideoConference['_id'] | undefined> {
return wrapExceptions(async () => VideoConferenceModel.createVoIP(data)).catch((err) => {
logger.error({
name: 'Error on VideoConf.createVoIP',
err,
});
throw err;
});
}
private notifyUser(
userId: IUser['_id'],
action: string,
params: { uid: IUser['_id']; rid: IRoom['_id']; callId: VideoConference['_id'] },
): void {
void api.broadcast('user.video-conference', { userId, action, params });
}
private notifyVideoConfUpdate(rid: IRoom['_id'], callId: VideoConference['_id']): void {
void api.broadcast('room.video-conference', { rid, callId });
}
private async endCall(callId: VideoConference['_id']): Promise<void> {
const call = await this.getUnfiltered(callId);
if (!call) {
return;
}
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);
}
}
private async expireCall(callId: VideoConference['_id']): Promise<void> {
const call = await VideoConferenceModel.findOneById<Pick<VideoConference, '_id' | 'messages'>>(callId, { projection: { messages: 1 } });
if (!call) {
return;
}
await VideoConferenceModel.setDataById(call._id, { endedAt: new Date(), status: VideoConferenceStatus.EXPIRED });
}
private async endDirectCall(call: IDirectVideoConference): Promise<void> {
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);
// 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;
}
this.notifyUser(subscription.u._id, 'end', params);
}
}
private async getTypeForNewVideoConference(
rid: IRoom['_id'],
allowRinging: boolean,
): Promise<AtLeast<VideoConferenceCreateData, 'type'>> {
const room = await Rooms.findOneById<Pick<IRoom, '_id' | 't'>>(rid, {
projection: { t: 1 },
});
if (!room) {
throw new Error('invalid-room');
}
return videoConfTypes.getTypeForRoom(room, allowRinging);
}
private async createMessage(call: VideoConference, createdBy?: IUser, customBlocks?: IMessage['blocks']): Promise<IMessage['_id']> {
const record = {
t: 'videoconf',
msg: i18n.t('Video_Conference', {
lng: createdBy?.language || settings.get('Language') || 'en',
}),
groupable: false,
blocks: customBlocks || [this.buildVideoConfBlock(call._id)],
} satisfies Partial<IMessage>;
const room = await Rooms.findOneById(call.rid);
const appId = videoConfProviders.getProviderAppId(call.providerName);
const user = createdBy || (appId && (await Users.findOneByAppId(appId))) || (await Users.findOneById('rocket.cat'));
const message = await sendMessage(user, record, room);
if (!message) {
throw new Error('failed-to-create-message');
}
return message._id;
}
private async validateProvider(providerName: string): Promise<void> {
const manager = await this.getProviderManager();
const configured = await manager.isFullyConfigured(providerName).catch(() => false);
if (!configured) {
throw new Error(availabilityErrors.NOT_CONFIGURED);
}
}
private async getValidatedProvider(): Promise<string> {
if (!videoConfProviders.hasAnyProvider()) {
throw new Error(availabilityErrors.NO_APP);
}
const providerName = videoConfProviders.getActiveProvider();
if (!providerName) {
throw new Error(availabilityErrors.NOT_ACTIVE);
}
await this.validateProvider(providerName);
return providerName;
}
private async createEphemeralMessage(uid: string, rid: string, i18nKey: string): Promise<void> {
const user = await Users.findOneById<Pick<IUser, 'language' | 'roles'>>(uid, { projection: { language: 1, roles: 1 } });
const language = user?.language || settings.get<string>('Language') || 'en';
const key = user?.roles.includes('admin') ? `admin-${i18nKey}` : i18nKey;
const msg = i18n.t(key, {
lng: language,
});
void api.broadcast('notify.ephemeralMessage', uid, rid, {
msg,
});
}
private async createLivechatMessage(call: ILivechatVideoConference, user: IUser, url: string): Promise<IMessage['_id']> {
const username = (settings.get<boolean>('UI_Use_Real_Name') ? user.name : user.username) || user.username || '';
const text = i18n.t('video_livechat_started', {
username,
});
return this.createMessage(call, user, [
this.buildMessageBlock(text),
{
type: 'actions',
appId: 'videoconf-core',
blockId: call._id,
elements: [
{
appId: 'videoconf-core',
blockId: call._id,
actionId: 'joinLivechat',
type: 'button',
text: {
type: 'plain_text',
text: i18n.t('Join_call'),
emoji: true,
},
url,
},
],
},
]);
}
private buildVideoConfBlock(callId: string): UiKit.MessageSurfaceLayout[number] {
return {
type: 'video_conf',
blockId: callId,
callId,
appId: 'videoconf-core',
};
}
private buildMessageBlock(text: string): UiKit.MessageSurfaceLayout[number] {
return {
type: 'section',
appId: 'videoconf-core',
text: {
type: 'mrkdwn',
text: `${text}`,
},
};
}
private async sendPushNotification(
call: AtLeast<IDirectVideoConference, 'createdBy' | 'rid' | '_id' | 'status'>,
calleeId: IUser['_id'],
): Promise<void> {
if (
settings.get('Push_enable') !== true ||
settings.get('VideoConf_Mobile_Ringing') !== true ||
!(await getUserPreference(calleeId, 'enableMobileRinging'))
) {
return;
}
await Push.send({
from: 'push',
badge: 0,
sound: 'ringtone.mp3',
priority: 10,
title: `@${call.createdBy.username}`,
text: i18n.t('Video_Conference'),
payload: {
host: Meteor.absoluteUrl(),
rid: call.rid,
notificationType: 'videoconf',
caller: call.createdBy,
avatar: getUserAvatarURL(call.createdBy.username),
status: call.status,
callId: call._id,
},
userId: calleeId,
notId: PushNotification.getNotificationId(`${call.rid}|${call._id}`),
gcm: {
style: 'inbox',
image: RocketChatAssets.getURL('Assets_favicon_192'),
},
apn: {
category: 'VIDEOCONF',
},
});
metrics.notificationsSent.inc({ notification_type: 'mobile' });
metrics.notificationsSentTotal.inc({ notification_type: 'mobile' });
}
private async sendAllPushNotifications(callId: VideoConference['_id']): Promise<void> {
if (settings.get('Push_enable') !== true || settings.get('VideoConf_Mobile_Ringing') !== true) {
return;
}
const call = await VideoConferenceModel.findOneById<Pick<VideoConference, 'createdBy' | 'rid' | '_id' | 'users' | 'status'>>(callId, {
projection: { createdBy: 1, rid: 1, users: 1, status: 1 },
});
if (!call) {
return;
}
const subscriptions = Subscriptions.findByRoomIdAndNotUserId(call.rid, call.createdBy._id, {
projection: { 'u._id': 1, '_id': 0 },
});
for await (const subscription of subscriptions) {
await this.sendPushNotification(call, subscription.u._id);
}
}
private async startDirect(
providerName: string,
user: IUser,
{ _id: rid, uids }: AtLeast<IRoom, '_id' | 'uids'>,
extraData?: Partial<IDirectVideoConference>,
): Promise<DirectCallInstructions> {
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,
rid,
createdBy: {
_id: user._id,
name: user.name as string,
username: user.username as string,
},
providerName,
});
await this.runNewVideoConferenceEvent(callId);
await this.maybeCreateDiscussion(callId, user);
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 messageId = await this.createMessage(call, user);
call.messages.started = messageId;
await VideoConferenceModel.setMessageById(callId, 'started', messageId);
// After 40 seconds if the status is still "calling", we cancel the call automatically.
setTimeout(async () => {
try {
const call = await VideoConferenceModel.findOneById<IDirectVideoConference>(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,
};
}
private async notifyUsersOfRoom(
rid: IRoom['_id'],
uid: IUser['_id'],
action: string,
params: { uid: IUser['_id']; rid: IRoom['_id']; callId: VideoConference['_id'] },
): Promise<void> {
const subscriptions = Subscriptions.findByRoomIdAndNotUserId(rid, uid, {
projection: { 'u._id': 1, '_id': 0 },
});
await subscriptions.forEach((subscription) => this.notifyUser(subscription.u._id, action, params));
}
private async startGroup(
providerName: string,
user: IUser,
rid: IRoom['_id'],
title: string,
extraData?: Partial<IGroupVideoConference>,
useAppUser = true,
): Promise<ConferenceInstructions> {
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.runNewVideoConferenceEvent(callId);
await this.maybeCreateDiscussion(callId, user);
const call = (await this.getUnfiltered(callId)) as IGroupVideoConference | null;
if (!call) {
throw new Error('failed-to-create-group-call');
}
const url = await this.generateNewUrl(call);
await VideoConferenceModel.setUrlById(callId, url);
call.url = url;
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,
};
}
private async startLivechat(providerName: string, user: IUser, rid: IRoom['_id']): Promise<LivechatInstructions> {
const callId = await VideoConferenceModel.createLivechat({
rid,
createdBy: {
_id: user._id,
name: user.name as string,
username: user.username as string,
},
providerName,
});
const call = (await this.getUnfiltered(callId)) as ILivechatVideoConference | null;
if (!call) {
throw new Error('failed-to-create-livechat-call');
}
await this.runNewVideoConferenceEvent(callId);
// Livechat conferences do not use discussions
const joinUrl = await this.getUrl(call);
const messageId = await this.createLivechatMessage(call, user, joinUrl);
call.messages.started = messageId;
await VideoConferenceModel.setMessageById(callId, 'started', messageId);
return {
type: 'livechat',
callId,
};
}
private async joinCall(
call: ExternalVideoConference,
user: AtLeast<IUser, '_id' | 'username' | 'name' | 'avatarETag'> | undefined,
options: VideoConferenceJoinOptions,
): Promise<string> {
void callbacks.runAsync('onJoinVideoConference', call._id, user?._id);
await this.runOnUserJoinEvent(call._id, user as IVideoConferenceUser);
return this.getUrl(call, user, options);
}
private async getProviderManager(): Promise<AppVideoConfProviderManager> {
if (!Apps.self?.isLoaded()) {
throw new Error('apps-engine-not-loaded');
}
const manager = Apps.self?.getManager()?.getVideoConfProviderManager();
if (!manager) {
throw new Error(availabilityErrors.NO_APP);
}
return manager;
}
private async getRoomName(rid: string): Promise<string> {
const room = await Rooms.findOneById<Pick<IRoom, '_id' | 'name' | 'fname'>>(rid, { projection: { name: 1, fname: 1 } });
return room?.fname || room?.name || rid;
}
private async generateNewUrl(call: ExternalVideoConference): Promise<string> {
if (!videoConfProviders.isProviderAvailable(call.providerName)) {
throw new Error('video-conf-provider-unavailable');
}
const title = isGroupVideoConference(call) ? call.title || (await this.getRoomName(call.rid)) : '';
const callData: VideoConfData = {
_id: call._id,
type: call.type,
rid: call.rid,
createdBy: call.createdBy as Required<VideoConference['createdBy']>,
title,
providerData: call.providerData,
discussionRid: call.discussionRid,
};
return (await this.getProviderManager()).generateUrl(call.providerName, callData);
}
private async getCallTitleForUser(call: VideoConference, userId?: IUser['_id']): Promise<string> {
if (call.type === 'videoconference' && call.title) {
return call.title;
}
if (userId) {
const subscription = await Subscriptions.findOneByRoomIdAndUserId(call.rid, userId, { projection: { fname: 1, name: 1 } });
if (subscription) {
return subscription.fname || subscription.name;
}
}
const room = await Rooms.findOneById(call.rid);
return room?.fname || room?.name || 'Rocket.Chat';
}
private async getCallTitle(call: VideoConference): Promise<string> {
if (call.type === 'videoconference') {
if (call.title) {
return call.title;
}
}
const room = await Rooms.findOneById(call.rid);
if (room) {
if (room.t === 'd') {
if (room.usernames?.length) {
return room.usernames.join(', ');
}
} else if (room.fname) {
return room.fname;
} else if (room.name) {
return room.name;
}
}
return 'Rocket.Chat';
}
private async getUrl(
call: ExternalVideoConference,
user?: AtLeast<IUser, '_id' | 'username' | 'name'>,
options: VideoConferenceJoinOptions = {},
): Promise<string> {
if (!videoConfProviders.isProviderAvailable(call.providerName)) {
throw new Error('video-conf-provider-unavailable');
}
if (!call.url) {
call.url = await this.generateNewUrl(call);
await VideoConferenceModel.setUrlById(call._id, call.url);
}
const callData: VideoConfDataExtended = {
_id: call._id,
type: call.type,
rid: call.rid,
url: call.url,
createdBy: call.createdBy as Required<VideoConference['createdBy']>,
providerData: {
...(call.providerData || {}),
...{ customCallTitle: await this.getCallTitleForUser(call, user?._id) },
},
title: await this.getCallTitle(call),
discussionRid: call.discussionRid,
};
const userData = user && {
_id: user._id,
username: user.username as string,
name: user.name as string,
};
return (await this.getProviderManager()).customizeUrl(call.providerName, callData, userData, options);
}
private async runNewVideoConferenceEvent(callId: VideoConference['_id']): Promise<void> {
const call = await VideoConferenceModel.findOneById(callId);
if (!call) {
throw new Error('video-conf-data-not-found');
}
if (!videoConfTypes.isCallManagedByApp(call)) {
return;
}
if (!videoConfProviders.isProviderAvailable(call.providerName)) {
throw new Error('video-conf-provider-unavailable');
}
return (await this.getProviderManager()).onNewVideoConference(call.providerName, call);
}
private async runVideoConferenceChangedEvent(callId: VideoConference['_id']): Promise<void> {
const call = await VideoConferenceModel.findOneById(callId);
if (!call) {
throw new Error('video-conf-data-not-found');
}
if (!videoConfTypes.isCallManagedByApp(call)) {
return;
}
if (!videoConfProviders.isProviderAvailable(call.providerName)) {
throw new Error('video-conf-provider-unavailable');
}
return (await this.getProviderManager()).onVideoConferenceChanged(call.providerName, call);
}
private async runOnUserJoinEvent(callId: VideoConference['_id'], user?: IVideoConferenceUser): Promise<void> {
const call = await VideoConferenceModel.findOneById(callId);
if (!call) {
throw new Error('video-conf-data-not-found');
}
if (!videoConfTypes.isCallManagedByApp(call)) {
return;
}
if (!videoConfProviders.isProviderAvailable(call.providerName)) {
throw new Error('video-conf-provider-unavailable');
}
return (await this.getProviderManager()).onUserJoin(call.providerName, call, user);
}
private async addUserToCall(
call: Optional<VideoConference, 'providerData'>,
{ _id, username, name, avatarETag, ts }: AtLeast<Required<IUser>, '_id' | 'username' | 'name' | 'avatarETag'> & { ts?: Date },
): Promise<void> {
// 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);
}
if (call.users.find((user) => user._id === _id)) {
return;
}
await VideoConferenceModel.addUserById(call._id, { _id, username, name, avatarETag, ts });
if (call.type === 'direct') {
return this.updateDirectCall(call as IDirectVideoConference, _id);
}
this.notifyVideoConfUpdate(call.rid, call._id);
}
private async addAnonymousUser(call: Optional<IGroupVideoConference, 'providerData'>): Promise<void> {
await VideoConferenceModel.increaseAnonymousCount(call._id);
}
private async updateDirectCall(call: IDirectVideoConference, newUserId: IUser['_id']): Promise<void> {
// 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;
}
await VideoConferenceModel.setStatusById(call._id, VideoConferenceStatus.STARTED);
this.notifyVideoConfUpdate(call.rid, call._id);
await this.runVideoConferenceChangedEvent(call._id);
await this.sendAllPushNotifications(call._id);
}
private isPersistentChatEnabled(): boolean {
// Persistent chat discussions are always created unencrypted, so persistent chat is treated as disabled
// while the workspace enforces encryption on private rooms.
const encryptionEnforced = settings.get<boolean>('E2E_Enable') && settings.get<boolean>('E2E_Force_Encryption_For_Private_Rooms');
return settings.get<boolean>('VideoConf_Enable_Persistent_Chat') && settings.get<boolean>('Discussion_enabled') && !encryptionEnforced;
}
private async maybeCreateDiscussion(callId: VideoConference['_id'], createdBy?: IUser): Promise<void> {
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');
}
// 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;
}
const name = settings.get<string>('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}`;
}
await this.createDiscussionForConference(displayName, call, createdBy);
}
private async getRoomForDiscussion(
baseRoom: IRoom['_id'],
childRoomIds: IRoom['_id'][] = [],
): Promise<Pick<IRoom, '_id' | 't' | 'teamId' | 'prid'>> {
const room = await Rooms.findOneById<Pick<IRoom, '_id' | 't' | 'teamId' | 'prid'>>(baseRoom, {
projection: { t: 1, teamId: 1, prid: 1 },
});
if (!room) {
throw new Error('invalid-room');
}
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;
}
private async createDiscussionForConference(
name: string,
call: AtLeast<VideoConference, '_id' | 'rid' | 'createdBy'>,
createdBy?: IUser,
): Promise<void> {
const room = await this.getRoomForDiscussion(call.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,
[],
false,
false,
{
fname: name,
prid: room._id,
encrypted: false,
},
{
creator: user._id,
subscriptionExtra: {
open: false,
},
},
);
return this.assignDiscussionToConference(call._id, discussion._id);
}
public async assignDiscussionToConference(callId: VideoConference['_id'], rid: IRoom['_id'] | undefined): Promise<void> {
// Ensures the specified rid is a valid room
const room = rid ? await Rooms.findOneById<Pick<IRoom, '_id' | 'prid'>>(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 } });
if (!call) {
return;
}
if (rid === undefined) {
await VideoConferenceModel.unsetDiscussionRidById(callId);
} else {
await VideoConferenceModel.setDiscussionRidById(callId, rid);
}
if (room) {
await Promise.all(call.users.map(({ _id }) => this.addUserToDiscussion(room._id, _id)));
}
}
private async addUserToDiscussion(rid: IRoom['_id'], uid: IUser['_id']): Promise<void> {
try {
await Room.addUserToRoom(rid, { _id: uid }, undefined, { skipSystemMessage: true, createAsHidden: true });
} catch (err) {
// Ignore any errors here so that the subscription doesn't block the user from participating in the conference.
logger.error({
name: 'Error trying to subscribe user to discussion',
err,
rid,
uid,
});
}
}
}