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 { 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 { 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 { 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, room: Partial, upsert = false): Promise { return sendMessage(user, message, room, { upsert }); } async deleteMessage(user: IUser, message: IMessage): Promise { return deleteMessage(message, user); } async updateMessage(message: IMessage, user: IUser, originalMsg?: IMessage, previewUrls?: string[]): Promise { return updateMessage(message, user, originalMsg, previewUrls); } async reactToMessage(userId: string, reaction: string, messageId: IMessage['_id'], shouldReact?: boolean): Promise { return executeSetReaction(userId, reaction, messageId, shouldReact); } async saveSystemMessageAndNotifyUser( type: MessageTypesValues, rid: string, messageText: string, owner: Pick, extraData?: Partial, ): Promise { 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( type: MessageTypesValues, rid: string, message: string, owner: Pick, extraData?: Partial, ): Promise { 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; previewUrls?: string[]; parseUrls?: boolean; }): Promise { // 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('Message_QuoteChainLimit'), siteUrl: settings.get('Site_Url'), useRealName: settings.get('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 { 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('Message_CustomDomain_AutoLink'); const customDomains = customDomainAutoLink ? customDomainAutoLink.split(',').map((domain) => domain.trim()) : []; return { colors: settings.get('HexColorPreview_Enabled'), emoticons: true, customDomains, ...(settings.get('Katex_Enabled') && { katex: { dollarSyntax: settings.get('Katex_Dollar_Syntax'), parenthesisSyntax: settings.get('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) { 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); } }