snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { MessageTypes } from '@rocket.chat/message-types';
import { escapeHTML } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { trim } from '../../../lib/utils/stringUtils';
import { settings } from '../../settings';
import { i18n } from '../i18n';
export const Message = {
parse(msg: IMessage, language: string) {
const messageType = MessageTypes.getType(msg);
if (messageType) {
return messageType.text(i18n.getFixedT(language || Accounts.storageLocation.getItem('userLanguage') || 'en'), msg);
}
if (msg.u && msg.u.username === settings.get('Chatops_Username')) {
msg.html = msg.msg;
return msg.html;
}
msg.html = msg.msg;
if (trim(msg.html) !== '') {
msg.html = escapeHTML(msg.html);
}
msg.html = msg.html.replace(/\n/gm, '<br/>');
return msg.html;
},
};

View File

@@ -0,0 +1,14 @@
import { Permissions } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
Meteor.startup(async () => {
// Add permissions for discussion
const permissions = [
{ _id: 'start-discussion', roles: ['admin', 'user', 'guest', 'app'] },
{ _id: 'start-discussion-other-user', roles: ['admin', 'user', 'owner', 'app'] },
];
for (const permission of permissions) {
await Permissions.create(permission._id, permission.roles);
}
});

View File

@@ -0,0 +1,55 @@
import type { IRoom, MessageTypesValues } from '@rocket.chat/core-typings';
import { Messages } from '@rocket.chat/models';
import { settings } from '../../../settings/cached';
import { notifyOnMessageChange } from '../../notifyListener';
type DiscussionRoom = Pick<IRoom, '_id' | 'msgs' | 'lm' | 'sysMes'>;
/**
* Both the system messages hidden globally and the ones hidden on the room itself are
* filtered out from the room history, so the count has to consider both of them.
* Type `rm` is filtered out because it's already discounted when the message is deleted.
*/
const getHiddenTypesToDiscount = (room: DiscussionRoom): MessageTypesValues[] => {
const globalHiddenTypes = settings.get<MessageTypesValues[]>('Hide_System_Messages');
const globallyHiddenTypes = Array.isArray(globalHiddenTypes) ? globalHiddenTypes : [];
const roomHiddenTypes = Array.isArray(room.sysMes) ? room.sysMes : [];
return [...new Set([...globallyHiddenTypes, ...roomHiddenTypes])]
.flatMap<MessageTypesValues>((type) =>
// `mute_unmute` is a single option covering two different message types
type === 'mute_unmute' ? ['user-muted', 'user-unmuted'] : [type],
)
.filter((type) => type !== 'rm');
};
const getDiscussionMessagesCount = async (room: DiscussionRoom): Promise<number> => {
const hiddenMessageTypes = getHiddenTypesToDiscount(room);
if (!hiddenMessageTypes.length) {
return room.msgs;
}
const hiddenMessagesCount = await Messages.countVisibleByRoomIdContainingTypes(room._id, hiddenMessageTypes);
return Math.max(room.msgs - hiddenMessagesCount, 0);
};
/**
* Copies the current metadata of a discussion (messages count and last message timestamp) to the
* message which links to it on the parent room, and notifies the change to the clients.
*/
export const updateAndNotifyParentRoomWithParentMessage = async (room: DiscussionRoom): Promise<void> => {
room.msgs = await getDiscussionMessagesCount(room);
const parentMessage = await Messages.refreshDiscussionMetadata(room);
if (!parentMessage) {
return;
}
void notifyOnMessageChange({
id: parentMessage._id,
data: parentMessage,
});
};

View File

@@ -0,0 +1,28 @@
import type { EmojiPackages } from '../../../app/emoji/lib/rocketchat';
export const emoji: EmojiPackages = {
packages: {
base: {
emojiCategories: [{ key: 'recent', i18n: 'Frequently_Used' }],
categoryIndex: 0,
emojisByCategory: {
recent: [],
},
toneList: {},
render: (message: string) => message,
renderPicker(emojiToRender) {
if (!emoji.list[emojiToRender]) {
return;
}
const correctPackage = emoji.list[emojiToRender].emojiPackage;
if (!correctPackage) {
return;
}
return emoji.packages[correctPackage]?.renderPicker(emojiToRender);
},
},
},
/** @type {Record<string, unknown>} */
list: {},
};

