Skip to content

Commit

Permalink
feat: Bot warning when mentioning users not in a channel (#30464)
Browse files Browse the repository at this point in the history
Co-authored-by: Guilherme Jun Grillo <48109548+guijun13@users.noreply.github.com>
  • Loading branch information
gabriellsh and guijun13 authored Dec 14, 2023
1 parent c407aaf commit 44dd24d
Show file tree
Hide file tree
Showing 8 changed files with 476 additions and 46 deletions.
5 changes: 5 additions & 0 deletions .changeset/fresh-socks-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rocket.chat/meteor": minor
---

Mentioning users that are not in the channel now dispatches a warning message with actions
1 change: 1 addition & 0 deletions apps/meteor/app/lib/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import './methods/createToken';
import './methods/deleteMessage';
import './methods/deleteUserOwnAccount';
import './methods/executeSlashCommandPreview';
import './startup/mentionUserNotInChannel';
import './methods/getChannelHistory';
import './methods/getRoomJoinCode';
import './methods/getRoomRoles';
Expand Down
47 changes: 1 addition & 46 deletions apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { Room } from '@rocket.chat/core-services';
import { Subscriptions, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import moment from 'moment';

import { callbacks } from '../../../../lib/callbacks';
Expand Down Expand Up @@ -347,50 +345,7 @@ export async function sendAllNotifications(message, room) {
return message;
}

const { sender, hasMentionToAll, hasMentionToHere, notificationMessage, mentionIds, mentionIdsWithoutGroups } =
await sendMessageNotifications(message, room);

// on public channels, if a mentioned user is not member of the channel yet, he will first join the channel and then be notified based on his preferences.
if (room.t === 'c') {
// get subscriptions from users already in room (to not send them a notification)
const mentions = [...mentionIdsWithoutGroups];
const cursor = Subscriptions.findByRoomIdAndUserIds(room._id, mentionIdsWithoutGroups, {
projection: { 'u._id': 1 },
});

for await (const subscription of cursor) {
const index = mentions.indexOf(subscription.u._id);
if (index !== -1) {
mentions.splice(index, 1);
}
}

const users = await Promise.all(
mentions.map(async (userId) => {
await Room.join({ room, user: { _id: userId } });

return userId;
}),
).catch((error) => {
throw new Meteor.Error(error);
});

const subscriptions = await Subscriptions.findByRoomIdAndUserIds(room._id, users).toArray();
users.forEach((userId) => {
const subscription = subscriptions.find((subscription) => subscription.u._id === userId);

void sendNotification({
subscription,
sender,
hasMentionToAll,
hasMentionToHere,
message,
notificationMessage,
room,
mentionIds,
});
});
}
await sendMessageNotifications(message, room);

return message;
}
Expand Down
142 changes: 142 additions & 0 deletions apps/meteor/app/lib/server/startup/mentionUserNotInChannel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { api } from '@rocket.chat/core-services';
import type { IMessage } from '@rocket.chat/core-typings';
import { isDirectMessageRoom, isEditedMessage, isRoomFederated } from '@rocket.chat/core-typings';
import { Subscriptions, Rooms, Users, Settings } from '@rocket.chat/models';
import type { ActionsBlock } from '@rocket.chat/ui-kit';
import moment from 'moment';

import { callbacks } from '../../../../lib/callbacks';
import { getUserDisplayName } from '../../../../lib/getUserDisplayName';
import { isTruthy } from '../../../../lib/isTruthy';
import { i18n } from '../../../../server/lib/i18n';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';

const APP_ID = 'mention-core';
const getBlocks = (mentions: IMessage['mentions'], messageId: string, lng: string | undefined) => {
const stringifiedMentions = JSON.stringify(mentions);
return {
addUsersBlock: {
type: 'button',
appId: APP_ID,
blockId: messageId,
value: stringifiedMentions,
actionId: 'add-users',
text: {
type: 'plain_text',
text: i18n.t('Add_them', undefined, lng),
},
},
dismissBlock: {
type: 'button',
appId: APP_ID,
blockId: messageId,
value: stringifiedMentions,
actionId: 'dismiss',
text: {
type: 'plain_text',
text: i18n.t('Do_nothing', undefined, lng),
},
},
dmBlock: {
type: 'button',
appId: APP_ID,
value: stringifiedMentions,
blockId: messageId,
actionId: 'share-message',
text: {
type: 'plain_text',
text: i18n.t('Let_them_know', undefined, lng),
},
},
} as const;
};

callbacks.add(
'beforeSaveMessage',
async (message) => {
// TODO: check if I need to test this 60 second rule.
// If the message was edited, or is older than 60 seconds (imported)
// the notifications will be skipped, so we can also skip this validation
if (isEditedMessage(message) || (message.ts && Math.abs(moment(message.ts).diff(moment())) > 60000) || !message.mentions) {
return message;
}

const mentions = message.mentions.filter(({ _id }) => _id !== 'all' && _id !== 'here');
if (!mentions.length) {
return message;
}

const room = await Rooms.findOneById(message.rid);
if (!room || isDirectMessageRoom(room) || isRoomFederated(room) || room.t === 'l') {
return message;
}

const subs = await Subscriptions.findByRoomIdAndUserIds(
message.rid,
mentions.map(({ _id }) => _id),
{ projection: { u: 1 } },
).toArray();

// get all users that are mentioned but not in the channel
const mentionsUsersNotInChannel = mentions.filter(({ _id }) => !subs.some((sub) => sub.u._id === _id));

if (!mentionsUsersNotInChannel.length) {
return message;
}

const canAddUsersToThisRoom = await hasPermissionAsync(message.u._id, 'add-user-to-joined-room', message.rid);
const canAddToAnyRoom = await (room.t === 'c'
? hasPermissionAsync(message.u._id, 'add-user-to-any-c-room')
: hasPermissionAsync(message.u._id, 'add-user-to-any-p-room'));
const canDMUsers = await hasPermissionAsync(message.u._id, 'create-d'); // TODO: Perhaps check if user has DM with mentioned user (might be too expensive)
const canAddUsers = canAddUsersToThisRoom || canAddToAnyRoom;
const { language } = (await Users.findOneById(message.u._id)) || {};

const actionBlocks = getBlocks(mentionsUsersNotInChannel, message._id, language);
const elements: ActionsBlock['elements'] = [
canAddUsers && actionBlocks.addUsersBlock,
(canAddUsers || canDMUsers) && actionBlocks.dismissBlock,
canDMUsers && actionBlocks.dmBlock,
].filter(isTruthy);

const messageLabel = canAddUsers
? 'You_mentioned___mentions__but_theyre_not_in_this_room'
: 'You_mentioned___mentions__but_theyre_not_in_this_room_You_can_ask_a_room_admin_to_add_them';

const { value: useRealName } = (await Settings.findOneById('UI_Use_Real_Name')) || {};

const usernamesOrNames = mentionsUsersNotInChannel.map(
({ username, name }) => `*${getUserDisplayName(name, username, Boolean(useRealName))}*`,
);

const mentionsText = usernamesOrNames.join(', ');

// TODO: Mentions style
void api.broadcast('notify.ephemeralMessage', message.u._id, message.rid, {
msg: '',
mentions: mentionsUsersNotInChannel,
tmid: message.tmid,
blocks: [
{
appId: APP_ID,
type: 'section',
text: {
type: 'mrkdwn',
text: i18n.t(messageLabel, { mentions: mentionsText }, language),
},
} as const,
Boolean(elements.length) &&
({
type: 'actions',
appId: APP_ID,
elements,
} as const),
].filter(isTruthy),
private: true,
});

return message;
},
callbacks.priority.LOW,
'mention-user-not-in-channel',
);
7 changes: 7 additions & 0 deletions apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@
"add-team-channel_description": "Permission to add a channel to a team",
"add-team-member": "Add Team Member",
"add-team-member_description": "Permission to add members to a team",
"Add_them": "Add them",
"add-user": "Add User",
"add-user_description": "Permission to add new users to the server via users screen",
"add-user-to-any-c-room": "Add User to Any Public Channel",
Expand Down Expand Up @@ -1705,6 +1706,7 @@
"Do_not_display_unread_counter": "Do not display any counter of this channel",
"Do_not_provide_this_code_to_anyone": "Do not provide this code to anyone.",
"Do_Nothing": "Do Nothing",
"Do_nothing": "Do nothing",
"Do_you_have_any_notes_for_this_conversation": "Do you have any notes for this conversation?",
"Do_you_want_to_accept": "Do you want to accept?",
"Do_you_want_to_change_to_s_question": "Do you want to change to <strong>%s</strong>?",
Expand Down Expand Up @@ -3074,6 +3076,7 @@
"leave-p": "Leave Private Groups",
"leave-p_description": "Permission to leave private groups",
"Lets_get_you_new_one": "Let's get you a new one!",
"Let_them_know": "Let them know",
"License": "License",
"Line": "Line",
"Link": "Link",
Expand Down Expand Up @@ -5841,6 +5844,9 @@
"You_have_not_verified_your_email": "You have not verified your email.",
"You_have_successfully_unsubscribed": "You have successfully unsubscribed from our Mailling List.",
"You_must_join_to_view_messages_in_this_channel": "You must join to view messages in this channel",
"You_mentioned___mentions__but_theyre_not_in_this_room": "You mentioned {{mentions}}, but they're not in this room.",
"You_mentioned___mentions__but_theyre_not_in_this_room_You_can_ask_a_room_admin_to_add_them": "You mentioned {{mentions}}, but they're not in this room. You can ask a room admin to add them.",
"You_mentioned___mentions__but_theyre_not_in_this_room_You_let_them_know_via_dm": "You mentioned {{mentions}}, but they're not in this room. You let them know via DM.",
"You_need_confirm_email": "You need to confirm your email to login!",
"You_need_install_an_extension_to_allow_screen_sharing": "You need install an extension to allow screen sharing",
"You_need_to_change_your_password": "You need to change your password",
Expand Down Expand Up @@ -5879,6 +5885,7 @@
"Your_TOTP_has_been_reset": "Your Two Factor TOTP has been reset.",
"Your_web_browser_blocked_Rocket_Chat_from_opening_tab": "Your web browser blocked Rocket.Chat from opening a new tab.",
"Your_workspace_is_ready": "Your workspace is ready to use 🎉",
"Youre_not_a_part_of__channel__and_I_mentioned_you_there": "You're not a part of {{channel}} and I mentioned you there",
"Zapier": "Zapier",
"registration.page.login.errors.wrongCredentials": "User not found or incorrect password",
"registration.page.login.errors.invalidEmail": "Invalid Email",
Expand Down
125 changes: 125 additions & 0 deletions apps/meteor/server/modules/core-apps/mention.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { api } from '@rocket.chat/core-services';
import type { IUiKitCoreApp } from '@rocket.chat/core-services';
import type { IMessage } from '@rocket.chat/core-typings';
import { Subscriptions, Messages } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';

import { processWebhookMessage } from '../../../app/lib/server/functions/processWebhookMessage';
import { addUsersToRoomMethod } from '../../../app/lib/server/methods/addUsersToRoom';
import { i18n } from '../../lib/i18n';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';

const retrieveMentionsFromPayload = (stringifiedMentions: string): Exclude<IMessage['mentions'], undefined> => {
try {
const mentions = JSON.parse(stringifiedMentions);
if (!Array.isArray(mentions) || !mentions.length || !('username' in mentions[0])) {
throw new Error('Invalid payload');
}
return mentions;
} catch (error) {
throw new Error('Invalid payload');
}
};

export class MentionModule implements IUiKitCoreApp {
appId = 'mention-core';

async blockAction(payload: any): Promise<any> {
const {
actionId,
payload: { value: stringifiedMentions, blockId: referenceMessageId },
} = payload;

const mentions = retrieveMentionsFromPayload(stringifiedMentions);

const usernames = mentions.map(({ username }) => username);

const message = await Messages.findOneById(referenceMessageId, { projection: { _id: 1, tmid: 1 } });

if (!message) {
throw new Error('Mention bot - Failed to retrieve message information');
}

const joinedUsernames = `@${usernames.join(', @')}`;

if (actionId === 'dismiss') {
void api.broadcast('notify.ephemeralMessage', payload.user._id, payload.room, {
msg: i18n.t(
'You_mentioned___mentions__but_theyre_not_in_this_room',
{
mentions: joinedUsernames,
},
payload.user.language,
),
_id: payload.message,
tmid: message.tmid,
mentions,
});
return;
}

if (actionId === 'add-users') {
void addUsersToRoomMethod(payload.user._id, { rid: payload.room, users: usernames as string[] }, payload.user);
void api.broadcast('notify.ephemeralMessage', payload.user._id, payload.room, {
msg: i18n.t(
'You_mentioned___mentions__but_theyre_not_in_this_room',
{
mentions: joinedUsernames,
},
payload.user.language,
),
tmid: message.tmid,
_id: payload.message,
mentions,
});
return;
}

if (actionId === 'share-message') {
const sub = await Subscriptions.findOneByRoomIdAndUserId(payload.room, payload.user._id, { projection: { t: 1, rid: 1, name: 1 } });
// this should exist since the event is fired from withing the room (e.g the user sent a message)
if (!sub) {
throw new Error('Mention bot - Failed to retrieve room information');
}

const roomPath = roomCoordinator.getRouteLink(sub.t, { rid: sub.rid, name: sub.name });
if (!roomPath) {
throw new Error('Mention bot - Failed to retrieve path to room');
}

const messageText = i18n.t(
'Youre_not_a_part_of__channel__and_I_mentioned_you_there',
{
channel: `#${sub.name}`,
},
payload.user.language,
);

const link = new URL(Meteor.absoluteUrl(roomPath));
link.searchParams.set('msg', message._id);
const text = `[ ](${link.toString()})\n${messageText}`;

// forwards message to all DMs
await processWebhookMessage(
{
roomId: mentions.map(({ _id }) => _id),
text,
},
payload.user,
);

void api.broadcast('notify.ephemeralMessage', payload.user._id, payload.room, {
msg: i18n.t(
'You_mentioned___mentions__but_theyre_not_in_this_room_You_let_them_know_via_dm',
{
mentions: joinedUsernames,
},
payload.user.language,
),
tmid: message.tmid,
_id: payload.message,
mentions,
});
}
}
}
2 changes: 2 additions & 0 deletions apps/meteor/server/startup/coreApps.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BannerModule } from '../modules/core-apps/banner.module';
import { CloudAnnouncementsModule } from '../modules/core-apps/cloudAnnouncements.module';
import { MentionModule } from '../modules/core-apps/mention.module';
import { Nps } from '../modules/core-apps/nps.module';
import { VideoConfModule } from '../modules/core-apps/videoconf.module';
import { registerCoreApp } from '../services/uikit-core-app/service';
Expand All @@ -8,3 +9,4 @@ registerCoreApp(new CloudAnnouncementsModule());
registerCoreApp(new Nps());
registerCoreApp(new BannerModule());
registerCoreApp(new VideoConfModule());
registerCoreApp(new MentionModule());
Loading

0 comments on commit 44dd24d

Please sign in to comment.