Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions apps/meteor/app/api/server/v1/media-calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,76 @@ declare module '@rocket.chat/rest-typings' {
interface Endpoints extends MediaCallsAnswerEndpoints {}
}

type MediaCallsEscalate = {
callId: string;
};

const MediaCallsEscalateSchema: JSONSchemaType<MediaCallsEscalate> = {
type: 'object',
properties: {
callId: {
type: 'string',
},
},
required: ['callId'],
additionalProperties: false,
};

export const isMediaCallsEscalateProps = ajv.compile<MediaCallsEscalate>(MediaCallsEscalateSchema);

const mediaCallsEscalateEndpoints = API.v1.post(
'media-calls.escalate',
{
response: {
200: ajv.compile<{
providerName: string;
url: string;
}>({
additionalProperties: false,
type: 'object',
properties: {
providerName: {
type: 'string',
description: 'The name of the conference provider.',
},
url: {
type: 'string',
description: 'The url of the conference.',
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['providerName', 'url', 'success'],
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
body: isMediaCallsEscalateProps,
authRequired: true,
},
async function action() {
const { callId } = this.bodyParams;

const url = await MediaCall.escalateCall(this.userId, { callId });

return API.v1.success({
providerName: 'core.pexip',
url,
});
},
);

type MediaCallsEscalateEndpoints = ExtractRoutesFromAPI<typeof mediaCallsEscalateEndpoints>;

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends MediaCallsEscalateEndpoints {}
}

type MediaCallsStateSignalsParams = {
contractId: string;
};
Expand Down
12 changes: 12 additions & 0 deletions apps/meteor/ee/server/settings/voip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ export function addSettings(): Promise<void> {
i18nDescription: 'VoIP_TeamCollab_ExternalCallHistory_Timeout_Description',
});
});

await this.section('VoIP_TeamCollab_AdvancedFeatures', async function () {
const enableQuery = { _id: 'Pexip_Integration_Enabled', value: true };

await this.add('VoIP_TeamCollab_Video_Escalation_Enabled', false, {
type: 'boolean',
public: true,
invalidValue: false,
enableQuery,
i18nDescription: 'VoIP_TeamCollab_Video_Escalation_Enabled_Description',
});
});
},
);
});
Expand Down
150 changes: 138 additions & 12 deletions apps/meteor/server/services/media-call/service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { api, ServiceClassInternal, type IMediaCallService, Authorization } from '@rocket.chat/core-services';
import { api, ServiceClassInternal, type IMediaCallService, Authorization, VideoConf } from '@rocket.chat/core-services';
import type {
IMediaCall,
IUser,
IRoom,
IInternalMediaCallHistoryItem,
CallHistoryItemState,
IExternalMediaCallHistoryItem,
VideoConference,
AtLeast,
} from '@rocket.chat/core-typings';
import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls';
import type {
Expand All @@ -17,7 +19,7 @@ import type {
} from '@rocket.chat/media-signaling';
import { isClientMediaSignal } from '@rocket.chat/media-signaling';
import type { InsertionModel } from '@rocket.chat/model-typings';
import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models';
import { CallHistory, MediaCalls, Rooms, Users, VideoConference as VideoConferenceModel } from '@rocket.chat/models';
import { callStateToTranslationKey, getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload';

import { logger } from './logger';
Expand Down Expand Up @@ -398,20 +400,19 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
},
mobileRinging,
permissionCheck: (uid, callType) => this.userHasMediaCallPermission(uid, callType),
isFeatureAvailableForUser: (uid, feature) => this.userHasFeaturePermission(uid, feature),
isFeatureEnabled: (feature) => this.isFeatureEnabled(feature),
};
}