View File

@@ -0,0 +1,10 @@
import type { MessageTypesValues, IRoom } from '@rocket.chat/core-typings';
export const getHiddenSystemMessages = (room: Pick<IRoom, 'sysMes'>, hiddenSystemMessages: MessageTypesValues[]): MessageTypesValues[] => {
const hiddenTypes = hiddenSystemMessages.reduce((array, value): MessageTypesValues[] => {
const newValue: MessageTypesValues[] = value === 'mute_unmute' ? ['user-muted', 'user-unmuted'] : [value];
return [...array, ...newValue];
}, [] as MessageTypesValues[]);
return Array.isArray(room?.sysMes) ? room.sysMes : hiddenTypes;
};

View File

@@ -0,0 +1,21 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { createMarkdownMessageRenderer, createMarkdownNotificationRenderer } from '../../../app/markdown/lib/markdown';
import { callbacks } from '../callbacks';
export { Markdown } from '../../../app/markdown/lib/markdown';
Meteor.startup(() => {
Tracker.autorun(() => {
const options = {
rootUrl: Meteor.absoluteUrl(),
};
const renderMessage = createMarkdownMessageRenderer(options);
callbacks.add('renderMessage', renderMessage, callbacks.priority.HIGH, 'markdown');
});
const renderNotification = createMarkdownNotificationRenderer();
callbacks.add('renderNotification', renderNotification, callbacks.priority.HIGH, 'filter-markdown');
});

View File

