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-10-14 11:26:52 +00:00
const { Readable } = require ( "stream" )
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-10-13 10:23:15 +00:00
const entities = require ( "entities" )
2023-07-02 13:06:05 +00:00
const passthrough = require ( "../../passthrough" )
2023-09-18 10:51:59 +00:00
const { sync , db , discord , select , from } = passthrough
2023-08-26 10:22:54 +00:00
/** @type {import("../converters/utils")} */
2023-11-30 03:27:40 +00:00
const mxUtils = sync . require ( "../converters/utils" )
/** @type {import("../../discord/utils")} */
const dUtils = sync . require ( "../../discord/utils" )
2023-09-23 13:55:47 +00:00
/** @type {import("./emoji-sheet")} */
const emojiSheet = sync . require ( "./emoji-sheet" )
2023-07-02 13:06:05 +00:00
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" ) } > `
2023-11-30 03:27:40 +00:00
if ( node . getAttribute ( "data-message-id" ) ) return ` https://discord.com/channels/ ${ node . getAttribute ( "data-guild-id" ) } / ${ node . getAttribute ( "data-channel-id" ) } / ${ node . getAttribute ( "data-message-id" ) } `
2023-08-26 11:22:23 +00:00
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-10-27 11:24:42 +00:00
turndownService . addRule ( "listItem" , {
filter : "li" ,
replacement : function ( content , node , options ) {
content = content
. replace ( /^\n+/ , "" ) // remove leading newlines
. replace ( /\n+$/ , "\n" ) // replace trailing newlines with just a single one
. replace ( /\n/gm , "\n " ) // indent
var prefix = options . bulletListMarker + " "
var parent = node . parentNode
if ( parent . nodeName === "OL" ) {
var start = parent . getAttribute ( "start" )
var index = Array . prototype . indexOf . call ( parent . children , node )
2023-11-23 00:41:31 +00:00
prefix = ( start ? Number ( start ) + index : index + 1 ) + ". "
2023-10-27 11:24:42 +00:00
}
return prefix + content + ( node . nextSibling && ! /\n$/ . test ( content ) ? "\n" : "" )
}
} )
2023-09-23 13:55:47 +00:00
/** @type {string[]} SPRITE SHEET EMOJIS FEATURE: mxc urls for the currently processing message */
let endOfMessageEmojis = [ ]
2023-09-19 07:59:58 +00:00
turndownService . addRule ( "emoji" , {
filter : function ( node , options ) {
2023-09-23 13:55:47 +00:00
if ( node . nodeName !== "IMG" || ! node . hasAttribute ( "data-mx-emoticon" ) || ! node . getAttribute ( "src" ) || ! node . getAttribute ( "title" ) ) return false
return true
} ,
replacement : function ( content , node ) {
const mxcUrl = node . getAttribute ( "src" )
2023-09-27 10:25:46 +00:00
// Get the known emoji from the database. (We may not be able to actually use this if it was from another server.)
2023-10-05 23:31:10 +00:00
const row = select ( "emoji" , [ "emoji_id" , "name" , "animated" ] , { mxc _url : mxcUrl } ) . get ( )
2023-09-27 10:25:46 +00:00
// Also guess a suitable emoji based on the ID (if available) or name
let guess = null
const guessedName = node . getAttribute ( "title" ) . replace ( /^:|:$/g , "" )
2023-10-07 09:47:31 +00:00
for ( const guild of discord . guilds . values ( ) ) {
2023-09-27 10:25:46 +00:00
/** @type {{name: string, id: string, animated: number}[]} */
// @ts-ignore
const emojis = guild . emojis
2023-10-05 23:31:10 +00:00
const match = emojis . find ( e => e . id === row ? . emoji _id ) || emojis . find ( e => e . name === guessedName ) || emojis . find ( e => e . name ? . toLowerCase ( ) === guessedName . toLowerCase ( ) )
2023-09-27 10:25:46 +00:00
if ( match ) {
guess = match
break
2023-09-19 12:37:15 +00:00
}
}
2023-09-27 10:25:46 +00:00
if ( guess ) {
// We know an emoji, and we can use it
const animatedChar = guess . animated ? "a" : ""
return ` < ${ animatedChar } : ${ guess . name } : ${ guess . id } > `
} else if ( endOfMessageEmojis . includes ( mxcUrl ) ) {
// We can't locate or use a suitable emoji. After control returns, it will rewind over this, delete this section, and upload the emojis as a sprite sheet.
return ` <::> `
2023-09-23 13:55:47 +00:00
} else {
2023-09-27 10:25:46 +00:00
// We prefer not to upload this as a sprite sheet because the emoji is not at the end of the message, it is in the middle.
2023-11-30 03:27:40 +00:00
return ` [ ${ node . getAttribute ( "title" ) } ]( ${ mxUtils . getPublicUrlForMxc ( mxcUrl ) } ) `
2023-09-23 13:55:47 +00:00
}
2023-09-19 07:59:58 +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 ) {
2023-10-05 23:31:10 +00:00
const row = select ( "member_cache" , [ "displayname" , "avatar_url" ] , { room _id : roomID , mxid } ) . get ( )
2023-08-26 10:22:54 +00:00
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-09-10 02:59:31 +00:00
2023-09-10 01:47:24 +00:00
/ * *
* Splits a display name into one chunk containing <= 80 characters , and another chunk containing the rest of the characters . Splits on
* whitespace if possible .
* These chunks , respectively , go in the display name , and at the top of the message .
* If the second part isn 't empty, it' ll also contain boldening markdown and a line break at the end , so that regardless of its value it
* can be prepended to the message content as - is .
* @ summary Splits too - long Matrix names into a display name chunk and a message content chunk .
* @ param { string } displayName - The Matrix side display name to chop up .
2023-09-10 02:59:31 +00:00
* @ returns { [ string , string ] } [ shortened display name , display name runoff ]
2023-09-10 01:47:24 +00:00
* /
function splitDisplayName ( displayName ) {
/** @type {string[]} */
let displayNameChunks = chunk ( displayName , 80 )
if ( displayNameChunks . length === 1 ) {
return [ displayName , "" ]
} else {
const displayNamePreRunoff = displayNameChunks [ 0 ]
2023-09-10 02:59:31 +00:00
// displayNameRunoff is a slice of the original rather than a concatenation of the rest of the chunks in order to preserve whatever whitespace it was broken on.
const displayNameRunoff = ` ** ${ displayName . slice ( displayNamePreRunoff . length + 1 ) } ** \n `
2023-09-10 01:47:24 +00:00
return [ displayNamePreRunoff , displayNameRunoff ]
}
}
2023-08-26 10:22:54 +00:00
2023-09-23 13:55:47 +00:00
/ * *
* At the time of this executing , we know what the end of message emojis are , and we know that at least one of them is unknown .
* This function will strip them from the content and generate the correct pending file of the sprite sheet .
* @ param { string } content
* @ param { { id : string , name : string } [ ] } attachments
* @ param { ( { name : string , url : string } | { name : string , url : string , key : string , iv : string } | { name : string , buffer : Buffer } ) [ ] } pendingFiles
* /
async function uploadEndOfMessageSpriteSheet ( content , attachments , pendingFiles ) {
if ( ! content . includes ( "<::>" ) ) return content // No unknown emojis, nothing to do
// Remove known and unknown emojis from the end of the message
2023-09-26 19:20:18 +00:00
const r = /<a?:[a-zA-Z0-9_]*:[0-9]*>\s*$/
2023-09-23 13:55:47 +00:00
while ( content . match ( r ) ) {
content = content . replace ( r , "" )
}
// Create a sprite sheet of known and unknown emojis from the end of the message
const buffer = await emojiSheet . compositeMatrixEmojis ( endOfMessageEmojis )
// Attach it
const name = "emojis.png"
attachments . push ( { id : "0" , name } )
pendingFiles . push ( { name , buffer } )
return content
}
2023-12-02 04:13:10 +00:00
/ * *
* @ param { string } input
* @ param { { api : import ( "../../matrix/api" ) } } di simple - as - nails dependency injection for the matrix API
* /
async function handleRoomOrMessageLinks ( input , di ) {
let offset = 0
for ( const match of [ ... input . matchAll ( /("?https:\/\/matrix.to\/#\/(![^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*)?)(">|[, )]|$)/g ) ] ) {
assert ( typeof match . index === "number" )
const [ _ , attributeValue , roomID , eventID , endMarker ] = match
let result
const resultType = endMarker === '">' ? "html" : "plain"
const MAKE _RESULT = {
ROOM _LINK : {
html : channelID => ` ${ attributeValue } " data-channel-id=" ${ channelID } "> ` ,
plain : channelID => ` <# ${ channelID } > ${ endMarker } `
} ,
MESSAGE _LINK : {
html : ( guildID , channelID , messageID ) => ` ${ attributeValue } " data-channel-id=" ${ channelID } " data-guild-id=" ${ guildID } " data-message-id=" ${ messageID } "> ` ,
plain : ( guildID , channelID , messageID ) => ` https://discord.com/channels/ ${ guildID } / ${ channelID } / ${ messageID } ${ endMarker } `
}
}
// Don't process links that are part of the reply fallback, they'll be removed entirely by turndown
if ( input . slice ( match . index + match [ 0 ] . length + offset ) . startsWith ( "In reply to" ) ) continue
const channelID = select ( "channel_room" , "channel_id" , { room _id : roomID } ) . pluck ( ) . get ( )
if ( ! channelID ) continue
if ( ! eventID ) {
// 1: It's a room link, so <#link> to the channel
result = MAKE _RESULT . ROOM _LINK [ resultType ] ( channelID )
} else {
// Linking to a particular event with a discord.com/channels/guildID/channelID/messageID link
// Need to know the guildID and messageID
const guildID = discord . channels . get ( channelID ) ? . [ "guild_id" ]
if ( ! guildID ) continue
const messageID = select ( "event_message" , "message_id" , { event _id : eventID } ) . pluck ( ) . get ( )
if ( messageID ) {
// 2: Linking to a known event
result = MAKE _RESULT . MESSAGE _LINK [ resultType ] ( guildID , channelID , messageID )
} else {
// 3: Linking to an unknown event that OOYE didn't originally bridge - we can guess messageID from the timestamp
2024-01-19 03:38:31 +00:00
let originalEvent
try {
originalEvent = await di . api . getEvent ( roomID , eventID )
} catch ( e ) {
continue // Our homeserver doesn't know about the event, so can't resolve it to a Discord link
}
2023-12-02 04:13:10 +00:00
const guessedMessageID = dUtils . timestampToSnowflakeInexact ( originalEvent . origin _server _ts )
result = MAKE _RESULT . MESSAGE _LINK [ resultType ] ( guildID , channelID , guessedMessageID )
}
}
input = input . slice ( 0 , match . index + offset ) + result + input . slice ( match . index + match [ 0 ] . length + offset )
offset += result . length - match [ 0 ] . length
}
return input
}
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
2024-01-20 10:51:26 +00:00
* @ param { { api : import ( "../../matrix/api" ) , snow : import ( "snowtransfer" ) . SnowTransfer , fetch : import ( "node-fetch" ) [ "default" ] } } 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-10-14 11:26:52 +00:00
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */
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-11-30 03:27:40 +00:00
if ( member . avatar _url ) avatarURL = mxUtils . getPublicUrlForMxc ( member . avatar _url ) || undefined
2023-09-10 02:59:31 +00:00
// If the display name is too long to be put into the webhook (80 characters is the maximum),
// put the excess characters into displayNameRunoff, later to be put at the top of the message
let [ displayNameShortened , displayNameRunoff ] = splitDisplayName ( displayName )
2023-09-10 01:47:24 +00:00
// If the message type is m.emote, the full name is already included at the start of the message, so remove any runoff
if ( event . type === "m.room.message" && event . content . msgtype === "m.emote" ) {
displayNameRunoff = ""
}
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-23 13:55:47 +00:00
/** @type {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */
2023-09-02 11:28:41 +00:00
const pendingFiles = [ ]
2023-11-23 00:41:02 +00:00
/** @type {DiscordTypes.APIUser[]} */
const ensureJoined = [ ]
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
2023-10-05 23:31:10 +00:00
messageIDsToEdit = select ( "event_message" , "message_id" , { event _id : originalEventId } , "ORDER BY part" ) . pluck ( ) . all ( )
2023-08-27 13:30:07 +00:00
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
2024-01-19 03:38:31 +00:00
let repliedToEvent
try {
repliedToEvent = await di . api . getEvent ( event . room _id , repliedToEventId )
} catch ( e ) {
// Original event isn't on our homeserver, so we'll *partially* trust the client's reply fallback.
// We'll trust the fallback's quoted content and put it in the reply preview, but we won't trust the authorship info on it.
// (But if the fallback's quoted content doesn't exist, we give up. There's nothing for us to quote.)
if ( event . content [ "format" ] !== "org.matrix.custom.html" || typeof event . content [ "formatted_body" ] !== "string" ) {
const lines = event . content . body . split ( "\n" )
let stage = 0
for ( let i = 0 ; i < lines . length ; i ++ ) {
if ( stage >= 0 && lines [ i ] [ 0 ] === ">" ) stage = 1
if ( stage >= 1 && lines [ i ] . trim ( ) === "" ) stage = 2
if ( stage === 2 && lines [ i ] . trim ( ) !== "" ) {
event . content . body = lines . slice ( i ) . join ( "\n" )
break
}
}
return
}
const mxReply = event . content [ "formatted_body" ]
const quoted = mxReply . match ( /^<mx-reply><blockquote>.*?In reply to.*?<br>(.*)<\/blockquote><\/mx-reply>/ ) ? . [ 1 ]
if ( ! quoted ) return
const contentPreviewChunks = chunk (
entities . decodeHTML5Strict ( // Remove entities like & "
quoted . replace ( / ^ \ s * < b l o c k q u o t e > . * ? < \ / b l o c k q u o t e > ( . . . . . ) / s , " $ 1 " ) / / I f t h e m e s s a g e s t a r t s w i t h a b l o c k q u o t e , d o n ' t c o u n t i t a n d u s e t h e m e s s a g e b o d y a f t e r w a r d s
. replace ( /(?:\n|<br>)+/g , " " ) // Should all be on one line
. replace ( /<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g , "[spoiler]" ) // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
. replace ( /<[^>]+>/g , "" ) // Completely strip all HTML tags and formatting.
) , 50 )
replyLine = "> " + contentPreviewChunks [ 0 ]
if ( contentPreviewChunks . length > 1 ) replyLine = replyLine . replace ( /[,.']$/ , "" ) + "..."
replyLine += "\n"
return
}
2023-10-07 11:00:34 +00:00
// @ts-ignore
const autoEmoji = new Map ( select ( "auto_emoji" , [ "name" , "emoji_id" ] , { } , "WHERE name = 'L1' OR name = 'L2'" ) . raw ( ) . all ( ) )
replyLine = ` <:L1: ${ autoEmoji . get ( "L1" ) } ><:L2: ${ autoEmoji . get ( "L2" ) } > `
2023-10-05 23:31:10 +00:00
const row = from ( "event_message" ) . join ( "message_channel" , "message_id" ) . select ( "channel_id" , "message_id" ) . where ( { event _id : repliedToEventId } ) . and ( "ORDER BY part" ) . get ( )
2023-08-26 08:30:22 +00:00
if ( row ) {
2023-10-07 11:00:34 +00:00
replyLine += ` https://discord.com/channels/ ${ guild . id } / ${ row . channel _id } / ${ row . message _id } `
2023-08-26 08:30:22 +00:00
}
const sender = repliedToEvent . sender
2023-10-05 23:31:10 +00:00
const authorID = select ( "sim" , "user_id" , { mxid : repliedToEvent . sender } ) . pluck ( ) . get ( )
2023-08-26 08:30:22 +00:00
if ( authorID ) {
2023-09-07 11:48:44 +00:00
replyLine += ` <@ ${ authorID } > `
2023-08-26 08:30:22 +00:00
} else {
2023-10-13 10:05:07 +00:00
let senderName = select ( "member_cache" , "displayname" , { mxid : repliedToEvent . sender } ) . pluck ( ) . get ( )
2024-01-19 03:38:31 +00:00
if ( ! senderName ) {
const match = sender . match ( /@([^:]*)/ )
assert ( match )
senderName = match [ 1 ]
}
2023-09-07 11:48:44 +00:00
replyLine += ` Ⓜ️** ${ senderName } ** `
2023-08-26 08:30:22 +00:00
}
2023-09-07 12:13:25 +00:00
// If the event has been edited, the homeserver will include the relation in `unsigned`.
if ( repliedToEvent . unsigned ? . [ "m.relations" ] ? . [ "m.replace" ] ? . content ? . [ "m.new_content" ] ) {
repliedToEvent = repliedToEvent . unsigned [ "m.relations" ] [ "m.replace" ] // Note: this changes which event_id is in repliedToEvent.
repliedToEvent . content = repliedToEvent . content [ "m.new_content" ]
}
2023-09-07 11:48:44 +00:00
let contentPreview
const fileReplyContentAlternative =
( repliedToEvent . content . msgtype === "m.image" ? "🖼️"
: repliedToEvent . content . msgtype === "m.video" ? "🎞️"
: repliedToEvent . content . msgtype === "m.audio" ? "🎶"
: repliedToEvent . content . msgtype === "m.file" ? "📄"
: null )
if ( fileReplyContentAlternative ) {
contentPreview = " " + fileReplyContentAlternative
} else {
2023-09-10 02:59:31 +00:00
const repliedToContent = repliedToEvent . content . formatted _body || repliedToEvent . content . body
2023-09-07 12:13:25 +00:00
const contentPreviewChunks = chunk (
2023-10-13 10:23:15 +00:00
entities . decodeHTML5Strict ( // Remove entities like & "
2024-01-10 09:42:13 +00:00
repliedToContent . replace ( / . * < \ / m x - r e p l y > / s , " " ) / / R e m o v e e v e r y t h i n g b e f o r e r e p l i e s , s o j u s t u s e t h e a c t u a l m e s s a g e b o d y
2023-10-14 06:27:45 +00:00
. replace ( / ^ \ s * < b l o c k q u o t e > . * ? < \ / b l o c k q u o t e > ( . . . . . ) / s , " $ 1 " ) / / I f t h e m e s s a g e s t a r t s w i t h a b l o c k q u o t e , d o n ' t c o u n t i t a n d u s e t h e m e s s a g e b o d y a f t e r w a r d s
2023-10-13 10:23:15 +00:00
. replace ( /(?:\n|<br>)+/g , " " ) // Should all be on one line
. replace ( /<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g , "[spoiler]" ) // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
. replace ( /<[^>]+>/g , "" ) // Completely strip all HTML tags and formatting.
) , 50 )
2024-01-10 09:46:20 +00:00
contentPreview = ":\n> " + contentPreviewChunks [ 0 ]
if ( contentPreviewChunks . length > 1 ) contentPreview = contentPreview . replace ( /[,.']$/ , "" ) + "..."
2023-09-07 11:48:44 +00:00
}
replyLine = ` > ${ replyLine } ${ 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
2024-01-06 06:00:57 +00:00
input = input . replace ( /("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g , ( whole , attributeValue , mxid ) => {
mxid = decodeURIComponent ( mxid )
2023-11-30 03:27:40 +00:00
if ( mxUtils . eventSenderIsFromDiscord ( mxid ) ) {
2023-11-28 06:04:08 +00:00
// Handle mention of an OOYE sim user by their mxid
const userID = select ( "sim" , "user_id" , { mxid : mxid } ) . pluck ( ) . get ( )
if ( ! userID ) return whole
return ` ${ attributeValue } data-user-id=" ${ userID } "> `
} else {
// Handle mention of a Matrix user by their mxid
// Check if this Matrix user is actually the sim user from another old bridge in the room?
const match = mxid . match ( /[^:]*discord[^:]*_([0-9]{6,}):/ ) // try to match @_discord_123456, @_discordpuppet_123456, etc.
if ( match ) return ` ${ attributeValue } data-user-id=" ${ match [ 1 ] } "> `
// Nope, just a real Matrix user.
return whole
}
2023-08-28 13:31:52 +00:00
} )
2023-11-30 03:27:40 +00:00
// Handling mentions of rooms and room-messages
2023-12-02 04:13:10 +00:00
input = await handleRoomOrMessageLinks ( input , di )
2023-08-28 13:31:52 +00:00
2023-10-27 11:37:20 +00:00
// Stripping colons after mentions
input = input . replace ( /( data-user-id.*?<\/a>):?/g , "$1" )
input = input . replace ( /("https:\/\/matrix.to.*?<\/a>):?/g , "$1" )
2023-08-28 13:31:52 +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.
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 || ""
2023-11-30 03:27:40 +00:00
if ( ! mxUtils . BLOCK _ELEMENTS . includes ( beforeTag . toUpperCase ( ) ) && ! mxUtils . BLOCK _ELEMENTS . includes ( afterTag . toUpperCase ( ) ) ) {
2023-08-28 13:31:52 +00:00
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"
2023-09-23 13:55:47 +00:00
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
// First we need to determine which emojis are at the end.
endOfMessageEmojis = [ ]
let match
let last = input . length
while ( ( match = input . slice ( 0 , last ) . match ( /<img [^>]*>\s*$/ ) ) ) {
if ( ! match [ 0 ] . includes ( "data-mx-emoticon" ) ) break
const mxcUrl = match [ 0 ] . match ( /\bsrc="(mxc:\/\/[^"]+)"/ )
if ( mxcUrl ) endOfMessageEmojis . unshift ( mxcUrl [ 1 ] )
2024-01-19 03:38:31 +00:00
assert ( typeof match . index === "number" , "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec" )
2023-09-23 13:55:47 +00:00
last = match . index
}
2023-08-28 13:31:52 +00:00
// @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" )
2023-09-23 13:55:47 +00:00
// SPRITE SHEET EMOJIS FEATURE:
content = await uploadEndOfMessageSpriteSheet ( content , attachments , pendingFiles )
2023-08-28 13:31:52 +00:00
} 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-12-02 04:13:10 +00:00
content = await handleRoomOrMessageLinks ( content , di )
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 = ""
2024-01-06 06:42:13 +00:00
const filename = event . content . filename || event . content . body
// A written `event.content.body` will be bridged to Discord's image `description` which is like alt text.
// Bridging as description rather than message content in order to match Matrix clients (Element, Neochat) which treat this as alt text or title text.
const description = ( event . content . body !== event . content . filename && event . content . filename && event . content . body ) || undefined
2023-09-03 03:40:25 +00:00
if ( "url" in event . content ) {
// Unencrypted
2023-11-30 03:27:40 +00:00
const url = mxUtils . getPublicUrlForMxc ( event . content . url )
2023-09-03 03:40:25 +00:00
assert ( url )
2024-01-06 06:42:13 +00:00
attachments . push ( { id : "0" , description , filename } )
2023-09-03 03:40:25 +00:00
pendingFiles . push ( { name : filename , url } )
} else {
// Encrypted
2023-11-30 03:27:40 +00:00
const url = mxUtils . getPublicUrlForMxc ( event . content . file . url )
2023-09-03 03:40:25 +00:00
assert ( url )
assert . equal ( event . content . file . key . alg , "A256CTR" )
2024-01-06 06:42:13 +00:00
attachments . push ( { id : "0" , description , filename } )
2023-09-03 03:40:25 +00:00
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 = ""
2023-11-30 03:27:40 +00:00
const url = mxUtils . getPublicUrlForMxc ( event . content . url )
2023-09-02 11:28:41 +00:00
assert ( url )
2023-09-30 03:21:07 +00:00
let filename = event . content . body
if ( event . type === "m.sticker" ) {
let mimetype
if ( event . content . info ? . mimetype ? . includes ( "/" ) ) {
mimetype = event . content . info . mimetype
} else {
2024-01-19 03:38:31 +00:00
const res = await di . fetch ( url , { method : "HEAD" } )
if ( res . status === 200 ) {
mimetype = res . headers . get ( "content-type" )
}
if ( ! mimetype ) throw new Error ( ` Server error ${ res . status } or missing content-type while detecting sticker mimetype ` )
2023-09-30 03:21:07 +00:00
}
filename += "." + mimetype . split ( "/" ) [ 1 ]
}
2023-09-02 11:28:41 +00:00
attachments . push ( { id : "0" , filename } )
pendingFiles . push ( { name : filename , url } )
2023-07-02 13:06:05 +00:00
}
2023-09-10 01:47:24 +00:00
content = displayNameRunoff + replyLine + content
2023-08-26 08:30:22 +00:00
2023-11-23 00:41:02 +00:00
// Handling written @mentions: we need to look for candidate Discord members to join to the room
2023-11-25 10:09:28 +00:00
let writtenMentionMatch = content . match ( / ( ? : ^ | [ ^ " < > / A - Z a - z 0 - 9 ] ) @ ( [ A - Z a - z ] [ A - Z a - z 0 - 9 . _ \ [ \ ] \ ( \ ) - ] + ) : ? / d ) / / / d f l a g f o r i n d i c e s r e q u i r e s n o d e . j s 1 6 +
2023-11-23 00:41:02 +00:00
if ( writtenMentionMatch ) {
const results = await di . snow . guild . searchGuildMembers ( guild . id , { query : writtenMentionMatch [ 1 ] } )
if ( results [ 0 ] ) {
assert ( results [ 0 ] . user )
2023-11-25 10:09:28 +00:00
// @ts-ignore - typescript doesn't know about indices yet
content = content . slice ( 0 , writtenMentionMatch . indices [ 1 ] [ 0 ] - 1 ) + ` <@ ${ results [ 0 ] . user . id } > ` + content . slice ( writtenMentionMatch . indices [ 1 ] [ 1 ] )
2023-11-23 00:41:02 +00:00
ensureJoined . push ( results [ 0 ] . user )
}
}
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 ,
2023-09-10 01:47:24 +00:00
username : displayNameShortened ,
2023-08-25 13:43:17 +00:00
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 ,
2023-09-10 01:47:24 +00:00
username : displayNameShortened ,
2023-09-02 11:28:41 +00:00
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 ,
2023-11-23 00:41:02 +00:00
messagesToDelete : messageIDsToEdit ,
ensureJoined
2023-08-27 13:30:07 +00:00
}
2023-07-02 13:06:05 +00:00
}
module . exports . eventToMessage = eventToMessage