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/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index 96f6c9cd06aae..aed78ee8dbca8 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -1,7 +1,9 @@ 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'; +import { afterLeaveRoomCallback } from '../../../../lib/callbacks/afterLeaveRoomCallback'; +import { afterRemoveFromRoomCallback } from '../../../../lib/callbacks/afterRemoveFromRoomCallback'; callbacks.add( 'native-federation.onAddUsersToRoom', @@ -42,3 +44,27 @@ callbacks.add( callbacks.priority.HIGH, 'federation-matrix-after-unset-reaction', ); + +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', +); + +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, + 'federation-matrix-after-remove-from-room', +); diff --git a/apps/meteor/ee/server/index.ts b/apps/meteor/ee/server/index.ts index 4a5681de30eec..2ae1821824806 100644 --- a/apps/meteor/ee/server/index.ts +++ b/apps/meteor/ee/server/index.ts @@ -13,6 +13,7 @@ import './configuration/index'; import './local-services/ldap/service'; import './methods/getReadReceipts'; import './patches'; +import './hooks/federation'; import { License } from '@rocket.chat/license'; export * from './apps/startup'; 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/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 0947f820465d3..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'; @@ -347,4 +347,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..91f930dd77ab0 --- /dev/null +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -0,0 +1,61 @@ +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.membership', async (data) => { + try { + // Only handle leave events (including kicks) + if (data.content.membership !== 'leave') { + logger.debug(`Ignoring membership event with membership: ${data.content.membership}`); + return; + } + + 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; + } + + // 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 affectedUser = await Users.findOneById(affectedMatrixUser.uid); + if (!affectedUser) { + logger.error(`No Rocket.Chat user found for bridged user: ${affectedMatrixUser.uid}`); + return; + } + + // 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); + } + + await Room.removeUserFromRoom(room.rid, affectedUser, { + byUser: kickerUser || { _id: 'matrix.federation', username: 'Matrix User' }, + }); + + 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 membership 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; }