@@ -0,0 +1,128 @@
/*
* Mentions is a named function that will process Mentions
* @param {Object} message - The message object
*/
import { isE2EEMessage, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings';
import { type MentionsParserArgs, MentionsParser } from '../../../../app/mentions/lib/MentionsParser';
import { extractMentionsFromMessageAST } from '../../messages/extractMentionsFromMessageAST';
type MentionsServerArgs = MentionsParserArgs & {
messageMaxAll: () => number;
getChannels: (c: string[]) => Promise<Pick<IRoom, '_id' | 'name' | 'fname' | 'federated'>[]>;
getUsers: (u: string[]) => Promise<{ type: 'team' | 'user'; _id: string; username?: string; name?: string }[]>;
getUser: (u: string) => Promise<IUser | null>;
getTotalChannelMembers: (rid: string) => Promise<number>;
onMaxRoomMembersExceeded: ({ sender, rid }: { sender: IMessage['u']; rid: string }) => Promise<void>;
};
export class MentionsServer extends MentionsParser {
messageMaxAll: MentionsServerArgs['messageMaxAll'];
getChannels: MentionsServerArgs['getChannels'];
getUsers: MentionsServerArgs['getUsers'];
getUser: MentionsServerArgs['getUser'];
getTotalChannelMembers: MentionsServerArgs['getTotalChannelMembers'];
onMaxRoomMembersExceeded: MentionsServerArgs['onMaxRoomMembersExceeded'];
constructor(args: MentionsServerArgs) {
super(args);
this.messageMaxAll = args.messageMaxAll;
this.getChannels = args.getChannels;
this.getUsers = args.getUsers;
this.getUser = args.getUser;
this.getTotalChannelMembers = args.getTotalChannelMembers;
this.onMaxRoomMembersExceeded =
args.onMaxRoomMembersExceeded ||
(() => {
/* do nothing */
});
}
async getUsersByMentions(message: IMessage): Promise<IMessage['mentions']> {
const { msg, rid, u: sender, e2eMentions }: Pick<IMessage, 'msg' | 'rid' | 'u' | 't' | 'e2eMentions'> = message;
const mentions =
isE2EEMessage(message) && e2eMentions?.e2eUserMentions && e2eMentions?.e2eUserMentions.length > 0
? e2eMentions?.e2eUserMentions
: this.getUserMentions(msg);
return this.convertMentionsToUsers(mentions, rid, sender);
}
async convertMentionsToUsers(mentions: string[], rid: string, sender: IMessage['u']): Promise<IMessage['mentions']> {
const mentionsAll: { _id: string; username: string }[] = [];
const userMentions = new Set<string>();
// A message may mention both @all and @here; fetch the member count at
// most once per message instead of once per mention.
let totalChannelMembers: number | undefined;
for (const m of mentions) {
let mention: string;
if (m.includes(':')) {
mention = m.trim();
} else if (m.startsWith('@')) {
mention = m.substring(1);
} else {
mention = m;
}
if (mention !== 'all' && mention !== 'here') {
userMentions.add(mention);
continue;
}
if (this.messageMaxAll() > 0) {
totalChannelMembers ??= await this.getTotalChannelMembers(rid);
if (totalChannelMembers > this.messageMaxAll()) {
await this.onMaxRoomMembersExceeded({ sender, rid });
continue;
}
}
mentionsAll.push({
_id: mention,
username: mention,
});
}
return [...mentionsAll, ...(userMentions.size ? await this.getUsers(Array.from(userMentions)) : [])];
}
async getChannelbyMentions(message: IMessage) {
const { msg, e2eMentions }: Pick<IMessage, 'msg' | 't' | 'e2eMentions'> = message;
const channels =
isE2EEMessage(message) && e2eMentions?.e2eChannelMentions && e2eMentions?.e2eChannelMentions.length > 0
? e2eMentions?.e2eChannelMentions
: this.getChannelMentions(msg);
return this.convertMentionsToChannels(channels);
}
async convertMentionsToChannels(channels: string[]): Promise<Pick<IRoom, '_id' | 'name' | 'fname' | 'federated'>[]> {
// Most messages don't mention any channel; skip the (empty) database
// query entirely in that case. An empty $in matches nothing anyway.
if (channels.length === 0) {
return [];
}
return this.getChannels(channels.map((c) => (c.startsWith('#') ? c.substring(1) : c)));
}
async execute(message: IMessage) {
if (message.md) {
const { mentions, channels } = extractMentionsFromMessageAST(message.md);
message.mentions = await this.convertMentionsToUsers(mentions, message.rid, message.u);
message.channels = await this.convertMentionsToChannels(channels);
return message;
}
message.mentions = await this.getUsersByMentions(message);
message.channels = await this.getChannelbyMentions(message);
return message;
}
}

View File

@@ -0,0 +1,23 @@
import { Team } from '@rocket.chat/core-services';
import type { MessageMention } from '@rocket.chat/core-typings';
import { settings } from '../../../settings';
import { callbacks } from '../../callbacks';
const beforeGetMentions = async (mentionIds: string[], teamMentions: MessageMention[]): Promise<string[]> => {
if (!teamMentions.length) {
return mentionIds;
}
const teamsIds = teamMentions.map(({ _id }) => _id);
const members = await Team.getMembersByTeamIds(teamsIds, { projection: { userId: 1 } });
return [...new Set([...mentionIds, ...members.map(({ userId }) => userId)])];
};
settings.watch<boolean>('Troubleshoot_Disable_Teams_Mention', (value) => {
if (value) {
callbacks.remove('beforeGetMentions', 'before-get-mentions-get-teams');
} else {
callbacks.add('beforeGetMentions', beforeGetMentions, callbacks.priority.MEDIUM, 'before-get-mentions-get-teams');
}
});

View File

@@ -0,0 +1,3 @@
import notifications from '../notifications/core/lib/Notifications';
export const msgStream = notifications.streamRoomMessage;

View File

@@ -0,0 +1,228 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import { Message } from '@rocket.chat/core-services';
import { isQuoteAttachment, isRegisterUser } from '@rocket.chat/core-typings';
import type { IMessage, MessageAttachment, MessageQuoteAttachment } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models';
import { isTruthy } from '@rocket.chat/tools';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../settings';
import { canAccessRoomAsync, roomAccessAttributes } from '../../authorization';
import { hasPermissionAsync } from '../../authorization/hasPermission';
import { methodDeprecationLogger } from '../../deprecationWarningLogger';
import { isTheLastMessage } from '../../messages/isTheLastMessage';
import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../notifyListener';
import { getUserAvatarURL } from '../../utils/getUserAvatarURL';
const recursiveRemove = (msg: MessageAttachment, deep = 1) => {
if (!msg || !isQuoteAttachment(msg)) {
return;
}
if (deep > (settings.get<number>('Message_QuoteChainLimit') ?? 0)) {
delete msg.attachments;
return msg;
}
msg.attachments = Array.isArray(msg.attachments)
? msg.attachments.map((nestedMsg) => recursiveRemove(nestedMsg, deep + 1)).filter(isTruthy)
: undefined;
return msg;
};
const shouldAdd = (attachments: MessageAttachment[], attachment: MessageQuoteAttachment) =>
!attachments.some((_attachment) => isQuoteAttachment(_attachment) && _attachment.message_link === attachment.message_link);
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
pinMessage(message: IMessage, pinnedAt?: Date): IMessage | null;
unpinMessage(message: IMessage): boolean;
}
}
export async function pinMessage(message: IMessage, userId: string, pinnedAt?: Date) {
let originalMessage = await Messages.findOneById(message._id);
if (!originalMessage?.rid) {
throw new Meteor.Error('error-invalid-message', 'Message you are pinning was not found', {
method: 'pinMessage',
action: 'Message_pinning',
});
}
if (!settings.get('Message_AllowPinning')) {
throw new Meteor.Error('error-action-not-allowed', 'Message pinning not allowed', {
method: 'pinMessage',
action: 'Message_pinning',
});
}
if (!(await hasPermissionAsync(userId, 'pin-message', originalMessage.rid))) {
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'pinMessage' });
}
const room = await Rooms.findOneById(originalMessage.rid);
if (!room) {
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'pinMessage' });
}
if (!(await canAccessRoomAsync(room, { _id: userId }))) {
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'pinMessage' });
}
if (originalMessage.pinned) {
return originalMessage;
}
const me = await Users.findOneById(userId);
if (!me) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'pinMessage' });
}
originalMessage.pinned = true;
originalMessage.pinnedAt = pinnedAt || new Date();
originalMessage.pinnedBy = {
_id: userId,
username: me.username,
};
originalMessage = await Message.beforeSave({ message: originalMessage, room, user: me });
await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned);
if (isTheLastMessage(room, originalMessage)) {
await Rooms.setLastMessagePinned(room._id, originalMessage.pinnedBy, originalMessage.pinned);
}
const attachments: MessageAttachment[] = [];
if (Array.isArray(originalMessage.attachments)) {
originalMessage.attachments.forEach((attachment) => {
if (!isQuoteAttachment(attachment) || shouldAdd(attachments, attachment)) {
attachments.push(attachment);
}
});
}
// App IPostMessagePinned event hook
await Apps.self?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, me, originalMessage.pinned);
const pinMessageType = originalMessage.t === 'e2e' ? 'message_pinned_e2e' : 'message_pinned';
return Message.saveSystemMessage(pinMessageType, originalMessage.rid, '', me, {
attachments: [
{
text: originalMessage.msg,
author_name: originalMessage.u.username,
author_icon: getUserAvatarURL(originalMessage.u.username),
...(originalMessage.content && { content: originalMessage.content }),
ts: originalMessage.ts,
attachments: attachments.map(recursiveRemove),
},
],
});
}
export const unpinMessage = async (userId: string, message: IMessage) => {
if (!settings.get('Message_AllowPinning')) {
throw new Meteor.Error('error-action-not-allowed', 'Message pinning not allowed', {
method: 'unpinMessage',
action: 'Message_pinning',
});
}
let originalMessage = await Messages.findOneById(message._id);
if (originalMessage?._id == null) {
throw new Meteor.Error('error-invalid-message', 'Message you are unpinning was not found', {
method: 'unpinMessage',
action: 'Message_pinning',
});
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(originalMessage.rid, userId, { projection: { _id: 1 } });
if (!subscription) {
// If it's a valid message but on a room that the user is not subscribed to, report that the message was not found.
throw new Meteor.Error('error-invalid-message', 'Message you are unpinning was not found', {
method: 'unpinMessage',
action: 'Message_pinning',
});
}
if (!(await hasPermissionAsync(userId, 'pin-message', originalMessage.rid))) {
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'unpinMessage' });
}
const me = await Users.findOneById(userId);
if (!me) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'unpinMessage' });
}
// If we keep history of edits, insert a new message to store history information
if (settings.get('Message_KeepHistory') && isRegisterUser(me)) {
await Messages.cloneAndSaveAsHistoryById(originalMessage._id, me);
}
originalMessage.pinned = false;
originalMessage.pinnedBy = {
_id: userId,
username: me.username,
};
const room = await Rooms.findOneById(originalMessage.rid, { projection: { ...roomAccessAttributes, lastMessage: 1 } });
if (!room) {
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'unpinMessage' });
}
if (!(await canAccessRoomAsync(room, { _id: userId }))) {
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'unpinMessage' });
}
originalMessage = await Message.beforeSave({ message: originalMessage, room, user: me });
if (isTheLastMessage(room, message)) {
await Rooms.setLastMessagePinned(room._id, originalMessage.pinnedBy, originalMessage.pinned);
void notifyOnRoomChangedById(room._id);
}
// App IPostMessagePinned event hook
await Apps.self?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, me, originalMessage.pinned);
await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned);
void notifyOnMessageChange({
id: message._id,
});
return true;
};
Meteor.methods<ServerMethods>({
async pinMessage(message, pinnedAt) {
methodDeprecationLogger.method('pinMessage', '9.0.0', '/v1/chat.pinMessage');
check(message._id, String);
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'pinMessage',
});
}
return pinMessage(message, userId, pinnedAt);
},
async unpinMessage(message) {
methodDeprecationLogger.method('unpinMessage', '9.0.0', '/v1/chat.unPinMessage');
check(message._id, String);
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'unpinMessage',
});
}
return unpinMessage(userId, message);
},
});

