diff --git a/.changeset/five-chicken-invite.md b/.changeset/five-chicken-invite.md new file mode 100644 index 0000000000000..0c13329c3f84d --- /dev/null +++ b/.changeset/five-chicken-invite.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/federation-matrix': minor +'@rocket.chat/meteor': minor +--- + +Adds support to name changes on federated rooms diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index ff2d09a2eeaf3..affbcb3192941 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -353,3 +353,18 @@ callbacks.add( callbacks.priority.MEDIUM, 'federation-read-receipt', ); + +callbacks.add('afterSaveUser', async ({ user: userUpdated, oldUser: oldUserData }) => { + if (!userUpdated || !oldUserData) { + return; + } + + if (isUserNativeFederated(userUpdated)) { + // if the user is federated, it means the update came from Matrix, so we don't need to notify Matrix again + return; + } + + if ('name' in userUpdated && userUpdated.name !== oldUserData.name) { + void FederationMatrix.updateUserName(userUpdated); + } +}); diff --git a/apps/meteor/package.json b/apps/meteor/package.json index a0d1d3846f837..6263a6ec90adb 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -103,7 +103,7 @@ "@rocket.chat/emitter": "^0.32.0", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/federation-matrix": "workspace:^", - "@rocket.chat/federation-sdk": "0.4.3", + "@rocket.chat/federation-sdk": "0.5.0", "@rocket.chat/fuselage": "^0.73.0", "@rocket.chat/fuselage-forms": "^1.0.0", "@rocket.chat/fuselage-hooks": "^0.40.0", diff --git a/apps/meteor/server/methods/saveUserProfile.ts b/apps/meteor/server/methods/saveUserProfile.ts index f28e0c59f8c62..5f17d4a98f7fa 100644 --- a/apps/meteor/server/methods/saveUserProfile.ts +++ b/apps/meteor/server/methods/saveUserProfile.ts @@ -16,6 +16,7 @@ import { passwordPolicy } from '../../app/lib/server/lib/passwordPolicy'; import { setEmailFunction } from '../../app/lib/server/methods/setEmail'; import { settings as rcSettings } from '../../app/settings/server'; import { setUserStatusMethod } from '../../app/user-status/server/methods/setUserStatus'; +import { callbacks } from '../lib/callbacks'; import { compareUserPassword } from '../lib/compareUserPassword'; import { compareUserPasswordHistory } from '../lib/compareUserPasswordHistory'; @@ -178,6 +179,11 @@ async function saveUserProfile( throw new Error('Unexpected error after saving user profile: user not found'); } + await callbacks.run('afterSaveUser', { + user: updatedUser, + oldUser: user, + }); + void notifyOnUserChange({ clientAction: 'updated', id: updatedUser._id, diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index 44d894d1977b1..168a3bdaf32c9 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -42,6 +42,9 @@ export class RoomService extends ServiceClassInternal implements IRoomService { protected name = 'room'; async updateDirectMessageRoomName(room: IRoom, ignoreStatusFromSubs?: string[]): Promise { + if (room.t !== 'd') { + throw new Error('Invalid room type'); + } const subs = await Subscriptions.findByRoomId(room._id, { projection: { u: 1, status: 1 } }).toArray(); const uids = subs.map((sub) => sub.u._id); diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index e16d61c001d40..289daf7f813de 100644 --- a/ee/packages/federation-matrix/package.json +++ b/ee/packages/federation-matrix/package.json @@ -22,14 +22,16 @@ "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "^0.32.0", - "@rocket.chat/federation-sdk": "0.4.3", + "@rocket.chat/federation-sdk": "0.5.0", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/license": "workspace:^", "@rocket.chat/models": "workspace:^", "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", "emojione": "^4.5.0", + "lodash.debounce": "^4.0.8", "marked": "^16.1.2", + "mem": "^8.1.1", "mongodb": "6.16.0", "pino": "10.3.1", "reflect-metadata": "^0.2.2", @@ -40,6 +42,7 @@ "devDependencies": { "@rocket.chat/ddp-client": "workspace:^", "@types/emojione": "^2.2.9", + "@types/lodash.debounce": "^4.0.9", "@types/node": "~22.16.5", "@types/sanitize-html": "~2.16.0", "eslint": "~9.39.3", diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index c23b4ffbdfc2b..6c532618a9815 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -7,7 +7,7 @@ import { isUserNativeFederated, UserStatus, } from '@rocket.chat/core-typings'; -import type { MessageQuoteAttachment, IMessage, IRoom, IUser, IRoomNativeFederated } from '@rocket.chat/core-typings'; +import type { MessageQuoteAttachment, IMessage, IRoom, IUser, IRoomNativeFederated, ISubscription } from '@rocket.chat/core-typings'; import { eventIdSchema, roomIdSchema, userIdSchema, federationSDK, FederationRequestError } from '@rocket.chat/federation-sdk'; import type { EventID, FileMessageType, PresenceState } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; @@ -948,4 +948,33 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS ...(threadEventId && { threadId: eventIdSchema.parse(threadEventId) }), }); } + + // when a user changes their username, we need to send a new event for every room the user is a member + async updateUserName(user: IUser): Promise { + const matrixUserId = userIdSchema.parse(`@${user.username}:${this.serverName}`); + + const subs = await Subscriptions.findJoinedByUserId>(user._id, { projection: { rid: 1 } }).toArray(); + + const rooms = await Rooms.findFederatedByIds>( + subs.map(({ rid }) => rid), + { projection: { _id: 1, federation: 1, federated: 1 } }, + ).toArray(); + + await Promise.all( + rooms.map(async ({ federation }) => { + try { + await federationSDK.updateRoomMembership({ + roomId: roomIdSchema.parse(federation.mrid), + userId: matrixUserId, + membership: 'join', + content: { + displayname: user.name || user.username, + }, + }); + } catch (err) { + this.logger.error({ msg: 'Failed to update username in Matrix for a room', roomId: federation.mrid, err }); + } + }), + ); + } } diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index bde8d5f939e9a..19b629055bb96 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -4,6 +4,8 @@ import type { IRoomNativeFederated, IRoom, IUser, RoomType } from '@rocket.chat/ import { federationSDK, type HomeserverEventSignatures, type PduForType } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import debounce from 'lodash.debounce'; +import mem from 'mem'; import { createOrUpdateFederatedUser } from '../helpers/createOrUpdateFederatedUser'; import { getUsernameServername } from '../helpers/getUsernameServername'; @@ -196,9 +198,16 @@ async function handleInvite({ } } +const getUpdateUserNameDebounced = mem((userId: string) => debounce((name: string) => Users.setName(userId, name), 2000)); + +function updateUserNameDebounced(userId: string, newName: string): void { + void getUpdateUserNameDebounced(userId)(newName); +} + async function handleJoin({ room_id: roomId, state_key: userId, + content, }: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { const joiningUser = await getOrCreateFederatedUser(userId); if (!joiningUser?.username) { @@ -215,6 +224,13 @@ async function handleJoin({ throw new Error(`Subscription not found while joining user ${userId} to room ${roomId}`); } + // updates user name whenever we receive a join event, because Matrix sends a new join event with the updated display name whenever a user changes their display name + if ('displayname' in content && content.displayname !== joiningUser.name) { + // whan a user changes the it's display name we receive a new join event for every room the user is in + // so we need to debounce the name update to avoid updating the name multiple times in a row + void updateUserNameDebounced(joiningUser._id, content.displayname || ''); + } + // update room name for DMs if (room.t === 'd') { await Room.updateDirectMessageRoomName(room, [subscription._id]); diff --git a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts index 7a9873d8b0da2..a4a8c2202530f 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts @@ -126,7 +126,7 @@ const waitForRoomEvent = async ( subscriptionInvite = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request); expect(subscriptionInvite).toHaveProperty('status', 'INVITED'); - expect(subscriptionInvite).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`); + expect(subscriptionInvite).toHaveProperty('fname', federationConfig.hs1.adminUser); }); }); @@ -153,7 +153,7 @@ const waitForRoomEvent = async ( it('should display the fname properly', async () => { const sub = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request); - expect(sub).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); + expect(sub).toHaveProperty('fname', federationConfig.hs1.adminUser); }); it('should return own user name as the room name when user is alone in the DM', async () => { @@ -236,7 +236,7 @@ const waitForRoomEvent = async ( subscriptionInvite = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request); expect(subscriptionInvite).toHaveProperty('status', 'INVITED'); - expect(subscriptionInvite).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`); + expect(subscriptionInvite).toHaveProperty('fname', federationConfig.hs1.adminUser); }); }); @@ -264,7 +264,7 @@ const waitForRoomEvent = async ( it('should display the fname properly', async () => { const sub = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request); - expect(sub).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); + expect(sub).toHaveProperty('fname', federationConfig.hs1.adminUser); }); it('should be able to leave the DM from Rocket.Chat', async () => { @@ -412,7 +412,7 @@ const waitForRoomEvent = async ( const sub = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request); // After acceptance, should display the Synapse user's ID - expect(sub).toHaveProperty('fname', federationConfig.hs1.additionalUser1.matrixUserId); + expect(sub).toHaveProperty('fname', federationConfig.hs1.additionalUser1.username); }); }); @@ -543,8 +543,7 @@ const waitForRoomEvent = async ( pendingInvitation1 = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig1.credentials, rcUserConfig1.request); expect(pendingInvitation1).toHaveProperty('status', 'INVITED'); - expect(pendingInvitation1).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`); - expect(pendingInvitation1).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); + expect(pendingInvitation1).toHaveProperty('fname', federationConfig.hs1.adminUser); }); it('should have user1 as regular user of the group DM on RC', async () => { @@ -556,8 +555,7 @@ const waitForRoomEvent = async ( pendingInvitation2 = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig2.credentials, rcUserConfig2.request); expect(pendingInvitation2).toHaveProperty('status', 'INVITED'); - expect(pendingInvitation2).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`); - expect(pendingInvitation2).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); + expect(pendingInvitation2).toHaveProperty('fname', federationConfig.hs1.adminUser); }); it('should have user2 as regular user of the group DM on RC', async () => { @@ -582,7 +580,7 @@ const waitForRoomEvent = async ( expect(sub).not.toHaveProperty('status'); expect(sub).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${userDm2}`); - expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${userDm2Name}`); + expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${userDm2Name}`); }, { delayMs: 100 }, ); @@ -778,7 +776,7 @@ const waitForRoomEvent = async ( const pendingInvitationB = await getSubscriptionByRoomId(rcRoomConverted._id, rcUserConfigB.credentials, rcUserConfigB.request); expect(pendingInvitationB).toHaveProperty('status', 'INVITED'); - expect(pendingInvitationB).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); + expect(pendingInvitationB).toHaveProperty('fname', federationConfig.hs1.adminUser); }); const membersInMatrix = await hs1RoomConverted.getMembers(); @@ -809,14 +807,14 @@ const waitForRoomEvent = async ( expect(subA).not.toHaveProperty('status'); expect(subA).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${userDmB}`); - expect(subA).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${userDmBName}`); + expect(subA).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${userDmBName}`); // Check userB's subscription const subB = await getSubscriptionByRoomId(rcRoomConverted._id, rcUserConfigB.credentials, rcUserConfigB.request); expect(subB).not.toHaveProperty('status'); expect(subB).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${userDmA}`); - expect(subB).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${userDmAName}`); + expect(subB).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${userDmAName}`); }, { delayMs: 100 }, ); @@ -944,7 +942,7 @@ const waitForRoomEvent = async ( // Should contain both invited users in the name expect(sub).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser2.username}`); - expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser2.fullName}`); + expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${rcUser2.fullName}`); }); it("should display only the inviter's username for the invited user on Rocket.Chat", async () => { @@ -952,7 +950,7 @@ const waitForRoomEvent = async ( expect(sub).toHaveProperty('status', 'INVITED'); expect(sub).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser1.username}`); - expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser1.fullName}`); + expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${rcUser1.fullName}`); }); it('should accept the invitation on Synapse', async () => { @@ -979,7 +977,7 @@ const waitForRoomEvent = async ( expect(sub).toHaveProperty('status', 'INVITED'); expect(sub).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser1.username}`); - expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser1.fullName}`); + expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${rcUser1.fullName}`); }, { delayMs: 100 }, ); @@ -1254,7 +1252,7 @@ const waitForRoomEvent = async ( const sub = await getSubscriptionByRoomId(rcRoom._id, rcUser2.config.credentials, rcUser2.config.request); // After acceptance, should display the Synapse user's ID - expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser1.fullName}`); + expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${rcUser1.fullName}`); }); // Then create non-federated DM between rcUser1 and rcUser2 which should be returned on duplication @@ -1439,7 +1437,7 @@ const waitForRoomEvent = async ( const sub = await getSubscriptionByRoomId(rcRoom1on1._id, rcUser1.config.credentials, rcUser1.config.request); // After acceptance, should display the Synapse user's ID - expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}`); + expect(sub).toHaveProperty('fname', federationConfig.hs1.adminUser); }); }); @@ -1489,7 +1487,7 @@ const waitForRoomEvent = async ( const sub = await getSubscriptionByRoomId(rcRoom._id, rcUser2.config.credentials, rcUser2.config.request); // After acceptance, should display the Synapse user's ID - expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser1.fullName}`); + expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${rcUser1.fullName}`); }, { delayMs: 100 }, ); @@ -1704,7 +1702,7 @@ const waitForRoomEvent = async ( // Should contain both invited users in the name expect(sub).toHaveProperty('name', federationConfig.hs1.adminMatrixUserId); - expect(sub).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId); + expect(sub).toHaveProperty('fname', federationConfig.hs1.adminUser); }); it('should send an invite to another Synapse user', async () => { @@ -1740,10 +1738,7 @@ const waitForRoomEvent = async ( 'name', `${federationConfig.hs1.adminMatrixUserId}, ${federationConfig.hs1.additionalUser1.matrixUserId}`, ); - expect(subA).toHaveProperty( - 'fname', - `${federationConfig.hs1.adminMatrixUserId}, ${federationConfig.hs1.additionalUser1.matrixUserId}`, - ); + expect(subA).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${federationConfig.hs1.additionalUser1.username}`); }, { delayMs: 100 }, ); @@ -1778,7 +1773,7 @@ const waitForRoomEvent = async ( expect(sub).toHaveProperty('status', 'INVITED'); expect(sub).toHaveProperty('name', federationConfig.hs1.additionalUser1.matrixUserId); - expect(sub).toHaveProperty('fname', federationConfig.hs1.additionalUser1.matrixUserId); + expect(sub).toHaveProperty('fname', federationConfig.hs1.additionalUser1.username); }, { delayMs: 100 }, ); diff --git a/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts index 4b51395eb8d84..760c74435da83 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts @@ -2,6 +2,7 @@ import type { IMessage, IUser } from '@rocket.chat/core-typings'; import type { Room } from 'matrix-js-sdk'; import { EventTimeline } from 'matrix-js-sdk'; +import { api } from '../../../../../apps/meteor/tests/data/api-data'; import { createRoom, getRoomInfo, @@ -1810,5 +1811,167 @@ import { SynapseClient } from '../helper/synapse-client'; }); }); }); + + describe.skip('Synchronizing user names across federated servers', () => { + const ts = Date.now(); + + const rcUser1 = { + username: `sync-name-user1-${ts}`, + fullName: `Sync Name User1 ${ts}`, + newFullName: `Updated Sync Name User1 ${ts}`, + adminUpdatedName: `Admin Updated User1 ${ts}`, + get matrixId() { + return `@${this.username}:${federationConfig.rc1.domain}`; + }, + config: {} as IRequestConfig, + user: {} as IUser, + }; + + const newSynapseDisplayName = `Updated Synapse Name ${ts}`; + + let channelName: string; + let federatedChannel: { _id: string; name: string; t: string; federated?: boolean; federation?: { mrid: string } }; + + beforeAll(async () => { + rcUser1.user = await createUser( + { + username: rcUser1.username, + password: 'random', + email: `${rcUser1.username}@rocket.chat`, + name: rcUser1.fullName, + }, + rc1AdminRequestConfig, + ); + + rcUser1.config = await getRequestConfig(federationConfig.rc1.url, rcUser1.username, 'random'); + + channelName = `sync-name-channel-${ts}`; + + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [federationConfig.hs1.adminMatrixUserId], + extraData: { federated: true }, + config: rcUser1.config, + }); + + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('federation'); + + await hs1AdminApp.acceptInvitationForRoomName(channelName); + }, 20000); + + afterAll(async () => { + await deleteUser(rcUser1.user, {}, rc1AdminRequestConfig); + }); + + describe('When a RC local user changes their display name', () => { + it('should propagate the updated displayname to the Synapse side', async () => { + // Action: update the RC user's display name via their own profile + await rcUser1.config.request + .post(api('users.updateOwnBasicInfo')) + .set(rcUser1.config.credentials) + .send({ data: { name: rcUser1.newFullName } }) + .expect(200); + + // Synapse view: verify the member's displayname was updated in the federated room + await retry( + 'waiting for RC user displayname to propagate to Synapse', + async () => { + const members = await hs1AdminApp.getRoomMembers(channelName); + const member = members.find((m) => m.userId === rcUser1.matrixId); + expect(member).toBeDefined(); + expect(member?.name).toBe(rcUser1.newFullName); + }, + { retries: 10, delayMs: 1000 }, + ); + }); + }); + + describe('When a RC admin changes the user display name via admin APIs', () => { + it('should propagate the updated displayname to the Synapse side', async () => { + // Action: update the RC user's display name via the admin users.update API + await rc1AdminRequestConfig.request + .post(api('users.update')) + .set(rc1AdminRequestConfig.credentials) + .send({ userId: rcUser1.user._id, data: { name: rcUser1.adminUpdatedName } }) + .expect(200); + + // Synapse view: verify the member's displayname was updated in the federated room + await retry( + 'waiting for admin-updated displayname to propagate to Synapse', + async () => { + const members = await hs1AdminApp.getRoomMembers(channelName); + const member = members.find((m) => m.userId === rcUser1.matrixId); + expect(member).toBeDefined(); + expect(member?.name).toBe(rcUser1.adminUpdatedName); + }, + { retries: 10, delayMs: 1000 }, + ); + }); + }); + + describe('When a new federated room is created on RC', () => { + it.failing('should show the user current display name on the Synapse side', async () => { + // At this point rcUser1's name is adminUpdatedName from the previous test + const newChannelName = `sync-name-channel-2-${ts}`; + + const createResponse = await createRoom({ + type: 'p', + name: newChannelName, + members: [federationConfig.hs1.adminMatrixUserId], + extraData: { federated: true }, + config: rcUser1.config, + }); + + const newChannel = createResponse.body.group; + expect(newChannel).toHaveProperty('_id'); + expect(newChannel).toHaveProperty('federation'); + + await hs1AdminApp.acceptInvitationForRoomName(newChannelName); + + // Synapse view: the RC user should appear with their current name in the new room + await retry( + 'waiting for RC user to appear with correct displayname in new Synapse room', + async () => { + const members = await hs1AdminApp.getRoomMembers(newChannelName); + const member = members.find((m) => m.userId === rcUser1.matrixId); + expect(member).toBeDefined(); + expect(member?.name).toBe(rcUser1.adminUpdatedName); + }, + { retries: 10, delayMs: 1000 }, + ); + }); + }); + + describe('When a Synapse user changes their displayname', () => { + afterAll(async () => { + await hs1AdminApp.matrixClient.setDisplayName(federationConfig.hs1.adminMatrixUserId); // reset Synapse admin name + }); + + it('should propagate the updated name to the RC side', async () => { + // Action: update the Synapse user's displayname — Synapse broadcasts new m.room.member + // events to all joined rooms, which RC federation receives and debounces the name update + await hs1AdminApp.matrixClient.setDisplayName(newSynapseDisplayName); + + // RC view: verify the federated user's name was updated — wait for debounce (> 2s) + propagation + await retry( + 'waiting for Synapse user displayname to propagate to RC', + async () => { + const response = await rc1AdminRequestConfig.request + .get(api('users.info')) + .set(rc1AdminRequestConfig.credentials) + .query({ username: federationConfig.hs1.adminMatrixUserId }) + .expect(200); + + expect(response.body.user).toHaveProperty('name', newSynapseDisplayName); + }, + { retries: 15, delayMs: 1000 }, + ); + }); + }); + }); }); }); diff --git a/packages/core-services/package.json b/packages/core-services/package.json index 06b7a873e7920..5f3e84e623501 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/federation-sdk": "0.4.3", + "@rocket.chat/federation-sdk": "0.5.0", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/icons": "~0.47.0", "@rocket.chat/media-signaling": "workspace:^", diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index ce63c854912ac..d8bcb7165a891 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -33,4 +33,5 @@ export interface IFederationMatrixService { handleInvite(subscriptionId: ISubscription['_id'], userId: IUser['_id'], action: 'accept' | 'reject'): Promise; canUserAccessFederation(user: IUser): Promise; notifyRoomRead(params: { room: IRoomNativeFederated; userId: string; threadId?: string }): Promise; + updateUserName(user: IUser): Promise; } diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 2639f6adcb3a7..736ccc811c403 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -1,4 +1,13 @@ -import type { IDirectMessageRoom, IMessage, IOmnichannelGenericRoom, IRoom, IRoomFederated, ITeam, IUser } from '@rocket.chat/core-typings'; +import type { + IDirectMessageRoom, + IMessage, + IOmnichannelGenericRoom, + IRoom, + IRoomFederated, + IRoomNativeFederated, + ITeam, + IUser, +} from '@rocket.chat/core-typings'; import type { AggregationCursor, DeleteResult, @@ -336,4 +345,5 @@ export interface IRoomsModel extends IBaseModel { updateAbacAttributeValuesArrayFilteredById(rid: IRoom['_id'], key: string, values: string[]): Promise; removeAbacAttributeByRoomIdAndKey(rid: IRoom['_id'], key: string): Promise; removeUserReferenceFromDMsById(roomId: string, username: string, userId: string): Promise; + findFederatedByIds(ids: Array, options?: FindOptions): FindCursor; } diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 81242ff09ff2d..656f2e89254c7 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -343,4 +343,5 @@ export interface ISubscriptionsModel extends IBaseModel { banByRoomIdAndUserId(roomId: string, userId: string): Promise; unbanByRoomIdAndUserId(roomId: string, userId: string): Promise; setAbacLastTimeCheckedByUserIdAndRoomId(userId: string, roomId: string, time: Date): Promise; + findJoinedByUserId(userId: ISubscription['u']['_id'], options?: FindOptions): FindCursor; } diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 9bcac18cf72fe..f2a1d07a7e552 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -4,6 +4,7 @@ import type { IOmnichannelGenericRoom, IRoom, IRoomFederated, + IRoomNativeFederated, ITeam, IUser, RocketChatRecordDeleted, @@ -914,6 +915,15 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.find(query, options); } + findFederatedByIds(ids: Array, options: FindOptions = {}): FindCursor { + const query = { + _id: { $in: ids }, + federated: true, + }; + + return this.find(query, options); + } + findOneFederatedByMrid(mrid: string, options: FindOptions = {}): Promise { const query: Filter = { 'federated': true, diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index 045945eda5f5b..5a7bbdfc7e51a 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -2166,4 +2166,14 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateOne(query, update); } + + findJoinedByUserId(userId: ISubscription['u']['_id'], options?: FindOptions): FindCursor { + return this.find( + { + 'u._id': userId, + 'status': { $exists: false }, + }, + options, + ); + } } diff --git a/packages/server-fetch/package.json b/packages/server-fetch/package.json index 3a19b665aad44..5e6dfa0022472 100644 --- a/packages/server-fetch/package.json +++ b/packages/server-fetch/package.json @@ -20,6 +20,7 @@ "@rocket.chat/models": "workspace:^", "@rocket.chat/tools": "workspace:^", "@types/proxy-from-env": "^1.0.4", + "abort-controller": "^3.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "node-fetch": "2.7.0", diff --git a/yarn.lock b/yarn.lock index 4d427c6cb817f..e6b5782599475 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9140,7 +9140,7 @@ __metadata: dependencies: "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-typings": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.4.3" + "@rocket.chat/federation-sdk": "npm:0.5.0" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/icons": "npm:~0.47.0" "@rocket.chat/jest-presets": "workspace:~" @@ -9350,21 +9350,24 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/ddp-client": "workspace:^" "@rocket.chat/emitter": "npm:^0.32.0" - "@rocket.chat/federation-sdk": "npm:0.4.3" + "@rocket.chat/federation-sdk": "npm:0.5.0" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/license": "workspace:^" "@rocket.chat/models": "workspace:^" "@rocket.chat/network-broker": "workspace:^" "@rocket.chat/rest-typings": "workspace:^" "@types/emojione": "npm:^2.2.9" + "@types/lodash.debounce": "npm:^4.0.9" "@types/node": "npm:~22.16.5" "@types/sanitize-html": "npm:~2.16.0" emojione: "npm:^4.5.0" eslint: "npm:~9.39.3" jest: "npm:~30.2.0" jest-qase-reporter: "npm:^2.1.4" + lodash.debounce: "npm:^4.0.8" marked: "npm:^16.1.2" matrix-js-sdk: "npm:^38.4.0" + mem: "npm:^8.1.1" mongodb: "npm:6.16.0" pino: "npm:10.3.1" pino-pretty: "npm:13.1.3" @@ -9376,22 +9379,22 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/federation-sdk@npm:0.4.3": - version: 0.4.3 - resolution: "@rocket.chat/federation-sdk@npm:0.4.3" +"@rocket.chat/federation-sdk@npm:0.5.0": + version: 0.5.0 + resolution: "@rocket.chat/federation-sdk@npm:0.5.0" dependencies: "@datastructures-js/priority-queue": "npm:^6.3.5" "@noble/ed25519": "npm:^3.0.0" "@rocket.chat/emitter": "npm:^0.32.0" mongodb: "npm:^6.16.0" - pino: "npm:^8.21.0" + pino: "npm:^10.3.1" reflect-metadata: "npm:^0.2.2" tsyringe: "npm:^4.10.0" tweetnacl: "npm:^1.0.3" zod: "npm:~4.3.6" peerDependencies: typescript: ~5.9.2 - checksum: 10/09380fd6ef9782448d3df001f183959785201a40b7bf0798fb88103ec0a096f3431f6a2623be22d8e671d5f7653c95c1ab0767ab126374f8c20bd02fd1ee2cc5 + checksum: 10/34d8a28a9edda6864939735c8f9333ca2de0178a67baf14ff94090238030c1227fe9a03cb8d6db12e14ce6fdb61072ebc2a25bb82616aaa667796f95a41b1ae3 languageName: node linkType: hard @@ -9998,7 +10001,7 @@ __metadata: "@rocket.chat/emitter": "npm:^0.32.0" "@rocket.chat/favicon": "workspace:^" "@rocket.chat/federation-matrix": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.4.3" + "@rocket.chat/federation-sdk": "npm:0.5.0" "@rocket.chat/fuselage": "npm:^0.73.0" "@rocket.chat/fuselage-forms": "npm:^1.0.0" "@rocket.chat/fuselage-hooks": "npm:^0.40.0" @@ -10850,6 +10853,7 @@ __metadata: "@types/jest": "npm:^29.5.5" "@types/node-fetch": "npm:~2.6.13" "@types/proxy-from-env": "npm:^1.0.4" + abort-controller: "npm:^3.0.0" eslint: "npm:~9.39.3" http-proxy-agent: "npm:^7.0.2" https-proxy-agent: "npm:^7.0.6" @@ -22810,13 +22814,6 @@ __metadata: languageName: node linkType: hard -"fast-redact@npm:^3.1.1": - version: 3.5.0 - resolution: "fast-redact@npm:3.5.0" - checksum: 10/24b27e2023bd5a62f908d97a753b1adb8d89206b260f97727728e00b693197dea2fc2aa3711147a385d0ec6e713569fd533df37a4ef947e08cb65af3019c7ad5 - languageName: node - linkType: hard - "fast-safe-stringify@npm:^2.0.7, fast-safe-stringify@npm:^2.1.1": version: 2.1.1 resolution: "fast-safe-stringify@npm:2.1.1" @@ -31166,16 +31163,6 @@ __metadata: languageName: node linkType: hard -"pino-abstract-transport@npm:^1.2.0": - version: 1.2.0 - resolution: "pino-abstract-transport@npm:1.2.0" - dependencies: - readable-stream: "npm:^4.0.0" - split2: "npm:^4.0.0" - checksum: 10/6ec1d19a7ff3347fd21576f744c31c3e38ca4463ae638818408f43698c936f96be6a0bc750af5f7c1ae81873183bfcb062b7a0d12dc159a1813ea900c388c693 - languageName: node - linkType: hard - "pino-abstract-transport@npm:^3.0.0": version: 3.0.0 resolution: "pino-abstract-transport@npm:3.0.0" @@ -31208,13 +31195,6 @@ __metadata: languageName: node linkType: hard -"pino-std-serializers@npm:^6.0.0": - version: 6.2.2 - resolution: "pino-std-serializers@npm:6.2.2" - checksum: 10/a00cdff4e1fbc206da9bed047e6dc400b065f43e8b4cef1635b0192feab0e8f932cdeb0faaa38a5d93d2e777ba4cda939c2ed4c1a70f6839ff25f9aef97c27ff - languageName: node - linkType: hard - "pino-std-serializers@npm:^7.0.0": version: 7.1.0 resolution: "pino-std-serializers@npm:7.1.0" @@ -31222,7 +31202,7 @@ __metadata: languageName: node linkType: hard -"pino@npm:10.3.1": +"pino@npm:10.3.1, pino@npm:^10.3.1": version: 10.3.1 resolution: "pino@npm:10.3.1" dependencies: @@ -31243,27 +31223,6 @@ __metadata: languageName: node linkType: hard -"pino@npm:^8.21.0": - version: 8.21.0 - resolution: "pino@npm:8.21.0" - dependencies: - atomic-sleep: "npm:^1.0.0" - fast-redact: "npm:^3.1.1" - on-exit-leak-free: "npm:^2.1.0" - pino-abstract-transport: "npm:^1.2.0" - pino-std-serializers: "npm:^6.0.0" - process-warning: "npm:^3.0.0" - quick-format-unescaped: "npm:^4.0.3" - real-require: "npm:^0.2.0" - safe-stable-stringify: "npm:^2.3.1" - sonic-boom: "npm:^3.7.0" - thread-stream: "npm:^2.6.0" - bin: - pino: bin.js - checksum: 10/5a054eab533ab91b20f63497b86070f0a6b40e4688cde9de66d23e03d6046c4e95d69c3f526dea9f30bcbc5874c7fbf0f91660cded4753946fd02261ca8ac340 - languageName: node - linkType: hard - "pirates@npm:^4.0.4, pirates@npm:^4.0.6, pirates@npm:^4.0.7": version: 4.0.7 resolution: "pirates@npm:4.0.7" @@ -32280,13 +32239,6 @@ __metadata: languageName: node linkType: hard -"process-warning@npm:^3.0.0": - version: 3.0.0 - resolution: "process-warning@npm:3.0.0" - checksum: 10/2d82fa641e50a5789eaf0f2b33453760996e373d4591aac576a22d696186ab7e240a0592db86c264d4f28a46c2abbe9b94689752017db7dadc90f169f12b0924 - languageName: node - linkType: hard - "process-warning@npm:^5.0.0": version: 5.0.0 resolution: "process-warning@npm:5.0.0" @@ -35132,15 +35084,6 @@ __metadata: languageName: node linkType: hard -"sonic-boom@npm:^3.7.0": - version: 3.8.1 - resolution: "sonic-boom@npm:3.8.1" - dependencies: - atomic-sleep: "npm:^1.0.0" - checksum: 10/e03c9611e43fa81132cd2ce0fe4eb7fbcf19db267e9dec20dc6c586f82465c9c906e91a02f72150c740463ad9335536ea2131850307aaa6686d1fb5d4cc4be3e - languageName: node - linkType: hard - "sonic-boom@npm:^4.0.1": version: 4.2.1 resolution: "sonic-boom@npm:4.2.1" @@ -36560,15 +36503,6 @@ __metadata: languageName: node linkType: hard -"thread-stream@npm:^2.6.0": - version: 2.7.0 - resolution: "thread-stream@npm:2.7.0" - dependencies: - real-require: "npm:^0.2.0" - checksum: 10/03e743a2ccb2af5fa695d2e4369113336ee9b9f09c4453d50a222cbb4ae3af321bff658e0e5bf8bfbce9d7f5a7bf6262d12a2a365e160f4e76380ec624d32e7b - languageName: node - linkType: hard - "thread-stream@npm:^4.0.0": version: 4.0.0 resolution: "thread-stream@npm:4.0.0"