From 0d691141d329ff3bcb8d9b56bf17912472cc3105 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 15 Jul 2025 08:44:28 -0300 Subject: [PATCH 01/18] chore: adds bridged messages collection support --- .../src/federation/IMatrixBridgedMessage.ts | 6 ++++ packages/core-typings/src/federation/index.ts | 1 + packages/model-typings/src/index.ts | 1 + .../src/models/IMatrixBridgedMessageModel.ts | 10 ++++++ packages/models/src/index.ts | 4 +++ packages/models/src/modelClasses.ts | 1 + .../models/src/models/MatrixBridgedMessage.ts | 36 +++++++++++++++++++ 7 files changed, 59 insertions(+) create mode 100644 packages/core-typings/src/federation/IMatrixBridgedMessage.ts create mode 100644 packages/model-typings/src/models/IMatrixBridgedMessageModel.ts create mode 100644 packages/models/src/models/MatrixBridgedMessage.ts diff --git a/packages/core-typings/src/federation/IMatrixBridgedMessage.ts b/packages/core-typings/src/federation/IMatrixBridgedMessage.ts new file mode 100644 index 0000000000000..17c69cdf1772e --- /dev/null +++ b/packages/core-typings/src/federation/IMatrixBridgedMessage.ts @@ -0,0 +1,6 @@ +import type { IRocketChatRecord } from '../IRocketChatRecord'; + +export interface IMatrixBridgedMessage extends IRocketChatRecord { + mid: string; + meid: string; +} diff --git a/packages/core-typings/src/federation/index.ts b/packages/core-typings/src/federation/index.ts index f3dbfe7778c66..300788422a364 100644 --- a/packages/core-typings/src/federation/index.ts +++ b/packages/core-typings/src/federation/index.ts @@ -1,4 +1,5 @@ export * from './IMatrixBridgedRoom'; export * from './IMatrixBridgedUser'; +export * from './IMatrixBridgedMessage'; export * from './v1'; diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 50816e2b6f043..74cbc48443df0 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -70,6 +70,7 @@ export * from './models/IVoipRoomModel'; export * from './models/IWebdavAccountsModel'; export * from './models/IMatrixBridgedRoomModel'; export * from './models/IMatrixBridgedUserModel'; +export * from './models/IMatrixBridgedMessageModel'; export * from './models/ICalendarEventModel'; export * from './models/IOmnichannelServiceLevelAgreementsModel'; export * from './models/IAppLogsModel'; diff --git a/packages/model-typings/src/models/IMatrixBridgedMessageModel.ts b/packages/model-typings/src/models/IMatrixBridgedMessageModel.ts new file mode 100644 index 0000000000000..3bbb7e80d0a31 --- /dev/null +++ b/packages/model-typings/src/models/IMatrixBridgedMessageModel.ts @@ -0,0 +1,10 @@ +import type { IMatrixBridgedMessage } from '@rocket.chat/core-typings'; + +import type { IBaseModel } from './IBaseModel'; + +export interface IMatrixBridgedMessageModel extends IBaseModel { + getExternalEventId(localMessageId: string): Promise; + getLocalMessageId(externalEventId: string): Promise; + createOrUpdate(localMessageId: string, externalEventId: string): Promise; + removeByLocalMessageId(localMessageId: string): Promise; +} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index d1f9863d24490..3079e91513d89 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -76,6 +76,7 @@ import type { IWebdavAccountsModel, IMatrixBridgedRoomModel, IMatrixBridgedUserModel, + IMatrixBridgedMessageModel, ICalendarEventModel, IOmnichannelServiceLevelAgreementsModel, IAppsModel, @@ -117,6 +118,7 @@ import { UsersSessionsRaw, MatrixBridgedUserRaw, MatrixBridgedRoomRaw, + MatrixBridgedMessageRaw, } from './modelClasses'; import { proxify, registerModel } from './proxify'; @@ -213,6 +215,7 @@ export const VoipRoom = proxify('IVoipRoomModel'); export const WebdavAccounts = proxify('IWebdavAccountsModel'); export const MatrixBridgedRoom = proxify('IMatrixBridgedRoomModel'); export const MatrixBridgedUser = proxify('IMatrixBridgedUserModel'); +export const MatrixBridgedMessage = proxify('IMatrixBridgedMessageModel'); export const CalendarEvent = proxify('ICalendarEventModel'); export const OmnichannelServiceLevelAgreements = proxify( 'IOmnichannelServiceLevelAgreementsModel', @@ -258,6 +261,7 @@ export function registerServiceModels(db: Db, trash?: Collection new LivechatVisitorsRaw(db)); registerModel('IMatrixBridgedUserModel', () => new MatrixBridgedUserRaw(db)); registerModel('IMatrixBridgedRoomModel', () => new MatrixBridgedRoomRaw(db)); + registerModel('IMatrixBridgedMessageModel', () => new MatrixBridgedMessageRaw(db)); } if (!dbWatchersDisabled) { diff --git a/packages/models/src/modelClasses.ts b/packages/models/src/modelClasses.ts index 148ab141b53e8..ce432246fe9ea 100644 --- a/packages/models/src/modelClasses.ts +++ b/packages/models/src/modelClasses.ts @@ -69,6 +69,7 @@ export * from './models/VoipRoom'; export * from './models/WebdavAccounts'; export * from './models/MatrixBridgedRoom'; export * from './models/MatrixBridgedUser'; +export * from './models/MatrixBridgedMessage'; export * from './models/CredentialTokens'; export * from './models/MessageReads'; export * from './models/CronHistoryModel'; diff --git a/packages/models/src/models/MatrixBridgedMessage.ts b/packages/models/src/models/MatrixBridgedMessage.ts new file mode 100644 index 0000000000000..cc67b648be5db --- /dev/null +++ b/packages/models/src/models/MatrixBridgedMessage.ts @@ -0,0 +1,36 @@ +import type { IMatrixBridgedMessage, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { IMatrixBridgedMessageModel } from '@rocket.chat/model-typings'; +import type { Collection, Db, IndexDescription } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class MatrixBridgedMessageRaw extends BaseRaw implements IMatrixBridgedMessageModel { + constructor(db: Db, trash?: Collection>) { + super(db, 'matrix_bridged_messages', trash); + } + + protected modelIndexes(): IndexDescription[] { + return [ + { key: { mid: 1 }, unique: true, sparse: true }, + { key: { meid: 1 }, unique: true, sparse: true }, + ]; + } + + async getExternalEventId(localMessageId: string): Promise { + const bridgedMessage = await this.findOne({ mid: localMessageId }); + return bridgedMessage ? bridgedMessage.meid : null; + } + + async getLocalMessageId(externalEventId: string): Promise { + const bridgedMessage = await this.findOne({ meid: externalEventId }); + return bridgedMessage ? bridgedMessage.mid : null; + } + + async createOrUpdate(localMessageId: string, externalEventId: string): Promise { + await this.updateOne({ mid: localMessageId }, { $set: { mid: localMessageId, meid: externalEventId } }, { upsert: true }); + } + + async removeByLocalMessageId(localMessageId: string): Promise { + await this.deleteOne({ mid: localMessageId }); + } +} From 0bd37704089ceb71b5234cb8592efd87b78b208c Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 15 Jul 2025 08:45:17 -0300 Subject: [PATCH 02/18] feat: adds set and unset reactions capabilities --- ee/apps/federation-service/src/service.ts | 3 + .../federation-matrix/src/FederationMatrix.ts | 130 ++++++++++++++++-- .../federation-matrix/src/events/index.ts | 2 + .../federation-matrix/src/events/reaction.ts | 104 ++++++++++++++ .../federation-matrix/src/hooks/index.ts | 13 ++ .../federation-matrix/src/hooks/reaction.ts | 111 +++++++++++++++ .../federation-matrix/src/types/ICallbacks.ts | 18 +++ .../src/utils/emojiConverter.ts | 45 ++++++ .../src/types/IFederationMatrixService.ts | 2 + 9 files changed, 417 insertions(+), 11 deletions(-) create mode 100644 ee/packages/federation-matrix/src/events/reaction.ts create mode 100644 ee/packages/federation-matrix/src/hooks/index.ts create mode 100644 ee/packages/federation-matrix/src/hooks/reaction.ts create mode 100644 ee/packages/federation-matrix/src/types/ICallbacks.ts create mode 100644 ee/packages/federation-matrix/src/utils/emojiConverter.ts diff --git a/ee/apps/federation-service/src/service.ts b/ee/apps/federation-service/src/service.ts index 1c4ee6a9e0f0f..ac707379fe77c 100644 --- a/ee/apps/federation-service/src/service.ts +++ b/ee/apps/federation-service/src/service.ts @@ -49,6 +49,9 @@ function handleHealthCheck(app: Hono) { } const { FederationMatrix } = await import('@rocket.chat/federation-matrix'); + + // TODO: In microservice mode, callbacks are not available as they're part of the Meteor app + // Reaction hooks will only work in monolith mode const federationMatrix = await FederationMatrix.create(); api.registerService(federationMatrix); diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index a4b35262f5e27..61c2c0fad07f9 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -8,7 +8,7 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Router } from '@rocket.chat/http-router'; import { Logger } from '@rocket.chat/logger'; -import { MatrixBridgedUser, MatrixBridgedRoom, Users } from '@rocket.chat/models'; +import { MatrixBridgedUser, MatrixBridgedRoom, MatrixBridgedMessage, Users, Messages } from '@rocket.chat/models'; import { getWellKnownRoutes } from './api/.well-known/server'; import { getMatrixInviteRoutes } from './api/_matrix/invite'; @@ -19,6 +19,9 @@ import { getMatrixSendJoinRoutes } from './api/_matrix/send-join'; import { getMatrixTransactionsRoutes } from './api/_matrix/transactions'; import { getFederationVersionsRoutes } from './api/_matrix/versions'; import { registerEvents } from './events'; +import { registerHooks, removeAllHooks } from './hooks'; +import type { ICallbacks } from './types/ICallbacks'; +import { convertEmojiToUnicode } from './utils/emojiConverter'; export class FederationMatrix extends ServiceClass implements IFederationMatrixService { protected name = 'federation-matrix'; @@ -33,12 +36,14 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS private httpRoutes: { matrix: Router<'/_matrix'>; wellKnown: Router<'/.well-known'> }; + private callbacks?: ICallbacks; + private constructor(emitter?: Emitter) { super(); this.eventHandler = emitter || new Emitter(); } - static async create(emitter?: Emitter): Promise { + static async create(emitter?: Emitter, callbacks?: ICallbacks): Promise { const instance = new FederationMatrix(emitter); const config = new ConfigService({ database: { @@ -104,12 +109,17 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS async created(): Promise { try { registerEvents(this.eventHandler); + registerHooks(this, this.callbacks); } catch (error) { this.logger.warn('Homeserver module not available, running in limited mode'); } } - async getMatrixDomain(): Promise { + async stopped(): Promise { + removeAllHooks(this.callbacks); + } + + public async getMatrixDomain(): Promise { if (this.matrixDomain) { return this.matrixDomain; } @@ -131,7 +141,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS this.logger.warn('Homeserver services not available, skipping room creation'); return; } - + if (!(room.t === 'c' || room.t === 'p')) { throw new Error('Room is not a public or private room'); } @@ -142,11 +152,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const roomName = room.name || room.fname || 'Untitled Room'; // canonical alias computed from name - const matrixRoomResult = await this.homeserverServices.room.createRoom( - matrixUserId, - roomName, - room.t === 'c' ? 'public' : 'invite', - ); + const matrixRoomResult = await this.homeserverServices.room.createRoom(matrixUserId, roomName, room.t === 'c' ? 'public' : 'invite'); this.logger.debug('Matrix room created:', matrixRoomResult); @@ -212,8 +218,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const result = await this.homeserverServices.message.sendMessage(matrixRoomId, message.msg, matrixUserId, targetServer); - // TODO: Store the event ID mapping for future reference (edits, deletions, etc.) - // This would allow us to map between Rocket.Chat message IDs and Matrix event IDs + await MatrixBridgedMessage.createOrUpdate(message._id, result.event_id); this.logger.debug('Message sent to Matrix successfully:', result.event_id); } catch (error) { @@ -221,4 +226,107 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS throw error; } } + + async sendReaction(messageId: string, reaction: string, user: IUser): Promise { + try { + const message = await Messages.findOneById(messageId); + if (!message) { + throw new Error(`Message ${messageId} not found`); + } + + const matrixRoomId = await MatrixBridgedRoom.getExternalRoomId(message.rid); + if (!matrixRoomId) { + throw new Error(`No Matrix room mapping found for room ${message.rid}`); + } + + const matrixEventId = await MatrixBridgedMessage.getExternalEventId(messageId); + if (!matrixEventId) { + throw new Error(`No Matrix event ID mapping found for message ${messageId}`); + } + + const matrixDomain = await this.getMatrixDomain(); + const matrixUserId = `@${user.username}:${matrixDomain}`; + const existingMatrixUserId = await MatrixBridgedUser.getExternalUserIdByLocalUserId(user._id); + if (!existingMatrixUserId) { + await MatrixBridgedUser.createOrUpdateByLocalId(user._id, matrixUserId, true, matrixDomain); + } + + if (!this.homeserverServices) { + this.logger.warn('Homeserver services not available, skipping reaction send'); + return; + } + + const reactionKey = convertEmojiToUnicode(reaction); + + // TODO: Fix hardcoded server + const targetServer = 'hs1-garim.tunnel.dev.rocket.chat'; + const result = await this.homeserverServices.message.sendReaction( + matrixRoomId, + matrixEventId, + reactionKey, + matrixUserId, + targetServer, + ); + + const reactionMappingKey = `${messageId}_reaction_${reaction}`; + await MatrixBridgedMessage.createOrUpdate(reactionMappingKey, result.event_id); + + this.logger.debug('Reaction sent to Matrix successfully:', result.event_id); + } catch (error) { + this.logger.error('Failed to send reaction to Matrix:', error); + throw error; + } + } + + async removeReaction(messageId: string, reaction: string, user: IUser): Promise { + try { + const message = await Messages.findOneById(messageId); + if (!message) { + throw new Error(`Message ${messageId} not found`); + } + + const matrixRoomId = await MatrixBridgedRoom.getExternalRoomId(message.rid); + if (!matrixRoomId) { + throw new Error(`No Matrix room mapping found for room ${message.rid}`); + } + + const matrixEventId = await MatrixBridgedMessage.getExternalEventId(messageId); + if (!matrixEventId) { + throw new Error(`No Matrix event ID mapping found for message ${messageId}`); + } + + const matrixDomain = await this.getMatrixDomain(); + const matrixUserId = `@${user.username}:${matrixDomain}`; + + if (!this.homeserverServices) { + this.logger.warn('Homeserver services not available, skipping reaction removal'); + return; + } + + // TODO: Fix hardcoded server + const targetServer = 'hs1-garim.tunnel.dev.rocket.chat'; + + const reactionMappingKey = `${messageId}_reaction_${reaction}`; + const reactionEventId = await MatrixBridgedMessage.getExternalEventId(reactionMappingKey); + if (!reactionEventId) { + this.logger.warn(`No reaction event ID found for ${reactionMappingKey}`); + return; + } + + const result = await this.homeserverServices.message.redactMessage( + matrixRoomId, + reactionEventId, + undefined, + matrixUserId, + targetServer, + ); + + await MatrixBridgedMessage.removeByLocalMessageId(reactionMappingKey); + + this.logger.debug('Reaction removed from Matrix successfully:', result.event_id); + } catch (error) { + this.logger.error('Failed to remove reaction from Matrix:', error); + throw error; + } + } } diff --git a/ee/packages/federation-matrix/src/events/index.ts b/ee/packages/federation-matrix/src/events/index.ts index c0c2747f3baf5..f65b8c7053bd9 100644 --- a/ee/packages/federation-matrix/src/events/index.ts +++ b/ee/packages/federation-matrix/src/events/index.ts @@ -4,9 +4,11 @@ import type { Emitter } from '@rocket.chat/emitter'; import { invite } from './invite'; import { message } from './message'; import { ping } from './ping'; +import { reaction } from './reaction'; export function registerEvents(emitter: Emitter) { ping(emitter); message(emitter); invite(emitter); + reaction(emitter); } diff --git a/ee/packages/federation-matrix/src/events/reaction.ts b/ee/packages/federation-matrix/src/events/reaction.ts new file mode 100644 index 0000000000000..f394def334abd --- /dev/null +++ b/ee/packages/federation-matrix/src/events/reaction.ts @@ -0,0 +1,104 @@ +import type { HomeserverEventSignatures } from '@hs/federation-sdk'; +import { Message } from '@rocket.chat/core-services'; +import type { Emitter } from '@rocket.chat/emitter'; +import { Logger } from '@rocket.chat/logger'; +import { Users, MatrixBridgedMessage, Messages } from '@rocket.chat/models'; + +import { convertUnicodeToEmoji } from '../utils/emojiConverter'; + +const logger = new Logger('federation-matrix:reaction'); + +export function reaction(emitter: Emitter) { + emitter.on('homeserver.matrix.reaction', async (data) => { + try { + logger.info('Received Matrix reaction event:', { + event_id: data.event_id, + room_id: data.room_id, + sender: data.sender, + relates_to: data.content?.['m.relates_to'], + }); + + const relatesTo = data.content?.['m.relates_to']; + if (!relatesTo || relatesTo.rel_type !== 'm.annotation') { + logger.debug('Invalid reaction event structure'); + return; + } + + const targetEventId = relatesTo.event_id; + const reactionKey = relatesTo.key; + + if (!targetEventId || !reactionKey) { + logger.debug('Missing target event ID or reaction key'); + return; + } + + const rcMessageId = await MatrixBridgedMessage.getLocalMessageId(targetEventId); + if (!rcMessageId) { + logger.debug(`No RC message mapping found for Matrix event ${targetEventId}`); + return; + } + + const message = await Messages.findOneById(rcMessageId); + if (!message) { + logger.debug(`RC message ${rcMessageId} not found`); + return; + } + + const [userPart, domain] = data.sender.split(':'); + if (!userPart || !domain) { + logger.error('Invalid Matrix sender ID format:', data.sender); + return; + } + + const username = userPart.substring(1); + const user = await Users.findOneByUsername(username); + if (!user) { + return; + } + + const reactionEmoji = convertUnicodeToEmoji(reactionKey); + + await Message.reactToMessage(rcMessageId, reactionEmoji, user._id); + + const reactionMappingKey = `${rcMessageId}_reaction_${reactionEmoji}`; + await MatrixBridgedMessage.createOrUpdate(reactionMappingKey, data.event_id); + + logger.debug('Matrix reaction processed successfully'); + } catch (error) { + logger.error('Failed to process Matrix reaction:', error); + } + }); + + emitter.on('homeserver.matrix.redaction', async (data) => { + try { + const redactedEventId = data.redacts; + if (!redactedEventId) { + return; + } + + const reactionMappingKey = await MatrixBridgedMessage.getLocalMessageId(redactedEventId); + if (!reactionMappingKey || !reactionMappingKey.includes('_reaction_')) { + return; + } + + const [messageId, , reaction] = reactionMappingKey.split('_'); + + const [userPart] = data.sender.split(':'); + const username = userPart.substring(1); + const user = await Users.findOneByUsername(username); + + if (!user) { + logger.debug('User not found for reaction redaction'); + return; + } + + await Message.reactToMessage(user._id, reaction, messageId, false); + + await MatrixBridgedMessage.removeByLocalMessageId(reactionMappingKey); + + logger.debug('Matrix reaction redaction processed successfully'); + } catch (error) { + logger.error('Failed to process Matrix reaction redaction:', error); + } + }); +} diff --git a/ee/packages/federation-matrix/src/hooks/index.ts b/ee/packages/federation-matrix/src/hooks/index.ts new file mode 100644 index 0000000000000..6e77c1e6f65e9 --- /dev/null +++ b/ee/packages/federation-matrix/src/hooks/index.ts @@ -0,0 +1,13 @@ +import type { FederationMatrix } from '../FederationMatrix'; +import { reaction, removeReactionListeners } from './reaction'; +import type { ICallbacks } from '../types/ICallbacks'; + +export function registerHooks(federationMatrixService: FederationMatrix, callbacks?: ICallbacks) { + if (callbacks) { + reaction(federationMatrixService, callbacks); + } +} + +export function removeAllHooks(callbacks?: ICallbacks) { + removeReactionListeners(callbacks); +} diff --git a/ee/packages/federation-matrix/src/hooks/reaction.ts b/ee/packages/federation-matrix/src/hooks/reaction.ts new file mode 100644 index 0000000000000..69c08e0743b88 --- /dev/null +++ b/ee/packages/federation-matrix/src/hooks/reaction.ts @@ -0,0 +1,111 @@ +import { Settings } from '@rocket.chat/core-services'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import { isMessageFromMatrixFederation } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { Rooms } from '@rocket.chat/models'; + +import type { FederationMatrix } from '../FederationMatrix'; +import type { ICallbacks } from '../types/ICallbacks'; + +const logger = new Logger('federation-matrix:reaction'); + +let registeredCallbacks: Array<{ hook: string; id: string }> = []; + +export function reaction(federationMatrixService: FederationMatrix, callbacks: ICallbacks) { + callbacks.add( + 'afterSetReaction', + async (message: IMessage, params: { user: IUser; reaction: string }): Promise => { + await handleReactionAdded(federationMatrixService, message, params.user, params.reaction); + }, + callbacks.priority.HIGH, + 'federation-matrix-after-set-reaction', + ); + registeredCallbacks.push({ hook: 'afterSetReaction', id: 'federation-matrix-after-set-reaction' }); + + callbacks.add( + 'afterUnsetReaction', + async (_message: IMessage, params: { user: IUser; reaction: string; oldMessage: IMessage }): Promise => { + await handleReactionRemoved(federationMatrixService, params.oldMessage, params.user, params.reaction); + }, + callbacks.priority.HIGH, + 'federation-matrix-after-unset-reaction', + ); + registeredCallbacks.push({ hook: 'afterUnsetReaction', id: 'federation-matrix-after-unset-reaction' }); +} + +export function removeReactionListeners(callbacks?: ICallbacks): void { + if (!callbacks) { + return; + } + + for (const { hook, id } of registeredCallbacks) { + callbacks.remove(hook, id); + } + registeredCallbacks = []; +} + +async function handleReactionAdded( + federationMatrixService: FederationMatrix, + message: IMessage, + user: IUser, + reactionString: string, +): Promise { + try { + if (!(await shouldHandleReaction(federationMatrixService, message, user))) { + return; + } + + await federationMatrixService.sendReaction(message._id, reactionString, user); + } catch (error) { + logger.error('Failed to handle reaction added:', error); + } +} + +async function handleReactionRemoved( + federationMatrixService: FederationMatrix, + message: IMessage, + user: IUser, + reactionString: string, +): Promise { + try { + if (!(await shouldHandleReaction(federationMatrixService, message, user))) { + return; + } + + await federationMatrixService.removeReaction(message._id, reactionString, user); + } catch (error) { + logger.error('Failed to handle reaction removed:', error); + } +} + +async function shouldHandleReaction(federationMatrixService: FederationMatrix, message: IMessage, user: IUser): Promise { + try { + const room = await Rooms.findOneById(message.rid); + if (!room?.federated) { + return false; + } + + if (!isMessageFromMatrixFederation(message)) { + return false; + } + + if (user.federated) { + return false; + } + + const matrixDomain = await federationMatrixService.getMatrixDomain(); + if (user.username?.includes(':') && !user.username.endsWith(`:${matrixDomain}`)) { + return false; + } + + const federationEnabled = await Settings.get('Federation_Matrix_enabled'); + if (!federationEnabled) { + return false; + } + + return true; + } catch (error) { + logger.error('Error in shouldHandleReaction:', error); + return false; + } +} diff --git a/ee/packages/federation-matrix/src/types/ICallbacks.ts b/ee/packages/federation-matrix/src/types/ICallbacks.ts new file mode 100644 index 0000000000000..687950c0b25f1 --- /dev/null +++ b/ee/packages/federation-matrix/src/types/ICallbacks.ts @@ -0,0 +1,18 @@ +import type { IMessage, IUser } from '@rocket.chat/core-typings'; + +export interface ICallbackPriority { + HIGH: number; + MEDIUM: number; + LOW: number; +} + +export interface ICallbacks { + priority: ICallbackPriority; + add(hook: string, callback: (...args: any[]) => any, priority?: number, id?: string): void; + remove(hook: string, id: string): void; +} + +export interface IFederationCallbackHandlers { + afterSetReaction?: (message: IMessage, params: { user: IUser; reaction: string }) => Promise; + afterUnsetReaction?: (message: IMessage, params: { user: IUser; reaction: string; oldMessage: IMessage }) => Promise; +} diff --git a/ee/packages/federation-matrix/src/utils/emojiConverter.ts b/ee/packages/federation-matrix/src/utils/emojiConverter.ts new file mode 100644 index 0000000000000..f73483380343e --- /dev/null +++ b/ee/packages/federation-matrix/src/utils/emojiConverter.ts @@ -0,0 +1,45 @@ +const EMOJI_MAP: Record = { + ':thumbsup:': '👍', + ':thumbsdown:': '👎', + ':heart:': '❤️', + ':smile:': '😊', + ':laughing:': '😂', + ':cry:': '😢', + ':angry:': '😠', + ':star:': '⭐', + ':fire:': '🔥', + ':clap:': '👏', + ':ok_hand:': '👌', + ':wave:': '👋', + ':+1:': '👍', + ':-1:': '👎', + ':100:': '💯', + ':rocket:': '🚀', + ':eyes:': '👀', + ':thinking:': '🤔', + ':party:': '🎉', + ':tada:': '🎉', +}; + +export function convertEmojiToUnicode(reaction: string): string { + if (!reaction.startsWith(':') || !reaction.endsWith(':')) { + return reaction; + } + + const unicode = EMOJI_MAP[reaction]; + if (unicode) { + return unicode; + } + + return reaction.slice(1, -1); +} + +export function convertUnicodeToEmoji(unicode: string): string { + for (const [shortcode, emoji] of Object.entries(EMOJI_MAP)) { + if (emoji === unicode) { + return shortcode; + } + } + + return unicode; +} diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index 02bd33f95eebf..8817cf45fdfec 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -17,4 +17,6 @@ export interface IFederationMatrixService { }; createRoom(room: IRoom, owner: IUser, members: string[]): Promise; sendMessage(message: IMessage, room: IRoom, user: IUser): Promise; + sendReaction(messageId: string, reaction: string, user: IUser): Promise; + removeReaction(messageId: string, reaction: string, user: IUser): Promise; } From cff527be2ee6d2e200943a8865aa9304deb803bc Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 15 Jul 2025 21:39:20 -0300 Subject: [PATCH 03/18] chore: move hooks to ee folder --- .../ee/server/hooks/federation/index.ts | 23 ++++++++++++++++--- ee/apps/federation-service/src/service.ts | 2 -- .../federation-matrix/src/FederationMatrix.ts | 11 +-------- .../federation-matrix/src/hooks/index.ts | 13 ----------- .../federation-matrix/src/hooks/reaction.ts | 13 ----------- 5 files changed, 21 insertions(+), 41 deletions(-) delete mode 100644 ee/packages/federation-matrix/src/hooks/index.ts diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index c824106abe75f..c8213357c48af 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -1,5 +1,22 @@ -// import { FederationMatrix } from '@rocket.chat/core-services'; +import { FederationMatrix } from '@rocket.chat/core-services'; -// import { callbacks } from '../../../../lib/callbacks'; +import { callbacks } from '../../../../lib/callbacks'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; -// callbacks.add('federation-event-example', async () => FederationMatrix.handleExample(), callbacks.priority.MEDIUM, 'federation-event-example-handler'); +callbacks.add( + 'afterSetReaction', + async (message: IMessage, params: { user: IUser; reaction: string }): Promise => { + await FederationMatrix.sendReaction(message._id, params.reaction, params.user); + }, + callbacks.priority.HIGH, + 'federation-matrix-after-set-reaction', +); + +callbacks.add( + 'afterUnsetReaction', + async (_message: IMessage, params: { user: IUser; reaction: string; oldMessage: IMessage }): Promise => { + await FederationMatrix.removeReaction(params.oldMessage._id, params.reaction, params.user); + }, + callbacks.priority.HIGH, + 'federation-matrix-after-unset-reaction', +); diff --git a/ee/apps/federation-service/src/service.ts b/ee/apps/federation-service/src/service.ts index ac707379fe77c..d5dead427e4e2 100644 --- a/ee/apps/federation-service/src/service.ts +++ b/ee/apps/federation-service/src/service.ts @@ -50,8 +50,6 @@ function handleHealthCheck(app: Hono) { const { FederationMatrix } = await import('@rocket.chat/federation-matrix'); - // TODO: In microservice mode, callbacks are not available as they're part of the Meteor app - // Reaction hooks will only work in monolith mode const federationMatrix = await FederationMatrix.create(); api.registerService(federationMatrix); diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 61c2c0fad07f9..f9913693ab611 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -19,8 +19,6 @@ import { getMatrixSendJoinRoutes } from './api/_matrix/send-join'; import { getMatrixTransactionsRoutes } from './api/_matrix/transactions'; import { getFederationVersionsRoutes } from './api/_matrix/versions'; import { registerEvents } from './events'; -import { registerHooks, removeAllHooks } from './hooks'; -import type { ICallbacks } from './types/ICallbacks'; import { convertEmojiToUnicode } from './utils/emojiConverter'; export class FederationMatrix extends ServiceClass implements IFederationMatrixService { @@ -36,14 +34,12 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS private httpRoutes: { matrix: Router<'/_matrix'>; wellKnown: Router<'/.well-known'> }; - private callbacks?: ICallbacks; - private constructor(emitter?: Emitter) { super(); this.eventHandler = emitter || new Emitter(); } - static async create(emitter?: Emitter, callbacks?: ICallbacks): Promise { + static async create(emitter?: Emitter): Promise { const instance = new FederationMatrix(emitter); const config = new ConfigService({ database: { @@ -109,16 +105,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS async created(): Promise { try { registerEvents(this.eventHandler); - registerHooks(this, this.callbacks); } catch (error) { this.logger.warn('Homeserver module not available, running in limited mode'); } } - async stopped(): Promise { - removeAllHooks(this.callbacks); - } - public async getMatrixDomain(): Promise { if (this.matrixDomain) { return this.matrixDomain; diff --git a/ee/packages/federation-matrix/src/hooks/index.ts b/ee/packages/federation-matrix/src/hooks/index.ts deleted file mode 100644 index 6e77c1e6f65e9..0000000000000 --- a/ee/packages/federation-matrix/src/hooks/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { FederationMatrix } from '../FederationMatrix'; -import { reaction, removeReactionListeners } from './reaction'; -import type { ICallbacks } from '../types/ICallbacks'; - -export function registerHooks(federationMatrixService: FederationMatrix, callbacks?: ICallbacks) { - if (callbacks) { - reaction(federationMatrixService, callbacks); - } -} - -export function removeAllHooks(callbacks?: ICallbacks) { - removeReactionListeners(callbacks); -} diff --git a/ee/packages/federation-matrix/src/hooks/reaction.ts b/ee/packages/federation-matrix/src/hooks/reaction.ts index 69c08e0743b88..b051c11655aba 100644 --- a/ee/packages/federation-matrix/src/hooks/reaction.ts +++ b/ee/packages/federation-matrix/src/hooks/reaction.ts @@ -20,7 +20,6 @@ export function reaction(federationMatrixService: FederationMatrix, callbacks: I callbacks.priority.HIGH, 'federation-matrix-after-set-reaction', ); - registeredCallbacks.push({ hook: 'afterSetReaction', id: 'federation-matrix-after-set-reaction' }); callbacks.add( 'afterUnsetReaction', @@ -30,18 +29,6 @@ export function reaction(federationMatrixService: FederationMatrix, callbacks: I callbacks.priority.HIGH, 'federation-matrix-after-unset-reaction', ); - registeredCallbacks.push({ hook: 'afterUnsetReaction', id: 'federation-matrix-after-unset-reaction' }); -} - -export function removeReactionListeners(callbacks?: ICallbacks): void { - if (!callbacks) { - return; - } - - for (const { hook, id } of registeredCallbacks) { - callbacks.remove(hook, id); - } - registeredCallbacks = []; } async function handleReactionAdded( From 160132ef246e5d80d9dd16ee96d7b95f6975f59c Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 15 Jul 2025 21:43:58 -0300 Subject: [PATCH 04/18] chore: reset files from feat/federation --- ee/apps/federation-service/src/service.ts | 3 +-- ee/packages/federation-matrix/src/FederationMatrix.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/ee/apps/federation-service/src/service.ts b/ee/apps/federation-service/src/service.ts index d5dead427e4e2..702b9f53a10d4 100644 --- a/ee/apps/federation-service/src/service.ts +++ b/ee/apps/federation-service/src/service.ts @@ -49,7 +49,6 @@ function handleHealthCheck(app: Hono) { } const { FederationMatrix } = await import('@rocket.chat/federation-matrix'); - const federationMatrix = await FederationMatrix.create(); api.registerService(federationMatrix); @@ -58,7 +57,7 @@ function handleHealthCheck(app: Hono) { app.mount('/_matrix', matrix.getHonoRouter().fetch); app.mount('/.well-known', wellKnown.getHonoRouter().fetch); - + handleHealthCheck(app); serve({ diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index f9913693ab611..673ff75a2deb7 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -110,7 +110,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } - public async getMatrixDomain(): Promise { + async getMatrixDomain(): Promise { if (this.matrixDomain) { return this.matrixDomain; } From 6d40cc6548522c0dab81d5a128fe9349515c45b2 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 15 Jul 2025 22:06:35 -0300 Subject: [PATCH 05/18] chore: adds emojione lib --- ee/packages/federation-matrix/package.json | 2 + .../federation-matrix/src/FederationMatrix.ts | 4 +- .../federation-matrix/src/events/reaction.ts | 7 ++- .../federation-matrix/src/hooks/reaction.ts | 2 - .../src/utils/emojiConverter.ts | 45 ------------------- yarn.lock | 2 + 6 files changed, 9 insertions(+), 53 deletions(-) delete mode 100644 ee/packages/federation-matrix/src/utils/emojiConverter.ts diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index 89327fe46a086..e99bc461b7316 100644 --- a/ee/packages/federation-matrix/package.json +++ b/ee/packages/federation-matrix/package.json @@ -8,6 +8,7 @@ "@babel/preset-env": "~7.26.0", "@babel/preset-typescript": "~7.26.0", "@rocket.chat/eslint-config": "workspace:^", + "@types/emojione": "^2.2.9", "@types/node": "~22.14.0", "babel-jest": "~30.0.0", "eslint": "~8.45.0", @@ -40,6 +41,7 @@ "@rocket.chat/models": "workspace:^", "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", + "emojione": "^4.5.0", "mongodb": "6.10.0", "pino": "8.21.0", "reflect-metadata": "^0.2.2" diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 673ff75a2deb7..45cd989599200 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -9,6 +9,7 @@ import { Emitter } from '@rocket.chat/emitter'; import { Router } from '@rocket.chat/http-router'; import { Logger } from '@rocket.chat/logger'; import { MatrixBridgedUser, MatrixBridgedRoom, MatrixBridgedMessage, Users, Messages } from '@rocket.chat/models'; +import emojione from 'emojione'; import { getWellKnownRoutes } from './api/.well-known/server'; import { getMatrixInviteRoutes } from './api/_matrix/invite'; @@ -19,7 +20,6 @@ import { getMatrixSendJoinRoutes } from './api/_matrix/send-join'; import { getMatrixTransactionsRoutes } from './api/_matrix/transactions'; import { getFederationVersionsRoutes } from './api/_matrix/versions'; import { registerEvents } from './events'; -import { convertEmojiToUnicode } from './utils/emojiConverter'; export class FederationMatrix extends ServiceClass implements IFederationMatrixService { protected name = 'federation-matrix'; @@ -247,7 +247,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return; } - const reactionKey = convertEmojiToUnicode(reaction); + const reactionKey = emojione.shortnameToUnicode(reaction); // TODO: Fix hardcoded server const targetServer = 'hs1-garim.tunnel.dev.rocket.chat'; diff --git a/ee/packages/federation-matrix/src/events/reaction.ts b/ee/packages/federation-matrix/src/events/reaction.ts index f394def334abd..62ab4c490c4e3 100644 --- a/ee/packages/federation-matrix/src/events/reaction.ts +++ b/ee/packages/federation-matrix/src/events/reaction.ts @@ -3,8 +3,7 @@ import { Message } from '@rocket.chat/core-services'; import type { Emitter } from '@rocket.chat/emitter'; import { Logger } from '@rocket.chat/logger'; import { Users, MatrixBridgedMessage, Messages } from '@rocket.chat/models'; - -import { convertUnicodeToEmoji } from '../utils/emojiConverter'; +import emojione from 'emojione'; const logger = new Logger('federation-matrix:reaction'); @@ -56,7 +55,7 @@ export function reaction(emitter: Emitter) { return; } - const reactionEmoji = convertUnicodeToEmoji(reactionKey); + const reactionEmoji = emojione.toShort(reactionKey); await Message.reactToMessage(rcMessageId, reactionEmoji, user._id); @@ -77,7 +76,7 @@ export function reaction(emitter: Emitter) { } const reactionMappingKey = await MatrixBridgedMessage.getLocalMessageId(redactedEventId); - if (!reactionMappingKey || !reactionMappingKey.includes('_reaction_')) { + if (!reactionMappingKey?.includes('_reaction_')) { return; } diff --git a/ee/packages/federation-matrix/src/hooks/reaction.ts b/ee/packages/federation-matrix/src/hooks/reaction.ts index b051c11655aba..a10c8d51fef6e 100644 --- a/ee/packages/federation-matrix/src/hooks/reaction.ts +++ b/ee/packages/federation-matrix/src/hooks/reaction.ts @@ -9,8 +9,6 @@ import type { ICallbacks } from '../types/ICallbacks'; const logger = new Logger('federation-matrix:reaction'); -let registeredCallbacks: Array<{ hook: string; id: string }> = []; - export function reaction(federationMatrixService: FederationMatrix, callbacks: ICallbacks) { callbacks.add( 'afterSetReaction', diff --git a/ee/packages/federation-matrix/src/utils/emojiConverter.ts b/ee/packages/federation-matrix/src/utils/emojiConverter.ts deleted file mode 100644 index f73483380343e..0000000000000 --- a/ee/packages/federation-matrix/src/utils/emojiConverter.ts +++ /dev/null @@ -1,45 +0,0 @@ -const EMOJI_MAP: Record = { - ':thumbsup:': '👍', - ':thumbsdown:': '👎', - ':heart:': '❤️', - ':smile:': '😊', - ':laughing:': '😂', - ':cry:': '😢', - ':angry:': '😠', - ':star:': '⭐', - ':fire:': '🔥', - ':clap:': '👏', - ':ok_hand:': '👌', - ':wave:': '👋', - ':+1:': '👍', - ':-1:': '👎', - ':100:': '💯', - ':rocket:': '🚀', - ':eyes:': '👀', - ':thinking:': '🤔', - ':party:': '🎉', - ':tada:': '🎉', -}; - -export function convertEmojiToUnicode(reaction: string): string { - if (!reaction.startsWith(':') || !reaction.endsWith(':')) { - return reaction; - } - - const unicode = EMOJI_MAP[reaction]; - if (unicode) { - return unicode; - } - - return reaction.slice(1, -1); -} - -export function convertUnicodeToEmoji(unicode: string): string { - for (const [shortcode, emoji] of Object.entries(EMOJI_MAP)) { - if (emoji === unicode) { - return shortcode; - } - } - - return unicode; -} diff --git a/yarn.lock b/yarn.lock index 5597f3e60829e..f3afba793ccf8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9062,8 +9062,10 @@ __metadata: "@rocket.chat/models": "workspace:^" "@rocket.chat/network-broker": "workspace:^" "@rocket.chat/rest-typings": "workspace:^" + "@types/emojione": "npm:^2.2.9" "@types/node": "npm:~22.14.0" babel-jest: "npm:~30.0.0" + emojione: "npm:^4.5.0" eslint: "npm:~8.45.0" jest: "npm:~30.0.0" mongodb: "npm:6.10.0" From e8e93bb84fb30568051065212a8875f4a9c0e977 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 15 Jul 2025 08:45:17 -0300 Subject: [PATCH 06/18] feat: adds set and unset reactions capabilities --- ee/apps/federation-service/src/service.ts | 3 ++ .../federation-matrix/src/FederationMatrix.ts | 14 +++++- .../federation-matrix/src/hooks/index.ts | 13 ++++++ .../federation-matrix/src/hooks/reaction.ts | 24 ++++++++++ .../src/utils/emojiConverter.ts | 45 +++++++++++++++++++ 5 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 ee/packages/federation-matrix/src/hooks/index.ts create mode 100644 ee/packages/federation-matrix/src/utils/emojiConverter.ts diff --git a/ee/apps/federation-service/src/service.ts b/ee/apps/federation-service/src/service.ts index 702b9f53a10d4..3c6b94a43cf9e 100644 --- a/ee/apps/federation-service/src/service.ts +++ b/ee/apps/federation-service/src/service.ts @@ -49,6 +49,9 @@ function handleHealthCheck(app: Hono) { } const { FederationMatrix } = await import('@rocket.chat/federation-matrix'); + + // TODO: In microservice mode, callbacks are not available as they're part of the Meteor app + // Reaction hooks will only work in monolith mode const federationMatrix = await FederationMatrix.create(); api.registerService(federationMatrix); diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 45cd989599200..17891d3f99c93 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -20,6 +20,9 @@ import { getMatrixSendJoinRoutes } from './api/_matrix/send-join'; import { getMatrixTransactionsRoutes } from './api/_matrix/transactions'; import { getFederationVersionsRoutes } from './api/_matrix/versions'; import { registerEvents } from './events'; +import { registerHooks, removeAllHooks } from './hooks'; +import type { ICallbacks } from './types/ICallbacks'; +import { convertEmojiToUnicode } from './utils/emojiConverter'; export class FederationMatrix extends ServiceClass implements IFederationMatrixService { protected name = 'federation-matrix'; @@ -34,12 +37,14 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS private httpRoutes: { matrix: Router<'/_matrix'>; wellKnown: Router<'/.well-known'> }; + private callbacks?: ICallbacks; + private constructor(emitter?: Emitter) { super(); this.eventHandler = emitter || new Emitter(); } - static async create(emitter?: Emitter): Promise { + static async create(emitter?: Emitter, callbacks?: ICallbacks): Promise { const instance = new FederationMatrix(emitter); const config = new ConfigService({ database: { @@ -105,12 +110,17 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS async created(): Promise { try { registerEvents(this.eventHandler); + registerHooks(this, this.callbacks); } catch (error) { this.logger.warn('Homeserver module not available, running in limited mode'); } } - async getMatrixDomain(): Promise { + async stopped(): Promise { + removeAllHooks(this.callbacks); + } + + public async getMatrixDomain(): Promise { if (this.matrixDomain) { return this.matrixDomain; } diff --git a/ee/packages/federation-matrix/src/hooks/index.ts b/ee/packages/federation-matrix/src/hooks/index.ts new file mode 100644 index 0000000000000..6e77c1e6f65e9 --- /dev/null +++ b/ee/packages/federation-matrix/src/hooks/index.ts @@ -0,0 +1,13 @@ +import type { FederationMatrix } from '../FederationMatrix'; +import { reaction, removeReactionListeners } from './reaction'; +import type { ICallbacks } from '../types/ICallbacks'; + +export function registerHooks(federationMatrixService: FederationMatrix, callbacks?: ICallbacks) { + if (callbacks) { + reaction(federationMatrixService, callbacks); + } +} + +export function removeAllHooks(callbacks?: ICallbacks) { + removeReactionListeners(callbacks); +} diff --git a/ee/packages/federation-matrix/src/hooks/reaction.ts b/ee/packages/federation-matrix/src/hooks/reaction.ts index a10c8d51fef6e..67b0b729b4080 100644 --- a/ee/packages/federation-matrix/src/hooks/reaction.ts +++ b/ee/packages/federation-matrix/src/hooks/reaction.ts @@ -9,6 +9,11 @@ import type { ICallbacks } from '../types/ICallbacks'; const logger = new Logger('federation-matrix:reaction'); +<<<<<<< HEAD +======= +let registeredCallbacks: Array<{ hook: string; id: string }> = []; + +>>>>>>> f72828efff (feat: adds set and unset reactions capabilities) export function reaction(federationMatrixService: FederationMatrix, callbacks: ICallbacks) { callbacks.add( 'afterSetReaction', @@ -18,6 +23,10 @@ export function reaction(federationMatrixService: FederationMatrix, callbacks: I callbacks.priority.HIGH, 'federation-matrix-after-set-reaction', ); +<<<<<<< HEAD +======= + registeredCallbacks.push({ hook: 'afterSetReaction', id: 'federation-matrix-after-set-reaction' }); +>>>>>>> f72828efff (feat: adds set and unset reactions capabilities) callbacks.add( 'afterUnsetReaction', @@ -27,6 +36,21 @@ export function reaction(federationMatrixService: FederationMatrix, callbacks: I callbacks.priority.HIGH, 'federation-matrix-after-unset-reaction', ); +<<<<<<< HEAD +======= + registeredCallbacks.push({ hook: 'afterUnsetReaction', id: 'federation-matrix-after-unset-reaction' }); +} + +export function removeReactionListeners(callbacks?: ICallbacks): void { + if (!callbacks) { + return; + } + + for (const { hook, id } of registeredCallbacks) { + callbacks.remove(hook, id); + } + registeredCallbacks = []; +>>>>>>> f72828efff (feat: adds set and unset reactions capabilities) } async function handleReactionAdded( diff --git a/ee/packages/federation-matrix/src/utils/emojiConverter.ts b/ee/packages/federation-matrix/src/utils/emojiConverter.ts new file mode 100644 index 0000000000000..f73483380343e --- /dev/null +++ b/ee/packages/federation-matrix/src/utils/emojiConverter.ts @@ -0,0 +1,45 @@ +const EMOJI_MAP: Record = { + ':thumbsup:': '👍', + ':thumbsdown:': '👎', + ':heart:': '❤️', + ':smile:': '😊', + ':laughing:': '😂', + ':cry:': '😢', + ':angry:': '😠', + ':star:': '⭐', + ':fire:': '🔥', + ':clap:': '👏', + ':ok_hand:': '👌', + ':wave:': '👋', + ':+1:': '👍', + ':-1:': '👎', + ':100:': '💯', + ':rocket:': '🚀', + ':eyes:': '👀', + ':thinking:': '🤔', + ':party:': '🎉', + ':tada:': '🎉', +}; + +export function convertEmojiToUnicode(reaction: string): string { + if (!reaction.startsWith(':') || !reaction.endsWith(':')) { + return reaction; + } + + const unicode = EMOJI_MAP[reaction]; + if (unicode) { + return unicode; + } + + return reaction.slice(1, -1); +} + +export function convertUnicodeToEmoji(unicode: string): string { + for (const [shortcode, emoji] of Object.entries(EMOJI_MAP)) { + if (emoji === unicode) { + return shortcode; + } + } + + return unicode; +} From adc869dc3e213bb198bed2fd2de747b0c448579c Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 15 Jul 2025 21:39:20 -0300 Subject: [PATCH 07/18] chore: move hooks to ee folder --- ee/apps/federation-service/src/service.ts | 2 -- .../federation-matrix/src/FederationMatrix.ts | 11 +---------- ee/packages/federation-matrix/src/hooks/index.ts | 13 ------------- ee/packages/federation-matrix/src/hooks/reaction.ts | 11 ++++++----- 4 files changed, 7 insertions(+), 30 deletions(-) delete mode 100644 ee/packages/federation-matrix/src/hooks/index.ts diff --git a/ee/apps/federation-service/src/service.ts b/ee/apps/federation-service/src/service.ts index 3c6b94a43cf9e..091e6b9f893a4 100644 --- a/ee/apps/federation-service/src/service.ts +++ b/ee/apps/federation-service/src/service.ts @@ -50,8 +50,6 @@ function handleHealthCheck(app: Hono) { const { FederationMatrix } = await import('@rocket.chat/federation-matrix'); - // TODO: In microservice mode, callbacks are not available as they're part of the Meteor app - // Reaction hooks will only work in monolith mode const federationMatrix = await FederationMatrix.create(); api.registerService(federationMatrix); diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 17891d3f99c93..560469bb49a24 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -20,8 +20,6 @@ import { getMatrixSendJoinRoutes } from './api/_matrix/send-join'; import { getMatrixTransactionsRoutes } from './api/_matrix/transactions'; import { getFederationVersionsRoutes } from './api/_matrix/versions'; import { registerEvents } from './events'; -import { registerHooks, removeAllHooks } from './hooks'; -import type { ICallbacks } from './types/ICallbacks'; import { convertEmojiToUnicode } from './utils/emojiConverter'; export class FederationMatrix extends ServiceClass implements IFederationMatrixService { @@ -37,14 +35,12 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS private httpRoutes: { matrix: Router<'/_matrix'>; wellKnown: Router<'/.well-known'> }; - private callbacks?: ICallbacks; - private constructor(emitter?: Emitter) { super(); this.eventHandler = emitter || new Emitter(); } - static async create(emitter?: Emitter, callbacks?: ICallbacks): Promise { + static async create(emitter?: Emitter): Promise { const instance = new FederationMatrix(emitter); const config = new ConfigService({ database: { @@ -110,16 +106,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS async created(): Promise { try { registerEvents(this.eventHandler); - registerHooks(this, this.callbacks); } catch (error) { this.logger.warn('Homeserver module not available, running in limited mode'); } } - async stopped(): Promise { - removeAllHooks(this.callbacks); - } - public async getMatrixDomain(): Promise { if (this.matrixDomain) { return this.matrixDomain; diff --git a/ee/packages/federation-matrix/src/hooks/index.ts b/ee/packages/federation-matrix/src/hooks/index.ts deleted file mode 100644 index 6e77c1e6f65e9..0000000000000 --- a/ee/packages/federation-matrix/src/hooks/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { FederationMatrix } from '../FederationMatrix'; -import { reaction, removeReactionListeners } from './reaction'; -import type { ICallbacks } from '../types/ICallbacks'; - -export function registerHooks(federationMatrixService: FederationMatrix, callbacks?: ICallbacks) { - if (callbacks) { - reaction(federationMatrixService, callbacks); - } -} - -export function removeAllHooks(callbacks?: ICallbacks) { - removeReactionListeners(callbacks); -} diff --git a/ee/packages/federation-matrix/src/hooks/reaction.ts b/ee/packages/federation-matrix/src/hooks/reaction.ts index 67b0b729b4080..3d8844d3113e0 100644 --- a/ee/packages/federation-matrix/src/hooks/reaction.ts +++ b/ee/packages/federation-matrix/src/hooks/reaction.ts @@ -9,11 +9,6 @@ import type { ICallbacks } from '../types/ICallbacks'; const logger = new Logger('federation-matrix:reaction'); -<<<<<<< HEAD -======= -let registeredCallbacks: Array<{ hook: string; id: string }> = []; - ->>>>>>> f72828efff (feat: adds set and unset reactions capabilities) export function reaction(federationMatrixService: FederationMatrix, callbacks: ICallbacks) { callbacks.add( 'afterSetReaction', @@ -24,9 +19,12 @@ export function reaction(federationMatrixService: FederationMatrix, callbacks: I 'federation-matrix-after-set-reaction', ); <<<<<<< HEAD +<<<<<<< HEAD ======= registeredCallbacks.push({ hook: 'afterSetReaction', id: 'federation-matrix-after-set-reaction' }); >>>>>>> f72828efff (feat: adds set and unset reactions capabilities) +======= +>>>>>>> ea5a9a58e8 (chore: move hooks to ee folder) callbacks.add( 'afterUnsetReaction', @@ -37,6 +35,7 @@ export function reaction(federationMatrixService: FederationMatrix, callbacks: I 'federation-matrix-after-unset-reaction', ); <<<<<<< HEAD +<<<<<<< HEAD ======= registeredCallbacks.push({ hook: 'afterUnsetReaction', id: 'federation-matrix-after-unset-reaction' }); } @@ -51,6 +50,8 @@ export function removeReactionListeners(callbacks?: ICallbacks): void { } registeredCallbacks = []; >>>>>>> f72828efff (feat: adds set and unset reactions capabilities) +======= +>>>>>>> ea5a9a58e8 (chore: move hooks to ee folder) } async function handleReactionAdded( From 17c1cfd9d0d4d11a929b9c4e33dd30508336447a Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 15 Jul 2025 21:43:58 -0300 Subject: [PATCH 08/18] chore: reset files from feat/federation --- ee/apps/federation-service/src/service.ts | 1 - ee/packages/federation-matrix/src/FederationMatrix.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/ee/apps/federation-service/src/service.ts b/ee/apps/federation-service/src/service.ts index 091e6b9f893a4..702b9f53a10d4 100644 --- a/ee/apps/federation-service/src/service.ts +++ b/ee/apps/federation-service/src/service.ts @@ -49,7 +49,6 @@ function handleHealthCheck(app: Hono) { } const { FederationMatrix } = await import('@rocket.chat/federation-matrix'); - const federationMatrix = await FederationMatrix.create(); api.registerService(federationMatrix); diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 560469bb49a24..ec41adfbc112b 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -111,7 +111,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } - public async getMatrixDomain(): Promise { + async getMatrixDomain(): Promise { if (this.matrixDomain) { return this.matrixDomain; } From 63efdaad7f1468790e7b58284e6338151779512f Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Sun, 27 Jul 2025 23:39:38 -0300 Subject: [PATCH 09/18] chore: adjusts reactions to new state events approach --- .../federation-matrix/src/FederationMatrix.ts | 61 ++++++------------- .../federation-matrix/src/events/reaction.ts | 44 ++++++++----- 2 files changed, 47 insertions(+), 58 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index ec41adfbc112b..a64721574327c 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -8,7 +8,7 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Router } from '@rocket.chat/http-router'; import { Logger } from '@rocket.chat/logger'; -import { MatrixBridgedUser, MatrixBridgedRoom, MatrixBridgedMessage, Users, Messages } from '@rocket.chat/models'; +import { MatrixBridgedUser, MatrixBridgedRoom, Users, Messages } from '@rocket.chat/models'; import emojione from 'emojione'; import { getWellKnownRoutes } from './api/.well-known/server'; @@ -193,26 +193,22 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const matrixUserId = `@${user.username}:${matrixDomain}`; const existingMatrixUserId = await MatrixBridgedUser.getExternalUserIdByLocalUserId(user._id); if (!existingMatrixUserId) { - const port = await Settings.get('Federation_Service_Matrix_Port'); - const domain = await Settings.get('Federation_Service_Matrix_Domain'); - const matrixDomain = port === 443 || port === 80 ? domain : `${domain}:${port}`; - await MatrixBridgedUser.createOrUpdateByLocalId(user._id, matrixUserId, true, matrixDomain); + // const port = await Settings.get('Federation_Service_Matrix_Port'); + // const domain = await Settings.get('Federation_Service_Matrix_Domain'); + // const matrixDomain = port === 443 || port === 80 ? domain : `${domain}:${port}`; + await MatrixBridgedUser.createOrUpdateByLocalId(user._id, `@${user.username}`, true, matrixDomain); } - // TODO: We should fix this to not hardcode neither inform the target server - // This is on the homeserver mandate to track all the eligible servers in the federated room - const targetServer = 'hs1-garim.tunnel.dev.rocket.chat'; - if (!this.homeserverServices) { this.logger.warn('Homeserver services not available, skipping message send'); return; } - const result = await this.homeserverServices.message.sendMessage(matrixRoomId, message.msg, matrixUserId, targetServer); + const result = await this.homeserverServices.message.sendMessage(matrixRoomId, message.msg, matrixUserId); - await MatrixBridgedMessage.createOrUpdate(message._id, result.event_id); + await Messages.setFederationEventIdById(message._id, result.eventId); - this.logger.debug('Message sent to Matrix successfully:', result.event_id); + this.logger.debug('Message sent to Matrix successfully:', result.eventId); } catch (error) { this.logger.error('Failed to send message to Matrix:', error); throw error; @@ -231,7 +227,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS throw new Error(`No Matrix room mapping found for room ${message.rid}`); } - const matrixEventId = await MatrixBridgedMessage.getExternalEventId(messageId); + const matrixEventId = message.federation?.eventId; if (!matrixEventId) { throw new Error(`No Matrix event ID mapping found for message ${messageId}`); } @@ -250,20 +246,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const reactionKey = emojione.shortnameToUnicode(reaction); - // TODO: Fix hardcoded server - const targetServer = 'hs1-garim.tunnel.dev.rocket.chat'; - const result = await this.homeserverServices.message.sendReaction( - matrixRoomId, - matrixEventId, - reactionKey, - matrixUserId, - targetServer, - ); + const eventId = await this.homeserverServices.message.sendReaction(matrixRoomId, matrixEventId, reactionKey, matrixUserId); - const reactionMappingKey = `${messageId}_reaction_${reaction}`; - await MatrixBridgedMessage.createOrUpdate(reactionMappingKey, result.event_id); + await Messages.setFederationReactionEventId(user.username || '', messageId, reaction, eventId); - this.logger.debug('Reaction sent to Matrix successfully:', result.event_id); + this.logger.debug('Reaction sent to Matrix successfully:', eventId); } catch (error) { this.logger.error('Failed to send reaction to Matrix:', error); throw error; @@ -282,7 +269,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS throw new Error(`No Matrix room mapping found for room ${message.rid}`); } - const matrixEventId = await MatrixBridgedMessage.getExternalEventId(messageId); + const matrixEventId = message.federation?.eventId; if (!matrixEventId) { throw new Error(`No Matrix event ID mapping found for message ${messageId}`); } @@ -295,27 +282,13 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return; } - // TODO: Fix hardcoded server - const targetServer = 'hs1-garim.tunnel.dev.rocket.chat'; - - const reactionMappingKey = `${messageId}_reaction_${reaction}`; - const reactionEventId = await MatrixBridgedMessage.getExternalEventId(reactionMappingKey); - if (!reactionEventId) { - this.logger.warn(`No reaction event ID found for ${reactionMappingKey}`); - return; - } + const reactionKey = emojione.shortnameToUnicode(reaction); - const result = await this.homeserverServices.message.redactMessage( - matrixRoomId, - reactionEventId, - undefined, - matrixUserId, - targetServer, - ); + const eventId = await this.homeserverServices.message.unsetReaction(matrixRoomId, matrixEventId, reactionKey, matrixUserId); - await MatrixBridgedMessage.removeByLocalMessageId(reactionMappingKey); + await Messages.unsetFederationReactionEventId(eventId, messageId, reaction); - this.logger.debug('Reaction removed from Matrix successfully:', result.event_id); + this.logger.debug('Reaction removed from Matrix successfully:', eventId); } catch (error) { this.logger.error('Failed to remove reaction from Matrix:', error); throw error; diff --git a/ee/packages/federation-matrix/src/events/reaction.ts b/ee/packages/federation-matrix/src/events/reaction.ts index 62ab4c490c4e3..ccca709bd099f 100644 --- a/ee/packages/federation-matrix/src/events/reaction.ts +++ b/ee/packages/federation-matrix/src/events/reaction.ts @@ -2,7 +2,7 @@ import type { HomeserverEventSignatures } from '@hs/federation-sdk'; import { Message } from '@rocket.chat/core-services'; import type { Emitter } from '@rocket.chat/emitter'; import { Logger } from '@rocket.chat/logger'; -import { Users, MatrixBridgedMessage, Messages } from '@rocket.chat/models'; +import { Users, Messages } from '@rocket.chat/models'; import emojione from 'emojione'; const logger = new Logger('federation-matrix:reaction'); @@ -31,17 +31,14 @@ export function reaction(emitter: Emitter) { return; } - const rcMessageId = await MatrixBridgedMessage.getLocalMessageId(targetEventId); - if (!rcMessageId) { + const rcMessage = await Messages.findOneByFederationId(targetEventId); + if (!rcMessage) { logger.debug(`No RC message mapping found for Matrix event ${targetEventId}`); return; } + const rcMessageId = rcMessage._id; - const message = await Messages.findOneById(rcMessageId); - if (!message) { - logger.debug(`RC message ${rcMessageId} not found`); - return; - } + // Message already retrieved above const [userPart, domain] = data.sender.split(':'); if (!userPart || !domain) { @@ -59,8 +56,7 @@ export function reaction(emitter: Emitter) { await Message.reactToMessage(rcMessageId, reactionEmoji, user._id); - const reactionMappingKey = `${rcMessageId}_reaction_${reactionEmoji}`; - await MatrixBridgedMessage.createOrUpdate(reactionMappingKey, data.event_id); + await Messages.setFederationReactionEventId(user.username || username, rcMessageId, reactionEmoji, data.event_id); logger.debug('Matrix reaction processed successfully'); } catch (error) { @@ -75,12 +71,32 @@ export function reaction(emitter: Emitter) { return; } - const reactionMappingKey = await MatrixBridgedMessage.getLocalMessageId(redactedEventId); - if (!reactionMappingKey?.includes('_reaction_')) { + // First check if this is a reaction redaction by looking for messages with this reaction event ID + const messageWithReaction = await Messages.findOneByFederationIdAndUsernameOnReactions( + redactedEventId, + data.sender.split(':')[0].substring(1), + ); + if (!messageWithReaction) { + return; + } + + // Find which reaction was redacted + let redactedReaction: string | null = null; + if (messageWithReaction.reactions) { + for (const [reaction, reactionData] of Object.entries(messageWithReaction.reactions)) { + if (reactionData.federationReactionEventIds?.[redactedEventId]) { + redactedReaction = reaction; + break; + } + } + } + + if (!redactedReaction) { return; } - const [messageId, , reaction] = reactionMappingKey.split('_'); + const messageId = messageWithReaction._id; + const reaction = redactedReaction; const [userPart] = data.sender.split(':'); const username = userPart.substring(1); @@ -93,7 +109,7 @@ export function reaction(emitter: Emitter) { await Message.reactToMessage(user._id, reaction, messageId, false); - await MatrixBridgedMessage.removeByLocalMessageId(reactionMappingKey); + await Messages.unsetFederationReactionEventId(redactedEventId, messageId, reaction); logger.debug('Matrix reaction redaction processed successfully'); } catch (error) { From 51ab53831370fca035b44c01f71fe579cfa0a648 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Sun, 27 Jul 2025 23:39:56 -0300 Subject: [PATCH 10/18] chore: removes bridged messages collection --- .../src/federation/IMatrixBridgedMessage.ts | 6 ---- packages/core-typings/src/federation/index.ts | 1 - packages/model-typings/src/index.ts | 1 - .../src/models/IMatrixBridgedMessageModel.ts | 10 ------ packages/models/src/index.ts | 4 --- packages/models/src/modelClasses.ts | 1 - .../models/src/models/MatrixBridgedMessage.ts | 36 ------------------- 7 files changed, 59 deletions(-) delete mode 100644 packages/core-typings/src/federation/IMatrixBridgedMessage.ts delete mode 100644 packages/model-typings/src/models/IMatrixBridgedMessageModel.ts delete mode 100644 packages/models/src/models/MatrixBridgedMessage.ts diff --git a/packages/core-typings/src/federation/IMatrixBridgedMessage.ts b/packages/core-typings/src/federation/IMatrixBridgedMessage.ts deleted file mode 100644 index 17c69cdf1772e..0000000000000 --- a/packages/core-typings/src/federation/IMatrixBridgedMessage.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { IRocketChatRecord } from '../IRocketChatRecord'; - -export interface IMatrixBridgedMessage extends IRocketChatRecord { - mid: string; - meid: string; -} diff --git a/packages/core-typings/src/federation/index.ts b/packages/core-typings/src/federation/index.ts index 300788422a364..f3dbfe7778c66 100644 --- a/packages/core-typings/src/federation/index.ts +++ b/packages/core-typings/src/federation/index.ts @@ -1,5 +1,4 @@ export * from './IMatrixBridgedRoom'; export * from './IMatrixBridgedUser'; -export * from './IMatrixBridgedMessage'; export * from './v1'; diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 74cbc48443df0..50816e2b6f043 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -70,7 +70,6 @@ export * from './models/IVoipRoomModel'; export * from './models/IWebdavAccountsModel'; export * from './models/IMatrixBridgedRoomModel'; export * from './models/IMatrixBridgedUserModel'; -export * from './models/IMatrixBridgedMessageModel'; export * from './models/ICalendarEventModel'; export * from './models/IOmnichannelServiceLevelAgreementsModel'; export * from './models/IAppLogsModel'; diff --git a/packages/model-typings/src/models/IMatrixBridgedMessageModel.ts b/packages/model-typings/src/models/IMatrixBridgedMessageModel.ts deleted file mode 100644 index 3bbb7e80d0a31..0000000000000 --- a/packages/model-typings/src/models/IMatrixBridgedMessageModel.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { IMatrixBridgedMessage } from '@rocket.chat/core-typings'; - -import type { IBaseModel } from './IBaseModel'; - -export interface IMatrixBridgedMessageModel extends IBaseModel { - getExternalEventId(localMessageId: string): Promise; - getLocalMessageId(externalEventId: string): Promise; - createOrUpdate(localMessageId: string, externalEventId: string): Promise; - removeByLocalMessageId(localMessageId: string): Promise; -} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 3079e91513d89..d1f9863d24490 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -76,7 +76,6 @@ import type { IWebdavAccountsModel, IMatrixBridgedRoomModel, IMatrixBridgedUserModel, - IMatrixBridgedMessageModel, ICalendarEventModel, IOmnichannelServiceLevelAgreementsModel, IAppsModel, @@ -118,7 +117,6 @@ import { UsersSessionsRaw, MatrixBridgedUserRaw, MatrixBridgedRoomRaw, - MatrixBridgedMessageRaw, } from './modelClasses'; import { proxify, registerModel } from './proxify'; @@ -215,7 +213,6 @@ export const VoipRoom = proxify('IVoipRoomModel'); export const WebdavAccounts = proxify('IWebdavAccountsModel'); export const MatrixBridgedRoom = proxify('IMatrixBridgedRoomModel'); export const MatrixBridgedUser = proxify('IMatrixBridgedUserModel'); -export const MatrixBridgedMessage = proxify('IMatrixBridgedMessageModel'); export const CalendarEvent = proxify('ICalendarEventModel'); export const OmnichannelServiceLevelAgreements = proxify( 'IOmnichannelServiceLevelAgreementsModel', @@ -261,7 +258,6 @@ export function registerServiceModels(db: Db, trash?: Collection new LivechatVisitorsRaw(db)); registerModel('IMatrixBridgedUserModel', () => new MatrixBridgedUserRaw(db)); registerModel('IMatrixBridgedRoomModel', () => new MatrixBridgedRoomRaw(db)); - registerModel('IMatrixBridgedMessageModel', () => new MatrixBridgedMessageRaw(db)); } if (!dbWatchersDisabled) { diff --git a/packages/models/src/modelClasses.ts b/packages/models/src/modelClasses.ts index ce432246fe9ea..148ab141b53e8 100644 --- a/packages/models/src/modelClasses.ts +++ b/packages/models/src/modelClasses.ts @@ -69,7 +69,6 @@ export * from './models/VoipRoom'; export * from './models/WebdavAccounts'; export * from './models/MatrixBridgedRoom'; export * from './models/MatrixBridgedUser'; -export * from './models/MatrixBridgedMessage'; export * from './models/CredentialTokens'; export * from './models/MessageReads'; export * from './models/CronHistoryModel'; diff --git a/packages/models/src/models/MatrixBridgedMessage.ts b/packages/models/src/models/MatrixBridgedMessage.ts deleted file mode 100644 index cc67b648be5db..0000000000000 --- a/packages/models/src/models/MatrixBridgedMessage.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { IMatrixBridgedMessage, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; -import type { IMatrixBridgedMessageModel } from '@rocket.chat/model-typings'; -import type { Collection, Db, IndexDescription } from 'mongodb'; - -import { BaseRaw } from './BaseRaw'; - -export class MatrixBridgedMessageRaw extends BaseRaw implements IMatrixBridgedMessageModel { - constructor(db: Db, trash?: Collection>) { - super(db, 'matrix_bridged_messages', trash); - } - - protected modelIndexes(): IndexDescription[] { - return [ - { key: { mid: 1 }, unique: true, sparse: true }, - { key: { meid: 1 }, unique: true, sparse: true }, - ]; - } - - async getExternalEventId(localMessageId: string): Promise { - const bridgedMessage = await this.findOne({ mid: localMessageId }); - return bridgedMessage ? bridgedMessage.meid : null; - } - - async getLocalMessageId(externalEventId: string): Promise { - const bridgedMessage = await this.findOne({ meid: externalEventId }); - return bridgedMessage ? bridgedMessage.mid : null; - } - - async createOrUpdate(localMessageId: string, externalEventId: string): Promise { - await this.updateOne({ mid: localMessageId }, { $set: { mid: localMessageId, meid: externalEventId } }, { upsert: true }); - } - - async removeByLocalMessageId(localMessageId: string): Promise { - await this.deleteOne({ mid: localMessageId }); - } -} From 949bc0531db7ba3b3d0d3e4cce631b8ec8b34de8 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 28 Jul 2025 00:01:37 -0300 Subject: [PATCH 11/18] chore: use common matrixUserId on sendMessage --- ee/packages/federation-matrix/src/FederationMatrix.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index a64721574327c..4fe2e9ca02c8f 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -20,7 +20,6 @@ import { getMatrixSendJoinRoutes } from './api/_matrix/send-join'; import { getMatrixTransactionsRoutes } from './api/_matrix/transactions'; import { getFederationVersionsRoutes } from './api/_matrix/versions'; import { registerEvents } from './events'; -import { convertEmojiToUnicode } from './utils/emojiConverter'; export class FederationMatrix extends ServiceClass implements IFederationMatrixService { protected name = 'federation-matrix'; @@ -193,10 +192,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const matrixUserId = `@${user.username}:${matrixDomain}`; const existingMatrixUserId = await MatrixBridgedUser.getExternalUserIdByLocalUserId(user._id); if (!existingMatrixUserId) { - // const port = await Settings.get('Federation_Service_Matrix_Port'); - // const domain = await Settings.get('Federation_Service_Matrix_Domain'); - // const matrixDomain = port === 443 || port === 80 ? domain : `${domain}:${port}`; - await MatrixBridgedUser.createOrUpdateByLocalId(user._id, `@${user.username}`, true, matrixDomain); + await MatrixBridgedUser.createOrUpdateByLocalId(user._id, matrixUserId, true, matrixDomain); } if (!this.homeserverServices) { From 4afe8c9928daa137b22badedbad08e27e3754f1a Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 29 Jul 2025 19:46:39 -0300 Subject: [PATCH 12/18] xxx --- .../app/reactions/server/setReaction.ts | 2 +- apps/meteor/ee/server/index.ts | 2 +- .../federation-matrix/src/FederationMatrix.ts | 55 ++++++-- .../federation-matrix/src/events/reaction.ts | 40 +++--- .../federation-matrix/src/hooks/reaction.ts | 121 ------------------ 5 files changed, 61 insertions(+), 159 deletions(-) delete mode 100644 ee/packages/federation-matrix/src/hooks/reaction.ts diff --git a/apps/meteor/app/reactions/server/setReaction.ts b/apps/meteor/app/reactions/server/setReaction.ts index be6e5aed4a545..cd304f5715460 100644 --- a/apps/meteor/app/reactions/server/setReaction.ts +++ b/apps/meteor/app/reactions/server/setReaction.ts @@ -39,7 +39,7 @@ export async function setReaction( reaction: string, userAlreadyReacted?: boolean, ) { - await Message.beforeReacted(message, room); + // await Message.beforeReacted(message, room); if (Array.isArray(room.muted) && room.muted.includes(user.username as string)) { throw new Meteor.Error('error-not-allowed', i18n.t('You_have_been_muted', { lng: user.language }), { diff --git a/apps/meteor/ee/server/index.ts b/apps/meteor/ee/server/index.ts index 826639c03efb3..e3604bbb36141 100644 --- a/apps/meteor/ee/server/index.ts +++ b/apps/meteor/ee/server/index.ts @@ -13,7 +13,7 @@ import './configuration/index'; import './local-services/ldap/service'; import './methods/getReadReceipts'; import './patches'; -// import './hooks/federation'; +import './hooks/federation'; export * from './apps/startup'; export { registerEEBroker } from './startup'; diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 4fe2e9ca02c8f..e98eaae159dae 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -200,7 +200,9 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return; } - const result = await this.homeserverServices.message.sendMessage(matrixRoomId, message.msg, matrixUserId); + const actualMatrixUserId = existingMatrixUserId || matrixUserId; + + const result = await this.homeserverServices.message.sendMessage(matrixRoomId, message.msg, actualMatrixUserId); await Messages.setFederationEventIdById(message._id, result.eventId); @@ -213,6 +215,18 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS async sendReaction(messageId: string, reaction: string, user: IUser): Promise { try { + const matrixDomain = await this.getMatrixDomain(); + const existingMatrixUserId = await MatrixBridgedUser.getExternalUserIdByLocalUserId(user._id); + if (existingMatrixUserId) { + const userDomain = existingMatrixUserId.split(':')[1]; + if (userDomain && userDomain !== matrixDomain) { + this.logger.debug( + `User ${existingMatrixUserId} is from external domain ${userDomain}, skipping federation notification to prevent loop`, + ); + return; + } + } + const message = await Messages.findOneById(messageId); if (!message) { throw new Error(`Message ${messageId} not found`); @@ -228,11 +242,10 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS throw new Error(`No Matrix event ID mapping found for message ${messageId}`); } - const matrixDomain = await this.getMatrixDomain(); - const matrixUserId = `@${user.username}:${matrixDomain}`; - const existingMatrixUserId = await MatrixBridgedUser.getExternalUserIdByLocalUserId(user._id); - if (!existingMatrixUserId) { - await MatrixBridgedUser.createOrUpdateByLocalId(user._id, matrixUserId, true, matrixDomain); + let actualMatrixUserId = existingMatrixUserId; + if (!actualMatrixUserId) { + actualMatrixUserId = `@${user.username}:${matrixDomain}`; + await MatrixBridgedUser.createOrUpdateByLocalId(user._id, actualMatrixUserId, true, matrixDomain); } if (!this.homeserverServices) { @@ -242,7 +255,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const reactionKey = emojione.shortnameToUnicode(reaction); - const eventId = await this.homeserverServices.message.sendReaction(matrixRoomId, matrixEventId, reactionKey, matrixUserId); + const eventId = await this.homeserverServices.message.sendReaction(matrixRoomId, matrixEventId, reactionKey, actualMatrixUserId); await Messages.setFederationReactionEventId(user.username || '', messageId, reaction, eventId); @@ -255,6 +268,18 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS async removeReaction(messageId: string, reaction: string, user: IUser): Promise { try { + const matrixDomain = await this.getMatrixDomain(); + const existingMatrixUserId = await MatrixBridgedUser.getExternalUserIdByLocalUserId(user._id); + if (existingMatrixUserId) { + const userDomain = existingMatrixUserId.split(':')[1]; + if (userDomain && userDomain !== matrixDomain) { + this.logger.debug( + `User ${existingMatrixUserId} is from external domain ${userDomain}, skipping federation notification to prevent loop`, + ); + return; + } + } + const message = await Messages.findOneById(messageId); if (!message) { throw new Error(`Message ${messageId} not found`); @@ -270,9 +295,6 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS throw new Error(`No Matrix event ID mapping found for message ${messageId}`); } - const matrixDomain = await this.getMatrixDomain(); - const matrixUserId = `@${user.username}:${matrixDomain}`; - if (!this.homeserverServices) { this.logger.warn('Homeserver services not available, skipping reaction removal'); return; @@ -280,11 +302,18 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const reactionKey = emojione.shortnameToUnicode(reaction); - const eventId = await this.homeserverServices.message.unsetReaction(matrixRoomId, matrixEventId, reactionKey, matrixUserId); + let actualMatrixUserId = existingMatrixUserId; + if (!actualMatrixUserId) { + actualMatrixUserId = `@${user.username}:${matrixDomain}`; + } - await Messages.unsetFederationReactionEventId(eventId, messageId, reaction); + const eventId = await this.homeserverServices.message.unsetReaction(matrixRoomId, matrixEventId, reactionKey, actualMatrixUserId); - this.logger.debug('Reaction removed from Matrix successfully:', eventId); + if (eventId) { + await Messages.unsetFederationReactionEventId(eventId, messageId, reaction); + } else { + this.logger.warn('No reaction event found to remove in Matrix'); + } } catch (error) { this.logger.error('Failed to remove reaction from Matrix:', error); throw error; diff --git a/ee/packages/federation-matrix/src/events/reaction.ts b/ee/packages/federation-matrix/src/events/reaction.ts index ccca709bd099f..45da2cac4b0bf 100644 --- a/ee/packages/federation-matrix/src/events/reaction.ts +++ b/ee/packages/federation-matrix/src/events/reaction.ts @@ -2,7 +2,7 @@ import type { HomeserverEventSignatures } from '@hs/federation-sdk'; import { Message } from '@rocket.chat/core-services'; import type { Emitter } from '@rocket.chat/emitter'; import { Logger } from '@rocket.chat/logger'; -import { Users, Messages } from '@rocket.chat/models'; +import { Users, Messages, Rooms } from '@rocket.chat/models'; import emojione from 'emojione'; const logger = new Logger('federation-matrix:reaction'); @@ -10,13 +10,6 @@ const logger = new Logger('federation-matrix:reaction'); export function reaction(emitter: Emitter) { emitter.on('homeserver.matrix.reaction', async (data) => { try { - logger.info('Received Matrix reaction event:', { - event_id: data.event_id, - room_id: data.room_id, - sender: data.sender, - relates_to: data.content?.['m.relates_to'], - }); - const relatesTo = data.content?.['m.relates_to']; if (!relatesTo || relatesTo.rel_type !== 'm.annotation') { logger.debug('Invalid reaction event structure'); @@ -38,8 +31,6 @@ export function reaction(emitter: Emitter) { } const rcMessageId = rcMessage._id; - // Message already retrieved above - const [userPart, domain] = data.sender.split(':'); if (!userPart || !domain) { logger.error('Invalid Matrix sender ID format:', data.sender); @@ -47,18 +38,27 @@ export function reaction(emitter: Emitter) { } const username = userPart.substring(1); - const user = await Users.findOneByUsername(username); + const user = await Users.findOneByUsername(data.sender); if (!user) { return; } const reactionEmoji = emojione.toShort(reactionKey); - await Message.reactToMessage(rcMessageId, reactionEmoji, user._id); + const message = await Messages.findOneById(rcMessageId); + if (!message) { + logger.error('Message not found when trying to set reaction'); + return; + } - await Messages.setFederationReactionEventId(user.username || username, rcMessageId, reactionEmoji, data.event_id); + const room = await Rooms.findOneById(message.rid); + if (!room) { + logger.error('Room not found when trying to set reaction'); + return; + } - logger.debug('Matrix reaction processed successfully'); + await Message.reactToMessage(user._id, reactionEmoji, rcMessageId, true); + await Messages.setFederationReactionEventId(user.username || username, rcMessageId, reactionEmoji, data.event_id); } catch (error) { logger.error('Failed to process Matrix reaction:', error); } @@ -68,19 +68,19 @@ export function reaction(emitter: Emitter) { try { const redactedEventId = data.redacts; if (!redactedEventId) { + logger.debug('No redacts field in redaction event'); return; } - // First check if this is a reaction redaction by looking for messages with this reaction event ID const messageWithReaction = await Messages.findOneByFederationIdAndUsernameOnReactions( redactedEventId, data.sender.split(':')[0].substring(1), ); if (!messageWithReaction) { + logger.debug(`No message found with reaction event ID ${redactedEventId}`); return; } - // Find which reaction was redacted let redactedReaction: string | null = null; if (messageWithReaction.reactions) { for (const [reaction, reactionData] of Object.entries(messageWithReaction.reactions)) { @@ -98,20 +98,14 @@ export function reaction(emitter: Emitter) { const messageId = messageWithReaction._id; const reaction = redactedReaction; - const [userPart] = data.sender.split(':'); - const username = userPart.substring(1); - const user = await Users.findOneByUsername(username); - + const user = await Users.findOneByUsername(data.sender); if (!user) { logger.debug('User not found for reaction redaction'); return; } await Message.reactToMessage(user._id, reaction, messageId, false); - await Messages.unsetFederationReactionEventId(redactedEventId, messageId, reaction); - - logger.debug('Matrix reaction redaction processed successfully'); } catch (error) { logger.error('Failed to process Matrix reaction redaction:', error); } diff --git a/ee/packages/federation-matrix/src/hooks/reaction.ts b/ee/packages/federation-matrix/src/hooks/reaction.ts deleted file mode 100644 index 3d8844d3113e0..0000000000000 --- a/ee/packages/federation-matrix/src/hooks/reaction.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Settings } from '@rocket.chat/core-services'; -import type { IMessage, IUser } from '@rocket.chat/core-typings'; -import { isMessageFromMatrixFederation } from '@rocket.chat/core-typings'; -import { Logger } from '@rocket.chat/logger'; -import { Rooms } from '@rocket.chat/models'; - -import type { FederationMatrix } from '../FederationMatrix'; -import type { ICallbacks } from '../types/ICallbacks'; - -const logger = new Logger('federation-matrix:reaction'); - -export function reaction(federationMatrixService: FederationMatrix, callbacks: ICallbacks) { - callbacks.add( - 'afterSetReaction', - async (message: IMessage, params: { user: IUser; reaction: string }): Promise => { - await handleReactionAdded(federationMatrixService, message, params.user, params.reaction); - }, - callbacks.priority.HIGH, - 'federation-matrix-after-set-reaction', - ); -<<<<<<< HEAD -<<<<<<< HEAD -======= - registeredCallbacks.push({ hook: 'afterSetReaction', id: 'federation-matrix-after-set-reaction' }); ->>>>>>> f72828efff (feat: adds set and unset reactions capabilities) -======= ->>>>>>> ea5a9a58e8 (chore: move hooks to ee folder) - - callbacks.add( - 'afterUnsetReaction', - async (_message: IMessage, params: { user: IUser; reaction: string; oldMessage: IMessage }): Promise => { - await handleReactionRemoved(federationMatrixService, params.oldMessage, params.user, params.reaction); - }, - callbacks.priority.HIGH, - 'federation-matrix-after-unset-reaction', - ); -<<<<<<< HEAD -<<<<<<< HEAD -======= - registeredCallbacks.push({ hook: 'afterUnsetReaction', id: 'federation-matrix-after-unset-reaction' }); -} - -export function removeReactionListeners(callbacks?: ICallbacks): void { - if (!callbacks) { - return; - } - - for (const { hook, id } of registeredCallbacks) { - callbacks.remove(hook, id); - } - registeredCallbacks = []; ->>>>>>> f72828efff (feat: adds set and unset reactions capabilities) -======= ->>>>>>> ea5a9a58e8 (chore: move hooks to ee folder) -} - -async function handleReactionAdded( - federationMatrixService: FederationMatrix, - message: IMessage, - user: IUser, - reactionString: string, -): Promise { - try { - if (!(await shouldHandleReaction(federationMatrixService, message, user))) { - return; - } - - await federationMatrixService.sendReaction(message._id, reactionString, user); - } catch (error) { - logger.error('Failed to handle reaction added:', error); - } -} - -async function handleReactionRemoved( - federationMatrixService: FederationMatrix, - message: IMessage, - user: IUser, - reactionString: string, -): Promise { - try { - if (!(await shouldHandleReaction(federationMatrixService, message, user))) { - return; - } - - await federationMatrixService.removeReaction(message._id, reactionString, user); - } catch (error) { - logger.error('Failed to handle reaction removed:', error); - } -} - -async function shouldHandleReaction(federationMatrixService: FederationMatrix, message: IMessage, user: IUser): Promise { - try { - const room = await Rooms.findOneById(message.rid); - if (!room?.federated) { - return false; - } - - if (!isMessageFromMatrixFederation(message)) { - return false; - } - - if (user.federated) { - return false; - } - - const matrixDomain = await federationMatrixService.getMatrixDomain(); - if (user.username?.includes(':') && !user.username.endsWith(`:${matrixDomain}`)) { - return false; - } - - const federationEnabled = await Settings.get('Federation_Matrix_enabled'); - if (!federationEnabled) { - return false; - } - - return true; - } catch (error) { - logger.error('Error in shouldHandleReaction:', error); - return false; - } -} From 688377ad349b1486de62274b83f27a3a6b5d263f Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 29 Jul 2025 22:58:09 -0300 Subject: [PATCH 13/18] chore: simplifies configs --- .../federation-matrix/src/FederationMatrix.ts | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index e98eaae159dae..ffd6f06431b8d 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -1,6 +1,5 @@ import 'reflect-metadata'; -import { toUnpaddedBase64 } from '@hs/core'; import { ConfigService, createFederationContainer, getAllServices } from '@hs/federation-sdk'; import type { HomeserverEventSignatures, HomeserverServices, FederationContainerOptions } from '@hs/federation-sdk'; import { type IFederationMatrixService, ServiceClass, Settings } from '@rocket.chat/core-services'; @@ -41,43 +40,27 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS static async create(emitter?: Emitter): Promise { const instance = new FederationMatrix(emitter); + const config = new ConfigService({ + serverName: process.env.SERVER_NAME || 'rc1', + port: Number.parseInt(process.env.SERVER_PORT || '8080', 10), + version: process.env.SERVER_VERSION || '1.0', + matrixDomain: process.env.MATRIX_DOMAIN || 'rc1', + keyRefreshInterval: Number.parseInt(process.env.MATRIX_KEY_REFRESH_INTERVAL || '60', 10), + timeout: 30000, + signingKeyPath: process.env.CONFIG_FOLDER || './rc1.signing.key', database: { uri: process.env.MONGODB_URI || 'mongodb://localhost:3001/meteor', name: process.env.DATABASE_NAME || 'meteor', poolSize: Number.parseInt(process.env.DATABASE_POOL_SIZE || '10', 10), }, - server: { - name: process.env.SERVER_NAME || 'rc1', - version: process.env.SERVER_VERSION || '1.0', - port: Number.parseInt(process.env.SERVER_PORT || '8080', 10), - baseUrl: process.env.SERVER_BASE_URL || 'http://rc1:8080', - host: process.env.SERVER_HOST || '0.0.0.0', - }, - matrix: { - serverName: process.env.MATRIX_SERVER_NAME || 'rc1', - domain: process.env.MATRIX_DOMAIN || 'rc1', - keyRefreshInterval: Number.parseInt(process.env.MATRIX_KEY_REFRESH_INTERVAL || '60', 10), - }, - signingKeyPath: process.env.CONFIG_FOLDER || './rc1.signing.key', }); - const matrixConfig = config.getMatrixConfig(); - const serverConfig = config.getServerConfig(); - const signingKeys = await config.getSigningKey(); - const signingKey = signingKeys[0]; const containerOptions: FederationContainerOptions = { emitter: instance.eventHandler, - federationOptions: { - serverName: matrixConfig.serverName, - signingKey: toUnpaddedBase64(signingKey.privateKey), - signingKeyId: `ed25519:${signingKey.version}`, - timeout: 30000, - baseUrl: serverConfig.baseUrl, - }, }; - await createFederationContainer(containerOptions, config); + createFederationContainer(containerOptions, config); instance.homeserverServices = getAllServices(); instance.buildMatrixHTTPRoutes(); @@ -166,7 +149,6 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } catch (error) { this.logger.error('Error creating or updating bridged user:', error); } - // We are not generating bridged users for members outside of the current workspace // They will be created when the invite is accepted From 812a06d935da66c59527d502e56b99eae939adc4 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 30 Jul 2025 07:02:29 -0300 Subject: [PATCH 14/18] chore: adjusts unseting reactions from rc --- .../ee/server/hooks/federation/index.ts | 4 +- .../federation-matrix/src/FederationMatrix.ts | 104 +++++++++--------- .../federation-matrix/src/events/reaction.ts | 84 ++++++-------- .../src/types/IFederationMatrixService.ts | 3 +- 4 files changed, 87 insertions(+), 108 deletions(-) diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index c8213357c48af..8c3756d70cd1b 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -1,7 +1,7 @@ import { FederationMatrix } from '@rocket.chat/core-services'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; import { callbacks } from '../../../../lib/callbacks'; -import type { IMessage, IUser } from '@rocket.chat/core-typings'; callbacks.add( 'afterSetReaction', @@ -15,7 +15,7 @@ callbacks.add( callbacks.add( 'afterUnsetReaction', async (_message: IMessage, params: { user: IUser; reaction: string; oldMessage: IMessage }): Promise => { - await FederationMatrix.removeReaction(params.oldMessage._id, params.reaction, params.user); + await FederationMatrix.removeReaction(params.oldMessage._id, params.reaction, params.user, params.oldMessage); }, callbacks.priority.HIGH, 'federation-matrix-after-unset-reaction', diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index ffd6f06431b8d..90d7aa3ccd116 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -197,18 +197,6 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS async sendReaction(messageId: string, reaction: string, user: IUser): Promise { try { - const matrixDomain = await this.getMatrixDomain(); - const existingMatrixUserId = await MatrixBridgedUser.getExternalUserIdByLocalUserId(user._id); - if (existingMatrixUserId) { - const userDomain = existingMatrixUserId.split(':')[1]; - if (userDomain && userDomain !== matrixDomain) { - this.logger.debug( - `User ${existingMatrixUserId} is from external domain ${userDomain}, skipping federation notification to prevent loop`, - ); - return; - } - } - const message = await Messages.findOneById(messageId); if (!message) { throw new Error(`Message ${messageId} not found`); @@ -224,20 +212,15 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS throw new Error(`No Matrix event ID mapping found for message ${messageId}`); } - let actualMatrixUserId = existingMatrixUserId; - if (!actualMatrixUserId) { - actualMatrixUserId = `@${user.username}:${matrixDomain}`; - await MatrixBridgedUser.createOrUpdateByLocalId(user._id, actualMatrixUserId, true, matrixDomain); - } + const reactionKey = emojione.shortnameToUnicode(reaction); - if (!this.homeserverServices) { - this.logger.warn('Homeserver services not available, skipping reaction send'); + const existingMatrixUserId = await MatrixBridgedUser.getExternalUserIdByLocalUserId(user._id); + if (!existingMatrixUserId) { + this.logger.error(`No Matrix user ID mapping found for user ${user._id}`); return; } - const reactionKey = emojione.shortnameToUnicode(reaction); - - const eventId = await this.homeserverServices.message.sendReaction(matrixRoomId, matrixEventId, reactionKey, actualMatrixUserId); + const eventId = await this.homeserverServices.message.sendReaction(matrixRoomId, matrixEventId, reactionKey, existingMatrixUserId); await Messages.setFederationReactionEventId(user.username || '', messageId, reaction, eventId); @@ -248,57 +231,74 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } - async removeReaction(messageId: string, reaction: string, user: IUser): Promise { + async removeReaction(messageId: string, reaction: string, user: IUser, oldMessage: IMessage): Promise { try { - const matrixDomain = await this.getMatrixDomain(); - const existingMatrixUserId = await MatrixBridgedUser.getExternalUserIdByLocalUserId(user._id); - if (existingMatrixUserId) { - const userDomain = existingMatrixUserId.split(':')[1]; - if (userDomain && userDomain !== matrixDomain) { - this.logger.debug( - `User ${existingMatrixUserId} is from external domain ${userDomain}, skipping federation notification to prevent loop`, - ); - return; - } - } - const message = await Messages.findOneById(messageId); if (!message) { - throw new Error(`Message ${messageId} not found`); + this.logger.error(`Message ${messageId} not found`); + return; + } + + const targetEventId = message.federation?.eventId; + if (!targetEventId) { + this.logger.warn(`No federation event ID found for message ${messageId}`); + return; } const matrixRoomId = await MatrixBridgedRoom.getExternalRoomId(message.rid); if (!matrixRoomId) { - throw new Error(`No Matrix room mapping found for room ${message.rid}`); + this.logger.error(`No Matrix room mapping found for room ${message.rid}`); + return; } - const matrixEventId = message.federation?.eventId; - if (!matrixEventId) { - throw new Error(`No Matrix event ID mapping found for message ${messageId}`); + const reactionKey = emojione.shortnameToUnicode(reaction); + const existingMatrixUserId = await MatrixBridgedUser.getExternalUserIdByLocalUserId(user._id); + if (!existingMatrixUserId) { + this.logger.error(`No Matrix user ID mapping found for user ${user._id}`); + return; } - if (!this.homeserverServices) { - this.logger.warn('Homeserver services not available, skipping reaction removal'); + const reactionData = oldMessage.reactions?.[reaction]; + if (!reactionData?.federationReactionEventIds) { return; } - const reactionKey = emojione.shortnameToUnicode(reaction); - - let actualMatrixUserId = existingMatrixUserId; - if (!actualMatrixUserId) { - actualMatrixUserId = `@${user.username}:${matrixDomain}`; - } + for await (const [eventId, username] of Object.entries(reactionData.federationReactionEventIds)) { + if (username !== user.username) { + continue; + } - const eventId = await this.homeserverServices.message.unsetReaction(matrixRoomId, matrixEventId, reactionKey, actualMatrixUserId); + const redactionEventId = await this.homeserverServices.message.unsetReaction( + matrixRoomId, + eventId, + reactionKey, + existingMatrixUserId, + ); + if (!redactionEventId) { + this.logger.warn('No reaction event found to remove in Matrix'); + return; + } - if (eventId) { await Messages.unsetFederationReactionEventId(eventId, messageId, reaction); - } else { - this.logger.warn('No reaction event found to remove in Matrix'); + break; } } catch (error) { this.logger.error('Failed to remove reaction from Matrix:', error); throw error; } } + + async getEventById(eventId: string): Promise { + if (!this.homeserverServices) { + this.logger.warn('Homeserver services not available'); + return null; + } + + try { + return await this.homeserverServices.event.getEventById(eventId); + } catch (error) { + this.logger.error('Failed to get event by ID:', error); + throw error; + } + } } diff --git a/ee/packages/federation-matrix/src/events/reaction.ts b/ee/packages/federation-matrix/src/events/reaction.ts index 45da2cac4b0bf..a2390f6139ccb 100644 --- a/ee/packages/federation-matrix/src/events/reaction.ts +++ b/ee/packages/federation-matrix/src/events/reaction.ts @@ -1,8 +1,8 @@ import type { HomeserverEventSignatures } from '@hs/federation-sdk'; -import { Message } from '@rocket.chat/core-services'; +import { Message, FederationMatrix } from '@rocket.chat/core-services'; import type { Emitter } from '@rocket.chat/emitter'; import { Logger } from '@rocket.chat/logger'; -import { Users, Messages, Rooms } from '@rocket.chat/models'; +import { Users, Messages } from '@rocket.chat/models'; // Rooms import emojione from 'emojione'; const logger = new Logger('federation-matrix:reaction'); @@ -10,26 +10,10 @@ const logger = new Logger('federation-matrix:reaction'); export function reaction(emitter: Emitter) { emitter.on('homeserver.matrix.reaction', async (data) => { try { - const relatesTo = data.content?.['m.relates_to']; - if (!relatesTo || relatesTo.rel_type !== 'm.annotation') { - logger.debug('Invalid reaction event structure'); - return; - } - - const targetEventId = relatesTo.event_id; - const reactionKey = relatesTo.key; + const isSetReaction = data.content?.['m.relates_to']; - if (!targetEventId || !reactionKey) { - logger.debug('Missing target event ID or reaction key'); - return; - } - - const rcMessage = await Messages.findOneByFederationId(targetEventId); - if (!rcMessage) { - logger.debug(`No RC message mapping found for Matrix event ${targetEventId}`); - return; - } - const rcMessageId = rcMessage._id; + const reactionTargetEventId = isSetReaction?.event_id; + const reactionKey = isSetReaction?.key; const [userPart, domain] = data.sender.split(':'); if (!userPart || !domain) { @@ -37,28 +21,26 @@ export function reaction(emitter: Emitter) { return; } - const username = userPart.substring(1); const user = await Users.findOneByUsername(data.sender); if (!user) { + logger.error(`No RC user mapping found for Matrix event ${reactionTargetEventId} ${data.sender}`); return; } - const reactionEmoji = emojione.toShort(reactionKey); - - const message = await Messages.findOneById(rcMessageId); - if (!message) { - logger.error('Message not found when trying to set reaction'); + if (!isSetReaction) { + logger.debug(`No relates_to content in reaction event`); return; } - const room = await Rooms.findOneById(message.rid); - if (!room) { - logger.error('Room not found when trying to set reaction'); + const rcMessage = await Messages.findOneByFederationId(reactionTargetEventId); + if (!rcMessage) { + logger.debug(`No RC message mapping found for Matrix event ${reactionTargetEventId}`); return; } - await Message.reactToMessage(user._id, reactionEmoji, rcMessageId, true); - await Messages.setFederationReactionEventId(user.username || username, rcMessageId, reactionEmoji, data.event_id); + const reactionEmoji = emojione.toShort(reactionKey); + await Message.reactToMessage(user._id, reactionEmoji, rcMessage._id, true); + await Messages.setFederationReactionEventId(data.sender, rcMessage._id, reactionEmoji, data.event_id); } catch (error) { logger.error('Failed to process Matrix reaction:', error); } @@ -72,40 +54,36 @@ export function reaction(emitter: Emitter) { return; } - const messageWithReaction = await Messages.findOneByFederationIdAndUsernameOnReactions( - redactedEventId, - data.sender.split(':')[0].substring(1), - ); - if (!messageWithReaction) { - logger.debug(`No message found with reaction event ID ${redactedEventId}`); + const reactionEvent = await FederationMatrix.getEventById(redactedEventId); + if (!reactionEvent || reactionEvent.type !== 'm.reaction') { + logger.debug(`Event ${redactedEventId} is not a reaction event`); return; } - let redactedReaction: string | null = null; - if (messageWithReaction.reactions) { - for (const [reaction, reactionData] of Object.entries(messageWithReaction.reactions)) { - if (reactionData.federationReactionEventIds?.[redactedEventId]) { - redactedReaction = reaction; - break; - } - } + const reactionContent = reactionEvent.content?.['m.relates_to']; + if (!reactionContent) { + logger.debug('No relates_to content in reaction event'); + return; } - if (!redactedReaction) { + const targetMessageEventId = reactionContent.event_id; + const reactionKey = reactionContent.key; + + const rcMessage = await Messages.findOneByFederationId(targetMessageEventId); + if (!rcMessage) { + logger.debug(`No RC message found for event ${targetMessageEventId}`); return; } - const messageId = messageWithReaction._id; - const reaction = redactedReaction; - const user = await Users.findOneByUsername(data.sender); if (!user) { - logger.debug('User not found for reaction redaction'); + logger.debug(`User not found: ${data.sender}`); return; } - await Message.reactToMessage(user._id, reaction, messageId, false); - await Messages.unsetFederationReactionEventId(redactedEventId, messageId, reaction); + const reactionEmoji = emojione.toShort(reactionKey); + await Message.reactToMessage(user._id, reactionEmoji, rcMessage._id, false); + await Messages.unsetFederationReactionEventId(redactedEventId, rcMessage._id, reactionEmoji); } catch (error) { logger.error('Failed to process Matrix reaction redaction:', error); } diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index 8817cf45fdfec..05e37f2c976c2 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -18,5 +18,6 @@ export interface IFederationMatrixService { createRoom(room: IRoom, owner: IUser, members: string[]): Promise; sendMessage(message: IMessage, room: IRoom, user: IUser): Promise; sendReaction(messageId: string, reaction: string, user: IUser): Promise; - removeReaction(messageId: string, reaction: string, user: IUser): Promise; + removeReaction(messageId: string, reaction: string, user: IUser, oldMessage: IMessage): Promise; + getEventById(eventId: string): Promise; } From 422235d361bb6f946eb5497813bd3e3b27515709 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 30 Jul 2025 20:48:22 -0300 Subject: [PATCH 15/18] fix: fixes hook loop on reactions --- apps/meteor/ee/server/hooks/federation/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index 8c3756d70cd1b..afbc8de7fe282 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -6,6 +6,10 @@ import { callbacks } from '../../../../lib/callbacks'; callbacks.add( 'afterSetReaction', async (message: IMessage, params: { user: IUser; reaction: string }): Promise => { + // Don't federate reactions that came from Matrix + if (params.user.username?.includes(':')) { + return; + } await FederationMatrix.sendReaction(message._id, params.reaction, params.user); }, callbacks.priority.HIGH, @@ -15,6 +19,10 @@ callbacks.add( callbacks.add( 'afterUnsetReaction', async (_message: IMessage, params: { user: IUser; reaction: string; oldMessage: IMessage }): Promise => { + // Don't federate reactions that came from Matrix + if (params.user.username?.includes(':')) { + return; + } await FederationMatrix.removeReaction(params.oldMessage._id, params.reaction, params.user, params.oldMessage); }, callbacks.priority.HIGH, From 13bc42eaa7e29d6a3faccb63072a1bd7dd650d2a Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 30 Jul 2025 11:30:03 -0300 Subject: [PATCH 16/18] feat: adds user kick and leave support --- .../ee/server/hooks/federation/index.ts | 20 +++- .../federation-matrix/src/FederationMatrix.ts | 90 +++++++++++++++++- .../federation-matrix/src/events/index.ts | 2 + .../federation-matrix/src/events/member.ts | 92 +++++++++++++++++++ .../src/types/IFederationMatrixService.ts | 2 + 5 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 ee/packages/federation-matrix/src/events/member.ts diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index afbc8de7fe282..61455885c862f 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -1,5 +1,5 @@ import { FederationMatrix } from '@rocket.chat/core-services'; -import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { callbacks } from '../../../../lib/callbacks'; @@ -28,3 +28,21 @@ callbacks.add( callbacks.priority.HIGH, 'federation-matrix-after-unset-reaction', ); + +callbacks.add( + 'afterLeaveRoom', + async (user: IUser, room: IRoom): Promise => { + await FederationMatrix.leaveRoom(room._id, user); + }, + callbacks.priority.HIGH, + 'federation-matrix-after-leave-room', +); + +callbacks.add( + 'afterRemoveFromRoom', + async (data: { removedUser: IUser; userWhoRemoved: IUser }, room: IRoom): Promise => { + await FederationMatrix.kickUser(room._id, data.removedUser, data.userWhoRemoved); + }, + callbacks.priority.HIGH, + 'federation-matrix-after-remove-from-room', +); diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 90d7aa3ccd116..3645641a504bd 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -7,7 +7,7 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Router } from '@rocket.chat/http-router'; import { Logger } from '@rocket.chat/logger'; -import { MatrixBridgedUser, MatrixBridgedRoom, Users, Messages } from '@rocket.chat/models'; +import { MatrixBridgedUser, MatrixBridgedRoom, Users, Messages, Rooms } from '@rocket.chat/models'; import emojione from 'emojione'; import { getWellKnownRoutes } from './api/.well-known/server'; @@ -301,4 +301,92 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS throw error; } } + + async leaveRoom(roomId: string, user: IUser): Promise { + try { + const room = await Rooms.findOneById(roomId); + if (!room?.federated) { + this.logger.debug(`Room ${roomId} is not federated, skipping leave operation`); + return; + } + + const matrixRoomId = await MatrixBridgedRoom.getExternalRoomId(roomId); + if (!matrixRoomId) { + this.logger.warn(`No Matrix room mapping found for federated room ${roomId}, skipping leave`); + return; + } + + const matrixDomain = await this.getMatrixDomain(); + const matrixUserId = `@${user.username}:${matrixDomain}`; + const existingMatrixUserId = await MatrixBridgedUser.getExternalUserIdByLocalUserId(user._id); + + if (!existingMatrixUserId) { + // User might not have been bridged yet if they never sent a message + await MatrixBridgedUser.createOrUpdateByLocalId(user._id, matrixUserId, true, matrixDomain); + } + + if (!this.homeserverServices) { + this.logger.warn('Homeserver services not available, skipping room leave'); + return; + } + + const actualMatrixUserId = existingMatrixUserId || matrixUserId; + + await this.homeserverServices.room.leaveRoom(matrixRoomId, actualMatrixUserId); + + this.logger.info(`User ${user.username} left Matrix room ${matrixRoomId} successfully`); + } catch (error) { + this.logger.error('Failed to leave room in Matrix:', error); + throw error; + } + } + + async kickUser(roomId: string, removedUser: IUser, userWhoRemoved: IUser): Promise { + try { + const room = await Rooms.findOneById(roomId); + if (!room?.federated) { + this.logger.debug(`Room ${roomId} is not federated, skipping kick operation`); + return; + } + + const matrixRoomId = await MatrixBridgedRoom.getExternalRoomId(roomId); + if (!matrixRoomId) { + this.logger.warn(`No Matrix room mapping found for federated room ${roomId}, skipping kick`); + return; + } + + const matrixDomain = await this.getMatrixDomain(); + + const kickedMatrixUserId = `@${removedUser.username}:${matrixDomain}`; + const existingKickedMatrixUserId = await MatrixBridgedUser.getExternalUserIdByLocalUserId(removedUser._id); + if (!existingKickedMatrixUserId) { + await MatrixBridgedUser.createOrUpdateByLocalId(removedUser._id, kickedMatrixUserId, true, matrixDomain); + } + const actualKickedMatrixUserId = existingKickedMatrixUserId || kickedMatrixUserId; + + const senderMatrixUserId = `@${userWhoRemoved.username}:${matrixDomain}`; + const existingSenderMatrixUserId = await MatrixBridgedUser.getExternalUserIdByLocalUserId(userWhoRemoved._id); + if (!existingSenderMatrixUserId) { + await MatrixBridgedUser.createOrUpdateByLocalId(userWhoRemoved._id, senderMatrixUserId, true, matrixDomain); + } + const actualSenderMatrixUserId = existingSenderMatrixUserId || senderMatrixUserId; + + if (!this.homeserverServices) { + this.logger.warn('Homeserver services not available, skipping user kick'); + return; + } + + await this.homeserverServices.room.kickUser( + matrixRoomId, + actualKickedMatrixUserId, + actualSenderMatrixUserId, + `Kicked by ${userWhoRemoved.username}`, + ); + + this.logger.info(`User ${removedUser.username} was kicked from Matrix room ${matrixRoomId} by ${userWhoRemoved.username}`); + } catch (error) { + this.logger.error('Failed to kick user from Matrix room:', error); + throw error; + } + } } diff --git a/ee/packages/federation-matrix/src/events/index.ts b/ee/packages/federation-matrix/src/events/index.ts index f65b8c7053bd9..3c5910aff0767 100644 --- a/ee/packages/federation-matrix/src/events/index.ts +++ b/ee/packages/federation-matrix/src/events/index.ts @@ -2,6 +2,7 @@ import type { HomeserverEventSignatures } from '@hs/federation-sdk'; import type { Emitter } from '@rocket.chat/emitter'; import { invite } from './invite'; +import { member } from './member'; import { message } from './message'; import { ping } from './ping'; import { reaction } from './reaction'; @@ -11,4 +12,5 @@ export function registerEvents(emitter: Emitter) { message(emitter); invite(emitter); reaction(emitter); + member(emitter); } diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts new file mode 100644 index 0000000000000..e6a5aa7baae42 --- /dev/null +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -0,0 +1,92 @@ +import type { HomeserverEventSignatures } from '@hs/federation-sdk'; +import { Room } from '@rocket.chat/core-services'; +import type { Emitter } from '@rocket.chat/emitter'; +import { Logger } from '@rocket.chat/logger'; +import { MatrixBridgedRoom, MatrixBridgedUser, Users } from '@rocket.chat/models'; + +const logger = new Logger('federation-matrix:member'); + +export function member(emitter: Emitter) { + emitter.on('homeserver.matrix.leave', async (data) => { + try { + logger.info('Received Matrix leave event:', { + event_id: data.event_id, + room_id: data.room_id, + user_id: data.user_id, + }); + + const room = await MatrixBridgedRoom.findOne({ mri: data.room_id }); + if (!room) { + logger.warn(`No bridged room found for Matrix room_id: ${data.room_id}`); + return; + } + + const matrixUser = await MatrixBridgedUser.findOne({ mui: data.user_id }); + if (!matrixUser) { + logger.warn(`No bridged user found for Matrix user_id: ${data.user_id}`); + return; + } + + const user = await Users.findOneById(matrixUser.uid); + if (!user) { + logger.error(`No Rocket.Chat user found for bridged user: ${matrixUser.uid}`); + return; + } + + await Room.removeUserFromRoom(room.rid, user); + + logger.info(`User ${user.username} left room ${room.rid} via Matrix federation`); + } catch (error) { + logger.error('Failed to process Matrix leave event:', error); + } + }); + + emitter.on('homeserver.matrix.kick', async (data) => { + try { + logger.info('Received Matrix kick event:', { + event_id: data.event_id, + room_id: data.room_id, + kicked_user_id: data.kicked_user_id, + kicked_by: data.kicked_by, + reason: data.reason, + }); + + const room = await MatrixBridgedRoom.findOne({ mri: data.room_id }); + if (!room) { + logger.warn(`No bridged room found for Matrix room_id: ${data.room_id}`); + return; + } + + const kickedMatrixUser = await MatrixBridgedUser.findOne({ mui: data.kicked_user_id }); + if (!kickedMatrixUser) { + logger.warn(`No bridged user found for kicked Matrix user_id: ${data.kicked_user_id}`); + return; + } + + const kickedUser = await Users.findOneById(kickedMatrixUser.uid); + if (!kickedUser) { + logger.error(`No Rocket.Chat user found for kicked bridged user: ${kickedMatrixUser.uid}`); + return; + } + + const kickerMatrixUser = await MatrixBridgedUser.findOne({ mui: data.kicked_by }); + let kickerUsername = 'Matrix User'; + if (kickerMatrixUser) { + const kickerUser = await Users.findOneById(kickerMatrixUser.uid); + if (kickerUser) { + kickerUsername = kickerUser.username || 'Matrix User'; + } + } + + const kickerUser = kickerMatrixUser ? await Users.findOneById(kickerMatrixUser.uid) : null; + await Room.removeUserFromRoom(room.rid, kickedUser, { + byUser: kickerUser || { _id: 'matrix.federation', username: kickerUsername }, + }); + + const reasonText = data.reason ? ` Reason: ${data.reason}` : ''; + logger.info(`User ${kickedUser.username} was kicked from room ${room.rid} by ${kickerUsername} via Matrix federation.${reasonText}`); + } catch (error) { + logger.error('Failed to process Matrix kick event:', error); + } + }); +} diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index 05e37f2c976c2..89df53ca9fb6e 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -20,4 +20,6 @@ export interface IFederationMatrixService { sendReaction(messageId: string, reaction: string, user: IUser): Promise; removeReaction(messageId: string, reaction: string, user: IUser, oldMessage: IMessage): Promise; getEventById(eventId: string): Promise; + leaveRoom(roomId: string, user: IUser): Promise; + kickUser(roomId: string, removedUser: IUser, userWhoRemoved: IUser): Promise; } From 4fdcb8874ad4661611f73d34a55f0a65c9705d55 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 31 Jul 2025 10:04:01 -0300 Subject: [PATCH 17/18] mitigates events looping and checks matrix version --- .../ee/server/hooks/federation/index.ts | 16 +++- apps/meteor/server/services/room/service.ts | 17 +++- .../federation-matrix/src/events/member.ts | 89 ++++++------------- 3 files changed, 56 insertions(+), 66 deletions(-) diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index 61455885c862f..d1c39bd702de9 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -2,6 +2,8 @@ import { FederationMatrix } from '@rocket.chat/core-services'; import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { callbacks } from '../../../../lib/callbacks'; +import { afterLeaveRoomCallback } from '../../../../lib/callbacks/afterLeaveRoomCallback'; +import { afterRemoveFromRoomCallback } from '../../../../lib/callbacks/afterRemoveFromRoomCallback'; callbacks.add( 'afterSetReaction', @@ -29,18 +31,24 @@ callbacks.add( 'federation-matrix-after-unset-reaction', ); -callbacks.add( - 'afterLeaveRoom', +afterLeaveRoomCallback.add( async (user: IUser, room: IRoom): Promise => { + if (!room.federated) { + return; + } + await FederationMatrix.leaveRoom(room._id, user); }, callbacks.priority.HIGH, 'federation-matrix-after-leave-room', ); -callbacks.add( - 'afterRemoveFromRoom', +afterRemoveFromRoomCallback.add( async (data: { removedUser: IUser; userWhoRemoved: IUser }, room: IRoom): Promise => { + if (!room.federated) { + return; + } + await FederationMatrix.kickUser(room._id, data.removedUser, data.userWhoRemoved); }, callbacks.priority.HIGH, diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index cc83ffe66e88a..8b54a8aaae056 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -12,6 +12,7 @@ import { getValidRoomName } from '../../../app/utils/server/lib/getValidRoomName import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { createDirectMessage } from '../../methods/createDirectMessage'; +import { getFederationVersion } from '../federation/utils'; export class RoomService extends ServiceClassInternal implements IRoomService { protected name = 'room'; @@ -128,11 +129,23 @@ export class RoomService extends ServiceClassInternal implements IRoomService { } async beforeLeave(room: IRoom): Promise { - FederationActions.blockIfRoomFederatedButServiceNotReady(room); + const federationVersion = getFederationVersion(); + + // If its from the deprecated federation, we need to block if the service is not ready + // If its from the new federation, do nothing at this point cause removals will be handled by callbacks + if (federationVersion === 'matrix' && room.federated === true) { + FederationActions.blockIfRoomFederatedButServiceNotReady(room); + } } async beforeUserRemoved(room: IRoom): Promise { - FederationActions.blockIfRoomFederatedButServiceNotReady(room); + const federationVersion = getFederationVersion(); + + // If its from the deprecated federation, we need to block if the service is not ready + // If its from the new federation, do nothing at this point cause removals will be handled by callbacks + if (federationVersion === 'matrix' && room.federated === true) { + FederationActions.blockIfRoomFederatedButServiceNotReady(room); + } } async beforeNameChange(room: IRoom): Promise { diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index e6a5aa7baae42..91f930dd77ab0 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -7,86 +7,55 @@ import { MatrixBridgedRoom, MatrixBridgedUser, Users } from '@rocket.chat/models const logger = new Logger('federation-matrix:member'); export function member(emitter: Emitter) { - emitter.on('homeserver.matrix.leave', async (data) => { + emitter.on('homeserver.matrix.membership', async (data) => { try { - logger.info('Received Matrix leave event:', { - event_id: data.event_id, - room_id: data.room_id, - user_id: data.user_id, - }); - - const room = await MatrixBridgedRoom.findOne({ mri: data.room_id }); - if (!room) { - logger.warn(`No bridged room found for Matrix room_id: ${data.room_id}`); - return; - } - - const matrixUser = await MatrixBridgedUser.findOne({ mui: data.user_id }); - if (!matrixUser) { - logger.warn(`No bridged user found for Matrix user_id: ${data.user_id}`); - return; - } - - const user = await Users.findOneById(matrixUser.uid); - if (!user) { - logger.error(`No Rocket.Chat user found for bridged user: ${matrixUser.uid}`); + // Only handle leave events (including kicks) + if (data.content.membership !== 'leave') { + logger.debug(`Ignoring membership event with membership: ${data.content.membership}`); return; } - await Room.removeUserFromRoom(room.rid, user); - - logger.info(`User ${user.username} left room ${room.rid} via Matrix federation`); - } catch (error) { - logger.error('Failed to process Matrix leave event:', error); - } - }); - - emitter.on('homeserver.matrix.kick', async (data) => { - try { - logger.info('Received Matrix kick event:', { - event_id: data.event_id, - room_id: data.room_id, - kicked_user_id: data.kicked_user_id, - kicked_by: data.kicked_by, - reason: data.reason, - }); - const room = await MatrixBridgedRoom.findOne({ mri: data.room_id }); if (!room) { logger.warn(`No bridged room found for Matrix room_id: ${data.room_id}`); return; } - const kickedMatrixUser = await MatrixBridgedUser.findOne({ mui: data.kicked_user_id }); - if (!kickedMatrixUser) { - logger.warn(`No bridged user found for kicked Matrix user_id: ${data.kicked_user_id}`); + // state_key is the user affected by the membership change + const affectedMatrixUser = await MatrixBridgedUser.findOne({ mui: data.state_key }); + if (!affectedMatrixUser) { + logger.warn(`No bridged user found for Matrix user_id: ${data.state_key}`); return; } - const kickedUser = await Users.findOneById(kickedMatrixUser.uid); - if (!kickedUser) { - logger.error(`No Rocket.Chat user found for kicked bridged user: ${kickedMatrixUser.uid}`); + const affectedUser = await Users.findOneById(affectedMatrixUser.uid); + if (!affectedUser) { + logger.error(`No Rocket.Chat user found for bridged user: ${affectedMatrixUser.uid}`); return; } - const kickerMatrixUser = await MatrixBridgedUser.findOne({ mui: data.kicked_by }); - let kickerUsername = 'Matrix User'; - if (kickerMatrixUser) { - const kickerUser = await Users.findOneById(kickerMatrixUser.uid); - if (kickerUser) { - kickerUsername = kickerUser.username || 'Matrix User'; + // Check if this is a kick (sender != state_key) or voluntary leave (sender == state_key) + if (data.sender === data.state_key) { + // Voluntary leave + await Room.removeUserFromRoom(room.rid, affectedUser); + logger.info(`User ${affectedUser.username} left room ${room.rid} via Matrix federation`); + } else { + // Kick - find who kicked + const kickerMatrixUser = await MatrixBridgedUser.findOne({ mui: data.sender }); + let kickerUser = null; + if (kickerMatrixUser) { + kickerUser = await Users.findOneById(kickerMatrixUser.uid); } - } - const kickerUser = kickerMatrixUser ? await Users.findOneById(kickerMatrixUser.uid) : null; - await Room.removeUserFromRoom(room.rid, kickedUser, { - byUser: kickerUser || { _id: 'matrix.federation', username: kickerUsername }, - }); + await Room.removeUserFromRoom(room.rid, affectedUser, { + byUser: kickerUser || { _id: 'matrix.federation', username: 'Matrix User' }, + }); - const reasonText = data.reason ? ` Reason: ${data.reason}` : ''; - logger.info(`User ${kickedUser.username} was kicked from room ${room.rid} by ${kickerUsername} via Matrix federation.${reasonText}`); + const reasonText = data.content.reason ? ` Reason: ${data.content.reason}` : ''; + logger.info(`User ${affectedUser.username} was kicked from room ${room.rid} by ${data.sender} via Matrix federation.${reasonText}`); + } } catch (error) { - logger.error('Failed to process Matrix kick event:', error); + logger.error('Failed to process Matrix membership event:', error); } }); } From e7299ae0e1cb4b09f4bf851a798f4bc9ee545b78 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Sun, 3 Aug 2025 18:47:22 -0300 Subject: [PATCH 18/18] adds missing Rooms model import from conflict solving --- 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 0b58e90fd181b..4cda0fa4acc0f 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -7,7 +7,7 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Router } from '@rocket.chat/http-router'; import { Logger } from '@rocket.chat/logger'; -import { MatrixBridgedUser, MatrixBridgedRoom, Users, Subscriptions, Messages } from '@rocket.chat/models'; +import { MatrixBridgedUser, MatrixBridgedRoom, Users, Subscriptions, Messages, Rooms } from '@rocket.chat/models'; import emojione from 'emojione'; import { getWellKnownRoutes } from './api/.well-known/server';