private userHasFeaturePermission(_uid: IUser['_id'], feature: CallFeature): boolean {
if (feature === 'audio') {
return true;
private isFeatureEnabled(feature: CallFeature): boolean {
switch (feature) {
case 'screen-share':
return settings.get<boolean>('VoIP_TeamCollab_Screen_Sharing_Enabled') ?? false;
case 'conference-escalation':
return Boolean(settings.get('VoIP_TeamCollab_Video_Escalation_Enabled') && settings.get('Pexip_Integration_Enabled'));
default:
return true;
}

if (feature === 'screen-share') {
return settings.get<boolean>('VoIP_TeamCollab_Screen_Sharing_Enabled') ?? false;
}

return true;
}

private async userHasMediaCallPermission(uid: IUser['_id'], callType: 'internal' | 'external' | 'any'): Promise<boolean> {
Expand All @@ -436,4 +437,129 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
throw err;
}
}

public async escalateCall(uid: IUser['_id'], params: { callId: string }): Promise<string> {
const { callId } = params;

const call = await MediaCalls.findOneById(callId);
if (!call?.acceptedAt) {
throw new Error('not-found');
}

if (!call.uids.includes(uid)) {
throw new Error('not-found');
}

const user = await Users.findOneById(uid);
if (!user) {
throw new Error('internal-error');
}

// if (!call.features.includes('conference-escalation')) {
// throw new Error('feature-not-available');
// }

const url = await this.escalateVoiceCallToConference(user, call);
return url;
}

private async escalateVoiceCallToConference(user: IUser, call: IMediaCall): Promise<string> {
const conference = await this.getOrCreateConferenceForEscalatingCall(call, user);
if (conference?.type !== 'videoconference') {
logger.error({ msg: 'Failed to create conference for voice call escalation', type: conference?.type });
throw new Error('internal-error');
}

void this.flagAsEscalated(call).catch((err) => {
logger.error({ msg: 'Unexpected error while flagging call as escalated', err });
});

const result = await VideoConf.joinCall(conference, user, { mic: true, cam: false });

return result;
}

private async findExistingConferenceForCall(call: IMediaCall): Promise<VideoConference | null> {
const existingConference = await VideoConferenceModel.findOneByMediaCallId(call._id);
if (existingConference || !call.remoteLegCallId) {
return existingConference;
}

return VideoConferenceModel.findOneByMediaCallId(call.remoteLegCallId);
}

private async getOrCreateConferenceForEscalatingCall(call: IMediaCall, user: IUser): Promise<VideoConference | null> {
const existingConference = await this.findExistingConferenceForCall(call);
if (existingConference) {
return existingConference;
}

// If the call is already flagged as escalated but no conference for it exists, don't create a new conference - some other process might still be running
if (call.escalatedAt) {
throw new Error('pre-escalated-conference-not-found');
}

const mediaCallIds = [call._id, call.remoteLegCallId].filter((callId): callId is string => Boolean(callId));

const conferenceId = await this.createConferenceForEscalatingCall(user, mediaCallIds);
return VideoConferenceModel.findOneById(conferenceId);
}

private async createConferenceForEscalatingCall(user: IUser, mediaCallIds: string[]): Promise<string> {
const rid = 'GENERAL';

const callId = await VideoConferenceModel.createGroup({
rid,
title: 'Escalated Media Call',
createdBy: {
_id: user._id,
name: user.name as string,
username: user.username as string,
},
providerName: 'core.pexip',
mediaCallIds,
});

return callId;
}

private async flagAsEscalated(call: IMediaCall): Promise<void> {
if (call.escalatedAt) {
return;
}

const updateResult = await MediaCalls.flagAsEscalatedByCallId(call._id);
if (!updateResult.modifiedCount) {
return;
}

if (call.remoteLegCallId) {
await MediaCalls.flagAsEscalatedByCallId(call.remoteLegCallId).catch((err) => {
logger.error({ msg: 'Unexpected error while flagging remote call leg as escalated', err });
});
}

await this.notifyEscalatedCall(call);

if (call.remoteLegCallId) {
await this.notifyEscalatedCallById(call.remoteLegCallId);
}
}

private async notifyEscalatedCall(call: AtLeast<IMediaCall, '_id' | 'uids'>): Promise<void> {
for (const uid of call.uids) {
await this.sendSignal(uid, {
callId: call._id,
type: 'notification',
notification: 'escalated',
});
}
}

private async notifyEscalatedCallById(callId: string): Promise<void> {
const call = await MediaCalls.findOneById<Pick<IMediaCall, '_id' | 'uids'>>(callId, { projection: { uids: 1 } });
if (call) {
this.notifyEscalatedCall(call);
}
}
}
2 changes: 1 addition & 1 deletion apps/meteor/server/services/video-conference/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -905,7 +905,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
};
}