View File

@@ -0,0 +1,150 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import { Message } from '@rocket.chat/core-services';
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import { Messages, EmojiCustom, Rooms, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { canAccessRoomAsync } from '../../authorization';
import { hasPermissionAsync } from '../../authorization/hasPermission';
import { callbacks } from '../../callbacks';
import { i18n } from '../../i18n';
import { isTheLastMessage } from '../../messages/isTheLastMessage';
import { notifyOnMessageChange } from '../../notifyListener';
import { emoji } from '../emoji';
export const removeUserReaction = (message: IMessage, reaction: string, username: string) => {
if (!message.reactions) {
return message;
}
const idx = message.reactions[reaction].usernames.indexOf(username);
// user not found in reaction array
if (idx === -1) {
return message;
}
message.reactions[reaction].usernames.splice(idx, 1);
if (!message.reactions[reaction].usernames.length) {
delete message.reactions[reaction];
}
return message;
};
export async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction: string, userAlreadyReacted?: boolean) {
await Message.beforeReacted(message, room);
if (Array.isArray(room.muted) && room.muted.includes(user.username as string)) {
throw new Meteor.Error('error-not-allowed', i18n.t('You_have_been_muted', { lng: user.language }), {
rid: room._id,
});
}
if (room.ro === true && !room.reactWhenReadOnly && !(await hasPermissionAsync(user, 'post-readonly', room._id))) {
// Unless the user was manually unmuted
if (!(room.unmuted || []).includes(user.username as string)) {
throw new Error("You can't send messages because the room is readonly.");
}
}
let isReacted;
if (userAlreadyReacted) {
const oldMessage = JSON.parse(JSON.stringify(message));
removeUserReaction(message, reaction, user.username as string);
if (Object.keys(message.reactions || {}).length === 0) {
delete message.reactions;
await Messages.unsetReactions(message._id);
if (isTheLastMessage(room, message)) {
await Rooms.unsetReactionsInLastMessage(room._id);
}
} else {
await Messages.setReactions(message._id, message.reactions);
if (isTheLastMessage(room, message)) {
await Rooms.setReactionsInLastMessage(room._id, message.reactions);
}
}
void callbacks.run('afterUnsetReaction', message, { user, reaction, shouldReact: false, oldMessage, room });
isReacted = false;
} else {
if (!message.reactions) {
message.reactions = {};
}
if (!message.reactions[reaction]) {
message.reactions[reaction] = {
usernames: [],
};
}
message.reactions[reaction].usernames.push(user.username as string);
await Messages.setReactions(message._id, message.reactions);
if (isTheLastMessage(room, message)) {
await Rooms.setReactionsInLastMessage(room._id, message.reactions);
}
void callbacks.run('afterSetReaction', message, { user, reaction, shouldReact: true, room });
isReacted = true;
}
void Apps.self?.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted);
void notifyOnMessageChange({
id: message._id,
});
}
export async function executeSetReaction(
userId: string,
reaction: string,
messageParam: IMessage['_id'] | IMessage,
shouldReact?: boolean,
) {
// Check if the emoji is valid before proceeding
const reactionWithoutColons = reaction.replace(/:/g, '');
reaction = `:${reactionWithoutColons}:`;
if (!emoji.list[reaction] && (await EmojiCustom.countByNameOrAlias(reactionWithoutColons)) === 0) {
throw new Meteor.Error('error-not-allowed', 'Invalid emoji provided.', {
method: 'setReaction',
});
}
const user = await Users.findOneById(userId);
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setReaction' });
}
const message = typeof messageParam === 'string' ? await Messages.findOneById(messageParam) : messageParam;
if (!message) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' });
}
const userAlreadyReacted = Boolean(message.reactions?.[reaction]?.usernames?.includes(user.username as string));
// When shouldReact was not informed, toggle the reaction.
if (shouldReact === undefined) {
shouldReact = !userAlreadyReacted;
}
if (userAlreadyReacted === shouldReact) {
return;
}
const room = await Rooms.findOneById(message.rid);
if (!room) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' });
}
if (!(await canAccessRoomAsync(room, user))) {
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'setReaction' });
}
return setReaction(room, user, message, reaction, userAlreadyReacted);
}
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
setReaction(reaction: string, messageId: IMessage['_id'], shouldReact?: boolean): boolean | undefined;
}
}

