From 96051a1a24a95ca2b9d4b838ee2b500b68a310d7 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 12 Feb 2026 18:33:47 -0300 Subject: [PATCH 01/15] feat: allow clients to accept calls before the session is established --- apps/meteor/app/api/server/index.ts | 1 + apps/meteor/app/api/server/v1/media-calls.ts | 99 +++++++++++++++++++ .../server/services/media-call/service.ts | 44 ++++++++- .../src/definition/IMediaCallServer.ts | 4 +- .../media-calls/src/definition/common.ts | 6 ++ .../src/internal/SignalProcessor.ts | 19 +++- .../internal/agents/CallSignalProcessor.ts | 38 +++++-- .../media-calls/src/server/MediaCallServer.ts | 15 +-- .../src/types/IMediaCallService.ts | 5 +- .../src/definition/call/IClientMediaCall.ts | 13 ++- .../src/definition/signals/client/answer.ts | 4 +- 11 files changed, 209 insertions(+), 39 deletions(-) create mode 100644 apps/meteor/app/api/server/v1/media-calls.ts diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 59986d6e2da87..320ed4aea0deb 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -22,6 +22,7 @@ import './v1/integrations'; import './v1/invites'; import './v1/import'; import './v1/ldap'; +import './v1/media-calls'; import './v1/misc'; import './v1/permissions'; import './v1/presence'; diff --git a/apps/meteor/app/api/server/v1/media-calls.ts b/apps/meteor/app/api/server/v1/media-calls.ts new file mode 100644 index 0000000000000..9c3adb0cdf387 --- /dev/null +++ b/apps/meteor/app/api/server/v1/media-calls.ts @@ -0,0 +1,99 @@ +import { MediaCall } from '@rocket.chat/core-services'; +import type { IMediaCall } from '@rocket.chat/core-typings'; +import type { CallAnswer, CallFeature } from '@rocket.chat/media-signaling'; +import { callFeatureList, callAnswerList } from '@rocket.chat/media-signaling'; +import { + ajv, + validateNotFoundErrorResponse, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; +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.', + nullable: true, + }, + 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 {} +} diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 6b1dd30b1e1e9..9edb93c5845f1 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -9,7 +9,8 @@ import type { } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { callServer, type IMediaCallServerSettings } from '@rocket.chat/media-calls'; -import { isClientMediaSignal, type ClientMediaSignal, type ServerMediaSignal } from '@rocket.chat/media-signaling'; +import type { ClientMediaSignal, ServerMediaSignal, 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'; import { getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload'; @@ -39,21 +40,56 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall this.configureMediaCallServer(); } + public async answerCall(uid: IUser['_id'], params: Omit): Promise { + const { callId } = params; + + const call = await MediaCalls.findOneById>(callId, { + projection: { callee: 1, acceptedAt: 1 }, + }); + if (!call || call.callee.type !== 'user' || call.callee.id !== uid) { + 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'); + } + + if (updatedCall.callee.contractId !== signal.contractId) { + if (updatedCall.callee.contractId) { + throw new Error('invalid-call-state'); + } + throw new Error('internal-error'); + } + + 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 ba1994f6f2697..ed59484e0eaa4 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 { 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'> }; @@ -41,7 +41,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 cb0af26822ffa..46616a1a6914e 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 19a0e9f619330..1a6f7f3542ee8 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -10,7 +10,7 @@ import type { } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; -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'; @@ -29,7 +29,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); @@ -38,7 +38,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) }); @@ -55,6 +55,7 @@ export class GlobalSignalProcessor { private async processCallSignal( uid: IUser['_id'], signal: Exclude, + { throwIfSkipped }: SignalProcessingOptions, ): Promise { try { const call = await MediaCalls.findOneById(signal.callId); @@ -90,6 +91,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; } @@ -99,7 +103,14 @@ 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); diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index 57d40fbb9e713..61d46bccf815b 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -20,6 +20,7 @@ import type { import { MediaCallChannels, MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; 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'; @@ -58,6 +59,8 @@ export class UserActorSignalProcessor { public readonly ignored: boolean; + private throwIfSkipped: boolean; + constructor( protected readonly agent: IMediaCallAgent, protected readonly call: IMediaCall, @@ -67,6 +70,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 { @@ -80,7 +84,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', @@ -90,6 +94,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 @@ -290,13 +296,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 { @@ -309,13 +313,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 { @@ -338,6 +340,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/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index e6dcc4dc90465..dc43786a939b4 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -1,13 +1,13 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; -import { isClientMediaSignal } from '@rocket.chat/media-signaling'; import type { CallRejectedReason, ClientMediaSignal, ClientMediaSignalBody, ServerMediaSignal } from '@rocket.chat/media-signaling'; 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'; @@ -41,15 +41,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 a00e76a034d82..dcd7004937bf8 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 } from '@rocket.chat/media-signaling'; +import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; +import type { ClientMediaSignal, 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 e96be078b6859..35985ec2adda1 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -50,11 +50,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 Date: Mon, 16 Feb 2026 16:22:55 -0300 Subject: [PATCH 02/15] cleanup --- apps/meteor/app/api/server/v1/media-calls.ts | 1 - apps/meteor/server/services/media-call/service.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/api/server/v1/media-calls.ts b/apps/meteor/app/api/server/v1/media-calls.ts index 9c3adb0cdf387..9d6e0bde77d84 100644 --- a/apps/meteor/app/api/server/v1/media-calls.ts +++ b/apps/meteor/app/api/server/v1/media-calls.ts @@ -65,7 +65,6 @@ const mediaCallsAnswerEndpoints = API.v1.post( type: 'object', $ref: '#/components/schemas/IMediaCall', description: 'The updated call information.', - nullable: true, }, success: { type: 'boolean', diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 9edb93c5845f1..0f914ad4acc54 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -43,8 +43,8 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall public async answerCall(uid: IUser['_id'], params: Omit): Promise { const { callId } = params; - const call = await MediaCalls.findOneById>(callId, { - projection: { callee: 1, acceptedAt: 1 }, + const call = await MediaCalls.findOneById>(callId, { + projection: { callee: 1 }, }); if (!call || call.callee.type !== 'user' || call.callee.id !== uid) { throw new Error('not-found'); From 7f15e37c46621178443a2dd896df08cd68079660 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 26 Feb 2026 13:20:59 -0300 Subject: [PATCH 03/15] use query to validate callee --- .../server/services/media-call/service.ts | 10 ++++++---- .../src/models/IMediaCallsModel.ts | 14 +++++++++++++- packages/models/src/models/MediaCalls.ts | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 0f914ad4acc54..9ed8c6c2dd6d7 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -43,10 +43,12 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall public async answerCall(uid: IUser['_id'], params: Omit): Promise { const { callId } = params; - const call = await MediaCalls.findOneById>(callId, { - projection: { callee: 1 }, - }); - if (!call || call.callee.type !== 'user' || call.callee.id !== uid) { + const call = await MediaCalls.findOneByIdAndCallee>( + callId, + { type: 'user', id: uid }, + { projection: { _id: 1 } }, + ); + if (!call) { throw new Error('not-found'); } diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index a290fc73c3f33..2221a5335d226 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -1,9 +1,21 @@ -import type { IMediaCall, IUser, MediaCallActorType, MediaCallContact, MediaCallSignedContact } from '@rocket.chat/core-typings'; +import type { + IMediaCall, + IUser, + MediaCallActor, + MediaCallActorType, + MediaCallContact, + MediaCallSignedContact, +} from '@rocket.chat/core-typings'; import type { Document, FindCursor, FindOptions, UpdateResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; export interface IMediaCallsModel extends IBaseModel { + 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 }, From 57d51634cb512b6221678d30eb48d6cf49a4bf43 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 26 Feb 2026 13:28:22 -0300 Subject: [PATCH 04/15] changeset --- .changeset/fair-lions-smell.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/fair-lions-smell.md 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 From 6c3ae5d2d5e753bf5e8e39dd74e6cd8e08ab25f4 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 16 Mar 2026 12:31:14 -0300 Subject: [PATCH 05/15] handle ack invalid state --- .../server/services/media-call/service.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 9ed8c6c2dd6d7..f048be327ce57 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -41,7 +41,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } public async answerCall(uid: IUser['_id'], params: Omit): Promise { - const { callId } = params; + const { callId, answer } = params; const call = await MediaCalls.findOneByIdAndCallee>( callId, @@ -64,11 +64,22 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall throw new Error('internal-error'); } - if (updatedCall.callee.contractId !== signal.contractId) { - if (updatedCall.callee.contractId) { - throw new Error('invalid-call-state'); - } - throw new Error('internal-error'); + switch (answer) { + case 'ack': + if (updatedCall.acceptedAt || updatedCall.ended) { + throw new Error('invalid-call-state'); + } + break; + case 'unavailable': + break; + default: + if (updatedCall.callee.contractId !== signal.contractId) { + if (updatedCall.callee.contractId) { + throw new Error('invalid-call-state'); + } + throw new Error('internal-error'); + } + break; } return updatedCall; From bbd36e2037ff585a4dbd5d1fb95e9bc226199661 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 16 Mar 2026 13:08:56 -0300 Subject: [PATCH 06/15] handle reject validations --- apps/meteor/server/services/media-call/service.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index f048be327ce57..3f91264cc17c3 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -70,9 +70,12 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall throw new Error('invalid-call-state'); } break; - case 'unavailable': + case 'reject': + if (!updatedCall.ended || updatedCall.endedBy?.id !== uid) { + throw new Error('invalid-call-state'); + } break; - default: + case 'accept': if (updatedCall.callee.contractId !== signal.contractId) { if (updatedCall.callee.contractId) { throw new Error('invalid-call-state'); From 3a3e7e903c8c2c8b2b2c22f932b79082d59aa3db Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 8 Apr 2026 15:39:11 -0300 Subject: [PATCH 07/15] feat: new endpoint to get the current media state from the server --- apps/meteor/app/api/server/index.ts | 1 + apps/meteor/app/api/server/v1/media-calls.ts | 96 +++++++++++++++++++ .../server/services/media-call/service.ts | 17 +++- ee/packages/media-calls/src/base/BaseAgent.ts | 4 +- .../src/definition/IMediaCallAgent.ts | 4 +- ee/packages/media-calls/src/index.ts | 1 + .../src/internal/SignalProcessor.ts | 35 ++----- .../src/internal/agents/UserActorAgent.ts | 34 +++---- .../media-calls/src/server/BroadcastAgent.ts | 6 +- .../media-calls/src/server/CallDirector.ts | 12 ++- .../src/server/getCallRoleForUser.ts | 13 +++ .../server/signals/getInitialOfferSignal.ts | 29 ++++++ .../getNewCallSignal.ts} | 2 +- .../{ => signals}/getNewCallTransferredBy.ts | 0 .../signals/getSignalsForExistingCall.ts | 35 +++++++ .../server/signals/getStateNotification.ts | 36 +++++++ .../src/types/IMediaCallService.ts | 5 +- 17 files changed, 269 insertions(+), 61 deletions(-) create mode 100644 apps/meteor/app/api/server/v1/media-calls.ts create mode 100644 ee/packages/media-calls/src/server/getCallRoleForUser.ts create mode 100644 ee/packages/media-calls/src/server/signals/getInitialOfferSignal.ts rename ee/packages/media-calls/src/server/{buildNewCallSignal.ts => signals/getNewCallSignal.ts} (92%) rename ee/packages/media-calls/src/server/{ => signals}/getNewCallTransferredBy.ts (100%) create mode 100644 ee/packages/media-calls/src/server/signals/getSignalsForExistingCall.ts create mode 100644 ee/packages/media-calls/src/server/signals/getStateNotification.ts diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 176141af83e08..5a6a6f06cbbab 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -21,6 +21,7 @@ import './v1/integrations'; import './v1/invites'; import './v1/import'; import './v1/ldap'; +import './v1/media-calls'; import './v1/misc'; import './v1/permissions'; import './v1/presence'; diff --git a/apps/meteor/app/api/server/v1/media-calls.ts b/apps/meteor/app/api/server/v1/media-calls.ts new file mode 100644 index 0000000000000..adfe3d20c47af --- /dev/null +++ b/apps/meteor/app/api/server/v1/media-calls.ts @@ -0,0 +1,96 @@ +import { MediaCall } from '@rocket.chat/core-services'; +import type { IMediaCall } from '@rocket.chat/core-typings'; +import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; +import { + ajv, + validateNotFoundErrorResponse, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; +import type { JSONSchemaType } from 'ajv'; + +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; + +type MediaCallsStateParams = { + contractId: string; +}; + +const MediaCallsStateSchema: JSONSchemaType = { + type: 'object', + properties: { + contractId: { + type: 'string', + }, + }, + required: ['contractId'], + additionalProperties: false, +}; + +export const isMediaCallsStateProps = ajv.compile(MediaCallsStateSchema); + +const mediaCallsStateEndpoints = API.v1.get( + 'media-calls.state', + { + response: { + 200: ajv.compile<{ + calls: IMediaCall[]; + signals: ServerMediaSignal[]; + }>({ + additionalProperties: false, + type: 'object', + properties: { + calls: { + type: 'array', + items: { + type: 'object', + $ref: '#/components/schemas/IMediaCall', + }, + description: 'The list of active calls.', + }, + signals: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string' }, + callId: { type: 'string' }, + }, + required: ['callId', 'type'], + additionalProperties: true, + }, + description: 'The list of signals that were already sent for the active calls.', + }, + success: { + type: 'boolean', + description: 'Indicates the request was successful.', + }, + }, + required: ['calls', 'signals', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + query: isMediaCallsStateProps, + authRequired: true, + }, + async function action() { + const { contractId } = this.queryParams; + const { calls, signals } = await MediaCall.getUserState(this.userId, contractId); + + return API.v1.success({ + calls, + signals, + }); + }, +); + +type MediaCallsStateEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends MediaCallsStateEndpoints {} +} diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index c5c6411bdc7c5..b5c42dfa387d2 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -8,7 +8,7 @@ import type { IExternalMediaCallHistoryItem, } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; -import { callServer, type IMediaCallServerSettings } from '@rocket.chat/media-calls'; +import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls'; import { type CallFeature, isClientMediaSignal, type ClientMediaSignal, type ServerMediaSignal } from '@rocket.chat/media-signaling'; import type { InsertionModel } from '@rocket.chat/model-typings'; import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models'; @@ -72,6 +72,21 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } } + public async getUserState(uid: IUser['_id'], contractId: string): Promise<{ calls: IMediaCall[]; signals: ServerMediaSignal[] }> { + const calls = await MediaCalls.findAllNotOverByUid(uid).toArray(); + + const signals: ServerMediaSignal[] = []; + for (const call of calls) { + const callSignals = await getSignalsForExistingCall(call, uid, contractId); + signals.push(...callSignals); + } + + return { + calls, + signals, + }; + } + private async saveCallToHistory(callId: IMediaCall['_id']): Promise { logger.info({ msg: 'saving media call to history', callId }); diff --git a/ee/packages/media-calls/src/base/BaseAgent.ts b/ee/packages/media-calls/src/base/BaseAgent.ts index 05f3fa76dba54..425b17a2a7ad8 100644 --- a/ee/packages/media-calls/src/base/BaseAgent.ts +++ b/ee/packages/media-calls/src/base/BaseAgent.ts @@ -6,7 +6,7 @@ import type { MediaCallContact, MediaCallSignedActor, } from '@rocket.chat/core-typings'; -import type { CallFeature, CallRole } from '@rocket.chat/media-signaling'; +import type { CallRole } from '@rocket.chat/media-signaling'; import type { InsertionModel } from '@rocket.chat/model-typings'; import { MediaCallChannels } from '@rocket.chat/models'; @@ -61,7 +61,7 @@ export abstract class BaseMediaCallAgent implements IMediaCallAgent { }; } - public abstract onCallAccepted(callId: string, data: { signedContractId: string; features: CallFeature[] }): Promise; + public abstract onCallAccepted(call: IMediaCall): Promise; public abstract onCallActive(callId: string): Promise; diff --git a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts index bc320aa2fb73e..e68c53e64eac3 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts @@ -1,5 +1,5 @@ import type { IMediaCall, MediaCallActor, MediaCallActorType, MediaCallContact } from '@rocket.chat/core-typings'; -import type { CallFeature, CallRole } from '@rocket.chat/media-signaling'; +import type { CallRole } from '@rocket.chat/media-signaling'; export interface IMediaCallAgent { readonly actorType: MediaCallActorType; @@ -12,7 +12,7 @@ export interface IMediaCallAgent { onCallEnded(callId: string): Promise; /* Called when the call was accepted, even if the webrtc negotiation is pending */ - onCallAccepted(callId: string, data: { signedContractId: string; features: CallFeature[] }): Promise; + onCallAccepted(call: IMediaCall): Promise; onCallActive(callId: string): Promise; onCallCreated(call: IMediaCall): Promise; /* Called when the sdp of the other actor is available, regardless of call state, or when this actor must provide an offer */ diff --git a/ee/packages/media-calls/src/index.ts b/ee/packages/media-calls/src/index.ts index cc9e882c075f2..26cc20f190ec1 100644 --- a/ee/packages/media-calls/src/index.ts +++ b/ee/packages/media-calls/src/index.ts @@ -1,3 +1,4 @@ export type * from './definition/IMediaCallServer'; export { callServer } from './server/configuration'; +export { getSignalsForExistingCall } from './server/signals/getSignalsForExistingCall'; diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index 35d9b5546198e..1a5e3252c9e09 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -15,7 +15,9 @@ import type { InternalCallParams } from '../definition/common'; import { logger } from '../logger'; import { mediaCallDirector } from '../server/CallDirector'; import { UserActorAgent } from './agents/UserActorAgent'; -import { buildNewCallSignal } from '../server/buildNewCallSignal'; +import { getCallRoleForUser } from '../server/getCallRoleForUser'; +import { getNewCallSignal } from '../server/signals/getNewCallSignal'; +import { getSignalsForExistingCall } from '../server/signals/getSignalsForExistingCall'; import { stripSensitiveDataFromSignal } from '../server/stripSensitiveData'; export type SignalProcessorEvents = { @@ -126,16 +128,11 @@ export class GlobalSignalProcessor { return; } - const isCaller = call.caller.type === 'user' && call.caller.id === uid; - const isCallee = call.callee.type === 'user' && call.callee.id === uid; - - if (!isCaller && !isCallee) { + const role = getCallRoleForUser(call, uid); + if (!role) { return; } - - const role = isCaller ? 'caller' : 'callee'; const actor = call[role]; - // If this user's side of the call has already been signed if (actor.contractId) { // If it was signed by a session that the current session is replacing (as in a browser refresh) @@ -148,22 +145,10 @@ export class GlobalSignalProcessor { await mediaCallDirector.renewCallId(call._id); } - this.sendSignal(uid, buildNewCallSignal(call, role)); - - if (call.state === 'active') { - this.sendSignal(uid, { - callId: call._id, - type: 'notification', - notification: 'active', - ...(actor.contractId && { signedContractId: actor.contractId }), - }); - } else if (actor.contractId && !isPendingState(call.state)) { - this.sendSignal(uid, { - callId: call._id, - type: 'notification', - notification: 'accepted', - signedContractId: actor.contractId, - }); + const signals = await getSignalsForExistingCall(call, uid, signal.contractId); + + for (const signal of signals) { + this.sendSignal(uid, signal); } } @@ -242,7 +227,7 @@ export class GlobalSignalProcessor { this.rejectCallRequest(uid, { ...rejection, reason: 'already-requested' }); } - this.sendSignal(uid, buildNewCallSignal(call, 'caller')); + this.sendSignal(uid, getNewCallSignal(call, 'caller')); return call; } diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index 4a8623ac65776..aa15b915d079d 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -6,8 +6,10 @@ import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { UserActorSignalProcessor } from './CallSignalProcessor'; import { BaseMediaCallAgent } from '../../base/BaseAgent'; import { logger } from '../../logger'; -import { buildNewCallSignal } from '../../server/buildNewCallSignal'; import { getMediaCallServer } from '../../server/injection'; +import { getInitialOfferSignal } from '../../server/signals/getInitialOfferSignal'; +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 { @@ -21,32 +23,24 @@ export class UserActorAgent extends BaseMediaCallAgent { getMediaCallServer().sendSignal(this.actorId, signal); } - public async onCallAccepted(callId: string, data: { signedContractId: string; features: CallFeature[] }): Promise { - await this.sendSignal({ - callId, - type: 'notification', - notification: 'accepted', - ...data, - }); + public async onCallAccepted(call: IMediaCall): Promise { + const stateSignal = getStateNotification(call, this.role); + if (stateSignal?.notification !== 'accepted') { + return; + } + + await this.sendSignal(stateSignal); if (this.role !== 'callee') { return; } - const negotiation = await MediaCallNegotiations.findLatestByCallId(callId); - if (!negotiation?.offer) { + const initialOfferSignal = await getInitialOfferSignal(call, this.role); + if (!initialOfferSignal) { logger.debug('The call was accepted but the webrtc offer is not yet available.'); return; } - - await this.sendSignal({ - callId, - toContractId: data.signedContractId, - type: 'remote-sdp', - sdp: negotiation.offer, - negotiationId: negotiation._id, - streams: negotiation.offerStreams, - }); + await this.sendSignal(initialOfferSignal); } public async onCallEnded(callId: string): Promise { @@ -71,7 +65,7 @@ export class UserActorAgent extends BaseMediaCallAgent { await this.getOrCreateChannel(call, call.caller.contractId); } - await this.sendSignal(buildNewCallSignal(call, this.role)); + await this.sendSignal(getNewCallSignal(call, this.role)); } public async onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise { diff --git a/ee/packages/media-calls/src/server/BroadcastAgent.ts b/ee/packages/media-calls/src/server/BroadcastAgent.ts index 5b76704dd799c..bd10a224ad484 100644 --- a/ee/packages/media-calls/src/server/BroadcastAgent.ts +++ b/ee/packages/media-calls/src/server/BroadcastAgent.ts @@ -1,5 +1,5 @@ import type { IMediaCall } from '@rocket.chat/core-typings'; -import type { CallFeature, ClientMediaSignalBody } from '@rocket.chat/media-signaling'; +import type { ClientMediaSignalBody } from '@rocket.chat/media-signaling'; import { BaseMediaCallAgent } from '../base/BaseAgent'; import { logger } from '../logger'; @@ -14,8 +14,8 @@ import type { BaseCallProvider } from '../base/BaseCallProvider'; export class BroadcastActorAgent extends BaseMediaCallAgent { public provider: BaseCallProvider | null = null; - public async onCallAccepted(callId: string, _data: { signedContractId: string; features: CallFeature[] }): Promise { - this.reportCallUpdated({ callId }); + public async onCallAccepted(call: IMediaCall): Promise { + this.reportCallUpdated({ callId: call._id }); } public async onCallEnded(callId: string): Promise { diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index a3f63267650b1..2fbe07d8e0ab2 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -11,7 +11,6 @@ import type { InsertionModel } from '@rocket.chat/model-typings'; import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { getCastDirector, getMediaCallServer } from './injection'; -import { DEFAULT_CALL_FEATURES } from '../constants'; import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; import type { IMediaCallCastDirector } from '../definition/IMediaCallCastDirector'; import type { InternalCallParams, MediaCallHeader } from '../definition/common'; @@ -79,11 +78,14 @@ class MediaCallDirector { logger.info({ msg: 'Call was flagged as accepted', callId: call._id }); this.scheduleExpirationCheckByCallId(call._id); - const updatedCall = await MediaCalls.findOneById(call._id, { projection: { features: 1 } }); - const features = (updatedCall?.features || DEFAULT_CALL_FEATURES) as CallFeature[]; + const updatedCall = await MediaCalls.findOneById(call._id); + if (!updatedCall) { + logger.error({ msg: 'Unable to find up to date call data', callId: call._id }); + return false; + } - await calleeAgent.onCallAccepted(call._id, { signedContractId: data.calleeContractId, features }); - await calleeAgent.oppositeAgent?.onCallAccepted(call._id, { signedContractId: call.caller.contractId, features }); + await calleeAgent.onCallAccepted(updatedCall); + await calleeAgent.oppositeAgent?.onCallAccepted(updatedCall); if (data.webrtcAnswer && negotiation) { const negotiationResult = await MediaCallNegotiations.setAnswerById(negotiation._id, data.webrtcAnswer); diff --git a/ee/packages/media-calls/src/server/getCallRoleForUser.ts b/ee/packages/media-calls/src/server/getCallRoleForUser.ts new file mode 100644 index 0000000000000..7486246329478 --- /dev/null +++ b/ee/packages/media-calls/src/server/getCallRoleForUser.ts @@ -0,0 +1,13 @@ +import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; +import type { CallRole } from '@rocket.chat/media-signaling'; + +export function getCallRoleForUser(call: IMediaCall, uid: IUser['_id']): CallRole | null { + if (call.caller.type === 'user' && call.caller.id === uid) { + return 'caller'; + } + if (call.callee.type === 'user' && call.callee.id === uid) { + return 'callee'; + } + + return null; +} diff --git a/ee/packages/media-calls/src/server/signals/getInitialOfferSignal.ts b/ee/packages/media-calls/src/server/signals/getInitialOfferSignal.ts new file mode 100644 index 0000000000000..8a7700fe2c156 --- /dev/null +++ b/ee/packages/media-calls/src/server/signals/getInitialOfferSignal.ts @@ -0,0 +1,29 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; +import type { CallRole, ServerMediaSignalRemoteSDP } from '@rocket.chat/media-signaling'; +import { MediaCallNegotiations } from '@rocket.chat/models'; + +export async function getInitialOfferSignal(call: IMediaCall, role: CallRole): Promise { + // Since the initial offer is always provided by the caller, they don't need to receive it from the server + if (role !== 'callee') { + return null; + } + + const { [role]: actor } = call; + if (!actor.contractId) { + return null; + } + + const negotiation = await MediaCallNegotiations.findLatestByCallId(call._id); + if (!negotiation?.offer) { + return null; + } + + return { + callId: call._id, + toContractId: actor.contractId, + type: 'remote-sdp', + sdp: negotiation.offer, + negotiationId: negotiation._id, + streams: negotiation.offerStreams, + }; +} diff --git a/ee/packages/media-calls/src/server/buildNewCallSignal.ts b/ee/packages/media-calls/src/server/signals/getNewCallSignal.ts similarity index 92% rename from ee/packages/media-calls/src/server/buildNewCallSignal.ts rename to ee/packages/media-calls/src/server/signals/getNewCallSignal.ts index 5c331196e0e2a..ad45c84234854 100644 --- a/ee/packages/media-calls/src/server/buildNewCallSignal.ts +++ b/ee/packages/media-calls/src/server/signals/getNewCallSignal.ts @@ -20,7 +20,7 @@ function getCallFlags(call: IMediaCall, role: CallRole): CallFlag[] { return flags; } -export function buildNewCallSignal(call: IMediaCall, role: CallRole): ServerMediaSignalNewCall { +export function getNewCallSignal(call: IMediaCall, role: CallRole): ServerMediaSignalNewCall { const self = role === 'caller' ? call.caller : call.callee; const contact = role === 'caller' ? call.callee : call.caller; const transferredBy = getNewCallTransferredBy(call); diff --git a/ee/packages/media-calls/src/server/getNewCallTransferredBy.ts b/ee/packages/media-calls/src/server/signals/getNewCallTransferredBy.ts similarity index 100% rename from ee/packages/media-calls/src/server/getNewCallTransferredBy.ts rename to ee/packages/media-calls/src/server/signals/getNewCallTransferredBy.ts diff --git a/ee/packages/media-calls/src/server/signals/getSignalsForExistingCall.ts b/ee/packages/media-calls/src/server/signals/getSignalsForExistingCall.ts new file mode 100644 index 0000000000000..c55f4441db9cb --- /dev/null +++ b/ee/packages/media-calls/src/server/signals/getSignalsForExistingCall.ts @@ -0,0 +1,35 @@ +import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; +import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; + +import { getNewCallSignal } from './getNewCallSignal'; +import { getCallRoleForUser } from '../getCallRoleForUser'; +import { getInitialOfferSignal } from './getInitialOfferSignal'; +import { getStateNotification } from './getStateNotification'; + +export async function getSignalsForExistingCall(call: IMediaCall, uid: IUser['_id'], contractId: string): Promise { + if (call.state === 'hangup') { + return []; + } + + const role = getCallRoleForUser(call, uid); + if (!role) { + return []; + } + + const signals: ServerMediaSignal[] = []; + signals.push(getNewCallSignal(call, role)); + + const stateSignal = getStateNotification(call, role); + if (stateSignal) { + signals.push(stateSignal); + } + + if (role === 'callee' && call.callee.contractId === contractId) { + const initialOfferSignal = await getInitialOfferSignal(call, role); + if (initialOfferSignal) { + signals.push(initialOfferSignal); + } + } + + return signals; +} diff --git a/ee/packages/media-calls/src/server/signals/getStateNotification.ts b/ee/packages/media-calls/src/server/signals/getStateNotification.ts new file mode 100644 index 0000000000000..6a0f97ef9ad76 --- /dev/null +++ b/ee/packages/media-calls/src/server/signals/getStateNotification.ts @@ -0,0 +1,36 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; +import { isPendingState } from '@rocket.chat/media-signaling'; +import type { CallFeature, CallNotification, CallRole, ServerMediaSignalNotification } from '@rocket.chat/media-signaling'; + +function getStateForNotification(call: IMediaCall): CallNotification | null { + if (call.ended || call.state === 'hangup') { + return 'hangup'; + } + + if (call.state === 'active') { + return 'active'; + } + + if (isPendingState(call.state) || !call.callee.contractId) { + return null; + } + + return 'accepted'; +} + +export function getStateNotification(call: IMediaCall, role: CallRole): ServerMediaSignalNotification | null { + const state = getStateForNotification(call); + if (!state) { + return null; + } + + const actor = call[role]; + + return { + callId: call._id, + type: 'notification', + notification: state, + ...(actor.contractId && { signedContractId: actor.contractId }), + features: call.features as CallFeature[], + }; +} diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index a00e76a034d82..3f92b66ee9d52 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -1,8 +1,9 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import type { ClientMediaSignal } from '@rocket.chat/media-signaling'; +import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; +import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; export interface IMediaCallService { processSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): Promise; processSerializedSignal(fromUid: IUser['_id'], signal: string): Promise; hangupExpiredCalls(): Promise; + getUserState(uid: IUser['_id'], contractId: string): Promise<{ calls: IMediaCall[]; signals: ServerMediaSignal[] }>; } From bbd6e94c0780c3fdaa18eddb484badaf2bfc0a4f Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 9 Apr 2026 13:49:46 -0300 Subject: [PATCH 08/15] split the endpoint in two --- apps/meteor/app/api/server/v1/media-calls.ts | 138 +++++++++++------- .../server/services/media-call/service.ts | 7 +- .../src/types/IMediaCallService.ts | 4 +- 3 files changed, 89 insertions(+), 60 deletions(-) diff --git a/apps/meteor/app/api/server/v1/media-calls.ts b/apps/meteor/app/api/server/v1/media-calls.ts index adfe3d20c47af..ad43b03993b3a 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 { ServerMediaSignal } from '@rocket.chat/media-signaling'; +import { MediaCalls } from '@rocket.chat/models'; import { ajv, validateNotFoundErrorResponse, @@ -13,11 +14,11 @@ import type { JSONSchemaType } from 'ajv'; import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; -type MediaCallsStateParams = { +type MediaCallsStateSignalsParams = { contractId: string; }; -const MediaCallsStateSchema: JSONSchemaType = { +const MediaCallsStateSignalsSchema: JSONSchemaType = { type: 'object', properties: { contractId: { @@ -28,65 +29,96 @@ const MediaCallsStateSchema: JSONSchemaType = { additionalProperties: false, }; -export const isMediaCallsStateProps = ajv.compile(MediaCallsStateSchema); +export const isMediaCallsStateSignalsProps = ajv.compile(MediaCallsStateSignalsSchema); -const mediaCallsStateEndpoints = API.v1.get( - 'media-calls.state', - { - response: { - 200: ajv.compile<{ - calls: IMediaCall[]; - signals: ServerMediaSignal[]; - }>({ - additionalProperties: false, - type: 'object', - properties: { - calls: { - type: 'array', - items: { - type: 'object', - $ref: '#/components/schemas/IMediaCall', +const mediaCallsStateEndpoints = API.v1 + .get( + 'media-calls.state', + { + response: { + 200: ajv.compile<{ + calls: IMediaCall[]; + }>({ + additionalProperties: false, + type: 'object', + properties: { + calls: { + type: 'array', + items: { + type: 'object', + $ref: '#/components/schemas/IMediaCall', + }, + description: 'The list of active calls.', + }, + success: { + type: 'boolean', + description: 'Indicates the request was successful.', }, - description: 'The list of active calls.', }, - signals: { - type: 'array', - items: { - type: 'object', - properties: { - type: { type: 'string' }, - callId: { type: 'string' }, + required: ['calls', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + authRequired: true, + }, + async function action() { + const calls = await MediaCalls.findAllNotOverByUid(this.userId).toArray(); + + return API.v1.success({ + calls, + }); + }, + ) + .get( + 'media-calls.stateSignals', + { + response: { + 200: ajv.compile<{ + signals: ServerMediaSignal[]; + }>({ + additionalProperties: false, + type: 'object', + properties: { + signals: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string' }, + callId: { type: 'string' }, + }, + required: ['callId', 'type'], + additionalProperties: true, }, - required: ['callId', 'type'], - additionalProperties: true, + description: 'The list of signals that were already sent for the active calls.', + }, + success: { + type: 'boolean', + description: 'Indicates the request was successful.', }, - description: 'The list of signals that were already sent for the active calls.', - }, - success: { - type: 'boolean', - description: 'Indicates the request was successful.', }, - }, - required: ['calls', 'signals', 'success'], - }), - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 403: validateForbiddenErrorResponse, - 404: validateNotFoundErrorResponse, + required: ['signals', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + query: isMediaCallsStateSignalsProps, + authRequired: true, }, - query: isMediaCallsStateProps, - authRequired: true, - }, - async function action() { - const { contractId } = this.queryParams; - const { calls, signals } = await MediaCall.getUserState(this.userId, contractId); + async function action() { + const { contractId } = this.queryParams; + const signals = await MediaCall.getUserStateSignals(this.userId, contractId); - return API.v1.success({ - calls, - signals, - }); - }, -); + return API.v1.success({ + signals, + }); + }, + ); type MediaCallsStateEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index b5c42dfa387d2..f87ddc8fe56d6 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -72,7 +72,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } } - public async getUserState(uid: IUser['_id'], contractId: string): Promise<{ calls: IMediaCall[]; signals: ServerMediaSignal[] }> { + public async getUserStateSignals(uid: IUser['_id'], contractId: string): Promise { const calls = await MediaCalls.findAllNotOverByUid(uid).toArray(); const signals: ServerMediaSignal[] = []; @@ -81,10 +81,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall signals.push(...callSignals); } - return { - calls, - signals, - }; + return signals; } private async saveCallToHistory(callId: IMediaCall['_id']): Promise { diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index 3f92b66ee9d52..74210a5514ff1 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -1,9 +1,9 @@ -import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; +import type { IUser } from '@rocket.chat/core-typings'; import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; export interface IMediaCallService { processSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): Promise; processSerializedSignal(fromUid: IUser['_id'], signal: string): Promise; hangupExpiredCalls(): Promise; - getUserState(uid: IUser['_id'], contractId: string): Promise<{ calls: IMediaCall[]; signals: ServerMediaSignal[] }>; + getUserStateSignals(uid: IUser['_id'], contractId: string): Promise; } From 821d79e0babf846fa93d227c85c2ba91e5a44a99 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 9 Apr 2026 13:50:21 -0300 Subject: [PATCH 09/15] add param to not load initial state signals via websocket --- ee/packages/media-calls/src/internal/SignalProcessor.ts | 5 +++++ .../src/definition/signals/client/register.ts | 6 ++++++ packages/media-signaling/src/lib/Session.ts | 6 ++++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index 1a5e3252c9e09..1e1f7b8fc12a4 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -145,6 +145,11 @@ export class GlobalSignalProcessor { await mediaCallDirector.renewCallId(call._id); } + const needsSignals = signal.requestSignals ?? true; + if (!needsSignals) { + return; + } + const signals = await getSignalsForExistingCall(call, uid, signal.contractId); for (const signal of signals) { diff --git a/packages/media-signaling/src/definition/signals/client/register.ts b/packages/media-signaling/src/definition/signals/client/register.ts index 003c151815786..7c69c503a1156 100644 --- a/packages/media-signaling/src/definition/signals/client/register.ts +++ b/packages/media-signaling/src/definition/signals/client/register.ts @@ -6,6 +6,8 @@ export type ClientMediaSignalRegister = { contractId: string; oldContractId?: string; + // Unless explicitly false, signals for existing calls will be re-sent to the client + requestSignals?: boolean; }; export const clientMediaSignalRegisterSchema: JSONSchemaType = { @@ -24,6 +26,10 @@ export const clientMediaSignalRegisterSchema: JSONSchemaType { this.transporter = new MediaSignalTransportWrapper(this._sessionId, config.transport, config.logger); - this.register(); + this.register(config.requestInitialStateSignals ?? true); this.enableStateReport(STATE_REPORT_INTERVAL); } @@ -228,12 +229,13 @@ export class MediaSignalingSession extends Emitter { await call.requestCall({ type: calleeType, id: calleeId }, this.config.features, contactInfo); } - public register(): void { + public register(requestSignals = true): void { this.lastRegisterTimestamp = new Date(); this.transporter.sendSignal({ type: 'register', contractId: this._sessionId, + requestSignals, ...(this.config.oldSessionId && { oldContractId: this.config.oldSessionId }), }); } From 6c22bb452e47c2ce3b039784bf38ab284e975183 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 9 Apr 2026 13:51:16 -0300 Subject: [PATCH 10/15] ensure that potentially repeated signals won't revert the call state --- packages/media-signaling/src/lib/Call.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index f33adb523fb1f..b081ea3cd6cfc 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -893,8 +893,27 @@ export class ClientMediaCall implements IClientMediaCall { return this._flags.includes(flag); } - private changeState(newState: CallState): void { + private canChangeToState(newState: CallState): boolean { if (newState === this._state) { + return false; + } + + if (this._state === 'hangup') { + return false; + } + + switch (newState) { + case 'accepted': + return this.isPendingAcceptance(); + case 'active': + return this._state === 'accepted' || this.hidden; + } + + return true; + } + + private changeState(newState: CallState): void { + if (!this.canChangeToState(newState)) { return; } From 181a3ca872e74806ea73c29b33ed07369152937c Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Fri, 10 Apr 2026 19:02:51 -0300 Subject: [PATCH 11/15] imported registration changes from the voip push PR --- .../src/internal/SignalProcessor.ts | 16 +++- .../src/definition/signals/client/register.ts | 2 +- .../definition/signals/server/MediaSignal.ts | 7 +- .../src/definition/signals/server/index.ts | 1 + .../definition/signals/server/registered.ts | 10 ++ packages/media-signaling/src/lib/Session.ts | 92 +++++++++++++++---- .../src/lib/components/SessionRegistration.ts | 81 ++++++++++++++++ .../src/providers/useMediaSessionInstance.ts | 1 + 8 files changed, 189 insertions(+), 21 deletions(-) create mode 100644 packages/media-signaling/src/definition/signals/server/registered.ts create mode 100644 packages/media-signaling/src/lib/components/SessionRegistration.ts diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index 1e1f7b8fc12a4..eb7abae405eac 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -116,6 +116,19 @@ export class GlobalSignalProcessor { logger.debug({ msg: 'GlobalSignalProcessor.processRegisterSignal', signal: stripSensitiveDataFromSignal(signal), uid }); const calls = await MediaCalls.findAllNotOverByUid(uid).toArray(); + const activeCalls = calls.filter( + ({ callee, caller }) => + (callee.type === 'user' && callee.id === uid && callee.contractId === signal.contractId) || + (caller.type === 'user' && caller.id === uid && caller.contractId === signal.contractId), + ); + + this.sendSignal(uid, { + type: 'registered', + toContractId: signal.contractId, + calls: calls.map(({ _id }) => _id), + activeCalls: activeCalls.map(({ _id }) => _id), + }); + if (!calls.length) { return; } @@ -145,8 +158,7 @@ export class GlobalSignalProcessor { await mediaCallDirector.renewCallId(call._id); } - const needsSignals = signal.requestSignals ?? true; - if (!needsSignals) { + if (!signal.requestSignals) { return; } diff --git a/packages/media-signaling/src/definition/signals/client/register.ts b/packages/media-signaling/src/definition/signals/client/register.ts index 7c69c503a1156..feddc5c6ab1c0 100644 --- a/packages/media-signaling/src/definition/signals/client/register.ts +++ b/packages/media-signaling/src/definition/signals/client/register.ts @@ -6,7 +6,7 @@ export type ClientMediaSignalRegister = { contractId: string; oldContractId?: string; - // Unless explicitly false, signals for existing calls will be re-sent to the client + // If true, signals for existing calls will be re-sent to the client requestSignals?: boolean; }; diff --git a/packages/media-signaling/src/definition/signals/server/MediaSignal.ts b/packages/media-signaling/src/definition/signals/server/MediaSignal.ts index b75e5fb04a808..d3e6247a4234b 100644 --- a/packages/media-signaling/src/definition/signals/server/MediaSignal.ts +++ b/packages/media-signaling/src/definition/signals/server/MediaSignal.ts @@ -1,14 +1,19 @@ import type { ServerMediaSignalNewCall } from './new'; import type { ServerMediaSignalNotification } from './notification'; +import type { ServerMediaSignalRegistered } from './registered'; import type { ServerMediaSignalRejectedCallRequest } from './rejected-call-request'; import type { ServerMediaSignalRemoteSDP } from './remote-sdp'; import type { ServerMediaSignalRequestOffer } from './request-offer'; -export type ServerMediaSignal = +export type ServerMediaCallSignal = | ServerMediaSignalNewCall | ServerMediaSignalRemoteSDP | ServerMediaSignalRequestOffer | ServerMediaSignalNotification | ServerMediaSignalRejectedCallRequest; +export type ServerMediaSessionSignal = ServerMediaSignalRegistered; + +export type ServerMediaSignal = ServerMediaCallSignal | ServerMediaSessionSignal; + export type ServerMediaSignalType = ServerMediaSignal['type']; diff --git a/packages/media-signaling/src/definition/signals/server/index.ts b/packages/media-signaling/src/definition/signals/server/index.ts index e077408e20142..55343c5b6154f 100644 --- a/packages/media-signaling/src/definition/signals/server/index.ts +++ b/packages/media-signaling/src/definition/signals/server/index.ts @@ -1,5 +1,6 @@ export type * from './new'; export type * from './notification'; +export type * from './registered'; export type * from './rejected-call-request'; export type * from './remote-sdp'; export type * from './request-offer'; diff --git a/packages/media-signaling/src/definition/signals/server/registered.ts b/packages/media-signaling/src/definition/signals/server/registered.ts new file mode 100644 index 0000000000000..8c9d89ec69ad7 --- /dev/null +++ b/packages/media-signaling/src/definition/signals/server/registered.ts @@ -0,0 +1,10 @@ +/** Server is notifying the client that its registration was processed */ +export type ServerMediaSignalRegistered = { + type: 'registered'; + + toContractId: string; + + calls: string[]; + + activeCalls: string[]; +}; diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 38f685e09a790..ec0804a8fa118 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -8,10 +8,14 @@ import type { MediaSignalTransport, MediaStreamFactory, RandomStringFactory, + ServerMediaCallSignal, + ServerMediaSessionSignal, ServerMediaSignal, + ServerMediaSignalRegistered, } from '../definition'; import type { IClientMediaCall, CallActorType, CallContact, CallFeature, AnyMediaCallData } from '../definition/call'; import type { IMediaSignalLogger } from '../definition/logger'; +import { SessionRegistration } from './components/SessionRegistration'; export type MediaSignalingEvents = { sessionStateChange: void; @@ -19,6 +23,8 @@ export type MediaSignalingEvents = { acceptedCall: { call: IClientMediaCall }; endedCall: void; hiddenCall: void; + registered: { activeCalls: IClientMediaCall['callId'][] }; + outOfSync: { missingCalls: IClientMediaCall['callId'][] }; }; export type MediaSignalingSessionConfig = { @@ -34,7 +40,7 @@ export type MediaSignalingSessionConfig = { iceGatheringTimeout?: number; iceServers?: RTCIceServer[]; features: CallFeature[]; - requestInitialStateSignals?: boolean; + autoSync?: boolean; }; const STATE_REPORT_INTERVAL = 60000; @@ -66,6 +72,8 @@ export class MediaSignalingSession extends Emitter { private lastState: { hasCall: boolean; hasVisibleCall: boolean; hasBusyCall: boolean }; + private registration: SessionRegistration; + public get sessionId(): string { return this._sessionId; } @@ -74,6 +82,10 @@ export class MediaSignalingSession extends Emitter { return this._userId; } + public get registered(): boolean { + return this.registration.registered; + } + constructor(private config: MediaSignalingSessionConfig) { super(); this._userId = config.userId; @@ -89,8 +101,12 @@ export class MediaSignalingSession extends Emitter { this.lastState = { hasCall: false, hasVisibleCall: false, hasBusyCall: false }; this.transporter = new MediaSignalTransportWrapper(this._sessionId, config.transport, config.logger); + this.registration = new SessionRegistration({ + logger: config.logger, + registerFn: () => this.sendRegisterSignal(), + }); - this.register(config.requestInitialStateSignals ?? true); + this.registration.register(); this.enableStateReport(STATE_REPORT_INTERVAL); } @@ -114,6 +130,7 @@ export class MediaSignalingSession extends Emitter { } public endSession(): void { + this.registration.endSession(); this.disableStateReport(); // best‑effort: stop capturing audio @@ -127,6 +144,7 @@ export class MediaSignalingSession extends Emitter { } this.knownCalls.clear(); + this.emit('sessionStateChange'); } public getCallData(callId: string): IClientMediaCall | null { @@ -176,7 +194,21 @@ export class MediaSignalingSession extends Emitter { public async processSignal(signal: ServerMediaSignal): Promise { this.config.logger?.debug('MediaSignalingSession.processSignal', signal); + if ('callId' in signal) { + return this.processCallSignal(signal); + } + + return this.processSessionSignal(signal); + } + + private processSessionSignal(signal: ServerMediaSessionSignal): void { + switch (signal.type) { + case 'registered': + return this.confirmSessionRegistered(signal); + } + } + private async processCallSignal(signal: ServerMediaCallSignal): Promise { if (this.isCallIgnored(signal.callId)) { return; } @@ -229,17 +261,6 @@ export class MediaSignalingSession extends Emitter { await call.requestCall({ type: calleeType, id: calleeId }, this.config.features, contactInfo); } - public register(requestSignals = true): void { - this.lastRegisterTimestamp = new Date(); - - this.transporter.sendSignal({ - type: 'register', - contractId: this._sessionId, - requestSignals, - ...(this.config.oldSessionId && { oldContractId: this.config.oldSessionId }), - }); - } - public setIceGatheringTimeout(newTimeout: number): void { this.config.iceGatheringTimeout = newTimeout; } @@ -268,7 +289,36 @@ export class MediaSignalingSession extends Emitter { } } - private getExistingCallBySignal(signal: ServerMediaSignal): ClientMediaCall | null { + private sendRegisterSignal(): void { + this.lastRegisterTimestamp = new Date(); + this.transporter.sendSignal({ + type: 'register', + contractId: this._sessionId, + requestSignals: Boolean(this.config.autoSync), + ...(this.config.oldSessionId && { oldContractId: this.config.oldSessionId }), + }); + } + + private confirmSessionRegistered(signal: ServerMediaSignalRegistered): void { + this.config.logger?.debug('MediaSignalingSession.sessionRegistered', signal.calls); + const wasRegistered = this.registered; + this.registration.confirmRegistration(); + + this.emit('registered', { activeCalls: signal.activeCalls }); + + if (!wasRegistered) { + this.onSessionStateChange(); + } + + if (!this.config.autoSync) { + const missingCalls = signal.calls.filter((callId) => !this.knownCalls.has(callId) && !this.ignoredCalls.has(callId)); + if (missingCalls.length) { + this.emit('outOfSync', { missingCalls }); + } + } + } + + private getExistingCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall | null { const existingCall = this.knownCalls.get(signal.callId); if (existingCall) { return existingCall; @@ -286,7 +336,7 @@ export class MediaSignalingSession extends Emitter { return null; } - private getReplacedCallBySignal(signal: ServerMediaSignal): ClientMediaCall | null { + private getReplacedCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall | null { if ('replacingCallId' in signal && signal.replacingCallId) { return this.knownCalls.get(signal.replacingCallId) || null; } @@ -294,7 +344,7 @@ export class MediaSignalingSession extends Emitter { return null; } - private getOrCreateCallBySignal(signal: ServerMediaSignal): ClientMediaCall { + private getOrCreateCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall { this.config.logger?.debug('MediaSignalingSession.getOrCreateCallBySignal', signal); const existingCall = this.getExistingCallBySignal(signal); if (existingCall) { @@ -344,7 +394,7 @@ export class MediaSignalingSession extends Emitter { } } - this.register(); + this.registration.reRegister(); } private async setInputTrack(newInputTrack: MediaStreamTrack | null): Promise { @@ -646,6 +696,14 @@ export class MediaSignalingSession extends Emitter { const hadVisibleCall = this.lastState.hasVisibleCall; const hadBusyCall = this.lastState.hasBusyCall; + if (!this.registration.active) { + if (hadCall) { + this.emit('endedCall'); + } + 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); diff --git a/packages/media-signaling/src/lib/components/SessionRegistration.ts b/packages/media-signaling/src/lib/components/SessionRegistration.ts new file mode 100644 index 0000000000000..c371f644bbdb2 --- /dev/null +++ b/packages/media-signaling/src/lib/components/SessionRegistration.ts @@ -0,0 +1,81 @@ +import type { IMediaSignalLogger } from '../../definition'; + +const REGISTER_CONFIRMATION_TIMEOUT = 1000; +const MAX_REGISTER_ATTEMPTS = 10; + +type SessionRegistrationConfig = { + logger?: IMediaSignalLogger; + registerFn: () => void; +}; + +export class SessionRegistration { + public get registered(): boolean { + return this.registrationConfirmed; + } + + public get active(): boolean { + return this.registered && !this.sessionEnded; + } + + private sessionEnded = false; + + private registrationConfirmed = false; + + private registerConfirmationHandler: ReturnType | null = null; + + constructor(private config: SessionRegistrationConfig) { + // + } + + public register(): void { + if (this.registerConfirmationHandler) { + return; + } + + this.registerAttempt(1); + } + + public reRegister(): void { + if (this.sessionEnded) { + return; + } + + this.config.logger?.debug('SessionRegistration.reRegister'); + this.clearRegisterConfirmationHandler(); + this.register(); + } + + public confirmRegistration(): void { + this.registrationConfirmed = true; + + this.clearRegisterConfirmationHandler(); + } + + public endSession(): void { + this.sessionEnded = true; + } + + private clearRegisterConfirmationHandler(): void { + if (this.registerConfirmationHandler) { + clearTimeout(this.registerConfirmationHandler); + this.registerConfirmationHandler = null; + } + } + + private registerAttempt(attempt: number): void { + if (this.sessionEnded) { + return; + } + this.config.logger?.debug('SessionRegistration.registerAttempt', attempt); + const timeout = attempt * REGISTER_CONFIRMATION_TIMEOUT; + + this.registerConfirmationHandler = setTimeout(() => { + this.registerConfirmationHandler = null; + if (attempt < MAX_REGISTER_ATTEMPTS) { + this.registerAttempt(attempt + 1); + } + }, timeout); + + this.config.registerFn(); + } +} diff --git a/packages/ui-voip/src/providers/useMediaSessionInstance.ts b/packages/ui-voip/src/providers/useMediaSessionInstance.ts index 63524deb4881f..fbd55eb3056dc 100644 --- a/packages/ui-voip/src/providers/useMediaSessionInstance.ts +++ b/packages/ui-voip/src/providers/useMediaSessionInstance.ts @@ -102,6 +102,7 @@ class MediaSessionStore extends Emitter<{ change: void }> { oldSessionId: this.getOldSessionId(userId), logger: new MediaCallLogger(), features: ['audio', 'screen-share', 'transfer', 'hold'], + autoSync: true, }); if (window.sessionStorage) { From ecd12e7392a38ce8ad723609277f6bde485642cb Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 13 Apr 2026 10:49:54 -0300 Subject: [PATCH 12/15] add changeset --- .changeset/shiny-berries-check.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/shiny-berries-check.md diff --git a/.changeset/shiny-berries-check.md b/.changeset/shiny-berries-check.md new file mode 100644 index 0000000000000..4073ccdfc12c4 --- /dev/null +++ b/.changeset/shiny-berries-check.md @@ -0,0 +1,10 @@ +--- +'@rocket.chat/media-signaling': minor +'@rocket.chat/media-calls': minor +'@rocket.chat/core-services': minor +'@rocket.chat/apps-engine': minor +'@rocket.chat/ui-voip': minor +'@rocket.chat/meteor': minor +--- + +Adds new API endpoints to load the user's current voice call state from the server From d9bb207b31556401988eea58b8359314fceca232 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 13 Apr 2026 11:39:10 -0300 Subject: [PATCH 13/15] applying changes from code review --- apps/meteor/app/api/server/v1/media-calls.ts | 4 ++-- apps/meteor/server/services/media-call/service.ts | 7 ++++--- .../src/server/signals/getInitialOfferSignal.ts | 7 +------ packages/core-services/src/types/IMediaCallService.ts | 4 ++-- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/apps/meteor/app/api/server/v1/media-calls.ts b/apps/meteor/app/api/server/v1/media-calls.ts index ad43b03993b3a..16d5194aa0996 100644 --- a/apps/meteor/app/api/server/v1/media-calls.ts +++ b/apps/meteor/app/api/server/v1/media-calls.ts @@ -1,6 +1,6 @@ import { MediaCall } from '@rocket.chat/core-services'; import type { IMediaCall } from '@rocket.chat/core-typings'; -import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; +import type { ServerMediaCallSignal } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; import { ajv, @@ -77,7 +77,7 @@ const mediaCallsStateEndpoints = API.v1 { response: { 200: ajv.compile<{ - signals: ServerMediaSignal[]; + signals: ServerMediaCallSignal[]; }>({ additionalProperties: false, type: 'object', diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index f87ddc8fe56d6..5a6c05bf9fe73 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -9,7 +9,8 @@ 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, isClientMediaSignal, type ClientMediaSignal, type ServerMediaSignal } from '@rocket.chat/media-signaling'; +import type { CallFeature, ClientMediaSignal, ServerMediaSignal, ServerMediaCallSignal } 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 { callStateToTranslationKey, getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload'; @@ -72,10 +73,10 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } } - public async getUserStateSignals(uid: IUser['_id'], contractId: string): Promise { + public async getUserStateSignals(uid: IUser['_id'], contractId: string): Promise { const calls = await MediaCalls.findAllNotOverByUid(uid).toArray(); - const signals: ServerMediaSignal[] = []; + const signals: ServerMediaCallSignal[] = []; for (const call of calls) { const callSignals = await getSignalsForExistingCall(call, uid, contractId); signals.push(...callSignals); diff --git a/ee/packages/media-calls/src/server/signals/getInitialOfferSignal.ts b/ee/packages/media-calls/src/server/signals/getInitialOfferSignal.ts index 8a7700fe2c156..591a78dbb5832 100644 --- a/ee/packages/media-calls/src/server/signals/getInitialOfferSignal.ts +++ b/ee/packages/media-calls/src/server/signals/getInitialOfferSignal.ts @@ -3,18 +3,13 @@ import type { CallRole, ServerMediaSignalRemoteSDP } from '@rocket.chat/media-si import { MediaCallNegotiations } from '@rocket.chat/models'; export async function getInitialOfferSignal(call: IMediaCall, role: CallRole): Promise { - // Since the initial offer is always provided by the caller, they don't need to receive it from the server - if (role !== 'callee') { - return null; - } - const { [role]: actor } = call; if (!actor.contractId) { return null; } const negotiation = await MediaCallNegotiations.findLatestByCallId(call._id); - if (!negotiation?.offer) { + if (!negotiation?.offer || negotiation.offerer === role) { return null; } diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index 74210a5514ff1..be00d078a374a 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -1,9 +1,9 @@ import type { IUser } from '@rocket.chat/core-typings'; -import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; +import type { ClientMediaSignal, ServerMediaCallSignal } from '@rocket.chat/media-signaling'; export interface IMediaCallService { processSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): Promise; processSerializedSignal(fromUid: IUser['_id'], signal: string): Promise; hangupExpiredCalls(): Promise; - getUserStateSignals(uid: IUser['_id'], contractId: string): Promise; + getUserStateSignals(uid: IUser['_id'], contractId: string): Promise; } From e977ad2cf4c81e1ed76d601b8bcb1064586e6c8b Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 13 Apr 2026 12:13:36 -0300 Subject: [PATCH 14/15] fix type --- .../src/server/signals/getSignalsForExistingCall.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ee/packages/media-calls/src/server/signals/getSignalsForExistingCall.ts b/ee/packages/media-calls/src/server/signals/getSignalsForExistingCall.ts index c55f4441db9cb..d14932783cc53 100644 --- a/ee/packages/media-calls/src/server/signals/getSignalsForExistingCall.ts +++ b/ee/packages/media-calls/src/server/signals/getSignalsForExistingCall.ts @@ -1,12 +1,12 @@ import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; -import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; +import type { ServerMediaCallSignal } from '@rocket.chat/media-signaling'; import { getNewCallSignal } from './getNewCallSignal'; import { getCallRoleForUser } from '../getCallRoleForUser'; import { getInitialOfferSignal } from './getInitialOfferSignal'; import { getStateNotification } from './getStateNotification'; -export async function getSignalsForExistingCall(call: IMediaCall, uid: IUser['_id'], contractId: string): Promise { +export async function getSignalsForExistingCall(call: IMediaCall, uid: IUser['_id'], contractId: string): Promise { if (call.state === 'hangup') { return []; } @@ -16,7 +16,7 @@ export async function getSignalsForExistingCall(call: IMediaCall, uid: IUser['_i return []; } - const signals: ServerMediaSignal[] = []; + const signals: ServerMediaCallSignal[] = []; signals.push(getNewCallSignal(call, role)); const stateSignal = getStateNotification(call, role); From c8d2aa78716e6de523a0f11126a49356dbab2ca5 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 14 Apr 2026 13:21:30 -0300 Subject: [PATCH 15/15] missing param --- ee/packages/media-calls/src/internal/SignalProcessor.ts | 2 +- .../media-calls/src/internal/agents/UserActorAgent.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index e1cfb28dc2a4e..d247f7236a1a3 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -116,7 +116,7 @@ export class GlobalSignalProcessor { 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/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 {