From a9fcf32db65898505c970497cb90120dd1c10bd0 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Fri, 27 Feb 2026 17:30:06 -0300 Subject: [PATCH 01/37] chore: accept additional push tokens intended for voip to iOS devices --- apps/meteor/app/api/server/v1/push.ts | 27 +++++-- apps/meteor/server/services/push/service.ts | 7 +- .../tokenManagement/findDocumentToUpdate.ts | 5 ++ .../push/tokenManagement/registerPushToken.ts | 73 ++++++++++++++----- packages/core-typings/src/IPushToken.ts | 5 +- .../src/models/IPushTokenModel.ts | 5 +- packages/models/src/models/PushToken.ts | 26 ++++--- 7 files changed, 109 insertions(+), 39 deletions(-) diff --git a/apps/meteor/app/api/server/v1/push.ts b/apps/meteor/app/api/server/v1/push.ts index 0d5e21e3f97eb..05d75755152b9 100644 --- a/apps/meteor/app/api/server/v1/push.ts +++ b/apps/meteor/app/api/server/v1/push.ts @@ -1,5 +1,6 @@ import { Push } from '@rocket.chat/core-services'; -import type { IPushToken } from '@rocket.chat/core-typings'; +import { pushTokenTypes } from '@rocket.chat/core-typings'; +import type { IPushToken, IPushTokenTypes } from '@rocket.chat/core-typings'; import { Messages, PushToken, Users, Rooms, Settings } from '@rocket.chat/models'; import { ajv, @@ -22,7 +23,7 @@ import type { SuccessResult } from '../definition'; type PushTokenPOST = { id?: string; - type: 'apn' | 'gcm'; + type: IPushTokenTypes; value: string; appName: string; }; @@ -36,7 +37,7 @@ const PushTokenPOSTSchema: JSONSchemaType = { }, type: { type: 'string', - enum: ['apn', 'gcm'], + enum: pushTokenTypes, }, value: { type: 'string', @@ -71,13 +72,13 @@ const PushTokenDELETESchema: JSONSchemaType = { export const isPushTokenDELETEProps = ajv.compile(PushTokenDELETESchema); -type PushTokenResult = Pick; +type PushTokenResult = Pick; /** * Pick only the attributes we actually want to return on the endpoint, ensuring nothing from older schemas get mixed in */ function cleanTokenResult(result: Omit): PushTokenResult { - const { _id, token, appName, userId, enabled, createdAt, _updatedAt } = result; + const { _id, token, appName, userId, enabled, createdAt, _updatedAt, voipToken } = result; return { _id, @@ -87,6 +88,7 @@ function cleanTokenResult(result: Omit): PushTokenResul enabled, createdAt, _updatedAt, + voipToken, }; } @@ -113,10 +115,13 @@ const pushTokenEndpoints = API.v1 token: { type: 'object', properties: { - apn: { + 'apn': { type: 'string', }, - gcm: { + 'gcm': { + type: 'string', + }, + 'apn.voip': { type: 'string', }, }, @@ -139,6 +144,9 @@ const pushTokenEndpoints = API.v1 _updatedAt: { type: 'string', }, + voipToken: { + type: 'string', + }, }, additionalProperties: false, }, @@ -155,6 +163,10 @@ const pushTokenEndpoints = API.v1 async function action() { const { id, type, value, appName } = this.bodyParams; + if (type === 'apn.voip' && !id) { + throw new Error('voip-tokens-must-specify-device-id'); + } + const rawToken = this.request.headers.get('x-auth-token'); if (!rawToken) { throw new Meteor.Error('error-authToken-param-not-valid', 'The required "authToken" header param is missing or invalid.'); @@ -167,6 +179,7 @@ const pushTokenEndpoints = API.v1 authToken, appName, userId: this.userId, + ...(type === 'apn.voip' && { voipToken: value }), }); return API.v1.success({ result: cleanTokenResult(result) }); diff --git a/apps/meteor/server/services/push/service.ts b/apps/meteor/server/services/push/service.ts index b6ac954f9356f..986fdd0455019 100644 --- a/apps/meteor/server/services/push/service.ts +++ b/apps/meteor/server/services/push/service.ts @@ -36,7 +36,12 @@ export class PushService extends ServiceClassInternal implements IPushService { ): Promise> { const tokenId = await registerPushToken(data); - const removeResult = await PushToken.removeByTokenAndAppNameExceptId(data.token, data.appName, tokenId); + const removeResult = await PushToken.removeDuplicateTokens({ + _id: tokenId, + token: data.token, + appName: data.appName, + authToken: data.authToken, + }); if (removeResult.deletedCount) { logger.debug({ msg: 'Removed existing app items', removed: removeResult.deletedCount }); } diff --git a/apps/meteor/server/services/push/tokenManagement/findDocumentToUpdate.ts b/apps/meteor/server/services/push/tokenManagement/findDocumentToUpdate.ts index 9e7fa5967f183..71ed99b8e3ef7 100644 --- a/apps/meteor/server/services/push/tokenManagement/findDocumentToUpdate.ts +++ b/apps/meteor/server/services/push/tokenManagement/findDocumentToUpdate.ts @@ -9,6 +9,11 @@ export async function findDocumentToUpdate(data: Partial): Promise, '_id' | 'metadata'>, -): Promise { - const doc = await findDocumentToUpdate(data); +export type PushTokenData = Optional< + Pick, + '_id' | 'metadata' +>; - if (!doc) { - const insertResult = await PushToken.insertToken({ - ...(data._id && { _id: data._id }), - token: data.token, - authToken: data.authToken, - appName: data.appName, - userId: data.userId, - ...(data.metadata && { metadata: data.metadata }), - }); - - const { authToken: _, ...dataWithNoAuthToken } = data; - logger.debug({ msg: 'Push token added', dataWithNoAuthToken, insertResult }); +function canModifyTokenDocument(doc: IPushToken, data: Partial): boolean { + // If there's no voip on either side of the operation, any doc can be updated + if (!doc.voipToken && !data.voipToken) { + return true; + } - return insertResult.insertedId; + // VoIP tokens MUST be referenced by id, so if there's no id on the data, do not let this doc be changed + if (!data._id || data._id !== doc._id) { + return false; } - const updateResult = await PushToken.refreshTokenById(doc._id, { + return true; +} + +async function insertToken(data: PushTokenData): Promise { + const insertResult = await PushToken.insertToken({ + ...(data._id && { _id: data._id }), token: data.token, authToken: data.authToken, appName: data.appName, userId: data.userId, + ...(data.metadata && { metadata: data.metadata }), + ...(data.voipToken && data._id && { voipToken: data.voipToken }), }); + const { authToken: _, ...dataWithNoAuthToken } = data; + logger.debug({ msg: 'Push token added', dataWithNoAuthToken, insertResult }); + + return insertResult.insertedId; +} + +async function updateToken(doc: IPushToken, data: PushTokenData): Promise { + const hasNonVoipToken = Object.keys(doc.token).filter((key) => !key.includes('voip')).length > 0; + + const updateResult = await (async () => { + if (data.voipToken && hasNonVoipToken) { + return PushToken.setVoipTokenById(doc._id, data.voipToken); + } + + return PushToken.refreshTokenById(doc._id, { + token: data.token, + authToken: data.authToken, + appName: data.appName, + userId: data.userId, + ...(data.voipToken && { voipToken: data.voipToken }), + }); + })(); + if (updateResult.modifiedCount) { const { authToken: _, ...dataWithNoAuthToken } = data; logger.debug({ msg: 'Push token updated', dataWithNoAuthToken, updateResult }); @@ -39,3 +64,13 @@ export async function registerPushToken( return doc._id; } + +export async function registerPushToken(data: PushTokenData): Promise { + const doc = await findDocumentToUpdate(data); + + if (!doc || !canModifyTokenDocument(doc, data)) { + return insertToken(data); + } + + return updateToken(doc, data); +} diff --git a/packages/core-typings/src/IPushToken.ts b/packages/core-typings/src/IPushToken.ts index ee6280fa27832..6e695fe8fa631 100644 --- a/packages/core-typings/src/IPushToken.ts +++ b/packages/core-typings/src/IPushToken.ts @@ -1,7 +1,9 @@ import type { IRocketChatRecord } from './IRocketChatRecord'; import type { ILoginToken } from './IUser'; -export type IPushTokenTypes = 'gcm' | 'apn'; +export const pushTokenTypes = ['gcm', 'apn', 'apn.voip'] as const; + +export type IPushTokenTypes = (typeof pushTokenTypes)[number]; export interface IPushToken extends IRocketChatRecord { token: Partial>; @@ -11,4 +13,5 @@ export interface IPushToken extends IRocketChatRecord { authToken: ILoginToken['hashedToken']; metadata?: Record; createdAt: Date; + voipToken?: string; } diff --git a/packages/model-typings/src/models/IPushTokenModel.ts b/packages/model-typings/src/models/IPushTokenModel.ts index e9be625e47705..d444734018f23 100644 --- a/packages/model-typings/src/models/IPushTokenModel.ts +++ b/packages/model-typings/src/models/IPushTokenModel.ts @@ -13,11 +13,12 @@ export interface IPushTokenModel extends IBaseModel { insertToken(data: AtLeast): Promise>; refreshTokenById( id: IPushToken['_id'], - data: Pick, + data: Pick, ): Promise>; + setVoipTokenById(id: IPushToken['_id'], voipToken: string): Promise>; removeByUserIdExceptTokens(userId: string, tokens: IPushToken['authToken'][]): Promise; - removeByTokenAndAppNameExceptId(token: IPushToken['token'], appName: IPushToken['appName'], id: IPushToken['_id']): Promise; + removeDuplicateTokens(tokenData: Pick): Promise; removeAllByUserId(userId: string): Promise; removeAllByTokenStringAndUserId(token: string, userId: string): Promise; diff --git a/packages/models/src/models/PushToken.ts b/packages/models/src/models/PushToken.ts index e7af5131fcfe2..f5e8e7f5e7df2 100644 --- a/packages/models/src/models/PushToken.ts +++ b/packages/models/src/models/PushToken.ts @@ -56,7 +56,7 @@ export class PushTokenRaw extends BaseRaw implements IPushTokenModel async refreshTokenById( id: IPushToken['_id'], - data: Pick, + data: Pick, ): Promise> { return this.updateOne( { _id: id }, @@ -66,11 +66,16 @@ export class PushTokenRaw extends BaseRaw implements IPushTokenModel authToken: data.authToken, appName: data.appName, userId: data.userId, + ...(data.voipToken && { voipToken: data.voipToken }), }, }, ); } + async setVoipTokenById(id: IPushToken['_id'], voipToken: string): Promise> { + return this.updateOne({ _id: id }, { $set: { voipToken } }); + } + findOneByTokenAndAppName(token: IPushToken['token'], appName: IPushToken['appName']): Promise { return this.findOne({ token, @@ -85,15 +90,18 @@ export class PushTokenRaw extends BaseRaw implements IPushTokenModel }); } - removeByTokenAndAppNameExceptId( - token: IPushToken['token'], - appName: IPushToken['appName'], - id: IPushToken['_id'], - ): Promise { + removeDuplicateTokens(tokenData: Pick): Promise { return this.deleteMany({ - token, - appName, - _id: { $ne: id }, + _id: { $ne: tokenData._id }, + $or: [ + { + token: tokenData.token, + appName: tokenData.appName, + }, + { + authToken: tokenData.authToken, + }, + ], }); } From 8f276711d654955efd702c34e9c80dcd3a2209aa Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 5 Mar 2026 13:50:23 -0300 Subject: [PATCH 02/37] configure apn and voip tokens on the same request --- apps/meteor/app/api/server/v1/push.ts | 18 ++++++++++-------- .../tokenManagement/findDocumentToUpdate.ts | 2 +- .../push/tokenManagement/registerPushToken.ts | 6 ------ packages/core-typings/src/IPushToken.ts | 2 +- .../src/models/IPushTokenModel.ts | 1 - packages/models/src/models/PushToken.ts | 5 +---- 6 files changed, 13 insertions(+), 21 deletions(-) diff --git a/apps/meteor/app/api/server/v1/push.ts b/apps/meteor/app/api/server/v1/push.ts index 05d75755152b9..7c6c939b989e9 100644 --- a/apps/meteor/app/api/server/v1/push.ts +++ b/apps/meteor/app/api/server/v1/push.ts @@ -26,6 +26,7 @@ type PushTokenPOST = { type: IPushTokenTypes; value: string; appName: string; + voipToken?: string; }; const PushTokenPOSTSchema: JSONSchemaType = { @@ -47,6 +48,10 @@ const PushTokenPOSTSchema: JSONSchemaType = { type: 'string', minLength: 1, }, + voipToken: { + type: 'string', + nullable: true, + }, }, required: ['type', 'value', 'appName'], additionalProperties: false, @@ -115,13 +120,10 @@ const pushTokenEndpoints = API.v1 token: { type: 'object', properties: { - 'apn': { - type: 'string', - }, - 'gcm': { + apn: { type: 'string', }, - 'apn.voip': { + gcm: { type: 'string', }, }, @@ -161,9 +163,9 @@ const pushTokenEndpoints = API.v1 authRequired: true, }, async function action() { - const { id, type, value, appName } = this.bodyParams; + const { id, type, value, appName, voipToken } = this.bodyParams; - if (type === 'apn.voip' && !id) { + if (voipToken && !id) { throw new Error('voip-tokens-must-specify-device-id'); } @@ -179,7 +181,7 @@ const pushTokenEndpoints = API.v1 authToken, appName, userId: this.userId, - ...(type === 'apn.voip' && { voipToken: value }), + ...(voipToken && { voipToken }), }); return API.v1.success({ result: cleanTokenResult(result) }); diff --git a/apps/meteor/server/services/push/tokenManagement/findDocumentToUpdate.ts b/apps/meteor/server/services/push/tokenManagement/findDocumentToUpdate.ts index 71ed99b8e3ef7..5d423c69db9b7 100644 --- a/apps/meteor/server/services/push/tokenManagement/findDocumentToUpdate.ts +++ b/apps/meteor/server/services/push/tokenManagement/findDocumentToUpdate.ts @@ -10,7 +10,7 @@ export async function findDocumentToUpdate(data: Partial): Promise { } async function updateToken(doc: IPushToken, data: PushTokenData): Promise { - const hasNonVoipToken = Object.keys(doc.token).filter((key) => !key.includes('voip')).length > 0; - const updateResult = await (async () => { - if (data.voipToken && hasNonVoipToken) { - return PushToken.setVoipTokenById(doc._id, data.voipToken); - } - return PushToken.refreshTokenById(doc._id, { token: data.token, authToken: data.authToken, diff --git a/packages/core-typings/src/IPushToken.ts b/packages/core-typings/src/IPushToken.ts index 6e695fe8fa631..bfface372e03a 100644 --- a/packages/core-typings/src/IPushToken.ts +++ b/packages/core-typings/src/IPushToken.ts @@ -1,7 +1,7 @@ import type { IRocketChatRecord } from './IRocketChatRecord'; import type { ILoginToken } from './IUser'; -export const pushTokenTypes = ['gcm', 'apn', 'apn.voip'] as const; +export const pushTokenTypes = ['gcm', 'apn'] as const; export type IPushTokenTypes = (typeof pushTokenTypes)[number]; diff --git a/packages/model-typings/src/models/IPushTokenModel.ts b/packages/model-typings/src/models/IPushTokenModel.ts index d444734018f23..68345f281c9a6 100644 --- a/packages/model-typings/src/models/IPushTokenModel.ts +++ b/packages/model-typings/src/models/IPushTokenModel.ts @@ -15,7 +15,6 @@ export interface IPushTokenModel extends IBaseModel { id: IPushToken['_id'], data: Pick, ): Promise>; - setVoipTokenById(id: IPushToken['_id'], voipToken: string): Promise>; removeByUserIdExceptTokens(userId: string, tokens: IPushToken['authToken'][]): Promise; removeDuplicateTokens(tokenData: Pick): Promise; diff --git a/packages/models/src/models/PushToken.ts b/packages/models/src/models/PushToken.ts index f5e8e7f5e7df2..a0ccbf62c214e 100644 --- a/packages/models/src/models/PushToken.ts +++ b/packages/models/src/models/PushToken.ts @@ -68,14 +68,11 @@ export class PushTokenRaw extends BaseRaw implements IPushTokenModel userId: data.userId, ...(data.voipToken && { voipToken: data.voipToken }), }, + ...(!data.voipToken && { $unset: { voipToken: 1 } }), }, ); } - async setVoipTokenById(id: IPushToken['_id'], voipToken: string): Promise> { - return this.updateOne({ _id: id }, { $set: { voipToken } }); - } - findOneByTokenAndAppName(token: IPushToken['token'], appName: IPushToken['appName']): Promise { return this.findOne({ token, From 8a525cbf37de011875f1fb1c1b6d2c0a6d4f2200 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 5 Mar 2026 13:52:43 -0300 Subject: [PATCH 03/37] use API.v1.failure --- apps/meteor/app/api/server/v1/push.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/api/server/v1/push.ts b/apps/meteor/app/api/server/v1/push.ts index 7c6c939b989e9..0c9375c668883 100644 --- a/apps/meteor/app/api/server/v1/push.ts +++ b/apps/meteor/app/api/server/v1/push.ts @@ -166,7 +166,7 @@ const pushTokenEndpoints = API.v1 const { id, type, value, appName, voipToken } = this.bodyParams; if (voipToken && !id) { - throw new Error('voip-tokens-must-specify-device-id'); + return API.v1.failure('voip-tokens-must-specify-device-id'); } const rawToken = this.request.headers.get('x-auth-token'); From 453508e53e092914406a952d17d7451774e242bb Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 5 Mar 2026 13:55:01 -0300 Subject: [PATCH 04/37] this was no longer needed --- .../push/tokenManagement/registerPushToken.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/meteor/server/services/push/tokenManagement/registerPushToken.ts b/apps/meteor/server/services/push/tokenManagement/registerPushToken.ts index 30ab07ec58360..0730e3fa45bea 100644 --- a/apps/meteor/server/services/push/tokenManagement/registerPushToken.ts +++ b/apps/meteor/server/services/push/tokenManagement/registerPushToken.ts @@ -41,15 +41,13 @@ async function insertToken(data: PushTokenData): Promise { } async function updateToken(doc: IPushToken, data: PushTokenData): Promise { - const updateResult = await (async () => { - return PushToken.refreshTokenById(doc._id, { - token: data.token, - authToken: data.authToken, - appName: data.appName, - userId: data.userId, - ...(data.voipToken && { voipToken: data.voipToken }), - }); - })(); + const updateResult = await PushToken.refreshTokenById(doc._id, { + token: data.token, + authToken: data.authToken, + appName: data.appName, + userId: data.userId, + ...(data.voipToken && { voipToken: data.voipToken }), + }); if (updateResult.modifiedCount) { const { authToken: _, ...dataWithNoAuthToken } = data; From 0eaeee8a89112e02c2b81cb68ab5a36732aeaad5 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 5 Mar 2026 15:10:27 -0300 Subject: [PATCH 05/37] feat: send push notifications for voice calls --- apps/meteor/app/push/server/apn.ts | 32 +++++--- apps/meteor/app/push/server/definition.ts | 7 +- apps/meteor/app/push/server/fcm.ts | 12 +-- apps/meteor/app/push/server/push.ts | 70 ++++++++--------- .../server/services/media-call/service.ts | 75 +++++++++++++++++++ .../src/definition/IMediaCallServer.ts | 2 + .../src/internal/agents/UserActorAgent.ts | 4 + .../media-calls/src/server/MediaCallServer.ts | 6 ++ .../src/IPushNotificationConfig.ts | 8 +- .../src/models/IPushTokenModel.ts | 4 +- packages/models/src/models/PushToken.ts | 31 +++++++- 11 files changed, 186 insertions(+), 65 deletions(-) diff --git a/apps/meteor/app/push/server/apn.ts b/apps/meteor/app/push/server/apn.ts index e8732a9daae5f..a73b976c4c5e1 100644 --- a/apps/meteor/app/push/server/apn.ts +++ b/apps/meteor/app/push/server/apn.ts @@ -1,5 +1,5 @@ import apn from '@parse/node-apn'; -import type { IPushToken, RequiredField } from '@rocket.chat/core-typings'; +import type { RequiredField } from '@rocket.chat/core-typings'; import EJSON from 'ejson'; import type { PushOptions, PendingPushNotification } from './definition'; @@ -24,7 +24,7 @@ export const sendAPN = ({ }: { userToken: string; notification: PendingPushNotification & { topic: string }; - _removeToken: (token: IPushToken['token']) => void; + _removeToken: (token: string) => void; }) => { if (!apnConnection) { throw new Error('Apn Connection not initialized.'); @@ -34,7 +34,13 @@ export const sendAPN = ({ const note = new apn.Notification(); - note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now. + if (notification.voip) { + note.expiry = Math.floor(Date.now() / 1000) + 60; // Expires in 60 seconds + note.pushType = 'voip'; + } else { + note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now. + } + if (notification.badge !== undefined) { note.badge = notification.badge; } @@ -50,10 +56,16 @@ export const sendAPN = ({ // adds category support for iOS8 custom actions as described here: // https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/ // RemoteNotificationsPG/Chapters/IPhoneOSClientImp.html#//apple_ref/doc/uid/TP40008194-CH103-SW36 - note.category = notification.apn?.category; + if (notification.apn?.category) { + note.category = notification.apn.category; + } - note.body = notification.text; - note.title = notification.title; + if (notification.text) { + note.body = notification.text; + } + if (notification.title) { + note.title = notification.title; + } if (notification.notId != null) { note.threadId = String(notification.notId); @@ -62,7 +74,9 @@ export const sendAPN = ({ // Allow the user to set payload data note.payload = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {}; - note.payload.messageFrom = notification.from; + if (notification.from) { + note.payload.messageFrom = notification.from; + } note.priority = priority; note.topic = notification.topic; @@ -81,9 +95,7 @@ export const sendAPN = ({ msg: 'Removing APN token', token: userToken, }); - _removeToken({ - apn: userToken, - }); + _removeToken(userToken); } }); }); diff --git a/apps/meteor/app/push/server/definition.ts b/apps/meteor/app/push/server/definition.ts index c849d06c11b88..a25f13dc12c6e 100644 --- a/apps/meteor/app/push/server/definition.ts +++ b/apps/meteor/app/push/server/definition.ts @@ -18,9 +18,9 @@ export type PushOptions = { }; export type PendingPushNotification = { - from: string; - title: string; - text: string; + from?: string; + title?: string; + text?: string; badge?: number; sound?: string; notId?: number; @@ -42,4 +42,5 @@ export type PendingPushNotification = { priority?: number; contentAvailable?: 1 | 0; + voip?: boolean; }; diff --git a/apps/meteor/app/push/server/fcm.ts b/apps/meteor/app/push/server/fcm.ts index 9a3529d02e1f4..afe365784ef03 100644 --- a/apps/meteor/app/push/server/fcm.ts +++ b/apps/meteor/app/push/server/fcm.ts @@ -9,8 +9,8 @@ import type { NativeNotificationParameters } from './push'; type FCMDataField = Record; type FCMNotificationField = { - title: string; - body: string; + title?: string; + body?: string; image?: string; }; @@ -140,13 +140,13 @@ function getFCMMessagesFromPushData(userTokens: string[], notification: PendingP // then we will create the notification field const notificationField: FCMNotificationField = { - title: notification.title, - body: notification.text, + ...(notification.title && { title: notification.title }), + ...(notification.text && { body: notification.text }), }; // then we will create the message const message: FCMMessage = { - notification: notificationField, + ...(Object.keys(notificationField).length && { notification: notificationField }), data, android: { priority: 'HIGH', @@ -185,7 +185,7 @@ export const sendFCM = function ({ userTokens, notification, _removeToken, optio const removeToken = () => { const { token } = fcmRequest.message; - token && _removeToken({ gcm: token }); + token && _removeToken(token); }; const response = fetchWithRetry(url, removeToken, { diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 860900a92471c..2009d943da0e7 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -78,9 +78,9 @@ export const isFCMCredentials = ajv.compile(FCMCredentialsValida // This type must match the type defined in the push gateway type GatewayNotification = { uniqueId: string; - from: string; - title: string; - text: string; + from?: string; + title?: string; + text?: string; badge?: number; sound?: string; notId?: number; @@ -123,8 +123,7 @@ type GatewayNotification = { export type NativeNotificationParameters = { userTokens: string | string[]; notification: PendingPushNotification; - _replaceToken: (currentToken: IPushToken['token'], newToken: IPushToken['token']) => void; - _removeToken: (token: IPushToken['token']) => void; + _removeToken: (token: string) => void; options: RequiredField; }; @@ -167,12 +166,10 @@ class PushClass { } } - private replaceToken(currentToken: IPushToken['token'], newToken: IPushToken['token']): void { - void PushToken.updateMany({ token: currentToken }, { $set: { token: newToken } }); - } - - private removeToken(token: IPushToken['token']): void { - void PushToken.deleteOne({ token }); + private removeToken(token: string): void { + void PushToken.removeAllByTokenString(token).catch((err) => { + logger.error({ msg: 'Failed to remove push token', err }); + }); } private shouldUseGateway(): boolean { @@ -188,10 +185,13 @@ class PushClass { logger.debug({ msg: 'send to token', token: app.token }); if ('apn' in app.token && app.token.apn) { + const userToken = notification.voip ? app.voipToken : app.token.apn; + const topic = notification.voip ? `${app.appName}.voip` : app.appName; + countApn.push(app._id); // Send to APN - if (this.options.apn) { - sendAPN({ userToken: app.token.apn, notification: { topic: app.appName, ...notification }, _removeToken: this.removeToken }); + if (this.options.apn && userToken) { + sendAPN({ userToken, notification: { topic, ...notification }, _removeToken: this.removeToken }); } } else if ('gcm' in app.token && app.token.gcm) { countGcm.push(app._id); @@ -210,7 +210,6 @@ class PushClass { sendFCM({ userTokens: app.token.gcm, notification, - _replaceToken: this.replaceToken, _removeToken: this.removeToken, options: sendGCMOptions as RequiredField, }); @@ -275,16 +274,7 @@ class PushClass { if (result.status === 406) { logger.info({ msg: 'removing push token', token }); - await PushToken.deleteMany({ - $or: [ - { - 'token.apn': token, - }, - { - 'token.gcm': token, - }, - ], - }); + this.removeToken(token); return; } @@ -340,8 +330,13 @@ class PushClass { logger.debug({ msg: 'send to token', token: app.token }); if ('apn' in app.token && app.token.apn) { - countApn.push(app._id); - return this.sendGatewayPush(gateway, 'apn', app.token.apn, { topic: app.appName, ...gatewayNotification }); + const token = notification.voip ? app.voipToken : app.token.apn; + const topic = notification.voip ? `${app.appName}.voip` : app.appName; + + if (token) { + countApn.push(app._id); + return this.sendGatewayPush(gateway, 'apn', token, { topic, ...gatewayNotification }); + } } if ('gcm' in app.token && app.token.gcm) { @@ -373,12 +368,7 @@ class PushClass { userId: notification.userId, }); - const query = { - userId: notification.userId, - $or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }], - }; - - const appTokens = PushToken.find(query); + const appTokens = PushToken.findAllTokensByUserId(notification.userId); for await (const app of appTokens) { logger.debug({ msg: 'send to token', token: app.token }); @@ -427,9 +417,9 @@ class PushClass { private _validateDocument(notification: PendingPushNotification): void { // Check the general notification check(notification, { - from: String, - title: String, - text: String, + from: Match.Optional(String), + title: Match.Optional(String), + text: Match.Optional(String), sent: Match.Optional(Boolean), sending: Match.Optional(Match.Integer), badge: Match.Optional(Match.Integer), @@ -448,6 +438,7 @@ class PushClass { createdAt: Date, createdBy: Match.OneOf(String, null), priority: Match.Optional(Match.Integer), + voip: Match.Optional(Boolean), }); if (!notification.userId) { @@ -464,16 +455,15 @@ class PushClass { } public async send(options: IPushNotificationConfig) { - const notification: PendingPushNotification = { + const notification = { createdAt: new Date(), // createdBy is no longer used, but the gateway still expects it createdBy: '', sent: false, sending: 0, - title: truncateString(options.title, PUSH_TITLE_LIMIT), - text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT), - - ...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority'), + ...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority', 'voip'), + ...(options.title && { title: truncateString(options.title, PUSH_TITLE_LIMIT) }), + ...(options.text && { text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT) }), ...(this.hasApnOptions(options) ? { diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index ed26ead8c61d8..6fc8682a3ca62 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -6,6 +6,7 @@ import type { IInternalMediaCallHistoryItem, CallHistoryItemState, IExternalMediaCallHistoryItem, + MediaCallContact, } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { callServer, type IMediaCallServerSettings } from '@rocket.chat/media-calls'; @@ -15,7 +16,11 @@ import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models'; import { getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload'; import { sendMessage } from '../../../app/lib/server/functions/sendMessage'; +import { metrics } from '../../../app/metrics/server/lib/metrics'; +import { Push } from '../../../app/push/server/push'; +import PushNotification from '../../../app/push-notifications/server/lib/PushNotification'; import { settings } from '../../../app/settings/server'; +import { getUserAvatarURL } from '../../../app/utils/server/getUserAvatarURL'; import { createDirectMessage } from '../../methods/createDirectMessage'; const logger = new Logger('media-call service'); @@ -28,6 +33,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall callServer.emitter.on('signalRequest', ({ toUid, signal }) => this.sendSignal(toUid, signal)); callServer.emitter.on('callUpdated', (params) => api.broadcast('media-call.updated', params)); callServer.emitter.on('historyUpdate', ({ callId }) => setImmediate(() => this.saveCallToHistory(callId))); + callServer.emitter.on('pushNotificationRequest', ({ callId }) => this.sendPushNotification(callId)); this.onEvent('media-call.updated', (params) => callServer.receiveCallUpdate(params)); this.onEvent('watch.settings', async ({ setting }): Promise => { @@ -71,6 +77,75 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } } + private async getActorUser>( + actor: MediaCallContact, + ): Promise { + const options = { projection: { name: 1, username: 1 } }; + + switch (actor.type) { + case 'user': + return Users.findOneById(actor.id, options); + case 'sip': + return Users.findOneByFreeSwitchExtension(actor.id, options); + } + } + + private async getActorUserData(actor: MediaCallContact): Promise<{ name: string; avatarUrl?: string }> { + const user = await this.getActorUser(actor); + + if (user) { + return { + name: user.name || user.username || user.freeSwitchExtension || '', + ...(user.username && { avatarUrl: getUserAvatarURL(user.username) }), + }; + } + + if (actor.type === 'sip') { + return { + name: actor.displayName || actor.sipExtension || actor.id, + }; + } + + return { + name: actor.displayName || actor.username || '', + ...(actor.username && { avatarUrl: getUserAvatarURL(actor.username) }), + }; + } + + private async sendPushNotification(callId: IMediaCall['_id']): Promise { + const call = await MediaCalls.findOneById(callId); + if (!call) { + logger.error({ msg: 'Failed to send push notification: Media Call not found', callId }); + return; + } + + if (call.callee.type !== 'user') { + logger.error({ msg: 'Failed to send push notification: Invalid Callee Type', callId }); + return; + } + + const { id: userId, username } = call.callee; + const { name: caller, avatarUrl: avatar } = await this.getActorUserData(call.caller); + + metrics.notificationsSent.inc({ notification_type: 'mobile' }); + await Push.send({ + voip: true, + priority: 10, + payload: { + host: Meteor.absoluteUrl(), + hostName: settings.get('Site_Name'), + notificationType: 'voip', + ...(avatar && { avatar }), + state: call.state, + callId: call._id, + caller, + username, + }, + userId, + notId: PushNotification.getNotificationId(call._id), + }); + } + private async saveCallToHistory(callId: IMediaCall['_id']): Promise { logger.info({ msg: 'saving media call to history', callId }); diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts index ba1994f6f2697..c2eb3aa8ba9c5 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallServer.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -8,6 +8,7 @@ export type MediaCallServerEvents = { callUpdated: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }; signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal }; historyUpdate: { callId: string }; + pushNotificationRequest: { callId: string }; }; export interface IMediaCallServerSettings { @@ -39,6 +40,7 @@ export interface IMediaCallServer { sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void; reportCallUpdate(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void; updateCallHistory(params: { callId: string }): void; + sendPushNotification(params: { callId: string }): void; // functions that are run on events receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): void; diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index 112e5fa3e5c61..ca1a0a5138572 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -72,6 +72,10 @@ export class UserActorAgent extends BaseMediaCallAgent { } await this.sendSignal(buildNewCallSignal(call, this.role)); + + if (this.role === 'callee') { + getMediaCallServer().sendPushNotification({ callId: call._id }); + } } public async onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise { diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index e6dcc4dc90465..b8dbd41aa1e4d 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -70,6 +70,12 @@ export class MediaCallServer implements IMediaCallServer { this.emitter.emit('historyUpdate', params); } + public sendPushNotification(params: { callId: string }): void { + logger.debug({ msg: 'MediaCallServer.sendPushNotification', params }); + + this.emitter.emit('pushNotificationRequest', params); + } + public async requestCall(params: InternalCallParams): Promise { try { const fullParams = await this.parseCallContacts(params); diff --git a/packages/core-typings/src/IPushNotificationConfig.ts b/packages/core-typings/src/IPushNotificationConfig.ts index 3279d42aa7b2c..6aab7ead620ee 100644 --- a/packages/core-typings/src/IPushNotificationConfig.ts +++ b/packages/core-typings/src/IPushNotificationConfig.ts @@ -1,10 +1,10 @@ export interface IPushNotificationConfig { - from: string; + from?: string; badge?: number; sound?: string; priority?: number; - title: string; - text: string; + title?: string; + text?: string; payload?: Record; userId: string; notId?: number; @@ -14,6 +14,6 @@ export interface IPushNotificationConfig { }; apn?: { category: string; - topicSuffix?: string; }; + voip?: boolean; } diff --git a/packages/model-typings/src/models/IPushTokenModel.ts b/packages/model-typings/src/models/IPushTokenModel.ts index 68345f281c9a6..9b5748d7ab60e 100644 --- a/packages/model-typings/src/models/IPushTokenModel.ts +++ b/packages/model-typings/src/models/IPushTokenModel.ts @@ -1,5 +1,5 @@ import type { AtLeast, IPushToken, IUser } from '@rocket.chat/core-typings'; -import type { DeleteResult, FindOptions, InsertOneResult, UpdateResult } from 'mongodb'; +import type { DeleteResult, FindOptions, InsertOneResult, UpdateResult, FindCursor } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -9,6 +9,7 @@ export interface IPushTokenModel extends IBaseModel { countApnTokens(): Promise; findOneByTokenAndAppName(token: IPushToken['token'], appName: IPushToken['appName']): Promise; findFirstByUserId(userId: IUser['_id'], options?: FindOptions): Promise; + findAllTokensByUserId(userId: IUser['_id'], options?: FindOptions): FindCursor; insertToken(data: AtLeast): Promise>; refreshTokenById( @@ -21,4 +22,5 @@ export interface IPushTokenModel extends IBaseModel { removeAllByUserId(userId: string): Promise; removeAllByTokenStringAndUserId(token: string, userId: string): Promise; + removeAllByTokenString(token: string): Promise; } diff --git a/packages/models/src/models/PushToken.ts b/packages/models/src/models/PushToken.ts index a0ccbf62c214e..05dec5607a0e3 100644 --- a/packages/models/src/models/PushToken.ts +++ b/packages/models/src/models/PushToken.ts @@ -1,6 +1,6 @@ import type { IPushToken, IUser, AtLeast } from '@rocket.chat/core-typings'; import type { IPushTokenModel } from '@rocket.chat/model-typings'; -import type { Db, DeleteResult, FindOptions, IndexDescription, InsertOneResult, UpdateResult } from 'mongodb'; +import type { Db, DeleteResult, FindOptions, IndexDescription, InsertOneResult, UpdateResult, FindCursor } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -46,6 +46,16 @@ export class PushTokenRaw extends BaseRaw implements IPushTokenModel return this.findOne({ userId }, options); } + findAllTokensByUserId(userId: IUser['_id'], options?: FindOptions): FindCursor { + return this.find( + { + userId, + $or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }], + }, + options, + ); + } + async insertToken(data: AtLeast): Promise> { return this.insertOne({ enabled: true, @@ -117,8 +127,27 @@ export class PushTokenRaw extends BaseRaw implements IPushTokenModel { 'token.gcm': token, }, + { + voipToken: token, + }, ], userId, }); } + + removeAllByTokenString(token: string): Promise { + return this.deleteMany({ + $or: [ + { + 'token.apn': token, + }, + { + 'token.gcm': token, + }, + { + voipToken: token, + }, + ], + }); + } } From 43b8434ae6983437c12bd5437333b990aaf622d2 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Fri, 6 Mar 2026 17:04:40 -0300 Subject: [PATCH 06/37] state change notifications --- apps/meteor/app/push/server/definition.ts | 2 +- apps/meteor/app/push/server/push.ts | 48 +++++--- .../server/services/media-call/logger.ts | 3 + .../push/getPushNotificationType.ts | 23 ++++ .../push/sendVoipPushNotification.ts | 103 ++++++++++++++++++ .../server/services/media-call/service.ts | 81 +------------- .../src/definition/IMediaCallServer.ts | 7 +- .../src/internal/agents/UserActorAgent.ts | 8 +- .../media-calls/src/server/MediaCallServer.ts | 9 +- .../src/IPushNotificationConfig.ts | 5 +- .../src/models/IPushTokenModel.ts | 5 + packages/models/src/models/PushToken.ts | 15 +++ 12 files changed, 206 insertions(+), 103 deletions(-) create mode 100644 apps/meteor/server/services/media-call/logger.ts create mode 100644 apps/meteor/server/services/media-call/push/getPushNotificationType.ts create mode 100644 apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts diff --git a/apps/meteor/app/push/server/definition.ts b/apps/meteor/app/push/server/definition.ts index a25f13dc12c6e..87128af95347b 100644 --- a/apps/meteor/app/push/server/definition.ts +++ b/apps/meteor/app/push/server/definition.ts @@ -42,5 +42,5 @@ export type PendingPushNotification = { priority?: number; contentAvailable?: 1 | 0; - voip?: boolean; + useVoipToken?: boolean; }; diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 2009d943da0e7..7dcd75048f117 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -18,6 +18,7 @@ export const _matchToken = Match.OneOf({ apn: String }, { gcm: String }); const PUSH_TITLE_LIMIT = 65; const PUSH_MESSAGE_BODY_LIMIT = 240; +const PUSH_GATEWAY_MAX_ATTEMPTS = 5; type FCMCredentials = { type: string; @@ -185,8 +186,8 @@ class PushClass { logger.debug({ msg: 'send to token', token: app.token }); if ('apn' in app.token && app.token.apn) { - const userToken = notification.voip ? app.voipToken : app.token.apn; - const topic = notification.voip ? `${app.appName}.voip` : app.appName; + const userToken = notification.useVoipToken ? app.voipToken : app.token.apn; + const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName; countApn.push(app._id); // Send to APN @@ -254,7 +255,7 @@ class PushClass { service: 'apn' | 'gcm', token: string, notification: Optional, - tries = 0, + retryOptions: { tries: number; maxTries: number } = { tries: 0, maxTries: PUSH_GATEWAY_MAX_ATTEMPTS }, ): Promise { notification.uniqueId = this.options.uniqueId; @@ -292,15 +293,17 @@ class PushClass { return; } + const { tries, maxTries } = retryOptions; + logger.error({ msg: 'Error sending push to gateway', tries, err: response }); - if (tries <= 4) { + if (tries < maxTries) { // [1, 2, 4, 8, 16] minutes (total 31) const ms = 60000 * Math.pow(2, tries); logger.log({ msg: 'Retrying push to gateway', tries: tries + 1, in: ms }); - setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, tries + 1), ms); + setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, { tries: tries + 1, maxTries }), ms); } } @@ -325,40 +328,47 @@ class PushClass { } const gatewayNotification = this.getGatewayNotificationData(notification); + const retryOptions = { + tries: 0, + maxTries: notification.useVoipToken ? 1 : PUSH_GATEWAY_MAX_ATTEMPTS, + }; for (const gateway of this.options.gateways) { logger.debug({ msg: 'send to token', token: app.token }); if ('apn' in app.token && app.token.apn) { - const token = notification.voip ? app.voipToken : app.token.apn; - const topic = notification.voip ? `${app.appName}.voip` : app.appName; + const token = notification.useVoipToken ? app.voipToken : app.token.apn; + const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName; if (token) { countApn.push(app._id); - return this.sendGatewayPush(gateway, 'apn', token, { topic, ...gatewayNotification }); + return this.sendGatewayPush(gateway, 'apn', token, { topic, ...gatewayNotification }, retryOptions); } } if ('gcm' in app.token && app.token.gcm) { countGcm.push(app._id); - return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, gatewayNotification); + return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, gatewayNotification, retryOptions); } } } - private async sendNotification(notification: PendingPushNotification): Promise<{ apn: string[]; gcm: string[] }> { + private async sendNotification( + notification: PendingPushNotification, + options: { skipTokenId?: IPushToken['_id'] } = {}, + ): Promise<{ apn: string[]; gcm: string[] }> { logger.debug({ msg: 'Sending notification', notification }); const countApn: string[] = []; const countGcm: string[] = []; - if (notification.from !== String(notification.from)) { + if (notification.from && notification.from !== String(notification.from)) { throw new Error('Push.send: option "from" not a string'); } - if (notification.title !== String(notification.title)) { + if (notification.title && notification.title !== String(notification.title)) { throw new Error('Push.send: option "title" not a string'); } - if (notification.text !== String(notification.text)) { + if (notification.text && notification.text !== String(notification.text)) { throw new Error('Push.send: option "text" not a string'); } @@ -368,7 +378,9 @@ class PushClass { userId: notification.userId, }); - const appTokens = PushToken.findAllTokensByUserId(notification.userId); + const appTokens = options.skipTokenId + ? PushToken.findTokensByUserIdExceptId(notification.userId, options.skipTokenId) + : PushToken.findAllTokensByUserId(notification.userId); for await (const app of appTokens) { logger.debug({ msg: 'send to token', token: app.token }); @@ -438,7 +450,7 @@ class PushClass { createdAt: Date, createdBy: Match.OneOf(String, null), priority: Match.Optional(Match.Integer), - voip: Match.Optional(Boolean), + useVoipToken: Match.Optional(Boolean), }); if (!notification.userId) { @@ -455,13 +467,13 @@ class PushClass { } public async send(options: IPushNotificationConfig) { - const notification = { + const notification: PendingPushNotification = { createdAt: new Date(), // createdBy is no longer used, but the gateway still expects it createdBy: '', sent: false, sending: 0, - ...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority', 'voip'), + ...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority', 'useVoipToken'), ...(options.title && { title: truncateString(options.title, PUSH_TITLE_LIMIT) }), ...(options.text && { text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT) }), @@ -485,7 +497,7 @@ class PushClass { this._validateDocument(notification); try { - await this.sendNotification(notification); + await this.sendNotification(notification, pick(options, 'skipTokenId')); } catch (error: any) { logger.debug({ msg: 'Could not send notification to user', diff --git a/apps/meteor/server/services/media-call/logger.ts b/apps/meteor/server/services/media-call/logger.ts new file mode 100644 index 0000000000000..2c59c042c4b57 --- /dev/null +++ b/apps/meteor/server/services/media-call/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('media-call service'); diff --git a/apps/meteor/server/services/media-call/push/getPushNotificationType.ts b/apps/meteor/server/services/media-call/push/getPushNotificationType.ts new file mode 100644 index 0000000000000..614c6820eb070 --- /dev/null +++ b/apps/meteor/server/services/media-call/push/getPushNotificationType.ts @@ -0,0 +1,23 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; + +export type VoipPushNotificationType = 'incoming_call' | 'remoteEnded' | 'answeredElsewhere' | 'declinedElsewhere' | 'unanswered'; + +export function getPushNotificationType(call: IMediaCall): VoipPushNotificationType { + if (call.acceptedAt) { + return 'answeredElsewhere'; + } + + if (call.endedBy?.id === call.callee.id || call.hangupReason === 'rejected') { + return 'declinedElsewhere'; + } + + if (call.endedBy?.id === call.caller.id) { + return 'remoteEnded'; + } + + if (call.ended) { + return 'unanswered'; + } + + return 'incoming_call'; +} diff --git a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts new file mode 100644 index 0000000000000..046488ecd2d84 --- /dev/null +++ b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts @@ -0,0 +1,103 @@ +import type { IMediaCall, IUser, MediaCallContact, MediaCallActorType } from '@rocket.chat/core-typings'; +import type { VoipPushNotificationEventType } from '@rocket.chat/media-calls'; +import { MediaCalls, Users } from '@rocket.chat/models'; + +import { getPushNotificationType } from './getPushNotificationType'; +import { metrics } from '../../../../app/metrics/server/lib/metrics'; +import { Push } from '../../../../app/push/server/push'; +import PushNotification from '../../../../app/push-notifications/server/lib/PushNotification'; +import { settings } from '../../../../app/settings/server'; +import { getUserAvatarURL } from '../../../../app/utils/server/getUserAvatarURL'; +import { logger } from '../logger'; + +async function getActorUser>( + actor: MediaCallContact, +): Promise { + const options = { projection: { name: 1, username: 1 } }; + + switch (actor.type) { + case 'user': + return Users.findOneById(actor.id, options); + case 'sip': + return Users.findOneByFreeSwitchExtension(actor.id, options); + } +} + +async function getActorUserData( + actor: MediaCallContact, +): Promise<{ type: MediaCallActorType; id: string; name: string; avatarUrl?: string }> { + const actorUsername = actor.type === 'user' ? actor.username : undefined; + const actorExtension = actor.sipExtension || (actor.type === 'sip' ? actor.id : undefined); + + const data = { + type: actor.type, + id: actor.id, + name: actor.displayName || actorUsername || actorExtension || '', + }; + + const user = await getActorUser(actor); + + if (user) { + const username = user.username || actorUsername; + + return { + ...data, + name: user.name || user.username || user.freeSwitchExtension || data.name, + ...(username && { avatarUrl: getUserAvatarURL(username) }), + }; + } + + return { + ...data, + ...(actorUsername && { avatarUrl: getUserAvatarURL(actorUsername) }), + }; +} + +export async function sendVoipPushNotification(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): Promise { + const call = await MediaCalls.findOneById(callId); + if (!call) { + logger.error({ msg: 'Failed to send push notification: Media Call not found', callId }); + return; + } + + if (call.callee.type !== 'user') { + logger.error({ msg: 'Failed to send push notification: Invalid Callee Type', callId }); + return; + } + + // If the call was accepted, we don't need to notify when it ends + if (call.acceptedAt && event !== 'answer') { + return; + } + + const type = getPushNotificationType(call); + // If the state changed before we had a chance to send the incoming call, skip it altogether + if (event === 'new' && type !== 'incoming_call') { + return; + } + if (type === 'incoming_call' && event !== 'new') { + return; + } + + const { id: userId, username } = call.callee; + const caller = await getActorUserData(call.caller); + + metrics.notificationsSent.inc({ notification_type: 'mobile' }); + await Push.send({ + useVoipToken: type === 'incoming_call', + priority: 10, + payload: { + host: Meteor.absoluteUrl(), + hostName: settings.get('Site_Name'), + notificationType: 'voip', + type, + callId: call._id, + caller, + username, + }, + userId, + notId: PushNotification.getNotificationId(call._id), + // We should not send state change notifications to the device where the call was accepted/rejected + ...(call.callee.contractId && { skipTokenId: call.callee.contractId }), + }); +} diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 6fc8682a3ca62..5278b73b41331 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -6,25 +6,19 @@ import type { IInternalMediaCallHistoryItem, CallHistoryItemState, IExternalMediaCallHistoryItem, - MediaCallContact, } 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 { 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'; +import { logger } from './logger'; +import { sendVoipPushNotification } from './push/sendVoipPushNotification'; import { sendMessage } from '../../../app/lib/server/functions/sendMessage'; -import { metrics } from '../../../app/metrics/server/lib/metrics'; -import { Push } from '../../../app/push/server/push'; -import PushNotification from '../../../app/push-notifications/server/lib/PushNotification'; import { settings } from '../../../app/settings/server'; -import { getUserAvatarURL } from '../../../app/utils/server/getUserAvatarURL'; import { createDirectMessage } from '../../methods/createDirectMessage'; -const logger = new Logger('media-call service'); - export class MediaCallService extends ServiceClassInternal implements IMediaCallService { protected name = 'media-call'; @@ -33,7 +27,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall callServer.emitter.on('signalRequest', ({ toUid, signal }) => this.sendSignal(toUid, signal)); callServer.emitter.on('callUpdated', (params) => api.broadcast('media-call.updated', params)); callServer.emitter.on('historyUpdate', ({ callId }) => setImmediate(() => this.saveCallToHistory(callId))); - callServer.emitter.on('pushNotificationRequest', ({ callId }) => this.sendPushNotification(callId)); + callServer.emitter.on('pushNotificationRequest', ({ callId, event }) => sendVoipPushNotification(callId, event)); this.onEvent('media-call.updated', (params) => callServer.receiveCallUpdate(params)); this.onEvent('watch.settings', async ({ setting }): Promise => { @@ -77,75 +71,6 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } } - private async getActorUser>( - actor: MediaCallContact, - ): Promise { - const options = { projection: { name: 1, username: 1 } }; - - switch (actor.type) { - case 'user': - return Users.findOneById(actor.id, options); - case 'sip': - return Users.findOneByFreeSwitchExtension(actor.id, options); - } - } - - private async getActorUserData(actor: MediaCallContact): Promise<{ name: string; avatarUrl?: string }> { - const user = await this.getActorUser(actor); - - if (user) { - return { - name: user.name || user.username || user.freeSwitchExtension || '', - ...(user.username && { avatarUrl: getUserAvatarURL(user.username) }), - }; - } - - if (actor.type === 'sip') { - return { - name: actor.displayName || actor.sipExtension || actor.id, - }; - } - - return { - name: actor.displayName || actor.username || '', - ...(actor.username && { avatarUrl: getUserAvatarURL(actor.username) }), - }; - } - - private async sendPushNotification(callId: IMediaCall['_id']): Promise { - const call = await MediaCalls.findOneById(callId); - if (!call) { - logger.error({ msg: 'Failed to send push notification: Media Call not found', callId }); - return; - } - - if (call.callee.type !== 'user') { - logger.error({ msg: 'Failed to send push notification: Invalid Callee Type', callId }); - return; - } - - const { id: userId, username } = call.callee; - const { name: caller, avatarUrl: avatar } = await this.getActorUserData(call.caller); - - metrics.notificationsSent.inc({ notification_type: 'mobile' }); - await Push.send({ - voip: true, - priority: 10, - payload: { - host: Meteor.absoluteUrl(), - hostName: settings.get('Site_Name'), - notificationType: 'voip', - ...(avatar && { avatar }), - state: call.state, - callId: call._id, - caller, - username, - }, - userId, - notId: PushNotification.getNotificationId(call._id), - }); - } - private async saveCallToHistory(callId: IMediaCall['_id']): Promise { logger.info({ msg: 'saving media call to history', callId }); diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts index c2eb3aa8ba9c5..80753bdd83c85 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallServer.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -4,11 +4,14 @@ import type { ClientMediaSignal, ClientMediaSignalBody, ServerMediaSignal } from import type { InternalCallParams } from './common'; +export type VoipPushNotificationType = 'incoming_call' | 'remoteEnded' | 'answeredElsewhere' | 'declinedElsewhere' | 'unanswered'; +export type VoipPushNotificationEventType = 'new' | 'answer' | 'end'; + export type MediaCallServerEvents = { callUpdated: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }; signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal }; historyUpdate: { callId: string }; - pushNotificationRequest: { callId: string }; + pushNotificationRequest: { callId: string; event: VoipPushNotificationEventType }; }; export interface IMediaCallServerSettings { @@ -40,7 +43,7 @@ export interface IMediaCallServer { sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void; reportCallUpdate(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void; updateCallHistory(params: { callId: string }): void; - sendPushNotification(params: { callId: string }): void; + sendPushNotification(params: { callId: string; event: VoipPushNotificationEventType }): void; // functions that are run on events receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): void; diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index ca1a0a5138572..a10166de4032a 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -33,6 +33,8 @@ export class UserActorAgent extends BaseMediaCallAgent { return; } + getMediaCallServer().sendPushNotification({ callId, event: 'answer' }); + const negotiation = await MediaCallNegotiations.findLatestByCallId(callId); if (!negotiation?.offer) { logger.debug('The call was accepted but the webrtc offer is not yet available.'); @@ -50,6 +52,10 @@ export class UserActorAgent extends BaseMediaCallAgent { } public async onCallEnded(callId: string): Promise { + if (this.role === 'callee') { + getMediaCallServer().sendPushNotification({ callId, event: 'end' }); + } + return this.sendSignal({ callId, type: 'notification', @@ -74,7 +80,7 @@ export class UserActorAgent extends BaseMediaCallAgent { await this.sendSignal(buildNewCallSignal(call, this.role)); if (this.role === 'callee') { - getMediaCallServer().sendPushNotification({ callId: call._id }); + getMediaCallServer().sendPushNotification({ callId: call._id, event: 'new' }); } } diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index b8dbd41aa1e4d..29f80168c7e0b 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -6,7 +6,12 @@ import type { CallRejectedReason, ClientMediaSignal, ClientMediaSignalBody, Serv import { mediaCallDirector } from './CallDirector'; import { getDefaultSettings } from './getDefaultSettings'; import { stripSensitiveDataFromSignal } from './stripSensitiveData'; -import type { IMediaCallServer, IMediaCallServerSettings, MediaCallServerEvents } from '../definition/IMediaCallServer'; +import type { + IMediaCallServer, + IMediaCallServerSettings, + MediaCallServerEvents, + VoipPushNotificationEventType, +} from '../definition/IMediaCallServer'; import { CallRejectedError, type GetActorContactOptions, type InternalCallParams } from '../definition/common'; import { InternalCallProvider } from '../internal/InternalCallProvider'; import { GlobalSignalProcessor } from '../internal/SignalProcessor'; @@ -70,7 +75,7 @@ export class MediaCallServer implements IMediaCallServer { this.emitter.emit('historyUpdate', params); } - public sendPushNotification(params: { callId: string }): void { + public sendPushNotification(params: { callId: string; event: VoipPushNotificationEventType }): void { logger.debug({ msg: 'MediaCallServer.sendPushNotification', params }); this.emitter.emit('pushNotificationRequest', params); diff --git a/packages/core-typings/src/IPushNotificationConfig.ts b/packages/core-typings/src/IPushNotificationConfig.ts index 6aab7ead620ee..79562f4d78b28 100644 --- a/packages/core-typings/src/IPushNotificationConfig.ts +++ b/packages/core-typings/src/IPushNotificationConfig.ts @@ -1,3 +1,5 @@ +import type { IPushToken } from './IPushToken'; + export interface IPushNotificationConfig { from?: string; badge?: number; @@ -15,5 +17,6 @@ export interface IPushNotificationConfig { apn?: { category: string; }; - voip?: boolean; + useVoipToken?: boolean; + skipTokenId?: IPushToken['_id']; } diff --git a/packages/model-typings/src/models/IPushTokenModel.ts b/packages/model-typings/src/models/IPushTokenModel.ts index 9b5748d7ab60e..e47822fd4ebad 100644 --- a/packages/model-typings/src/models/IPushTokenModel.ts +++ b/packages/model-typings/src/models/IPushTokenModel.ts @@ -10,6 +10,11 @@ export interface IPushTokenModel extends IBaseModel { findOneByTokenAndAppName(token: IPushToken['token'], appName: IPushToken['appName']): Promise; findFirstByUserId(userId: IUser['_id'], options?: FindOptions): Promise; findAllTokensByUserId(userId: IUser['_id'], options?: FindOptions): FindCursor; + findTokensByUserIdExceptId( + userId: IUser['_id'], + idToIgnore: IPushToken['_id'], + options?: FindOptions, + ): FindCursor; insertToken(data: AtLeast): Promise>; refreshTokenById( diff --git a/packages/models/src/models/PushToken.ts b/packages/models/src/models/PushToken.ts index 05dec5607a0e3..6af60be10f909 100644 --- a/packages/models/src/models/PushToken.ts +++ b/packages/models/src/models/PushToken.ts @@ -56,6 +56,21 @@ export class PushTokenRaw extends BaseRaw implements IPushTokenModel ); } + findTokensByUserIdExceptId( + userId: IUser['_id'], + idToIgnore: IPushToken['_id'], + options?: FindOptions, + ): FindCursor { + return this.find( + { + _id: { $ne: idToIgnore }, + userId, + $or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }], + }, + options, + ); + } + async insertToken(data: AtLeast): Promise> { return this.insertOne({ enabled: true, From 380f0e7df33ebfffc5e880923294da0e24a92c2d Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Fri, 6 Mar 2026 17:06:04 -0300 Subject: [PATCH 07/37] shrink changes --- apps/meteor/app/push/server/push.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 7dcd75048f117..5d8fbe036aa23 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -473,10 +473,11 @@ class PushClass { createdBy: '', sent: false, sending: 0, - ...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority', 'useVoipToken'), ...(options.title && { title: truncateString(options.title, PUSH_TITLE_LIMIT) }), ...(options.text && { text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT) }), + ...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority', 'useVoipToken'), + ...(this.hasApnOptions(options) ? { apn: { From d990acaa0e17ca694b9bbcde80632714c15f91bf Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Fri, 6 Mar 2026 17:27:30 -0300 Subject: [PATCH 08/37] use UUIDs --- apps/meteor/app/push/server/apn.ts | 2 +- ee/packages/media-calls/src/server/CallDirector.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/push/server/apn.ts b/apps/meteor/app/push/server/apn.ts index a73b976c4c5e1..0982ca0a4d91f 100644 --- a/apps/meteor/app/push/server/apn.ts +++ b/apps/meteor/app/push/server/apn.ts @@ -34,7 +34,7 @@ export const sendAPN = ({ const note = new apn.Notification(); - if (notification.voip) { + if (notification.useVoipToken) { note.expiry = Math.floor(Date.now() / 1000) + 60; // Expires in 60 seconds note.pushType = 'voip'; } else { diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index e59a6dca3412e..7c60ae6eb4a76 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'crypto'; + import type { IMediaCall, IMediaCallNegotiation, @@ -212,7 +214,9 @@ class MediaCallDirector { callerAgent.oppositeAgent = calleeAgent; calleeAgent.oppositeAgent = callerAgent; - const call: Omit = { + const call: Omit = { + // Use UUIDs to identify all media calls, for better compatibility with libs that require it (such as React Native's CallKit) + _id: randomUUID(), service, kind: 'direct', state: 'none', From 424406ad540f20c399a51d77e614cd353ba693e5 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 9 Mar 2026 16:16:12 -0300 Subject: [PATCH 09/37] add createdAt to voip push notification payload --- .../server/services/media-call/push/sendVoipPushNotification.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts index 046488ecd2d84..ee05f0b6e7850 100644 --- a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts +++ b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts @@ -94,6 +94,7 @@ export async function sendVoipPushNotification(callId: IMediaCall['_id'], event: callId: call._id, caller, username, + createdAt: call.createdAt.toISOString(), }, userId, notId: PushNotification.getNotificationId(call._id), From cb4338fccca0600f948b2577d6683382afa322f7 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 9 Mar 2026 17:49:23 -0300 Subject: [PATCH 10/37] code review --- apps/meteor/app/push/server/push.ts | 8 ++++---- .../media-call/push/getPushNotificationType.ts | 3 +-- .../media-call/push/sendVoipPushNotification.ts | 8 +++++++- packages/core-typings/src/index.ts | 2 +- .../model-typings/src/models/IPushTokenModel.ts | 2 +- packages/models/src/models/PushToken.ts | 15 ++++++++++----- 6 files changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 5d8fbe036aa23..a546a4ff36788 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -168,7 +168,7 @@ class PushClass { } private removeToken(token: string): void { - void PushToken.removeAllByTokenString(token).catch((err) => { + void PushToken.removeOrUnsetByTokenString(token).catch((err) => { logger.error({ msg: 'Failed to remove push token', err }); }); } @@ -189,9 +189,9 @@ class PushClass { const userToken = notification.useVoipToken ? app.voipToken : app.token.apn; const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName; - countApn.push(app._id); // Send to APN if (this.options.apn && userToken) { + countApn.push(app._id); sendAPN({ userToken, notification: { topic, ...notification }, _removeToken: this.removeToken }); } } else if ('gcm' in app.token && app.token.gcm) { @@ -308,9 +308,9 @@ class PushClass { } private getGatewayNotificationData(notification: PendingPushNotification): Omit { - // Gateway currently accepts every attribute from the PendingPushNotification type, except for the priority + // Gateway currently accepts every attribute from the PendingPushNotification type, except for the priority and useVoipToken // If new attributes are added to the PendingPushNotification type, they'll need to be removed here as well. - const { priority: _priority, ...notifData } = notification; + const { priority: _priority, useVoipToken: _useVoipToken, ...notifData } = notification; return { ...notifData, diff --git a/apps/meteor/server/services/media-call/push/getPushNotificationType.ts b/apps/meteor/server/services/media-call/push/getPushNotificationType.ts index 614c6820eb070..8683eaf705459 100644 --- a/apps/meteor/server/services/media-call/push/getPushNotificationType.ts +++ b/apps/meteor/server/services/media-call/push/getPushNotificationType.ts @@ -1,6 +1,5 @@ import type { IMediaCall } from '@rocket.chat/core-typings'; - -export type VoipPushNotificationType = 'incoming_call' | 'remoteEnded' | 'answeredElsewhere' | 'declinedElsewhere' | 'unanswered'; +import type { VoipPushNotificationType } from '@rocket.chat/media-calls'; export function getPushNotificationType(call: IMediaCall): VoipPushNotificationType { if (call.acceptedAt) { diff --git a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts index ee05f0b6e7850..1eb4960484279 100644 --- a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts +++ b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts @@ -53,7 +53,7 @@ async function getActorUserData( }; } -export async function sendVoipPushNotification(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): Promise { +async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): Promise { const call = await MediaCalls.findOneById(callId); if (!call) { logger.error({ msg: 'Failed to send push notification: Media Call not found', callId }); @@ -102,3 +102,9 @@ export async function sendVoipPushNotification(callId: IMediaCall['_id'], event: ...(call.callee.contractId && { skipTokenId: call.callee.contractId }), }); } + +export function sendVoipPushNotification(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): void { + void sendVoipPushNotificationAsync(callId, event).catch((err) => { + logger.error({ msg: 'Failed to send VoIP push notification', err, callId, event }); + }) +} diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 011df49c0f28c..35ea69ad055eb 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -27,7 +27,7 @@ export type * from './ICustomSound'; export type * from './ICloud'; export * from './IServerEvent'; export type * from './IRocketChatAssets'; -export type * from './IPushToken'; +export * from './IPushToken'; export type * from './IPushNotificationConfig'; export type * from './SlashCommands'; export * from './license'; diff --git a/packages/model-typings/src/models/IPushTokenModel.ts b/packages/model-typings/src/models/IPushTokenModel.ts index e47822fd4ebad..4b907ce47ee24 100644 --- a/packages/model-typings/src/models/IPushTokenModel.ts +++ b/packages/model-typings/src/models/IPushTokenModel.ts @@ -27,5 +27,5 @@ export interface IPushTokenModel extends IBaseModel { removeAllByUserId(userId: string): Promise; removeAllByTokenStringAndUserId(token: string, userId: string): Promise; - removeAllByTokenString(token: string): Promise; + removeOrUnsetByTokenString(token: string): Promise; } diff --git a/packages/models/src/models/PushToken.ts b/packages/models/src/models/PushToken.ts index 6af60be10f909..e30f2cc5c0097 100644 --- a/packages/models/src/models/PushToken.ts +++ b/packages/models/src/models/PushToken.ts @@ -150,8 +150,8 @@ export class PushTokenRaw extends BaseRaw implements IPushTokenModel }); } - removeAllByTokenString(token: string): Promise { - return this.deleteMany({ + async removeOrUnsetByTokenString(token: string): Promise { + await this.deleteMany({ $or: [ { 'token.apn': token, @@ -159,10 +159,15 @@ export class PushTokenRaw extends BaseRaw implements IPushTokenModel { 'token.gcm': token, }, - { - voipToken: token, - }, ], }); + + await this.updateMany({ + voipToken: token, + }, { + $unset: { + voipToken: 1, + }, + }); } } From 7096fdfcda4ca71e69bb7745f1b1b2d7e54b1dc5 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 9 Mar 2026 17:53:32 -0300 Subject: [PATCH 11/37] more changes from code review --- apps/meteor/app/api/server/v1/push.ts | 1 + .../server/services/media-call/push/sendVoipPushNotification.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/meteor/app/api/server/v1/push.ts b/apps/meteor/app/api/server/v1/push.ts index 27ed31d3c7fd4..3fe6765393ecf 100644 --- a/apps/meteor/app/api/server/v1/push.ts +++ b/apps/meteor/app/api/server/v1/push.ts @@ -149,6 +149,7 @@ const pushTokenEndpoints = API.v1 }, voipToken: { type: 'string', + nullable: true, }, }, additionalProperties: false, diff --git a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts index 1eb4960484279..78ccd95ce9951 100644 --- a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts +++ b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts @@ -13,7 +13,7 @@ import { logger } from '../logger'; async function getActorUser>( actor: MediaCallContact, ): Promise { - const options = { projection: { name: 1, username: 1 } }; + const options = { projection: { name: 1, username: 1, freeSwitchExtension: 1 } }; switch (actor.type) { case 'user': From 021bb35a615a59e7e6f5dd107446aaae8ba3e2f2 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 9 Mar 2026 18:12:48 -0300 Subject: [PATCH 12/37] adjust maxRetries --- apps/meteor/app/push/server/push.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index a546a4ff36788..b1b30d745f83c 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -18,7 +18,7 @@ export const _matchToken = Match.OneOf({ apn: String }, { gcm: String }); const PUSH_TITLE_LIMIT = 65; const PUSH_MESSAGE_BODY_LIMIT = 240; -const PUSH_GATEWAY_MAX_ATTEMPTS = 5; +const PUSH_GATEWAY_MAX_RETRIES = 5; type FCMCredentials = { type: string; @@ -255,7 +255,7 @@ class PushClass { service: 'apn' | 'gcm', token: string, notification: Optional, - retryOptions: { tries: number; maxTries: number } = { tries: 0, maxTries: PUSH_GATEWAY_MAX_ATTEMPTS }, + retryOptions: { tries: number; maxRetries: number } = { tries: 0, maxRetries: PUSH_GATEWAY_MAX_RETRIES }, ): Promise { notification.uniqueId = this.options.uniqueId; @@ -293,17 +293,17 @@ class PushClass { return; } - const { tries, maxTries } = retryOptions; + const { tries, maxRetries } = retryOptions; logger.error({ msg: 'Error sending push to gateway', tries, err: response }); - if (tries < maxTries) { + if (tries < maxRetries) { // [1, 2, 4, 8, 16] minutes (total 31) const ms = 60000 * Math.pow(2, tries); logger.log({ msg: 'Retrying push to gateway', tries: tries + 1, in: ms }); - setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, { tries: tries + 1, maxTries }), ms); + setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, { tries: tries + 1, maxRetries }), ms); } } @@ -330,7 +330,7 @@ class PushClass { const gatewayNotification = this.getGatewayNotificationData(notification); const retryOptions = { tries: 0, - maxTries: notification.useVoipToken ? 1 : PUSH_GATEWAY_MAX_ATTEMPTS, + maxRetries: notification.useVoipToken ? 0 : PUSH_GATEWAY_MAX_RETRIES, }; for (const gateway of this.options.gateways) { From 36adc1ff30b745c2816bd6eaa0891c3d9f3b598f Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 9 Mar 2026 18:16:16 -0300 Subject: [PATCH 13/37] import meteor explicitly --- .../services/media-call/push/sendVoipPushNotification.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts index 78ccd95ce9951..da746889e2296 100644 --- a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts +++ b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts @@ -1,6 +1,7 @@ import type { IMediaCall, IUser, MediaCallContact, MediaCallActorType } from '@rocket.chat/core-typings'; import type { VoipPushNotificationEventType } from '@rocket.chat/media-calls'; import { MediaCalls, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; import { getPushNotificationType } from './getPushNotificationType'; import { metrics } from '../../../../app/metrics/server/lib/metrics'; @@ -106,5 +107,5 @@ async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: V export function sendVoipPushNotification(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): void { void sendVoipPushNotificationAsync(callId, event).catch((err) => { logger.error({ msg: 'Failed to send VoIP push notification', err, callId, event }); - }) + }); } From 395f8ad975177c2040e9f98fad2397b6a555166b Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 9 Mar 2026 18:28:10 -0300 Subject: [PATCH 14/37] LSP crashed once again --- packages/models/src/models/PushToken.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/models/src/models/PushToken.ts b/packages/models/src/models/PushToken.ts index e30f2cc5c0097..22c11f111855f 100644 --- a/packages/models/src/models/PushToken.ts +++ b/packages/models/src/models/PushToken.ts @@ -162,12 +162,15 @@ export class PushTokenRaw extends BaseRaw implements IPushTokenModel ], }); - await this.updateMany({ - voipToken: token, - }, { - $unset: { - voipToken: 1, + await this.updateMany( + { + voipToken: token, }, - }); + { + $unset: { + voipToken: 1, + }, + }, + ); } } From 65c07d6bb5fd6fbd30a95ec00bed1cbd41fb5572 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 10 Mar 2026 11:42:31 -0300 Subject: [PATCH 15/37] add caller username to push payload --- .../media-call/push/sendVoipPushNotification.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts index da746889e2296..168acb76115fb 100644 --- a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts +++ b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts @@ -26,7 +26,7 @@ async function getActorUser { +): Promise<{ type: MediaCallActorType; id: string; name: string; avatarUrl?: string; username?: string }> { const actorUsername = actor.type === 'user' ? actor.username : undefined; const actorExtension = actor.sipExtension || (actor.type === 'sip' ? actor.id : undefined); @@ -34,7 +34,7 @@ async function getActorUserData( type: actor.type, id: actor.id, name: actor.displayName || actorUsername || actorExtension || '', - }; + } as const; const user = await getActorUser(actor); @@ -44,13 +44,13 @@ async function getActorUserData( return { ...data, name: user.name || user.username || user.freeSwitchExtension || data.name, - ...(username && { avatarUrl: getUserAvatarURL(username) }), + ...(username && { username, avatarUrl: getUserAvatarURL(username) }), }; } return { ...data, - ...(actorUsername && { avatarUrl: getUserAvatarURL(actorUsername) }), + ...(actorUsername && { username: actorUsername, avatarUrl: getUserAvatarURL(actorUsername) }), }; } @@ -80,7 +80,7 @@ async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: V return; } - const { id: userId, username } = call.callee; + const { id: userId } = call.callee; const caller = await getActorUserData(call.caller); metrics.notificationsSent.inc({ notification_type: 'mobile' }); @@ -94,7 +94,6 @@ async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: V type, callId: call._id, caller, - username, createdAt: call.createdAt.toISOString(), }, userId, From 533d8058d4fb05998ca353f5a6a0fbb40bb2bd9f Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 10 Mar 2026 13:33:36 -0300 Subject: [PATCH 16/37] add call kind to the notification payload --- .../services/media-call/push/sendVoipPushNotification.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts index 168acb76115fb..372cb5a53f5fa 100644 --- a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts +++ b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts @@ -80,7 +80,10 @@ async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: V return; } - const { id: userId } = call.callee; + const { + kind, + callee: { id: userId }, + } = call; const caller = await getActorUserData(call.caller); metrics.notificationsSent.inc({ notification_type: 'mobile' }); @@ -92,6 +95,7 @@ async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: V hostName: settings.get('Site_Name'), notificationType: 'voip', type, + kind, callId: call._id, caller, createdAt: call.createdAt.toISOString(), From a9769552d1ed2d1e25b8a864467f938b9cc76561 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:11:45 -0300 Subject: [PATCH 17/37] Update apps/meteor/server/services/media-call/logger.ts Co-authored-by: Kevin Aleman --- apps/meteor/server/services/media-call/logger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/server/services/media-call/logger.ts b/apps/meteor/server/services/media-call/logger.ts index 2c59c042c4b57..a021409284141 100644 --- a/apps/meteor/server/services/media-call/logger.ts +++ b/apps/meteor/server/services/media-call/logger.ts @@ -1,3 +1,3 @@ import { Logger } from '@rocket.chat/logger'; -export const logger = new Logger('media-call service'); +export const logger = new Logger('MediaCall'); From 38d455bb7af37dcf906688081b8d89b1ffcee77d Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 23 Mar 2026 18:15:52 -0300 Subject: [PATCH 18/37] changes to signaling lib to support accepting a call received via push --- ee/packages/media-calls/src/base/BaseAgent.ts | 2 +- .../src/internal/SignalProcessor.ts | 61 ++++++--- .../src/internal/agents/UserActorAgent.ts | 29 ++++- .../src/definition/call/IClientMediaCall.ts | 3 +- .../definition/signals/server/MediaSignal.ts | 7 +- .../src/definition/signals/server/index.ts | 1 + .../definition/signals/server/registered.ts | 8 ++ packages/media-signaling/src/lib/Call.ts | 95 ++++++++++---- packages/media-signaling/src/lib/Session.ts | 122 +++++++++++++----- .../src/lib/components/SessionRegistration.ts | 73 +++++++++++ 10 files changed, 318 insertions(+), 83 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/base/BaseAgent.ts b/ee/packages/media-calls/src/base/BaseAgent.ts index 05f3fa76dba54..61f0090272922 100644 --- a/ee/packages/media-calls/src/base/BaseAgent.ts +++ b/ee/packages/media-calls/src/base/BaseAgent.ts @@ -63,7 +63,7 @@ export abstract class BaseMediaCallAgent implements IMediaCallAgent { public abstract onCallAccepted(callId: string, data: { signedContractId: string; features: CallFeature[] }): Promise; - public abstract onCallActive(callId: string): Promise; + public abstract onCallActive(callId: string, data?: { signedContractId?: string }): Promise; public abstract onCallEnded(callId: string): Promise; diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index 35d9b5546198e..80cac10c5c5fc 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -2,6 +2,7 @@ import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { isPendingState } from '@rocket.chat/media-signaling'; import type { + CallFeature, ClientMediaSignal, ClientMediaSignalRegister, ClientMediaSignalRequestCall, @@ -114,6 +115,12 @@ export class GlobalSignalProcessor { logger.debug({ msg: 'GlobalSignalProcessor.processRegisterSignal', signal: stripSensitiveDataFromSignal(signal), uid }); const calls = await MediaCalls.findAllNotOverByUid(uid).toArray(); + this.sendSignal(uid, { + type: 'registered', + toContractId: signal.contractId, + activeCalls: calls.map(({ _id }) => _id), + }); + if (!calls.length) { return; } @@ -136,34 +143,46 @@ export class GlobalSignalProcessor { 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) - if (actor.contractId === signal.oldContractId) { - logger.info({ msg: 'Server detected a client refresh for a session with an active call.', callId: call._id }); - await mediaCallDirector.hangupDetachedCall(call, { endedBy: { ...actor, contractId: signal.contractId }, reason: 'unknown' }); - return; - } - } else { - await mediaCallDirector.renewCallId(call._id); + // If this user's side of the call is signed to a session that the current session is replacing (as in a browser refresh) + if (actor.contractId && actor.contractId === signal.oldContractId) { + logger.info({ msg: 'Server detected a client refresh for a session with an active call.', callId: call._id }); + await mediaCallDirector.hangupDetachedCall(call, { endedBy: { ...actor, contractId: signal.contractId }, reason: 'unknown' }); + return; } - this.sendSignal(uid, buildNewCallSignal(call, role)); + await mediaCallDirector.renewCallId(call._id); - if (call.state === 'active') { - this.sendSignal(uid, { + const agents = await mediaCallDirector.cast.getAgentsFromCall(call); + const { [role]: agent } = agents; + + const otherAgent = agent.oppositeAgent; + if (otherAgent && otherAgent instanceof UserActorAgent) { + await otherAgent.sendSignal({ callId: call._id, type: 'notification', - notification: 'active', - ...(actor.contractId && { signedContractId: actor.contractId }), + notification: 'trying', }); - } else if (actor.contractId && !isPendingState(call.state)) { - this.sendSignal(uid, { - callId: call._id, - type: 'notification', - notification: 'accepted', - signedContractId: actor.contractId, + } + + if (!(agent instanceof UserActorAgent)) { + logger.error({ + msg: 'Actor agent is not prepared to process signals', + method: 'reactToUnknownCall', + signal: stripSensitiveDataFromSignal(signal), + isCaller, + isCallee, }); + throw new Error('internal-error'); + } + + agent.disablePushNotifications(); + + await agent.onCallCreated(call); + + if (call.state === 'active') { + await agent.onCallActive(call._id, actor.contractId ? { signedContractId: actor.contractId } : undefined); + } else if (actor.contractId && !isPendingState(call.state)) { + await agent.onCallAccepted(call._id, { signedContractId: actor.contractId, features: call.features as CallFeature[] }); } } diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index 41903e3807dbf..16fd06c8a9fb5 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -5,11 +5,18 @@ import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { UserActorSignalProcessor } from './CallSignalProcessor'; import { BaseMediaCallAgent } from '../../base/BaseAgent'; +import type { VoipPushNotificationEventType } from '../../definition/IMediaCallServer'; import { logger } from '../../logger'; import { buildNewCallSignal } from '../../server/buildNewCallSignal'; import { getMediaCallServer } from '../../server/injection'; export class UserActorAgent extends BaseMediaCallAgent { + private pushNotificationsEnabled = true; + + public disablePushNotifications(): void { + this.pushNotificationsEnabled = false; + } + public async processSignal(call: IMediaCall, signal: ClientMediaSignal): Promise { const channel = await this.getOrCreateChannel(call, signal.contractId); @@ -33,7 +40,7 @@ export class UserActorAgent extends BaseMediaCallAgent { return; } - getMediaCallServer().sendPushNotification({ callId, event: 'answer' }); + this.sendPushNotification({ callId, event: 'answer' }); const negotiation = await MediaCallNegotiations.findLatestByCallId(callId); if (!negotiation?.offer) { @@ -41,6 +48,11 @@ export class UserActorAgent extends BaseMediaCallAgent { return; } + if (negotiation.offerer !== 'caller') { + logger.debug('onCallAccepted event was triggered with a renegotiation already in place.'); + return; + } + await this.sendSignal({ callId, toContractId: data.signedContractId, @@ -53,7 +65,7 @@ export class UserActorAgent extends BaseMediaCallAgent { public async onCallEnded(callId: string): Promise { if (this.role === 'callee') { - getMediaCallServer().sendPushNotification({ callId, event: 'end' }); + this.sendPushNotification({ callId, event: 'end' }); } return this.sendSignal({ @@ -63,11 +75,12 @@ export class UserActorAgent extends BaseMediaCallAgent { }); } - public async onCallActive(callId: string): Promise { + public async onCallActive(callId: string, data: { signedContractId?: string } = {}): Promise { return this.sendSignal({ callId, type: 'notification', notification: 'active', + ...(data.signedContractId && { signedContractId: data.signedContractId }), }); } @@ -80,7 +93,7 @@ export class UserActorAgent extends BaseMediaCallAgent { await this.sendSignal(buildNewCallSignal(call, this.role)); if (this.role === 'callee') { - getMediaCallServer().sendPushNotification({ callId: call._id, event: 'new' }); + this.sendPushNotification({ callId: call._id, event: 'new' }); } } @@ -169,4 +182,12 @@ export class UserActorAgent extends BaseMediaCallAgent { logger.debug({ msg: 'UserActorAgent.onDTMF', callId, dtmf, duration, role: this.role }); // internal calls have nothing to do with DTMFs } + + private sendPushNotification(params: { callId: string; event: VoipPushNotificationEventType }): void { + if (!this.pushNotificationsEnabled) { + return; + } + + getMediaCallServer().sendPushNotification(params); + } } diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index b2856303abd06..c408e9c6e5438 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -60,7 +60,8 @@ export type CallAnswer = export type CallNotification = | 'accepted' // notify that the call has been accepted by both actors | 'active' // notify that call activity was confirmed - | 'hangup'; // notify that the call is over; + | 'hangup' // notify that the call is over; + | 'trying'; // notify that the other client is connecting but still need more time export type CallRejectedReason = | 'invalid-call-id' // the call id can't be used for a new call diff --git a/packages/media-signaling/src/definition/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..25392c5cb113c --- /dev/null +++ b/packages/media-signaling/src/definition/signals/server/registered.ts @@ -0,0 +1,8 @@ +/** Server is notifying the client that its registration was processed */ +export type ServerMediaSignalRegistered = { + type: 'registered'; + + toContractId: string; + + activeCalls: string[]; +}; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 0cf9426f487d2..4fcd0ac740229 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -40,7 +40,7 @@ export interface IClientMediaCallConfig { supportedFeatures: CallFeature[]; } -const TIMEOUT_TO_ACCEPT = 30000; +const TIMEOUT_TO_ACCEPT = 60000; const TIMEOUT_TO_CONFIRM_ACCEPTANCE = 2000; const TIMEOUT_TO_PROGRESS_SIGNALING = 10000; const STATE_REPORT_DELAY = 300; @@ -49,9 +49,10 @@ const CALLS_WITH_NO_REMOTE_DATA_REPORT_DELAY = 5000; // if the server tells us we're the caller in a call we don't recognize, ignore it completely const AUTO_IGNORE_UNKNOWN_OUTBOUND_CALLS = true; -type StateTimeoutHandler = { +type StateTimeoutData = { state: ClientState; - handler: ReturnType; + reset: () => void; + clear: () => void; }; export class ClientMediaCall implements IClientMediaCall { @@ -165,6 +166,8 @@ export class ClientMediaCall implements IClientMediaCall { private acceptedLocally: boolean; + private acceptedRemotely: boolean; + private endedLocally: boolean; private hasRemoteData: boolean; @@ -175,7 +178,7 @@ export class ClientMediaCall implements IClientMediaCall { private earlySignals: Set; - private stateTimeoutHandlers: Set; + private stateTimeoutHandlers: Set; private remoteCallId: string | null; @@ -229,6 +232,7 @@ export class ClientMediaCall implements IClientMediaCall { this.remoteCallId = null; this.acceptedLocally = false; + this.acceptedRemotely = false; this.endedLocally = false; this.hasRemoteData = false; this.initialized = false; @@ -588,6 +592,12 @@ export class ClientMediaCall implements IClientMediaCall { } this.acceptedLocally = true; + // If the server already signed us into this call, go straight to the accepted state + if (this.acceptedRemotely) { + this.changeState('accepted'); + return; + } + this.config.transporter.answer(this.callId, 'accept', { supportedFeatures: this.config.supportedFeatures }); if (this.getClientState() === 'accepting') { @@ -1061,6 +1071,9 @@ export class ClientMediaCall implements IClientMediaCall { this.changeState('active'); } return; + case 'trying': + this.resetStateTimeouts(); + break; case 'hangup': return this.flagAsEnded('remote'); @@ -1068,7 +1081,12 @@ export class ClientMediaCall implements IClientMediaCall { } private async flagAsAccepted(enabledFeatures?: CallFeature[]): Promise { + if (!this.isPendingAcceptance()) { + return; + } + this.config.logger?.debug('ClientMediaCall.flagAsAccepted'); + this.acceptedRemotely = true; if (enabledFeatures && this._state !== 'accepted') { this.enabledFeatures = enabledFeatures; @@ -1080,16 +1098,15 @@ export class ClientMediaCall implements IClientMediaCall { return; } - if (!this.acceptedLocally) { - this.config.transporter.sendError(this.callId, { errorType: 'signaling', errorCode: 'not-accepted', critical: true }); - this.config.logger?.error('Trying to activate a call that was not yet accepted locally.'); - return; - } - if (this.contractState === 'proposed') { this.contractState = 'self-signed'; } + if (!this.acceptedLocally) { + this.config.logger?.debug('Server signed us into a call that we have not yet accepted locally.'); + return; + } + // Both sides of the call have accepted it, we can change the state now this.changeState('accepted'); } @@ -1117,26 +1134,39 @@ export class ClientMediaCall implements IClientMediaCall { return; } - const handler = { + let handler: ReturnType | null = null; + + const data = { state, - handler: setTimeout(() => { - if (this.stateTimeoutHandlers.has(handler)) { - this.stateTimeoutHandlers.delete(handler); + clear: () => { + if (handler) { + clearTimeout(handler); } + handler = null; + }, + reset: () => { + data.clear(); + handler = setTimeout(() => { + if (this.stateTimeoutHandlers.has(data)) { + this.stateTimeoutHandlers.delete(data); + } - if (state !== this.getClientState()) { - return; - } + if (state !== this.getClientState()) { + return; + } - if (callback) { - callback(); - } else { - void this.hangup(this.getTimeoutHangupReason(state)); - } - }, timeout), + if (callback) { + callback(); + } else { + void this.hangup(this.getTimeoutHangupReason(state)); + } + }, timeout); + }, }; - this.stateTimeoutHandlers.add(handler); + data.reset(); + + this.stateTimeoutHandlers.add(data); } private getTimeoutHangupReason(state: ClientState): CallHangupReason { @@ -1155,6 +1185,19 @@ export class ClientMediaCall implements IClientMediaCall { return 'timeout'; } + private resetStateTimeouts(): void { + this.config.logger?.debug('ClientMediaCall.resetStateTimeouts'); + const clientState = this.getClientState(); + + for (const handler of this.stateTimeoutHandlers.values()) { + if (handler.state !== clientState) { + continue; + } + + handler.reset(); + } + } + private updateStateTimeouts(): void { this.config.logger?.debug('ClientMediaCall.updateStateTimeouts'); const clientState = this.getClientState(); @@ -1164,14 +1207,14 @@ export class ClientMediaCall implements IClientMediaCall { continue; } - clearTimeout(handler.handler); + handler.reset(); this.stateTimeoutHandlers.delete(handler); } } private clearStateTimeouts(): void { for (const handler of this.stateTimeoutHandlers.values()) { - clearTimeout(handler.handler); + handler.reset(); } this.stateTimeoutHandlers.clear(); } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index b3f4f80668d2c..1e2118f355589 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 } from '../definition/call'; import type { IMediaSignalLogger } from '../definition/logger'; +import { SessionRegistration } from './components/SessionRegistration'; export type MediaSignalingEvents = { sessionStateChange: void; @@ -19,6 +23,7 @@ export type MediaSignalingEvents = { acceptedCall: { call: IClientMediaCall }; endedCall: void; hiddenCall: void; + registered: { activeCalls: IClientMediaCall['callId'][] }; }; export type MediaSignalingSessionConfig = { @@ -65,6 +70,10 @@ export class MediaSignalingSession extends Emitter { private lastState: { hasCall: boolean; hasVisibleCall: boolean; hasBusyCall: boolean }; + private sessionEnded = false; + + private registration: SessionRegistration; + public get sessionId(): string { return this._sessionId; } @@ -73,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; @@ -88,6 +101,10 @@ 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(); this.enableStateReport(STATE_REPORT_INTERVAL); @@ -113,6 +130,8 @@ export class MediaSignalingSession extends Emitter { } public endSession(): void { + this.sessionEnded = true; + this.registration.sessionEnded = true; this.disableStateReport(); // best‑effort: stop capturing audio @@ -160,29 +179,15 @@ export class MediaSignalingSession extends Emitter { } public async processSignal(signal: ServerMediaSignal): Promise { - this.config.logger?.debug('MediaSignalingSession.processSignal', signal); - - if (this.isCallIgnored(signal.callId)) { + if (this.sessionEnded) { return; } - - const call = this.getOrCreateCallBySignal(signal); - - if (signal.type === 'notification' && signal.signedContractId) { - if (signal.signedContractId === this._sessionId) { - call.setContractState('signed'); - } else if (signal.notification === 'accepted') { - // The server accepted a contract, but it wasn't ours - ignore the call in this session - call.setContractState('ignored'); - } - } else if ('toContractId' in signal) { - call.setContractState(signal.toContractId === this._sessionId ? 'signed' : 'ignored'); - } else if (signal.type === 'new' && signal.self.contractId) { - call.setContractState(signal.self.contractId === this._sessionId ? 'signed' : 'ignored'); + this.config.logger?.debug('MediaSignalingSession.processSignal', signal); + if ('callId' in signal) { + return this.processCallSignal(signal); } - const oldCall = this.getReplacedCallBySignal(signal); - await call.processSignal(signal, oldCall); + return this.processSessionSignal(signal); } public async setDeviceId(deviceId: ConstrainDOMString | null): Promise { @@ -215,13 +220,7 @@ export class MediaSignalingSession extends Emitter { } public register(): void { - this.lastRegisterTimestamp = new Date(); - - this.transporter.sendSignal({ - type: 'register', - contractId: this._sessionId, - ...(this.config.oldSessionId && { oldContractId: this.config.oldSessionId }), - }); + this.registration.reRegister(); } public setIceGatheringTimeout(newTimeout: number): void { @@ -252,7 +251,7 @@ export class MediaSignalingSession extends Emitter { } } - private getExistingCallBySignal(signal: ServerMediaSignal): ClientMediaCall | null { + private getExistingCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall | null { const existingCall = this.knownCalls.get(signal.callId); if (existingCall) { return existingCall; @@ -278,7 +277,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) { @@ -328,7 +327,7 @@ export class MediaSignalingSession extends Emitter { } } - this.register(); + this.registration.register(); } private async setInputTrack(newInputTrack: MediaStreamTrack | null): Promise { @@ -524,6 +523,62 @@ export class MediaSignalingSession extends Emitter { await this.setScreenVideoTrack(track, call); } + private sendRegisterSignal(): void { + this.lastRegisterTimestamp = new Date(); + this.transporter.sendSignal({ + type: 'register', + contractId: this._sessionId, + ...(this.config.oldSessionId && { oldContractId: this.config.oldSessionId }), + }); + } + + private async processCallSignal(signal: ServerMediaCallSignal): Promise { + if (this.isCallIgnored(signal.callId)) { + return; + } + + const call = this.getOrCreateCallBySignal(signal); + + if (signal.type === 'notification' && signal.signedContractId) { + if (signal.signedContractId === this._sessionId) { + call.setContractState('signed'); + } else if (signal.notification === 'accepted') { + // The server accepted a contract, but it wasn't ours - ignore the call in this session + call.setContractState('ignored'); + } + } else if ('toContractId' in signal) { + call.setContractState(signal.toContractId === this._sessionId ? 'signed' : 'ignored'); + } else if (signal.type === 'new' && signal.self.contractId) { + call.setContractState(signal.self.contractId === this._sessionId ? 'signed' : 'ignored'); + } + + const oldCall = this.getReplacedCallBySignal(signal); + await call.processSignal(signal, oldCall); + } + + private processSessionSignal(signal: ServerMediaSessionSignal): void { + if (signal.toContractId !== this._sessionId) { + return; + } + + switch (signal.type) { + case 'registered': + return this.confirmSessionRegistered(signal); + } + } + + private confirmSessionRegistered(signal: ServerMediaSignalRegistered): void { + this.config.logger?.debug('MediaSignalingSession.sessionRegistered'); + const wasRegistered = this.registered; + this.registration.confirmRegistration(); + + this.emit('registered', { activeCalls: signal.activeCalls }); + + if (!wasRegistered) { + this.onSessionStateChange(); + } + } + private createCall(callId: string): ClientMediaCall { this.config.logger?.debug('MediaSignalingSession.createCall'); const config = { @@ -625,6 +680,15 @@ export class MediaSignalingSession extends Emitter { } private onSessionStateChange(): void { + if (this.sessionEnded) { + return; + } + + if (!this.registered) { + this.config.logger?.debug('skipping session events on unregistered session'); + return; + } + const hadCall = this.lastState.hasCall; const hadVisibleCall = this.lastState.hasVisibleCall; const hadBusyCall = this.lastState.hasBusyCall; 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..db8500e62af03 --- /dev/null +++ b/packages/media-signaling/src/lib/components/SessionRegistration.ts @@ -0,0 +1,73 @@ +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 sessionEnded = false; + + public get registered(): boolean { + return this.registrationConfirmed; + } + + 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(); + } + + 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(); + } +} From aa49ca4ce7318eee3ec52cd85a458686ec369367 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 23 Mar 2026 18:37:41 -0300 Subject: [PATCH 19/37] merge error --- packages/media-signaling/src/lib/Call.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 4fcd0ac740229..3877277045218 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -1207,14 +1207,14 @@ export class ClientMediaCall implements IClientMediaCall { continue; } - handler.reset(); + handler.clear(); this.stateTimeoutHandlers.delete(handler); } } private clearStateTimeouts(): void { for (const handler of this.stateTimeoutHandlers.values()) { - handler.reset(); + handler.clear(); } this.stateTimeoutHandlers.clear(); } From a4845346299f15d621b737b60109b95d86803ac5 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 25 Mar 2026 10:37:56 -0300 Subject: [PATCH 20/37] use an agent event for the trying signal --- ee/packages/media-calls/src/base/BaseAgent.ts | 2 ++ .../src/definition/IMediaCallAgent.ts | 1 + .../src/internal/SignalProcessor.ts | 26 ++++++++----------- .../src/internal/agents/UserActorAgent.ts | 8 ++++++ .../media-calls/src/server/BroadcastAgent.ts | 4 +++ 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/ee/packages/media-calls/src/base/BaseAgent.ts b/ee/packages/media-calls/src/base/BaseAgent.ts index 61f0090272922..7cdee4cdb98f4 100644 --- a/ee/packages/media-calls/src/base/BaseAgent.ts +++ b/ee/packages/media-calls/src/base/BaseAgent.ts @@ -73,6 +73,8 @@ export abstract class BaseMediaCallAgent implements IMediaCallAgent { public abstract onCallCreated(call: IMediaCall): Promise; + public abstract onCallTrying(callId: string): Promise; + public abstract onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise; public abstract onCallTransferred(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..3830493949ae2 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts @@ -15,6 +15,7 @@ export interface IMediaCallAgent { onCallAccepted(callId: string, data: { signedContractId: string; features: CallFeature[] }): Promise; onCallActive(callId: string): Promise; onCallCreated(call: IMediaCall): Promise; + onCallTrying(callId: string): Promise; /* Called when the sdp of the other actor is available, regardless of call state, or when this actor must provide an offer */ onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise; diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index 80cac10c5c5fc..3a163152d5631 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -143,26 +143,22 @@ export class GlobalSignalProcessor { const role = isCaller ? 'caller' : 'callee'; const actor = call[role]; - // If this user's side of the call is signed to a session that the current session is replacing (as in a browser refresh) - if (actor.contractId && actor.contractId === signal.oldContractId) { - logger.info({ msg: 'Server detected a client refresh for a session with an active call.', callId: call._id }); - await mediaCallDirector.hangupDetachedCall(call, { endedBy: { ...actor, contractId: signal.contractId }, reason: 'unknown' }); - return; + // 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) + if (actor.contractId === signal.oldContractId) { + logger.info({ msg: 'Server detected a client refresh for a session with an active call.', callId: call._id }); + await mediaCallDirector.hangupDetachedCall(call, { endedBy: { ...actor, contractId: signal.contractId }, reason: 'unknown' }); + return; + } + } else { + await mediaCallDirector.renewCallId(call._id); } - await mediaCallDirector.renewCallId(call._id); - const agents = await mediaCallDirector.cast.getAgentsFromCall(call); const { [role]: agent } = agents; - const otherAgent = agent.oppositeAgent; - if (otherAgent && otherAgent instanceof UserActorAgent) { - await otherAgent.sendSignal({ - callId: call._id, - type: 'notification', - notification: 'trying', - }); - } + await agent.oppositeAgent?.onCallTrying(call._id); if (!(agent instanceof UserActorAgent)) { logger.error({ diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index 16fd06c8a9fb5..2b7eed02a1466 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -178,6 +178,14 @@ export class UserActorAgent extends BaseMediaCallAgent { }); } + public async onCallTrying(callId: string): Promise { + await this.sendSignal({ + callId, + type: 'notification', + notification: 'trying', + }); + } + public async onDTMF(callId: string, dtmf: string, duration: number): Promise { logger.debug({ msg: 'UserActorAgent.onDTMF', callId, dtmf, duration, role: this.role }); // internal calls have nothing to do with DTMFs diff --git a/ee/packages/media-calls/src/server/BroadcastAgent.ts b/ee/packages/media-calls/src/server/BroadcastAgent.ts index 5b76704dd799c..1827418ab60aa 100644 --- a/ee/packages/media-calls/src/server/BroadcastAgent.ts +++ b/ee/packages/media-calls/src/server/BroadcastAgent.ts @@ -43,6 +43,10 @@ export class BroadcastActorAgent extends BaseMediaCallAgent { this.reportCallUpdated({ callId, dtmf: { dtmf, duration } }); } + public async onCallTrying(_callId: string): Promise { + // No need to broadcast trying signals as this doesn't change anything on the call data + } + protected reportCallUpdated(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void { const { callId, ...otherParams } = params; From 89670cffa0b128985bb470efab4ad88259838a40 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 25 Mar 2026 11:08:14 -0300 Subject: [PATCH 21/37] add expirationSeconds param to apn notifications --- apps/meteor/app/push/server/apn.ts | 8 +++++--- apps/meteor/app/push/server/definition.ts | 1 + apps/meteor/app/push/server/push.ts | 4 +++- .../services/media-call/push/sendVoipPushNotification.ts | 5 ++++- packages/core-typings/src/IPushNotificationConfig.ts | 1 + 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/meteor/app/push/server/apn.ts b/apps/meteor/app/push/server/apn.ts index 0982ca0a4d91f..d5d4415d61316 100644 --- a/apps/meteor/app/push/server/apn.ts +++ b/apps/meteor/app/push/server/apn.ts @@ -34,13 +34,15 @@ export const sendAPN = ({ const note = new apn.Notification(); + // Expires 1 hour from now, unless configured otherwise. + const expirationSeconds = notification.apn?.expirationSeconds ?? 3600; + if (notification.useVoipToken) { - note.expiry = Math.floor(Date.now() / 1000) + 60; // Expires in 60 seconds note.pushType = 'voip'; - } else { - note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now. } + note.expiry = Math.floor(Date.now() / 1000) + expirationSeconds; + if (notification.badge !== undefined) { note.badge = notification.badge; } diff --git a/apps/meteor/app/push/server/definition.ts b/apps/meteor/app/push/server/definition.ts index 87128af95347b..d194f66664c68 100644 --- a/apps/meteor/app/push/server/definition.ts +++ b/apps/meteor/app/push/server/definition.ts @@ -26,6 +26,7 @@ export type PendingPushNotification = { notId?: number; apn?: { category?: string; + expirationSeconds?: number; }; gcm?: { style?: string; diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index b1b30d745f83c..80f29459f8430 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -96,6 +96,7 @@ type GatewayNotification = { sound?: string; notId?: number; category?: string; + expirationSeconds?: number; }; gcm?: { from?: string; @@ -440,6 +441,7 @@ class PushClass { contentAvailable: Match.Optional(Match.Integer), apn: Match.Optional({ category: Match.Optional(String), + expirationSeconds: Match.Optional(Match.Integer), }), gcm: Match.Optional({ image: Match.Optional(String), @@ -481,7 +483,7 @@ class PushClass { ...(this.hasApnOptions(options) ? { apn: { - ...pick(options.apn, 'category'), + ...pick(options.apn, 'category', 'expirationSeconds'), }, } : {}), diff --git a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts index 372cb5a53f5fa..77a4e7c36292f 100644 --- a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts +++ b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts @@ -87,8 +87,10 @@ async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: V const caller = await getActorUserData(call.caller); metrics.notificationsSent.inc({ notification_type: 'mobile' }); + const useVoipToken = type === 'incoming_call'; + await Push.send({ - useVoipToken: type === 'incoming_call', + useVoipToken, priority: 10, payload: { host: Meteor.absoluteUrl(), @@ -100,6 +102,7 @@ async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: V caller, createdAt: call.createdAt.toISOString(), }, + ...(useVoipToken && { apn: { expirationSeconds: 60 } }), userId, notId: PushNotification.getNotificationId(call._id), // We should not send state change notifications to the device where the call was accepted/rejected diff --git a/packages/core-typings/src/IPushNotificationConfig.ts b/packages/core-typings/src/IPushNotificationConfig.ts index 79562f4d78b28..5c5c5fdac29dd 100644 --- a/packages/core-typings/src/IPushNotificationConfig.ts +++ b/packages/core-typings/src/IPushNotificationConfig.ts @@ -16,6 +16,7 @@ export interface IPushNotificationConfig { }; apn?: { category: string; + expirationSeconds?: number; }; useVoipToken?: boolean; skipTokenId?: IPushToken['_id']; From 8301f27c98197b2ea4fdbd9631196daef171b226 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 25 Mar 2026 17:13:52 -0300 Subject: [PATCH 22/37] filter report of active calls to list only calls signed to that session --- ee/packages/media-calls/src/internal/SignalProcessor.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index 3a163152d5631..186fcbb6aca7b 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -115,10 +115,16 @@ export class GlobalSignalProcessor { logger.debug({ msg: 'GlobalSignalProcessor.processRegisterSignal', signal: stripSensitiveDataFromSignal(signal), uid }); const calls = await MediaCalls.findAllNotOverByUid(uid).toArray(); + const signedCalls = 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, - activeCalls: calls.map(({ _id }) => _id), + activeCalls: signedCalls.map(({ _id }) => _id), }); if (!calls.length) { From 3bdd500b3ca7a0b3b3d216497325e595e48afda0 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 6 Apr 2026 11:19:44 -0300 Subject: [PATCH 23/37] invalid type --- packages/core-typings/src/IPushNotificationConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-typings/src/IPushNotificationConfig.ts b/packages/core-typings/src/IPushNotificationConfig.ts index 5c5c5fdac29dd..2695294408139 100644 --- a/packages/core-typings/src/IPushNotificationConfig.ts +++ b/packages/core-typings/src/IPushNotificationConfig.ts @@ -15,7 +15,7 @@ export interface IPushNotificationConfig { image: string; }; apn?: { - category: string; + category?: string; expirationSeconds?: number; }; useVoipToken?: boolean; From e815db66c92397bc0ddef02707be981064198183 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 7 Apr 2026 11:55:14 -0300 Subject: [PATCH 24/37] removed register event and notification --- .../src/internal/SignalProcessor.ts | 12 -- .../definition/signals/server/MediaSignal.ts | 7 +- .../src/definition/signals/server/index.ts | 1 - .../definition/signals/server/registered.ts | 8 -- packages/media-signaling/src/lib/Session.ts | 113 +++++------------- .../src/lib/components/SessionRegistration.ts | 73 ----------- 6 files changed, 30 insertions(+), 184 deletions(-) delete mode 100644 packages/media-signaling/src/definition/signals/server/registered.ts delete 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 186fcbb6aca7b..98e948622118f 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -115,18 +115,6 @@ export class GlobalSignalProcessor { logger.debug({ msg: 'GlobalSignalProcessor.processRegisterSignal', signal: stripSensitiveDataFromSignal(signal), uid }); const calls = await MediaCalls.findAllNotOverByUid(uid).toArray(); - const signedCalls = 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, - activeCalls: signedCalls.map(({ _id }) => _id), - }); - if (!calls.length) { return; } diff --git a/packages/media-signaling/src/definition/signals/server/MediaSignal.ts b/packages/media-signaling/src/definition/signals/server/MediaSignal.ts index d3e6247a4234b..b75e5fb04a808 100644 --- a/packages/media-signaling/src/definition/signals/server/MediaSignal.ts +++ b/packages/media-signaling/src/definition/signals/server/MediaSignal.ts @@ -1,19 +1,14 @@ 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 ServerMediaCallSignal = +export type ServerMediaSignal = | 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 55343c5b6154f..e077408e20142 100644 --- a/packages/media-signaling/src/definition/signals/server/index.ts +++ b/packages/media-signaling/src/definition/signals/server/index.ts @@ -1,6 +1,5 @@ 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 deleted file mode 100644 index 25392c5cb113c..0000000000000 --- a/packages/media-signaling/src/definition/signals/server/registered.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Server is notifying the client that its registration was processed */ -export type ServerMediaSignalRegistered = { - type: 'registered'; - - toContractId: string; - - activeCalls: string[]; -}; diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 1e2118f355589..a7da1cecfdf5c 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -8,14 +8,10 @@ import type { MediaSignalTransport, MediaStreamFactory, RandomStringFactory, - ServerMediaCallSignal, - ServerMediaSessionSignal, ServerMediaSignal, - ServerMediaSignalRegistered, } from '../definition'; import type { IClientMediaCall, CallActorType, CallContact, CallFeature } from '../definition/call'; import type { IMediaSignalLogger } from '../definition/logger'; -import { SessionRegistration } from './components/SessionRegistration'; export type MediaSignalingEvents = { sessionStateChange: void; @@ -23,7 +19,6 @@ export type MediaSignalingEvents = { acceptedCall: { call: IClientMediaCall }; endedCall: void; hiddenCall: void; - registered: { activeCalls: IClientMediaCall['callId'][] }; }; export type MediaSignalingSessionConfig = { @@ -72,8 +67,6 @@ export class MediaSignalingSession extends Emitter { private sessionEnded = false; - private registration: SessionRegistration; - public get sessionId(): string { return this._sessionId; } @@ -82,10 +75,6 @@ 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; @@ -101,10 +90,6 @@ 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(); this.enableStateReport(STATE_REPORT_INTERVAL); @@ -131,7 +116,6 @@ export class MediaSignalingSession extends Emitter { public endSession(): void { this.sessionEnded = true; - this.registration.sessionEnded = true; this.disableStateReport(); // best‑effort: stop capturing audio @@ -183,11 +167,27 @@ export class MediaSignalingSession extends Emitter { return; } this.config.logger?.debug('MediaSignalingSession.processSignal', signal); - if ('callId' in signal) { - return this.processCallSignal(signal); + if (this.isCallIgnored(signal.callId)) { + return; + } + + const call = this.getOrCreateCallBySignal(signal); + + if (signal.type === 'notification' && signal.signedContractId) { + if (signal.signedContractId === this._sessionId) { + call.setContractState('signed'); + } else if (signal.notification === 'accepted') { + // The server accepted a contract, but it wasn't ours - ignore the call in this session + call.setContractState('ignored'); + } + } else if ('toContractId' in signal) { + call.setContractState(signal.toContractId === this._sessionId ? 'signed' : 'ignored'); + } else if (signal.type === 'new' && signal.self.contractId) { + call.setContractState(signal.self.contractId === this._sessionId ? 'signed' : 'ignored'); } - return this.processSessionSignal(signal); + const oldCall = this.getReplacedCallBySignal(signal); + await call.processSignal(signal, oldCall); } public async setDeviceId(deviceId: ConstrainDOMString | null): Promise { @@ -220,7 +220,13 @@ export class MediaSignalingSession extends Emitter { } public register(): void { - this.registration.reRegister(); + this.lastRegisterTimestamp = new Date(); + + this.transporter.sendSignal({ + type: 'register', + contractId: this._sessionId, + ...(this.config.oldSessionId && { oldContractId: this.config.oldSessionId }), + }); } public setIceGatheringTimeout(newTimeout: number): void { @@ -251,7 +257,7 @@ export class MediaSignalingSession extends Emitter { } } - private getExistingCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall | null { + private getExistingCallBySignal(signal: ServerMediaSignal): ClientMediaCall | null { const existingCall = this.knownCalls.get(signal.callId); if (existingCall) { return existingCall; @@ -277,7 +283,7 @@ export class MediaSignalingSession extends Emitter { return null; } - private getOrCreateCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall { + private getOrCreateCallBySignal(signal: ServerMediaSignal): ClientMediaCall { this.config.logger?.debug('MediaSignalingSession.getOrCreateCallBySignal', signal); const existingCall = this.getExistingCallBySignal(signal); if (existingCall) { @@ -327,7 +333,7 @@ export class MediaSignalingSession extends Emitter { } } - this.registration.register(); + this.register(); } private async setInputTrack(newInputTrack: MediaStreamTrack | null): Promise { @@ -523,62 +529,6 @@ export class MediaSignalingSession extends Emitter { await this.setScreenVideoTrack(track, call); } - private sendRegisterSignal(): void { - this.lastRegisterTimestamp = new Date(); - this.transporter.sendSignal({ - type: 'register', - contractId: this._sessionId, - ...(this.config.oldSessionId && { oldContractId: this.config.oldSessionId }), - }); - } - - private async processCallSignal(signal: ServerMediaCallSignal): Promise { - if (this.isCallIgnored(signal.callId)) { - return; - } - - const call = this.getOrCreateCallBySignal(signal); - - if (signal.type === 'notification' && signal.signedContractId) { - if (signal.signedContractId === this._sessionId) { - call.setContractState('signed'); - } else if (signal.notification === 'accepted') { - // The server accepted a contract, but it wasn't ours - ignore the call in this session - call.setContractState('ignored'); - } - } else if ('toContractId' in signal) { - call.setContractState(signal.toContractId === this._sessionId ? 'signed' : 'ignored'); - } else if (signal.type === 'new' && signal.self.contractId) { - call.setContractState(signal.self.contractId === this._sessionId ? 'signed' : 'ignored'); - } - - const oldCall = this.getReplacedCallBySignal(signal); - await call.processSignal(signal, oldCall); - } - - private processSessionSignal(signal: ServerMediaSessionSignal): void { - if (signal.toContractId !== this._sessionId) { - return; - } - - switch (signal.type) { - case 'registered': - return this.confirmSessionRegistered(signal); - } - } - - private confirmSessionRegistered(signal: ServerMediaSignalRegistered): void { - this.config.logger?.debug('MediaSignalingSession.sessionRegistered'); - const wasRegistered = this.registered; - this.registration.confirmRegistration(); - - this.emit('registered', { activeCalls: signal.activeCalls }); - - if (!wasRegistered) { - this.onSessionStateChange(); - } - } - private createCall(callId: string): ClientMediaCall { this.config.logger?.debug('MediaSignalingSession.createCall'); const config = { @@ -684,11 +634,6 @@ export class MediaSignalingSession extends Emitter { return; } - if (!this.registered) { - this.config.logger?.debug('skipping session events on unregistered session'); - return; - } - const hadCall = this.lastState.hasCall; const hadVisibleCall = this.lastState.hasVisibleCall; const hadBusyCall = this.lastState.hasBusyCall; diff --git a/packages/media-signaling/src/lib/components/SessionRegistration.ts b/packages/media-signaling/src/lib/components/SessionRegistration.ts deleted file mode 100644 index db8500e62af03..0000000000000 --- a/packages/media-signaling/src/lib/components/SessionRegistration.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 sessionEnded = false; - - public get registered(): boolean { - return this.registrationConfirmed; - } - - 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(); - } - - 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(); - } -} From 3a3e7e903c8c2c8b2b2c22f932b79082d59aa3db Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 8 Apr 2026 15:39:11 -0300 Subject: [PATCH 25/37] 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 26/37] 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 27/37] 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 28/37] 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 29/37] 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 30/37] 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 31/37] 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 32/37] 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 0693f21bd39df7ba0ab40a2061f0cbb56b2ccde9 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 13 Apr 2026 13:18:08 -0300 Subject: [PATCH 33/37] add setting and validations for sending push notifications --- .../PreferencesNotificationsSection.tsx | 4 +++- apps/meteor/ee/server/settings/voip.ts | 8 ++++++++ .../push/sendVoipPushNotification.ts | 18 ++++++++++++++---- .../server/services/media-call/service.ts | 2 ++ .../src/definition/IMediaCallServer.ts | 2 ++ .../src/internal/agents/UserActorAgent.ts | 10 ---------- .../media-calls/src/server/MediaCallServer.ts | 3 +++ .../src/server/getDefaultSettings.ts | 1 + packages/i18n/src/locales/en.i18n.json | 3 +++ 9 files changed, 36 insertions(+), 15 deletions(-) diff --git a/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx index 4b3ed1b4ae99d..3384a260e5cb2 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx @@ -38,7 +38,9 @@ const PreferencesNotificationsSection = () => { const loginEmailEnabled = useSetting('Device_Management_Enable_Login_Emails'); const allowLoginEmailPreference = useSetting('Device_Management_Allow_Login_Email_preference'); const showNewLoginEmailPreference = loginEmailEnabled && allowLoginEmailPreference; - const showMobileRinging = useSetting('VideoConf_Mobile_Ringing'); + const showVideoConfMobileRinging = useSetting('VideoConf_Mobile_Ringing'); + const showVoipMobileRinging = useSetting('VoIP_TeamCollab_Mobile_Ringing_Enabled'); + const showMobileRinging = showVideoConfMobileRinging || showVoipMobileRinging; const notify = useNotification(); const userEmailNotificationMode = useUserPreference('emailNotificationMode') as keyof typeof emailNotificationOptionsLabelMap; diff --git a/apps/meteor/ee/server/settings/voip.ts b/apps/meteor/ee/server/settings/voip.ts index c04700948299c..5468145e6c67c 100644 --- a/apps/meteor/ee/server/settings/voip.ts +++ b/apps/meteor/ee/server/settings/voip.ts @@ -17,6 +17,14 @@ export function addSettings(): Promise { i18nDescription: 'VoIP_TeamCollab_Screen_Sharing_Enabled_Description', }); + await this.add('VoIP_TeamCollab_Mobile_Ringing_Enabled', false, { + type: 'boolean', + public: true, + invalidValue: false, + alert: 'VoIP_TeamCollab_Mobile_Ringing_Enabled_Alert', + i18nDescription: 'VoIP_TeamCollab_Mobile_Ringing_Enabled_Description', + }); + await this.add('VoIP_TeamCollab_Ice_Servers', 'stun:stun.l.google.com:19302', { type: 'string', public: true, diff --git a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts index 77a4e7c36292f..7c0b2b9d0d4fd 100644 --- a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts +++ b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts @@ -9,6 +9,7 @@ import { Push } from '../../../../app/push/server/push'; import PushNotification from '../../../../app/push-notifications/server/lib/PushNotification'; import { settings } from '../../../../app/settings/server'; import { getUserAvatarURL } from '../../../../app/utils/server/getUserAvatarURL'; +import { getUserPreference } from '../../../../app/utils/server/lib/getUserPreference'; import { logger } from '../logger'; async function getActorUser>( @@ -55,6 +56,10 @@ async function getActorUserData( } async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): Promise { + if (settings.get('Push_enable') !== true || settings.get('VoIP_TeamCollab_Mobile_Ringing_Enabled') !== true) { + return; + } + const call = await MediaCalls.findOneById(callId); if (!call) { logger.error({ msg: 'Failed to send push notification: Media Call not found', callId }); @@ -66,6 +71,15 @@ async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: V return; } + const { + kind, + callee: { id: userId }, + } = call; + + if (!(await getUserPreference(userId, 'enableMobileRinging'))) { + return; + } + // If the call was accepted, we don't need to notify when it ends if (call.acceptedAt && event !== 'answer') { return; @@ -80,10 +94,6 @@ async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: V return; } - const { - kind, - callee: { id: userId }, - } = call; const caller = await getActorUserData(call.caller); metrics.notificationsSent.inc({ notification_type: 'mobile' }); diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 99e7ff7d81bc9..0fc72f0eac92c 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -317,6 +317,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall private getMediaServerSettings(): IMediaCallServerSettings { const sipEnabled = settings.get('VoIP_TeamCollab_SIP_Integration_Enabled') ?? false; + const mobileRinging = settings.get('VoIP_TeamCollab_Mobile_Ringing_Enabled') ?? false; const forceSip = sipEnabled && (settings.get('VoIP_TeamCollab_SIP_Integration_For_Internal_Calls') ?? false); return { @@ -336,6 +337,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall port: settings.get('VoIP_TeamCollab_SIP_Server_Port') ?? 5060, }, }, + mobileRinging, permissionCheck: (uid, callType) => this.userHasMediaCallPermission(uid, callType), isFeatureAvailableForUser: (uid, feature) => this.userHasFeaturePermission(uid, feature), }; diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts index 552477db43b6b..6eb6e7e0e47f4 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallServer.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -33,6 +33,8 @@ export interface IMediaCallServerSettings { }; }; + mobileRinging: boolean; + permissionCheck: (uid: IUser['_id'], callType: 'internal' | 'external' | 'any') => Promise; isFeatureAvailableForUser: (uid: IUser['_id'], feature: CallFeature) => boolean; } diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index 891da9419148e..f141ceda4af62 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -13,12 +13,6 @@ import { getNewCallSignal } from '../../server/signals/getNewCallSignal'; import { getStateNotification } from '../../server/signals/getStateNotification'; export class UserActorAgent extends BaseMediaCallAgent { - private pushNotificationsEnabled = true; - - public disablePushNotifications(): void { - this.pushNotificationsEnabled = false; - } - public async processSignal(call: IMediaCall, signal: ClientMediaSignal): Promise { const channel = await this.getOrCreateChannel(call, signal.contractId); @@ -172,10 +166,6 @@ export class UserActorAgent extends BaseMediaCallAgent { } private sendPushNotification(params: { callId: string; event: VoipPushNotificationEventType }): void { - if (!this.pushNotificationsEnabled) { - return; - } - getMediaCallServer().sendPushNotification(params); } } diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index 6099fe5713455..c8329d3257cda 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -82,6 +82,9 @@ export class MediaCallServer implements IMediaCallServer { } public sendPushNotification(params: { callId: string; event: VoipPushNotificationEventType }): void { + if (!this.settings.mobileRinging) { + return; + } logger.debug({ msg: 'MediaCallServer.sendPushNotification', params }); this.emitter.emit('pushNotificationRequest', params); diff --git a/ee/packages/media-calls/src/server/getDefaultSettings.ts b/ee/packages/media-calls/src/server/getDefaultSettings.ts index 4445d13643d78..07ddfe33ccc9a 100644 --- a/ee/packages/media-calls/src/server/getDefaultSettings.ts +++ b/ee/packages/media-calls/src/server/getDefaultSettings.ts @@ -18,6 +18,7 @@ export function getDefaultSettings(): IMediaCallServerSettings { port: 5080, }, }, + mobileRinging: false, permissionCheck: async () => false, isFeatureAvailableForUser: () => false, diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 3222a9196906a..abffa98d9238e 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -5807,6 +5807,9 @@ "VoIP_TeamCollab_SIP_Integration": "SIP Integration", "VoIP_TeamCollab_SIP_Integration_Enabled": "SIP Integration Enabled", "VoIP_TeamCollab_SIP_Integration_For_Internal_Calls": "Route internal calls through the SIP integration", + "VoIP_TeamCollab_Mobile_Ringing_Enabled": "Mobile Ringing", + "VoIP_TeamCollab_Mobile_Ringing_Enabled_Alert": "This feature is currently in an experimental stage and may not yet be fully supported by the mobile app. When enabled it will send additional Push Notifications to users.", + "VoIP_TeamCollab_Mobile_Ringing_Enabled_Description": "Allow users to make and receive calls on the mobile app.", "VoIP_TeamCollab_Screen_Sharing_Enabled": "Screen sharing", "VoIP_TeamCollab_Screen_Sharing_Enabled_Description": "Allow users to share their screen during voice calls.", "VoIP_TeamCollab_Screen_Sharing_Enabled_Alert": "Screen sharing is currently in beta.", From 50d49ae0a917a9ae7b245f0134a218a6d9825c1f Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 13 Apr 2026 13:29:43 -0300 Subject: [PATCH 34/37] removing dead code left by previous merge --- ee/packages/media-calls/src/base/BaseAgent.ts | 2 +- ee/packages/media-calls/src/internal/agents/UserActorAgent.ts | 3 +-- packages/media-signaling/src/lib/Session.ts | 4 ---- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/ee/packages/media-calls/src/base/BaseAgent.ts b/ee/packages/media-calls/src/base/BaseAgent.ts index 81e352fe3105b..425b17a2a7ad8 100644 --- a/ee/packages/media-calls/src/base/BaseAgent.ts +++ b/ee/packages/media-calls/src/base/BaseAgent.ts @@ -63,7 +63,7 @@ export abstract class BaseMediaCallAgent implements IMediaCallAgent { public abstract onCallAccepted(call: IMediaCall): Promise; - public abstract onCallActive(callId: string, data?: { signedContractId?: string }): Promise; + public abstract onCallActive(callId: string): Promise; public abstract onCallEnded(callId: string): Promise; diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index f141ceda4af62..d35ea4184bdc3 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -58,12 +58,11 @@ export class UserActorAgent extends BaseMediaCallAgent { }); } - public async onCallActive(callId: string, data: { signedContractId?: string } = {}): Promise { + public async onCallActive(callId: string): Promise { return this.sendSignal({ callId, type: 'notification', notification: 'active', - ...(data.signedContractId && { signedContractId: data.signedContractId }), }); } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 445a6586032fa..64dd3d4592786 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -698,10 +698,6 @@ export class MediaSignalingSession extends Emitter { } private onSessionStateChange(): void { - if (this.sessionEnded) { - return; - } - const hadCall = this.lastState.hasCall; const hadVisibleCall = this.lastState.hasVisibleCall; const hadBusyCall = this.lastState.hasBusyCall; From 22cfeeddaa0633e89a8806261054b930d94137ed Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 13 Apr 2026 14:35:22 -0300 Subject: [PATCH 35/37] do not send trying signals for active calls --- .../media-calls/src/internal/SignalProcessor.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index 34cc613ed9d49..c594b1a856d33 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -158,13 +158,15 @@ export class GlobalSignalProcessor { await mediaCallDirector.renewCallId(call._id); } - const otherActor = role === 'caller' ? call.callee : call.caller; - if (otherActor.type === 'user') { - this.sendSignal(otherActor.id, { - callId: call._id, - type: 'notification', - notification: 'trying', - }); + if (call.state !== 'active') { + const otherActor = role === 'caller' ? call.callee : call.caller; + if (otherActor.type === 'user') { + this.sendSignal(otherActor.id, { + callId: call._id, + type: 'notification', + notification: 'trying', + }); + } } if (!signal.requestSignals) { From 4e6c4beaada82b5f64a61725fde9d84873881a7f Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:19:36 -0300 Subject: [PATCH 36/37] Apply suggestion from @sampaiodiego Co-authored-by: Diego Sampaio --- apps/meteor/app/push/server/push.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 80f29459f8430..63b33832c19ec 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -500,7 +500,7 @@ class PushClass { this._validateDocument(notification); try { - await this.sendNotification(notification, pick(options, 'skipTokenId')); + await this.sendNotification(notification, { skipTokenId: options.skipTokenId }); } catch (error: any) { logger.debug({ msg: 'Could not send notification to user', From 0279a763f040e5e9477384e8a2381704159de5c1 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 15 Apr 2026 18:24:18 -0300 Subject: [PATCH 37/37] changes from code review --- .../media-call/push/sendVoipPushNotification.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts index 7c0b2b9d0d4fd..391339a9e1104 100644 --- a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts +++ b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts @@ -12,16 +12,14 @@ import { getUserAvatarURL } from '../../../../app/utils/server/getUserAvatarURL' import { getUserPreference } from '../../../../app/utils/server/lib/getUserPreference'; import { logger } from '../logger'; -async function getActorUser>( - actor: MediaCallContact, -): Promise { +async function getActorUser(actor: MediaCallContact): Promise | null> { const options = { projection: { name: 1, username: 1, freeSwitchExtension: 1 } }; switch (actor.type) { case 'user': - return Users.findOneById(actor.id, options); + return Users.findOneById(actor.id, options); case 'sip': - return Users.findOneByFreeSwitchExtension(actor.id, options); + return Users.findOneByFreeSwitchExtension(actor.id, options); } } @@ -76,10 +74,6 @@ async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: V callee: { id: userId }, } = call; - if (!(await getUserPreference(userId, 'enableMobileRinging'))) { - return; - } - // If the call was accepted, we don't need to notify when it ends if (call.acceptedAt && event !== 'answer') { return; @@ -94,6 +88,10 @@ async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: V return; } + if (!(await getUserPreference(userId, 'enableMobileRinging'))) { + return; + } + const caller = await getActorUserData(call.caller); metrics.notificationsSent.inc({ notification_type: 'mobile' });