View File

@@ -0,0 +1,77 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Subscriptions, Rooms } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../settings';
import { canAccessRoomAsync, roomAccessAttributes } from '../../authorization';
import { methodDeprecationLogger } from '../../deprecationWarningLogger';
import { isTheLastMessage } from '../../messages/isTheLastMessage';
import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../notifyListener';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
starMessage(message: Pick<IMessage, 'rid' | '_id'> & { starred: boolean }): boolean;
}
}
export const starMessage = async (user: IUser, message: Pick<IMessage, 'rid' | '_id'> & { starred: boolean }): Promise<boolean> => {
if (!settings.get('Message_AllowStarring')) {
throw new Meteor.Error('error-action-not-allowed', 'Message starring not allowed', {
method: 'starMessage',
action: 'Message_starring',
});
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(message.rid, user._id, {
projection: { _id: 1 },
});
if (!subscription) {
return false;
}
if (!(await Messages.findOneByRoomIdAndMessageId(message.rid, message._id))) {
return false;
}
const room = await Rooms.findOneById(message.rid, { projection: { ...roomAccessAttributes, lastMessage: 1 } });
if (!room) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'starMessage' });
}
if (!(await canAccessRoomAsync(room, { _id: user._id }))) {
throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'starMessage' });
}
if (isTheLastMessage(room, message)) {
await Rooms.updateLastMessageStar(room._id, user._id, message.starred);
void notifyOnRoomChangedById(room._id);
}
await Apps.self?.triggerEvent(AppEvents.IPostMessageStarred, message, user, message.starred);
await Messages.updateUserStarById(message._id, user._id, message.starred);
void notifyOnMessageChange({
id: message._id,
});
return true;
};
Meteor.methods<ServerMethods>({
async starMessage(message) {
methodDeprecationLogger.method('starMessage', '9.0.0', '/v1/chat.starMessage');
const user = (await Meteor.userAsync()) as IUser;
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'starMessage',
});
}
return starMessage(user, message);
},
});

