Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
690fc8f
refactor: remove auto-join invite capability
ricardogarim Nov 17, 2025
5ce51eb
feat: add invited and federation fields to subscription model
ricardogarim Nov 17, 2025
cad34e4
feat: expose invited on user listing for channel member list
ricardogarim Nov 18, 2025
b5c0b10
refactor: remove beforeAddUserToRoom hook
ricardogarim Nov 18, 2025
23308f8
fix: align homeserver event signatures with tidying (event + event_id)
ricardogarim Nov 18, 2025
3889507
refactor: add findOneFederatedByMrid and replace direct model calls
ricardogarim Nov 18, 2025
32b0147
fix: ensure canAccessRoom denies access for invited users
ricardogarim Nov 19, 2025
c0c04d7
chore: improve addUsersToRoom readability
ricardogarim Nov 18, 2025
24b76f8
refactor: clarify inviteUsersToRoom and eliminate redundant loop checks
ricardogarim Nov 18, 2025
d037ff9
feat: add ‘user invited’ and ‘invite rejected’ system messages
ricardogarim Nov 19, 2025
af68472
fix: remove mandatory origin field from invite route to match protocol
ricardogarim Nov 19, 2025
11ca46a
chore: apply eslint fixes and CodeRabbit minor adjustments
ricardogarim Nov 19, 2025
022b945
refactor: add markInviteAsAccepted and replace direct model calls
ricardogarim Nov 19, 2025
2bca9c5
feat: add capabilities for accepting and rejecting invites
ricardogarim Nov 17, 2025
d422053
chore: adjust processInvite input signature*
ricardogarim Nov 19, 2025
5b1ba2d
refactor: change invited prop name to status
ricardogarim Nov 24, 2025
e9ffadc
refactor: change inviter username to subscription root level
ricardogarim Nov 24, 2025
9981a0a
refactor: remove federation invite specifics from subscription
ricardogarim Nov 24, 2025
7f32615
refactor: make room invite route to receive roomId instead of subscri…
ricardogarim Nov 25, 2025
a3e10a5
chore: move helpers to member specific file
ricardogarim Nov 25, 2025
87852ca
chore: adjust acceptRoomInvite signature*
ricardogarim Nov 25, 2025
bee3f14
refactor: adjust markInviteAsAccepted to unset inviterUsername
ricardogarim Nov 25, 2025
a359a2d
refactor: make errors throw instead of soft failing
ricardogarim Nov 25, 2025
25c197f
chore: tidying on createRoom and handleInvite*
ricardogarim Nov 25, 2025
ec4d949
fix: revert addUsersToRoom
ricardogarim Nov 25, 2025
734cb26
chore: remove beforeAddUserToRoom EE hook
ricardogarim Nov 27, 2025
c6c716b
chore: create room using roomName instead of roomId
ricardogarim Nov 27, 2025
045a8c2
chore: ensure correct props are set on subscription during dm creation
ricardogarim Nov 27, 2025
47c27b2
chore: add event.unsigned undefined guard
ricardogarim Nov 27, 2025
b0d5af4
chore: ensure user.username exists on acceptRoomInvite signature
ricardogarim Nov 27, 2025
de4d8af
chore: remove spreaded vars and use object params in membership handl…
ricardogarim Nov 27, 2025
3b744e0
chore: add findInvitedSubscription to subscription model
ricardogarim Nov 27, 2025
48bba11
chore: revert inviteUsersToRoom signature
ricardogarim Nov 28, 2025
612c1c8
chore: remove federationMatrix calls from CE code
ricardogarim Nov 28, 2025
39b5c3d
chore: revert createDirectRoom creator props
ricardogarim Nov 28, 2025
5dd716b
fix: inviterUsername prop typo
ricardogarim Nov 28, 2025
364eaf8
chore: pass route invite_room_state to processInvite
ricardogarim Nov 28, 2025
cf783b4
fix: use previous room naming convention
ricardogarim Nov 28, 2025
4f99db3
feat: add make leave route support
ricardogarim Dec 1, 2025
b1c1859
feat: add send leave route support
ricardogarim Dec 1, 2025
9ad15e5
refactor: isolate homeserver-triggered actions from RC-originated flo…
ricardogarim Dec 1, 2025
73f8469
refactor: add subscription data on findUsersOfRoomOrderedByRole respo…
ricardogarim Dec 1, 2025
855618d
chore: remove acceptInvite from invite controler to use joinUser
ricardogarim Dec 1, 2025
c4fc5d2
chore: extract room type from stripped events before RC room creation
ricardogarim Dec 2, 2025
3e57281
refactor: add subscription to rooms.membersOrderedByRole route signature
ricardogarim Dec 2, 2025
9ab9491
test: fix broken tests due to invite feature
ricardogarim Dec 2, 2025
7522d3e
bump @rocket.chat/federation-sdk
sampaiodiego Dec 2, 2025
ab21ff7
remove acceptRoomInvite from Room service
sampaiodiego Dec 2, 2025
2433bf1
test: ensure accept/reject actions can only be performed by the invit…
ricardogarim Dec 3, 2025
b215fbe
fix: room creation with new federated user
sampaiodiego Dec 4, 2025
fc4849d
fix: get right userId from homeserver.matrix.room.name processing
ricardogarim Dec 4, 2025
6570a83
use creator user id
ricardogarim Dec 4, 2025
0e6deb6
set open, unread and userMentions to newly created DMs
ricardogarim Dec 4, 2025
6ad7804
move logic to create subscription
sampaiodiego Dec 5, 2025
de16650
rename accept subscription model
sampaiodiego Dec 8, 2025
31021a5
add TODOs
sampaiodiego Dec 8, 2025
b3672f6
send whole room object to user removal
sampaiodiego Dec 8, 2025
5b2c5e5
change subscription invite field to `inviter`
sampaiodiego Dec 8, 2025
3b03b14
keep invite code contained
sampaiodiego Dec 8, 2025
fadf98c
validate creator for federated DMs
sampaiodiego Dec 8, 2025
26ea248
perform accept invite on accept
sampaiodiego Dec 8, 2025
72fb063
code cleanup
sampaiodiego Dec 9, 2025
f74bbb4
regression: add inviter param
sampaiodiego Dec 9, 2025
53bf6d8
regression: validate adding federated users to non-federated rooms
sampaiodiego Dec 9, 2025
48771b4
use $unwind to return subscription
sampaiodiego Dec 9, 2025
03677ca
remove unneeded prop federatedRoomId
sampaiodiego Dec 9, 2025
7e00ab8
remove unused params
sampaiodiego Dec 9, 2025
08e1351
remove some federation code spread all around
sampaiodiego Dec 9, 2025
cedc8bf
use additional callback param
sampaiodiego Dec 9, 2025
78f37b5
fix room being created as IRoomNativeFederated
sampaiodiego Dec 9, 2025
f09e1f2
fix federation condition
sampaiodiego Dec 10, 2025
ff339d3
dont do anything if room is already native federated
sampaiodiego Dec 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions apps/meteor/app/api/server/v1/rooms.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,6 +15,9 @@ import {
isRoomsMembersOrderedByRoleProps,
isRoomsChangeArchivationStateProps,
isRoomsHideProps,
isRoomsInviteProps,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { Meteor } from 'meteor/meteor';

Expand Down Expand Up @@ -1073,7 +1076,37 @@ export const roomEndpoints = API.v1.get(
},
);

