From 3841e39f78ad675bd56f832dac6db91b66436c47 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 19 Aug 2026 12:29:27 -0300 Subject: [PATCH 1/6] fix: federated presence is never sent --- .../federation-matrix/src/FederationMatrix.ts | 19 +- .../tests/end-to-end/presence.spec.ts | 176 ++++++++++++++++++ 2 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 ee/packages/federation-matrix/tests/end-to-end/presence.spec.ts diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 0aa7d05776796..66e558bda873e 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -103,17 +103,18 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS if (!user.username || !user.status || user.username.includes(':')) { return; } - const localUser = await Users.findOneByUsername(user.username, { projection: { _id: 1, federated: 1, federation: 1 } }); - if (!localUser) { - return; - } - - if (!isUserNativeFederated(localUser)) { + const localUser = await Users.findOneByUsername>(user.username, { + projection: { _id: 1, username: 1, federated: 1, federation: 1 }, + }); + if (!localUser?.username) { return; } // TODO: Check if it should exclude himself from the list const roomsUserIsMemberOf = await Subscriptions.findUserFederatedRoomIds(localUser._id).toArray(); + if (!roomsUserIsMemberOf.length) { + return; + } const statusMap: Record = { [UserStatus.ONLINE]: 'online', [UserStatus.OFFLINE]: 'offline', @@ -121,10 +122,14 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS [UserStatus.BUSY]: 'unavailable', [UserStatus.DISABLED]: 'offline', }; + // local users carry no federation metadata, so derive their Matrix ID the same way + // notifyUserTyping does instead of requiring a stored `mui` + const userMui = isUserNativeFederated(localUser) ? localUser.federation.mui : `@${localUser.username}:${this.serverName}`; + void federationSDK.sendPresenceUpdateToRooms( [ { - user_id: localUser.federation.mui, + user_id: userMui, presence: statusMap[user.status] || 'offline', }, ], diff --git a/ee/packages/federation-matrix/tests/end-to-end/presence.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/presence.spec.ts new file mode 100644 index 0000000000000..67bbd20474963 --- /dev/null +++ b/ee/packages/federation-matrix/tests/end-to-end/presence.spec.ts @@ -0,0 +1,176 @@ +import type { IRoomNativeFederated } from '@rocket.chat/core-typings'; +import { UserStatus } from '@rocket.chat/core-typings'; +import { Visibility } from 'matrix-js-sdk'; + +import { api } from '../../../../../apps/meteor/tests/data/api-data'; +import { acceptRoomInvite } from '../../../../../apps/meteor/tests/data/rooms.helper'; +import { type IRequestConfig, createUser, getRequestConfig, getUserByUsername } from '../../../../../apps/meteor/tests/data/users.helper'; +import { IS_EE } from '../../../../../apps/meteor/tests/e2e/config/constants'; +import { retry } from '../../../../../apps/meteor/tests/end-to-end/api/helpers/retry'; +import { federationConfig } from '../helper/config'; +import { DDPListener } from '../helper/ddp-listener'; +import { SynapseClient } from '../helper/synapse-client'; + +const localUser = federationConfig.rc1.additionalUser1; +const remoteUser = federationConfig.hs1.additionalUser1; + +const PRESENCE_SETTING = 'Federation_Service_EDU_Process_Presence'; + +/** + * Presence is only federated while `Federation_Service_EDU_Process_Presence` is on, and it + * defaults to off, so this suite owns the setting and restores it afterwards. + * + * The assertion deliberately reads the remote homeserver's own view of the user rather than + * Rocket.Chat's, because the send path is what regressed: the handler used to require + * federation metadata that is never written for local users, so no EDU was ever emitted and + * a Rocket.Chat side check would have passed against a shadow user that never updated. + */ +(IS_EE ? describe : describe.skip)('Federation presence', () => { + let rc1AdminRequestConfig: IRequestConfig; + let rc1UserRequestConfig: IRequestConfig; + let hs1UserApp: SynapseClient; + let federatedRoomId: string; + + const setPresenceSetting = async (value: boolean) => { + await rc1AdminRequestConfig.request + .post(api(`settings/${PRESENCE_SETTING}`)) + .set(rc1AdminRequestConfig.credentials) + .send({ value }) + .expect(200); + }; + + // the remote server is the source of truth here: it only knows a status if an EDU arrived + const expectRemotePresence = async (expected: string) => + retry( + `waiting for ${remoteUser.username} to see ${localUser.matrixUserId} as ${expected}`, + async () => { + const status = await hs1UserApp.matrixClient.getPresence(localUser.matrixUserId); + + expect(status.presence).toBe(expected); + }, + { retries: 10, delayMs: 2000 }, + ); + + beforeAll(async () => { + rc1AdminRequestConfig = await getRequestConfig( + federationConfig.rc1.url, + federationConfig.rc1.adminUser, + federationConfig.rc1.adminPassword, + ); + + const existingLocalUser = await getUserByUsername(localUser.username, rc1AdminRequestConfig); + if (!existingLocalUser?._id) { + await createUser( + { + username: localUser.username, + password: localUser.password, + email: `${localUser.username}@rocket.chat`, + name: localUser.username, + }, + rc1AdminRequestConfig, + ); + } + + rc1UserRequestConfig = await getRequestConfig(federationConfig.rc1.url, localUser.username, localUser.password); + + await setPresenceSetting(true); + + hs1UserApp = new SynapseClient(federationConfig.hs1.url, remoteUser.username, remoteUser.password); + await hs1UserApp.initialize(); + + // presence is only sent to servers that share a federated room with the user, so the + // two sides need one before any status change can be observed + const channelName = `fed-presence-${Date.now()}`; + const synapseRoomId = await hs1UserApp.createRoom(channelName, Visibility.Private); + await hs1UserApp.inviteUserToRoom(synapseRoomId, localUser.matrixUserId); + + await retry( + 'waiting for the federated room to reach RC', + async () => { + const response = await rc1UserRequestConfig.request.get(api('rooms.get')).set(rc1UserRequestConfig.credentials).expect(200); + + const rcRoom = response.body.update.find( + (room: IRoomNativeFederated) => room.federation?.mrid === synapseRoomId, + ) as IRoomNativeFederated | null; + + expect(rcRoom).toBeTruthy(); + federatedRoomId = rcRoom!._id; + }, + { retries: 10, delayMs: 2000 }, + ); + + const accepted = await acceptRoomInvite(federatedRoomId, rc1UserRequestConfig); + expect(accepted).toHaveProperty('success', true); + }, 120000); + + afterAll(async () => { + // leave the workspace as it was found: this setting is off by default + if (rc1AdminRequestConfig) { + await setPresenceSetting(false); + } + await hs1UserApp?.close(); + }); + + const setLocalStatus = async (status: UserStatus) => { + const response = await rc1UserRequestConfig.request + .post(api('users.setStatus')) + .set(rc1UserRequestConfig.credentials) + .send({ status, message: '' }) + .expect(200); + + expect(response.body).toHaveProperty('success', true); + }; + + it('should share a federated room with the remote user', async () => { + const response = await rc1UserRequestConfig.request + .get(api('subscriptions.getOne')) + .set(rc1UserRequestConfig.credentials) + .query({ roomId: federatedRoomId }) + .expect(200); + + expect(response.body.subscription).toBeTruthy(); + }); + + // `online` is the assertion that actually pins the regression: an unknown remote user reads + // as `offline` on Synapse, so only a non-offline state proves an EDU was received. + it('should reach the remote server when the local user goes online', async () => { + await setLocalStatus(UserStatus.ONLINE); + + await expectRemotePresence('online'); + }, 60000); + + describe('with a connected client', () => { + let ddp: DDPListener; + + // away and busy are only *effective* statuses while the user has a live connection; + // for a REST-only user Rocket.Chat resolves both to offline, which is indistinguishable + // from "no EDU arrived". A DDP session makes them real, and therefore assertable. + beforeAll(async () => { + ddp = new DDPListener(federationConfig.rc1.url, rc1UserRequestConfig); + await ddp.connect(); + }, 60000); + + afterAll(() => { + ddp?.disconnect(); + }); + + // away and busy both collapse to the Matrix `unavailable` state + it('should reach the remote server when the local user goes away', async () => { + await setLocalStatus(UserStatus.AWAY); + + await expectRemotePresence('unavailable'); + }, 60000); + + it('should reach the remote server when the local user goes busy', async () => { + await setLocalStatus(UserStatus.BUSY); + + await expectRemotePresence('unavailable'); + }, 60000); + + it('should reach the remote server when the local user comes back online', async () => { + await setLocalStatus(UserStatus.ONLINE); + + await expectRemotePresence('online'); + }, 60000); + }); +}); From fc05bf6c5ffb913d4f0e3340798f774d2b992d3f Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 19 Aug 2026 13:48:45 -0300 Subject: [PATCH 2/6] chore: add changeset --- .changeset/federated-presence-never-sent.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/federated-presence-never-sent.md diff --git a/.changeset/federated-presence-never-sent.md b/.changeset/federated-presence-never-sent.md new file mode 100644 index 0000000000000..5f5178cad1e9e --- /dev/null +++ b/.changeset/federated-presence-never-sent.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/federation-matrix': patch +--- + +Fixed federated user presence never being sent to remote workspaces From 33ff43d32ace501d86b5eb07960b853e9cc07bad Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 19 Aug 2026 14:28:17 -0300 Subject: [PATCH 3/6] test: busy when no EDU is sent --- .../tests/end-to-end/presence.spec.ts | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/ee/packages/federation-matrix/tests/end-to-end/presence.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/presence.spec.ts index 67bbd20474963..e83e0b4b3e79d 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/presence.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/presence.spec.ts @@ -16,15 +16,6 @@ const remoteUser = federationConfig.hs1.additionalUser1; const PRESENCE_SETTING = 'Federation_Service_EDU_Process_Presence'; -/** - * Presence is only federated while `Federation_Service_EDU_Process_Presence` is on, and it - * defaults to off, so this suite owns the setting and restores it afterwards. - * - * The assertion deliberately reads the remote homeserver's own view of the user rather than - * Rocket.Chat's, because the send path is what regressed: the handler used to require - * federation metadata that is never written for local users, so no EDU was ever emitted and - * a Rocket.Chat side check would have passed against a shadow user that never updated. - */ (IS_EE ? describe : describe.skip)('Federation presence', () => { let rc1AdminRequestConfig: IRequestConfig; let rc1UserRequestConfig: IRequestConfig; @@ -39,7 +30,6 @@ const PRESENCE_SETTING = 'Federation_Service_EDU_Process_Presence'; .expect(200); }; - // the remote server is the source of truth here: it only knows a status if an EDU arrived const expectRemotePresence = async (expected: string) => retry( `waiting for ${remoteUser.username} to see ${localUser.matrixUserId} as ${expected}`, @@ -78,8 +68,6 @@ const PRESENCE_SETTING = 'Federation_Service_EDU_Process_Presence'; hs1UserApp = new SynapseClient(federationConfig.hs1.url, remoteUser.username, remoteUser.password); await hs1UserApp.initialize(); - // presence is only sent to servers that share a federated room with the user, so the - // two sides need one before any status change can be observed const channelName = `fed-presence-${Date.now()}`; const synapseRoomId = await hs1UserApp.createRoom(channelName, Visibility.Private); await hs1UserApp.inviteUserToRoom(synapseRoomId, localUser.matrixUserId); @@ -104,7 +92,6 @@ const PRESENCE_SETTING = 'Federation_Service_EDU_Process_Presence'; }, 120000); afterAll(async () => { - // leave the workspace as it was found: this setting is off by default if (rc1AdminRequestConfig) { await setPresenceSetting(false); } @@ -131,8 +118,6 @@ const PRESENCE_SETTING = 'Federation_Service_EDU_Process_Presence'; expect(response.body.subscription).toBeTruthy(); }); - // `online` is the assertion that actually pins the regression: an unknown remote user reads - // as `offline` on Synapse, so only a non-offline state proves an EDU was received. it('should reach the remote server when the local user goes online', async () => { await setLocalStatus(UserStatus.ONLINE); @@ -142,9 +127,6 @@ const PRESENCE_SETTING = 'Federation_Service_EDU_Process_Presence'; describe('with a connected client', () => { let ddp: DDPListener; - // away and busy are only *effective* statuses while the user has a live connection; - // for a REST-only user Rocket.Chat resolves both to offline, which is indistinguishable - // from "no EDU arrived". A DDP session makes them real, and therefore assertable. beforeAll(async () => { ddp = new DDPListener(federationConfig.rc1.url, rc1UserRequestConfig); await ddp.connect(); @@ -162,6 +144,9 @@ const PRESENCE_SETTING = 'Federation_Service_EDU_Process_Presence'; }, 60000); it('should reach the remote server when the local user goes busy', async () => { + await setLocalStatus(UserStatus.ONLINE); + await expectRemotePresence('online'); + await setLocalStatus(UserStatus.BUSY); await expectRemotePresence('unavailable'); From add67d3341f116287c6b5a3da3581267de460ccd Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 19 Aug 2026 14:45:46 -0300 Subject: [PATCH 4/6] fix: remove spurious short-circuit --- ee/packages/federation-matrix/src/FederationMatrix.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 66e558bda873e..2624c6f96a86e 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -112,9 +112,6 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS // TODO: Check if it should exclude himself from the list const roomsUserIsMemberOf = await Subscriptions.findUserFederatedRoomIds(localUser._id).toArray(); - if (!roomsUserIsMemberOf.length) { - return; - } const statusMap: Record = { [UserStatus.ONLINE]: 'online', [UserStatus.OFFLINE]: 'offline', @@ -122,8 +119,6 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS [UserStatus.BUSY]: 'unavailable', [UserStatus.DISABLED]: 'offline', }; - // local users carry no federation metadata, so derive their Matrix ID the same way - // notifyUserTyping does instead of requiring a stored `mui` const userMui = isUserNativeFederated(localUser) ? localUser.federation.mui : `@${localUser.username}:${this.serverName}`; void federationSDK.sendPresenceUpdateToRooms( From b70945a03d911ba0a97ff37afbfea096b1c3e8b0 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 19 Aug 2026 14:54:15 -0300 Subject: [PATCH 5/6] chore: remove unneeded explicit generic --- ee/packages/federation-matrix/src/FederationMatrix.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 2624c6f96a86e..9d9ea2f5fcd11 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -103,7 +103,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS if (!user.username || !user.status || user.username.includes(':')) { return; } - const localUser = await Users.findOneByUsername>(user.username, { + const localUser = await Users.findOneByUsername(user.username, { projection: { _id: 1, username: 1, federated: 1, federation: 1 }, }); if (!localUser?.username) { From 9fcaebbe94c1cf11b36a4368886acb14c642344f Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 14 Sep 2026 06:44:22 -0600 Subject: [PATCH 6/6] Actualizar federated-presence-never-sent.md --- .changeset/federated-presence-never-sent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/federated-presence-never-sent.md b/.changeset/federated-presence-never-sent.md index 5f5178cad1e9e..dd69aee075a00 100644 --- a/.changeset/federated-presence-never-sent.md +++ b/.changeset/federated-presence-never-sent.md @@ -2,4 +2,4 @@ '@rocket.chat/federation-matrix': patch --- -Fixed federated user presence never being sent to remote workspaces +Fixes federated user presence never being sent to remote workspaces