View File

@@ -0,0 +1,17 @@
# Threads
# TODO
* Tests
* reply to message and check if thread is created
* reply to a msg reply and see if thead id updated
* remove the unique reply of a thread and see if the thread is removed
* remove some message from a thread and see if tlm is updated
* open threads list
* start a thread sending a reply, the thread should appear on the list
* remove a thread, the thread should be removed from the list
* open a thread
* send a new reply, the message should appear
* delete a reply, the message should be removed
* remove the thread, the panel should close

View File

@@ -0,0 +1,97 @@
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import { isEditedMessage } from '@rocket.chat/core-typings';
import { Messages, Subscriptions, NotificationQueue } from '@rocket.chat/models';
import { getMentions, getUserIdsFromHighlights } from '../../../hooks/messages/notifyUsersOnMessage';
import { callbacks } from '../../callbacks';
import { notifyOnSubscriptionChangedByRoomIdAndUserIds, notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../notifyListener';
export async function reply({ tmid }: { tmid?: string }, message: IMessage, parentMessage: IMessage, followers: string[]) {
if (!tmid || isEditedMessage(message)) {
return false;
}
const { rid, ts, u } = message;
const { toAll, toHere, mentionIds } = await getMentions(message);
const addToReplies = [
...new Set([
...followers,
...mentionIds,
...(Array.isArray(parentMessage.replies) && parentMessage.replies.length ? [u._id] : [parentMessage.u._id, u._id]),
]),
];
await Messages.updateRepliesByThreadId(tmid, addToReplies, ts);
const [highlightsUids, threadFollowers] = await Promise.all([
getUserIdsFromHighlights(rid, message),
Messages.getThreadFollowsByThreadId(tmid),
]);
const threadFollowersUids = threadFollowers?.filter((userId) => userId !== u._id && !mentionIds.includes(userId)) || [];
// Notify everyone involved in the thread
const notifyOptions = toAll || toHere ? { groupMention: true } : {};
// Notify message mentioned users and highlights
const mentionedUsers = [...new Set([...mentionIds, ...highlightsUids])];
const promises = [Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, threadFollowersUids, tmid, notifyOptions)];
if (mentionedUsers.length) {
promises.push(Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, mentionedUsers, tmid, { userMention: true }));
}
if (highlightsUids.length) {
promises.push(
Subscriptions.setAlertForRoomIdAndUserIds(rid, highlightsUids),
Subscriptions.setOpenForRoomIdAndUserIds(rid, highlightsUids),
);
}
await Promise.allSettled(promises);
void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, [...threadFollowersUids, ...mentionedUsers, ...highlightsUids]);
}
export async function follow({ tmid, uid }: { tmid: string; uid: string }) {
if (!tmid || !uid) {
return false;
}
await Messages.addThreadFollowerByThreadId(tmid, uid);
}
export async function unfollow({ tmid, rid, uid }: { tmid: string; rid: string; uid: string }) {
if (!tmid || !uid) {
return false;
}
const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, uid, tmid);
if (removeUnreadThreadResponse.modifiedCount) {
void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, uid);
}
await Messages.removeThreadFollowerByThreadId(tmid, uid);
}
export const readThread = async ({ user, room, tmid }: { user: IUser; room: IRoom; tmid: string }) => {
const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { tunread: 1 } });
if (!sub) {
return;
}
// if the thread being marked as read is the last one unread also clear the unread subscription flag
const clearAlert = sub.tunread && sub.tunread?.length <= 1 && sub.tunread.includes(tmid);
const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId(room._id, user._id, tmid, clearAlert);
if (removeUnreadThreadResponse.modifiedCount) {
void notifyOnSubscriptionChangedByRoomIdAndUserId(room._id, user._id);
}
await NotificationQueue.clearQueueByUserId(user._id);
callbacks.runAsync('afterReadMessages', room, { uid: user._id, tmid });
};

