2023-07-02 13:06:05 +00:00
// @ts-check
2023-07-04 05:19:17 +00:00
const Ty = require ( "../../types" )
2023-07-02 13:06:05 +00:00
const DiscordTypes = require ( "discord-api-types/v10" )
2023-08-25 13:43:17 +00:00
const chunk = require ( "chunk-text" )
const TurndownService = require ( "turndown" )
2023-09-02 11:28:41 +00:00
const assert = require ( "assert" ) . strict
2023-07-02 13:06:05 +00:00
const passthrough = require ( "../../passthrough" )
const { sync , db , discord } = passthrough
/** @type {import("../../matrix/file")} */
const file = sync . require ( "../../matrix/file" )
2023-08-26 10:22:54 +00:00
/** @type {import("../converters/utils")} */
const utils = sync . require ( "../converters/utils" )
2023-07-02 13:06:05 +00:00
2023-08-25 13:43:17 +00:00
const BLOCK _ELEMENTS = [
"ADDRESS" , "ARTICLE" , "ASIDE" , "AUDIO" , "BLOCKQUOTE" , "BODY" , "CANVAS" ,
"CENTER" , "DD" , "DETAILS" , "DIR" , "DIV" , "DL" , "DT" , "FIELDSET" , "FIGCAPTION" , "FIGURE" ,
"FOOTER" , "FORM" , "FRAMESET" , "H1" , "H2" , "H3" , "H4" , "H5" , "H6" , "HEADER" ,
"HGROUP" , "HR" , "HTML" , "ISINDEX" , "LI" , "MAIN" , "MENU" , "NAV" , "NOFRAMES" ,
"NOSCRIPT" , "OL" , "OUTPUT" , "P" , "PRE" , "SECTION" , "SUMMARY" , "TABLE" , "TBODY" , "TD" ,
"TFOOT" , "TH" , "THEAD" , "TR" , "UL"
]
2023-09-03 13:37:33 +00:00
/** @type {[RegExp, string][]} */
const markdownEscapes = [
[ /\\/g , '\\\\' ] ,
[ /\*/g , '\\*' ] ,
[ /^-/g , '\\-' ] ,
[ /^\+ /g , '\\+ ' ] ,
[ /^(=+)/g , '\\$1' ] ,
[ /^(#{1,6}) /g , '\\$1 ' ] ,
[ /`/g , '\\`' ] ,
[ /^~~~/g , '\\~~~' ] ,
[ /\[/g , '\\[' ] ,
[ /\]/g , '\\]' ] ,
[ /^>/g , '\\>' ] ,
[ /_/g , '\\_' ] ,
[ /^(\d+)\. /g , '$1\\. ' ]
]
2023-08-25 13:43:17 +00:00
const turndownService = new TurndownService ( {
2023-08-25 14:04:49 +00:00
hr : "----" ,
headingStyle : "atx" ,
preformattedCode : true ,
2023-09-03 13:37:33 +00:00
codeBlockStyle : "fenced" ,
2023-08-25 13:43:17 +00:00
} )
2023-09-03 13:37:33 +00:00
/ * *
* Markdown characters in the HTML content need to be escaped , though take care not to escape the middle of bare links
* @ param { string } string
* /
// @ts-ignore bad type from turndown
turndownService . escape = function ( string ) {
const escapedWords = string . split ( " " ) . map ( word => {
if ( word . match ( /^https?:\/\// ) ) {
return word
} else {
return markdownEscapes . reduce ( function ( accumulator , escape ) {
return accumulator . replace ( escape [ 0 ] , escape [ 1 ] )
} , word )
}
} )
return escapedWords . join ( " " )
}
2023-08-26 08:30:22 +00:00
turndownService . remove ( "mx-reply" )
2023-08-25 13:43:17 +00:00
turndownService . addRule ( "strikethrough" , {
2023-08-30 03:20:39 +00:00
filter : [ "del" , "s" ] ,
2023-08-25 13:43:17 +00:00
replacement : function ( content ) {
return "~~" + content + "~~"
}
} )
2023-08-26 10:59:22 +00:00
turndownService . addRule ( "underline" , {
filter : [ "u" ] ,
replacement : function ( content ) {
return "__" + content + "__"
}
} )
2023-08-26 07:07:19 +00:00
turndownService . addRule ( "blockquote" , {
filter : "blockquote" ,
replacement : function ( content ) {
content = content . replace ( /^\n+|\n+$/g , "" )
content = content . replace ( /^/gm , "> " )
return content
}
} )
2023-08-28 13:36:15 +00:00
turndownService . addRule ( "spoiler" , {
filter : function ( node , options ) {
return node . hasAttribute ( "data-mx-spoiler" )
} ,
replacement : function ( content , node ) {
return "||" + content + "||"
}
} )
2023-08-26 11:22:23 +00:00
turndownService . addRule ( "inlineLink" , {
filter : function ( node , options ) {
2023-08-30 03:20:39 +00:00
return (
2023-08-26 11:22:23 +00:00
node . nodeName === "A" &&
node . getAttribute ( "href" )
2023-08-30 03:20:39 +00:00
)
2023-08-26 11:22:23 +00:00
} ,
replacement : function ( content , node ) {
if ( node . getAttribute ( "data-user-id" ) ) return ` <@ ${ node . getAttribute ( "data-user-id" ) } > `
if ( node . getAttribute ( "data-channel-id" ) ) return ` <# ${ node . getAttribute ( "data-channel-id" ) } > `
const href = node . getAttribute ( "href" )
let brackets = [ "" , "" ]
if ( href . startsWith ( "https://matrix.to" ) ) brackets = [ "<" , ">" ]
2023-08-30 01:29:16 +00:00
return "[" + content + "](" + brackets [ 0 ] + href + brackets [ 1 ] + ")"
2023-08-26 11:22:23 +00:00
}
} )
2023-08-26 07:07:19 +00:00
turndownService . addRule ( "fencedCodeBlock" , {
filter : function ( node , options ) {
return (
options . codeBlockStyle === "fenced" &&
node . nodeName === "PRE" &&
node . firstChild &&
node . firstChild . nodeName === "CODE"
)
} ,
replacement : function ( content , node , options ) {
const className = node . firstChild . getAttribute ( "class" ) || ""
const language = ( className . match ( /language-(\S+)/ ) || [ null , "" ] ) [ 1 ]
const code = node . firstChild
const visibleCode = code . childNodes . map ( c => c . nodeName === "BR" ? "\n" : c . textContent ) . join ( "" ) . replace ( /\n*$/g , "" )
var fence = "```"
return (
fence + language + "\n" +
visibleCode +
"\n" + fence
)
}
} )
2023-08-26 10:22:54 +00:00
/ * *
* @ param { string } roomID
* @ param { string } mxid
* @ returns { Promise < { displayname ? : string ? , avatar _url ? : string ? } > }
* /
async function getMemberFromCacheOrHomeserver ( roomID , mxid , api ) {
const row = db . prepare ( "SELECT displayname, avatar_url FROM member_cache WHERE room_id = ? AND mxid = ?" ) . get ( roomID , mxid )
if ( row ) return row
return api . getStateEvent ( roomID , "m.room.member" , mxid ) . then ( event => {
2023-08-26 10:50:54 +00:00
db . prepare ( "REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)" ) . run ( roomID , mxid , event ? . displayname || null , event ? . avatar _url || null )
2023-08-26 10:22:54 +00:00
return event
} ) . catch ( ( ) => {
return { displayname : null , avatar _url : null }
} )
}
2023-07-02 13:06:05 +00:00
/ * *
2023-09-03 03:40:25 +00:00
* @ param { Ty . Event . Outer _M _Room _Message | Ty . Event . Outer _M _Room _Message _File | Ty . Event . Outer _M _Sticker | Ty . Event . Outer _M _Room _Message _Encrypted _File } event
2023-08-26 08:30:22 +00:00
* @ param { import ( "discord-api-types/v10" ) . APIGuild } guild
* @ param { { api : import ( "../../matrix/api" ) } } di simple - as - nails dependency injection for the matrix API
2023-07-02 13:06:05 +00:00
* /
2023-08-26 08:30:22 +00:00
async function eventToMessage ( event , guild , di ) {
2023-07-02 13:06:05 +00:00
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer}[]})[]} */
2023-08-25 13:43:17 +00:00
let messages = [ ]
2023-07-02 13:06:05 +00:00
2023-08-19 06:37:34 +00:00
let displayName = event . sender
let avatarURL = undefined
2023-08-27 13:30:07 +00:00
/** @type {string[]} */
let messageIDsToEdit = [ ]
2023-08-26 08:30:22 +00:00
let replyLine = ""
2023-08-26 10:22:54 +00:00
// Extract a basic display name from the sender
2023-08-19 06:37:34 +00:00
const match = event . sender . match ( /^@(.*?):/ )
2023-08-26 10:22:54 +00:00
if ( match ) displayName = match [ 1 ]
// Try to extract an accurate display name and avatar URL from the member event
const member = await getMemberFromCacheOrHomeserver ( event . room _id , event . sender , di ? . api )
if ( member . displayname ) displayName = member . displayname
2023-09-02 11:28:41 +00:00
if ( member . avatar _url ) avatarURL = utils . getPublicUrlForMxc ( member . avatar _url ) || undefined
2023-08-19 06:37:34 +00:00
2023-08-25 13:43:17 +00:00
let content = event . content . body // ultimate fallback
2023-09-02 11:28:41 +00:00
const attachments = [ ]
2023-09-03 03:40:25 +00:00
/** @type {({name: string, url: string} | {name: string, url: string, key: string, iv: string})[]} */
2023-09-02 11:28:41 +00:00
const pendingFiles = [ ]
2023-08-25 13:43:17 +00:00
2023-08-28 13:31:52 +00:00
// Convert content depending on what the message is
2023-09-02 11:28:41 +00:00
if ( event . type === "m.room.message" && ( event . content . msgtype === "m.text" || event . content . msgtype === "m.emote" ) ) {
2023-08-27 13:30:07 +00:00
// Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events.
// this event ---is an edit of--> original event ---is a reply to--> past event
await ( async ( ) => {
if ( ! event . content [ "m.new_content" ] ) return
const relatesTo = event . content [ "m.relates_to" ]
if ( ! relatesTo ) return
// Check if we have a pointer to what was edited
const relType = relatesTo . rel _type
if ( relType !== "m.replace" ) return
const originalEventId = relatesTo . event _id
if ( ! originalEventId ) return
messageIDsToEdit = db . prepare ( "SELECT message_id FROM event_message WHERE event_id = ? ORDER BY part" ) . pluck ( ) . all ( originalEventId )
if ( ! messageIDsToEdit . length ) return
2023-08-28 13:31:52 +00:00
// Ok, it's an edit.
event . content = event . content [ "m.new_content" ]
// Is it editing a reply? We need special handling if it is.
2023-08-27 13:30:07 +00:00
// Get the original event, then check if it was a reply
const originalEvent = await di . api . getEvent ( event . room _id , originalEventId )
if ( ! originalEvent ) return
const repliedToEventId = originalEvent . content [ "m.relates_to" ] ? . [ "m.in_reply_to" ] ? . event _id
if ( ! repliedToEventId ) return
2023-08-28 13:31:52 +00:00
2023-08-27 13:30:07 +00:00
// After all that, it's an edit of a reply.
2023-08-28 13:31:52 +00:00
// We'll be sneaky and prepare the message data so that the next steps can handle it just like original messages.
Object . assign ( event . content , {
"m.relates_to" : {
"m.in_reply_to" : {
event _id : repliedToEventId
}
}
} )
2023-08-27 13:30:07 +00:00
} ) ( )
2023-08-26 08:30:22 +00:00
// Handling replies. We'll look up the data of the replied-to event from the Matrix homeserver.
2023-08-27 13:30:07 +00:00
// Note that an <mx-reply> element is not guaranteed because this might be m.new_content.
2023-08-26 08:30:22 +00:00
await ( async ( ) => {
2023-08-27 13:30:07 +00:00
const repliedToEventId = event . content [ "m.relates_to" ] ? . [ "m.in_reply_to" ] ? . event _id
2023-08-26 08:30:22 +00:00
if ( ! repliedToEventId ) return
const repliedToEvent = await di . api . getEvent ( event . room _id , repliedToEventId )
if ( ! repliedToEvent ) return
2023-08-28 05:32:55 +00:00
const row = db . prepare ( "SELECT channel_id, message_id FROM event_message INNER JOIN message_channel USING (message_id) WHERE event_id = ? ORDER BY part" ) . get ( repliedToEventId )
2023-08-26 08:30:22 +00:00
if ( row ) {
replyLine = ` <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/ ${ guild . id } / ${ row . channel _id } / ${ row . message _id } `
} else {
replyLine = ` <:L1:1144820033948762203><:L2:1144820084079087647> `
}
const sender = repliedToEvent . sender
const senderName = sender . match ( /@([^:]*)/ ) ? . [ 1 ] || sender
const authorID = db . prepare ( "SELECT discord_id FROM sim WHERE mxid = ?" ) . pluck ( ) . get ( repliedToEvent . sender )
if ( authorID ) {
2023-08-28 11:36:03 +00:00
replyLine += ` <@ ${ authorID } >: `
2023-08-26 08:30:22 +00:00
} else {
2023-08-28 11:36:03 +00:00
replyLine += ` Ⓜ️** ${ senderName } **: `
2023-08-26 08:30:22 +00:00
}
const repliedToContent = repliedToEvent . content . formatted _body || repliedToEvent . content . body
2023-09-03 04:38:54 +00:00
const contentPreviewChunks = chunk ( repliedToContent . replace ( /.*<\/mx-reply>/ , "" ) . replace ( /.*?<\/blockquote>/ , "" ) . replace ( /(?:\n|<br>)+/g , " " ) . replace ( /<[^>]+>/g , "" ) , 50 )
2023-08-26 08:30:22 +00:00
const contentPreview = contentPreviewChunks . length > 1 ? contentPreviewChunks [ 0 ] + "..." : contentPreviewChunks [ 0 ]
2023-08-27 11:25:08 +00:00
replyLine = ` > ${ replyLine } \n > ${ contentPreview } \n `
2023-08-26 08:30:22 +00:00
} ) ( )
2023-08-28 13:31:52 +00:00
if ( event . content . format === "org.matrix.custom.html" && event . content . formatted _body ) {
let input = event . content . formatted _body
if ( event . content . msgtype === "m.emote" ) {
input = ` * ${ displayName } ${ input } `
2023-08-25 13:43:17 +00:00
}
2023-08-28 13:31:52 +00:00
// Handling mentions of Discord users
input = input . replace ( /("https:\/\/matrix.to\/#\/(@[^"]+)")>/g , ( whole , attributeValue , mxid ) => {
if ( ! utils . eventSenderIsFromDiscord ( mxid ) ) return whole
const userID = db . prepare ( "SELECT discord_id FROM sim WHERE mxid = ?" ) . pluck ( ) . get ( mxid )
if ( ! userID ) return whole
return ` ${ attributeValue } data-user-id=" ${ userID } "> `
} )
// Handling mentions of Discord rooms
input = input . replace ( /("https:\/\/matrix.to\/#\/(![^"]+)")>/g , ( whole , attributeValue , roomID ) => {
const channelID = db . prepare ( "SELECT channel_id FROM channel_room WHERE room_id = ?" ) . pluck ( ) . get ( roomID )
if ( ! channelID ) return whole
return ` ${ attributeValue } data-channel-id=" ${ channelID } "> `
} )
// Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
input = input . replace ( /(?:\n|<br ?\/?>\s*)*<\/blockquote>/g , "</blockquote>" )
// The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason.
// But I should not count it if it's between block elements.
input = input . replace ( /(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g , ( whole , beforeContext , beforeTag , afterContext , afterTag ) => {
// console.error(beforeContext, beforeTag, afterContext, afterTag)
if ( typeof beforeTag !== "string" && typeof afterTag !== "string" ) {
return "<br>"
}
beforeContext = beforeContext || ""
beforeTag = beforeTag || ""
afterContext = afterContext || ""
afterTag = afterTag || ""
if ( ! BLOCK _ELEMENTS . includes ( beforeTag . toUpperCase ( ) ) && ! BLOCK _ELEMENTS . includes ( afterTag . toUpperCase ( ) ) ) {
return beforeContext + "<br>" + afterContext
} else {
return whole
}
} )
// Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me.
// If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works:
// input = input.replace(/ /g, " ")
// There is also a corresponding test to uncomment, named "event2message: whitespace is retained"
// @ts-ignore bad type from turndown
content = turndownService . turndown ( input )
2023-09-02 11:28:41 +00:00
// It's designed for commonmark, we need to replace the space-space-newline with just newline
2023-08-28 13:31:52 +00:00
content = content . replace ( / \n/g , "\n" )
} else {
// Looks like we're using the plaintext body!
content = event . content . body
if ( event . content . msgtype === "m.emote" ) {
content = ` * ${ displayName } ${ content } `
}
2023-08-25 13:43:17 +00:00
2023-09-03 13:37:33 +00:00
// Markdown needs to be escaped, though take care not to escape the middle of links
// @ts-ignore bad type from turndown
content = turndownService . escape ( content )
2023-08-28 13:31:52 +00:00
}
2023-09-02 11:28:41 +00:00
} else if ( event . type === "m.room.message" && ( event . content . msgtype === "m.file" || event . content . msgtype === "m.video" || event . content . msgtype === "m.audio" || event . content . msgtype === "m.image" ) ) {
content = ""
const filename = event . content . body
2023-09-03 03:40:25 +00:00
if ( "url" in event . content ) {
// Unencrypted
const url = utils . getPublicUrlForMxc ( event . content . url )
assert ( url )
attachments . push ( { id : "0" , filename } )
pendingFiles . push ( { name : filename , url } )
} else {
// Encrypted
const url = utils . getPublicUrlForMxc ( event . content . file . url )
assert ( url )
assert . equal ( event . content . file . key . alg , "A256CTR" )
attachments . push ( { id : "0" , filename } )
pendingFiles . push ( { name : filename , url , key : event . content . file . key . k , iv : event . content . file . iv } )
}
2023-09-02 11:28:41 +00:00
} else if ( event . type === "m.sticker" ) {
content = ""
let filename = event . content . body
if ( event . type === "m.sticker" && event . content . info . mimetype . includes ( "/" ) ) {
filename += "." + event . content . info . mimetype . split ( "/" ) [ 1 ]
}
const url = utils . getPublicUrlForMxc ( event . content . url )
assert ( url )
attachments . push ( { id : "0" , filename } )
pendingFiles . push ( { name : filename , url } )
2023-07-02 13:06:05 +00:00
}
2023-08-26 08:30:22 +00:00
content = replyLine + content
2023-08-25 13:43:17 +00:00
// Split into 2000 character chunks
const chunks = chunk ( content , 2000 )
messages = messages . concat ( chunks . map ( content => ( {
content ,
username : displayName ,
avatar _url : avatarURL
} ) ) )
2023-09-02 11:28:41 +00:00
if ( attachments . length ) {
// If content is empty (should be the case when uploading a file) then chunk-text will create 0 messages.
// There needs to be a message to add attachments to.
if ( ! messages . length ) messages . push ( {
content ,
username : displayName ,
avatar _url : avatarURL
} )
messages [ 0 ] . attachments = attachments
// @ts-ignore these will be converted to real files when the message is about to be sent
messages [ 0 ] . pendingFiles = pendingFiles
}
2023-08-27 13:30:07 +00:00
const messagesToEdit = [ ]
const messagesToSend = [ ]
for ( let i = 0 ; i < messages . length ; i ++ ) {
2023-08-28 13:31:52 +00:00
const next = messageIDsToEdit [ 0 ]
if ( next ) {
messagesToEdit . push ( { id : next , message : messages [ i ] } )
messageIDsToEdit . shift ( )
2023-08-27 13:30:07 +00:00
} else {
messagesToSend . push ( messages [ i ] )
}
}
2023-08-30 01:29:16 +00:00
// Ensure there is code coverage for adding, editing, and deleting
if ( messagesToSend . length ) void 0
if ( messagesToEdit . length ) void 0
if ( messageIDsToEdit . length ) void 0
2023-08-27 13:30:07 +00:00
return {
messagesToEdit ,
messagesToSend ,
messagesToDelete : messageIDsToEdit
}
2023-07-02 13:06:05 +00:00
}
module . exports . eventToMessage = eventToMessage