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-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-08-26 11:22:23 +00:00
function cleanAttribute ( attribute ) {
return attribute ? attribute . replace ( /(\n+\s*)+/g , "\n" ) : ""
}
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 ,
codeBlockStyle : "fenced"
2023-08-25 13:43:17 +00:00
} )
2023-08-26 08:30:22 +00:00
turndownService . remove ( "mx-reply" )
2023-08-25 13:43:17 +00:00
turndownService . addRule ( "strikethrough" , {
filter : [ "del" , "s" , "strike" ] ,
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-26 11:22:23 +00:00
turndownService . addRule ( "inlineLink" , {
filter : function ( node , options ) {
return (
options . linkStyle === "inlined" &&
node . nodeName === "A" &&
node . getAttribute ( "href" )
)
} ,
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 title = cleanAttribute ( node . getAttribute ( "title" ) )
if ( title ) title = ` " ` + title + ` " `
let brackets = [ "" , "" ]
if ( href . startsWith ( "https://matrix.to" ) ) brackets = [ "<" , ">" ]
return "[" + content + "](" + brackets [ 0 ] + href + title + brackets [ 1 ] + ")"
}
} )
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-07-04 05:19:17 +00:00
* @ param { Ty . Event . Outer < Ty . Event . M _Room _Message > } 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
if ( member . avatar _url ) avatarURL = utils . getPublicUrlForMxc ( member . avatar _url )
2023-08-19 06:37:34 +00:00
2023-08-25 13:43:17 +00:00
// Convert content depending on what the message is
let content = event . content . body // ultimate fallback
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 } `
}
// 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"
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
console . log ( "a" , originalEventId )
messageIDsToEdit = db . prepare ( "SELECT message_id FROM event_message WHERE event_id = ? ORDER BY part" ) . pluck ( ) . all ( originalEventId )
if ( ! messageIDsToEdit . length ) return
// 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
console . log ( "c" )
// After all that, it's an edit of a reply.
// We'll be sneaky and prepare the message data so that everything else can handle it just like original messages.
Object . assign ( event . content , event . content [ "m.new_content" ] )
input = event . content . formatted _body || event . content . body
relatesTo [ "m.in_reply_to" ] = { event _id : repliedToEventId }
} ) ( )
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
const row = db . prepare ( "SELECT channel_id, message_id FROM event_message WHERE event_id = ? ORDER BY part" ) . get ( repliedToEventId )
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 ) {
replyLine += ` <@ ${ authorID } >: `
} else {
replyLine += ` Ⓜ️** ${ senderName } **: `
}
const repliedToContent = repliedToEvent . content . formatted _body || repliedToEvent . content . body
2023-08-27 11:25:08 +00:00
const contentPreviewChunks = chunk ( repliedToContent . replace ( /.*<\/mx-reply>/ , "" ) . 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-26 11:22:23 +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 } "> `
} )
2023-08-26 08:30:22 +00:00
// 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.
2023-08-26 07:07:19 +00:00
input = input . replace ( /(?:\n|<br ?\/?>\s*)*<\/blockquote>/g , "</blockquote>" )
2023-08-25 13:43:17 +00:00
// 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 ) => {
2023-08-26 07:07:19 +00:00
// console.error(beforeContext, beforeTag, afterContext, afterTag)
2023-08-25 13:43:17 +00:00
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
}
2023-08-19 10:54:23 +00:00
} )
2023-08-25 13:43:17 +00:00
2023-08-26 11:22:23 +00:00
// @ts-ignore bad type from turndown
2023-08-25 13:43:17 +00:00
content = turndownService . turndown ( input )
// It's optimised for commonmark, we need to replace the space-space-newline with just newline
content = content . replace ( / \n/g , "\n" )
2023-08-25 14:04:49 +00:00
} else {
// Looks like we're using the plaintext body!
// Markdown needs to be escaped
content = content . replace ( /([*_~`#])/g , ` \\ $ 1 ` )
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-08-27 13:30:07 +00:00
const messagesToEdit = [ ]
const messagesToSend = [ ]
for ( let i = 0 ; i < messages . length ; i ++ ) {
if ( messageIDsToEdit . length ) {
messagesToEdit . push ( { id : messageIDsToEdit . shift ( ) , message : messages [ i ] } )
} else {
messagesToSend . push ( messages [ i ] )
}
}
return {
messagesToEdit ,
messagesToSend ,
messagesToDelete : messageIDsToEdit
}
2023-07-02 13:06:05 +00:00
}
module . exports . eventToMessage = eventToMessage