Files
upstream-mirror 30810eecd7
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 2s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

353 lines
12 KiB
TypeScript

import { AppEvents, Apps } from '@rocket.chat/apps';
import type { IMessageService } from '@rocket.chat/core-services';
import { Authorization, ServiceClassInternal } from '@rocket.chat/core-services';
import { isEditedMessage } from '@rocket.chat/core-typings';
import type { MessageUrl, IMessage, MessageTypesValues, IUser, IRoom, AtLeast } from '@rocket.chat/core-typings';
import { Messages, Rooms } from '@rocket.chat/models';
import { OEmbed } from './hooks/AfterSaveOEmbed';
import { BeforeSaveCannedResponse } from '../../../ee/server/hooks/messages/BeforeSaveCannedResponse';
import { FederationMatrixInvalidConfigurationError } from '../federation/utils';
import { FederationActions } from './hooks/BeforeFederationActions';
import { BeforeSaveBadWords } from './hooks/BeforeSaveBadWords';
import { BeforeSaveCheckMAC } from './hooks/BeforeSaveCheckMAC';
import { BeforeSaveJumpToMessage } from './hooks/BeforeSaveJumpToMessage';
import { BeforeSaveMarkdownParser } from './hooks/BeforeSaveMarkdownParser';
import { mentionServer } from './hooks/BeforeSaveMentions';
import { BeforeSavePreventMention } from './hooks/BeforeSavePreventMention';
import { BeforeSaveSpotify } from './hooks/BeforeSaveSpotify';
import { closeUnclosedCodeBlock } from '../../../lib/utils/closeUnclosedCodeBlock';
import { notifyUsersOnSystemMessage } from '../../hooks/messages/notifyUsersOnMessage';
import { SystemLogger } from '../../lib/logger/system';
import { deleteMessage } from '../../lib/messages/deleteMessage';
import { parseUrlsInMessage } from '../../lib/messages/parseUrlsInMessage';
import { sendMessage } from '../../lib/messages/sendMessage';
import { updateMessage } from '../../lib/messages/updateMessage';
import { updateAndNotifyParentRoomWithParentMessage } from '../../lib/messaging/discussions/updateAndNotifyParentRoomWithParentMessage';
import { executeSetReaction } from '../../lib/messaging/reactions/setReaction';
import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../lib/notifyListener';
import { shouldBreakInVersion } from '../../lib/shouldBreakInVersion';
import { getUserAvatarURL } from '../../lib/utils/getUserAvatarURL';
import { executeSendMessage } from '../../meteor-methods/messages/sendMessage';
import { settings } from '../../settings';
const disableMarkdownParser = ['yes', 'true'].includes(String(process.env.DISABLE_MESSAGE_PARSER).toLowerCase());
export class MessageService extends ServiceClassInternal implements IMessageService {
protected name = 'message';
private preventMention: BeforeSavePreventMention;
private badWords: BeforeSaveBadWords;
private spotify: BeforeSaveSpotify;
private jumpToMessage: BeforeSaveJumpToMessage;
private cannedResponse: BeforeSaveCannedResponse;
private markdownParser: BeforeSaveMarkdownParser;
private checkMAC: BeforeSaveCheckMAC;
override async created() {
this.preventMention = new BeforeSavePreventMention();
this.badWords = new BeforeSaveBadWords();
this.spotify = new BeforeSaveSpotify();
this.jumpToMessage = new BeforeSaveJumpToMessage({
getMessages(messageIds) {
return Messages.findVisibleByIds(messageIds).toArray();
},
getRooms(roomIds) {
return Rooms.findByIds(roomIds).toArray();
},
canAccessRoom(room: IRoom, user: IUser): Promise<boolean> {
return Authorization.canAccessRoom(room, user);
},
getUserAvatarURL(user?: string): string {
return (user && getUserAvatarURL(user)) || '';
},
});
this.cannedResponse = new BeforeSaveCannedResponse();
this.markdownParser = new BeforeSaveMarkdownParser(!disableMarkdownParser);
this.checkMAC = new BeforeSaveCheckMAC();
await this.configureBadWords();
}
private async configureBadWords() {
settings.watchMultiple(
['Message_AllowBadWordsFilter', 'Message_BadWordsFilterList', 'Message_BadWordsWhitelist'],
async ([enabled, badWordsList, whiteList]) => {
if (!enabled) {
this.badWords.disable();
return;
}
await this.badWords.configure(badWordsList as string, whiteList as string);
},
);
}
async sendMessage({ fromId, rid, msg }: { fromId: string; rid: string; msg: string }): Promise<IMessage> {
return executeSendMessage(fromId, { rid, msg });
}
async saveMessageFromFederation({
fromId,
rid,
federation_event_id,
msg,
e2e_content,
file,
files,
attachments,
thread,
ts,
}: {
fromId: string;
rid: string;
federation_event_id: string;
msg?: string;
e2e_content?: {
algorithm: 'm.megolm.v1.aes-sha2';
ciphertext: string;
};
file?: IMessage['file'];
files?: IMessage['files'];
attachments?: IMessage['attachments'];
thread?: { tmid: string; tshow: boolean };
ts: Date;
}): Promise<IMessage> {
return executeSendMessage(
fromId,
{
rid,
msg,
...thread,
federation: {
eventId: federation_event_id,
version: 1,
},
...(file && { file }),
...(files && { files }),
...(attachments && { attachments }),
...(e2e_content && {
t: 'e2e',
content: e2e_content,
}),
},
{ ts },
);
}
async sendMessageWithValidation(user: IUser, message: Partial<IMessage>, room: Partial<IRoom>, upsert = false): Promise<IMessage> {
return sendMessage(user, message, room, { upsert });
}
async deleteMessage(user: IUser, message: IMessage): Promise<void> {
return deleteMessage(message, user);
}
async updateMessage(message: IMessage, user: IUser, originalMsg?: IMessage, previewUrls?: string[]): Promise<void> {
return updateMessage(message, user, originalMsg, previewUrls);
}
async reactToMessage(userId: string, reaction: string, messageId: IMessage['_id'], shouldReact?: boolean): Promise<void> {
return executeSetReaction(userId, reaction, messageId, shouldReact);
}
async saveSystemMessageAndNotifyUser<T = IMessage>(
type: MessageTypesValues,
rid: string,
messageText: string,
owner: Pick<IUser, '_id' | 'username' | 'name'>,
extraData?: Partial<T>,
): Promise<IMessage> {
const createdMessage = await this.saveSystemMessage(type, rid, messageText, owner, extraData);
const room = await Rooms.findOneById(rid);
if (!room) {
throw new Error('Failed to find the room.');
}
await notifyUsersOnSystemMessage(createdMessage, room);
return createdMessage;
}
async saveSystemMessage<T = IMessage>(
type: MessageTypesValues,
rid: string,
message: string,
owner: Pick<IUser, '_id' | 'username' | 'name'>,
extraData?: Partial<T>,
): Promise<IMessage> {
const { _id: userId, username, name } = owner;
if (!username) {
throw new Error('The username cannot be empty.');
}
const [{ insertedId }, room] = await Promise.all([
Messages.createWithTypeRoomIdMessageUserAndUnread(
type,
rid,
message,
{ _id: userId, username, name },
settings.get('Message_Read_Receipt_Enabled'),
extraData,
),
Rooms.findOneAndIncMsgCountById(rid, 1, { projection: { prid: 1, msgs: 1, lm: 1, sysMes: 1 } }),
]);
if (!insertedId) {
throw new Error('Failed to save system message.');
}
const createdMessage = await Messages.findOneById(insertedId);
if (!createdMessage) {
throw new Error('Failed to find the created message.');
}
if (Apps.self?.isLoaded()) {
void Apps.self?.triggerEvent(AppEvents.IPostSystemMessageSent, createdMessage);
}
void notifyOnMessageChange({ id: createdMessage._id, data: createdMessage });
void notifyOnRoomChangedById(rid);
if (room?.prid) {
try {
await updateAndNotifyParentRoomWithParentMessage(room);
} catch (err) {
SystemLogger.error({ msg: 'Failed to propagate discussion metadata', err, rid });
}
}
return createdMessage;
}
async beforeSave({
message,
room,
user,
previewUrls,
parseUrls = true,
}: {
message: IMessage;
room: IRoom;
user: Pick<IUser, '_id' | 'username' | 'name' | 'emails' | 'language'>;
previewUrls?: string[];
parseUrls?: boolean;
}): Promise<IMessage> {
// TODO looks like this one was not being used (so I'll left it commented)
// await this.joinDiscussionOnMessage({ message, room, user });
if (!FederationActions.shouldPerformAction(message, room)) {
throw new FederationMatrixInvalidConfigurationError('Unable to send message');
}
message = await this.cannedResponse.replacePlaceholders({ message, room, user });
message = await this.badWords.filterBadWords({ message });
// TODO: Auto-close unclosed markdown code blocks for server versions below 9.0.0
// In 9.0.0, this behavior is handled on the client side, so this block should be removed.
if (!shouldBreakInVersion('9.0.0') && message.msg) {
message = { ...message, msg: closeUnclosedCodeBlock(message.msg) };
}
message = await this.markdownParser.parseMarkdown({ message, config: this.getMarkdownConfig() });
message = await mentionServer.execute(message);
if (parseUrls) {
message.urls = parseUrlsInMessage(message, previewUrls);
}
message = await this.spotify.convertSpotifyLinks({ message });
message = await this.jumpToMessage.createAttachmentForMessageURLs({
message,
user,
config: {
chainLimit: settings.get<number>('Message_QuoteChainLimit'),
siteUrl: settings.get<string>('Site_Url'),
useRealName: settings.get<boolean>('UI_Use_Real_Name'),
},
});
if (!this.isEditedOrOld(message)) {
await Promise.all([
this.checkMAC.isWithinLimits({ message, room }),
this.preventMention.preventMention({ message, user, mention: 'all', permission: 'mention-all' }),
this.preventMention.preventMention({ message, user, mention: 'here', permission: 'mention-here' }),
]);
}
return message;
}
// The actions made on this event should be asynchronous
// That means, caller should not expect to receive updated message
// after calling
async afterSave({ message }: { message: IMessage }): Promise<void> {
await OEmbed.rocketUrlParser(message);
// Since this will happen after the message is sent and ack on the UI
// we'll notify until after these hooks are finished
void notifyOnMessageChange({ id: message._id });
}
private getMarkdownConfig() {
const customDomainAutoLink = settings.get<string>('Message_CustomDomain_AutoLink');
const customDomains = customDomainAutoLink ? customDomainAutoLink.split(',').map((domain) => domain.trim()) : [];
return {
colors: settings.get<boolean>('HexColorPreview_Enabled'),
emoticons: true,
customDomains,
...(settings.get<boolean>('Katex_Enabled') && {
katex: {
dollarSyntax: settings.get<boolean>('Katex_Dollar_Syntax'),
parenthesisSyntax: settings.get<boolean>('Katex_Parenthesis_Syntax'),
},
}),
};
}
private isEditedOrOld(message: IMessage): boolean {
return isEditedMessage(message) || !message.ts || Math.abs(Date.now() - message.ts.getTime()) > 60000;
}
// joinDiscussionOnMessage
// private async joinDiscussionOnMessage({ message, room, user }: { message: IMessage; room: IRoom; user: IUser }) {
// // abort if room is not a discussion
// if (!room.prid) {
// return;
// }
// // check if user already joined the discussion
// const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, message.u._id, {
// projection: { _id: 1 },
// });
// if (sub) {
// return;
// }
// await Room.join({ room, user });
// }
async beforeReacted(message: IMessage, room: AtLeast<IRoom, 'federated'>) {
if (!FederationActions.shouldPerformAction(message, room)) {
throw new FederationMatrixInvalidConfigurationError('Unable to react to message');
}
}
async beforeDelete(message: IMessage, room: IRoom) {
if (!FederationActions.shouldPerformAction(message, room)) {
throw new FederationMatrixInvalidConfigurationError('Unable to delete message');
}
}
async parseOEmbedUrl(url: string): Promise<{
urlPreview: MessageUrl;
foundMeta: boolean;
}> {
return OEmbed.parseUrl(url);
}
}