Skip to content

Commit 9e4968b

Browse files
committed
replace MentionPill from remote lib
1 parent ea56057 commit 9e4968b

File tree

2 files changed

+110
-196
lines changed

2 files changed

+110
-196
lines changed

ee/packages/federation-matrix/package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
"@rocket.chat/eslint-config": "workspace:^",
1111
"@types/emojione": "^2.2.9",
1212
"@types/node": "~22.14.0",
13-
"@types/sanitize-html": "^2",
1413
"babel-jest": "~30.0.0",
1514
"eslint": "~8.45.0",
1615
"jest": "~30.0.0",
@@ -44,13 +43,11 @@
4443
"@rocket.chat/models": "workspace:^",
4544
"@rocket.chat/network-broker": "workspace:^",
4645
"@rocket.chat/rest-typings": "workspace:^",
47-
"@vector-im/matrix-bot-sdk": "^0.7.1-element.6",
4846
"emojione": "^4.5.0",
4947
"marked": "^16.1.2",
5048
"mongodb": "6.10.0",
5149
"pino": "^9.11.0",
5250
"reflect-metadata": "^0.2.2",
53-
"sanitize-html": "^2.17.0",
5451
"tsyringe": "^4.10.0",
5552
"tweetnacl": "^1.0.3"
5653
}
Lines changed: 110 additions & 193 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,96 @@
1-
import type { MentionPill as MentionPillType } from '@vector-im/matrix-bot-sdk';
1+
import type { EventID, HomeserverEventSignatures } from '@rocket.chat/federation-sdk';
22
import { marked } from 'marked';
3-
import sanitizeHtml from 'sanitize-html';
4-
import type { IFrame } from 'sanitize-html';
5-
6-
interface IInternalMention {
7-
mention: string;
8-
realName: string;
9-
}
10-
11-
const DEFAULT_LINK_FOR_MATRIX_MENTIONS = 'https://matrix.to/#/';
12-
const DEFAULT_TAGS_FOR_MATRIX_QUOTES = ['mx-reply', 'blockquote'];
13-
const INTERNAL_MENTIONS_FOR_EXTERNAL_USERS_REGEX = /@([0-9a-zA-Z-_.]+(@([0-9a-zA-Z-_.]+))?):+([0-9a-zA-Z-_.]+)(?=[^<>]*(?:<\w|$))/gm; // @username:server.com excluding any <a> tags
14-
const INTERNAL_MENTIONS_FOR_INTERNAL_USERS_REGEX = /(?:^|(?<=\s))@([0-9a-zA-Z-_.]+(@([0-9a-zA-Z-_.]+))?)(?=[^<>]*(?:<\w|$))/gm; // @username, @username.name excluding any <a> tags and emails
15-
const INTERNAL_GENERAL_REGEX = /(@all)|(@here)/gm;
16-
17-
const getAllMentionsWithTheirRealNames = (message: string, homeServerDomain: string, senderExternalId: string): IInternalMention[] => {
18-
const mentions: IInternalMention[] = [];
19-
sanitizeHtml(message, {
20-
allowedTags: ['a'],
21-
exclusiveFilter: (frame: IFrame): boolean => {
22-
const {
23-
attribs: { href = '' },
24-
tag,
25-
text,
26-
} = frame;
27-
const validATag = tag === 'a' && href && text;
28-
if (!validATag) {
29-
return false;
30-
}
31-
const isUsernameMention = href.includes(DEFAULT_LINK_FOR_MATRIX_MENTIONS) && href.includes('@');
32-
if (isUsernameMention) {
3+
4+
type MatrixMessageContent = HomeserverEventSignatures['homeserver.matrix.message']['content'] & { format?: string };
5+
6+
type MatrixEvent = {
7+
content?: { body?: string; formatted_body?: string };
8+
event_id: string;
9+
sender: string;
10+
};
11+
12+
const MATRIX_TO_URL = 'https://matrix.to/#/';
13+
const MATRIX_QUOTE_TAGS = ['mx-reply', 'blockquote'];
14+
const REGEX = {
15+
anchor: /<a\s+(?:[^>]*?\s+)?href=["']([^"']*)["'][^>]*>(.*?)<\/a>/gi,
16+
externalUsers: /@([0-9a-zA-Z-_.]+(@([0-9a-zA-Z-_.]+))?):+([0-9a-zA-Z-_.]+)(?=[^<>]*(?:<\w|$))/gm,
17+
internalUsers: /(?:^|(?<=\s))@([0-9a-zA-Z-_.]+(@([0-9a-zA-Z-_.]+))?)(?=[^<>]*(?:<\w|$))/gm,
18+
general: /(@all)|(@here)/gm,
19+
};
20+
21+
const escapeHtml = (text: string): string =>
22+
text.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' })[c] || c);
23+
24+
const stripHtml = (html: string, keep: string[] = []): string =>
25+
keep.includes('a') ? html.replace(/<(?!\/?a\b)[^>]+>/gi, '') : html.replace(/<[^>]+>/g, '');
26+
27+
const createMentionHtml = (id: string): string => `<a href="${MATRIX_TO_URL}${id}">${id}</a>`;
28+
29+
const extractAnchors = (html: string) => Array.from(html.matchAll(REGEX.anchor), ([, href, text]) => ({ href, text }));
30+
31+
const extractMentions = (html: string, homeServerDomain: string, senderExternalId: string) =>
32+
extractAnchors(html)
33+
.filter(({ href, text }) => href?.includes(MATRIX_TO_URL) && text)
34+
.map(({ href, text }) => {
35+
if (href.includes('@')) {
3336
const [, username] = href.split('@');
3437
const [, serverDomain] = username.split(':');
38+
const localUsername = `@${username.split(':')[0]}`;
39+
return {
40+
mention: serverDomain === homeServerDomain ? localUsername : `@${username}`,
41+
realName: senderExternalId === text ? localUsername : text,
42+
};
43+
}
44+
return { mention: '@all', realName: text };
45+
});
46+
47+
const replaceMentions = (message: string, mentions: Array<{ mention: string; realName: string }>): string => {
48+
if (!mentions.length) return message;
49+
50+
let result = message;
51+
for (const { mention, realName } of mentions) {
52+
const regex = new RegExp(`(?<!\\w)${realName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\w)`);
53+
if (result.includes(realName)) {
54+
result = result.replace(regex, mention);
55+
} else if (realName.startsWith('!')) {
56+
result = result.replace(/(?<!\w)@all(?!\w)/, mention);
57+
}
58+
}
59+
return result.trim();
60+
};
3561

36-
const withoutServerIdentification = `@${username.split(':').shift()}`;
37-
const fullUsername = `@${username}`;
38-
const isMentioningHimself = senderExternalId === text;
62+
const replaceWithMentionPills = async (message: string, regex: RegExp, createPill: (match: string) => string): Promise<string> => {
63+
const matches = Array.from(message.matchAll(regex), ([match]) => createPill(match.trimStart()));
64+
let i = 0;
65+
return message.replace(regex, () => ` ${matches[i++]}`);
66+
};
3967

40-
mentions.push({
41-
mention: serverDomain === homeServerDomain ? withoutServerIdentification : fullUsername,
42-
realName: isMentioningHimself ? withoutServerIdentification : text,
43-
});
44-
}
45-
const isMentioningAll = href.includes(DEFAULT_LINK_FOR_MATRIX_MENTIONS) && !href.includes('@');
46-
if (isMentioningAll) {
47-
mentions.push({
48-
mention: '@all',
49-
realName: text,
50-
});
51-
}
52-
return false;
53-
},
54-
});
68+
const stripQuotePrefix = (message: string): string => {
69+
const lines = message.split(/\r?\n/);
70+
const index = lines.findIndex((l) => !l.startsWith('>'));
71+
return lines
72+
.slice(index === -1 ? lines.length : index)
73+
.join('\n')
74+
.trim();
75+
};
76+
77+
const createReplyContent = (roomId: string, event: MatrixEvent, textBody: string, htmlBody: string): MatrixMessageContent => {
78+
const body = event.content?.body || '';
79+
const html = event.content?.formatted_body || escapeHtml(body);
80+
const quote = `> <${event.sender}> ${body.split('\n').join('\n> ')}`;
81+
const htmlQuote =
82+
`<mx-reply><blockquote>` +
83+
`<a href="${MATRIX_TO_URL}${roomId}/${event.event_id}">In reply to</a> ` +
84+
`<a href="${MATRIX_TO_URL}${event.sender}">${event.sender}</a><br />${html}` +
85+
`</blockquote></mx-reply>`;
5586

56-
return mentions;
87+
return {
88+
'm.relates_to': { 'm.in_reply_to': { event_id: event.event_id as EventID } },
89+
'msgtype': 'm.text',
90+
'body': `${quote}\n\n${textBody}`,
91+
'format': 'org.matrix.custom.html',
92+
'formatted_body': `${htmlQuote}${htmlBody}`,
93+
};
5794
};
5895

5996
export const toInternalMessageFormat = ({
@@ -66,61 +103,7 @@ export const toInternalMessageFormat = ({
66103
formattedMessage: string;
67104
homeServerDomain: string;
68105
senderExternalId: string;
69-
}): string =>
70-
replaceAllMentionsOneByOneSequentially(
71-
rawMessage,
72-
getAllMentionsWithTheirRealNames(formattedMessage, homeServerDomain, senderExternalId),
73-
);
74-
75-
const MATCH_ANYTHING = 'w';
76-
const replaceAllMentionsOneByOneSequentially = (message: string, allMentionsWithRealNames: IInternalMention[]): string => {
77-
let parsedMessage = '';
78-
let toCompareAgain = message;
79-
80-
if (allMentionsWithRealNames.length === 0) {
81-
return message;
82-
}
83-
84-
allMentionsWithRealNames.forEach(({ mention, realName }, mentionsIndex) => {
85-
const negativeLookAhead = `(?!${MATCH_ANYTHING})`;
86-
const realNameRegex = new RegExp(`(?<!w)${realName}${negativeLookAhead}`);
87-
let realNamePosition = toCompareAgain.search(realNameRegex);
88-
const realNamePresentInMessage = realNamePosition !== -1;
89-
let messageReplacedWithMention = realNamePresentInMessage ? toCompareAgain.replace(realNameRegex, mention) : '';
90-
let positionRemovingLastMention = realNamePresentInMessage ? realNamePosition + realName.length + 1 : -1;
91-
const mentionForRoom = realName.charAt(0) === '!';
92-
if (!realNamePresentInMessage && mentionForRoom) {
93-
const allMention = '@all';
94-
const defaultRegexForRooms = new RegExp(`(?<!w)${allMention}${negativeLookAhead}`);
95-
realNamePosition = toCompareAgain.search(defaultRegexForRooms);
96-
messageReplacedWithMention = toCompareAgain.replace(defaultRegexForRooms, mention);
97-
positionRemovingLastMention = realNamePosition + allMention.length + 1;
98-
}
99-
const lastItem = allMentionsWithRealNames.length - 1;
100-
const lastMentionToProcess = mentionsIndex === lastItem;
101-
const lastMentionPosition = realNamePosition + mention.length + 1;
102-
103-
toCompareAgain = toCompareAgain.slice(positionRemovingLastMention);
104-
parsedMessage += messageReplacedWithMention.slice(0, lastMentionToProcess ? undefined : lastMentionPosition);
105-
});
106-
107-
return parsedMessage.trim();
108-
};
109-
110-
function stripReplyQuote(message: string): string {
111-
const splitLines = message.split(/\r?\n/);
112-
113-
// Find which line the quote ends on
114-
let splitLineIndex = 0;
115-
for (const line of splitLines) {
116-
if (line[0] !== '>') {
117-
break;
118-
}
119-
splitLineIndex += 1;
120-
}
121-
122-
return splitLines.splice(splitLineIndex).join('\n').trim();
123-
}
106+
}): string => replaceMentions(rawMessage, extractMentions(formattedMessage, homeServerDomain, senderExternalId));
124107

125108
export const toInternalQuoteMessageFormat = async ({
126109
homeServerDomain,
@@ -135,69 +118,15 @@ export const toInternalQuoteMessageFormat = async ({
135118
homeServerDomain: string;
136119
senderExternalId: string;
137120
}): Promise<string> => {
138-
const withMentionsOnly = sanitizeHtml(formattedMessage, {
139-
allowedTags: ['a'],
140-
allowedAttributes: {
141-
a: ['href'],
142-
},
143-
nonTextTags: DEFAULT_TAGS_FOR_MATRIX_QUOTES,
121+
let cleaned = formattedMessage;
122+
MATRIX_QUOTE_TAGS.forEach((tag) => {
123+
cleaned = cleaned.replace(new RegExp(`<${tag}[^>]*>.*?</${tag}>`, 'gis'), '');
144124
});
145-
const rawMessageWithoutMatrixQuotingFormatting = stripReplyQuote(rawMessage);
146-
147-
return `[ ](${messageToReplyToUrl}) ${replaceAllMentionsOneByOneSequentially(
148-
rawMessageWithoutMatrixQuotingFormatting,
149-
getAllMentionsWithTheirRealNames(withMentionsOnly, homeServerDomain, senderExternalId),
150-
)}`;
151-
};
152-
153-
const replaceMessageMentions = async (
154-
message: string,
155-
mentionRegex: RegExp,
156-
parseMatchFn: (match: string) => Promise<MentionPillType>,
157-
): Promise<string> => {
158-
const promises: Promise<MentionPillType>[] = [];
159-
160-
message.replace(mentionRegex, (match: string): any => promises.push(parseMatchFn(match)));
161-
162-
const mentions = await Promise.all(promises);
163-
164-
return message.replace(mentionRegex, () => ` ${mentions.shift()?.html}`);
165-
};
125+
cleaned = stripHtml(cleaned, ['a']);
166126

167-
const replaceMentionsFromLocalExternalUsersForExternalFormat = async (message: string): Promise<string> => {
168-
const { MentionPill } = await import('@vector-im/matrix-bot-sdk');
169-
170-
return replaceMessageMentions(message, INTERNAL_MENTIONS_FOR_EXTERNAL_USERS_REGEX, (match: string) =>
171-
MentionPill.forUser(match.trimStart()),
172-
);
173-
};
174-
175-
const replaceInternalUsersMentionsForExternalFormat = async (message: string, homeServerDomain: string): Promise<string> => {
176-
const { MentionPill } = await import('@vector-im/matrix-bot-sdk');
177-
178-
return replaceMessageMentions(message, INTERNAL_MENTIONS_FOR_INTERNAL_USERS_REGEX, (match: string) =>
179-
MentionPill.forUser(`${match.trimStart()}:${homeServerDomain}`),
180-
);
181-
};
182-
183-
const replaceInternalGeneralMentionsForExternalFormat = async (message: string, externalRoomId: string): Promise<string> => {
184-
const { MentionPill } = await import('@vector-im/matrix-bot-sdk');
185-
186-
return replaceMessageMentions(message, INTERNAL_GENERAL_REGEX, () => MentionPill.forRoom(externalRoomId));
127+
return `[ ](${messageToReplyToUrl}) ${replaceMentions(stripQuotePrefix(rawMessage), extractMentions(cleaned, homeServerDomain, senderExternalId))}`;
187128
};
188129

189-
const removeAllExtraBlankSpacesForASingleOne = (message: string): string => message.replace(/\s+/g, ' ').trim();
190-
191-
const replaceInternalWithExternalMentions = async (message: string, externalRoomId: string, homeServerDomain: string): Promise<string> =>
192-
replaceInternalUsersMentionsForExternalFormat(
193-
await replaceMentionsFromLocalExternalUsersForExternalFormat(
194-
await replaceInternalGeneralMentionsForExternalFormat(message, externalRoomId),
195-
),
196-
homeServerDomain,
197-
);
198-
199-
const convertMarkdownToHTML = async (message: string): Promise<string> => marked.parse(message);
200-
201130
export const toExternalMessageFormat = async ({
202131
externalRoomId,
203132
homeServerDomain,
@@ -206,10 +135,14 @@ export const toExternalMessageFormat = async ({
206135
message: string;
207136
externalRoomId: string;
208137
homeServerDomain: string;
209-
}): Promise<string> =>
210-
removeAllExtraBlankSpacesForASingleOne(
211-
await convertMarkdownToHTML((await replaceInternalWithExternalMentions(message, externalRoomId, homeServerDomain)).trim()),
212-
);
138+
}): Promise<string> => {
139+
let result = message;
140+
result = await replaceWithMentionPills(result, REGEX.general, () => createMentionHtml(externalRoomId));
141+
result = await replaceWithMentionPills(result, REGEX.externalUsers, (match) => createMentionHtml(match));
142+
result = await replaceWithMentionPills(result, REGEX.internalUsers, (match) => createMentionHtml(`${match}:${homeServerDomain}`));
143+
144+
return (await marked.parse(result.trim())).replace(/\s+/g, ' ').trim();
145+
};
213146

214147
export const toExternalQuoteMessageFormat = async ({
215148
message,
@@ -224,32 +157,16 @@ export const toExternalQuoteMessageFormat = async ({
224157
message: string;
225158
homeServerDomain: string;
226159
}): Promise<{ message: string; formattedMessage: string }> => {
227-
const { RichReply } = await import('@vector-im/matrix-bot-sdk');
228-
229-
const formattedMessage = await convertMarkdownToHTML(message);
230-
const finalFormattedMessage = await convertMarkdownToHTML(
231-
await toExternalMessageFormat({
232-
message,
233-
externalRoomId,
234-
homeServerDomain,
235-
}),
236-
);
237-
238-
const { formatted_body: formattedBody } = RichReply.createFor(
239-
externalRoomId,
240-
{ event_id: eventToReplyTo, sender: originalEventSender },
241-
formattedMessage,
242-
finalFormattedMessage,
243-
);
244-
const { body } = RichReply.createFor(
245-
externalRoomId,
246-
{ event_id: eventToReplyTo, sender: originalEventSender },
247-
message,
248-
finalFormattedMessage,
249-
);
160+
const event = { event_id: eventToReplyTo, sender: originalEventSender, content: {} };
161+
const markdownHtml = await marked.parse(message);
162+
const withMentions = await toExternalMessageFormat({ message, externalRoomId, homeServerDomain });
163+
const withMentionsHtml = await marked.parse(withMentions);
164+
165+
const reply1 = createReplyContent(externalRoomId, event, markdownHtml, withMentionsHtml);
166+
const reply2 = createReplyContent(externalRoomId, event, message, withMentionsHtml);
250167

251168
return {
252-
message: body,
253-
formattedMessage: formattedBody,
169+
message: reply2.body,
170+
formattedMessage: reply1.formatted_body ?? '',
254171
};
255172
};

0 commit comments

Comments
 (0)