diff --git a/.changeset/fair-lions-smell.md b/.changeset/fair-lions-smell.md new file mode 100644 index 0000000000000..403b673842ee0 --- /dev/null +++ b/.changeset/fair-lions-smell.md @@ -0,0 +1,10 @@ +--- +'@rocket.chat/media-calls': minor +'@rocket.chat/core-services': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor +'@rocket.chat/media-signaling': minor +--- + +Adds a new REST endpoint to accept or reject media calls without an active media session diff --git a/apps/meteor/app/api/server/v1/media-calls.ts b/apps/meteor/app/api/server/v1/media-calls.ts index 16d5194aa0996..d526ec549ea1c 100644 --- a/apps/meteor/app/api/server/v1/media-calls.ts +++ b/apps/meteor/app/api/server/v1/media-calls.ts @@ -1,6 +1,7 @@ import { MediaCall } from '@rocket.chat/core-services'; import type { IMediaCall } from '@rocket.chat/core-typings'; -import type { ServerMediaCallSignal } from '@rocket.chat/media-signaling'; +import type { CallAnswer, CallFeature, ServerMediaCallSignal } from '@rocket.chat/media-signaling'; +import { callFeatureList, callAnswerList } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; import { ajv, @@ -14,6 +15,89 @@ import type { JSONSchemaType } from 'ajv'; import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; +type MediaCallsAnswer = { + callId: string; + contractId: string; + + answer: CallAnswer; + + supportedFeatures?: CallFeature[]; +}; + +const MediaCallsAnswerSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + }, + contractId: { + type: 'string', + }, + answer: { + type: 'string', + enum: callAnswerList, + }, + supportedFeatures: { + type: 'array', + items: { + type: 'string', + enum: callFeatureList, + }, + nullable: true, + }, + }, + required: ['callId', 'contractId', 'answer'], + additionalProperties: false, +}; + +export const isMediaCallsAnswerProps = ajv.compile(MediaCallsAnswerSchema); + +const mediaCallsAnswerEndpoints = API.v1.post( + 'media-calls.answer', + { + response: { + 200: ajv.compile<{ + call: IMediaCall; + }>({ + additionalProperties: false, + type: 'object', + properties: { + call: { + type: 'object', + $ref: '#/components/schemas/IMediaCall', + description: 'The updated call information.', + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['call', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + body: isMediaCallsAnswerProps, + authRequired: true, + }, + async function action() { + const call = await MediaCall.answerCall(this.userId, this.bodyParams); + + return API.v1.success({ + call, + }); + }, +); + +type MediaCallsAnswerEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends MediaCallsAnswerEndpoints {} +} + 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 5a6c05bf9fe73..31cf7a4b69897 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -9,7 +9,13 @@ import type { } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls'; -import type { CallFeature, ClientMediaSignal, ServerMediaSignal, ServerMediaCallSignal } from '@rocket.chat/media-signaling'; +import type { + CallFeature, + ClientMediaSignal, + ServerMediaSignal, + ServerMediaCallSignal, + ClientMediaSignalAnswer, +} 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'; @@ -41,21 +47,72 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall this.configureMediaCallServer(); } + public async answerCall(uid: IUser['_id'], params: Omit): Promise { + const { callId, answer } = params; + + const call = await MediaCalls.findOneByIdAndCallee>( + callId, + { type: 'user', id: uid }, + { projection: { _id: 1 } }, + ); + if (!call) { + throw new Error('not-found'); + } + + const signal: ClientMediaSignalAnswer = { + type: 'answer', + ...params, + }; + + await callServer.receiveSignal(uid, signal, { throwIfSkipped: true }); + + const updatedCall = await MediaCalls.findOneById(callId); + if (!updatedCall) { + throw new Error('internal-error'); + } + + switch (answer) { + case 'ack': + if (updatedCall.acceptedAt || updatedCall.ended) { + throw new Error('invalid-call-state'); + } + break; + case 'reject': + if (!updatedCall.ended || updatedCall.endedBy?.id !== uid) { + throw new Error('invalid-call-state'); + } + break; + case 'accept': + if (updatedCall.callee.contractId !== signal.contractId) { + if (updatedCall.callee.contractId) { + throw new Error('invalid-call-state'); + } + throw new Error('internal-error'); + } + break; + } + + return updatedCall; + } + public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { try { - callServer.receiveSignal(uid, signal); + await callServer.receiveSignal(uid, signal); } catch (err) { logger.error({ msg: 'failed to process client signal', err, signal, uid }); } } public async processSerializedSignal(uid: IUser['_id'], signal: string): Promise { + let signalType: string | null = null; + try { const deserialized = await this.deserializeClientSignal(signal); + signalType = deserialized.type; - callServer.receiveSignal(uid, deserialized); + await callServer.receiveSignal(uid, deserialized); } catch (err) { - logger.error({ msg: 'failed to process client signal', err, uid }); + logger.error({ msg: 'failed to process client signal', err, uid, type: signalType }); } } diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts index 9d80fd42d33a4..36ebdc836d0f3 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallServer.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -2,7 +2,7 @@ import type { IUser } 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 } from './common'; +import type { InternalCallParams, SignalProcessingOptions } from './common'; export type MediaCallServerEvents = { callUpdated: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }; @@ -42,7 +42,7 @@ export interface IMediaCallServer { updateCallHistory(params: { callId: string }): void; // functions that are run on events - receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): void; + receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal, options?: SignalProcessingOptions): Promise; receiveCallUpdate(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void; // extra functions available to the service diff --git a/ee/packages/media-calls/src/definition/common.ts b/ee/packages/media-calls/src/definition/common.ts index b9683a6d6a3fc..07dbbb0677969 100644 --- a/ee/packages/media-calls/src/definition/common.ts +++ b/ee/packages/media-calls/src/definition/common.ts @@ -28,3 +28,9 @@ export class CallRejectedError extends Error { super(message || 'call-rejected'); } } + +export type SignalProcessingOptions = { + // Some signals can be safely skipped when they are not relevant to the current call state, but + // if the signal was received via REST, we shouldn't return success without processing anything, so we throw an error instead + throwIfSkipped?: boolean; +}; diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index eb7abae405eac..d247f7236a1a3 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -11,7 +11,7 @@ import type { import { MediaCalls } from '@rocket.chat/models'; import { DEFAULT_CALL_FEATURES } from '../constants'; -import type { InternalCallParams } from '../definition/common'; +import type { InternalCallParams, SignalProcessingOptions } from '../definition/common'; import { logger } from '../logger'; import { mediaCallDirector } from '../server/CallDirector'; import { UserActorAgent } from './agents/UserActorAgent'; @@ -32,7 +32,7 @@ export class GlobalSignalProcessor { this.emitter = new Emitter(); } - public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { + public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal, options: SignalProcessingOptions): Promise { switch (signal.type) { case 'register': return this.processRegisterSignal(uid, signal); @@ -41,7 +41,7 @@ export class GlobalSignalProcessor { } if ('callId' in signal) { - return this.processCallSignal(uid, signal); + return this.processCallSignal(uid, signal, options); } logger.error({ msg: 'Unrecognized media signal', signal: stripSensitiveDataFromSignal(signal) }); @@ -58,6 +58,7 @@ export class GlobalSignalProcessor { private async processCallSignal( uid: IUser['_id'], signal: Exclude, + { throwIfSkipped }: SignalProcessingOptions, ): Promise { try { const call = await MediaCalls.findOneById(signal.callId); @@ -93,6 +94,9 @@ export class GlobalSignalProcessor { // Ignore signals from different sessions if the actor is already signed if (!skipContractCheck && callActor.contractId && callActor.contractId !== signal.contractId) { + if (throwIfSkipped) { + throw new Error('invalid-contract'); + } return; } @@ -102,10 +106,17 @@ export class GlobalSignalProcessor { const { [role]: agent } = agents; if (!(agent instanceof UserActorAgent)) { - throw new Error('Actor agent is not prepared to process signals'); + logger.error({ + msg: 'Actor agent is not prepared to process signals', + method: 'processSignal', + signal: stripSensitiveDataFromSignal(signal), + isCaller, + isCallee, + }); + throw new Error('internal-error'); } - await agent.processSignal(call, signal); + await agent.processSignal(call, signal, { throwIfSkipped }); } catch (e) { logger.error({ err: e }); throw e; diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index ae8b1f29ab319..fa2d8dfa875c1 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -22,6 +22,7 @@ import { MediaCallChannels, MediaCallNegotiations, MediaCalls } from '@rocket.ch import { DEFAULT_CALL_FEATURES } from '../../constants'; import type { IMediaCallAgent } from '../../definition/IMediaCallAgent'; +import type { SignalProcessingOptions } from '../../definition/common'; import { logger } from '../../logger'; import { mediaCallDirector } from '../../server/CallDirector'; import { getMediaCallServer } from '../../server/injection'; @@ -60,6 +61,8 @@ export class UserActorSignalProcessor { public readonly ignored: boolean; + private throwIfSkipped: boolean; + constructor( protected readonly agent: IMediaCallAgent, protected readonly call: IMediaCall, @@ -69,6 +72,7 @@ export class UserActorSignalProcessor { this.signed = Boolean(actor.contractId && actor.contractId === channel.contractId); this.ignored = Boolean(actor.contractId && actor.contractId !== channel.contractId); + this.throwIfSkipped = false; } public async requestWebRTCOffer(params: { negotiationId: string }): Promise { @@ -82,7 +86,7 @@ export class UserActorSignalProcessor { }); } - public async processSignal(signal: ClientMediaSignal): Promise { + public async processSignal(signal: ClientMediaSignal, options: SignalProcessingOptions = {}): Promise { if (signal.type !== 'local-state') { logger.debug({ msg: 'UserActorSignalProcessor.processSignal', @@ -92,6 +96,8 @@ export class UserActorSignalProcessor { }); } + this.throwIfSkipped = options.throwIfSkipped || false; + // The code will only reach this point if one of the following conditions are true: // 1. the signal came from the exact user session where the caller initiated the call // 2. the signal came from the exact user session where the callee accepted the call @@ -296,13 +302,11 @@ export class UserActorSignalProcessor { } protected async clientHasRejected(): Promise { - if (!this.isCallPending()) { + if (!this.validatePendingCallee()) { return; } - if (this.role === 'callee') { - return mediaCallDirector.hangup(this.call, this.agent, 'rejected'); - } + return mediaCallDirector.hangup(this.call, this.agent, 'rejected'); } protected async clientIsUnavailable(): Promise { @@ -315,13 +319,11 @@ export class UserActorSignalProcessor { } protected async clientHasAccepted(supportedFeatures: CallFeature[]): Promise { - if (!this.isCallPending()) { + if (!this.validatePendingCallee()) { return; } - if (this.role === 'callee') { - await mediaCallDirector.acceptCall(this.call, this.agent, { calleeContractId: this.contractId, supportedFeatures }); - } + await mediaCallDirector.acceptCall(this.call, this.agent, { calleeContractId: this.contractId, supportedFeatures }); } protected async clientIsActive(): Promise { @@ -344,6 +346,24 @@ export class UserActorSignalProcessor { return ['active', 'hangup'].includes(this.call.state); } + protected validatePendingCallee(): boolean { + if (this.role !== 'callee') { + if (this.throwIfSkipped) { + throw new Error('invalid-call-role'); + } + return false; + } + + if (!this.isCallPending()) { + if (this.throwIfSkipped) { + throw new Error('invalid-call-state'); + } + return false; + } + + return true; + } + private async reviewLocalState(signal: ClientMediaSignalLocalState): Promise { if (!this.signed) { return; diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index aa15b915d079d..1ed4d1566b7c9 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -5,6 +5,7 @@ import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { UserActorSignalProcessor } from './CallSignalProcessor'; import { BaseMediaCallAgent } from '../../base/BaseAgent'; +import type { SignalProcessingOptions } from '../../definition/common'; import { logger } from '../../logger'; import { getMediaCallServer } from '../../server/injection'; import { getInitialOfferSignal } from '../../server/signals/getInitialOfferSignal'; @@ -12,11 +13,11 @@ import { getNewCallSignal } from '../../server/signals/getNewCallSignal'; import { getStateNotification } from '../../server/signals/getStateNotification'; export class UserActorAgent extends BaseMediaCallAgent { - public async processSignal(call: IMediaCall, signal: ClientMediaSignal): Promise { + public async processSignal(call: IMediaCall, signal: ClientMediaSignal, options?: SignalProcessingOptions): Promise { const channel = await this.getOrCreateChannel(call, signal.contractId); const signalProcessor = new UserActorSignalProcessor(this, call, channel); - return signalProcessor.processSignal(signal); + return signalProcessor.processSignal(signal, options); } public async sendSignal(signal: ServerMediaSignal): Promise { diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index 6ca629c9c52fb..6851762e59c9f 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -1,6 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; -import { isClientMediaSignal } from '@rocket.chat/media-signaling'; import type { CallFeature, CallRejectedReason, @@ -13,7 +12,8 @@ import { mediaCallDirector } from './CallDirector'; import { getDefaultSettings } from './getDefaultSettings'; import { stripSensitiveDataFromSignal } from './stripSensitiveData'; import type { IMediaCallServer, IMediaCallServerSettings, MediaCallServerEvents } from '../definition/IMediaCallServer'; -import { CallRejectedError, type GetActorContactOptions, type InternalCallParams } from '../definition/common'; +import { CallRejectedError } from '../definition/common'; +import type { SignalProcessingOptions, GetActorContactOptions, InternalCallParams } from '../definition/common'; import { InternalCallProvider } from '../internal/InternalCallProvider'; import { GlobalSignalProcessor } from '../internal/SignalProcessor'; import { logger } from '../logger'; @@ -47,15 +47,8 @@ export class MediaCallServer implements IMediaCallServer { }); } - public receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): void { - if (!isClientMediaSignal(signal)) { - logger.error({ msg: 'The Media Signal Server received an invalid client signal object' }); - throw new Error('invalid-signal'); - } - - this.signalProcessor.processSignal(fromUid, signal).catch((err) => { - logger.error({ msg: 'Failed to process client signal', err, type: signal.type }); - }); + public async receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal, options: SignalProcessingOptions = {}): Promise { + return this.signalProcessor.processSignal(fromUid, signal, options); } public sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void { diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index be00d078a374a..60a9493a178d2 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -1,7 +1,8 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import type { ClientMediaSignal, ServerMediaCallSignal } from '@rocket.chat/media-signaling'; +import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; +import type { ClientMediaSignal, ServerMediaCallSignal, ClientMediaSignalAnswer } from '@rocket.chat/media-signaling'; export interface IMediaCallService { + answerCall(uid: IUser['_id'], params: Omit): Promise; processSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): Promise; processSerializedSignal(fromUid: IUser['_id'], signal: string): Promise; hangupExpiredCalls(): Promise; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index d2c80df79b74d..e1e3506c50888 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -42,11 +42,14 @@ export type CallHangupReason = | '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 -export type CallAnswer = - | 'accept' // actor accepts the call - | 'reject' // actor rejects the call - | 'ack' // agent confirms the actor is reachable - | 'unavailable'; // agent reports the actor is unavailable +export const callAnswerList = [ + 'accept', // actor accepts the call + 'reject', // actor rejects the call + 'ack', // agent confirms the actor is reachable + 'unavailable', // agent reports the actor is unavailable +] as const; + +export type CallAnswer = (typeof callAnswerList)[number]; export type CallNotification = | 'accepted' // notify that the call has been accepted by both actors diff --git a/packages/media-signaling/src/definition/signals/client/answer.ts b/packages/media-signaling/src/definition/signals/client/answer.ts index 9b14771a1cda3..c48d7f90ada07 100644 --- a/packages/media-signaling/src/definition/signals/client/answer.ts +++ b/packages/media-signaling/src/definition/signals/client/answer.ts @@ -1,7 +1,7 @@ import type { JSONSchemaType } from 'ajv'; import type { CallAnswer, CallFeature } from '../../call'; -import { callFeatureList } from '../../call/IClientMediaCall'; +import { callAnswerList, callFeatureList } from '../../call/IClientMediaCall'; /** Client is saying that the user accepted or rejected a call, or simply reporting that the user can or can't be reached */ export type ClientMediaSignalAnswer = { @@ -33,7 +33,7 @@ export const clientMediaSignalAnswerSchema: JSONSchemaType { + findOneByIdAndCallee( + id: IMediaCall['_id'], + callee: MediaCallActor, + options?: FindOptions, + ): Promise; findOneByCallerRequestedId( id: Required['callerRequestedId'], caller: { type: MediaCallActorType; id: string }, diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index ab02a344fb91f..858ea9a9680ec 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -5,6 +5,7 @@ import type { MediaCallSignedContact, MediaCallContact, IUser, + MediaCallActor, } from '@rocket.chat/core-typings'; import type { IMediaCallsModel } from '@rocket.chat/model-typings'; import type { @@ -37,6 +38,22 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ]; } + public async findOneByIdAndCallee( + id: IMediaCall['_id'], + callee: MediaCallActor, + options?: FindOptions, + ): Promise { + return this.findOne( + { + '_id': id, + 'callee.type': callee.type, + 'callee.id': callee.id, + ...(callee.contractId && { 'callee.contractId': callee.contractId }), + }, + options, + ); + } + public async findOneByCallerRequestedId( id: Required['callerRequestedId'], caller: { type: MediaCallActorType; id: string },