diff --git a/apps/meteor/ee/server/settings/voip.ts b/apps/meteor/ee/server/settings/voip.ts index 5ae0596670916..b9e609e4a9756 100644 --- a/apps/meteor/ee/server/settings/voip.ts +++ b/apps/meteor/ee/server/settings/voip.ts @@ -123,6 +123,18 @@ export function addSettings(): Promise { 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', + }); + }); }, ); }); diff --git a/apps/meteor/server/api/v1/media-calls.ts b/apps/meteor/server/api/v1/media-calls.ts index 0c544a7070754..9dfaad8fa2773 100644 --- a/apps/meteor/server/api/v1/media-calls.ts +++ b/apps/meteor/server/api/v1/media-calls.ts @@ -98,6 +98,76 @@ declare module '@rocket.chat/rest-typings' { interface Endpoints extends MediaCallsAnswerEndpoints {} } +type MediaCallsEscalate = { + callId: string; +}; + +const MediaCallsEscalateSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + }, + }, + required: ['callId'], + additionalProperties: false, +}; + +export const isMediaCallsEscalateProps = ajv.compile(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; + +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; }; diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 44ae6ab05b9ea..1e1261100ca4e 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -1,4 +1,4 @@ -import { api, Presence, ServiceClassInternal, type IMediaCallService, Authorization } from '@rocket.chat/core-services'; +import { api, Presence, ServiceClassInternal, type IMediaCallService, Authorization, VideoConf } from '@rocket.chat/core-services'; import type { IMediaCall, IUser, @@ -6,9 +6,13 @@ import type { IInternalMediaCallHistoryItem, CallHistoryItemState, IExternalMediaCallHistoryItem, + VideoConference, + AtLeast, + IGroupVideoConference, + IRegisterUser, } from '@rocket.chat/core-typings'; import { UserStatus } from '@rocket.chat/core-typings'; -import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls'; +import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall, ESCALATED_CALL_FEATURES } from '@rocket.chat/media-calls'; import type { CallFeature, ClientMediaSignal, @@ -18,7 +22,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'; @@ -42,7 +46,10 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall this.onEvent('media-call.updated', (params) => callServer.receiveCallUpdate(params)); this.onEvent('watch.settings', async ({ setting }): Promise => { - if (setting._id.startsWith('VoIP_TeamCollab_') && !setting._id.includes('ExternalCallHistory')) { + if ( + (setting._id.startsWith('VoIP_TeamCollab_') && !setting._id.includes('ExternalCallHistory')) || + setting._id.startsWith('Pexip_Integration_SIP_') + ) { setImmediate(() => this.configureMediaCallServer()); } }); @@ -429,23 +436,26 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall host: settings.get('VoIP_TeamCollab_SIP_Server_Host') ?? '', port: settings.get('VoIP_TeamCollab_SIP_Server_Port') ?? 5060, }, + pexipServer: { + host: settings.get('Pexip_Integration_SIP_Host') ?? '', + port: settings.get('Pexip_Integration_SIP_Port') ?? 5060, + }, }, 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; - } - - if (feature === 'screen-share') { - return settings.get('VoIP_TeamCollab_Screen_Sharing_Enabled') ?? false; + private isFeatureEnabled(feature: CallFeature): boolean { + switch (feature) { + case 'screen-share': + return settings.get('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; } - - return true; } private async userHasMediaCallPermission(uid: IUser['_id'], callType: 'internal' | 'external' | 'any'): Promise { @@ -470,4 +480,208 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall throw err; } } + + public async escalateCall(uid: IUser['_id'], params: { callId: string }): Promise { + const { callId } = params; + + logger.debug({ msg: 'Escalating Voice Call', method: 'MediaCallService.escalateCall', uid, callId }); + + const call = await MediaCalls.findOneById(callId); + + try { + if (!call?.acceptedAt || call.ended) { + throw new Error('not-found'); + } + + if (!call.uids.includes(uid)) { + throw new Error('not-found'); + } + if (!call.features.includes('conference-escalation')) { + throw new Error('feature-not-available'); + } + + const user = await Users.findOneById(uid); + if (!user) { + throw new Error('internal-error'); + } + + const url = await this.escalateVoiceCallToConference(user, call); + + // If the peer has also escalated this call, then we can hangup as we join the conference + if (call.escalatedByPeerAt) { + void callServer.hangupEscalatedCall(call, { type: 'user', id: user._id }).catch((err) => { + logger.error({ msg: 'Unexpected error while hanging up a fully escalated voice call', err }); + }); + } + + return url; + } catch (err) { + logger.debug({ msg: 'Unexpected error during escalation', err, uid, callId, call }); + throw err; + } + } + + private async escalateVoiceCallToConference(user: IUser, call: IMediaCall): Promise { + 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 getOrCreateConferenceForEscalatingCall(call: IMediaCall, user: IUser): Promise { + const existingConference = await VideoConferenceModel.findOneByMediaCallId(call._id); + 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'); + } + + return this.createConferenceForEscalatingCall(user, call); + } + + private async createConferenceForEscalatingCall(user: IUser, call: IMediaCall): Promise { + // TODO: ensure there are two legs with the same uid pair + const rid = await this.getRoomIdForExternalCall(call); + if (!rid) { + throw new Error('Could not find parent room to create the conference on'); + } + + return VideoConf.createEscalatedConference( + { + rid, + mediaCallIds: [call._id], + }, + user as IRegisterUser, + ); + } + + private async getRoomIdForExternalCall(call: IMediaCall): Promise { + const callerUid = call.caller.uid; + const calleeUid = call.callee.uid; + + if (!callerUid || !calleeUid) { + return this.parsePersistentChatExternalRoom(); + } + + try { + const uids = [callerUid, calleeUid]; + const uniqueUids = [...new Set(uids)]; + + const room = await Rooms.findOneDirectRoomContainingAllUserIDs(uniqueUids, { projection: { _id: 1 } }); + if (room) { + return room._id; + } + + const dmCreatorId = call.caller.type === 'user' ? callerUid : calleeUid; + + const usernames = ( + await Users.findByIds(uids, { projection: { username: 1 } }) + .map((user) => user.username) + .toArray() + ).filter((username) => username); + + if (usernames.length !== 2) { + throw new Error('Invalid usernames for DM.'); + } + + const newRoom = await createDirectMessage(usernames, dmCreatorId, false); + return newRoom.rid; + } catch (err) { + logger.error({ msg: 'Failed to determine DM room for external call', err }); + return this.parsePersistentChatExternalRoom(); + } + } + + private parsePersistentChatExternalRoom(): string | null { + const settingValue = settings.get('Pexip_Integration_PersistentChat_ExternalRoom'); + if (!settingValue || typeof settingValue !== 'object' || !Array.isArray(settingValue) || !settingValue.length) { + return null; + } + + for (const value of settingValue) { + if (!value || typeof value !== 'object' || !value._id) { + continue; + } + + return value._id; + } + + return null; + } + + private async flagAsEscalated(call: IMediaCall): Promise { + if (call.escalatedAt) { + return; + } + + const updateResult = await MediaCalls.flagAsEscalatedByCallId(call._id); + if (!updateResult.modifiedCount) { + return; + } + + await this.notifyEscalatedCall(call, Boolean(call.escalatedByPeerAt)); + api.broadcast('media-call.updated', { + callId: call._id, + }); + } + + public async hangupAutoEscalatedCall(call: IMediaCall, uid: IUser['_id']): Promise { + if (!call.escalatedByPeerAt) { + return this.flagAsEscalated(call); + } + + if (!call.escalatedAt) { + await MediaCalls.flagAsEscalatedByCallId(call._id).catch((err) => { + logger.error({ msg: 'Unexpected error while flagging call as auto escalated', err }); + }); + } + + await callServer.hangupEscalatedCall(call, { type: 'user', id: uid }).catch((err) => { + logger.error({ msg: 'Unexpected error while hanging up an auto escalated voice call', err }); + }); + } + + private async notifyEscalatedCall(call: AtLeast, escalatedByPeer = false): Promise { + for (const uid of call.uids) { + await this.sendSignal(uid, { + callId: call._id, + type: 'notification', + notification: 'escalated', + ...(escalatedByPeer && + call.features && { + features: call.features.filter((feature: any): feature is CallFeature => ESCALATED_CALL_FEATURES.includes(feature)), + }), + }); + } + } + + public async flagAsRemotelyEscalatedByCallId(callId: string): Promise { + const call = await MediaCalls.findOneById(callId, { projection: { _id: 1, escalatedByPeerAt: 1, uids: 1, features: 1 } }); + if (!call || call.escalatedByPeerAt) { + return; + } + + const updateResult = await MediaCalls.flagAsRemotelyEscalatedByCallId(call._id); + if (!updateResult.modifiedCount) { + return; + } + + if (!call.escalatedAt) { + await this.notifyEscalatedCall(call, true); + } + + // TODO: maybe hangup if escalatedAt is already set? + } } diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 8c491f8435072..19fa5050a56bf 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -1,3 +1,5 @@ +import crypto from 'crypto'; + 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'; @@ -23,6 +25,8 @@ import type { Optional, ExternalVideoConference, IVoIPVideoConference, + RequiredField, + IRegisterUser, } from '@rocket.chat/core-typings'; import { VideoConferenceStatus, @@ -564,7 +568,11 @@ 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 { const record = { t: 'videoconf', msg: i18n.t('Video_Conference', { @@ -772,6 +780,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf providerName, }); + await this.maybeAddSipAliasToCall(callId, providerName); + await this.runNewVideoConferenceEvent(callId); await this.maybeCreateDiscussion(callId, user); @@ -827,6 +837,105 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await subscriptions.forEach((subscription) => this.notifyUser(subscription.u._id, action, params)); } + private makeSipAlias(): string { + 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 { + 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 { + if (providerName !== 'core.pexip') { + return; + } + + if (!settings.get('Pexip_Integration_SIP_AddAlias')) { + return; + } + + await this.addSipAlias(callId); + } + + public async createEscalatedConference( + data: Required>, + user: IRegisterUser, + ): Promise { + const providerName = 'core.pexip'; + + const { _id, name, username } = user; + + const callId = await VideoConferenceModel.createGroup({ + ...data, + // TODO: custom title + title: 'Escalated Media Call', + providerName, + createdBy: { + _id, + name, + username, + }, + }); + + await this.maybeAddSipAliasToCall(callId, providerName); + await this.maybeCreateDiscussion(callId); + + const callData = { + _id: callId, + providerName, + rid: data.rid, + }; + + const messageId = await this.createMessage(callData, user); + await VideoConferenceModel.setMessageById(callId, 'started', messageId); + + return VideoConferenceModel.findOneById(callId); + } + private async startGroup( providerName: string, user: IUser, @@ -847,6 +956,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf providerName, }); + await this.maybeAddSipAliasToCall(callId, providerName); + await this.runNewVideoConferenceEvent(callId); await this.maybeCreateDiscussion(callId, user); @@ -907,7 +1018,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf }; } - private async joinCall( + public async joinCall( call: ExternalVideoConference, user: AtLeast | undefined, options: VideoConferenceJoinOptions, @@ -1002,6 +1113,12 @@ 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, @@ -1015,6 +1132,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf call.url = await this.generateNewUrl(call); await VideoConferenceModel.setUrlById(call._id, call.url); } + this.requireCallUrl(call); const userData = user && { _id: user._id, @@ -1110,7 +1228,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf const provider = videoConfProviders.getVideoConfProviderHandler(call.providerName); if (provider) { - return; + return provider.onUserJoin(call, user); } return (await this.getProviderManager()).onUserJoin(call.providerName, call, user); diff --git a/apps/meteor/server/settings/pexip.ts b/apps/meteor/server/settings/pexip.ts index 9684aaa35ae19..0569de0e43931 100644 --- a/apps/meteor/server/settings/pexip.ts +++ b/apps/meteor/server/settings/pexip.ts @@ -25,6 +25,13 @@ export function createPexipSettings(): Promise { i18nDescription: `Pexip_Integration_Meeting_Url_Description`, }); + await this.add('Pexip_Integration_Escalation_Params', 'join=1&muteCamera=true', { + type: 'string', + public: true, + invalidValue: '', + i18nDescription: `Pexip_Integration_Escalation_Params_Description`, + }); + await this.section('Pexip_Integration_API', async function () { await this.add('Pexip_Integration_API_Username', '', { type: 'string', @@ -98,6 +105,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`, + }); + }); }); } @@ -106,6 +145,7 @@ export function getPexipSettings(): PexipSettings { enabled: settings.get('Pexip_Integration_Enabled'), baseUrl: settings.get('Pexip_Integration_Base_Url'), meetingUrl: settings.get('Pexip_Integration_Meeting_Url'), + escalationParams: settings.get('Pexip_Integration_Escalation_Params'), api: { username: settings.get('Pexip_Integration_API_Username'), password: settings.get('Pexip_Integration_API_Password'), @@ -120,11 +160,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/ee/packages/media-calls/src/constants.ts b/ee/packages/media-calls/src/constants.ts index 86fa92bb08709..15b1ce331f905 100644 --- a/ee/packages/media-calls/src/constants.ts +++ b/ee/packages/media-calls/src/constants.ts @@ -1,4 +1,5 @@ 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']; +export const ESCALATED_CALL_FEATURES: CallFeature[] = ['audio', 'conference-escalation']; diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts index 37b119e4df5c3..05fb81daff303 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallServer.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -1,8 +1,8 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IMediaCall, 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'; -import type { InternalCallParams, SignalProcessingOptions } from './common'; +import type { InternalCallParams, MediaCallHeader, SignalProcessingOptions } from './common'; export type VoipPushNotificationType = 'incoming_call' | 'remoteEnded' | 'answeredElsewhere' | 'declinedElsewhere' | 'unanswered'; export type VoipPushNotificationEventType = 'new' | 'answer' | 'end'; @@ -33,12 +33,16 @@ export interface IMediaCallServerSettings { host: string; port: number; }; + pexipServer: { + host: string; + port: number; + }; }; mobileRinging: boolean; permissionCheck: (uid: IUser['_id'], callType: 'internal' | 'external' | 'any') => Promise; - isFeatureAvailableForUser: (uid: IUser['_id'], feature: CallFeature) => boolean; + isFeatureEnabled: (feature: CallFeature) => boolean; } export interface IMediaCallServer { @@ -58,9 +62,10 @@ export interface IMediaCallServer { hangupExpiredCalls(): Promise; scheduleExpirationCheck(): void; configure(settings: IMediaCallServerSettings): void; + hangupEscalatedCall(call: MediaCallHeader, endedBy?: IMediaCall['endedBy']): Promise; requestCall(params: InternalCallParams): Promise; permissionCheck(uid: IUser['_id'], callType: 'internal' | 'external' | 'any'): Promise; - isFeatureAvailableForUser(uid: IUser['_id'], feature: CallFeature): boolean; + isFeatureAvailableForParticipants(feature: CallFeature, participants: MediaCallContact[]): boolean; } diff --git a/ee/packages/media-calls/src/index.ts b/ee/packages/media-calls/src/index.ts index 26cc20f190ec1..58864b6b1de30 100644 --- a/ee/packages/media-calls/src/index.ts +++ b/ee/packages/media-calls/src/index.ts @@ -1,4 +1,5 @@ export type * from './definition/IMediaCallServer'; +export * from './constants'; export { callServer } from './server/configuration'; export { getSignalsForExistingCall } from './server/signals/getSignalsForExistingCall'; diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index 45c0284418c3f..c0d866131da8c 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -1,6 +1,6 @@ import type { IMediaCall, MediaCallSignedContact } from '@rocket.chat/core-typings'; -import { isBusyState } from '@rocket.chat/media-signaling'; -import type { ClientMediaSignal, ServerMediaSignal, CallFeature } from '@rocket.chat/media-signaling'; +import { isBusyState, isCallHangupReason } from '@rocket.chat/media-signaling'; +import type { ClientMediaSignal, ServerMediaSignal, CallFeature, CallHangupReason } from '@rocket.chat/media-signaling'; import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { UserActorSignalProcessor } from './CallSignalProcessor'; @@ -56,9 +56,35 @@ export class UserActorAgent extends BaseMediaCallAgent { callId, type: 'notification', notification: 'hangup', + hangupReason: await this.getCallHangupReasonForClient(callId), }); } + private async getCallHangupReasonForClient(callId: string): Promise { + const call = await MediaCalls.findOneById>(callId, { + projection: { endedBy: 1, hangupReason: 1, escalatedAt: 1 }, + }); + if (!call) { + return 'remote'; + } + + const { endedBy, hangupReason, escalatedAt } = call; + // If we requested an escalation, treat the hangup as normal + if (escalatedAt) { + return 'normal'; + } + + if (endedBy?.type !== this.actorType || endedBy?.id !== this.actorId) { + return 'remote'; + } + + if (hangupReason && isCallHangupReason(hangupReason)) { + return hangupReason; + } + + return 'remote'; + } + public async onCallActive(callId: string): Promise { return this.sendSignal({ callId, diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index 194d948befea8..38139b43d603f 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -37,8 +37,7 @@ class MediaCallDirector { const modified = await this.hangupCallById(call._id, { endedBy, reason }); if (modified) { - await actorAgent.onCallEnded(call._id); - await actorAgent.oppositeAgent?.onCallEnded(call._id); + await this.triggerOnCallEnded(call, actorAgent); } } @@ -215,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 = { // Use UUIDs to identify all media calls, for better compatibility with libs that require it (such as React Native's CallKit) _id: randomUUID(), @@ -463,6 +471,22 @@ class MediaCallDirector { return modified; } } + + private async getAgentFromCall(call: IMediaCall, role: CallRole): Promise { + return this.cast.getAgentFromCall(call, role).catch(() => null); + } + + private async triggerOnCallEnded(call: IMediaCall, agent: IMediaCallAgent): Promise { + await agent.onCallEnded(call._id); + if (agent.oppositeAgent) { + return agent.oppositeAgent.onCallEnded(call._id); + } + + const oppositeAgent = await this.getAgentFromCall(call, agent.oppositeRole); + if (oppositeAgent) { + await oppositeAgent?.onCallEnded(call._id); + } + } } export const mediaCallDirector = new MediaCallDirector(); diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index 86aaa932db858..911a313745da6 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -1,4 +1,4 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IMediaCall, IUser, MediaCallContact } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { CallFeature, @@ -18,7 +18,7 @@ import type { VoipPushNotificationEventType, } from '../definition/IMediaCallServer'; import { CallRejectedError } from '../definition/common'; -import type { SignalProcessingOptions, GetActorContactOptions, InternalCallParams } from '../definition/common'; +import type { SignalProcessingOptions, GetActorContactOptions, InternalCallParams, MediaCallHeader } from '../definition/common'; import { InternalCallProvider } from '../internal/InternalCallProvider'; import { GlobalSignalProcessor } from '../internal/SignalProcessor'; import { logger } from '../logger'; @@ -132,6 +132,13 @@ export class MediaCallServer implements IMediaCallServer { return mediaCallDirector.hangupExpiredCalls(); } + public async hangupEscalatedCall(call: MediaCallHeader, endedBy?: IMediaCall['endedBy']): Promise { + return mediaCallDirector.hangupDetachedCall(call, { + reason: 'conference-escalation', + ...(endedBy && { endedBy }), + }); + } + public scheduleExpirationCheck(): void { mediaCallDirector.scheduleExpirationCheck(); } @@ -146,8 +153,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; } /** diff --git a/ee/packages/media-calls/src/server/getDefaultSettings.ts b/ee/packages/media-calls/src/server/getDefaultSettings.ts index 07ddfe33ccc9a..1c9947c3af096 100644 --- a/ee/packages/media-calls/src/server/getDefaultSettings.ts +++ b/ee/packages/media-calls/src/server/getDefaultSettings.ts @@ -17,10 +17,14 @@ export function getDefaultSettings(): IMediaCallServerSettings { host: '', port: 5080, }, + pexipServer: { + host: '', + port: 5060, + }, }, mobileRinging: false, permissionCheck: async () => false, - isFeatureAvailableForUser: () => false, + isFeatureEnabled: () => false, }; } diff --git a/ee/packages/media-calls/src/sip/Session.ts b/ee/packages/media-calls/src/sip/Session.ts index 7afd6944744c9..dffbecd1f657d 100644 --- a/ee/packages/media-calls/src/sip/Session.ts +++ b/ee/packages/media-calls/src/sip/Session.ts @@ -12,6 +12,7 @@ import { IncomingSipCall } from './providers/IncomingSipCall'; import { OutgoingSipCall } from './providers/OutgoingSipCall'; import type { IMediaCallServerSettings } from '../definition/IMediaCallServer'; import type { InternalCallParams } from '../definition/common'; +import { mediaCallDirector } from '../server/CallDirector'; import { getDefaultSettings } from '../server/getDefaultSettings'; export class SipServerSession { @@ -110,6 +111,61 @@ export class SipServerSession { return `sip:${extension}@${host}${portStr}`; } + public getPexipUri(alias: string): string { + const { host, port } = this.settings.sip.pexipServer; + if (!host) { + throw new Error('Pexip Server Host is not configured'); + } + + const portStr = port ? `:${port}` : ''; + return `sip:${alias}@${host}${portStr}`; + } + + public isPexipIdentity(identity: string): boolean { + if (!identity) { + return false; + } + + const { host } = this.settings.sip.pexipServer; + if (!host) { + return false; + } + + return identity.includes(host); + } + + public async sendReferRequest( + sipDialog: Srf.Dialog, + params: { transferredTo?: MediaCallContact; transferredBy?: MediaCallContact; conferenceAlias?: string }, + ): Promise { + const { transferredBy, transferredTo, conferenceAlias } = params; + if (!transferredTo && !conferenceAlias) { + throw new Error('Missing refer destination'); + } + + // Sip targets can only be referred to other sip users + const referToActor = transferredTo && (await mediaCallDirector.cast.getContactForActor(transferredTo, { requiredType: 'sip' })); + const referredBy = transferredBy && this.geContactUri(transferredBy); + const referToConference = conferenceAlias && this.getPexipUri(conferenceAlias); + + const referTo = referToConference || (referToActor && this.geContactUri(referToActor)); + if (!referTo) { + throw new Error('invalid-transfer'); + } + + const res = await sipDialog.request({ + method: 'REFER', + headers: { + 'Refer-To': referTo, + ...(referredBy && { 'Referred-By': referredBy }), + }, + }); + + if (res.status === 202) { + logger.debug({ msg: 'REFER was accepted', method: 'SipServerSession.sendReferRequest', ...params }); + } + } + public stripDrachtioServerDetails(reqOrRes: Srf.SipMessage): Record { const { _agent, socket: _socket, _req, _res, ...data } = reqOrRes as Record; diff --git a/ee/packages/media-calls/src/sip/providers/BaseSipCall.ts b/ee/packages/media-calls/src/sip/providers/BaseSipCall.ts index 4196da5440d2c..a5dbd52d0b1c1 100644 --- a/ee/packages/media-calls/src/sip/providers/BaseSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/BaseSipCall.ts @@ -1,10 +1,11 @@ import type { IMediaCall, IMediaCallChannel, MediaCallContact } from '@rocket.chat/core-typings'; -import type { ClientMediaSignalBody } from '@rocket.chat/media-signaling'; -import { MediaCalls } from '@rocket.chat/models'; +import type { CallHangupReason, ClientMediaSignalBody } from '@rocket.chat/media-signaling'; +import { MediaCalls, VideoConference as VideoConferenceModel } from '@rocket.chat/models'; import type Srf from 'drachtio-srf'; import type { SrfRequest, SrfResponse } from 'drachtio-srf'; import { BaseCallProvider } from '../../base/BaseCallProvider'; +import { UserActorAgent } from '../../internal/agents/UserActorAgent'; import { logger } from '../../logger'; import type { BroadcastActorAgent } from '../../server/BroadcastAgent'; import { mediaCallDirector } from '../../server/CallDirector'; @@ -25,6 +26,12 @@ export abstract class BaseSipCall extends BaseCallProvider { protected abstract inboundRenegotiations: Map; + protected sipDialog: Srf.Dialog | null; + + protected processedTransfer: boolean; + + protected processedEscalation: boolean; + constructor( protected readonly session: SipServerSession, call: IMediaCall, @@ -33,6 +40,9 @@ export abstract class BaseSipCall extends BaseCallProvider { ) { super(call); this.lastCallState = 'none'; + this.sipDialog = null; + this.processedTransfer = false; + this.processedEscalation = false; } protected async handleDialogModify(req: SrfRequest, res: SrfResponse): Promise { @@ -42,7 +52,44 @@ export abstract class BaseSipCall extends BaseCallProvider { const newContact = await this.detectSipInitiatedTransfer(callingNumber); if (newContact) { - return this.updateRemoteContact(newContact); + const header = req.has('p-asserted-identity') ? req.get('p-asserted-identity') : req.get('from'); + + // If the call's updated identity includes the pexip SIP host, treat it as an escalated call. + if (header && this.session.isPexipIdentity(header)) { + await this.processEscalatedRemotely(callingNumber); + } + + await this.updateRemoteContact(newContact); + } + } + + /** + * Flag a call as escalated by peer based on a contact change on the SIP negotiation + */ + protected async processEscalatedRemotely(sipAlias: string): Promise { + // The call might have already been flagged as escalated by the event sink, so do nothing in that case + if (this.call.escalatedByPeerAt) { + return; + } + + const updateResult = await MediaCalls.flagAsRemotelyEscalatedByCallId(this.call._id); + if (!updateResult.modifiedCount) { + return; + } + + const conference = await VideoConferenceModel.addMediaCallIdByProviderNameAndSipAlias('core.pexip', sipAlias, this.call._id); + if (!conference) { + // TODO: maybe rollback `flagAsRemotelyEscalatedByCallId` ? + return; + } + + const { oppositeAgent } = this.agent; + if (oppositeAgent && oppositeAgent instanceof UserActorAgent) { + await oppositeAgent.sendSignal({ + callId: this.call._id, + type: 'notification', + notification: 'escalated', + }); } } @@ -192,6 +239,21 @@ export abstract class BaseSipCall extends BaseCallProvider { // no extra handling by default } + protected onDialogDestroyed(): void { + logger.debug({ + msg: 'SIP Dialog Destroyed', + type: this.constructor.name, + callId: this.call._id, + }); + + this.sipDialog = null; + if (this.processedEscalation) { + this.hangupCall('conference-escalation', 'user'); + } else { + this.hangupCall('remote'); + } + } + public override async reactToCallChanges(params: { dtmf?: ClientMediaSignalBody<'dtmf'> }): Promise { logger.debug({ msg: 'reactToCallChanges', type: this.constructor.name, callId: this.call._id, lastCallState: this.lastCallState }); @@ -217,6 +279,8 @@ export abstract class BaseSipCall extends BaseCallProvider { protected abstract reflectCall(call: IMediaCall, params: { dtmf?: ClientMediaSignalBody<'dtmf'> }): Promise; + protected abstract processEndedCall(call: IMediaCall): Promise; + protected async sendDTMF(dialog: Srf.Dialog, dtmf: string, duration: number): Promise { logger.debug({ msg: 'BaseSipCall.sendDTMF' }); await dialog.request({ @@ -227,4 +291,109 @@ export abstract class BaseSipCall extends BaseCallProvider { body: `Signal=${dtmf}\r\nDuration=${duration}`, }); } + + protected async processTransferredCall(call: IMediaCall): Promise { + if (this.lastCallState === 'hangup' || !call.transferredTo || !call.transferredBy) { + return; + } + + if (!this.sipDialog || this.processedTransfer) { + if (call.ended) { + return this.processEndedCall(call); + } + return; + } + + logger.debug({ msg: 'processTransferredCall', callId: call._id, lastCallState: this.lastCallState, type: this.constructor.name }); + this.processedTransfer = true; + + try { + await this.session.sendReferRequest(this.sipDialog, { + transferredTo: call.transferredTo, + transferredBy: call.transferredBy, + }); + } catch (err) { + logger.error({ msg: 'REFER failed', method: 'processTransferredCall', err, callId: call._id, type: this.constructor.name }); + if (!call.ended) { + this.hangupCall('signaling-error'); + } + return this.processEndedCall(call); + } + } + + /** + * The call has been flagged as escalated by a rocket.chat user, so update the SIP dialog accordingly + */ + protected async processEscalatedCall(call: IMediaCall): Promise { + if (this.lastCallState === 'hangup' || !call.escalatedAt) { + return; + } + + if (!this.sipDialog || this.processedEscalation || call.escalatedByPeerAt) { + if (call.ended || call.escalatedByPeerAt) { + return this.processEndedCall(call); + } + return; + } + + const conference = await VideoConferenceModel.findOneByMediaCallId(call._id, { projection: { sipAlias: 1, mediaCallIds: 1 } }); + if (!conference) { + logger.debug({ + msg: 'Could not find Conference for escalated voice call', + method: 'processEscalatedCall', + callId: call._id, + type: this.constructor.name, + }); + return; + } + + const { sipAlias: conferenceAlias, mediaCallIds } = conference; + + if (!conferenceAlias || !mediaCallIds) { + logger.debug({ + msg: 'Escalated Conference does not have a SIP Alias', + method: 'processEscalatedCall', + callId: call._id, + conferenceId: conference._id, + type: this.constructor.name, + }); + return; + } + + // Check again to avoid race conditions + if (this.processedEscalation) { + if (call.ended) { + return this.processEndedCall(call); + } + return; + } + + logger.debug({ msg: 'Processing Call Escalation', callId: call._id, lastCallState: this.lastCallState, type: this.constructor.name }); + this.processedEscalation = true; + + try { + // If the conference is already associated with two voice calls, then the remote SIP leg is already in it, do not refer + if (mediaCallIds.length >= 2) { + if (!call.ended) { + this.hangupCall('conference-escalation'); + } + return; + } + + await this.session.sendReferRequest(this.sipDialog, { conferenceAlias }); + } catch (err) { + logger.error({ msg: 'REFER failed', method: 'processEscalatedCall', err, type: this.constructor.name }); + if (!call.ended) { + this.hangupCall('signaling-error'); + } + } + } + + protected hangupCall(hangupReason: CallHangupReason, fromAgent: 'sip' | 'user' = 'sip'): void { + const agent = (fromAgent === 'user' && this.agent.oppositeAgent) || this.agent; + + void mediaCallDirector.hangup(this.call, agent, hangupReason).catch((err) => { + logger.debug({ msg: 'Unexpected error ending call', err, type: this.constructor.name, hangupReason }); + }); + } } diff --git a/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts b/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts index fc32d93f6b686..dde770f9cd410 100644 --- a/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts @@ -21,12 +21,8 @@ import { SipError, SipErrorCodes } from '../errorCodes'; import { parseDiversionHeader } from '../utils/parseDiversionHeader'; export class IncomingSipCall extends BaseSipCall { - private sipDialog: Srf.Dialog | null; - protected inboundRenegotiations: Map; - private processedTransfer: boolean; - constructor( session: SipServerSession, call: IMediaCall, @@ -40,6 +36,7 @@ export class IncomingSipCall extends BaseSipCall { this.sipDialog = null; this.inboundRenegotiations = new Map(); this.processedTransfer = false; + this.processedEscalation = false; } public static async processInvite(session: SipServerSession, srf: Srf, req: SrfRequest, res: SrfResponse): Promise { @@ -139,7 +136,7 @@ export class IncomingSipCall extends BaseSipCall { if (!uas) { logger.error({ msg: 'IncomingSipCall.createDialog - dialog creation failed', callId: this.callId }); - void mediaCallDirector.hangupByServer(this.call, 'signaling-error'); + this.hangupCall('signaling-error'); return; } @@ -147,11 +144,7 @@ export class IncomingSipCall extends BaseSipCall { void this.handleDialogModify(req, res); }); - uas.on('destroy', () => { - logger.debug({ msg: 'IncomingSipCall - uas.destroy' }); - this.sipDialog = null; - void mediaCallDirector.hangup(this.call, this.agent, 'remote'); - }); + uas.on('destroy', () => this.onDialogDestroyed()); this.sipDialog = uas; } @@ -160,7 +153,7 @@ export class IncomingSipCall extends BaseSipCall { logger.debug({ msg: 'IncomingSipCall.cancel', res: this.session.stripDrachtioServerDetails(res) }); logger.info({ msg: 'The incoming SIP call was canceled by the caller', callId: this.callId }); - void mediaCallDirector.hangup(this.call, this.agent, 'remote').catch(() => null); + this.hangupCall('remote'); } protected async reflectCall(call: IMediaCall, params: { dtmf?: ClientMediaSignalBody<'dtmf'> }): Promise { @@ -172,6 +165,10 @@ export class IncomingSipCall extends BaseSipCall { return this.processTransferredCall(call); } + if (call.escalatedAt) { + return this.processEscalatedCall(call); + } + if (call.ended) { return this.processEndedCall(call); } @@ -183,51 +180,6 @@ export class IncomingSipCall extends BaseSipCall { logger.debug({ msg: 'no changes detected', method: 'IncomingSipCall.reflectCall' }); } - protected async processTransferredCall(call: IMediaCall): Promise { - if (this.lastCallState === 'hangup' || !call.transferredTo || !call.transferredBy) { - return; - } - - if (!this.sipDialog || this.processedTransfer) { - if (call.ended) { - return this.processEndedCall(call); - } - return; - } - - logger.debug({ msg: 'IncomingSipCall.processTransferredCall', callId: call._id, lastCallState: this.lastCallState }); - this.processedTransfer = true; - - try { - // Sip targets can only be referred to other sip users - const newCallee = await mediaCallDirector.cast.getContactForActor(call.transferredTo, { requiredType: 'sip' }); - if (!newCallee) { - throw new Error('invalid-transfer'); - } - - const referTo = this.session.geContactUri(newCallee); - const referredBy = this.session.geContactUri(call.transferredBy); - - const res = await this.sipDialog.request({ - method: 'REFER', - headers: { - 'Refer-To': referTo, - 'Referred-By': referredBy, - }, - }); - - if (res.status === 202) { - logger.debug({ msg: 'REFER was accepted', method: 'IncomingSipCall.processTransferredCall' }); - } - } catch (err) { - logger.error({ msg: 'REFER failed', method: 'IncomingSipCall.processTransferredCall', err }); - if (!call.ended) { - void mediaCallDirector.hangupByServer(call, 'sip-refer-failed'); - } - return this.processEndedCall(call); - } - } - protected async processEndedCall(call: IMediaCall): Promise { logger.debug({ msg: 'IncomingSipCall.processEndedCall', lastCallState: this.lastCallState, hangupReason: call.hangupReason }); @@ -378,7 +330,7 @@ export class IncomingSipCall extends BaseSipCall { logger.debug('IncomingSipCall.hangupPendingCall'); this.cancelPendingInvites(errorCode); - void mediaCallDirector.hangupByServer(this.call, `sip-error-${errorCode}`); + this.hangupCall('signaling-error'); } private static async getCalleeFromInvite(req: SrfRequest): Promise { diff --git a/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts b/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts index 7a51125013b9a..96bb3fb5758c0 100644 --- a/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts @@ -1,7 +1,6 @@ import type { IMediaCall, IMediaCallChannel, MediaCallSignedContact } from '@rocket.chat/core-typings'; import { isBusyState, type ClientMediaSignalBody, type CallHangupReason } from '@rocket.chat/media-signaling'; import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; -import type Srf from 'drachtio-srf'; import type { SrfRequest } from 'drachtio-srf'; import { BaseSipCall, type SipCallNegotiation } from './BaseSipCall'; @@ -14,14 +13,10 @@ import type { SipServerSession } from '../Session'; import { SipError, SipErrorCodes } from '../errorCodes'; export class OutgoingSipCall extends BaseSipCall { - private sipDialog: Srf.Dialog | null; - private sipDialogReq: SrfRequest | null; protected inboundRenegotiations: Map; - private processedTransfer: boolean; - constructor( session: SipServerSession, call: IMediaCall, @@ -89,8 +84,12 @@ export class OutgoingSipCall extends BaseSipCall { return this.processTransferredCall(call); } + if (call.escalatedAt) { + return this.processEscalatedCall(call); + } + if (call.state === 'hangup') { - return this.processEndedCall(); + return this.processEndedCall(call); } if (this.lastCallState === 'none') { @@ -152,7 +151,7 @@ export class OutgoingSipCall extends BaseSipCall { msg: 'OutgoingSipCall.createDialog - request failed', err, }); - void mediaCallDirector.hangupByServer(call, 'signaling-error'); + this.hangupCall('signaling-error'); return; } @@ -193,16 +192,12 @@ export class OutgoingSipCall extends BaseSipCall { if (!this.sipDialog) { this.cancelAnyPendingRequest(); - void mediaCallDirector.hangupByServer(call, hangupReason || 'signaling-error'); + this.hangupCall(hangupReason || 'signaling-error'); return; } logger.debug({ msg: 'OutgoingSipCall.createDialog - dialog created', callId: this.sipDialog.sip?.callId }); - this.sipDialog.on('destroy', () => { - logger.debug({ msg: 'OutgoingSipCall - uac.destroy' }); - this.sipDialog = null; - void mediaCallDirector.hangup(call, this.agent, 'remote'); - }); + this.sipDialog.on('destroy', () => this.onDialogDestroyed()); this.sipDialog.on('modify', (req, res) => { void this.handleDialogModify(req, res); @@ -295,52 +290,7 @@ export class OutgoingSipCall extends BaseSipCall { }); } - protected async processTransferredCall(call: IMediaCall): Promise { - if (this.lastCallState === 'hangup' || !call.transferredTo || !call.transferredBy) { - return; - } - - if (!this.sipDialog || this.processedTransfer) { - if (call.ended) { - return this.processEndedCall(); - } - return; - } - - logger.debug({ msg: 'OutgoingSipCall.processTransferredCall', callId: call._id, lastCallState: this.lastCallState }); - this.processedTransfer = true; - - try { - // Sip targets can only be referred to other sip users - const newCallee = await mediaCallDirector.cast.getContactForActor(call.transferredTo, { requiredType: 'sip' }); - if (!newCallee) { - throw new Error('invalid-transfer'); - } - - const referTo = this.session.geContactUri(newCallee); - const referredBy = this.session.geContactUri(call.transferredBy); - - const res = await this.sipDialog.request({ - method: 'REFER', - headers: { - 'Refer-To': referTo, - 'Referred-By': referredBy, - }, - }); - - if (res.status === 202) { - logger.debug({ msg: 'REFER was accepted', method: 'OutgoingSipCall.processTransferredCall' }); - } - } catch (err) { - logger.error({ msg: 'REFER failed', method: 'OutgoingSipCall.processTransferredCall', err, callId: call._id }); - if (!call.ended) { - void mediaCallDirector.hangupByServer(call, 'signaling-error'); - } - return this.processEndedCall(); - } - } - - protected async processEndedCall(): Promise { + protected async processEndedCall(_call: IMediaCall): Promise { if (this.lastCallState === 'hangup') { return; } diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index 60a9493a178d2..1ae7ed6c67760 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -7,4 +7,7 @@ export interface IMediaCallService { processSerializedSignal(fromUid: IUser['_id'], signal: string): Promise; hangupExpiredCalls(): Promise; getUserStateSignals(uid: IUser['_id'], contractId: string): Promise; + escalateCall(uid: IUser['_id'], params: { callId: string }): Promise; + hangupAutoEscalatedCall(call: IMediaCall, uid: IUser['_id']): Promise; + flagAsRemotelyEscalatedByCallId(callId: string): Promise; } diff --git a/packages/core-services/src/types/IVideoConfService.ts b/packages/core-services/src/types/IVideoConfService.ts index 6d74413ccf211..93c034b5e1e57 100644 --- a/packages/core-services/src/types/IVideoConfService.ts +++ b/packages/core-services/src/types/IVideoConfService.ts @@ -1,4 +1,8 @@ import type { + AtLeast, + ExternalVideoConference, + IGroupVideoConference, + IRegisterUser, IRoom, IStats, IUser, @@ -44,4 +48,13 @@ export interface IVideoConfService { ): Promise; assignDiscussionToConference(callId: VideoConference['_id'], rid: IRoom['_id'] | undefined): Promise; createVoIP(data: InsertionModel): Promise; + joinCall( + call: ExternalVideoConference, + user: AtLeast | undefined, + options: VideoConferenceJoinOptions, + ): Promise; + createEscalatedConference( + data: Required>, + user: IRegisterUser, + ): Promise; } diff --git a/packages/core-typings/src/IVideoConference.ts b/packages/core-typings/src/IVideoConference.ts index 74cb7fc04ee63..12439abaadd4c 100644 --- a/packages/core-typings/src/IVideoConference.ts +++ b/packages/core-typings/src/IVideoConference.ts @@ -79,6 +79,9 @@ export interface IVideoConference extends IRocketChatRecord { ringing?: boolean; discussionRid?: IRoom['_id']; + + mediaCallIds?: string[]; + sipAlias?: string; } export interface IDirectVideoConference extends IVideoConference { diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 454032dcecffb..255f2c953031b 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -70,6 +70,9 @@ export interface IMediaCall extends IRocketChatRecord { uids: IUser['_id'][]; + escalatedAt?: Date; + escalatedByPeerAt?: Date; + /** The list of features that may be used in this call. Values are final once the call is accepted. */ features: string[]; diff --git a/packages/i18n/src/locales/de.i18n.json b/packages/i18n/src/locales/de.i18n.json index 4bf8faa8fd58d..89fd83f174ccc 100644 --- a/packages/i18n/src/locales/de.i18n.json +++ b/packages/i18n/src/locales/de.i18n.json @@ -4374,6 +4374,8 @@ "VideoConf_Enable_DMs": "Aktivieren in Direktnachrichten", "VideoConf_Enable_Groups": "Aktivieren in privaten Kanälen", "VideoConf_Enable_Teams": "Aktivieren in Teams", + "Video_escalation_modal_description": "Bitte informieren Sie Ihren Gesprächspartner, dass er diesem zustimmt und dies ebenfalls akzeptieren muss.", + "Video_escalation_modal_title": "Möchten Sie dieses Gespräch zu einem Video Gespräch erweitern?", "Video_Chat_Window": "Video-Chat", "Video_Conference": "Videokonferenz", "Video_Conference_Description": "Konfigurieren Sie Telefonkonferenzen für Ihren Arbeitsbereich.", diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 3841386682a46..e3f70264a37de 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4223,6 +4223,8 @@ "Pexip_Integration_Customization": "Customization", "Pexip_Integration_Enabled": "Enable Pexip Integration", "Pexip_Integration_Enabled_Description": "When enabled, Pexip will be registered as a Conference Call provider. You may need to set it as your default provider on the conference settings if you have multiple providers.", + "Pexip_Integration_Escalation_Params": "Additional Params for Escalated Calls", + "Pexip_Integration_Escalation_Params_Description": "Set additional params to be used exclusively on conferences initiated from Voice Calls.", "Pexip_Integration_Guest_Pin": "Static Guest Pin", "Pexip_Integration_Guest_Pin_Description": "A pin code to grant meeting access to guests. Leave empty to generate a new unique pin for every meeting.", "Pexip_Integration_Host_Pin": "Static Host Pin", @@ -4245,9 +4247,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", @@ -4873,6 +4885,7 @@ "Score": "Score", "Screen_Lock": "Screen Lock", "Screen_Share": "Screen Share", + "Screen_sharing_stopped_video_escalation": "Screen sharing was stopped because the call was escalated to a video call", "Script": "Script", "Script_Enabled": "Script Enabled", "Script_Engine": "Script Sandbox", @@ -5296,6 +5309,7 @@ "Support": "Support", "Survey": "Survey", "Survey_instructions": "Rate each question according to your satisfaction, 1 meaning you are completely unsatisfied and 5 meaning you are completely satisfied.", + "Switched_to_video_call": "Switched to video call", "Symbols": "Symbols", "Sync": "Sync", "Sync / Import": "Sync / Import", @@ -5637,6 +5651,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", @@ -5945,6 +5960,8 @@ "VideoConf_Mobile_Ringing_Description": "When enabled, direct calls to mobile users will ring their device as a phone call.", "VideoConf_Persistent_Chat_Discussion_Name": "Persistent Chat Discussion Name", "VideoConf_Persistent_Chat_Discussion_Name_Description": "Use [date] tag to set where to include the date. Date will be added to the start if tag is not included.", + "Video_escalation_modal_description": "If you start a video call the current call will be finished, the other participants will be transferred and notified about the new call.", + "Video_escalation_modal_title": "Are you sure you want to extend this to a Video Call?", "Video_Call_unavailable_for_this_type_of_room": "Video Call is unavailable for this type of room", "Video_Chat_Window": "Video Chat", "Video_Conference": "Conference Call", @@ -5987,6 +6004,7 @@ "Visitor_time_on_site": "Visitor time on site", "VoIP": "VoIP", "VoIP_TeamCollab": "Team voice calls (VoIP)", + "VoIP_TeamCollab_AdvancedFeatures": "Features", "VoIP_TeamCollab_Description": "Set up VoIP in Team collaboration", "VoIP_TeamCollab_ExternalCallHistory": "External Call History", "VoIP_TeamCollab_ExternalCallHistory_Enabled": "Enabled", @@ -6021,6 +6039,8 @@ "VoIP_TeamCollab_Drachtio_Password": "Drachtio Password", "VoIP_TeamCollab_SIP_Server_Host": "SIP Server Host", "VoIP_TeamCollab_SIP_Server_Port": "SIP Server Port", + "VoIP_TeamCollab_Video_Escalation_Enabled": "Video escalation", + "VoIP_TeamCollab_Video_Escalation_Enabled_Description": "Allow users to escalate voice calls to a video conference.", "VoIP_device_permission_required": "Mic/speaker access required", "VoIP_device_permission_required_description": "Your web browser stopped {{workspaceUrl}} from using your microphone and/or speaker.\n\nAllow speaker and microphone access in your browser settings to prevent seeing this message again.", "VoIP_allow_and_call": "Allow and call", @@ -7082,6 +7102,7 @@ "start-discussion-other-user": "Start Discussion (Other-User)", "start-discussion-other-user_description": "Permission to start a discussion, which gives permission to the user to create a discussion from a message sent by another user as well", "start-discussion_description": "Permission to start a discussion", + "Start_a_video_call": "Start a video call", "strike": "strike", "subscription.callout.activeUsers": "seats", "subscription.callout.allPremiumCapabilitiesDisabled": "All premium capabilities disabled", diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index 4c6c5ecf7f19b..dbffafc4ad688 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -36,4 +36,7 @@ export type CallEvents = { /* Triggered when any of the streams or tracks have changed */ streamChange: void; + + /* Triggered when the call is escalated into a video conference by any participant */ + escalated: void; }; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index 708f7cacc6fd0..02f76e008cef6 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -10,7 +10,7 @@ import type { CallActorType } from './common'; export type CallService = 'webrtc'; -export const callFeatureList = ['audio', 'screen-share', 'transfer', 'hold'] as const; +export const callFeatureList = ['audio', 'screen-share', 'transfer', 'hold', 'conference-escalation'] as const; export type CallFeature = (typeof callFeatureList)[number]; @@ -45,9 +45,12 @@ export const callHangupReasonList = [ 'error', // Hanging up because of an unidentified error 'unknown', // One of the call's signed users reported they don't know this call 'another-client', // One of the call's users requested a hangup from a different client session than the one where the call is happening + 'conference-escalation', // The user has escalated this call into a video conference ] as const; export type CallHangupReason = (typeof callHangupReasonList)[number]; +export const isCallHangupReason = (reason: string): reason is CallHangupReason => + (callHangupReasonList as readonly string[]).includes(reason); export const callAnswerList = [ 'accept', // actor accepts the call @@ -63,6 +66,7 @@ export const callNotificationList = [ 'active', // notify that call activity was confirmed 'hangup', // notify that the call is over; 'trying', // notify that the other client is connecting but still need more time + 'escalated', // notify that the call was escalated to a video-conference ] as const; export type CallNotification = (typeof callNotificationList)[number]; @@ -119,6 +123,7 @@ export interface IClientMediaCall { getStats(selector?: MediaStreamTrack | null): Promise; isFeatureAvailable(feature: CallFeature): boolean; hasFlag(flag: CallFlag): boolean; + shouldSkipSoundEffects(): boolean; readonly localParticipant: IClientMediaCallLocalParticipant; readonly remoteParticipants: IClientMediaCallRemoteParticipant[]; diff --git a/packages/media-signaling/src/definition/call/callStates/IDirectMediaCallData.ts b/packages/media-signaling/src/definition/call/callStates/IDirectMediaCallData.ts index d3a0d15a948cc..b0043e044579e 100644 --- a/packages/media-signaling/src/definition/call/callStates/IDirectMediaCallData.ts +++ b/packages/media-signaling/src/definition/call/callStates/IDirectMediaCallData.ts @@ -11,6 +11,7 @@ export interface IDirectMediaCallData { readonly features: readonly CallFeature[]; readonly state: CallState; readonly hidden: boolean; + readonly escalated: boolean; readonly ringing: boolean; readonly transferredBy: CallContact | null; diff --git a/packages/media-signaling/src/definition/signals/server/notification.ts b/packages/media-signaling/src/definition/signals/server/notification.ts index 5581bfa263093..e29c0a43395c2 100644 --- a/packages/media-signaling/src/definition/signals/server/notification.ts +++ b/packages/media-signaling/src/definition/signals/server/notification.ts @@ -1,4 +1,4 @@ -import type { CallFeature, CallNotification } from '../../call'; +import type { CallFeature, CallHangupReason, CallNotification } from '../../call'; /** Server is sending a notification about the call state */ export type ServerMediaSignalNotification = { @@ -13,4 +13,6 @@ export type ServerMediaSignalNotification = { signedContractId?: string; features?: CallFeature[]; + + hangupReason?: CallHangupReason; }; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index c1cb3899a26a7..d48166283d3c0 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -121,7 +121,7 @@ export class ClientMediaCall implements IClientMediaCall { * Since the Call instance is only created when we receive "something" from the server, this would mean we received signals out of order, or missed one. */ - return this.ignored || this.contractState === 'ignored' || !this.initialized; + return this.ignored || this.contractState === 'ignored' || !this._initialized; } public get muted(): boolean { @@ -246,6 +246,10 @@ export class ClientMediaCall implements IClientMediaCall { private enabledFeatures: CallFeature[] | null; + private escalated: boolean; + + private hangupReason: CallHangupReason | null; + private _flags: CallFlag[]; public get flags(): CallFlag[] { @@ -299,6 +303,7 @@ export class ClientMediaCall implements IClientMediaCall { activeTimestamp: this.activeTimestamp, tempCallId: this.tempCallId, hidden: this.hidden, + escalated: this.escalated, ringing: this.ringing, localParticipant: this.localParticipant, @@ -334,6 +339,8 @@ export class ClientMediaCall implements IClientMediaCall { this.sentLocalSdp = false; this.receivedRemoteSdp = false; this.enabledFeatures = null; + this.escalated = false; + this.hangupReason = null; this.earlySignals = new Set(); this.stateTimeoutHandlers = new Set(); @@ -389,7 +396,7 @@ export class ClientMediaCall implements IClientMediaCall { supportedFeatures: CallFeature[], contactInfo?: CallContact, ): Promise { - if (this.initialized) { + if (this._initialized) { return; } @@ -665,7 +672,7 @@ export class ClientMediaCall implements IClientMediaCall { if (!this.hasRemoteData) { // if the call is over, we no longer need to wait for its data if (signal.type === 'notification' && signal.notification === 'hangup') { - this.changeState('hangup'); + this.setHangupState(signal.hangupReason); return; } @@ -726,7 +733,7 @@ export class ClientMediaCall implements IClientMediaCall { } this.config.transporter.answer(this.callId, 'reject'); - this.changeState('hangup'); + this.setHangupState('rejected'); } public transfer(callee: { type: CallActorType; id: string }): void { @@ -940,6 +947,18 @@ export class ClientMediaCall implements IClientMediaCall { return this._flags.includes(flag); } + public shouldSkipSoundEffects(): boolean { + if (this.hidden) { + return true; + } + + if (this.hangupReason === 'normal') { + return true; + } + + return false; + } + private canChangeToState(newState: CallState): boolean { if (newState === this._state) { return false; @@ -1170,7 +1189,7 @@ export class ClientMediaCall implements IClientMediaCall { } this.config.transporter.answer(this.callId, 'unavailable'); - this.changeState('hangup'); + this.setHangupState('unavailable'); } protected async processEarlySignals(): Promise { @@ -1219,7 +1238,9 @@ export class ClientMediaCall implements IClientMediaCall { break; case 'hangup': - return this.flagAsEnded('remote'); + return this.flagAsEnded('remote', signal.hangupReason); + case 'escalated': + return this.flagAsEscalated(signal.features); } } @@ -1266,19 +1287,41 @@ export class ClientMediaCall implements IClientMediaCall { this.changeState('accepted'); } - private flagAsEnded(reason: CallHangupReason): void { - this.config.logger?.debug('ClientMediaCall.flagAsEnded', reason); + private flagAsEnded(reasonForServer: CallHangupReason, reasonForClient?: CallHangupReason): void { + this.config.logger?.debug('ClientMediaCall.flagAsEnded', reasonForServer, reasonForClient); if (this._state === 'hangup') { return; } if (!this.hidden && this.hasRemoteData) { - this.config.transporter.hangup(this.callId, reason); + this.config.transporter.hangup(this.callId, reasonForServer); } + this.setHangupState(reasonForClient || reasonForServer); + } + + private setHangupState(reason?: CallHangupReason): void { + if (reason) { + this.hangupReason = reason; + this.config.logger?.debug('Hangup Reason:', reason); + } this.changeState('hangup'); } + private flagAsEscalated(overrideFeatures?: CallFeature[]): void { + if (this.escalated) { + return; + } + + this.config.logger?.debug('ClientMediaCall.flagAsEscalated', overrideFeatures || ''); + if (overrideFeatures) { + this.enabledFeatures = overrideFeatures; + } + + this.escalated = true; + this.emitter.emit('escalated'); + } + private addStateTimeout(state: ClientState, timeout: number, callback?: () => void): void { this.config.logger?.debug('ClientMediaCall.addStateTimeout', state, `${timeout / 1000}s`); if (this.getClientState() !== state) { diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 8b992280cb169..09cd917db97d6 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -22,7 +22,7 @@ export type MediaSignalingEvents = { sessionStateChange: void; newCall: { call: IClientMediaCall }; acceptedCall: { call: IClientMediaCall }; - endedCall: void; + endedCall: { call: IClientMediaCall; wasHidden: boolean }; hiddenCall: void; registered: { activeCalls: IClientMediaCall['callId'][] }; outOfSync: { missingCalls: IClientMediaCall['callId'][] }; @@ -71,7 +71,7 @@ export class MediaSignalingSession extends Emitter { private lastRegisterTimestamp: Date | null = null; - private lastState: { hasCall: boolean; hasVisibleCall: boolean; hasBusyCall: boolean }; + private lastState: { mainCall: ClientMediaCall | null; hidden: boolean; busy: boolean }; private sessionEnded = false; @@ -101,7 +101,7 @@ export class MediaSignalingSession extends Emitter { this.deviceId = null; this.currentDeviceId = null; this.callsToGetUserMedia = 0; - this.lastState = { hasCall: false, hasVisibleCall: false, hasBusyCall: false }; + this.lastState = { mainCall: null, hidden: false, busy: false }; this.transporter = new MediaSignalTransportWrapper(this._sessionId, config.transport, config.logger); this.registration = new SessionRegistration({ @@ -221,6 +221,9 @@ export class MediaSignalingSession extends Emitter { } const call = this.getOrCreateCallBySignal(signal); + if (!call) { + return; + } if (signal.type === 'notification' && signal.signedContractId) { if (signal.signedContractId === this._sessionId) { @@ -356,13 +359,18 @@ export class MediaSignalingSession extends Emitter { return null; } - private getOrCreateCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall { + private getOrCreateCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall | null { this.config.logger?.debug('MediaSignalingSession.getOrCreateCallBySignal', signal); const existingCall = this.getExistingCallBySignal(signal); if (existingCall) { return existingCall; } + // Notifications that do not cause state change can be ignored if the call is still unknown + if (signal.type === 'notification' && ['escalated', 'trying'].includes(signal.notification)) { + return null; + } + return this.createCall(signal.callId); } @@ -663,6 +671,7 @@ export class MediaSignalingSession extends Emitter { call.emitter.on('ended', () => this.onEndedCall(call)); call.emitter.on('screenShareRequestChange', (requested: boolean) => this.onScreenShareRequestChange(call, requested)); call.emitter.on('streamChange', () => this.onSessionStateChange()); + call.emitter.on('escalated', () => this.onSessionStateChange()); return call; } @@ -741,40 +750,39 @@ export class MediaSignalingSession extends Emitter { } private onSessionStateChange(): void { - const hadCall = this.lastState.hasCall; - const hadVisibleCall = this.lastState.hasVisibleCall; - const hadBusyCall = this.lastState.hasBusyCall; + const { mainCall: oldCall, hidden: wasHidden, busy: wasBusy } = this.lastState; if (!this.registration.active) { - if (hadCall) { - this.emit('endedCall'); + if (oldCall) { + this.emit('endedCall', { call: oldCall, wasHidden }); } this.config.logger?.debug('skipping session events on inactive session'); return; } // Do not skip local calls if we transitioned from a different active call to it - const mainCall = this.getMainCall(!hadCall); - const hasCall = Boolean(mainCall); - const hasVisibleCall = Boolean(mainCall && !mainCall.hidden); - const hasBusyCall = Boolean(hasVisibleCall && mainCall?.busy); + const mainCall = this.getMainCall(!oldCall); + const hidden = mainCall?.hidden ?? false; + const busy = mainCall?.busy ?? false; - this.lastState = { hasCall, hasVisibleCall, hasBusyCall }; + this.lastState = { mainCall, hidden, busy }; - if (mainCall && !hadCall) { + if (mainCall && !oldCall) { this.emit('newCall', { call: mainCall }); } - if (mainCall && hasBusyCall && !hadBusyCall) { + if (mainCall && busy && !wasBusy) { this.emit('acceptedCall', { call: mainCall }); } this.emit('sessionStateChange'); this.requestInputTrackUpdate(); - if (hadCall && !hasCall) { - this.emit('endedCall'); - } else if (hadVisibleCall && !hasVisibleCall) { - this.emit('hiddenCall'); + if (oldCall) { + if (!mainCall) { + this.emit('endedCall', { call: oldCall, wasHidden }); + } else if (!wasHidden && hidden) { + this.emit('hiddenCall'); + } } } } diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index ec807b1e1b1f9..a6d236bc0ede1 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -33,10 +33,20 @@ export interface IMediaCallsModel extends IBaseModel { transferCallById(callId: string, params: { by: MediaCallSignedContact; to: MediaCallContact }): Promise; findAllExpiredCalls(options: FindOptions | undefined): FindCursor; findAllNotOverByUid(uid: IUser['_id'], options?: FindOptions): FindCursor; + findAllNotOverByOppositeSipExtension(sipExtension: string, options?: FindOptions): FindCursor; hasUnfinishedCalls(): Promise; hasUnfinishedCallsByUid(uid: IUser['_id'], exceptCallId?: string): Promise; + isUserInCallIds(uid: IUser['_id'], callIds: string[]): Promise; + findAllPendingEscalationByUidAndCallIds( + uid: IUser['_id'], + callIds: string[], + options?: FindOptions, + ): FindCursor; + isUserSipExtensionInCallIds(sipExtension: string, callIds: string[]): Promise; updateParticipantsById( callId: string, participants: { caller?: MediaCallSignedContact; callee?: MediaCallSignedContact }, ): Promise; + flagAsEscalatedByCallId(callId: string): Promise; + flagAsRemotelyEscalatedByCallId(callId: string): Promise; } diff --git a/packages/model-typings/src/models/IVideoConferenceModel.ts b/packages/model-typings/src/models/IVideoConferenceModel.ts index 66a082af85d2a..fcd1849f75e16 100644 --- a/packages/model-typings/src/models/IVideoConferenceModel.ts +++ b/packages/model-typings/src/models/IVideoConferenceModel.ts @@ -7,7 +7,7 @@ import type { VideoConferenceStatus, IVoIPVideoConference, } from '@rocket.chat/core-typings'; -import type { FindCursor, UpdateOptions, UpdateFilter, UpdateResult, FindOptions } from 'mongodb'; +import type { FindCursor, UpdateOptions, UpdateFilter, UpdateResult, FindOptions, WithId } from 'mongodb'; import type { FindPaginated, IBaseModel, InsertionModel } from './IBaseModel'; @@ -30,7 +30,8 @@ export interface IVideoConferenceModel extends IBaseModel { createGroup({ providerName, ...callDetails - }: Required>): Promise; + }: Required> & + Pick): Promise; createLivechat({ providerName, @@ -43,7 +44,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 +71,24 @@ export interface IVideoConferenceModel extends IBaseModel { unsetDiscussionRid(discussionRid: IRoom['_id']): Promise; createVoIP(call: InsertionModel): Promise; + + findOneByMediaCallId(callId: string, options?: FindOptions): Promise; + + setSipAliasById(callId: string, sipAlias: string): Promise; + + unsetSipAliasById(callId: string): Promise; + + addMediaCallIdByProviderNameAndSipAlias( + providerName: string, + sipAlias: string, + mediaCallId: string, + ): Promise | null>; + + findOneByProviderNameAndSipAlias( + providerName: string, + sipAlias: string, + options?: FindOptions, + ): Promise; + + addMediaCallIdByConferenceId(conferenceId: string, mediaCallId: string): Promise; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 2c7b8d25bf7f8..a6ed20c9d0a17 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -152,6 +152,36 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } + public async flagAsEscalatedByCallId(callId: string): Promise { + return this.updateOne( + { + _id: callId, + ended: false, + escalatedAt: { $exists: false }, + }, + { + $set: { + escalatedAt: new Date(), + }, + }, + ); + } + + public async flagAsRemotelyEscalatedByCallId(callId: string): Promise { + return this.updateOne( + { + _id: callId, + ended: false, + escalatedByPeerAt: { $exists: false }, + }, + { + $set: { + escalatedByPeerAt: new Date(), + }, + }, + ); + } + public async setExpiresAtById(callId: string, expiresAt: Date): Promise { return this.updateOne( { @@ -210,6 +240,25 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } + public findAllNotOverByOppositeSipExtension( + sipExtension: string, + options?: FindOptions, + ): FindCursor { + return this.find( + { + ended: false, + expiresAt: { + $gt: new Date(), + }, + $or: [ + { 'caller.type': 'user', 'caller.sipExtension': sipExtension, 'callee.type': 'sip' }, + { 'callee.type': 'user', 'callee.sipExtension': sipExtension, 'caller.type': 'sip' }, + ], + }, + options, + ); + } + public async hasUnfinishedCalls(): Promise { const count = await this.countDocuments({ ended: false }, { limit: 1 }); return count > 0; @@ -227,6 +276,48 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod return count > 0; } + public async isUserInCallIds(uid: IUser['_id'], callIds: string[]): Promise { + const count = await this.countDocuments( + { + uids: uid, + _id: { $in: callIds }, + }, + { limit: 1 }, + ); + return count > 0; + } + + public findAllPendingEscalationByUidAndCallIds( + uid: IUser['_id'], + callIds: string[], + options?: FindOptions, + ): FindCursor { + return this.find( + { + ended: false, + uids: uid, + _id: { $in: callIds }, + escalatedAt: { $exists: false }, + escalatedByPeerAt: { $exists: true }, + }, + options, + ); + } + + public async isUserSipExtensionInCallIds(sipExtension: string, callIds: string[]): Promise { + const count = await this.countDocuments( + { + _id: { $in: callIds }, + $or: [ + { 'caller.type': 'user', 'caller.sipExtension': sipExtension }, + { 'callee.type': 'user', 'callee.sipExtension': sipExtension }, + ], + }, + { limit: 1 }, + ); + return count > 0; + } + public async updateParticipantsById( callId: string, participants: { caller?: MediaCallSignedContact; callee?: MediaCallSignedContact }, diff --git a/packages/models/src/models/VideoConference.ts b/packages/models/src/models/VideoConference.ts index e6bba09747c03..10d8b33d1c9a2 100644 --- a/packages/models/src/models/VideoConference.ts +++ b/packages/models/src/models/VideoConference.ts @@ -18,6 +18,8 @@ import type { Collection, Db, CountDocumentsOptions, + FindOptions, + WithId, } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -32,6 +34,8 @@ 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: { mediaCallIds: 1 }, unique: true, sparse: true }, + { key: { providerName: 1, sipAlias: 1 }, unique: true, partialFilterExpression: { sipAlias: { $exists: true } } }, ]; } @@ -104,8 +108,10 @@ export class VideoConferenceRaw extends BaseRaw implements IVid public async createGroup({ providerName, + mediaCallIds, ...callDetails - }: Required>): Promise { + }: Required> & + Pick): Promise { const call: InsertionModel = { type: 'videoconference', users: [], @@ -114,6 +120,7 @@ export class VideoConferenceRaw extends BaseRaw implements IVid anonymousUsers: 0, createdAt: new Date(), providerName: providerName.toLowerCase(), + ...(mediaCallIds?.length && { mediaCallIds }), ...callDetails, }; @@ -158,12 +165,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 } }), }); } @@ -176,10 +191,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 }, + }), }); } @@ -302,4 +322,66 @@ export class VideoConferenceRaw extends BaseRaw implements IVid }, ); } + + public async findOneByMediaCallId(callId: string, options?: FindOptions): Promise { + return this.findOne( + { + mediaCallIds: callId, + }, + options || {}, + ); + } + + public async addMediaCallIdByProviderNameAndSipAlias( + providerName: string, + sipAlias: string, + mediaCallId: string, + ): Promise | null> { + return this.findOneAndUpdate( + { + providerName, + sipAlias, + status: VideoConferenceStatus.STARTED, + mediaCallIds: { $not: { $eq: mediaCallId } }, + }, + { + $addToSet: { + mediaCallIds: mediaCallId, + }, + }, + { + returnDocument: 'after', + }, + ); + } + + public async addMediaCallIdByConferenceId(conferenceId: string, mediaCallId: string): Promise { + return this.updateOneById(conferenceId, { + $addToSet: { + mediaCallIds: mediaCallId, + }, + }); + } + + 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 || {}, + ); + } } diff --git a/packages/pexip/package.json b/packages/pexip/package.json index e1ebb8ee4a1c2..b58bc4e8ead0d 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.17.1" diff --git a/packages/pexip/src/definition/PexipSettings.ts b/packages/pexip/src/definition/PexipSettings.ts index 94631c0ef1442..6231841089531 100644 --- a/packages/pexip/src/definition/PexipSettings.ts +++ b/packages/pexip/src/definition/PexipSettings.ts @@ -4,6 +4,7 @@ export type PexipSettings = { enabled: boolean; baseUrl: string; meetingUrl: string; + escalationParams: string; api: { username: string; password: string; @@ -23,4 +24,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..87521772a2896 --- /dev/null +++ b/packages/pexip/src/endpoints/endpoint.ts @@ -0,0 +1,29 @@ +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 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..a2398db0887cf 100644 --- a/packages/pexip/src/endpoints/eventSink.ts +++ b/packages/pexip/src/endpoints/eventSink.ts @@ -1,25 +1,50 @@ import { VideoConf } from '@rocket.chat/core-services'; import { VideoConferenceStatus } from '@rocket.chat/core-typings'; -import type { Pexip } from '../Pexip'; -import type { EventSinkRequest } from '../definition'; +import 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 } = data; + if (protocol !== 'SIP' || !conferenceUri || !participantUri) { + return; + } + + void this.detectVoiceCallEscalation(conferenceUri, participantUri).catch(() => null); + } + + private async detectVoiceCallEscalation(conferenceUri: string, participantUri: string): Promise { + const conferenceSipAlias = this.getIdentificationFromAlias(conferenceUri); + const participantSipExtension = this.getIdentificationFromAlias(participantUri); + + if (!conferenceSipAlias || !participantSipExtension) { + logger.debug({ msg: 'Someone connected to a Pexip Conference via SIP, but we could not identify them.' }); + return; } + + logger.debug({ msg: 'Pexip Participant joined via SIP', conferenceSipAlias, participantSipExtension }); } } diff --git a/packages/pexip/src/endpoints/serviceConfiguration.ts b/packages/pexip/src/endpoints/serviceConfiguration.ts index ca8af93d3bec4..70cfd3318afb3 100644 --- a/packages/pexip/src/endpoints/serviceConfiguration.ts +++ b/packages/pexip/src/endpoints/serviceConfiguration.ts @@ -1,37 +1,33 @@ -import { VideoConference as VideoConferenceModel } from '@rocket.chat/models'; +import { MediaCall } from '@rocket.chat/core-services'; +import type { VideoConference } from '@rocket.chat/core-typings'; +import { MediaCalls, 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, remote_alias: participantUri = 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; } const identification = this.getIdentificationFromAlias(alias); + const participantSipUri = protocol === 'sip' ? participantUri : null; - 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:', ''); + return this.getServiceConfigurationForIdentification(identification, participantSipUri); } - private async getServiceConfigurationForIdentification(identification: string): Promise { - const call = await VideoConferenceModel.findOneById(identification); + private async getServiceConfigurationForIdentification( + identification: string, + participantSipUri: string | null, + ): Promise { + const call = await this.getCallByIdentification(identification); if (!call) { logger.error({ msg: 'Invalid call identification', identification }); return null; @@ -42,10 +38,13 @@ export class ServerConfigurationEndpoint { const [hostPin, guestPin] = await this.pexip.createAndStorePinsForCall(call); - return this.makeServiceConfiguration(call._id, title, hostPin, guestPin); + const canSkipPin = await this.detectVoiceCallEscalation(call, participantSipUri); + const guestPinToUse = canSkipPin ? null : guestPin; + + return this.makeServiceConfiguration(call._id, title, hostPin, guestPinToUse); } - private makeServiceConfiguration(name: string, title: string, hostPin: string, guestPin: string): ServiceConfiguration { + private makeServiceConfiguration(name: string, title: string, hostPin: string, guestPin: string | null): ServiceConfiguration { const { customization } = this.pexip.settings; return { @@ -64,4 +63,59 @@ export class ServerConfigurationEndpoint { enable_overlay_text: customization.overlayText, }; } + + private async detectVoiceCallEscalation(conference: VideoConference, participantUri: string | null): Promise { + if (!participantUri) { + return false; + } + + const { sipAlias, mediaCallIds: linkedMediaCallIds } = conference; + + if (!sipAlias) { + return false; + } + + const participantSipExtension = this.getIdentificationFromAlias(participantUri); + + if (!participantSipExtension) { + logger.debug({ msg: 'Someone connected to a Pexip Conference via SIP, but we could not identify them.' }); + return false; + } + + try { + logger.debug({ + msg: 'Pexip Participant joined via SIP', + sipAlias: conference.sipAlias, + conferenceId: conference._id, + participantSipExtension, + }); + + const mediaCallIds = await MediaCalls.findAllNotOverByOppositeSipExtension(participantSipExtension, { projection: { _id: 1 } }) + .map(({ _id }) => _id) + .toArray(); + + if (mediaCallIds.length !== 1) { + // Check if the user is already linked to the conference + if (linkedMediaCallIds?.length) { + if (await MediaCalls.isUserSipExtensionInCallIds(participantSipExtension, linkedMediaCallIds)) { + return true; + } + } + + logger.debug({ msg: 'Could not identify the media call that the SIP Participant is connecting from', calls: mediaCallIds }); + return mediaCallIds.length > 0; + } + + const [mediaCallId] = mediaCallIds; + + const updateResult = await VideoConferenceModel.addMediaCallIdByConferenceId(conference._id, mediaCallId); + if (updateResult.modifiedCount) { + await MediaCall.flagAsRemotelyEscalatedByCallId(mediaCallId); + } + } catch (err) { + logger.error({ msg: 'Unexpected error handling Pexip Voice to Video Escalation', err }); + } + + return true; + } } diff --git a/packages/pexip/src/videoConfProvider.ts b/packages/pexip/src/videoConfProvider.ts index a40846eadd9db..348b598afdb25 100644 --- a/packages/pexip/src/videoConfProvider.ts +++ b/packages/pexip/src/videoConfProvider.ts @@ -1,8 +1,10 @@ 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 { MediaCall } from '@rocket.chat/core-services'; +import type { VideoConference, AtLeast, IRoom, IVideoConferenceUser, RequiredField } from '@rocket.chat/core-typings'; +import { MediaCalls, Rooms } from '@rocket.chat/models'; import type { Pexip } from './Pexip'; +import { logger } from './logger'; export class PexipVideoConfProvider { public readonly name = 'Pexip'; @@ -111,14 +113,48 @@ export class PexipVideoConfProvider { return url; } - public async customizeUrl(call: VideoConference, user: IVideoConferenceUser | undefined): Promise { + public async customizeUrl(call: RequiredField, user: IVideoConferenceUser | undefined): Promise { const pin = await this.getPinForUser(call, user); + const escalationParams = this.getEscalationParams(); - const { url } = call; + const { url: userUrl } = call; - const nameSuffix = user?.name ? `&name=${user.name}` : ''; + const url = new URL(userUrl); + if (user) { + const { _id: uid, name } = user; - return `${url}&pin=${pin}${nameSuffix}`; + if (escalationParams?.size && (await this.isEscalatedUser(call, uid))) { + for (const [key, value] of escalationParams) { + url.searchParams.set(key, value); + } + } + + if (name) { + url.searchParams.set('name', name); + } + } + + url.searchParams.set('pin', pin); + return url.toString(); + } + + private getEscalationParams(): URLSearchParams | null { + try { + return new URLSearchParams(this.pexip.settings.escalationParams); + } catch (err) { + logger.error({ msg: 'Failed to parse Pexip Escalation Params', err }); + return null; + } + } + + private async isEscalatedUser(conference: VideoConference, uid: string): Promise { + const { mediaCallIds } = conference; + + if (!mediaCallIds?.length) { + return false; + } + + return MediaCalls.isUserInCallIds(uid, mediaCallIds); } public async onNewVideoConference(call: VideoConference): Promise { @@ -151,6 +187,25 @@ export class PexipVideoConfProvider { ]; } + public async onUserJoin(call: VideoConference, user?: IVideoConferenceUser): Promise { + if (!user || !call.mediaCallIds?.length) { + return; + } + + void this.autoEscalateCallBasedOnConferenceJoin(call.mediaCallIds, user._id).catch((err) => { + logger.error({ msg: 'Unexpected error flagging media call as auto escalated', err }); + }); + } + + private async autoEscalateCallBasedOnConferenceJoin(mediaCallIds: string[], uid: string): Promise { + const [mediaCall] = await MediaCalls.findAllPendingEscalationByUidAndCallIds(uid, mediaCallIds, { limit: 1 }).toArray(); + if (!mediaCall) { + return; + } + + await MediaCall.hangupAutoEscalatedCall(mediaCall, uid); + } + private async getPinForUser(call: VideoConference, user: IVideoConferenceUser | undefined): Promise { const [hostPin, guestPin] = await this.pexip.createPinsForCall(call); diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 9c25e22e983f8..d73f405d33602 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -29,6 +29,7 @@ import type { LDAPEndpoints } from './v1/ldap'; import type { LicensesEndpoints } from './v1/licenses'; import type { MailerEndpoints } from './v1/mailer'; import type { MeEndpoints } from './v1/me'; +import type { MediaCallsEndpoints } from './v1/media-calls'; import type { MiscEndpoints } from './v1/misc'; import type { ModerationEndpoints } from './v1/moderation'; import type { OmnichannelEndpoints } from './v1/omnichannel'; @@ -90,6 +91,7 @@ export interface Endpoints AuthEndpoints, ImportEndpoints, ServerEventsEndpoints, + MediaCallsEndpoints, DefaultEndpoints {} type OperationsByPathPatternAndMethod< diff --git a/packages/rest-typings/src/v1/media-calls.ts b/packages/rest-typings/src/v1/media-calls.ts new file mode 100644 index 0000000000000..7f55beeafac4e --- /dev/null +++ b/packages/rest-typings/src/v1/media-calls.ts @@ -0,0 +1,8 @@ +export type MediaCallsEndpoints = { + '/v1/media-calls.escalate': { + POST: (params: { callId: string }) => { + providerName: string; + url: string; + }; + }; +}; diff --git a/packages/ui-voip/src/components/Actions/ActionStrip.tsx b/packages/ui-voip/src/components/Actions/ActionStrip.tsx index fa4606b987a7a..4bc4fe3bc18ee 100644 --- a/packages/ui-voip/src/components/Actions/ActionStrip.tsx +++ b/packages/ui-voip/src/components/Actions/ActionStrip.tsx @@ -28,7 +28,7 @@ const ActionStrip = ({ children, leftSlot, rightSlot }: ActionStripProps) => { {leftSlot} - {children} + {children && {children}} {rightSlot} diff --git a/packages/ui-voip/src/components/VideoCallButton.stories.tsx b/packages/ui-voip/src/components/VideoCallButton.stories.tsx new file mode 100644 index 0000000000000..4e08abba8b1e5 --- /dev/null +++ b/packages/ui-voip/src/components/VideoCallButton.stories.tsx @@ -0,0 +1,44 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from 'storybook/actions'; + +import VideoCallButton from './VideoCallButton'; + +const meta = { + title: 'V2/Components/VideoCallButton', + component: VideoCallButton, + decorators: [ + mockAppRoot() + .withTranslations('en', 'core', { + Start_a_video_call: 'Start a video call', + Join_video_call: 'Join video call', + Switched_to_video_call: 'Switched to video call', + VideoConf_start_call_modal_title: 'Starting a video call', + VideoConf_start_call_modal_description: 'If you start a video call the current call will be finished. Do you want to proceed?', + Start_video_call: 'Start video call', + Cancel: 'Cancel', + }) + .buildStoryDecorator(), + ], + args: { + onClick: action('onClick'), + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: 'Start state (no active video call)', + args: { + escalated: false, + }, +}; + +export const VideoCallActive: Story = { + name: 'Active state (video call escalated)', + args: { + escalated: true, + }, +}; diff --git a/packages/ui-voip/src/components/VideoCallButton.tsx b/packages/ui-voip/src/components/VideoCallButton.tsx new file mode 100644 index 0000000000000..29ef6a34c4325 --- /dev/null +++ b/packages/ui-voip/src/components/VideoCallButton.tsx @@ -0,0 +1,29 @@ +import { Button } from '@rocket.chat/fuselage'; +import type { ComponentProps } from 'react'; +import { useTranslation } from 'react-i18next'; + +type VideoCallButtonProps = Omit, 'onClick'> & { + escalated?: boolean; + loading?: boolean; + onClick: () => void; +}; + +const VideoCallButton = ({ escalated, loading, onClick, ...props }: VideoCallButtonProps) => { + const { t } = useTranslation(); + + return ( + <> + {escalated ? ( + + ) : ( + + )} + + ); +}; + +export default VideoCallButton; diff --git a/packages/ui-voip/src/components/VideoCallWidgetAction.stories.tsx b/packages/ui-voip/src/components/VideoCallWidgetAction.stories.tsx new file mode 100644 index 0000000000000..387a3b8b0711f --- /dev/null +++ b/packages/ui-voip/src/components/VideoCallWidgetAction.stories.tsx @@ -0,0 +1,50 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from 'storybook/actions'; + +import VideoCallWidgetAction from './VideoCallWidgetAction'; + +const meta = { + title: 'V2/Components/VideoCallWidgetAction', + component: VideoCallWidgetAction, + decorators: [ + mockAppRoot() + .withTranslations('en', 'core', { + Start_a_video_call: 'Start a video call', + Join_video_call: 'Join video call', + Switched_to_video_call: 'Switched to video call', + }) + .buildStoryDecorator(), + ], + args: { + onClick: action('onClick'), + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: 'Default (not escalated)', + args: { + escalated: false, + loading: false, + }, +}; + +export const Escalated: Story = { + name: 'Escalated (video call active)', + args: { + escalated: true, + loading: false, + }, +}; + +export const Loading: Story = { + name: 'Loading state', + args: { + escalated: false, + loading: true, + }, +}; diff --git a/packages/ui-voip/src/components/VideoCallWidgetAction.tsx b/packages/ui-voip/src/components/VideoCallWidgetAction.tsx new file mode 100644 index 0000000000000..48e136ee03389 --- /dev/null +++ b/packages/ui-voip/src/components/VideoCallWidgetAction.tsx @@ -0,0 +1,26 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +import VideoCallButton from './VideoCallButton'; + +type VideoCallWidgetActionProps = { + escalated?: boolean; + loading?: boolean; + onClick: () => void | Promise; +}; + +const VideoCallWidgetAction = ({ escalated, loading, onClick }: VideoCallWidgetActionProps) => { + const { t } = useTranslation(); + return ( + + {escalated && ( + + {t('Switched_to_video_call')} + + )} + + + ); +}; + +export default VideoCallWidgetAction; diff --git a/packages/ui-voip/src/components/index.ts b/packages/ui-voip/src/components/index.ts index aaf620b1ad009..382a9969351cf 100644 --- a/packages/ui-voip/src/components/index.ts +++ b/packages/ui-voip/src/components/index.ts @@ -15,3 +15,5 @@ export { default as DevicePicker } from './DevicePicker'; export { default as CallHistoryInternalUser } from './CallHistoryInternalUser'; export { default as CallHistoryExternalUser } from './CallHistoryExternalUser'; export { default as MediaCallWidgetSlot } from './MediaCallWidgetSlot'; +export { default as VideoCallWidgetAction } from './VideoCallWidgetAction'; +export { default as VideoCallButton } from './VideoCallButton'; diff --git a/packages/ui-voip/src/context/MediaCallViewContext.ts b/packages/ui-voip/src/context/MediaCallViewContext.ts index a85864b249607..d6eb4d33b88a0 100644 --- a/packages/ui-voip/src/context/MediaCallViewContext.ts +++ b/packages/ui-voip/src/context/MediaCallViewContext.ts @@ -12,6 +12,8 @@ export type MediaCallStreams = { export type MediaCallViewContextValue = { sessionState: SessionState; + isRequestingVideoCall: boolean; + onRequestVideoCall: () => void; onClickDirectMessage?: () => void; onMute: () => void; onHold: () => void; @@ -43,11 +45,14 @@ const defaultSessionState: SessionState = { remoteMuted: false, remoteHeld: false, callId: undefined, + escalated: false, supportedFeatures: ['audio', 'transfer', 'hold'], }; export const defaultMediaCallContextValue: MediaCallViewContextValue = { sessionState: defaultSessionState, + isRequestingVideoCall: false, + onRequestVideoCall: () => undefined, onMute: () => undefined, onHold: () => undefined, onDeviceChange: () => undefined, diff --git a/packages/ui-voip/src/context/definitions.d.ts b/packages/ui-voip/src/context/definitions.d.ts index 965d954f95675..540176d6da910 100644 --- a/packages/ui-voip/src/context/definitions.d.ts +++ b/packages/ui-voip/src/context/definitions.d.ts @@ -33,6 +33,7 @@ interface IBaseSession { remoteHeld: boolean; startedAt?: Date; hidden: boolean; + escalated?: boolean; ringing?: boolean; supportedFeatures: readonly CallFeature[]; // True when the idle dialer ('new') is docked inside a slot (the sidebar call panel) diff --git a/packages/ui-voip/src/hooks/useOpenVideoCall.spec.tsx b/packages/ui-voip/src/hooks/useOpenVideoCall.spec.tsx new file mode 100644 index 0000000000000..858d743bdf02f --- /dev/null +++ b/packages/ui-voip/src/hooks/useOpenVideoCall.spec.tsx @@ -0,0 +1,166 @@ +import { renderHook, act } from '@testing-library/react'; + +import { useOpenVideoCall } from './useOpenVideoCall'; + +const setModalMock = jest.fn(); + +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useSetModal: () => setModalMock, +})); + +jest.mock('../views', () => ({ + PopupBlockedModal: jest.fn(() => null), +})); + +const { PopupBlockedModal } = jest.requireMock('../views'); + +describe('useOpenVideoCall', () => { + const url = 'https://video.example.com/room'; + + beforeEach(() => { + jest.clearAllMocks(); + delete (window as any).RocketChatDesktop; + jest.spyOn(window, 'open').mockReturnValue({} as Window); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('when RocketChatDesktop.openInternalVideoChatWindow is available', () => { + const openInternalVideoChatWindowMock = jest.fn(); + + beforeEach(() => { + (window as any).RocketChatDesktop = { + openInternalVideoChatWindow: openInternalVideoChatWindowMock, + }; + }); + + it('calls openInternalVideoChatWindow with the url and providerName', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url, 'Jitsi'); + }); + + expect(openInternalVideoChatWindowMock).toHaveBeenCalledWith(url, { providerName: 'Jitsi' }); + }); + + it('calls openInternalVideoChatWindow with undefined providerName when not provided', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url); + }); + + expect(openInternalVideoChatWindowMock).toHaveBeenCalledWith(url, { providerName: undefined }); + }); + + it('does not call window.open', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url, 'Jitsi'); + }); + + expect(window.open).not.toHaveBeenCalled(); + }); + + it('does not show a modal', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url, 'Jitsi'); + }); + + expect(setModalMock).not.toHaveBeenCalled(); + }); + }); + + describe('when RocketChatDesktop is not available', () => { + it('calls window.open with the url', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url); + }); + + expect(window.open).toHaveBeenCalledWith(url); + }); + + it('does not show a modal when window.open succeeds', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url); + }); + + expect(setModalMock).not.toHaveBeenCalled(); + }); + + describe('when window.open returns null (popup blocked)', () => { + beforeEach(() => { + jest.spyOn(window, 'open').mockReturnValue(null); + }); + + it('shows PopupBlockedModal', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url); + }); + + expect(setModalMock).toHaveBeenCalledTimes(1); + const [[modalElement]] = setModalMock.mock.calls; + expect(modalElement.type).toBe(PopupBlockedModal); + }); + + it('closes modal when onClose is called', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url); + }); + + const [[modalElement]] = setModalMock.mock.calls; + act(() => { + modalElement.props.onClose(); + }); + + expect(setModalMock).toHaveBeenLastCalledWith(null); + }); + + it('opens url in new window when onConfirm is called', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url); + }); + + const [[modalElement]] = setModalMock.mock.calls; + act(() => { + modalElement.props.onConfirm(); + }); + + expect(window.open).toHaveBeenLastCalledWith(url); + }); + }); + }); + + describe('when RocketChatDesktop exists but openInternalVideoChatWindow is not defined', () => { + beforeEach(() => { + (window as any).RocketChatDesktop = {}; + }); + + it('falls back to window.open', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url); + }); + + expect(window.open).toHaveBeenCalledWith(url); + }); + }); +}); diff --git a/packages/ui-voip/src/hooks/useOpenVideoCall.tsx b/packages/ui-voip/src/hooks/useOpenVideoCall.tsx new file mode 100644 index 0000000000000..6d09ac77dd9d7 --- /dev/null +++ b/packages/ui-voip/src/hooks/useOpenVideoCall.tsx @@ -0,0 +1,28 @@ +import { useSetModal } from '@rocket.chat/ui-contexts'; +import { useCallback } from 'react'; + +import { useActiveCallWindow } from '../providers/useMediaSessionInstance'; +import { PopupBlockedModal } from '../views'; + +export const useOpenVideoCall = () => { + const setModal = useSetModal(); + const activeWindow = useActiveCallWindow(); + + return useCallback( + (url: string, providerName?: string) => { + const desktopApp = window.RocketChatDesktop; + + if (desktopApp?.openInternalVideoChatWindow) { + desktopApp.openInternalVideoChatWindow(url, { providerName }); + return; + } + + const popup = activeWindow.open(url); + + if (popup === null) { + setModal( setModal(null)} onConfirm={() => activeWindow.open(url)} />); + } + }, + [activeWindow, setModal], + ); +}; diff --git a/packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx b/packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx new file mode 100644 index 0000000000000..22f00134cc342 --- /dev/null +++ b/packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx @@ -0,0 +1,57 @@ +import { useEndpoint, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useMutation } from '@tanstack/react-query'; +import { useCallback } from 'react'; + +import type { SessionState } from '../context'; +import { useOpenVideoCall } from './useOpenVideoCall'; +import { ConfirmVideoEscalationModal } from '../views'; + +export const useVoiceToVideoEscalation = (sessionState: SessionState) => { + const dispatchToastMessage = useToastMessageDispatch(); + const setModal = useSetModal(); + const openVideoCall = useOpenVideoCall(); + const requestEscalation = useEndpoint('POST', '/v1/media-calls.escalate'); + + const { mutateAsync: requestVideoEscalation, isPending } = useMutation({ + mutationKey: ['request-video-escalation'], + mutationFn: requestEscalation, + }); + + const executeVideoEscalation = useCallback(async () => { + if (sessionState.state !== 'ongoing') { + return; + } + + const { callId } = sessionState; + + try { + const { url, providerName } = await requestVideoEscalation({ callId }); + openVideoCall(url, providerName); + } catch (error) { + dispatchToastMessage({ type: 'error', message: 'Unable_to_start_video_call' }); + console.error('Error requesting video escalation', error); + } + }, [sessionState, requestVideoEscalation, openVideoCall, dispatchToastMessage]); + + const onRequestVideoCall = useCallback(async () => { + if (sessionState.escalated) { + await executeVideoEscalation(); + return; + } + + setModal( + setModal(null)} + onConfirm={async () => { + setModal(null); + await executeVideoEscalation(); + }} + />, + ); + }, [sessionState.escalated, setModal, executeVideoEscalation]); + + return { + isRequestingVideoCall: isPending, + onRequestVideoCall, + }; +}; diff --git a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx index b51deafdd98a0..0a24ef18989e5 100644 --- a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx +++ b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx @@ -24,6 +24,7 @@ import MediaCallViewContext from '../context/MediaCallViewContext'; import type { PeerInfo } from '../context/definitions'; import { stopTracks, useDevicePermissionPrompt2, PermissionRequestCancelledCallRejectedError } from '../hooks/useDevicePermissionPrompt'; import { isValidTone, useTonePlayer } from '../hooks/useTonePlayer'; +import { useVoiceToVideoEscalation } from '../hooks/useVoiceToVideoEscalation'; import TransferModal from '../views/TransferModal'; export type MediaCallViewProviderProps = { @@ -41,6 +42,8 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { const { sessionState, toggleWidget, openDialer, closeDialer, selectPeer } = useMediaSession(instance); const controls = useMediaSessionControls(instance); + const { onRequestVideoCall, isRequestingVideoCall } = useVoiceToVideoEscalation(sessionState); + useDesktopNotifications(sessionState); useDesktopTelephonyListener({ sessionState, toggleWidget, selectPeer }); @@ -70,14 +73,14 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { if (!instance) { return; } - return instance.on('endedCall', () => { - if (sessionState.hidden) { + return instance.on('endedCall', ({ call, wasHidden }) => { + if (wasHidden || call.shouldSkipSoundEffects()) { return; } callback(); }); }, - [instance, sessionState.hidden], + [instance], ), ); @@ -263,8 +266,33 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { }); }, [instance, onChangePosition]); + useEffect(() => { + if (!sessionState.escalated) { + return; + } + + const state = instance?.getState(); + + if (!state?.confirmed) { + return; + } + + if (!state?.call.hasScreenVideoTrack()) { + return; + } + + try { + state.call.requestScreenShare(false); + dispatchToastMessage({ type: 'info', message: t('Screen_sharing_stopped_video_escalation') }); + } catch (error) { + console.error('Error stopping screen share', error); + } + }, [sessionState.escalated, dispatchToastMessage, t, instance]); + const contextValue = { sessionState, + isRequestingVideoCall, + onRequestVideoCall, onClickDirectMessage, onMute, onHold, diff --git a/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx b/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx index a6e0b77dab472..1eb10137c26f2 100644 --- a/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx +++ b/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx @@ -142,6 +142,7 @@ const MockedMediaCallProvider = ({ remoteHeld, callId: undefined, supportedFeatures: ['audio', 'screen-share', 'transfer', 'hold'], + escalated: false, } as SessionState; const contextValue = { @@ -161,6 +162,8 @@ const MockedMediaCallProvider = ({ onToggleScreenSharing: () => undefined, onOpenPopout: () => undefined, onClosePopout: () => undefined, + isRequestingVideoCall: false, + onRequestVideoCall: () => undefined, }; const instanceContextValue = { diff --git a/packages/ui-voip/src/providers/useMediaSession.ts b/packages/ui-voip/src/providers/useMediaSession.ts index 311e161ba6e01..a3d55ed83fbe8 100644 --- a/packages/ui-voip/src/providers/useMediaSession.ts +++ b/packages/ui-voip/src/providers/useMediaSession.ts @@ -21,6 +21,7 @@ const defaultSessionInfo: SessionState = { hidden: false, supportedFeatures: ['audio', 'transfer', 'hold'], docked: false, + escalated: false, }; export const getExtensionFromInstanceContact = (contact: CallContact): string | undefined => { @@ -216,6 +217,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS callId: instanceState.tempCallId, startedAt: undefined, supportedFeatures: [], + escalated: false, }, }); return; @@ -227,6 +229,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS activeTimestamp: startedAt, features: supportedFeatures, transferredBy: callTransferredBy, + escalated, remoteParticipant: { muted: remoteMuted, held: remoteHeld, contact }, ringing, } = instanceState; @@ -265,6 +268,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS callId, startedAt, supportedFeatures, + escalated, ringing, }, }); diff --git a/packages/ui-voip/src/providers/useMediaSessionInstance.ts b/packages/ui-voip/src/providers/useMediaSessionInstance.ts index 720003c8f2460..d97412572d97c 100644 --- a/packages/ui-voip/src/providers/useMediaSessionInstance.ts +++ b/packages/ui-voip/src/providers/useMediaSessionInstance.ts @@ -46,6 +46,10 @@ class MediaSessionStore extends Emitter { private popoutWindow: Window | undefined; + public get callExternalWindow(): Window | undefined { + return this.popoutWindow; + } + constructor() { super(); } @@ -154,7 +158,7 @@ class MediaSessionStore extends Emitter { randomStringFactory, oldSessionId: this.getOldSessionId(userId), logger: this.logger, - features: ['audio', 'screen-share', 'transfer', 'hold'], + features: ['audio', 'screen-share', 'transfer', 'hold', 'conference-escalation'], autoSync: true, }); @@ -217,6 +221,10 @@ export const useSetPopoutWindow = (popoutWindow?: Window) => { }); }; +export const useActiveCallWindow = () => { + return mediaSession.callExternalWindow || window; +}; + export const useMediaSessionInstance = (userId?: string) => { const { t } = useTranslation(); const iceServers = useIceServers(); diff --git a/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.spec.tsx b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.spec.tsx new file mode 100644 index 0000000000000..8ea898e59133e --- /dev/null +++ b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.spec.tsx @@ -0,0 +1,19 @@ +import { composeStories } from '@storybook/react'; +import { render } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import * as stories from './ConfirmVideoEscalationModal.stories'; + +const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); + +test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const view = render(); + expect(view.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); diff --git a/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.stories.tsx b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.stories.tsx new file mode 100644 index 0000000000000..d46309a8600a9 --- /dev/null +++ b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.stories.tsx @@ -0,0 +1,34 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; + +import ConfirmVideoEscalationModal from './ConfirmVideoEscalationModal'; + +const noop = () => undefined; + +const meta = { + title: 'Components/ConfirmVideoEscalationModal', + component: ConfirmVideoEscalationModal, + decorators: [ + mockAppRoot() + .withTranslations('en', 'core', { + Video_escalation_modal_title: 'Are you sure you want to extend this to a Video Call?', + Video_escalation_modal_description: + 'This will escalate the current voice call to a video call. The other participant will be notified.', + Start_video_call: 'Start video call', + Cancel: 'Cancel', + }) + .buildStoryDecorator(), + (Story) => , + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onCancel: noop, + onConfirm: noop, + }, +}; diff --git a/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.tsx b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.tsx new file mode 100644 index 0000000000000..0123af2fc496e --- /dev/null +++ b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.tsx @@ -0,0 +1,28 @@ +import { GenericModal } from '@rocket.chat/ui-client'; +import { useTranslation } from 'react-i18next'; + +type ConfirmVideoEscalationModalProps = { + onCancel: () => void; + onConfirm: () => void; +}; + +const ConfirmVideoEscalationModal = ({ onCancel, onConfirm }: ConfirmVideoEscalationModalProps) => { + const { t } = useTranslation(); + + return ( + + {t('Video_escalation_modal_description')} + + ); +}; + +export default ConfirmVideoEscalationModal; diff --git a/packages/ui-voip/src/views/ConfirmVideoEscalationModal/__snapshots__/ConfirmVideoEscalationModal.spec.tsx.snap b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/__snapshots__/ConfirmVideoEscalationModal.spec.tsx.snap new file mode 100644 index 0000000000000..2f1349bbe0f2b --- /dev/null +++ b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/__snapshots__/ConfirmVideoEscalationModal.spec.tsx.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders Default without crashing 1`] = ` + +
+ +
+
+
+
+

+ Are you sure you want to extend this to a Video Call? +

+
+ +
+
+
+
+ This will escalate the current voice call to a video call. The other participant will be notified. +
+
+ +
+
+
+ +`; diff --git a/packages/ui-voip/src/views/EscalatedCallPrompt.tsx b/packages/ui-voip/src/views/EscalatedCallPrompt.tsx new file mode 100644 index 0000000000000..fb20218e3ff3f --- /dev/null +++ b/packages/ui-voip/src/views/EscalatedCallPrompt.tsx @@ -0,0 +1,32 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +import VideoCallButton from '../components/VideoCallButton'; +import { useMediaCallView } from '../context'; + +const EscalatedCallPrompt = () => { + const { t } = useTranslation(); + const { isRequestingVideoCall, onRequestVideoCall } = useMediaCallView(); + + return ( + + + {t('Switched_to_video_call')} + + + + ); +}; + +export default EscalatedCallPrompt; diff --git a/packages/ui-voip/src/views/MediaCallPopoutView.tsx b/packages/ui-voip/src/views/MediaCallPopoutView.tsx index c5ed862b3ae7e..d38422812395a 100644 --- a/packages/ui-voip/src/views/MediaCallPopoutView.tsx +++ b/packages/ui-voip/src/views/MediaCallPopoutView.tsx @@ -3,7 +3,8 @@ import { useResizeObserver } from '@rocket.chat/fuselage-hooks'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { ToggleButton, Timer, DevicePicker, ActionButton, useShouldWrapCards, ActionStrip } from '../components'; +import { ToggleButton, Timer, DevicePicker, ActionButton, useShouldWrapCards, ActionStrip, VideoCallButton } from '../components'; +import EscalatedCallPrompt from './EscalatedCallPrompt'; import MediaCallCardList from './MediaCallCardList'; import { useMediaCallView } from '../context/MediaCallViewContext'; import AppActions from '../experimental/AppActionButtons/components/AppActions'; @@ -29,10 +30,11 @@ const MediaCallPopoutView = ({ user, onClickClosePopout, onClickFullscreen, full onForward, onEndCall, onToggleScreenSharing, + onRequestVideoCall, streams: { localScreen }, } = useMediaCallView(); - const { muted, held, peerInfo, connectionState, startedAt } = sessionState; + const { muted, held, peerInfo, connectionState, startedAt, escalated, supportedFeatures } = sessionState; const { ref, borderBoxSize } = useResizeObserver(); @@ -43,7 +45,11 @@ const MediaCallPopoutView = ({ user, onClickClosePopout, onClickFullscreen, full const appActions = useVisibleAppActions(); - const showHeaderActions = appActions.length > 0; + const showAppActions = appActions.length > 0; + + const escalationAvailable = supportedFeatures.includes('conference-escalation'); + + const showHeaderActions = escalationAvailable && !escalated; if (!peerInfo || 'number' in peerInfo) { return null; @@ -62,8 +68,9 @@ const MediaCallPopoutView = ({ user, onClickClosePopout, onClickFullscreen, full flexDirection='column' ref={ref} > - {showHeaderActions && } />} - + {showAppActions && } />} + {showHeaderActions ? } /> : null} + {escalationAvailable && escalated ? : } diff --git a/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx b/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx index 250257e185c59..5f59900107160 100644 --- a/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx +++ b/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx @@ -1,5 +1,5 @@ import { Box, ButtonGroup } from '@rocket.chat/fuselage'; -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -11,6 +11,7 @@ import { CARD_LIST_SECTION_MAX_HEIGHT, ActionStrip, ActionToggleChat, + VideoCallButton, } from '../../components'; import { useMediaCallInstance } from '../../context/MediaCallInstanceContext'; import { useMediaCallView } from '../../context/MediaCallViewContext'; @@ -18,6 +19,7 @@ import { usePeekMediaSessionFeatures } from '../../context/usePeekMediaSessionFe import useRegisterView from '../../context/useRegisterView'; import AppActions from '../../experimental/AppActionButtons/components/AppActions'; import { useVisibleAppActions } from '../../experimental/AppActionButtons/hooks/useVisibleAppActions'; +import EscalatedCallPrompt from '../EscalatedCallPrompt'; import MediaCallCardList from '../MediaCallCardList'; import PopoutDockPrompt from '../PopoutDockPrompt'; @@ -57,24 +59,27 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: onToggleScreenSharing, onOpenPopout, onClosePopout, + onRequestVideoCall, streams: { localScreen }, } = useMediaCallView(); const { currentViews } = useMediaCallInstance(); const isPopout = currentViews.includes('popout'); - const { muted, held, peerInfo, connectionState, startedAt } = sessionState; + const { muted, held, peerInfo, connectionState, startedAt, escalated, supportedFeatures } = sessionState; const shouldWrapCards = useShouldWrapCards(showChat, containerHeight); const connecting = connectionState === 'CONNECTING'; const reconnecting = connectionState === 'RECONNECTING'; + const escalationAvailable = supportedFeatures.includes('conference-escalation'); + useRegisterView('room'); const appActions = useVisibleAppActions(); - const showHeaderActions = appActions.length > 0; + const showAppActions = appActions.length > 0; const features = usePeekMediaSessionFeatures(); @@ -82,6 +87,20 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: const holdAvailable = features.includes('hold'); const transferAvailable = features.includes('transfer'); + const content = useMemo(() => { + if (isPopout) { + return ; + } + + if (escalationAvailable && escalated) { + return ; + } + + return ; + }, [isPopout, escalationAvailable, escalated, user, shouldWrapCards, onClosePopout]); + + const showHeaderActions = escalationAvailable && !escalated; + if (!peerInfo || !('userId' in peerInfo) || !peerInfo.userId) { return null; } @@ -98,8 +117,11 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: aria-label={t('Voice_call')} {...getSplitStyles(showChat)} > - {showHeaderActions && } />} - {isPopout ? : } + {showAppActions && } />} + {showHeaderActions ? } /> : null} + + {content} + diff --git a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx index 276170f67eb7c..ac528452db3bc 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx @@ -16,6 +16,7 @@ import { ActionButton, useKeypad, useInfoSlots, + VideoCallWidgetAction, } from '../../components'; import { useMediaCallView } from '../../context/MediaCallViewContext'; import { useMediaCallWidgetSlot } from '../../context/MediaCallWidgetSlotContext'; @@ -26,8 +27,9 @@ import { isExternalPeer } from '../../utils/isExternalPeer'; const OngoingCall = () => { const { t } = useTranslation(); - const { sessionState, onMute, onHold, onForward, onEndCall, onTone, onClickDirectMessage } = useMediaCallView(); - const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState, supportedFeatures, startedAt } = sessionState; + const { sessionState, isRequestingVideoCall, onRequestVideoCall, onMute, onHold, onForward, onEndCall, onTone, onClickDirectMessage } = + useMediaCallView(); + const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState, supportedFeatures, startedAt, escalated } = sessionState; const { inline } = useMediaCallWidgetSlot(); // The floating widget keeps its collapsible DTMF toggle for every ongoing call. @@ -43,6 +45,7 @@ const OngoingCall = () => { const transferDisabled = !supportedFeatures.includes('transfer'); const holdDisabled = !supportedFeatures.includes('hold'); + const videoConfAvailable = supportedFeatures.includes('conference-escalation'); const appActions = useVisibleAppActions(); @@ -63,6 +66,7 @@ const OngoingCall = () => { + {videoConfAvailable && } diff --git a/packages/ui-voip/src/views/MediaCallWidget/OngoingCallWithScreen.tsx b/packages/ui-voip/src/views/MediaCallWidget/OngoingCallWithScreen.tsx index 0ff9038e6199d..a47ca5a988782 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/OngoingCallWithScreen.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/OngoingCallWithScreen.tsx @@ -17,6 +17,7 @@ import { useInfoSlots, CardWidgetContainer, StreamCard, + VideoCallWidgetAction, } from '../../components'; import { useMediaCallInstance } from '../../context'; import { useMediaCallView } from '../../context/MediaCallViewContext'; @@ -30,6 +31,8 @@ const OngoingCall = () => { const { sessionState, + isRequestingVideoCall, + onRequestVideoCall, onMute, onHold, onForward, @@ -41,7 +44,7 @@ const OngoingCall = () => { widgetPositionTracker, onClosePopout, } = useMediaCallView(); - const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState, startedAt } = sessionState; + const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState, startedAt, escalated, supportedFeatures } = sessionState; const { currentViews } = useMediaCallInstance(); const isPopout = currentViews.includes('popout'); @@ -58,6 +61,10 @@ const OngoingCall = () => { const appActions = useVisibleAppActions(); + const transferDisabled = !supportedFeatures.includes('transfer'); + const holdDisabled = !supportedFeatures.includes('hold'); + const videoConfAvailable = supportedFeatures.includes('conference-escalation'); + // TODO: Figure out how to ensure this always exist before rendering the component if (!peerInfo) { throw new Error('Peer info is required'); @@ -122,6 +129,10 @@ const OngoingCall = () => { )} )} + + {videoConfAvailable && ( + + )} @@ -134,9 +145,10 @@ const OngoingCall = () => { { pressed={localScreen?.active ?? false} onToggle={onToggleScreenSharing} /> - + [Story.storyName || 'Story', Story]); + +test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const view = render(); + expect(view.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); diff --git a/packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.stories.tsx b/packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.stories.tsx new file mode 100644 index 0000000000000..a5563b55cb8a2 --- /dev/null +++ b/packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.stories.tsx @@ -0,0 +1,34 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; + +import PopupBlockedModal from './PopupBlockedModal'; + +const noop = () => undefined; + +const meta = { + title: 'Components/PopupBlockedModal', + component: PopupBlockedModal, + decorators: [ + mockAppRoot() + .withSetting('Site_Url', 'https://your.rocket.chat') + .withTranslations('en', 'core', { + Open_call_in_new_tab: 'Open call in new tab', + Open_call: 'Open call', + Your_web_browser_blocked_Rocket_Chat_from_opening_tab: 'Your web browser blocked Rocket.Chat from opening a new tab.', + To_prevent_seeing_this_message_again_allow_popups_from_workspace_URL: 'To prevent seeing this message again, allow popups from ', + }) + .buildStoryDecorator(), + (Story) => , + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onClose: noop, + onConfirm: noop, + }, +}; diff --git a/packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.tsx b/packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.tsx new file mode 100644 index 0000000000000..c0db80195bb64 --- /dev/null +++ b/packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.tsx @@ -0,0 +1,52 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import { GenericModal } from '@rocket.chat/ui-client'; +import { useSetting } from '@rocket.chat/ui-contexts'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +type PopupBlockedModalProps = { + onClose: () => void; + onConfirm: () => void; +}; + +const PopupBlockedModal = ({ onClose, onConfirm }: PopupBlockedModalProps) => { + const { t } = useTranslation(); + const workspaceUrl = useSetting('Site_Url'); + + const confirmButtonContent = ( + + + {t('Open_call')} + + ); + + const handleConfirm = useCallback(() => { + onConfirm(); + onClose(); + }, [onClose, onConfirm]); + + return ( + + <> + {t('Your_web_browser_blocked_Rocket_Chat_from_opening_tab')} + + {t('To_prevent_seeing_this_message_again_allow_popups_from_workspace_URL')} + + {workspaceUrl as string} + + + + + ); +}; + +export default PopupBlockedModal; diff --git a/packages/ui-voip/src/views/PopupBlockedModal/__snapshots__/PopupBlockedModal.spec.tsx.snap b/packages/ui-voip/src/views/PopupBlockedModal/__snapshots__/PopupBlockedModal.spec.tsx.snap new file mode 100644 index 0000000000000..668c52e98cf8d --- /dev/null +++ b/packages/ui-voip/src/views/PopupBlockedModal/__snapshots__/PopupBlockedModal.spec.tsx.snap @@ -0,0 +1,112 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders Default without crashing 1`] = ` + +
+ +
+
+
+
+

+ Open call in new tab +

+
+ +
+
+
+
+
+ Your web browser blocked Rocket.Chat from opening a new tab. +
+
+ To prevent seeing this message again, allow popups from + + https://your.rocket.chat + +
+
+
+ +
+
+
+ +`; diff --git a/packages/ui-voip/src/views/index.ts b/packages/ui-voip/src/views/index.ts index 4592a1809131b..2f5fbbbc3f746 100644 --- a/packages/ui-voip/src/views/index.ts +++ b/packages/ui-voip/src/views/index.ts @@ -1,6 +1,8 @@ export { default as TransferModal } from './TransferModal'; export * from './MediaCallWidget'; export { default as PermissionFlowModal, type PermissionFlowModalType } from './PermissionFlow/PermissionFlowModal'; +export { default as ConfirmVideoEscalationModal } from './ConfirmVideoEscalationModal/ConfirmVideoEscalationModal'; +export { default as PopupBlockedModal } from './PopupBlockedModal/PopupBlockedModal'; export * from './MediaCallHistoryTable'; export * from './CallHistoryContextualbar'; export * from './MediaCallRoomSection'; diff --git a/yarn.lock b/yarn.lock index 08ead76eadb3c..bcadae6c7719d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10416,6 +10416,7 @@ __metadata: resolution: "@rocket.chat/pexip@workspace:packages/pexip" dependencies: "@rocket.chat/core-services": "workspace:^" + "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/jest-presets": "workspace:~" "@rocket.chat/logger": "workspace:^" "@rocket.chat/models": "workspace:^"