type RoomEndpoints = ExtractRoutesFromAPI<typeof roomEndpoints>;
const roomInviteEndpoints = API.v1.post(
'rooms.invite',
{
authRequired: true,
body: isRoomsInviteProps,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
200: ajv.compile<void>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
}),
Comment on lines +1087 to +1094
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Response schema declares void but returns object with success.

The 200 response validator (line 1087) is typed as ajv.compile<void> but the schema defines an object with a success boolean property. The generic type should match the schema.

-      200: ajv.compile<void>({
+      200: ajv.compile<{ success: true }>({
         type: 'object',
         properties: {
           success: { type: 'boolean', enum: [true] },
         },
         required: ['success'],
         additionalProperties: false,
       }),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
200: ajv.compile<void>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
}),
200: ajv.compile<{ success: true }>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
}),
🤖 Prompt for AI Agents
In apps/meteor/app/api/server/v1/rooms.ts around lines 1087 to 1094, the 200
response ajv.compile is typed as ajv.compile<void> but the JSON schema defines
an object with a success:boolean property; update the generic to match the
schema (e.g. ajv.compile<{ success: boolean }>) so the TypeScript type reflects
the validated response, and ensure any callers/return types align with the new
typed shape.

},
},
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<typeof roomEndpoints> & ExtractRoutesFromAPI<typeof roomInviteEndpoints>;

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
Expand Down
8 changes: 8 additions & 0 deletions apps/meteor/app/lib/lib/MessageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
61 changes: 61 additions & 0 deletions apps/meteor/app/lib/server/functions/acceptRoomInvite.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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);
});
}
Comment on lines +50 to +60
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Unhandled promise rejections in deferred callbacks.

