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" )
2024-07-30 11:45:44 +00:00
const TurndownService = require ( "@cloudrac3r/turndown" )
2024-02-02 02:55:02 +00:00
const domino = require ( "domino" )
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\\. ' ]
2024-02-13 09:06:30 +00:00
/ *
Strikethrough is deliberately not escaped . Usually when Matrix users type ~ ~ it ' s not because they wanted to send ~ ~ ,
it 's because they wanted strikethrough and it didn' t work because their client doesn ' t support it .
As bridge developers , we can choose between "messages should look as similar as possible" vs "it was most likely intended to be strikethrough" .
I went with the latter . Even though the appearance doesn 't match, I' d rather it displayed as originally intended for 80 % of the readers than for 0 % .
* /
2024-02-01 09:22:48 +00:00
]
2023-09-03 13:37:33 +00:00
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 ,
2024-02-02 02:55:02 +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 ) {
2024-05-25 10:46:28 +00:00
return string . replace ( /\s+|\S+/g , part => { // match chunks of spaces or non-spaces
if ( part . match ( /\s/ ) ) return part // don't process spaces
if ( part . match ( /^https?:\/\// ) ) {
return part
2023-09-03 13:37:33 +00:00
} else {
return markdownEscapes . reduce ( function ( accumulator , escape ) {
return accumulator . replace ( escape [ 0 ] , escape [ 1 ] )
2024-05-25 10:46:28 +00:00
} , part )
2023-09-03 13:37:33 +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" , {
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 ) {
2024-02-13 07:47:10 +00:00
return node . tagName === "SPAN" && node . hasAttribute ( "data-mx-spoiler" )
2023-08-28 13:36:15 +00:00
} ,
replacement : function ( content , node ) {
2024-02-13 09:06:30 +00:00
if ( node . getAttribute ( "data-mx-spoiler" ) ) {
// escape parentheses so it can't become a link
return ` \\ ( ${ node . getAttribute ( "data-mx-spoiler" ) } \\ ) || ${ content } || `
}
return ` || ${ content } || `
2023-08-28 13:36:15 +00:00
}
} )
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 ) {
2024-02-01 09:22:48 +00:00
if ( node . getAttribute ( "data-user-id" ) ) {
const user _id = node . getAttribute ( "data-user-id" )
const row = select ( "sim_proxy" , [ "displayname" , "proxy_owner_id" ] , { user _id } ) . get ( )
if ( row ) {
return ` **@ ${ row . displayname } ** (<@ ${ row . proxy _owner _id } >) `
} else {
return ` <@ ${ 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" )
2024-01-22 10:10:33 +00:00
content = content . replace ( / @.*/ , "" )
2024-02-20 11:00:11 +00:00
if ( href === content ) return href
2024-07-30 12:06:32 +00:00
if ( decodeURIComponent ( href ) . startsWith ( "https://matrix.to/#/@" ) && content [ 0 ] !== "@" ) content = "@" + content
2024-02-20 11:00:11 +00:00
return "[" + content + "](" + href + ")"
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
const guessedName = node . getAttribute ( "title" ) . replace ( /^:|:$/g , "" )
2024-02-13 22:32:07 +00:00
return convertEmoji ( mxcUrl , guessedName , true , true )
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
2024-02-19 04:05:56 +00:00
const visibleCode = getCodeContent ( code )
2023-08-26 07:07:19 +00:00
var fence = "```"
return (
fence + language + "\n" +
visibleCode +
"\n" + fence
)
}
} )
2024-02-19 04:05:56 +00:00
/** @param {{ childNodes: Node[]; }} preCode the <code> directly inside the <pre> */
function getCodeContent ( preCode ) {
return preCode . childNodes . map ( c => c . nodeName === "BR" ? "\n" : c . textContent ) . join ( "" ) . replace ( /\n*$/g , "" )
}
2024-02-13 22:32:07 +00:00
/ * *
* @ param { string | null } mxcUrl
* @ param { string | null } nameForGuess without colons
* @ param { boolean } allowSpriteSheetIndicator
* @ param { boolean } allowLink
* @ returns { string } discord markdown that represents the custom emoji in some form
* /
function convertEmoji ( mxcUrl , nameForGuess , allowSpriteSheetIndicator , allowLink ) {
// Get the known emoji from the database.
let row
if ( mxcUrl ) row = select ( "emoji" , [ "emoji_id" , "name" , "animated" ] , { mxc _url : mxcUrl } ) . get ( )
2024-02-18 12:23:27 +00:00
// Now we have to search all servers to see if we're able to send this emoji.
if ( row ) {
const found = [ ... discord . guilds . values ( ) ] . find ( g => g . emojis . find ( e => e . id === row . id ) )
if ( ! found ) row = null
}
// Or, if we don't have an emoji right now, we search for the name instead.
2024-02-13 22:32:07 +00:00
if ( ! row && nameForGuess ) {
const nameForGuessLower = nameForGuess . toLowerCase ( )
for ( const guild of discord . guilds . values ( ) ) {
/** @type {{name: string, id: string, animated: number}[]} */
// @ts-ignore
const emojis = guild . emojis
2024-02-18 12:23:27 +00:00
const found = emojis . find ( e => e . id === row ? . id || e . name ? . toLowerCase ( ) === nameForGuessLower )
2024-02-13 22:32:07 +00:00
if ( found ) {
row = {
animated : found . animated ,
emoji _id : found . id ,
name : found . name
}
break
}
}
}
if ( row ) {
// We know an emoji, and we can use it
const animatedChar = row . animated ? "a" : ""
return ` < ${ animatedChar } : ${ row . name } : ${ row . emoji _id } > `
} else if ( allowSpriteSheetIndicator && mxcUrl && 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 ` <::> `
} else if ( allowLink && mxcUrl && nameForGuess ) {
// 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.
return ` [: ${ nameForGuess } :]( ${ mxUtils . getPublicUrlForMxc ( mxcUrl ) } ) `
} else if ( nameForGuess ) {
return ` : ${ nameForGuess } : `
} else {
return ""
}
}
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
/ * *
2024-03-04 22:07:51 +00:00
* Splits a display name into one chunk containing <= 80 characters ( 80 being how many characters Discord allows for the name of a webhook ) ,
* and another chunk containing the rest of the characters . Splits on whitespace if possible .
2023-09-10 01:47:24 +00:00
* 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
2024-01-31 00:09:39 +00:00
/ * *
* Convert a Matrix user ID into a Discord user ID for mentioning , where if the user is a PK proxy , it will mention the proxy owner .
* @ param { string } mxid
* /
function getUserOrProxyOwnerID ( mxid ) {
const row = from ( "sim" ) . join ( "sim_proxy" , "user_id" , "left" ) . select ( "user_id" , "proxy_owner_id" ) . where ( { mxid } ) . get ( )
if ( ! row ) return null
return row . proxy _owner _id || row . user _id
}
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
2024-03-01 04:28:14 +00:00
* @ param { ( mxc : string ) => Promise < Buffer | undefined > } mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data . use ` getAndConvertEmoji ` or a mock .
2023-09-23 13:55:47 +00:00
* /
2024-03-01 04:28:14 +00:00
async function uploadEndOfMessageSpriteSheet ( content , attachments , pendingFiles , mxcDownloader ) {
2023-09-23 13:55:47 +00:00
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
2024-03-01 04:28:14 +00:00
const buffer = await emojiSheet . compositeMatrixEmojis ( endOfMessageEmojis , mxcDownloader )
2023-09-23 13:55:47 +00:00
// Attach it
const name = "emojis.png"
2024-02-19 04:05:56 +00:00
attachments . push ( { id : String ( attachments . length ) , name } )
2023-09-23 13:55:47 +00:00
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
}
2024-02-02 02:55:02 +00:00
/ * *
* @ param { string } content
2024-03-07 03:23:23 +00:00
* @ param { string } senderMxid
* @ param { string } roomID
2024-02-02 02:55:02 +00:00
* @ param { DiscordTypes . APIGuild } guild
* @ param { { api : import ( "../../matrix/api" ) , snow : import ( "snowtransfer" ) . SnowTransfer , fetch : import ( "node-fetch" ) [ "default" ] } } di
* /
2024-03-07 03:23:23 +00:00
async function checkWrittenMentions ( content , senderMxid , roomID , guild , di ) {
2024-02-02 02:55:02 +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 +
if ( writtenMentionMatch ) {
2024-03-07 03:23:23 +00:00
if ( writtenMentionMatch [ 1 ] === "room" ) { // convert @room to @everyone
const powerLevels = await di . api . getStateEvent ( roomID , "m.room.power_levels" , "" )
const userPower = powerLevels . users ? . [ senderMxid ] || 0
if ( userPower >= powerLevels . notifications ? . room ) {
return {
// @ts-ignore - typescript doesn't know about indices yet
content : content . slice ( 0 , writtenMentionMatch . indices [ 1 ] [ 0 ] - 1 ) + ` @everyone ` + content . slice ( writtenMentionMatch . indices [ 1 ] [ 1 ] ) ,
ensureJoined : [ ] ,
allowedMentionsParse : [ "everyone" ]
}
}
} else {
const results = await di . snow . guild . searchGuildMembers ( guild . id , { query : writtenMentionMatch [ 1 ] } )
if ( results [ 0 ] ) {
assert ( results [ 0 ] . user )
return {
// @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 ] ) ,
ensureJoined : [ results [ 0 ] . user ] ,
allowedMentionsParse : [ ]
}
2024-02-02 02:55:02 +00:00
}
}
}
}
2024-02-19 04:05:56 +00:00
/ * *
* @ param { Element } node
* @ param { string [ ] } tagNames allcaps tag names
* @ returns { any | undefined } the node you were checking for , or undefined
* /
function nodeIsChildOf ( node , tagNames ) {
// @ts-ignore
for ( ; node ; node = node . parentNode ) if ( tagNames . includes ( node . tagName ) ) return node
}
2024-02-02 02:55:02 +00:00
const attachmentEmojis = new Map ( [
[ "m.image" , "🖼️" ] ,
[ "m.video" , "🎞️" ] ,
[ "m.audio" , "🎶" ] ,
[ "m.file" , "📄" ]
] )
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-03-01 04:28:14 +00:00
* @ param { { api : import ( "../../matrix/api" ) , snow : import ( "snowtransfer" ) . SnowTransfer , fetch : import ( "node-fetch" ) [ "default" ] , mxcDownloader : ( mxc : string ) => Promise < Buffer | undefined > } } 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-08-19 06:37:34 +00:00
let displayName = event . sender
let avatarURL = undefined
2024-03-07 03:23:23 +00:00
const allowedMentionsParse = [ "users" , "roles" ]
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 ( ) => {
2024-02-02 02:55:02 +00:00
// Check if there is an edit
2023-08-27 13:30:07 +00:00
const relatesTo = event . content [ "m.relates_to" ]
2024-02-02 02:55:02 +00:00
if ( ! event . content [ "m.new_content" ] || ! relatesTo || relatesTo . rel _type !== "m.replace" ) return
2023-08-27 13:30:07 +00:00
// Check if we have a pointer to what was edited
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 )
2024-08-15 13:08:01 +00:00
replyLine = "-# > " + contentPreviewChunks [ 0 ]
2024-01-19 03:38:31 +00:00
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
2024-01-31 00:09:39 +00:00
const authorID = getUserOrProxyOwnerID ( sender )
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 {
2024-01-31 00:09:39 +00:00
let senderName = select ( "member_cache" , "displayname" , { mxid : sender } ) . pluck ( ) . get ( )
2024-01-19 03:38:31 +00:00
if ( ! senderName ) {
const match = sender . match ( /@([^:]*)/ )
assert ( match )
senderName = match [ 1 ]
}
2024-08-15 13:08:01 +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
2024-02-02 02:55:02 +00:00
const fileReplyContentAlternative = attachmentEmojis . get ( repliedToEvent . content . msgtype )
2023-09-07 11:48:44 +00:00
if ( fileReplyContentAlternative ) {
contentPreview = " " + fileReplyContentAlternative
2024-02-13 09:27:55 +00:00
} else if ( repliedToEvent . unsigned ? . redacted _because ) {
contentPreview = " (in reply to a deleted message)"
2023-09-07 11:48:44 +00:00
} else {
2024-02-13 22:04:54 +00:00
// Generate a reply preview for a standard message
/** @type {string} */
let repliedToContent = repliedToEvent . content . formatted _body || repliedToEvent . content . body
repliedToContent = 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
repliedToContent = repliedToContent . 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
repliedToContent = repliedToContent . replace ( /(?:\n|<br>)+/g , " " ) // Should all be on one line
repliedToContent = repliedToContent . 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.)
repliedToContent = repliedToContent . replace ( /<img([^>]*)>/g , ( _ , att ) => { // Convert Matrix emoji images into Discord emoji markdown
const mxcUrlMatch = att . match ( /\bsrc="(mxc:\/\/[^"]+)"/ )
const titleTextMatch = att . match ( /\btitle=":?([^:"]+)/ )
2024-02-13 22:32:07 +00:00
return convertEmoji ( mxcUrlMatch ? . [ 1 ] , titleTextMatch ? . [ 1 ] , false , false )
2024-02-13 22:04:54 +00:00
} )
repliedToContent = repliedToContent . replace ( /<[^:>][^>]*>/g , "" ) // Completely strip all HTML tags and formatting.
repliedToContent = entities . decodeHTML5Strict ( repliedToContent ) // Remove entities like & "
const contentPreviewChunks = chunk ( repliedToContent , 50 )
if ( contentPreviewChunks . length ) {
2024-08-15 13:08:01 +00:00
contentPreview = ": " + contentPreviewChunks [ 0 ]
2024-02-13 22:04:54 +00:00
if ( contentPreviewChunks . length > 1 ) contentPreview = contentPreview . replace ( /[,.']$/ , "" ) + "..."
} else {
console . log ( "Unable to generate reply preview for this replied-to event because we stripped all of it:" , repliedToEvent )
contentPreview = ""
}
2023-09-07 11:48:44 +00:00
}
2024-08-15 13:08:01 +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
2024-02-01 09:22:48 +00:00
const id = select ( "sim" , "user_id" , { mxid } ) . pluck ( ) . get ( )
if ( ! id ) return whole
return ` ${ attributeValue } data-user-id=" ${ id } "> `
2023-11-28 06:04:08 +00:00
} 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"
2024-02-02 02:55:02 +00:00
// Handling written @mentions: we need to look for candidate Discord members to join to the room
// This shouldn't apply to code blocks, links, or inside attributes. So editing the HTML tree instead of regular expressions is a sensible choice here.
// We're using the domino parser because Turndown uses the same and can reuse this tree.
const doc = domino . createDocument (
// DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element.
'<x-turndown id="turndown-root">' + input + '</x-turndown>'
) ;
const root = doc . getElementById ( "turndown-root" ) ;
async function forEachNode ( node ) {
for ( ; node ; node = node . nextSibling ) {
2024-02-19 04:05:56 +00:00
// Check written mentions
if ( node . nodeType === 3 && node . nodeValue . includes ( "@" ) && ! nodeIsChildOf ( node , [ "A" , "CODE" , "PRE" ] ) ) {
2024-03-07 03:23:23 +00:00
const result = await checkWrittenMentions ( node . nodeValue , event . sender , event . room _id , guild , di )
2024-02-02 02:55:02 +00:00
if ( result ) {
node . nodeValue = result . content
2024-03-07 03:23:23 +00:00
ensureJoined . push ( ... result . ensureJoined )
allowedMentionsParse . push ( ... result . allowedMentionsParse )
2024-02-02 02:55:02 +00:00
}
}
2024-02-19 04:05:56 +00:00
// Check for incompatible backticks in code blocks
let preNode
if ( node . nodeType === 3 && node . nodeValue . includes ( "```" ) && ( preNode = nodeIsChildOf ( node , [ "PRE" ] ) ) ) {
if ( preNode . firstChild ? . nodeName === "CODE" ) {
const ext = ( preNode . firstChild . className . match ( /language-(\S+)/ ) || [ null , "txt" ] ) [ 1 ]
const filename = ` inline_code. ${ ext } `
// Build the replacement <code> node
const replacementCode = doc . createElement ( "code" )
replacementCode . textContent = ` [ ${ filename } ] `
// Build its containing <span> node
const replacement = doc . createElement ( "span" )
replacement . appendChild ( doc . createTextNode ( " " ) )
replacement . appendChild ( replacementCode )
replacement . appendChild ( doc . createTextNode ( " " ) )
// Replace the code block with the <span>
preNode . replaceWith ( replacement )
// Upload the code as an attachment
const content = getCodeContent ( preNode . firstChild )
attachments . push ( { id : String ( attachments . length ) , filename } )
pendingFiles . push ( { name : filename , buffer : Buffer . from ( content , "utf8" ) } )
}
2024-02-02 02:55:02 +00:00
}
2024-02-19 04:05:56 +00:00
await forEachNode ( node . firstChild )
2024-02-02 02:55:02 +00:00
}
}
await forEachNode ( root )
2024-02-18 12:23:27 +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 ] )
assert ( typeof match . index === "number" , "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec" )
last = match . index
}
2023-08-28 13:31:52 +00:00
// @ts-ignore bad type from turndown
2024-02-02 02:55:02 +00:00
content = turndownService . turndown ( root )
2023-08-28 13:31:52 +00:00
2024-02-20 11:00:11 +00:00
// Put < > around any surviving matrix.to links to hide the URL previews
2024-03-23 08:26:42 +00:00
content = content . replace ( /\bhttps?:\/\/matrix\.to\/[^ )]*/g , "<$&>" )
2024-02-20 11:00:11 +00:00
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
2024-02-13 09:06:30 +00:00
// If there's a blockquote at the start of the message body and this message is a reply, they should be visually separated
if ( replyLine && content . startsWith ( "> " ) ) content = "\n" + content
2023-09-23 13:55:47 +00:00
// SPRITE SHEET EMOJIS FEATURE:
2024-03-01 04:28:14 +00:00
content = await uploadEndOfMessageSpriteSheet ( content , attachments , pendingFiles , di ? . mxcDownloader )
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
2024-02-20 11:00:11 +00:00
content = await handleRoomOrMessageLinks ( content , di ) // Replace matrix.to links with discord.com equivalents where possible
content = content . replace ( /\bhttps?:\/\/matrix\.to\/[^ )]*/ , "<$&>" ) // Put < > around any surviving matrix.to links to hide the URL previews
2023-12-02 04:13:10 +00:00
2024-03-07 03:23:23 +00:00
const result = await checkWrittenMentions ( content , event . sender , event . room _id , guild , di )
2024-02-02 02:55:02 +00:00
if ( result ) {
content = result . content
2024-03-07 03:23:23 +00:00
ensureJoined . push ( ... result . ensureJoined )
allowedMentionsParse . push ( ... result . allowedMentionsParse )
2024-02-02 02:55:02 +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 = ""
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-08-25 13:43:17 +00:00
// Split into 2000 character chunks
const chunks = chunk ( content , 2000 )
2024-03-07 00:07:10 +00:00
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */
const messages = chunks . map ( content => ( {
2023-08-25 13:43:17 +00:00
content ,
2024-03-07 00:07:10 +00:00
allowed _mentions : {
2024-03-07 03:23:23 +00:00
parse : allowedMentionsParse
2024-03-07 00:07:10 +00:00
} ,
2023-09-10 01:47:24 +00:00
username : displayNameShortened ,
2023-08-25 13:43:17 +00:00
avatar _url : avatarURL
2024-03-07 00:07:10 +00:00
} ) )
2023-08-25 13:43:17 +00:00
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