1- import type { MentionPill as MentionPillType } from '@vector-im/matrix-bot -sdk' ;
1+ import type { EventID , HomeserverEventSignatures } from '@rocket.chat/federation -sdk' ;
22import { 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 - 9 a - z A - Z - _ .] + ( @ ( [ 0 - 9 a - z A - Z - _ .] + ) ) ? ) : + ( [ 0 - 9 a - z A - Z - _ .] + ) (? = [ ^ < > ] * (?: < \w | $ ) ) / gm; // @username :server.com excluding any <a> tags
14- const INTERNAL_MENTIONS_FOR_INTERNAL_USERS_REGEX = / (?: ^ | (?< = \s ) ) @ ( [ 0 - 9 a - z A - Z - _ .] + ( @ ( [ 0 - 9 a - z A - Z - _ .] + ) ) ? ) (? = [ ^ < > ] * (?: < \w | $ ) ) / gm; // @username , @username.name excluding any <a> tags and emails
15- const INTERNAL_GENERAL_REGEX = / ( @ a l l ) | ( @ h e r e ) / 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 + ) ? h r e f = [ " ' ] ( [ ^ " ' ] * ) [ " ' ] [ ^ > ] * > ( .* ?) < \/ a > / gi,
16+ externalUsers : / @ ( [ 0 - 9 a - z A - Z - _ .] + ( @ ( [ 0 - 9 a - z A - Z - _ .] + ) ) ? ) : + ( [ 0 - 9 a - z A - Z - _ .] + ) (? = [ ^ < > ] * (?: < \w | $ ) ) / gm,
17+ internalUsers : / (?: ^ | (?< = \s ) ) @ ( [ 0 - 9 a - z A - Z - _ .] + ( @ ( [ 0 - 9 a - z A - Z - _ .] + ) ) ? ) (? = [ ^ < > ] * (?: < \w | $ ) ) / gm,
18+ general : / ( @ a l l ) | ( @ h e r e ) / gm,
19+ } ;
20+
21+ const escapeHtml = ( text : string ) : string =>
22+ text . replace ( / [ & < > " ' ] / g, ( c ) => ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } ) [ 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 ) @ a l l (? ! \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
5996export 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
125108export 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-
201130export 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
214147export 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