diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 88558dc9a62df..eb9b261d634f3 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -1,4 +1,4 @@ -import { Media, MeteorError, Team } from '@rocket.chat/core-services'; +import { FederationMatrix, Media, MeteorError, Team } from '@rocket.chat/core-services'; import type { IRoom, IUpload } from '@rocket.chat/core-typings'; import { isPrivateRoom, isPublicRoom } from '@rocket.chat/core-typings'; import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/models'; @@ -15,6 +15,9 @@ import { isRoomsMembersOrderedByRoleProps, isRoomsChangeArchivationStateProps, isRoomsHideProps, + isRoomsInviteProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; @@ -1073,7 +1076,37 @@ export const roomEndpoints = API.v1.get( }, ); -type RoomEndpoints = ExtractRoutesFromAPI; +const roomInviteEndpoints = API.v1.post( + 'rooms.invite', + { + authRequired: true, + body: isRoomsInviteProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { roomId, action } = this.bodyParams; + + try { + await FederationMatrix.handleInvite(roomId, this.userId, action); + return API.v1.success(); + } catch (error) { + return API.v1.failure({ error: `Failed to handle invite: ${error instanceof Error ? error.message : String(error)}` }); + } + }, +); + +type RoomEndpoints = ExtractRoutesFromAPI & ExtractRoutesFromAPI; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface diff --git a/apps/meteor/app/lib/lib/MessageTypes.ts b/apps/meteor/app/lib/lib/MessageTypes.ts index 2d83584da5c08..8205200615ff7 100644 --- a/apps/meteor/app/lib/lib/MessageTypes.ts +++ b/apps/meteor/app/lib/lib/MessageTypes.ts @@ -29,6 +29,14 @@ export const MessageTypesValues: Array<{ key: MessageTypesValuesType; i18nLabel: key: 'au', // added user i18nLabel: 'Message_HideType_au', }, + { + key: 'ui', // user invited to room + i18nLabel: 'Message_HideType_ui', + }, + { + key: 'uir', // user rejected invitation to room + i18nLabel: 'Message_HideType_uir', + }, { key: 'added-user-to-team', i18nLabel: 'Message_HideType_added_user_to_team', diff --git a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts new file mode 100644 index 0000000000000..32370ab8da01f --- /dev/null +++ b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts @@ -0,0 +1,61 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; +import { Message } from '@rocket.chat/core-services'; +import type { IUser, IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { Subscriptions, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { callbacks } from '../../../../lib/callbacks'; +import { notifyOnSubscriptionChangedById } from '../lib/notifyListener'; + +/** + * Accepts a room invite when triggered by internal events such as federation + * or third-party callbacks. Performs the necessary database updates and triggers + * safe callbacks, ensuring no propagation loops are created during external event + * processing. + */ + +// TODO this funcion is pretty much the same as the one in addUserToRoom.ts, we should probably +// unify them at some point +export const performAcceptRoomInvite = async ( + room: IRoom, + subscription: ISubscription, + user: IUser & { username: string }, +): Promise => { + if (subscription.status !== 'INVITED' || !subscription.inviter) { + throw new Meteor.Error('error-not-invited', `User was not invited to this room ${subscription.status}`); + } + const inviter = await Users.findOneById(subscription.inviter._id); + + await callbacks.run('beforeJoinRoom', user, room); + + await callbacks.run('beforeAddedToRoom', { user, inviter }, room); + + try { + await Apps.self?.triggerEvent(AppEvents.IPreRoomUserJoined, room, user, inviter); + } catch (error: any) { + if (error.name === AppsEngineException.name) { + throw new Meteor.Error('error-app-prevented', error.message); + } + + throw error; + } + + await Subscriptions.acceptInvitationById(subscription._id); + + void notifyOnSubscriptionChangedById(subscription._id, 'updated'); + + await Message.saveSystemMessage('uj', room._id, user.username, user); + + if (room.t === 'c' || room.t === 'p') { + process.nextTick(async () => { + // Add a new event, with an optional inviter + await callbacks.run('afterAddedToRoom', { user, inviter }, room); + + // Keep the current event + await callbacks.run('afterJoinRoom', user, room); + + void Apps.self?.triggerEvent(AppEvents.IPostRoomUserJoined, room, user, inviter); + }); + } +}; diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 881afb11812c7..75667c70f4df9 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -1,18 +1,16 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; -import { Message, Team } from '@rocket.chat/core-services'; -import { type IUser } from '@rocket.chat/core-typings'; +import { Team, Room } from '@rocket.chat/core-services'; +import { isRoomNativeFederated, type IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; import { callbacks } from '../../../../lib/callbacks'; import { beforeAddUserToRoom } from '../../../../lib/callbacks/beforeAddUserToRoom'; -import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; import { settings } from '../../../settings/server'; -import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; -import { notifyOnRoomChangedById, notifyOnSubscriptionChangedById } from '../lib/notifyListener'; +import { notifyOnRoomChangedById } from '../lib/notifyListener'; /** * This function adds user to the given room. @@ -49,6 +47,12 @@ export const addUserToRoom = async ( throw new Meteor.Error('user-not-found'); } + // Check if user is already in room + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userToBeAdded._id); + if (subscription) { + return; + } + if ( !(await roomDirectives.allowMemberAction(room, RoomMemberActions.JOIN, userToBeAdded._id)) && !(await roomDirectives.allowMemberAction(room, RoomMemberActions.INVITE, userToBeAdded._id)) @@ -66,12 +70,6 @@ export const addUserToRoom = async ( await callbacks.run('beforeAddedToRoom', { user: userToBeAdded, inviter }); - // Check if user is already in room - const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userToBeAdded._id); - if (subscription || !userToBeAdded) { - return; - } - try { await Apps.self?.triggerEvent(AppEvents.IPreRoomUserJoined, room, userToBeAdded, inviter); } catch (error: any) { @@ -81,6 +79,12 @@ export const addUserToRoom = async ( throw error; } + + // for federation rooms we stop here since everything else will be handled by the federation invite flow + if (isRoomNativeFederated(room)) { + return; + } + // TODO: are we calling this twice? if (room.t === 'c' || room.t === 'p' || room.t === 'l') { // Add a new event, with an optional inviter @@ -90,50 +94,16 @@ export const addUserToRoom = async ( await callbacks.run('beforeJoinRoom', userToBeAdded, room); } - const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(userToBeAdded); - - const { insertedId } = await Subscriptions.createWithRoomAndUser(room, userToBeAdded as IUser, { + await Room.createUserSubscription({ + room, ts: now, - open: !createAsHidden, - alert: createAsHidden ? false : !skipAlertSound, - unread: 1, - userMentions: 1, - groupMentions: 0, - ...autoTranslateConfig, - ...getDefaultSubscriptionPref(userToBeAdded as IUser), + inviter, + userToBeAdded, + createAsHidden, + skipAlertSound, + skipSystemMessage, }); - if (insertedId) { - void notifyOnSubscriptionChangedById(insertedId, 'inserted'); - } - - if (!userToBeAdded.username) { - throw new Meteor.Error('error-invalid-user', 'Cannot add an user to a room without a username'); - } - - if (!skipSystemMessage) { - if (inviter) { - const extraData = { - ts: now, - u: { - _id: inviter._id, - username: inviter.username, - }, - }; - if (room.teamMain) { - await Message.saveSystemMessage('added-user-to-team', rid, userToBeAdded.username, userToBeAdded, extraData); - } else { - await Message.saveSystemMessage('au', rid, userToBeAdded.username, userToBeAdded, extraData); - } - } else if (room.prid) { - await Message.saveSystemMessage('ut', rid, userToBeAdded.username, userToBeAdded, { ts: now }); - } else if (room.teamMain) { - await Message.saveSystemMessage('ujt', rid, userToBeAdded.username, userToBeAdded, { ts: now }); - } else { - await Message.saveSystemMessage('uj', rid, userToBeAdded.username, userToBeAdded, { ts: now }); - } - } - if (room.t === 'c' || room.t === 'p') { process.nextTick(async () => { // Add a new event, with an optional inviter diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index 7f26a78d85089..b6ff87a87b2b1 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -44,9 +44,8 @@ export async function createDirectRoom( members: IUser[] | string[], roomExtraData: Partial = {}, options: { - creator?: string; + creator?: IUser['_id']; subscriptionExtra?: ISubscriptionExtraData; - federatedRoomId?: string; }, ): Promise { const maxUsers = settings.get('DirectMesssage_maxUsers') || 1; @@ -155,8 +154,29 @@ export async function createDirectRoom( { projection: { 'username': 1, 'settings.preferences': 1 } }, ).toArray(); + const creatorUser = roomMembers.find((member) => member._id === options?.creator); + if (roomExtraData.federated && !creatorUser) { + throw new Meteor.Error('error-creator-not-in-room', 'The creator user must be part of the direct room'); + } + for await (const member of membersWithPreferences) { const otherMembers = sortedMembers.filter(({ _id }) => _id !== member._id); + + const subscriptionStatus: Partial = + roomExtraData.federated && options.creator !== member._id && creatorUser + ? { + status: 'INVITED', + inviter: { + _id: creatorUser._id, + username: creatorUser.username, + name: creatorUser.name, + }, + open: true, + unread: 1, + userMentions: 1, + } + : {}; + const { modifiedCount, upsertedCount } = await Subscriptions.updateOne( { rid, 'u._id': member._id }, { @@ -164,6 +184,7 @@ export async function createDirectRoom( $setOnInsert: generateSubscription(getFname(otherMembers), getName(otherMembers), member, { ...options?.subscriptionExtra, ...(options?.creator !== member._id && { open: members.length > 2 }), + ...subscriptionStatus, }), }, { upsert: true }, @@ -181,7 +202,6 @@ export async function createDirectRoom( await callbacks.run('afterCreateDirectRoom', insertedRoom, { members: roomMembers, creatorId: options?.creator, - mrid: options?.federatedRoomId, }); void Apps.self?.triggerEvent(AppEvents.IPostRoomCreate, insertedRoom); diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 3d9e3b1810570..c6a5a9b88742a 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -1,9 +1,8 @@ import { AppEvents, Apps } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; -import { Message, Team } from '@rocket.chat/core-services'; +import { FederationMatrix, Message, Room, Team } from '@rocket.chat/core-services'; import type { ICreateRoomParams, ISubscriptionExtraData } from '@rocket.chat/core-services'; import type { ICreatedRoom, IUser, IRoom, RoomType } from '@rocket.chat/core-typings'; -import { isRoomNativeFederated } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -59,6 +58,27 @@ async function createUsersSubscriptions({ await notifyOnRoomChanged(room, 'inserted'); } + // Invite federated members to the room SYNCRONOUSLY, + // since we do not use to invite lots of users at once, this is acceptable. + const membersToInvite = members.filter((m) => m !== owner.username); + + await FederationMatrix.ensureFederatedUsersExistLocally(membersToInvite); + + for await (const memberUsername of membersToInvite) { + const member = await Users.findOneByUsername(memberUsername); + if (!member) { + throw new Error('Federated user not found locally'); + } + + await Room.createUserSubscription({ + ts: new Date(), + room, + userToBeAdded: member, + inviter: owner, + status: 'INVITED', + }); + } + return; } @@ -135,7 +155,7 @@ export const createRoom = async ( rid: string; } > => { - const { teamId, ...optionalExtraData } = roomExtraData || ({} as IRoom); + const { teamId, ...extraData } = roomExtraData || ({} as IRoom); // TODO: use a shared helper to check whether a user is federated const hasFederatedMembers = members.some((member) => { @@ -146,23 +166,12 @@ export const createRoom = async ( }); // Prevent adding federated users to rooms that are not marked as federated explicitly - if (hasFederatedMembers && optionalExtraData.federated !== true) { + if (hasFederatedMembers && extraData.federated !== true) { throw new Meteor.Error('error-federated-users-in-non-federated-rooms', 'Cannot add federated users to non-federated rooms', { method: 'createRoom', }); } - const extraData = { - ...optionalExtraData, - ...((hasFederatedMembers || optionalExtraData.federated) && { - federated: true, - federation: { - version: 1, - // TODO we should be able to provide all values from here, currently we update on callback afterCreateRoom - }, - }), - }; - await prepareCreateRoomCallback.run({ type, // name, @@ -173,7 +182,8 @@ export const createRoom = async ( // options, }); - const shouldBeHandledByFederation = isRoomNativeFederated(extraData); + const shouldBeHandledByFederation = extraData.federated === true; + if (shouldBeHandledByFederation && owner && !(await hasPermissionAsync(owner._id, 'access-federation'))) { throw new Meteor.Error('error-not-authorized-federation', 'Not authorized to access federation', { method: 'createRoom', @@ -181,7 +191,7 @@ export const createRoom = async ( } if (type === 'd') { - return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?.username }); + return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?._id }); } if (!onlyUsernames(members)) { @@ -286,6 +296,13 @@ export const createRoom = async ( void notifyOnRoomChanged(room, 'inserted'); + // If federated, we must create Matrix room BEFORE subscriptions so invites can be sent. + if (shouldBeHandledByFederation) { + // Reusing unused callback to create Matrix room. + // We should discuss the opportunity to rename it to something with "before" prefix. + await callbacks.run('federation.afterCreateFederatedRoom', room, { owner, originalMemberList: members, options }); + } + await createUsersSubscriptions({ room, members, now, owner, options, shouldBeHandledByFederation }); if (type === 'c') { @@ -301,10 +318,6 @@ export const createRoom = async ( } callbacks.runAsync('afterCreateRoom', owner, room); - if (shouldBeHandledByFederation) { - callbacks.runAsync('federation.afterCreateFederatedRoom', room, { owner, originalMemberList: members, options }); - } - void Apps.self?.triggerEvent(AppEvents.IPostRoomCreate, room); return { rid: room._id, // backwards compatible diff --git a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts index 23e82389cb271..ee01d451c07ad 100644 --- a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts +++ b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts @@ -1,7 +1,7 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; import { Message, Team, Room } from '@rocket.chat/core-services'; -import type { IUser } from '@rocket.chat/core-typings'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -10,32 +10,22 @@ import { beforeLeaveRoomCallback } from '../../../../lib/callbacks/beforeLeaveRo import { settings } from '../../../settings/server'; import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../lib/notifyListener'; -export const removeUserFromRoom = async function (rid: string, user: IUser, options?: { byUser: IUser }): Promise { - const room = await Rooms.findOneById(rid); - - if (!room) { +/** + * Removes a user from a room when triggered by federation or other external events. + * Executes only the necessary database operations, with no callbacks, to prevent + * propagation loops during external event processing. + */ +export const performUserRemoval = async function (room: IRoom, user: IUser, options?: { byUser?: IUser }): Promise { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { + projection: { _id: 1, status: 1 }, + }); + if (!subscription) { return; } - try { - await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user, options?.byUser); - } catch (error: any) { - if (error.name === AppsEngineException.name) { - throw new Meteor.Error('error-app-prevented', error.message); - } - - throw error; - } - - await Room.beforeLeave(room); - // TODO: move before callbacks to service await beforeLeaveRoomCallback.run(user, room); - const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id, { - projection: { _id: 1 }, - }); - if (subscription) { const removedUser = user; if (options?.byUser) { @@ -44,22 +34,24 @@ export const removeUserFromRoom = async function (rid: string, user: IUser, opti }; if (room.teamMain) { - await Message.saveSystemMessage('removed-user-from-team', rid, user.username || '', user, extraData); + await Message.saveSystemMessage('removed-user-from-team', room._id, user.username || '', user, extraData); } else { - await Message.saveSystemMessage('ru', rid, user.username || '', user, extraData); + await Message.saveSystemMessage('ru', room._id, user.username || '', user, extraData); } + } else if (subscription.status === 'INVITED') { + await Message.saveSystemMessage('uir', room._id, removedUser.username || '', removedUser); } else if (room.teamMain) { - await Message.saveSystemMessage('ult', rid, removedUser.username || '', removedUser); + await Message.saveSystemMessage('ult', room._id, removedUser.username || '', removedUser); } else { - await Message.saveSystemMessage('ul', rid, removedUser.username || '', removedUser); + await Message.saveSystemMessage('ul', room._id, removedUser.username || '', removedUser); } } if (room.t === 'l') { - await Message.saveSystemMessage('command', rid, 'survey', user); + await Message.saveSystemMessage('command', room._id, 'survey', user); } - const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(rid, user._id); + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(room._id, user._id); if (deletedSubscription) { void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); } @@ -72,10 +64,35 @@ export const removeUserFromRoom = async function (rid: string, user: IUser, opti await Rooms.removeUsersFromE2EEQueueByRoomId(room._id, [user._id]); } - // TODO: CACHE: maybe a queue? - await afterLeaveRoomCallback.run({ user, kicker: options?.byUser }, room); + void notifyOnRoomChangedById(room._id); +}; - void notifyOnRoomChangedById(rid); +/** + * Removes a user from the given room by performing the required database updates + * and triggering all standard callbacks. Used for local actions (UI or API) + * that should propagate normally to federation and other subscribers. + */ +export const removeUserFromRoom = async function (rid: string, user: IUser, options?: { byUser: IUser }): Promise { + const room = await Rooms.findOneById(rid); + if (!room) { + return; + } + + try { + await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user, options?.byUser); + } catch (error: any) { + if (error.name === AppsEngineException.name) { + throw new Meteor.Error('error-app-prevented', error.message); + } + + throw error; + } + + await Room.beforeLeave(room); + + await performUserRemoval(room, user, options); + + await afterLeaveRoomCallback.run({ user, kicker: options?.byUser }, room); await Apps.self?.triggerEvent(AppEvents.IPostRoomUserLeave, room, user, options?.byUser); }; diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index c5fd718d6911f..35c337305e1c5 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -1,8 +1,6 @@ import { api } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; -import { isRoomNativeFederated } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -91,13 +89,6 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; data.users.map(async (username) => { const sanitizedUsername = sanitizeUsername(username); - // If it's a federated username format and the room is not federated, throw error immediately - if (validateFederatedUsername(sanitizedUsername) && !isRoomNativeFederated(room)) { - throw new Meteor.Error('error-federated-users-in-non-federated-rooms', 'Cannot add federated users to non-federated rooms', { - method: 'addUsersToRoom', - }); - } - const newUser = await Users.findOneByUsernameIgnoringCase(sanitizedUsername); if (!newUser) { throw new Meteor.Error('error-user-not-found', 'User not found', { @@ -107,19 +98,18 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, newUser._id); if (!subscription) { - await addUserToRoom(data.rid, newUser, user); - } else { - if (!newUser.username) { - return; - } - void api.broadcast('notify.ephemeralMessage', userId, data.rid, { - msg: i18n.t('Username_is_already_in_here', { - postProcess: 'sprintf', - sprintf: [newUser.username], - lng: user?.language, - }), - }); + return addUserToRoom(data.rid, newUser, user); + } + if (!newUser.username) { + return; } + void api.broadcast('notify.ephemeralMessage', userId, data.rid, { + msg: i18n.t('Username_is_already_in_here', { + postProcess: 'sprintf', + sprintf: [newUser.username], + lng: user?.language, + }), + }); }), ); diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index da15ffc50ad01..01f94f31e5024 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -1,5 +1,7 @@ -import { FederationMatrix, Authorization, MeteorError } from '@rocket.chat/core-services'; -import { isEditedMessage, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings'; +import { FederationMatrix, Authorization, MeteorError, Room } from '@rocket.chat/core-services'; +import { isEditedMessage, isRoomNativeFederated, isUserNativeFederated } from '@rocket.chat/core-typings'; +import type { IRoomNativeFederated, IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; import { Rooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; @@ -7,29 +9,45 @@ import { afterLeaveRoomCallback } from '../../../../lib/callbacks/afterLeaveRoom import { afterRemoveFromRoomCallback } from '../../../../lib/callbacks/afterRemoveFromRoomCallback'; import { beforeAddUsersToRoom, beforeAddUserToRoom } from '../../../../lib/callbacks/beforeAddUserToRoom'; import { beforeChangeRoomRole } from '../../../../lib/callbacks/beforeChangeRoomRole'; +import { prepareCreateRoomCallback } from '../../../../lib/callbacks/beforeCreateRoomCallback'; import { FederationActions } from '../../../../server/services/room/hooks/BeforeFederationActions'; // callbacks.add('federation-event-example', async () => FederationMatrix.handleExample(), callbacks.priority.MEDIUM, 'federation-event-example-handler'); // TODO: move this to the hooks folder -callbacks.add('federation.afterCreateFederatedRoom', async (room, { owner, originalMemberList: members, options }) => { - if (FederationActions.shouldPerformFederationAction(room)) { - const federatedRoomId = options?.federatedRoomId; - - if (!federatedRoomId) { - // if room exists, we don't want to create it again - // adds bridge record - await FederationMatrix.createRoom(room, owner, members); - } else { - // matrix room was already created and passed - const fromServer = federatedRoomId.split(':')[1]; - - await Rooms.setAsFederated(room._id, { - mrid: federatedRoomId, - origin: fromServer, - }); - } + +// Called BEFORE subscriptions are created - creates Matrix room so invites can be sent. +// The invites are sent by beforeAddUserToRoom callback. +callbacks.add('federation.afterCreateFederatedRoom', async (room, { owner, originalMemberList: members }) => { + if (!FederationActions.shouldPerformFederationAction(room)) { + return; + } + + const federatedRoomId = room?.federation?.mrid; + if (!federatedRoomId) { + await FederationMatrix.createRoom(room, owner); + } else { + // TODO unify how to get server + // matrix room was already created and passed + const fromServer = federatedRoomId.split(':')[1]; + + await Rooms.setAsFederated(room._id, { + mrid: federatedRoomId, + origin: fromServer, + }); + } + + const federationRoom = await Rooms.findOneById(room._id); + if (!federationRoom || !isRoomNativeFederated(federationRoom)) { + throw new MeteorError('error-invalid-room', 'Invalid room'); } + + // TODO this won't be neeeded once we receive all state events at ee/packages/federation-matrix/src/events/member.ts + await FederationMatrix.inviteUsersToRoom( + federationRoom, + members.filter((member) => member !== owner.username), + owner, + ); }); callbacks.add( @@ -70,10 +88,18 @@ callbacks.add( 'native-federation-after-delete-message', ); -beforeAddUsersToRoom.add(async ({ usernames }, room) => { - if (FederationActions.shouldPerformFederationAction(room)) { - await FederationMatrix.ensureFederatedUsersExistLocally(usernames); +beforeAddUsersToRoom.add(async ({ usernames, inviter }, room) => { + if (!FederationActions.shouldPerformFederationAction(room) && inviter) { + // check if trying to invite a federated user to a non-federated room + const federatedUsernames = usernames.filter((u) => validateFederatedUsername(u)); + if (federatedUsernames.length > 0) { + throw new MeteorError('error-federated-users-in-non-federated-rooms', 'Cannot add federated users to non-federated rooms'); + } + return; } + + // we create local users before adding them to the room + await FederationMatrix.ensureFederatedUsersExistLocally(usernames); }); beforeAddUserToRoom.add( @@ -82,12 +108,32 @@ beforeAddUserToRoom.add( return; } - if (FederationActions.shouldPerformFederationAction(room)) { - if (!(await Authorization.hasPermission(user._id, 'access-federation'))) { - throw new MeteorError('error-not-authorized-federation', 'Not authorized to access federation'); - } - await FederationMatrix.inviteUsersToRoom(room, [user.username], inviter); + if (!FederationActions.shouldPerformFederationAction(room)) { + return; + } + + // TODO should we really check for "user" here? it is potentially an external user + if (!(await Authorization.hasPermission(user._id, 'access-federation'))) { + throw new MeteorError('error-not-authorized-federation', 'Not authorized to access federation'); } + + // If inviter is federated, the invite came from an external transaction. + // Don't propagate back to Matrix (it was already processed at origin server). + if (isUserNativeFederated(inviter)) { + return; + } + + await FederationMatrix.inviteUsersToRoom(room, [user.username], inviter); + + // after invite is sent we create the invite subscriptions + // TODO this may be not needed if we receive the emit for the invite event from matrix + await Room.createUserSubscription({ + ts: new Date(), + room, + userToBeAdded: user, + inviter, + status: 'INVITED', + }); }, callbacks.priority.MEDIUM, 'native-federation-on-before-add-users-to-room', @@ -201,20 +247,44 @@ callbacks.add( 'federation-matrix-before-create-direct-room', ); +callbacks.add('federation.beforeCreateDirectMessage', async (roomUsers, extraData) => { + // TODO: use a shared helper to check whether a user is federated + // since the DM creation API doesn't tell us if the room is federated (unlike normal channels), + // we're currently inferring it: if any participant has a Matrix-style ID (@user:server), we treat the DM as federated + const hasFederatedMembers = roomUsers.some((user: unknown) => typeof user === 'string' && user.includes(':') && user.includes('@')); + + if (hasFederatedMembers) { + extraData.federated = true; + extraData.federation = { + version: 1, + }; + } +}); + callbacks.add( 'afterCreateDirectRoom', - async (room: IRoom, params: { members: IUser[]; creatorId: IUser['_id']; mrid?: string }): Promise => { - if (params.mrid) { - await Rooms.setAsFederated(room._id, { - mrid: params.mrid, - origin: params.mrid.split(':').pop()!, - }); + async (room: IRoom, params: { members: IUser[]; creatorId: IUser['_id'] }): Promise => { + if (!FederationActions.shouldPerformFederationAction(room)) { return; } - if (FederationActions.shouldPerformFederationAction(room)) { + + // as per federation.beforeCreateDirectMessage we create a DM without federation data because we still don't have it. + if (!room.federation.mrid) { + // so after the DM is created we call the federation to create the DM on Matrix side and then updated the reference here await FederationMatrix.createDirectMessageRoom(room, params.members, params.creatorId); } }, callbacks.priority.HIGH, 'federation-matrix-after-create-direct-room', ); + +prepareCreateRoomCallback.add(async ({ extraData }) => { + if (!extraData.federated || isRoomNativeFederated(extraData)) { + return; + } + + // when we receive extraData.federated, we need to prepare the room to be considered IRoomNativeFederated. + // according to isRoomNativeFederated for a room to be considered IRoomNativeFederated it is enough to have + // only an empty "federation" object + (extraData as IRoomNativeFederated).federation = { version: 1 } as any; +}); diff --git a/apps/meteor/lib/callbacks.ts b/apps/meteor/lib/callbacks.ts index 0c2aec4cf78e2..d55a6c64ae96f 100644 --- a/apps/meteor/lib/callbacks.ts +++ b/apps/meteor/lib/callbacks.ts @@ -78,7 +78,7 @@ interface EventLikeCallbackSignatures { }, ) => void; 'beforeCreateDirectRoom': (members: string[], room: IRoom) => void; - 'federation.beforeCreateDirectMessage': (members: IUser[]) => void; + 'federation.beforeCreateDirectMessage': (members: IUser[], extraData: Record) => void; 'afterSetReaction': (message: IMessage, params: { user: IUser; reaction: string; shouldReact: boolean; room: IRoom }) => void; 'afterUnsetReaction': ( message: IMessage, diff --git a/apps/meteor/lib/callbacks/beforeCreateRoomCallback.ts b/apps/meteor/lib/callbacks/beforeCreateRoomCallback.ts index 625ea21984bc8..fe41628219240 100644 --- a/apps/meteor/lib/callbacks/beforeCreateRoomCallback.ts +++ b/apps/meteor/lib/callbacks/beforeCreateRoomCallback.ts @@ -6,4 +6,4 @@ export const beforeCreateRoomCallback = Callbacks.create<(data: { owner: IUser; room: Omit }) => void>('beforeCreateRoom'); export const prepareCreateRoomCallback = - Callbacks.create<(data: { type: IRoom['t']; extraData: { encrypted?: boolean } }) => void>('prepareCreateRoom'); + Callbacks.create<(data: { type: IRoom['t']; extraData: Partial }) => void>('prepareCreateRoom'); diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index 1caa84d6358a3..999c4b7f0b13f 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -42,6 +42,8 @@ export const subscriptionFields = { tunread: 1, tunreadGroup: 1, tunreadUser: 1, + status: 1, + inviter: 1, // Omnichannel fields department: 1, diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 983981fd0a7b3..8fc0c859861e6 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -98,7 +98,7 @@ "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/federation-matrix": "workspace:^", - "@rocket.chat/federation-sdk": "0.3.2", + "@rocket.chat/federation-sdk": "0.3.3", "@rocket.chat/freeswitch": "workspace:^", "@rocket.chat/fuselage": "^0.69.0", "@rocket.chat/fuselage-forms": "~0.1.1", diff --git a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts index 5b46c07d2e179..c4791b7e80aca 100644 --- a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts +++ b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts @@ -79,6 +79,14 @@ export const getMessageData = ( case 'ul': messageObject.msg = i18n.t('User_left_this_channel'); break; + case 'ui': + messageObject.msg = i18n.t('User_invited_to_room', { + user_invited: hideUserName(msg.msg, userData, usersMap), + }); + break; + case 'uir': + messageObject.msg = i18n.t('User_rejected_invitation_to_room'); + break; case 'ult': messageObject.msg = i18n.t('User_left_this_team'); break; diff --git a/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts index 6d37513db3694..e03544d3a5a03 100644 --- a/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts +++ b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts @@ -1,4 +1,4 @@ -import { type IUser, type IRole, ROOM_ROLE_PRIORITY_MAP } from '@rocket.chat/core-typings'; +import { type IUser, ROOM_ROLE_PRIORITY_MAP, type ISubscription } from '@rocket.chat/core-typings'; import { Subscriptions, Users } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { Document, FilterOperators } from 'mongodb'; @@ -16,8 +16,8 @@ type FindUsersParam = { extraQuery?: Document[]; }; -type UserWithRoleData = IUser & { - roles: IRole['_id'][]; +type UserWithRoleAndSubscriptionData = IUser & { + subscription: Pick; }; export async function findUsersOfRoomOrderedByRole({ @@ -29,7 +29,7 @@ export async function findUsersOfRoomOrderedByRole({ sort = {}, exceptions = [], extraQuery = [], -}: FindUsersParam): Promise<{ members: UserWithRoleData[]; total: number }> { +}: FindUsersParam): Promise<{ members: UserWithRoleAndSubscriptionData[]; total: number }> { const searchFields = settings.get('Accounts_SearchFields').trim().split(','); const termRegex = new RegExp(escapeRegExp(filter), 'i'); const orStmt = filter && searchFields.length ? searchFields.map((field) => ({ [field.trim()]: termRegex })) : []; @@ -62,7 +62,7 @@ export async function findUsersOfRoomOrderedByRole({ ], }; - const membersResult = Users.col.aggregate( + const membersResult = Users.col.aggregate( [ { $match: matchUserFilter, @@ -102,7 +102,7 @@ export async function findUsersOfRoomOrderedByRole({ }, }, }, - { $project: { roles: 1 } }, + { $project: { roles: 1, status: 1, ts: 1 } }, ], }, }, @@ -111,9 +111,14 @@ export async function findUsersOfRoomOrderedByRole({ roles: { $arrayElemAt: ['$subscription.roles', 0] }, }, }, + { + $unwind: { + path: '$subscription', + preserveNullAndEmptyArrays: true, + }, + }, { $project: { - subscription: 0, statusSortKey: 0, }, }, diff --git a/apps/meteor/server/methods/createDirectMessage.ts b/apps/meteor/server/methods/createDirectMessage.ts index ffe355f85ed94..f3dfc6cd14e0c 100644 --- a/apps/meteor/server/methods/createDirectMessage.ts +++ b/apps/meteor/server/methods/createDirectMessage.ts @@ -43,11 +43,6 @@ export async function createDirectMessage( const options: Exclude = { creator: me._id }; const roomUsers = excludeSelf ? users : [me, ...users]; - // TODO: use a shared helper to check whether a user is federated - // since the DM creation API doesn't tell us if the room is federated (unlike normal channels), - // we're currently inferring it: if any participant has a Matrix-style ID (@user:server), we treat the DM as federated - const hasFederatedMembers = roomUsers.some((user) => typeof user === 'string' && user.includes(':') && user.includes('@')); - // allow self-DMs if (roomUsers.length === 1 && roomUsers[0] !== undefined && typeof roomUsers[0] !== 'string' && roomUsers[0]._id !== me._id) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { @@ -78,8 +73,11 @@ export async function createDirectMessage( if (excludeSelf && (await hasPermissionAsync(userId, 'view-room-administration'))) { options.subscriptionExtra = { open: true }; } + + const extraData = {}; + try { - await callbacks.run('federation.beforeCreateDirectMessage', roomUsers); + await callbacks.run('federation.beforeCreateDirectMessage', roomUsers, extraData); } catch (error) { throw new Meteor.Error((error as any)?.message); } @@ -87,18 +85,7 @@ export async function createDirectMessage( _id: rid, inserted, ...room - } = await createRoom<'d'>( - 'd', - undefined, - undefined, - roomUsers as IUser[], - false, - undefined, - { - ...(hasFederatedMembers && { federated: true }), - }, - options, - ); + } = await createRoom<'d'>('d', undefined, undefined, roomUsers as IUser[], false, undefined, extraData, options); return { // @ts-expect-error - room type is already defined in the `createRoom` return type diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index 67ece854efa3b..33befeaf9fd6d 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -137,6 +137,8 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb | 'tunread' | 'tunreadGroup' | 'tunreadUser' + | 'status' + | 'inviter' // Omnichannel fields | 'department' diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index 1e9492d12792c..968b380b0a984 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -1,16 +1,21 @@ -import { ServiceClassInternal, Authorization, MeteorError } from '@rocket.chat/core-services'; +import { ServiceClassInternal, Authorization, Message, MeteorError } from '@rocket.chat/core-services'; import type { ICreateRoomParams, IRoomService } from '@rocket.chat/core-services'; -import { type AtLeast, type IRoom, type IUser, isOmnichannelRoom, isRoomWithJoinCode } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom, isRoomWithJoinCode } from '@rocket.chat/core-typings'; +import type { ISubscription, AtLeast, IRoom, IUser } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { FederationActions } from './hooks/BeforeFederationActions'; import { saveRoomName } from '../../../app/channel-settings/server'; import { saveRoomTopic } from '../../../app/channel-settings/server/functions/saveRoomTopic'; +import { performAcceptRoomInvite } from '../../../app/lib/server/functions/acceptRoomInvite'; import { addUserToRoom } from '../../../app/lib/server/functions/addUserToRoom'; import { createRoom } from '../../../app/lib/server/functions/createRoom'; // TODO remove this import -import { removeUserFromRoom } from '../../../app/lib/server/functions/removeUserFromRoom'; +import { removeUserFromRoom, performUserRemoval } from '../../../app/lib/server/functions/removeUserFromRoom'; +import { notifyOnSubscriptionChangedById } from '../../../app/lib/server/lib/notifyListener'; +import { getDefaultSubscriptionPref } from '../../../app/utils/lib/getDefaultSubscriptionPref'; import { getValidRoomName } from '../../../app/utils/server/lib/getValidRoomName'; import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { getSubscriptionAutotranslateDefaultConfig } from '../../lib/getSubscriptionAutotranslateDefaultConfig'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { addRoomLeader } from '../../methods/addRoomLeader'; import { addRoomModerator } from '../../methods/addRoomModerator'; @@ -81,6 +86,14 @@ export class RoomService extends ServiceClassInternal implements IRoomService { return removeUserFromRoom(roomId, user, options); } + async performUserRemoval(room: IRoom, user: IUser, options?: { byUser?: IUser }): Promise { + return performUserRemoval(room, user, options); + } + + async performAcceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser & { username: string }): Promise { + return performAcceptRoomInvite(room, subscription, user); + } + async getValidRoomName(displayName: string, roomId = '', options: { allowDuplicates?: boolean } = {}): Promise { return getValidRoomName(displayName, roomId, options); } @@ -206,4 +219,72 @@ export class RoomService extends ServiceClassInternal implements IRoomService { } } } + + async createUserSubscription({ + room, + ts, + userToBeAdded, + inviter, + createAsHidden = false, + skipAlertSound = false, + skipSystemMessage = false, + status, + }: { + room: IRoom; + ts: Date; + userToBeAdded: IUser; + inviter?: Pick; + createAsHidden?: boolean; + skipAlertSound?: boolean; + skipSystemMessage?: boolean; + status?: 'INVITED'; + }): Promise { + const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(userToBeAdded); + + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, userToBeAdded, { + ts, + open: !createAsHidden, + alert: createAsHidden ? false : !skipAlertSound, + unread: 1, + userMentions: 1, + groupMentions: 0, + ...(status && { status }), + ...(inviter && { inviter: { _id: inviter._id, username: inviter.username, name: inviter.name } }), + ...autoTranslateConfig, + ...getDefaultSubscriptionPref(userToBeAdded), + }); + + if (insertedId) { + void notifyOnSubscriptionChangedById(insertedId, 'inserted'); + } + + if (!skipSystemMessage && userToBeAdded.username) { + if (inviter) { + const extraData = { + ts, + u: { + _id: inviter._id, + username: inviter.username, + }, + }; + if (room.teamMain) { + await Message.saveSystemMessage('added-user-to-team', room._id, userToBeAdded.username, userToBeAdded, extraData); + } else if (status === 'INVITED') { + await Message.saveSystemMessage('ui', room._id, userToBeAdded.username, userToBeAdded, { + u: { _id: inviter._id, username: inviter.username }, + }); + } else { + await Message.saveSystemMessage('au', room._id, userToBeAdded.username, userToBeAdded, extraData); + } + } else if (room.prid) { + await Message.saveSystemMessage('ut', room._id, userToBeAdded.username, userToBeAdded, { ts }); + } else if (room.teamMain) { + await Message.saveSystemMessage('ujt', room._id, userToBeAdded.username, userToBeAdded, { ts }); + } else { + await Message.saveSystemMessage('uj', room._id, userToBeAdded.username, userToBeAdded, { ts }); + } + } + + return insertedId; + } } diff --git a/apps/meteor/tests/data/rooms.helper.ts b/apps/meteor/tests/data/rooms.helper.ts index 958318bc3c55a..32c033050d80f 100644 --- a/apps/meteor/tests/data/rooms.helper.ts +++ b/apps/meteor/tests/data/rooms.helper.ts @@ -424,3 +424,61 @@ export const loadHistory = async ( unreadNotLoaded?: number; }; }; + +/** + * Accepts a room invite for the authenticated user. + * + * Processes a room invitation by accepting it, which grants the user + * access to the room. This is essential for federated room workflows + * where users receive invitations rather than auto-joining. + * + * @param roomId - The unique identifier of the room + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to the acceptance response + */ +export const acceptRoomInvite = (roomId: IRoom['_id'], config?: IRequestConfig) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + return new Promise<{ success: boolean; error?: string }>((resolve) => { + void requestInstance + .post(api('rooms.invite')) + .set(credentialsInstance) + .send({ + roomId, + action: 'accept', + }) + .end((_err: any, req: any) => { + resolve(req.body); + }); + }); +}; + +/** + * Rejects a room invite for the authenticated user. + * + * Processes a room invitation by rejecting it, which prevents the user + * from joining the room and removes them from the invited members list. + * This is essential for federated room workflows where users can decline invitations. + * + * @param roomId - The unique identifier of the room + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to the rejection response + */ +export const rejectRoomInvite = (roomId: IRoom['_id'], config?: IRequestConfig) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + return new Promise<{ success: boolean; error?: string }>((resolve) => { + void requestInstance + .post(api('rooms.invite')) + .set(credentialsInstance) + .send({ + roomId, + action: 'reject', + }) + .end((_err: any, req: any) => { + resolve(req.body); + }); + }); +}; diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index f28cf3f57451a..8b7b9c68adc2a 100644 --- a/ee/packages/federation-matrix/package.json +++ b/ee/packages/federation-matrix/package.json @@ -22,7 +22,7 @@ "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "^0.31.25", - "@rocket.chat/federation-sdk": "0.3.2", + "@rocket.chat/federation-sdk": "0.3.3", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/license": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 7a60af564e0b9..32e8a7e98b7c3 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -1,4 +1,4 @@ -import { type IFederationMatrixService, ServiceClass } from '@rocket.chat/core-services'; +import { type IFederationMatrixService, Room, ServiceClass } from '@rocket.chat/core-services'; import { isDeletedMessage, isMessageFromMatrixFederation, @@ -14,7 +14,6 @@ import { Logger } from '@rocket.chat/logger'; import { Users, Subscriptions, Messages, Rooms, Settings } from '@rocket.chat/models'; import emojione from 'emojione'; -import { acceptInvite } from './api/_matrix/invite'; import { toExternalMessageFormat, toExternalQuoteMessageFormat } from './helpers/message.parsers'; import { MatrixMediaService } from './services/MatrixMediaService'; @@ -89,10 +88,11 @@ export const getUsernameServername = (mxid: string, serverName: string): [mxid: * Because of historical reasons, we can have users only with federated flag but no federation object * So we need to upsert the user with the federation object */ -export async function createOrUpdateFederatedUser(options: { username: UserID; name?: string; origin: string }): Promise { +export async function createOrUpdateFederatedUser(options: { username: string; name?: string; origin: string }): Promise { const { username, name = username, origin } = options; - const result = await Users.updateOne( + // TODO: Have a specific method to handle this upsert + const user = await Users.findOneAndUpdate( { username, }, @@ -119,17 +119,16 @@ export async function createOrUpdateFederatedUser(options: { username: UserID; n }, { upsert: true, + projection: { _id: 1, username: 1 }, + returnDocument: 'after', }, ); - const userId = result.upsertedId || (await Users.findOneByUsername(username, { projection: { _id: 1 } }))?._id; - if (!userId) { + if (!user) { throw new Error(`Failed to create or update federated user: ${username}`); } - if (typeof userId !== 'string') { - return userId.toString(); - } - return userId; + + return user; } export { generateEd25519RandomSecretKey } from '@rocket.chat/federation-sdk'; @@ -207,7 +206,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS this.processEDUPresence = (await Settings.getValueById('Federation_Service_EDU_Process_Presence')) || false; } - async createRoom(room: IRoom, owner: IUser, members: string[]): Promise<{ room_id: string; event_id: string }> { + async createRoom(room: IRoom, owner: IUser): Promise<{ room_id: string; event_id: string }> { if (room.t !== 'c' && room.t !== 'p') { throw new Error('Room is not a public or private room'); } @@ -223,15 +222,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS await Rooms.setAsFederated(room._id, { mrid: matrixRoomResult.room_id, origin: this.serverName }); - const federatedRoom = await Rooms.findOneById(room._id); - - if (federatedRoom && isRoomNativeFederated(federatedRoom)) { - await this.inviteUsersToRoom( - federatedRoom, - members.filter((m) => m !== owner.username), - owner, - ); - } + // Members are NOT invited here - invites are sent via beforeAddUserToRoom callback. this.logger.debug('Room creation completed successfully', room._id); @@ -562,13 +553,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return; } - const result = await federationSDK.inviteUserToRoom( + return federationSDK.inviteUserToRoom( userIdSchema.parse(`@${username}:${this.serverName}`), roomIdSchema.parse(room.federation.mrid), userIdSchema.parse(inviterUserId), ); - - return acceptInvite(result.event, username); }), ); } catch (error) { @@ -836,7 +825,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS if (!rid || !user) { return; } - const room = await Rooms.findOneById(rid); + const room = await Rooms.findOneById(rid, { projection: { _id: 1, federation: 1, federated: 1 } }); if (!room || !isRoomNativeFederated(room)) { return; } @@ -848,6 +837,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return; } + const hasUserJoinedRoom = await Subscriptions.findOneByRoomIdAndUserId(room._id, localUser?._id, { projection: { _id: 1 } }); + if (!hasUserJoinedRoom) { + return; + } + const userMui = isUserNativeFederated(localUser) ? localUser.federation.mui : `@${localUser.username}:${this.serverName}`; void federationSDK.sendTypingNotification(room.federation.mrid, userMui, isTyping); @@ -899,4 +893,37 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return results; } + + async handleInvite(roomId: IRoom['_id'], userId: IUser['_id'], action: 'accept' | 'reject'): Promise { + const subscription = await Subscriptions.findInvitedSubscription(roomId, userId); + if (!subscription) { + throw new Error('No subscription found or user does not have permission to accept or reject this invite'); + } + + const room = await Rooms.findOneById(roomId); + if (!room || !isRoomNativeFederated(room)) { + throw new Error('Room not found or not federated'); + } + + const user = await Users.findOneById(userId); + if (!user) { + throw new Error('User not found'); + } + + if (!user.username) { + throw new Error('User username not found'); + } + + // TODO: should use common function to get matrix user ID + const matrixUserId = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; + + if (action === 'accept') { + await federationSDK.acceptInvite(room.federation.mrid, matrixUserId); + + await Room.performAcceptRoomInvite(room, subscription, user); + } + if (action === 'reject') { + await federationSDK.rejectInvite(room.federation.mrid, matrixUserId); + } + } } diff --git a/ee/packages/federation-matrix/src/api/_matrix/invite.ts b/ee/packages/federation-matrix/src/api/_matrix/invite.ts index 9f4f1c918a784..271d33ffaaba5 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/invite.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/invite.ts @@ -1,17 +1,12 @@ -import { Authorization, Room } from '@rocket.chat/core-services'; -import { isUserNativeFederated, type IUser } from '@rocket.chat/core-typings'; -import type { PduMembershipEventContent, PersistentEventBase, RoomVersion } from '@rocket.chat/federation-sdk'; -import { eventIdSchema, roomIdSchema, NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk'; +import { Authorization } from '@rocket.chat/core-services'; +import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk'; import { Router } from '@rocket.chat/http-router'; import { Logger } from '@rocket.chat/logger'; -import { Rooms, Users } from '@rocket.chat/models'; +import { Users } from '@rocket.chat/models'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; -import { createOrUpdateFederatedUser, getUsernameServername } from '../../FederationMatrix'; import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; -const logger = new Logger('federation-matrix:invite'); - const EventBaseSchema = { type: 'object', properties: { @@ -67,7 +62,7 @@ const EventBaseSchema = { nullable: true, }, }, - required: ['type', 'content', 'sender', 'room_id', 'origin_server_ts', 'depth', 'prev_events', 'auth_events', 'origin'], + required: ['type', 'content', 'sender', 'room_id', 'origin_server_ts', 'depth', 'prev_events', 'auth_events'], }; const MembershipEventContentSchema = { @@ -134,181 +129,6 @@ const ProcessInviteResponseSchema = { const isProcessInviteResponseProps = ajv.compile(ProcessInviteResponseSchema); -// 5 seconds -// 25 seconds -// 625 seconds = 10 minutes 25 seconds // max -async function runWithBackoff(fn: () => Promise, delaySec = 5) { - try { - await fn(); - } catch (e) { - // don't retry on authorization/validation errors - they won't succeed on retry - if (e instanceof NotAllowedError) { - logger.error(e, 'Authorization error, not retrying'); - return; - } - - const delay = Math.min(625, delaySec ** 2); - logger.error(e, `error occurred, retrying in ${delay}s`); - setTimeout(() => { - runWithBackoff(fn, delay); - }, delay * 1000); - } -} - -async function joinRoom({ - inviteEvent, - user, // ours trying to join the room -}: { - inviteEvent: PersistentEventBase; - user: IUser; -}) { - // from the response we get the event - if (!inviteEvent.stateKey) { - throw new Error('join event has missing state key, unable to determine user to join'); - } - - // backoff needed for this call, can fail - await federationSDK.joinUser(inviteEvent, inviteEvent.event.state_key); - - // now we create the room we saved post joining - const matrixRoom = await federationSDK.getLatestRoomState2(inviteEvent.roomId); - if (!matrixRoom) { - throw new Error('room not found not processing invite'); - } - - // we only understand these two types of rooms, plus direct messages - const isDM = inviteEvent.getContent().is_direct; - - if (!isDM && !matrixRoom.isPublic() && !matrixRoom.isInviteOnly()) { - throw new Error('room is neither direct message - rocketchat is unable to join for now'); - } - - // need both the sender and the participating user to exist in the room - // TODO implement on model - const senderUser = await Users.findOneByUsername(inviteEvent.sender, { projection: { _id: 1 } }); - - const senderUserId = - senderUser?._id || - (await createOrUpdateFederatedUser({ - username: inviteEvent.sender, - origin: matrixRoom.origin, - })); - - if (!senderUserId) { - throw new Error('Sender user ID not found'); - } - - let internalRoomId: string; - - const internalMappedRoom = await Rooms.findOne({ 'federation.mrid': inviteEvent.roomId }); - - if (!internalMappedRoom) { - let roomType: 'c' | 'p' | 'd'; - - if (isDM) { - roomType = 'd'; - } else if (matrixRoom.isPublic()) { - roomType = 'c'; - } else if (matrixRoom.isInviteOnly()) { - roomType = 'p'; - } else { - throw new Error('room is neither public, private, nor direct message - rocketchat is unable to join for now'); - } - - let ourRoom: { _id: string }; - - if (isDM) { - const senderUser = await Users.findOneById(senderUserId, { projection: { _id: 1, username: 1 } }); - const inviteeUser = user; - - if (!senderUser?.username) { - throw new Error('Sender user not found'); - } - if (!inviteeUser?.username) { - throw new Error('Invitee user not found'); - } - - ourRoom = await Room.create(senderUserId, { - type: roomType, - name: inviteEvent.sender, - members: [senderUser.username, inviteeUser.username], - options: { - federatedRoomId: inviteEvent.roomId, - creator: senderUserId, - }, - extraData: { - federated: true, - }, - }); - } else { - const roomFname = `${matrixRoom.name}:${matrixRoom.origin}`; - const roomName = inviteEvent.roomId.replace('!', '').replace(':', '_'); - - ourRoom = await Room.create(senderUserId, { - type: roomType, - name: roomName, - options: { - federatedRoomId: inviteEvent.roomId, - creator: senderUserId, - }, - extraData: { - federated: true, - fname: roomFname, - }, - }); - } - - internalRoomId = ourRoom._id; - } else { - internalRoomId = internalMappedRoom._id; - } - - await Room.addUserToRoom(internalRoomId, { _id: user._id }, { _id: senderUserId, username: inviteEvent.sender }); -} - -async function startJoiningRoom(...opts: Parameters) { - void runWithBackoff(() => joinRoom(...opts)); -} - -// This is a special case where inside rocket chat we invite users inside rockechat, so if the sender or the invitee are external iw should throw an error -export const acceptInvite = async (inviteEvent: PersistentEventBase, username: string) => { - if (!inviteEvent.stateKey) { - throw new Error('join event has missing state key, unable to determine user to join'); - } - - const internalMappedRoom = await Rooms.findOne({ 'federation.mrid': inviteEvent.roomId }); - if (!internalMappedRoom) { - throw new Error('room not found not processing invite'); - } - - const serverName = federationSDK.getConfig('serverName'); - - const inviter = await Users.findOneByUsername>(getUsernameServername(inviteEvent.sender, serverName)[0], { - projection: { _id: 1, username: 1 }, - }); - - if (!inviter) { - throw new Error('Sender user ID not found'); - } - if (isUserNativeFederated(inviter)) { - throw new Error('Sender user is native federated'); - } - - const user = await Users.findOneByUsername>(username, { - projection: { username: 1, federation: 1, federated: 1 }, - }); - - // we cannot accept invites from users that are external - if (!user) { - throw new Error('User not found'); - } - if (isUserNativeFederated(user)) { - throw new Error('User is native federated'); - } - - await federationSDK.joinUser(inviteEvent, inviteEvent.event.state_key); -}; - export const getMatrixInviteRoutes = () => { const logger = new Logger('matrix-invite'); @@ -368,24 +188,7 @@ export const getMatrixInviteRoutes = () => { } try { - const inviteEvent = await federationSDK.processInvite( - event, - roomIdSchema.parse(roomId), - eventIdSchema.parse(eventId), - roomVersion, - c.get('authenticatedServer'), - strippedStateEvents, - ); - - setTimeout( - () => { - void startJoiningRoom({ - inviteEvent, - user: ourUser, - }); - }, - inviteEvent.event.content.is_direct ? 2000 : 0, - ); + const inviteEvent = await federationSDK.processInvite(event, eventId, roomVersion, strippedStateEvents); return { body: { diff --git a/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts b/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts new file mode 100644 index 0000000000000..72e741f8df8b7 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts @@ -0,0 +1,106 @@ +import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk'; +import { Router } from '@rocket.chat/http-router'; +import { Logger } from '@rocket.chat/logger'; +import { ajv } from '@rocket.chat/rest-typings'; + +import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; + +const isMakeLeaveParamsProps = ajv.compile({ + type: 'object', + properties: { roomId: { type: 'string' }, userId: { type: 'string' } }, + required: ['roomId', 'userId'], +}); +const isMakeLeaveSuccessResponseProps = ajv.compile({ + type: 'object', + properties: { + event: { + type: 'object', + properties: { + content: { + type: 'object', + properties: { + membership: { + type: 'string', + const: 'leave', + }, + }, + }, + origin: { + type: 'string', + }, + origin_server_ts: { + type: 'number', + }, + sender: { + type: 'string', + }, + state_key: { + type: 'string', + }, + type: { + type: 'string', + const: 'm.room.member', + }, + }, + }, + room_version: { type: 'string' }, + }, +}); +const isMakeLeaveForbiddenResponseProps = ajv.compile({ + type: 'object', + properties: { errcode: { type: 'string', const: 'M_FORBIDDEN' }, error: { type: 'string' } }, +}); +const isMakeLeaveErrorResponseProps = ajv.compile({ + type: 'object', + properties: { errcode: { type: 'string', const: 'M_UNKNOWN' }, error: { type: 'string' } }, +}); + +export const getMatrixMakeLeaveRoutes = () => { + const logger = new Logger('matrix-make-leave'); + + return new Router('/federation').get( + '/v1/make_leave/:roomId/:userId', + { + params: isMakeLeaveParamsProps, + response: { + 200: isMakeLeaveSuccessResponseProps, + 403: isMakeLeaveForbiddenResponseProps, + 500: isMakeLeaveErrorResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + isAuthenticatedMiddleware(), + async (c) => { + const { roomId, userId } = c.req.param(); + try { + // TODO: Remove out of spec attributes being returned + const makeLeaveResponse = await federationSDK.makeLeave(roomId, userId); + return { + body: makeLeaveResponse, + statusCode: 200, + }; + } catch (error) { + if (error instanceof NotAllowedError) { + return { + body: { + errcode: 'M_FORBIDDEN', + error: 'This server does not allow leaving this room based on federation settings.', + }, + statusCode: 403, + }; + } + + logger.error({ msg: 'Error making leave', err: error }); + + return { + body: { + errcode: 'M_UNKNOWN', + error: error instanceof Error ? error.message : 'Internal server error while processing request', + }, + statusCode: 500, + }; + } + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts b/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts new file mode 100644 index 0000000000000..7d12b743ed139 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts @@ -0,0 +1,109 @@ +import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk'; +import { Router } from '@rocket.chat/http-router'; +import { Logger } from '@rocket.chat/logger'; +import { ajv } from '@rocket.chat/rest-typings'; + +import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; + +const isSendLeaveParamsProps = ajv.compile({ + type: 'object', + properties: { roomId: { type: 'string' }, eventId: { type: 'string' } }, + required: ['roomId', 'eventId'], +}); +const isSendLeaveBodyProps = ajv.compile({ + type: 'object', + properties: { + content: { + type: 'object', + properties: { + membership: { + type: 'string', + const: 'leave', + }, + }, + }, + depth: { + type: 'number', + }, + origin: { + type: 'string', + }, + origin_server_ts: { + type: 'number', + }, + sender: { + type: 'string', + }, + state_key: { + type: 'string', + }, + type: { + type: 'string', + const: 'm.room.member', + }, + }, + required: ['content', 'depth', 'origin', 'origin_server_ts', 'sender', 'state_key', 'type'], +}); +const isSendLeaveSuccessResponseProps = ajv.compile({ + type: 'object', + properties: {}, +}); +const isSendLeaveForbiddenResponseProps = ajv.compile({ + type: 'object', + properties: { errcode: { type: 'string', const: 'M_FORBIDDEN' }, error: { type: 'string' } }, +}); +const isSendLeaveErrorResponseProps = ajv.compile({ + type: 'object', + properties: { errcode: { type: 'string', const: 'M_UNKNOWN' }, error: { type: 'string' } }, +}); + +export const getMatrixSendLeaveRoutes = () => { + const logger = new Logger('matrix-send-leave'); + + return new Router('/federation').put( + '/v2/send_leave/:roomId/:eventId', + { + params: isSendLeaveParamsProps, + body: isSendLeaveBodyProps, + response: { + 200: isSendLeaveSuccessResponseProps, + 403: isSendLeaveForbiddenResponseProps, + 500: isSendLeaveErrorResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + isAuthenticatedMiddleware(), + async (c) => { + const { roomId, eventId } = c.req.param(); + const body = await c.req.json(); + try { + await federationSDK.sendLeave(roomId, eventId, body); + return { + body: {}, + statusCode: 200, + }; + } catch (error) { + if (error instanceof NotAllowedError) { + return { + body: { + errcode: 'M_FORBIDDEN', + error: 'This server does not allow leaving this room based on federation settings.', + }, + statusCode: 403, + }; + } + + logger.error({ msg: 'Error making leave', err: error }); + + return { + body: { + errcode: 'M_UNKNOWN', + error: error instanceof Error ? error.message : 'Internal server error while processing request', + }, + statusCode: 500, + }; + } + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/routes.ts b/ee/packages/federation-matrix/src/api/routes.ts index 10a03684bcd73..986bc4db81b83 100644 --- a/ee/packages/federation-matrix/src/api/routes.ts +++ b/ee/packages/federation-matrix/src/api/routes.ts @@ -3,10 +3,12 @@ import { Router } from '@rocket.chat/http-router'; import { getWellKnownRoutes } from './.well-known/server'; import { getMatrixInviteRoutes } from './_matrix/invite'; import { getKeyServerRoutes } from './_matrix/key/server'; +import { getMatrixMakeLeaveRoutes } from './_matrix/make-leave'; import { getMatrixMediaRoutes } from './_matrix/media'; import { getMatrixProfilesRoutes } from './_matrix/profiles'; import { getMatrixRoomsRoutes } from './_matrix/rooms'; import { getMatrixSendJoinRoutes } from './_matrix/send-join'; +import { getMatrixSendLeaveRoutes } from './_matrix/send-leave'; import { getMatrixTransactionsRoutes } from './_matrix/transactions'; import { getFederationVersionsRoutes } from './_matrix/versions'; import { isFederationDomainAllowedMiddleware } from './middlewares/isFederationDomainAllowed'; @@ -28,7 +30,9 @@ export const getFederationRoutes = (version: string): { matrix: Router<'/_matrix .use(getMatrixRoomsRoutes()) .use(getMatrixSendJoinRoutes()) .use(getMatrixTransactionsRoutes()) - .use(getMatrixMediaRoutes()); + .use(getMatrixMediaRoutes()) + .use(getMatrixSendLeaveRoutes()) + .use(getMatrixMakeLeaveRoutes()); wellKnown.use(isFederationEnabledMiddleware).use(isLicenseEnabledMiddleware).use(getWellKnownRoutes()); diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 790f2dbb723ab..56b5030b5e4fc 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -1,6 +1,8 @@ import { Room } from '@rocket.chat/core-services'; +import type { IRoomNativeFederated, IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; import type { Emitter } from '@rocket.chat/emitter'; -import { federationSDK, type HomeserverEventSignatures } from '@rocket.chat/federation-sdk'; +import type { HomeserverEventSignatures, PduForType } from '@rocket.chat/federation-sdk'; +import { federationSDK } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; @@ -8,95 +10,252 @@ import { createOrUpdateFederatedUser, getUsernameServername } from '../Federatio const logger = new Logger('federation-matrix:member'); -async function membershipLeaveAction(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']) { - const room = await Rooms.findOne({ 'federation.mrid': event.room_id }, { projection: { _id: 1 } }); - if (!room) { - logger.warn(`No bridged room found for Matrix room_id: ${event.room_id}`); - return; - } +async function getOrCreateFederatedUser(userId: string): Promise { + try { + const serverName = federationSDK.getConfig('serverName'); + const [username, userServerName, isLocal] = getUsernameServername(userId, serverName); - const serverName = federationSDK.getConfig('serverName'); + const user = await Users.findOneByUsername(username); + if (user) { + return user; + } - const [affectedUsername] = getUsernameServername(event.state_key, serverName); - // state_key is the user affected by the membership change - const affectedUser = await Users.findOneByUsername(affectedUsername); - if (!affectedUser) { - logger.error(`No Rocket.Chat user found for bridged user: ${event.state_key}`); - return; + if (isLocal) { + throw new Error(`Local user ${username} not found for Matrix ID: ${userId}`); + } + + return createOrUpdateFederatedUser({ + username: userId, + name: userId, + origin: userServerName, + }); + } catch (error) { + logger.error(error, `Error getting or creating federated user ${userId}`); + throw new Error(`Error getting or creating federated user ${userId}`); } +} - // Check if this is a kick (sender != state_key) or voluntary leave (sender == state_key) - if (event.sender === event.state_key) { - // Voluntary leave - await Room.removeUserFromRoom(room._id, affectedUser); - logger.info(`User ${affectedUser.username} left room ${room._id} via Matrix federation`); - } else { - // Kick - find who kicked +async function getOrCreateFederatedRoom({ + matrixRoomId, + roomName, + roomFName, + roomType, + inviterUserId, + inviterUsername, + inviteeUsername, +}: { + matrixRoomId: string; + roomName: string; + roomFName: string; + roomType: RoomType; + inviterUserId: string; + inviterUsername: string; + inviteeUsername?: string; +}): Promise { + try { + const room = await Rooms.findOne({ 'federation.mrid': matrixRoomId }); + if (room) { + return room; + } + + const origin = matrixRoomId.split(':').pop(); + if (!origin) { + throw new Error(`Room origin not found for Matrix ID: ${matrixRoomId}`); + } - const [kickerUsername] = getUsernameServername(event.sender, serverName); - const kickerUser = await Users.findOneByUsername(kickerUsername); + // TODO room creator is not always the inviter - await Room.removeUserFromRoom(room._id, affectedUser, { - byUser: kickerUser || { _id: 'matrix.federation', username: 'Matrix User' }, + return Room.create(inviterUserId, { + type: roomType, + name: roomName, + members: inviteeUsername ? [inviteeUsername, inviterUsername] : [inviterUsername], + options: { + creator: inviterUserId, + }, + extraData: { + federated: true, + federation: { + version: 1, + mrid: matrixRoomId, + origin, + }, + fname: roomFName, + }, }); + } catch (error) { + logger.error(error, `Error getting or creating federated room ${roomName}`); + throw new Error(`Error getting or creating federated room ${roomName}`); + } +} - const reasonText = event.content.reason ? ` Reason: ${event.content.reason}` : ''; - logger.info(`User ${affectedUser.username} was kicked from room ${room._id} by ${event.sender} via Matrix federation.${reasonText}`); +// get the join rule type from the stripped state stored in the unsigned section of the event +// as per the spec, we must support several types but we only support invite and public for now. +// in the future, we must start looking into 'knock', 'knock_restricted', 'restricted' and 'private'. +function getJoinRuleType(strippedState: PduForType<'m.room.join_rules'>[]): 'p' | 'c' | 'd' { + const joinRulesState = strippedState?.find((state: PduForType<'m.room.join_rules'>) => state.type === 'm.room.join_rules'); + + // as per the spec, users need to be invited to join a room, unless the room’s join rules state otherwise. + if (!joinRulesState) { + return 'p'; + } + + const joinRule = joinRulesState?.content?.join_rule; + switch (joinRule) { + case 'invite': + return 'p'; + case 'public': + return 'c'; + case 'knock': + throw new Error(`Knock join rule is not supported`); + case 'knock_restricted': + throw new Error(`Knock restricted join rule is not supported`); + case 'restricted': + throw new Error(`Restricted join rule is not supported`); + case 'private': + throw new Error(`Private join rule is not supported`); + default: + throw new Error(`Unknown join rule type: ${joinRule}`); } } -async function membershipJoinAction(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']) { - const room = await Rooms.findOne({ 'federation.mrid': event.room_id }); +async function handleInvite({ + sender: senderId, + state_key: userId, + room_id: roomId, + content, + unsigned, +}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { + const inviterUser = await getOrCreateFederatedUser(senderId); + if (!inviterUser) { + throw new Error(`Failed to get or create inviter user: ${senderId}`); + } + + const inviteeUser = await getOrCreateFederatedUser(userId); + if (!inviteeUser) { + throw new Error(`Failed to get or create invitee user: ${userId}`); + } + + const strippedState = unsigned.invite_room_state; + + const joinRuleType = getJoinRuleType(strippedState); + + // DMs do not have a join rule type (they are treated as invite only rooms), + // so we use 'd' for direct messages translation to RC. + const roomType = content?.is_direct ? 'd' : joinRuleType; + + const roomOriginDomain = senderId.split(':')?.pop(); + if (!roomOriginDomain) { + throw new Error(`Room origin domain not found: ${roomId}`); + } + + const roomNameState = strippedState?.find((state: PduForType<'m.room.name'>) => state.type === 'm.room.name'); + const matrixRoomName = roomNameState?.content?.name; + + let roomName: string; + let roomFName: string; + if (content?.is_direct) { + roomName = senderId; + roomFName = senderId; + } else { + roomName = roomId.replace('!', '').replace(':', '_'); + roomFName = `${matrixRoomName}:${roomOriginDomain}`; + } + + const room = await getOrCreateFederatedRoom({ + matrixRoomId: roomId, + roomName, + roomFName, + roomType, + inviterUserId: inviterUser._id, + inviterUsername: inviterUser.username as string, // TODO: Remove force cast + inviteeUsername: content?.is_direct ? inviteeUser.username : undefined, + }); if (!room) { - logger.warn(`No bridged room found for room_id: ${event.room_id}`); + throw new Error(`Room not found or could not be created: ${roomId}`); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, inviteeUser._id); + if (subscription) { return; } - const [username, serverName, isLocal] = getUsernameServername(event.sender, federationSDK.getConfig('serverName')); + await Room.createUserSubscription({ + ts: new Date(), + room, + userToBeAdded: inviteeUser, + inviter: inviterUser, + status: 'INVITED', + }); +} - // for local users we must to remove the @ and the server domain - const localUser = isLocal && (await Users.findOneByUsername(username)); +async function handleJoin({ + room_id: roomId, + state_key: userId, +}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { + const joiningUser = await getOrCreateFederatedUser(userId); + if (!joiningUser?.username) { + throw new Error(`Failed to get or create joining user: ${userId}`); + } - if (localUser) { - const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, localUser._id); - if (subscription) { - return; - } - await Room.addUserToRoom(room._id, localUser); - return; + const room = await Rooms.findOneFederatedByMrid(roomId); + if (!room) { + throw new Error(`Room not found while joining user ${userId} to room ${roomId}`); } - if (!serverName) { - throw new Error('Invalid sender format, missing server name'); + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, joiningUser._id); + if (!subscription) { + throw new Error(`Subscription not found while joining user ${userId} to room ${roomId}`); } - const insertedId = await createOrUpdateFederatedUser({ - username: event.state_key, - origin: serverName, - name: event.content.displayname || event.state_key, - }); + if (!subscription.status) { + logger.info('User is already joined to the room, skipping...'); + return; + } - const user = await Users.findOneById(insertedId); + await Room.performAcceptRoomInvite(room, subscription, joiningUser); +} - if (!user) { - console.warn(`User with ID ${insertedId} not found after insertion`); +async function handleLeave({ + room_id: roomId, + state_key: userId, +}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { + const serverName = federationSDK.getConfig('serverName'); + const [username] = getUsernameServername(userId, serverName); + + const leavingUser = await Users.findOneByUsername(username); + if (!leavingUser) { return; } - await Room.addUserToRoom(room._id, user); + + const room = await Rooms.findOneFederatedByMrid(roomId); + if (!room) { + throw new Error(`Room not found while leaving user ${userId} from room ${roomId}`); + } + + await Room.performUserRemoval(room, leavingUser); + + // TODO check if there are no pending invites to the room, and if so, delete the room } export function member(emitter: Emitter) { emitter.on('homeserver.matrix.membership', async ({ event }) => { try { - if (event.content.membership === 'leave') { - return membershipLeaveAction(event); - } + switch (event.content.membership) { + case 'invite': + await handleInvite(event); + break; - if (event.content.membership === 'join') { - return membershipJoinAction(event); - } + case 'join': + await handleJoin(event); + break; + + case 'leave': + await handleLeave(event); + break; - logger.debug(`Ignoring membership event with membership: ${event.content.membership}`); + default: + logger.warn(`Unknown membership type: ${event.content.membership}`); + } } catch (error) { logger.error(error, 'Failed to process Matrix membership event'); } diff --git a/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts index 97c953009152a..f29586ebd5608 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts @@ -7,6 +7,8 @@ import { findRoomMember, addUserToRoom, addUserToRoomSlashCommand, + acceptRoomInvite, + rejectRoomInvite, } from '../../../../../apps/meteor/tests/data/rooms.helper'; import { type IRequestConfig, getRequestConfig, createUser, deleteUser } from '../../../../../apps/meteor/tests/data/users.helper'; import { IS_EE } from '../../../../../apps/meteor/tests/e2e/config/constants'; @@ -380,21 +382,21 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1AdminUserInSynapse).not.toBeNull(); }); - it('It should show the system message that the user added', async () => { + it('It should show the system message that the user was invited', async () => { // RC view: Check in RC. We don't check in Synapse because this is not part of the protocol // Get the room history to find the system message const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for a system message about the user joining - // System messages typically have t: 'uj' (user joined) and the msg contains the username - const joinMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, + // Look for a system message about the user being invited + // Members added during room creation are invited (status: 'INVITED'), not auto-joined + const inviteMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'ui' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, ); - expect(joinMessage).toBeDefined(); - expect(joinMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); - expect(joinMessage?.u?.username).toBe(federationConfig.hs1.adminMatrixUserId); + expect(inviteMessage).toBeDefined(); + expect(inviteMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(inviteMessage?.u?.username).toBe(federationConfig.rc1.adminUser); }); }); @@ -497,29 +499,55 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1User1InSynapseUser1).not.toBeNull(); }); - it('It should show the system messages that the user added on all RCs involved', async () => { + it('It should show the system messages that the users were invited', async () => { // RC view: Check in RC. We don't check in Synapse because this is not part of the protocol // Get the room history to find the system messages const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for system messages about both users joining - // System messages typically have t: 'uj' (user joined) and the msg contains the username - const adminJoinMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, + // Look for system messages about both users being invited + // Members added during room creation are invited (status: 'INVITED'), not auto-joined + const adminInviteMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'ui' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, ); - const hs1User1JoinMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.additionalUser1.matrixUserId, + const hs1User1InviteMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'ui' && message.msg && message.msg === federationConfig.hs1.additionalUser1.matrixUserId, ); - expect(adminJoinMessage).toBeDefined(); - expect(adminJoinMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); - expect(adminJoinMessage?.u?.username).toBe(federationConfig.hs1.adminMatrixUserId); + expect(adminInviteMessage).toBeDefined(); + expect(adminInviteMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(adminInviteMessage?.u?.username).toBe(federationConfig.rc1.adminUser); - expect(hs1User1JoinMessage).toBeDefined(); - expect(hs1User1JoinMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); - expect(hs1User1JoinMessage?.u?.username).toBe(federationConfig.hs1.additionalUser1.matrixUserId); + expect(hs1User1InviteMessage).toBeDefined(); + expect(hs1User1InviteMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); + expect(hs1User1InviteMessage?.u?.username).toBe(federationConfig.rc1.adminUser); + }); + + it('It should show the system messages that the users joined when they accept the invites', async () => { + // RC view: Check in RC. We don't check in Synapse because this is not part of the protocol + // Get the room history to find the system messages + const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); + expect(Array.isArray(historyResponse.messages)).toBe(true); + + // Look for system messages about both users joining after accepting invites + // 'uj' (user joined) message types + const adminJoinedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + ); + + const hs1User1JoinedMessage = historyResponse.messages.find( + (message: IMessage) => + message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.additionalUser1.matrixUserId), + ); + + expect(adminJoinedMessage).toBeDefined(); + expect(adminJoinedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(adminJoinedMessage?.u?.username).toBe(federationConfig.hs1.adminMatrixUserId); + + expect(hs1User1JoinedMessage).toBeDefined(); + expect(hs1User1JoinedMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); + expect(hs1User1JoinedMessage?.u?.username).toBe(federationConfig.hs1.additionalUser1.matrixUserId); }); }); @@ -554,9 +582,12 @@ import { SynapseClient } from '../helper/synapse-client'; expect(federatedChannel).toHaveProperty('federation'); expect((federatedChannel as any).federation).toHaveProperty('version', 1); - // Accept invitation for the federated user (local user is already added automatically) + // Accept invitation for the federated user const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); expect(acceptedRoomId).not.toBe(''); + + // Accept invitation for the local user (rc1User1) + await acceptRoomInvite(federatedChannel._id, rc1User1RequestConfig); }, 15000); it('It should show the room on the remote Element or RC and local for the second user', async () => { @@ -638,42 +669,43 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1AdminUserInSynapse).not.toBeNull(); }); - it('It should show the 2 system messages that the user added', async () => { - // RC view: Check in RC (admin view) for system messages about both users joining + it('It should show the 2 system messages that the users were invited', async () => { + // RC view: Check in RC (admin view) for system messages about both users being invited const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for system messages about both users joining - const localUserJoinMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.rc1.additionalUser1.username, + // Look for system messages about both users being invited + // Members added during room creation are invited (status: 'INVITED'), not auto-joined + const localUserInviteMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'ui' && message.msg && message.msg === federationConfig.rc1.additionalUser1.username, ); - const federatedUserJoinMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, + const federatedUserInviteMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'ui' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, ); - expect(localUserJoinMessage).toBeDefined(); - expect(localUserJoinMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); - expect(localUserJoinMessage?.u?.username).toBe(federationConfig.rc1.additionalUser1.username); + expect(localUserInviteMessage).toBeDefined(); + expect(localUserInviteMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); + expect(localUserInviteMessage?.u?.username).toBe(federationConfig.rc1.adminUser); - expect(federatedUserJoinMessage).toBeDefined(); - expect(federatedUserJoinMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); - expect(federatedUserJoinMessage?.u?.username).toBe(federationConfig.hs1.adminMatrixUserId); + expect(federatedUserInviteMessage).toBeDefined(); + expect(federatedUserInviteMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(federatedUserInviteMessage?.u?.username).toBe(federationConfig.rc1.adminUser); - // RC view: Check in RC (user1 view) for system messages about both users joining + // RC view: Check in RC (user1 view) for system messages about both users being invited const historyResponseUser1 = await getGroupHistory(federatedChannel._id, rc1User1RequestConfig); expect(Array.isArray(historyResponseUser1.messages)).toBe(true); - const localUserJoinMessageUser1 = historyResponseUser1.messages.find( - (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.rc1.additionalUser1.username, + const localUserInviteMessageUser1 = historyResponseUser1.messages.find( + (message: IMessage) => message.t === 'ui' && message.msg && message.msg === federationConfig.rc1.additionalUser1.username, ); - const federatedUserJoinMessageUser1 = historyResponseUser1.messages.find( - (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, + const federatedUserInviteMessageUser1 = historyResponseUser1.messages.find( + (message: IMessage) => message.t === 'ui' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, ); - expect(localUserJoinMessageUser1).toBeDefined(); - expect(federatedUserJoinMessageUser1).toBeDefined(); + expect(localUserInviteMessageUser1).toBeDefined(); + expect(federatedUserInviteMessageUser1).toBeDefined(); }); }); }); @@ -766,20 +798,20 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1AdminUserInSynapse).not.toBeNull(); }); - it('It should show the system message that the user added', async () => { + it('It should show the system message that the user joined', async () => { // RC view: Check in RC // Get the room history to find the system messages const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for system messages about the user being added - // look for 'au' (added user) message types - const addedMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + // Look for system messages about the user joining after accepting invite + // look for 'uj' (user joined) message types + const joinedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(addedMessage).toBeDefined(); - expect(addedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(joinedMessage).toBeDefined(); + expect(joinedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); }); }); @@ -893,29 +925,29 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1User1InSynapseUser1).not.toBeNull(); }); - it('It should show the system messages that the user added on all RCs involved', async () => { + it('It should show the system messages that the users joined', async () => { // RC view: Check in RC // Get the room history to find the system messages const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for system messages about both users being added - // 'au' (added user) message types - const adminAddedMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + // Look for system messages about both users joining after accepting invites + // 'uj' (user joined) message types + const adminJoinedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(adminAddedMessage).toBeDefined(); - expect(adminAddedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(adminJoinedMessage).toBeDefined(); + expect(adminJoinedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); - // Look for 'au' (added user) message types - const hs1User1AddedMessage = historyResponse.messages.find( + // Look for 'uj' (user joined) message types + const hs1User1JoinedMessage = historyResponse.messages.find( (message: IMessage) => - message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.additionalUser1.matrixUserId), + message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.additionalUser1.matrixUserId), ); - expect(hs1User1AddedMessage).toBeDefined(); - expect(hs1User1AddedMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); + expect(hs1User1JoinedMessage).toBeDefined(); + expect(hs1User1JoinedMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); }); }); @@ -959,9 +991,12 @@ import { SynapseClient } from '../helper/synapse-client'; expect(addUserResponse.body).toHaveProperty('success', true); - // Accept invitation for the federated user (local user is added automatically) + // Accept invitation for the federated user const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); expect(acceptedRoomId).not.toBe(''); + + // Accept invitation for the local user (rc1User1) + await acceptRoomInvite(federatedChannel._id, rc1User1RequestConfig); }, 15000); it('It should show the room on the remote Element or RC and local for the second user', async () => { @@ -1043,46 +1078,46 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1AdminUserInSynapse).not.toBeNull(); }); - it('It should show the 2 system messages that the user added', async () => { + it('It should show the 2 system messages that the user joined', async () => { // RC view: Check in RC (admin view) for system messages about both users joining const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // 'au' (added user) message types - const localUserAddedMessage = historyResponse.messages.find( + // 'uj' (user joined) message types + const localUserJoinedMessage = historyResponse.messages.find( (message: IMessage) => - message.t === 'au' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), + message.t === 'uj' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), ); - expect(localUserAddedMessage).toBeDefined(); - expect(localUserAddedMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); + expect(localUserJoinedMessage).toBeDefined(); + expect(localUserJoinedMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); - const federatedUserAddedMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + const federatedUserJoinedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(federatedUserAddedMessage).toBeDefined(); - expect(federatedUserAddedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(federatedUserJoinedMessage).toBeDefined(); + expect(federatedUserJoinedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); - // RC view: Check in RC (user1 view) for system messages about both users being added + // RC view: Check in RC (user1 view) for system messages about both users joining const historyResponseUser1 = await getGroupHistory(federatedChannel._id, rc1User1RequestConfig); expect(Array.isArray(historyResponseUser1.messages)).toBe(true); - // Look for 'au' (added user) message types - const localUserAddedMessageUser1 = historyResponseUser1.messages.find( + // Look for 'uj' (user joined) message types + const localUserJoinedMessageUser1 = historyResponseUser1.messages.find( (message: IMessage) => - message.t === 'au' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), + message.t === 'uj' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), ); - expect(localUserAddedMessageUser1).toBeDefined(); - expect(localUserAddedMessageUser1?.msg).toContain(federationConfig.rc1.additionalUser1.username); + expect(localUserJoinedMessageUser1).toBeDefined(); + expect(localUserJoinedMessageUser1?.msg).toContain(federationConfig.rc1.additionalUser1.username); - const federatedUserAddedMessageUser1 = historyResponseUser1.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + const federatedUserJoinedMessageUser1 = historyResponseUser1.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(federatedUserAddedMessageUser1).toBeDefined(); - expect(federatedUserAddedMessageUser1?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(federatedUserJoinedMessageUser1).toBeDefined(); + expect(federatedUserJoinedMessageUser1?.msg).toContain(federationConfig.hs1.adminMatrixUserId); }); }); }); @@ -1174,20 +1209,20 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1AdminUserInSynapse).not.toBeNull(); }); - it('It should show the system message that the user added', async () => { + it('It should show the system message that the user joined', async () => { // RC view: Check in RC // Get the room history to find the system messages const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for system messages about the user being added - // 'au' (added user) message types - const addedMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + // Look for system messages about the user joining after accepting invite + // 'uj' (user joined) message types + const joinedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(addedMessage).toBeDefined(); - expect(addedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(joinedMessage).toBeDefined(); + expect(joinedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); }); }); @@ -1301,28 +1336,28 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1User1InSynapseUser1).not.toBeNull(); }); - it('It should show the system messages that the user added on all RCs involved', async () => { + it('It should show the system messages that the user joined on all RCs involved', async () => { // RC view: Check in RC // Get the room history to find the system messages const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for system messages about both users being added - // 'au' (added user) message types - const adminAddedMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + // Look for system messages about both users joining after accepting invites + // 'uj' (user joined) message types + const adminJoinedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(adminAddedMessage).toBeDefined(); - expect(adminAddedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(adminJoinedMessage).toBeDefined(); + expect(adminJoinedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); - const hs1User1AddedMessage = historyResponse.messages.find( + const hs1User1JoinedMessage = historyResponse.messages.find( (message: IMessage) => - message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.additionalUser1.matrixUserId), + message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.additionalUser1.matrixUserId), ); - expect(hs1User1AddedMessage).toBeDefined(); - expect(hs1User1AddedMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); + expect(hs1User1JoinedMessage).toBeDefined(); + expect(hs1User1JoinedMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); }); }); @@ -1366,9 +1401,12 @@ import { SynapseClient } from '../helper/synapse-client'; expect(addUserResponse.body).toHaveProperty('success', true); - // Accept invitation for the federated user (local user is added automatically) + // Accept invitation for the federated user const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); expect(acceptedRoomId).not.toBe(''); + + // Accept invitation for the local user (rc1User1) + await acceptRoomInvite(federatedChannel._id, rc1User1RequestConfig); }, 15000); it('It should show the room on the remote Element or RC and local for the second user', async () => { @@ -1450,48 +1488,89 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1AdminUserInSynapse).not.toBeNull(); }); - it('It should show the 2 system messages that the user added', async () => { + it('It should show the 2 system messages that the user joined', async () => { // RC view: Check in RC (admin view) for system messages about both users joining const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for system messages about both users joining - // 'au' (added user) message types - const localUserAddedMessage = historyResponse.messages.find( + // Look for system messages about both users joining after accepting invites + // 'uj' (user joined) message types + const localUserJoinedMessage = historyResponse.messages.find( (message: IMessage) => - message.t === 'au' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), + message.t === 'uj' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), ); - expect(localUserAddedMessage).toBeDefined(); - expect(localUserAddedMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); + expect(localUserJoinedMessage).toBeDefined(); + expect(localUserJoinedMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); - const federatedUserAddedMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + const federatedUserJoinedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(federatedUserAddedMessage).toBeDefined(); - expect(federatedUserAddedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(federatedUserJoinedMessage).toBeDefined(); + expect(federatedUserJoinedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); - // RC view: Check in RC (user1 view) for system messages about both users being added + // RC view: Check in RC (user1 view) for system messages about both users joining const historyResponseUser1 = await getGroupHistory(federatedChannel._id, rc1User1RequestConfig); expect(Array.isArray(historyResponseUser1.messages)).toBe(true); - // Look for 'au' (added user) message types - const localUserAddedMessageUser1 = historyResponseUser1.messages.find( + // Look for 'uj' (user joined) message types + const localUserJoinedMessageUser1 = historyResponseUser1.messages.find( (message: IMessage) => - message.t === 'au' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), + message.t === 'uj' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), ); - const federatedUserAddedMessageUser1 = historyResponseUser1.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + const federatedUserJoinedMessageUser1 = historyResponseUser1.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(localUserAddedMessageUser1).toBeDefined(); - expect(localUserAddedMessageUser1?.msg).toContain(federationConfig.rc1.additionalUser1.username); + expect(localUserJoinedMessageUser1).toBeDefined(); + expect(localUserJoinedMessageUser1?.msg).toContain(federationConfig.rc1.additionalUser1.username); + + expect(federatedUserJoinedMessageUser1).toBeDefined(); + expect(federatedUserJoinedMessageUser1?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + }); + }); + }); + }); + + describe('Accept/Reject invitation permissions', () => { + describe('User tries to accept another user invitation', () => { + let channelName: string; + let federatedChannel: any; - expect(federatedUserAddedMessageUser1).toBeDefined(); - expect(federatedUserAddedMessageUser1?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + beforeAll(async () => { + channelName = `federated-channel-accept-permission-${Date.now()}`; + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [federationConfig.rc1.additionalUser1.username], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, }); + + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + }, 10000); + + it('It should not allow admin to accept invitation on behalf of another user', async () => { + // RC view: Admin tries to accept rc1User1's invitation + const response = await acceptRoomInvite(federatedChannel._id, rc1AdminRequestConfig); + expect(response.success).toBe(false); + expect(response.error).toBe('Failed to handle invite: No subscription found or user does not have permission to accept or reject this invite'); + }); + + it('It should not allow admin to reject invitation on behalf of another user', async () => { + // RC view: Admin tries to reject rc1User1's invitation + const response = await rejectRoomInvite(federatedChannel._id, rc1AdminRequestConfig); + expect(response.success).toBe(false); + expect(response.error).toBe('Failed to handle invite: No subscription found or user does not have permission to accept or reject this invite'); }); }); }); diff --git a/packages/apps-engine/src/definition/messages/MessageType.ts b/packages/apps-engine/src/definition/messages/MessageType.ts index 1894a92438f24..4b2734c1d3657 100644 --- a/packages/apps-engine/src/definition/messages/MessageType.ts +++ b/packages/apps-engine/src/definition/messages/MessageType.ts @@ -75,6 +75,10 @@ export type MessageType = | 'ru' /** Sent when a user was added */ | 'au' + /** Sent when a user was invited to a room */ + | 'ui' + /** Sent when a user was invited to a room and rejected */ + | 'uir' /** Sent when system messages were muted */ | 'mute_unmute' /** Sent when a room name was changed */ diff --git a/packages/core-services/package.json b/packages/core-services/package.json index dab350f0cde5d..70ddd59351cd2 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/federation-sdk": "0.3.2", + "@rocket.chat/federation-sdk": "0.3.3", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/icons": "~0.45.0", "@rocket.chat/media-signaling": "workspace:^", diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index 1d036069a8d86..ee721b18d5c61 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -1,8 +1,8 @@ -import type { IMessage, IRoomFederated, IRoomNativeFederated, IUser } from '@rocket.chat/core-typings'; +import type { IMessage, IRoomFederated, IRoomNativeFederated, ISubscription, IUser } from '@rocket.chat/core-typings'; import type { EventStore } from '@rocket.chat/federation-sdk'; export interface IFederationMatrixService { - createRoom(room: IRoomFederated, owner: IUser, members: string[]): Promise<{ room_id: string; event_id: string }>; + createRoom(room: IRoomFederated, owner: IUser): Promise<{ room_id: string; event_id: string }>; ensureFederatedUsersExistLocally(members: string[]): Promise; createDirectMessageRoom(room: IRoomFederated, members: IUser[], creatorId: IUser['_id']): Promise; sendMessage(message: IMessage, room: IRoomFederated, user: IUser): Promise; @@ -28,4 +28,5 @@ export interface IFederationMatrixService { inviteUsersToRoom(room: IRoomFederated, usersUserName: string[], inviter: IUser): Promise; notifyUserTyping(rid: string, user: string, isTyping: boolean): Promise; verifyMatrixIds(matrixIds: string[]): Promise<{ [key: string]: string }>; + handleInvite(subscriptionId: ISubscription['_id'], userId: IUser['_id'], action: 'accept' | 'reject'): Promise; } diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index 58d1e09d081ab..ecb850d45ef2b 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -1,4 +1,4 @@ -import type { AtLeast, IRoom, IUser } from '@rocket.chat/core-typings'; +import type { AtLeast, IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; export interface ISubscriptionExtraData { open: boolean; @@ -10,25 +10,19 @@ export interface ISubscriptionExtraData { export interface ICreateRoomOptions extends Partial> { creator: string; subscriptionExtra?: ISubscriptionExtraData; - federatedRoomId?: string; } -export interface ICreateRoomExtraData extends Record { - teamId: string; - teamMain: boolean; -} - -export interface ICreateRoomParams { +export interface ICreateRoomParams { type: IRoom['t']; name: IRoom['name']; members?: Array; // member's usernames readOnly?: boolean; - extraData?: Partial; + extraData?: Partial; options?: ICreateRoomOptions; } export interface IRoomService { addMember(uid: string, rid: string): Promise; - create(uid: string, params: ICreateRoomParams): Promise; + create(uid: string, params: ICreateRoomParams): Promise; createDirectMessage(data: { to: string; from: string }): Promise<{ rid: string }>; createDirectMessageWithMultipleUsers(members: string[], creatorId: string): Promise<{ rid: string }>; addUserToRoom( @@ -42,6 +36,8 @@ export interface IRoomService { }, ): Promise; removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: Pick }): Promise; + performUserRemoval(room: IRoom, user: IUser, options?: { byUser?: IUser }): Promise; + performAcceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser): Promise; getValidRoomName(displayName: string, roomId?: string, options?: { allowDuplicates?: boolean }): Promise; saveRoomTopic( roomId: string, @@ -57,4 +53,14 @@ export interface IRoomService { beforeTopicChange(room: IRoom): Promise; saveRoomName(roomId: string, userId: string, name: string): Promise; addUserRoleRoomScoped(fromUserId: string, userId: string, roomId: string, role: 'moderator' | 'owner' | 'leader' | 'user'): Promise; + createUserSubscription(params: { + room: IRoom; + ts: Date; + userToBeAdded: IUser; + inviter?: Pick; + createAsHidden?: boolean; + skipAlertSound?: boolean; + skipSystemMessage?: boolean; + status?: 'INVITED'; + }): Promise; } diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index d0442adc148d4..381ab53b03d4d 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -68,6 +68,8 @@ export type OtrSystemMessages = (typeof OtrSystemMessagesValues)[number]; const MessageTypes = [ 'e2e', 'uj', + 'ui', + 'uir', 'ul', 'ru', 'au', diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 629ac5ee1e124..c2975b49b07e7 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -116,6 +116,7 @@ export interface IRoomNativeFederated extends IRoomFederated { version: number; // Matrix's room ID. Example: !XqJXqZxXqJXq:matrix.org mrid: string; + origin: string; }; } diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index 742f63dac9c39..b984d2e06d6a6 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -7,6 +7,7 @@ type RoomID = string; export type OldKey = { e2eKeyId: string; ts: Date; E2EKey: string }; +export type SubscriptionStatus = 'INVITED'; export interface ISubscription extends IRocketChatRecord { u: Pick; v?: Pick & { token?: string }; @@ -72,6 +73,9 @@ export interface ISubscription extends IRocketChatRecord { customFields?: Record; oldRoomKeys?: OldKey[]; suggestedOldRoomKeys?: OldKey[]; + + status?: SubscriptionStatus; + inviter?: Pick; } export interface IOmnichannelSubscription extends ISubscription { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 9c98444aeba80..c010eb8b4f2bb 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3368,6 +3368,8 @@ "Message_GroupingPeriodDescription": "Messages will be grouped with previous message if both are from the same user and the elapsed time was less than the informed time in seconds.", "Message_HideType_added_user_to_team": "User added to team", "Message_HideType_au": "User added", + "Message_HideType_ui": "User invited to room", + "Message_HideType_uir": "User rejected invitation to room", "Message_HideType_changed_announcement": "Room announcement changed", "Message_HideType_changed_description": "Room description changed", "Message_HideType_livechat_closed": "Hide \"Conversation finished\" messages", @@ -5565,6 +5567,8 @@ "User_left": "Has left the channel.", "User_left_team": "left this Team", "User_left_this_channel": "left the channel", + "User_invited_to_room": "invited {{user_invited}} to the room", + "User_rejected_invitation_to_room": "rejected invitation to room", "User_left_this_team": "left this team", "User_logged_out": "User is logged out", "User_management": "User Management", diff --git a/packages/message-types/src/registrations/common.ts b/packages/message-types/src/registrations/common.ts index 684ea00633096..88566517c818a 100644 --- a/packages/message-types/src/registrations/common.ts +++ b/packages/message-types/src/registrations/common.ts @@ -25,6 +25,18 @@ export default (instance: MessageTypes) => { text: (t, message) => t('User_added_to', { user_added: message.msg }), }); + instance.registerType({ + id: 'ui', + system: true, + text: (t, message) => t('User_invited_to_room', { user_invited: message.msg }), + }); + + instance.registerType({ + id: 'uir', + system: true, + text: (t) => t('User_rejected_invitation_to_room'), + }); + instance.registerType({ id: 'added-user-to-team', system: true, diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 5da85091ea7b0..5408cdbf94031 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -165,6 +165,8 @@ export interface IRoomsModel extends IBaseModel { findFederatedRooms(options?: FindOptions): FindCursor; + findOneFederatedByMrid(mrid: string, options?: FindOptions): Promise; + findCountOfRoomsWithActiveCalls(): Promise; findBiggestFederatedRoomInNumberOfUsers(options?: FindOptions): Promise; diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 121da7e8ad1c3..c8e1e9ed6c5ff 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -336,4 +336,6 @@ export interface ISubscriptionsModel extends IBaseModel { setE2EKeyByUserIdAndRoomId(userId: string, rid: string, key: string): Promise>; countUsersInRoles(roles: IRole['_id'][], rid: IRoom['_id'] | undefined): Promise; findUserFederatedRoomIds(userId: IUser['_id']): AggregationCursor<{ _id: IRoom['_id']; externalRoomId: string }>; + findInvitedSubscription(roomId: ISubscription['rid'], userId: ISubscription['u']['_id']): Promise; + acceptInvitationById(subscriptionId: ISubscription['_id']): Promise; } diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 59f5151646bab..949014bc360f0 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -866,6 +866,15 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.find(query, options); } + findOneFederatedByMrid(mrid: string, options: FindOptions = {}): Promise { + const query: Filter = { + 'federated': true, + 'federation.mrid': mrid, + }; + + return this.findOne(query, options); + } + findCountOfRoomsWithActiveCalls(): Promise { const query: Filter = { // No matter the actual "status" of the call, if the room has a callStatus, it means there is/was a call diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index acc83474e1b33..99b50ef84de7a 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -161,6 +161,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri const query = { rid, 'u._id': uid, + 'status': { $exists: false }, }; return this.countDocuments(query); @@ -2085,4 +2086,28 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri }, ]); } + + async findInvitedSubscription(roomId: ISubscription['rid'], userId: ISubscription['u']['_id']): Promise { + return this.findOne({ + 'rid': roomId, + 'u._id': userId, + 'status': 'INVITED', + }); + } + + async acceptInvitationById(subscriptionId: string): Promise { + return this.updateOne( + { _id: subscriptionId }, + { + $unset: { + status: 1, + inviter: 1, + }, + $set: { + open: true, + alert: false, + }, + }, + ); + } } diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index 68f83cbe56b38..35400e04052b0 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -1,4 +1,4 @@ -import type { IMessage, IRoom, IUser, RoomAdminFieldsType, IUpload, IE2EEMessage, ITeam, IRole } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, IUser, RoomAdminFieldsType, IUpload, IE2EEMessage, ITeam, ISubscription } from '@rocket.chat/core-typings'; import { ajv } from './Ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; @@ -688,6 +688,29 @@ const roomsHideSchema = { export const isRoomsHideProps = ajv.compile(roomsHideSchema); +type RoomsInviteProps = { + roomId: string; + action: 'accept' | 'reject'; +}; + +const roomsInvitePropsSchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + minLength: 1, + }, + action: { + type: 'string', + enum: ['accept', 'reject'], + }, + }, + required: ['roomId', 'action'], + additionalProperties: false, +}; + +export const isRoomsInviteProps = ajv.compile(roomsInvitePropsSchema); + export type RoomsEndpoints = { '/v1/rooms.autocomplete.channelAndPrivate': { GET: (params: RoomsAutoCompleteChannelAndPrivateProps) => { @@ -861,11 +884,15 @@ export type RoomsEndpoints = { '/v1/rooms.membersOrderedByRole': { GET: (params: RoomsMembersOrderedByRoleProps) => PaginatedResult<{ - members: (IUser & { roles?: IRole['_id'][] })[]; + members: (IUser & { subscription: Pick })[]; }>; }; '/v1/rooms.hide': { POST: (params: RoomsHideProps) => void; }; + + '/v1/rooms.invite': { + POST: (params: RoomsInviteProps) => void; + }; }; diff --git a/yarn.lock b/yarn.lock index 809a8eb8120c3..09e32ee7ae991 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8284,7 +8284,7 @@ __metadata: "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.3.2" + "@rocket.chat/federation-sdk": "npm:0.3.3" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/icons": "npm:~0.45.0" "@rocket.chat/jest-presets": "workspace:~" @@ -8495,7 +8495,7 @@ __metadata: "@rocket.chat/ddp-client": "workspace:^" "@rocket.chat/emitter": "npm:^0.31.25" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.3.2" + "@rocket.chat/federation-sdk": "npm:0.3.3" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/license": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -8521,9 +8521,9 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/federation-sdk@npm:0.3.2": - version: 0.3.2 - resolution: "@rocket.chat/federation-sdk@npm:0.3.2" +"@rocket.chat/federation-sdk@npm:0.3.3": + version: 0.3.3 + resolution: "@rocket.chat/federation-sdk@npm:0.3.3" dependencies: "@datastructures-js/priority-queue": "npm:^6.3.5" "@noble/ed25519": "npm:^3.0.0" @@ -8536,7 +8536,7 @@ __metadata: zod: "npm:^3.24.1" peerDependencies: typescript: ~5.9.2 - checksum: 10/53c0179437425b731a5f77792ee8bf271526499474db4f9e1fdb946ef3b2697a4fd6feceec8d9576f50619ffade51d2ef1bd0cf3f8200024ceb9dcc944a4c561 + checksum: 10/e93f0d59da8508ee0ecfb53f6d599f93130ab4b9f651d87da77615355261e4852d6f4659aae9f4c8881cf4b26a628512e6363ecc407d6e01a31a27d20b9d7684 languageName: node linkType: hard @@ -9187,7 +9187,7 @@ __metadata: "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/favicon": "workspace:^" "@rocket.chat/federation-matrix": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.3.2" + "@rocket.chat/federation-sdk": "npm:0.3.3" "@rocket.chat/freeswitch": "workspace:^" "@rocket.chat/fuselage": "npm:^0.69.0" "@rocket.chat/fuselage-forms": "npm:~0.1.1"