View File

@@ -0,0 +1,4 @@
import { Logger } from '@rocket.chat/logger';
const logger = new Logger('MessageMarkAsUnread');
export default logger;

View File

@@ -0,0 +1,95 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Subscriptions } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import logger from './logger';
import { methodDeprecationLogger } from '../../deprecationWarningLogger';
import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../notifyListener';
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
unreadMessages(firstUnreadMessage?: Pick<IMessage, '_id'>, room?: IRoom['_id']): void;
}
}
export const unreadMessages = async (userId: string, firstUnreadMessage?: Pick<IMessage, '_id'>, room?: IRoom['_id']): Promise<void> => {
if (room && typeof room === 'string') {
const lastMessage = (
await Messages.findVisibleByRoomId(room, {
limit: 1,
sort: { ts: -1 },
}).toArray()
)[0];
if (!lastMessage) {
throw new Meteor.Error('error-no-message-for-unread', 'There are no messages to mark unread', {
method: 'unreadMessages',
action: 'Unread_messages',
});
}
const setAsUnreadResponse = await Subscriptions.setAsUnreadByRoomIdAndUserId(lastMessage.rid, userId, lastMessage.ts);
if (setAsUnreadResponse.modifiedCount) {
void notifyOnSubscriptionChangedByRoomIdAndUserId(lastMessage.rid, userId);
}
return;
}
if (typeof firstUnreadMessage?._id !== 'string') {
throw new Meteor.Error('error-action-not-allowed', 'Not allowed', {
method: 'unreadMessages',
action: 'Unread_messages',
});
}
const originalMessage = await Messages.findOneById(firstUnreadMessage._id, {
projection: {
u: 1,
rid: 1,
ts: 1,
},
});
if (!originalMessage || userId === originalMessage.u._id) {
throw new Meteor.Error('error-action-not-allowed', 'Not allowed', {
method: 'unreadMessages',
action: 'Unread_messages',
});
}
const lastSeen = (await Subscriptions.findOneByRoomIdAndUserId(originalMessage.rid, userId))?.ls;
if (!lastSeen) {
throw new Meteor.Error('error-subscription-not-found', 'Subscription not found', {
method: 'unreadMessages',
action: 'Unread_messages',
});
}
if (originalMessage.ts >= lastSeen) {
return logger.debug('Provided message is already marked as unread');
}
logger.debug({
msg: 'Updating unread message as the first unread',
timestamp: originalMessage.ts,
});
const setAsUnreadResponse = await Subscriptions.setAsUnreadByRoomIdAndUserId(originalMessage.rid, userId, originalMessage.ts);
if (setAsUnreadResponse.modifiedCount) {
void notifyOnSubscriptionChangedByRoomIdAndUserId(originalMessage.rid, userId);
}
};
Meteor.methods<ServerMethods>({
async unreadMessages(firstUnreadMessage, room) {
methodDeprecationLogger.method('unreadMessages', '9.0.0', '/v1/subscriptions.unread');
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'unreadMessages',
});
}
return unreadMessages(userId, firstUnreadMessage, room);
},
});

View File

@@ -0,0 +1,42 @@
import { ajv } from '@rocket.chat/rest-typings';
import mem from 'mem';
const customFieldsValidate = mem(
(customFieldsSetting: string) => {
const schema = JSON.parse(customFieldsSetting);
if (schema.type && schema.type !== 'object') {
throw new Error('Invalid custom fields config');
}
return ajv.compile({
...schema,
type: 'object',
additionalProperties: false,
});
},
{ maxAge: 1000 * 60 },
);
export const validateCustomMessageFields = ({
customFields,
messageCustomFieldsEnabled,
messageCustomFields,
}: {
customFields: Record<string, any>;
messageCustomFieldsEnabled: boolean;
messageCustomFields: string;
}) => {
// get the json schema for the custom fields of the message and validate it using ajv
// if the validation fails, throw an error
// if there are no custom fields, the message object remains unchanged
if (messageCustomFieldsEnabled !== true) {
throw new Error('Custom fields not enabled');
}
const validate = customFieldsValidate(messageCustomFields);
if (!validate(customFields)) {
throw new Error('Invalid custom fields');
}
};