private async joinCall(
public async joinCall(
call: ExternalVideoConference,
user: AtLeast<IUser, '_id' | 'username' | 'name' | 'avatarETag'> | undefined,
options: VideoConferenceJoinOptions,
Expand Down
2 changes: 1 addition & 1 deletion ee/packages/media-calls/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CallFeature } from '@rocket.chat/media-signaling';

export const DEFAULT_CALL_FEATURES: CallFeature[] = ['audio'];
export const SIP_CALL_FEATURES: CallFeature[] = ['audio', 'transfer', 'hold', 'screen-share'];
export const SIP_CALL_FEATURES: CallFeature[] = ['audio', 'transfer', 'hold', 'screen-share', 'conference-escalation'];
6 changes: 3 additions & 3 deletions ee/packages/media-calls/src/definition/IMediaCallServer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { IUser, MediaCallContact } from '@rocket.chat/core-typings';
import type { Emitter } from '@rocket.chat/emitter';
import type { CallFeature, ClientMediaSignal, ClientMediaSignalBody, ServerMediaSignal } from '@rocket.chat/media-signaling';

Expand Down Expand Up @@ -36,7 +36,7 @@ export interface IMediaCallServerSettings {
mobileRinging: boolean;

permissionCheck: (uid: IUser['_id'], callType: 'internal' | 'external' | 'any') => Promise<boolean>;
isFeatureAvailableForUser: (uid: IUser['_id'], feature: CallFeature) => boolean;
isFeatureEnabled: (feature: CallFeature) => boolean;
}

export interface IMediaCallServer {
Expand All @@ -60,5 +60,5 @@ export interface IMediaCallServer {
requestCall(params: InternalCallParams): Promise<void>;

permissionCheck(uid: IUser['_id'], callType: 'internal' | 'external' | 'any'): Promise<boolean>;
isFeatureAvailableForUser(uid: IUser['_id'], feature: CallFeature): boolean;
isFeatureAvailableForParticipants(feature: CallFeature, participants: MediaCallContact[]): boolean;
}
13 changes: 12 additions & 1 deletion ee/packages/media-calls/src/server/CallDirector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,16 @@ class MediaCallDirector {
callerAgent.oppositeAgent = calleeAgent;
calleeAgent.oppositeAgent = callerAgent;

const allowedFeatures = features.filter((feature) => getMediaCallServer().isFeatureAvailableForUser(caller.id, feature));
const forbiddenFeatures: CallFeature[] = [];
if (parentCallId) {
// Transferred calls can not be escalated yet
forbiddenFeatures.push('conference-escalation');
}

const participants = [caller, callee];
const allowedFeatures = features.filter(
(feature) => !forbiddenFeatures.includes(feature) && getMediaCallServer().isFeatureAvailableForParticipants(feature, participants),
);
const call: Omit<IMediaCall, '_updatedAt'> = {
// Use UUIDs to identify all media calls, for better compatibility with libs that require it (such as React Native's CallKit)
_id: randomUUID(),
Expand Down Expand Up @@ -274,6 +283,8 @@ class MediaCallDirector {
return;
}

// TODO: Consider call's divertedBy as well

const callerActiveCalls = await MediaCalls.findOutgoingSIPCallsNotOverByCallerUId<Pick<IMediaCall, '_id' | 'callee'>>(call.caller.uid, {
projection: { callee: 1 },
}).toArray();
Expand Down
30 changes: 27 additions & 3 deletions ee/packages/media-calls/src/server/MediaCallServer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { IUser, MediaCallContact } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import type {
CallFeature,
Expand Down Expand Up @@ -146,8 +146,32 @@ export class MediaCallServer implements IMediaCallServer {
return this.settings.permissionCheck(uid, callType);
}

public isFeatureAvailableForUser(uid: IUser['_id'], feature: CallFeature): boolean {
return this.settings.isFeatureAvailableForUser(uid, feature);
public isFeatureAvailableForParticipants(feature: CallFeature, participants: MediaCallContact[]): boolean {
if (!this.settings.isFeatureEnabled(feature)) {
return false;
}

if (feature === 'conference-escalation') {
// Conference escalation is only implemented on SIP calls
return this.isSipCallParticipants(participants);
}

return true;
}

private isSipCallParticipants(participants: MediaCallContact[]): boolean {
// On sip calls, one participant is an internal user and the other is a sip extension; The order depends on the call direction
const sipUser = participants.find(({ type }) => type === 'sip');
if (!sipUser) {
return false;
}

const internalUser = participants.find(({ type }) => type === 'user');
if (!internalUser) {
return false;
}

return true;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion ee/packages/media-calls/src/server/getDefaultSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ export function getDefaultSettings(): IMediaCallServerSettings {
mobileRinging: false,

permissionCheck: async () => false,
isFeatureAvailableForUser: () => false,
isFeatureEnabled: () => false,
};
}
Loading
Loading