RocketChat/Rocket.Chat

View on GitHub
apps/meteor/server/services/messages/service.ts

Summary

Maintainability
A
0 mins
Test Coverage
import type { IMessageService } from '@rocket.chat/core-services';
import { Authorization, ServiceClassInternal } from '@rocket.chat/core-services';
import { type IMessage, type MessageTypesValues, type IUser, type IRoom, isEditedMessage } from '@rocket.chat/core-typings';
import { Messages, Rooms } from '@rocket.chat/models';

import { deleteMessage } from '../../../app/lib/server/functions/deleteMessage';
import { sendMessage } from '../../../app/lib/server/functions/sendMessage';
import { updateMessage } from '../../../app/lib/server/functions/updateMessage';
import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../../app/lib/server/lib/notifyListener';
import { notifyUsersOnSystemMessage } from '../../../app/lib/server/lib/notifyUsersOnMessage';
import { executeSendMessage } from '../../../app/lib/server/methods/sendMessage';
import { executeSetReaction } from '../../../app/reactions/server/setReaction';
import { settings } from '../../../app/settings/server';
import { getUserAvatarURL } from '../../../app/utils/server/getUserAvatarURL';
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';

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;

    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 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 }] = await Promise.all([
            Messages.createWithTypeRoomIdMessageUserAndUnread(
                type,
                rid,
                message,
                { _id: userId, username, name },
                settings.get('Message_Read_Receipt_Enabled'),
                extraData,
            ),
            Rooms.incMsgCountById(rid, 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.');
        }

        void notifyOnMessageChange({ id: createdMessage._id, data: createdMessage });
        void notifyOnRoomChangedById(rid);

        return createdMessage;
    }

    async beforeSave({
        message,
        room,
        user,
    }: {
        message: IMessage;
        room: IRoom;
        user: Pick<IUser, '_id' | 'username' | 'name' | 'emails' | 'language'>;
    }): 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 mentionServer.execute(message);
        message = await this.cannedResponse.replacePlaceholders({ message, room, user });
        message = await this.badWords.filterBadWords({ message });
        message = await this.markdownParser.parseMarkdown({ message, config: this.getMarkdownConfig() });
        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;
    }

    private getMarkdownConfig() {
        const customDomains = settings.get<string>('Message_CustomDomain_AutoLink')
            ? settings
                    .get<string>('Message_CustomDomain_AutoLink')
                    .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: IRoom) {
        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');
        }
    }
}