Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/five-chicken-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/federation-matrix': minor
'@rocket.chat/meteor': minor
---

Adds support to name changes on federated rooms
15 changes: 15 additions & 0 deletions apps/meteor/ee/server/hooks/federation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

@cubic-dev-ai cubic-dev-ai Bot Mar 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Fire-and-forget updateUserName should still handle rejections; currently failures can become unhandled promise rejections.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/ee/server/hooks/federation/index.ts, line 325:

<comment>Fire-and-forget `updateUserName` should still handle rejections; currently failures can become unhandled promise rejections.</comment>

<file context>
@@ -322,6 +322,6 @@ callbacks.add('afterSaveUser', async ({ user: userUpdated, oldUser: oldUserData
 
 	if ('name' in userUpdated && userUpdated.name !== oldUserData.name) {
-		await FederationMatrix.updateUserName(userUpdated);
+		void FederationMatrix.updateUserName(userUpdated);
 	}
 });
</file context>
Suggested change
void FederationMatrix.updateUserName(userUpdated);
void FederationMatrix.updateUserName(userUpdated).catch((error) => {
console.error('[updateUserName] Failed to propagate user name change to federation:', error);
});
Fix with Cubic

}
});
2 changes: 1 addition & 1 deletion apps/meteor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions apps/meteor/server/methods/saveUserProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions apps/meteor/server/services/room/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export class RoomService extends ServiceClassInternal implements IRoomService {
protected name = 'room';

async updateDirectMessageRoomName(room: IRoom, ignoreStatusFromSubs?: string[]): Promise<boolean> {
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);
Expand Down
5 changes: 4 additions & 1 deletion ee/packages/federation-matrix/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
31 changes: 30 additions & 1 deletion ee/packages/federation-matrix/src/FederationMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
const matrixUserId = userIdSchema.parse(`@${user.username}:${this.serverName}`);

@cubic-dev-ai cubic-dev-ai Bot Mar 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Use the existing federated Matrix user ID when present; rebuilding it from the updated username can send updates for the wrong Matrix user after a rename.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ee/packages/federation-matrix/src/FederationMatrix.ts, line 895:

<comment>Use the existing federated Matrix user ID when present; rebuilding it from the updated username can send updates for the wrong Matrix user after a rename.</comment>

<file context>
@@ -888,4 +888,36 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS
+	// 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<void> {
+		// const oldMatrixUserId = user.federation.mui;
+		const matrixUserId = userIdSchema.parse(`@${user.username}:${this.serverName}`);
+
+		const subs = await Subscriptions.findJoinedByUserId<Pick<ISubscription, 'rid'>>(user._id, { projection: { rid: 1 } }).toArray();
</file context>
Fix with Cubic


const subs = await Subscriptions.findJoinedByUserId<Pick<ISubscription, 'rid'>>(user._id, { projection: { rid: 1 } }).toArray();

const rooms = await Rooms.findFederatedByIds<Pick<IRoomNativeFederated, '_id' | 'federation' | 'federated'>>(
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 });
}
}),
);
}
}
16 changes: 16 additions & 0 deletions ee/packages/federation-matrix/src/events/member.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
const joiningUser = await getOrCreateFederatedUser(userId);
if (!joiningUser?.username) {
Expand All @@ -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]);
Expand Down
45 changes: 20 additions & 25 deletions ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 },
);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 },
);
Expand Down Expand Up @@ -944,15 +942,15 @@ 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 () => {
const sub = await getSubscriptionByRoomId(rcRoom._id, rcUser2.config.credentials, rcUser2.config.request);

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 () => {
Expand All @@ -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 },
);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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 },
);
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 },
);
Expand Down Expand Up @@ -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 },
);
Expand Down
Loading
Loading