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
353 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|