The process.nextTick callback runs async operations but errors within it are not caught. If any callback or app event fails, the error will become an unhandled promise rejection.

Consider wrapping the deferred logic in a try-catch:

 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);
+    try {
+      await callbacks.run('afterAddedToRoom', { user, inviter }, room);
+      await callbacks.run('afterJoinRoom', user, room);
+      void Apps.self?.triggerEvent(AppEvents.IPostRoomUserJoined, room, user, inviter);
+    } catch (error) {
+      // Log error but don't propagate since this is a deferred callback
+      console.error('Error in post-join callbacks:', error);
+    }
   });
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
});
}
if (room.t === 'c' || room.t === 'p') {
process.nextTick(async () => {
try {
await callbacks.run('afterAddedToRoom', { user, inviter }, room);
await callbacks.run('afterJoinRoom', user, room);
void Apps.self?.triggerEvent(AppEvents.IPostRoomUserJoined, room, user, inviter);
} catch (error) {
console.error('Error in post-join callbacks:', error);
}
});
}
🤖 Prompt for AI Agents
In apps/meteor/app/lib/server/functions/acceptRoomInvite.ts around lines 50–60,
the process.nextTick callback executes async operations without error handling
causing possible unhandled promise rejections; wrap the entire async body in a
try/catch, catch and handle/log any errors from callbacks.run and
Apps.self.triggerEvent (do not let them bubble), and ensure the catch logs the
error context (which room/user/inviter) and prevents an unhandled rejection.

};
74 changes: 22 additions & 52 deletions apps/meteor/app/lib/server/functions/addUserToRoom.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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))
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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
Expand Down
26 changes: 23 additions & 3 deletions apps/meteor/app/lib/server/functions/createDirectRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,8 @@ export async function createDirectRoom(
members: IUser[] | string[],
roomExtraData: Partial<IRoom> = {},
options: {
creator?: string;
creator?: IUser['_id'];
subscriptionExtra?: ISubscriptionExtraData;
federatedRoomId?: string;
},
): Promise<ICreatedRoom> {
const maxUsers = settings.get<number>('DirectMesssage_maxUsers') || 1;
Expand Down Expand Up @@ -155,15 +154,37 @@ 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<ISubscription> =
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 },
{
...(options?.creator === member._id && { $set: { open: true } }),
$setOnInsert: generateSubscription(getFname(otherMembers), getName(otherMembers), member, {
...options?.subscriptionExtra,
...(options?.creator !== member._id && { open: members.length > 2 }),
...subscriptionStatus,
}),
},
{ upsert: true },
Expand All @@ -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);
Expand Down
Loading
Loading