diff --git a/apps/meteor/app/api/server/v1/media-calls.ts b/apps/meteor/app/api/server/v1/media-calls.ts index 1fb8b93957848..6ac17520ffa22 100644 --- a/apps/meteor/app/api/server/v1/media-calls.ts +++ b/apps/meteor/app/api/server/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/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/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 5ee5a6eb6a659..bf3c38ebbf392 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, ServiceClassInternal, type IMediaCallService, Authorization } from '@rocket.chat/core-services'; +import { api, ServiceClassInternal, type IMediaCallService, Authorization, VideoConf } from '@rocket.chat/core-services'; import type { IMediaCall, IUser, @@ -6,6 +6,8 @@ import type { IInternalMediaCallHistoryItem, CallHistoryItemState, IExternalMediaCallHistoryItem, + VideoConference, + AtLeast, } from '@rocket.chat/core-typings'; import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls'; import type { @@ -17,7 +19,7 @@ import type { } from '@rocket.chat/media-signaling'; import { isClientMediaSignal } from '@rocket.chat/media-signaling'; import type { InsertionModel } from '@rocket.chat/model-typings'; -import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models'; +import { CallHistory, MediaCalls, Rooms, Users, VideoConference as VideoConferenceModel } from '@rocket.chat/models'; import { callStateToTranslationKey, getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload'; import { logger } from './logger'; @@ -398,20 +400,19 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall }, mobileRinging, permissionCheck: (uid, callType) => this.userHasMediaCallPermission(uid, callType), - isFeatureAvailableForUser: (uid, feature) => this.userHasFeaturePermission(uid, feature), + isFeatureEnabled: (feature) => this.isFeatureEnabled(feature), }; } - private userHasFeaturePermission(_uid: IUser['_id'], feature: CallFeature): boolean { - if (feature === 'audio') { - return true; + private isFeatureEnabled(feature: CallFeature): boolean { + switch (feature) { + case 'screen-share': + return settings.get('VoIP_TeamCollab_Screen_Sharing_Enabled') ?? false; + case 'conference-escalation': + return Boolean(settings.get('VoIP_TeamCollab_Video_Escalation_Enabled') && settings.get('Pexip_Integration_Enabled')); + default: + return true; } - - if (feature === 'screen-share') { - return settings.get('VoIP_TeamCollab_Screen_Sharing_Enabled') ?? false; - } - - return true; } private async userHasMediaCallPermission(uid: IUser['_id'], callType: 'internal' | 'external' | 'any'): Promise { @@ -436,4 +437,129 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall throw err; } } + + public async escalateCall(uid: IUser['_id'], params: { callId: string }): Promise { + const { callId } = params; + + const call = await MediaCalls.findOneById(callId); + if (!call?.acceptedAt) { + throw new Error('not-found'); + } + + if (!call.uids.includes(uid)) { + throw new Error('not-found'); + } + + const user = await Users.findOneById(uid); + if (!user) { + throw new Error('internal-error'); + } + + // if (!call.features.includes('conference-escalation')) { + // throw new Error('feature-not-available'); + // } + + const url = await this.escalateVoiceCallToConference(user, call); + return url; + } + + private async escalateVoiceCallToConference(user: IUser, call: IMediaCall): Promise { + const conference = await this.getOrCreateConferenceForEscalatingCall(call, user); + if (conference?.type !== 'videoconference') { + logger.error({ msg: 'Failed to create conference for voice call escalation', type: conference?.type }); + throw new Error('internal-error'); + } + + void this.flagAsEscalated(call).catch((err) => { + logger.error({ msg: 'Unexpected error while flagging call as escalated', err }); + }); + + const result = await VideoConf.joinCall(conference, user, { mic: true, cam: false }); + + return result; + } + + private async findExistingConferenceForCall(call: IMediaCall): Promise { + const existingConference = await VideoConferenceModel.findOneByMediaCallId(call._id); + if (existingConference || !call.remoteLegCallId) { + return existingConference; + } + + return VideoConferenceModel.findOneByMediaCallId(call.remoteLegCallId); + } + + private async getOrCreateConferenceForEscalatingCall(call: IMediaCall, user: IUser): Promise { + const existingConference = await this.findExistingConferenceForCall(call); + if (existingConference) { + return existingConference; + } + + // If the call is already flagged as escalated but no conference for it exists, don't create a new conference - some other process might still be running + if (call.escalatedAt) { + throw new Error('pre-escalated-conference-not-found'); + } + + const mediaCallIds = [call._id, call.remoteLegCallId].filter((callId): callId is string => Boolean(callId)); + + const conferenceId = await this.createConferenceForEscalatingCall(user, mediaCallIds); + return VideoConferenceModel.findOneById(conferenceId); + } + + private async createConferenceForEscalatingCall(user: IUser, mediaCallIds: string[]): Promise { + const rid = 'GENERAL'; + + const callId = await VideoConferenceModel.createGroup({ + rid, + title: 'Escalated Media Call', + createdBy: { + _id: user._id, + name: user.name as string, + username: user.username as string, + }, + providerName: 'core.pexip', + mediaCallIds, + }); + + return callId; + } + + private async flagAsEscalated(call: IMediaCall): Promise { + if (call.escalatedAt) { + return; + } + + const updateResult = await MediaCalls.flagAsEscalatedByCallId(call._id); + if (!updateResult.modifiedCount) { + return; + } + + if (call.remoteLegCallId) { + await MediaCalls.flagAsEscalatedByCallId(call.remoteLegCallId).catch((err) => { + logger.error({ msg: 'Unexpected error while flagging remote call leg as escalated', err }); + }); + } + + await this.notifyEscalatedCall(call); + + if (call.remoteLegCallId) { + await this.notifyEscalatedCallById(call.remoteLegCallId); + } + } + + private async notifyEscalatedCall(call: AtLeast): Promise { + for (const uid of call.uids) { + await this.sendSignal(uid, { + callId: call._id, + type: 'notification', + notification: 'escalated', + }); + } + } + + private async notifyEscalatedCallById(callId: string): Promise { + const call = await MediaCalls.findOneById>(callId, { projection: { uids: 1 } }); + if (call) { + this.notifyEscalatedCall(call); + } + } } diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 193ed724d2693..8eef346cbe2a5 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -905,7 +905,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf }; } - private async joinCall( + public async joinCall( call: ExternalVideoConference, user: AtLeast | undefined, options: VideoConferenceJoinOptions, diff --git a/ee/packages/media-calls/src/constants.ts b/ee/packages/media-calls/src/constants.ts index 86fa92bb08709..11684481b79f1 100644 --- a/ee/packages/media-calls/src/constants.ts +++ b/ee/packages/media-calls/src/constants.ts @@ -1,4 +1,4 @@ import type { CallFeature } from '@rocket.chat/media-signaling'; export const DEFAULT_CALL_FEATURES: CallFeature[] = ['audio']; -export const SIP_CALL_FEATURES: CallFeature[] = ['audio', 'transfer', 'hold', 'screen-share']; +export const SIP_CALL_FEATURES: CallFeature[] = ['audio', 'transfer', 'hold', 'screen-share', 'conference-escalation']; diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts index 8449819ddb08b..a0ba207fcc73c 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallServer.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -1,4 +1,4 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IUser, MediaCallContact } from '@rocket.chat/core-typings'; import type { Emitter } from '@rocket.chat/emitter'; import type { CallFeature, ClientMediaSignal, ClientMediaSignalBody, ServerMediaSignal } from '@rocket.chat/media-signaling'; @@ -36,7 +36,7 @@ export interface IMediaCallServerSettings { 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 { @@ -60,5 +60,5 @@ export interface IMediaCallServer { 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/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index e2d969373ec64..388ab54d9c5a8 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -214,7 +214,16 @@ class MediaCallDirector { callerAgent.oppositeAgent = calleeAgent; calleeAgent.oppositeAgent = callerAgent; - const allowedFeatures = features.filter((feature) => getMediaCallServer().isFeatureAvailableForUser(caller.id, feature)); + const forbiddenFeatures: CallFeature[] = []; + if (parentCallId) { + // Transferred calls can not be escalated yet + forbiddenFeatures.push('conference-escalation'); + } + + const participants = [caller, callee]; + const allowedFeatures = features.filter( + (feature) => !forbiddenFeatures.includes(feature) && getMediaCallServer().isFeatureAvailableForParticipants(feature, participants), + ); const call: Omit = { // Use UUIDs to identify all media calls, for better compatibility with libs that require it (such as React Native's CallKit) _id: randomUUID(), @@ -274,6 +283,8 @@ class MediaCallDirector { return; } + // TODO: Consider call's divertedBy as well + const callerActiveCalls = await MediaCalls.findOutgoingSIPCallsNotOverByCallerUId>(call.caller.uid, { projection: { callee: 1 }, }).toArray(); diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index 86aaa932db858..99501d65a895d 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 { IUser, MediaCallContact } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { CallFeature, @@ -146,8 +146,32 @@ export class MediaCallServer implements IMediaCallServer { return this.settings.permissionCheck(uid, callType); } - public isFeatureAvailableForUser(uid: IUser['_id'], feature: CallFeature): boolean { - return this.settings.isFeatureAvailableForUser(uid, feature); + public isFeatureAvailableForParticipants(feature: CallFeature, participants: MediaCallContact[]): boolean { + if (!this.settings.isFeatureEnabled(feature)) { + return false; + } + + if (feature === 'conference-escalation') { + // Conference escalation is only implemented on SIP calls + return this.isSipCallParticipants(participants); + } + + return true; + } + + private isSipCallParticipants(participants: MediaCallContact[]): boolean { + // On sip calls, one participant is an internal user and the other is a sip extension; The order depends on the call direction + const sipUser = participants.find(({ type }) => type === 'sip'); + if (!sipUser) { + return false; + } + + const internalUser = participants.find(({ type }) => type === 'user'); + if (!internalUser) { + return false; + } + + return true; } /** diff --git a/ee/packages/media-calls/src/server/getDefaultSettings.ts b/ee/packages/media-calls/src/server/getDefaultSettings.ts index 07ddfe33ccc9a..8e9dee51ca5bd 100644 --- a/ee/packages/media-calls/src/server/getDefaultSettings.ts +++ b/ee/packages/media-calls/src/server/getDefaultSettings.ts @@ -21,6 +21,6 @@ export function getDefaultSettings(): IMediaCallServerSettings { mobileRinging: false, permissionCheck: async () => false, - isFeatureAvailableForUser: () => false, + isFeatureEnabled: () => false, }; } diff --git a/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts b/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts index 294b3ef1f1d21..129301eade984 100644 --- a/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts @@ -46,7 +46,7 @@ export class OutgoingSipCall extends BaseSipCall { public static async createCall(session: SipServerSession, params: InternalCallParams): Promise { logger.debug({ msg: 'OutgoingSipCall.createCall', sessionId: session.sessionId }); - const { callee, ...extraParams } = params; + const { callee, features: requestedFeatures, ...extraParams } = params; // pre-sign the callee to this session const signedCallee: MediaCallSignedContact = { @@ -68,12 +68,13 @@ export class OutgoingSipCall extends BaseSipCall { throw new SipError(SipErrorCodes.NOT_FOUND, 'Caller agent not found'); } + const features = requestedFeatures.filter((feature) => SIP_CALL_FEATURES.includes(feature)); const call = await mediaCallDirector.createCall({ ...extraParams, callee: signedCallee, calleeAgent, callerAgent, - features: SIP_CALL_FEATURES, + features, }); const channel = await calleeAgent.getOrCreateChannel(call, session.sessionId); diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index 60a9493a178d2..8c777f788e247 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -7,4 +7,5 @@ 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; } diff --git a/packages/core-services/src/types/IVideoConfService.ts b/packages/core-services/src/types/IVideoConfService.ts index 6d74413ccf211..d49edcd46d501 100644 --- a/packages/core-services/src/types/IVideoConfService.ts +++ b/packages/core-services/src/types/IVideoConfService.ts @@ -1,4 +1,6 @@ import type { + AtLeast, + ExternalVideoConference, IRoom, IStats, IUser, @@ -44,4 +46,9 @@ 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; } diff --git a/packages/core-typings/src/IVideoConference.ts b/packages/core-typings/src/IVideoConference.ts index 74cb7fc04ee63..a361b9bebeddf 100644 --- a/packages/core-typings/src/IVideoConference.ts +++ b/packages/core-typings/src/IVideoConference.ts @@ -79,6 +79,8 @@ export interface IVideoConference extends IRocketChatRecord { ringing?: boolean; discussionRid?: IRoom['_id']; + + mediaCallIds?: 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 29e3571bb74ce..dab2d9eeed4b1 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -70,6 +70,8 @@ export interface IMediaCall extends IRocketChatRecord { uids: IUser['_id'][]; + escalatedAt?: 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/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index e8b8ffb09fb56..0dca520d971c5 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -5923,6 +5923,7 @@ "Visitor_time_on_site": "Visitor time on site", "VoIP": "VoIP", "VoIP_TeamCollab": "Team voice calls (VoIP)", + "VoIP_TeamCollab_AdvancedFeatures": "Advanced Features", "VoIP_TeamCollab_Description": "Set up VoIP in Team collaboration", "VoIP_TeamCollab_ExternalCallHistory": "External Call History", "VoIP_TeamCollab_ExternalCallHistory_Enabled": "Enabled", @@ -5957,6 +5958,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", diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index 26b629b0294cd..9b0639eb194db 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -34,4 +34,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 32fb06a6a7882..0927e116de124 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]; @@ -51,11 +51,15 @@ export const callAnswerList = [ export type CallAnswer = (typeof callAnswerList)[number]; -export type CallNotification = - | 'accepted' // notify that the call has been accepted by both actors - | '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 +export const callNotificationList = [ + 'accepted', // notify that the call has been accepted by both actors + '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 +]; + +export type CallNotification = (typeof callNotificationList)[number]; export type CallRejectedReason = | 'invalid-call-id' // the call id can't be used for a new call diff --git a/packages/media-signaling/src/definition/call/callStates/IDirectMediaCallData.ts b/packages/media-signaling/src/definition/call/callStates/IDirectMediaCallData.ts index 8b35b081ebae6..919d86c100a10 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 transferredBy: CallContact | null; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 184a3675b19a4..7375cc3d86fd7 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -120,7 +120,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 { @@ -185,7 +185,11 @@ export class ClientMediaCall implements IClientMediaCall { private hasRemoteData: boolean; - private initialized: boolean; + private _initialized: boolean; + + public get initialized(): boolean { + return this._initialized; + } private acknowledged: boolean; @@ -222,6 +226,8 @@ export class ClientMediaCall implements IClientMediaCall { private enabledFeatures: CallFeature[] | null; + private escalated: boolean; + private _flags: CallFlag[]; public get flags(): CallFlag[] { @@ -274,6 +280,7 @@ export class ClientMediaCall implements IClientMediaCall { activeTimestamp: this.activeTimestamp, tempCallId: this.tempCallId, hidden: this.hidden, + escalated: this.escalated, localParticipant: this.localParticipant, remoteParticipant: this.remoteParticipant, @@ -296,7 +303,7 @@ export class ClientMediaCall implements IClientMediaCall { this.acceptedRemotely = false; this.endedLocally = false; this.hasRemoteData = false; - this.initialized = false; + this._initialized = false; this.acknowledged = false; this.contractState = 'proposed'; this.serviceStates = new Map(); @@ -308,6 +315,7 @@ export class ClientMediaCall implements IClientMediaCall { this.sentLocalSdp = false; this.receivedRemoteSdp = false; this.enabledFeatures = null; + this.escalated = false; this.earlySignals = new Set(); this.stateTimeoutHandlers = new Set(); @@ -340,7 +348,7 @@ export class ClientMediaCall implements IClientMediaCall { const wasInitialized = this.initialized; - this.initialized = true; + this._initialized = true; this.acceptedLocally = true; if (this.hasRemoteData) { this.changeContact(contact, { prioritizeExisting: true }); @@ -362,7 +370,7 @@ export class ClientMediaCall implements IClientMediaCall { supportedFeatures: CallFeature[], contactInfo?: CallContact, ): Promise { - if (this.initialized) { + if (this._initialized) { return; } @@ -388,7 +396,7 @@ export class ClientMediaCall implements IClientMediaCall { this.remoteCallId = signal.callId; const wasInitialized = this.initialized; - this.initialized = true; + this._initialized = true; this.hasRemoteData = true; this._service = signal.service; this._role = signal.role; @@ -1172,6 +1180,8 @@ export class ClientMediaCall implements IClientMediaCall { case 'hangup': return this.flagAsEnded('remote'); + case 'escalated': + return this.flagAsEscalated(); } } @@ -1219,6 +1229,17 @@ export class ClientMediaCall implements IClientMediaCall { this.changeState('hangup'); } + private flagAsEscalated(): void { + if (this.escalated) { + return; + } + + this.config.logger?.debug('ClientMediaCall.flagAsEscalated'); + + 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 64dd3d4592786..8ea316abd49e4 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -173,7 +173,7 @@ export class MediaSignalingSession extends Emitter { let pendingCall: ClientMediaCall | null = null; for (const call of this.knownCalls.values()) { - if (call.state === 'hangup' || call.ignored) { + if (call.state === 'hangup' || call.ignored || !call.initialized) { continue; } if (skipLocal && !call.confirmed) { @@ -220,6 +220,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) { @@ -350,13 +353,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); } @@ -625,6 +633,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; } diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 2a6668d9a894f..b584db15d2d47 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -37,4 +37,5 @@ export interface IMediaCallsModel extends IBaseModel { hasUnfinishedCalls(): Promise; hasUnfinishedCallsByUid(uid: IUser['_id'], exceptCallId?: string): Promise; setRemoteLegCallId(callId: string, remoteLegCallId: string): Promise; + flagAsEscalatedByCallId(callId: string): Promise; } diff --git a/packages/model-typings/src/models/IVideoConferenceModel.ts b/packages/model-typings/src/models/IVideoConferenceModel.ts index 66a082af85d2a..6c529e53d1e2a 100644 --- a/packages/model-typings/src/models/IVideoConferenceModel.ts +++ b/packages/model-typings/src/models/IVideoConferenceModel.ts @@ -30,7 +30,8 @@ export interface IVideoConferenceModel extends IBaseModel { createGroup({ providerName, ...callDetails - }: Required>): Promise; + }: Required> & + Pick): Promise; createLivechat({ providerName, @@ -70,4 +71,6 @@ export interface IVideoConferenceModel extends IBaseModel { unsetDiscussionRid(discussionRid: IRoom['_id']): Promise; createVoIP(call: InsertionModel): Promise; + + findOneByMediaCallId(callId: string, options?: FindOptions): Promise; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index f7db575282cb1..3206735cdcc36 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -168,6 +168,21 @@ 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 setExpiresAtById(callId: string, expiresAt: Date): Promise { return this.updateOne( { diff --git a/packages/models/src/models/VideoConference.ts b/packages/models/src/models/VideoConference.ts index e6bba09747c03..7207544531c55 100644 --- a/packages/models/src/models/VideoConference.ts +++ b/packages/models/src/models/VideoConference.ts @@ -18,6 +18,7 @@ import type { Collection, Db, CountDocumentsOptions, + FindOptions, } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -32,6 +33,7 @@ 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 }, ]; } @@ -104,8 +106,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 +118,7 @@ export class VideoConferenceRaw extends BaseRaw implements IVid anonymousUsers: 0, createdAt: new Date(), providerName: providerName.toLowerCase(), + ...(mediaCallIds?.length && { mediaCallIds }), ...callDetails, }; @@ -302,4 +307,13 @@ export class VideoConferenceRaw extends BaseRaw implements IVid }, ); } + + public async findOneByMediaCallId(callId: string, options?: FindOptions): Promise { + return this.findOne( + { + mediaCallIds: callId, + }, + options || {}, + ); + } } diff --git a/packages/ui-voip/src/providers/useMediaSessionInstance.ts b/packages/ui-voip/src/providers/useMediaSessionInstance.ts index 701027dcd1f14..4f0d5108c2dfb 100644 --- a/packages/ui-voip/src/providers/useMediaSessionInstance.ts +++ b/packages/ui-voip/src/providers/useMediaSessionInstance.ts @@ -151,7 +151,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, });