2023-04-25 20:06:08 +00:00
// @ts-check
2023-06-28 12:06:56 +00:00
const assert = require ( "assert" ) . strict
2023-04-25 20:06:08 +00:00
const markdown = require ( "discord-markdown" )
2023-07-13 05:36:20 +00:00
const pb = require ( "prettier-bytes" )
2023-07-11 04:51:30 +00:00
const DiscordTypes = require ( "discord-api-types/v10" )
2023-04-25 20:06:08 +00:00
2023-05-12 05:35:37 +00:00
const passthrough = require ( "../../passthrough" )
2023-06-28 12:06:56 +00:00
const { sync , db , discord } = passthrough
2023-05-12 05:35:37 +00:00
/** @type {import("../../matrix/file")} */
const file = sync . require ( "../../matrix/file" )
2023-07-11 05:27:40 +00:00
const reg = require ( "../../matrix/read-registration" )
const userRegex = reg . namespaces . users . map ( u => new RegExp ( u . regex ) )
2023-05-12 05:35:37 +00:00
2023-07-07 05:31:39 +00:00
function getDiscordParseCallbacks ( message , useHTML ) {
return {
2023-08-16 05:03:05 +00:00
/** @param {{id: string, type: "discordUser"}} node */
2023-07-07 05:31:39 +00:00
user : node => {
const mxid = db . prepare ( "SELECT mxid FROM sim WHERE discord_id = ?" ) . pluck ( ) . get ( node . id )
const username = message . mentions . find ( ment => ment . id === node . id ) ? . username || node . id
if ( mxid && useHTML ) {
return ` <a href="https://matrix.to/#/ ${ mxid } ">@ ${ username } </a> `
} else {
return ` @ ${ username } : `
}
} ,
2023-08-16 05:03:05 +00:00
/** @param {{id: string, type: "discordChannel"}} node */
2023-07-07 05:31:39 +00:00
channel : node => {
2023-08-20 20:07:05 +00:00
const row = db . prepare ( "SELECT room_id, name, nick FROM channel_room WHERE channel_id = ?" ) . get ( node . id )
if ( ! row ) {
return ` <# ${ node . id } > ` // fallback for when this channel is not bridged
} else if ( useHTML ) {
return ` <a href="https://matrix.to/#/ ${ row . room _id } "># ${ row . nick || row . name } </a> `
2023-07-07 05:31:39 +00:00
} else {
2023-08-20 20:07:05 +00:00
return ` # ${ row . nick || row . name } `
2023-07-07 05:31:39 +00:00
}
} ,
2023-08-16 05:03:05 +00:00
/** @param {{animated: boolean, name: string, id: string, type: "discordEmoji"}} node */
emoji : node => {
if ( useHTML ) {
// TODO: upload the emoji and actually use the right mxc!!
return ` <img src="mxc://cadence.moe/ ${ node . id } " data-mx-emoticon alt=": ${ node . name } :" title=": ${ node . name } :" height="24"> `
} else {
return ` : ${ node . name } : `
}
} ,
2023-07-07 05:31:39 +00:00
role : node =>
"@&" + node . id ,
everyone : node =>
"@room" ,
here : node =>
"@here"
}
}
2023-04-25 20:06:08 +00:00
/ * *
* @ param { import ( "discord-api-types/v10" ) . APIMessage } message
2023-06-28 12:06:56 +00:00
* @ param { import ( "discord-api-types/v10" ) . APIGuild } guild
2023-08-16 08:44:38 +00:00
* @ param { { includeReplyFallback ? : boolean , includeEditFallbackStar ? : boolean } } options default values :
* - includeReplyFallback : true
* - includeEditFallbackStar : false
* @ param { { api : import ( "../../matrix/api" ) } } di simple - as - nails dependency injection for the matrix API
2023-04-25 20:06:08 +00:00
* /
2023-08-16 08:44:38 +00:00
async function messageToEvent ( message , guild , options = { } , di ) {
2023-05-12 05:35:37 +00:00
const events = [ ]
2023-08-21 11:31:40 +00:00
if ( message . type === DiscordTypes . MessageType . ThreadCreated ) {
// This is the kind of message that appears when somebody makes a thread which isn't close enough to the message it's based off.
// It lacks the lines and the pill, so it looks kind of like a member join message, and it says:
// [#] NICKNAME started a thread: __THREAD NAME__. __See all threads__
// We're already bridging the THREAD_CREATED gateway event to make a comparable message, so drop this one.
return [ ]
}
if ( message . type === DiscordTypes . MessageType . ThreadStarterMessage ) {
// This is the message that appears at the top of a thread when the thread was based off an existing message.
// It's just a message reference, no content.
const ref = message . message _reference
assert ( ref )
assert ( ref . message _id )
2023-08-28 05:32:55 +00:00
const eventID = db . prepare ( "SELECT event_id FROM event_message WHERE message_id = ?" ) . pluck ( ) . get ( ref . message _id )
const roomID = db . prepare ( "SELECT room_id FROM channel_room WHERE channel_id = ?" ) . pluck ( ) . get ( ref . channel _id )
if ( ! eventID || ! roomID ) return [ ]
const event = await di . api . getEvent ( roomID , eventID )
2023-08-21 11:31:40 +00:00
return [ {
... event . content ,
$type : event . type
} ]
}
2023-07-11 04:51:30 +00:00
/ * *
@ type { { room ? : boolean , user _ids ? : string [ ] } }
We should consider the following scenarios for mentions :
2023-07-11 05:28:42 +00:00
1. A discord user rich - replies to a matrix user with a text post
2023-07-11 04:51:30 +00:00
+ The matrix user needs to be m . mentioned in the text event
+ The matrix user needs to have their name / mxid / link in the text event ( notification fallback )
- So prepend their ` @name: ` to the start of the plaintext body
2023-07-11 05:28:42 +00:00
2. A discord user rich - replies to a matrix user with an image event only
2023-07-11 04:51:30 +00:00
+ The matrix user needs to be m . mentioned in the image event
2023-07-11 05:28:42 +00:00
+ TODO The matrix user needs to have their name / mxid in the image event ' s body field , alongside the filename ( notification fallback )
2023-07-11 04:51:30 +00:00
- So append their name to the filename body , I guess ! ! !
2023-07-11 05:28:42 +00:00
3. A discord user ` @ ` s a matrix user in the text body of their text box
2023-07-11 04:51:30 +00:00
+ The matrix user needs to be m . mentioned in the text event
+ No change needed to the text event content : it already has their name
- So make sure we don ' t do anything in this case .
* /
const mentions = { }
let repliedToEventId = null
let repliedToEventRoomId = null
let repliedToEventSenderMxid = null
let repliedToEventOriginallyFromMatrix = false
function addMention ( mxid ) {
if ( ! mentions . user _ids ) mentions . user _ids = [ ]
2023-07-11 05:27:40 +00:00
if ( ! mentions . user _ids . includes ( mxid ) ) mentions . user _ids . push ( mxid )
2023-07-11 04:51:30 +00:00
}
// Mentions scenarios 1 and 2, part A. i.e. translate relevant message.mentions to m.mentions
// (Still need to do scenarios 1 and 2 part B, and scenario 3.)
if ( message . type === DiscordTypes . MessageType . Reply && message . message _reference ? . message _id ) {
2023-08-28 05:32:55 +00:00
const row = db . prepare ( "SELECT event_id, room_id, source FROM event_message INNER JOIN message_channel USING (message_id) INNER JOIN channel_room USING (channel_id) WHERE message_id = ? AND part = 0" ) . get ( message . message _reference . message _id )
2023-07-11 04:51:30 +00:00
if ( row ) {
repliedToEventId = row . event _id
repliedToEventRoomId = row . room _id
repliedToEventOriginallyFromMatrix = row . source === 0 // source 0 = matrix
}
}
if ( repliedToEventOriginallyFromMatrix ) {
// Need to figure out who sent that event...
2023-08-16 08:44:38 +00:00
const event = await di . api . getEvent ( repliedToEventRoomId , repliedToEventId )
2023-07-11 04:51:30 +00:00
repliedToEventSenderMxid = event . sender
// Need to add the sender to m.mentions
addMention ( repliedToEventSenderMxid )
}
2023-08-19 10:54:23 +00:00
let msgtype = "m.text"
// Handle message type 4, channel name changed
if ( message . type === DiscordTypes . MessageType . ChannelNameChange ) {
msgtype = "m.emote"
message . content = "changed the channel name to **" + message . content + "**"
}
2023-05-12 05:35:37 +00:00
// Text content appears first
2023-07-01 13:41:31 +00:00
if ( message . content ) {
2023-07-10 20:01:11 +00:00
let content = message . content
content = content . replace ( /https:\/\/(?:ptb\.|canary\.|www\.)?discord(?:app)?\.com\/channels\/([0-9]+)\/([0-9]+)\/([0-9]+)/ , ( whole , guildID , channelID , messageID ) => {
2023-08-28 05:32:55 +00:00
const eventID = db . prepare ( "SELECT event_id FROM event_message WHERE message_id = ?" ) . pluck ( ) . get ( messageID )
const roomID = db . prepare ( "SELECT room_id FROM channel_room WHERE channel_id = ?" ) . pluck ( ) . get ( channelID )
if ( eventID && roomID ) {
return ` https://matrix.to/#/ ${ roomID } / ${ eventID } `
2023-07-10 20:01:11 +00:00
} else {
return ` ${ whole } [event not found] `
}
} )
2023-07-11 04:51:30 +00:00
let html = markdown . toHTML ( content , {
2023-07-07 05:31:39 +00:00
discordCallback : getDiscordParseCallbacks ( message , true )
2023-07-01 13:41:31 +00:00
} , null , null )
2023-07-07 05:31:39 +00:00
2023-07-11 05:27:40 +00:00
// TODO: add a string return type to my discord-markdown library
2023-07-11 04:51:30 +00:00
let body = markdown . toHTML ( content , {
2023-07-10 20:01:11 +00:00
discordCallback : getDiscordParseCallbacks ( message , false ) ,
2023-07-07 05:31:39 +00:00
discordOnly : true ,
escapeHTML : false ,
} , null , null )
2023-08-17 06:17:53 +00:00
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
const matches = [ ... content . matchAll ( /@ ?([a-z0-9._]+)\b/gi ) ]
2023-07-11 05:27:40 +00:00
if ( matches . length && matches . some ( m => m [ 1 ] . match ( /[a-z]/i ) ) ) {
const writtenMentionsText = matches . map ( m => m [ 1 ] . toLowerCase ( ) )
const roomID = db . prepare ( "SELECT room_id FROM channel_room WHERE channel_id = ?" ) . pluck ( ) . get ( message . channel _id )
2023-08-16 08:44:38 +00:00
const { joined } = await di . api . getJoinedMembers ( roomID )
2023-07-11 05:27:40 +00:00
for ( const [ mxid , member ] of Object . entries ( joined ) ) {
if ( ! userRegex . some ( rx => mxid . match ( rx ) ) ) {
const localpart = mxid . match ( /@([^:]*)/ )
assert ( localpart )
if ( writtenMentionsText . includes ( localpart [ 1 ] . toLowerCase ( ) ) || writtenMentionsText . includes ( member . display _name . toLowerCase ( ) ) ) addMention ( mxid )
}
}
}
2023-08-16 08:44:38 +00:00
// Star * prefix for fallback edits
if ( options . includeEditFallbackStar ) {
body = "* " + body
html = "* " + html
}
2023-07-11 04:51:30 +00:00
// Fallback body/formatted_body for replies
2023-08-16 08:44:38 +00:00
// This branch is optional - do NOT change anything apart from the reply fallback, since it may not be run
if ( repliedToEventId && options . includeReplyFallback !== false ) {
2023-07-11 04:51:30 +00:00
let repliedToDisplayName
let repliedToUserHtml
if ( repliedToEventOriginallyFromMatrix && repliedToEventSenderMxid ) {
const match = repliedToEventSenderMxid . match ( /^@([^:]*)/ )
assert ( match )
repliedToDisplayName = match [ 1 ] || "a Matrix user" // grab the localpart as the display name, whatever
repliedToUserHtml = ` <a href="https://matrix.to/#/ ${ repliedToEventSenderMxid } "> ${ repliedToDisplayName } </a> `
} else {
repliedToDisplayName = message . referenced _message ? . author . global _name || message . referenced _message ? . author . username || "a Discord user"
repliedToUserHtml = repliedToDisplayName
}
2023-07-12 02:33:38 +00:00
let repliedToContent = message . referenced _message ? . content
if ( repliedToContent == "" ) repliedToContent = "[Media]"
else if ( ! repliedToContent ) repliedToContent = "[Replied-to message content wasn't provided by Discord]"
2023-07-11 04:51:30 +00:00
const repliedToHtml = markdown . toHTML ( repliedToContent , {
discordCallback : getDiscordParseCallbacks ( message , true )
} , null , null )
const repliedToBody = markdown . toHTML ( repliedToContent , {
discordCallback : getDiscordParseCallbacks ( message , false ) ,
discordOnly : true ,
escapeHTML : false ,
} , null , null )
html = ` <mx-reply><blockquote><a href="https://matrix.to/#/ ${ repliedToEventRoomId } / ${ repliedToEventId } ">In reply to</a> ${ repliedToUserHtml } `
+ ` <br> ${ repliedToHtml } </blockquote></mx-reply> `
+ html
body = ( ` ${ repliedToDisplayName } : ` // scenario 1 part B for mentions
+ repliedToBody ) . split ( "\n" ) . map ( line => "> " + line ) . join ( "\n" )
+ "\n\n" + body
}
const newTextMessageEvent = {
$type : "m.room.message" ,
"m.mentions" : mentions ,
2023-08-19 10:54:23 +00:00
msgtype ,
2023-07-11 04:51:30 +00:00
body : body
}
2023-07-01 13:41:31 +00:00
const isPlaintext = body === html
2023-07-07 05:31:39 +00:00
2023-07-11 04:51:30 +00:00
if ( ! isPlaintext ) {
Object . assign ( newTextMessageEvent , {
2023-07-01 13:41:31 +00:00
format : "org.matrix.custom.html" ,
formatted _body : html
} )
2023-05-12 05:35:37 +00:00
}
2023-07-11 04:51:30 +00:00
events . push ( newTextMessageEvent )
2023-04-25 20:06:08 +00:00
}
2023-05-12 05:35:37 +00:00
// Then attachments
const attachmentEvents = await Promise . all ( message . attachments . map ( async attachment => {
2023-07-13 05:36:20 +00:00
const emoji =
attachment . content _type ? . startsWith ( "image/jp" ) ? "📸"
: attachment . content _type ? . startsWith ( "image/" ) ? "🖼️"
: attachment . content _type ? . startsWith ( "video/" ) ? "🎞️"
: attachment . content _type ? . startsWith ( "text/" ) ? "📝"
: attachment . content _type ? . startsWith ( "audio/" ) ? "🎶"
: "📄"
// for large files, always link them instead of uploading so I don't use up all the space in the content repo
if ( attachment . size > reg . ooye . max _file _size ) {
return {
$type : "m.room.message" ,
"m.mentions" : mentions ,
msgtype : "m.text" ,
body : ` ${ emoji } Uploaded file: ${ attachment . url } ( ${ pb ( attachment . size ) } ) ` ,
format : "org.matrix.custom.html" ,
formatted _body : ` ${ emoji } Uploaded file: <a href=" ${ attachment . url } "> ${ attachment . filename } </a> ( ${ pb ( attachment . size ) } ) `
}
} else if ( attachment . content _type ? . startsWith ( "image/" ) && attachment . width && attachment . height ) {
2023-05-12 05:35:37 +00:00
return {
$type : "m.room.message" ,
2023-07-11 04:51:30 +00:00
"m.mentions" : mentions ,
2023-05-12 05:35:37 +00:00
msgtype : "m.image" ,
url : await file . uploadDiscordFileToMxc ( attachment . url ) ,
external _url : attachment . url ,
body : attachment . filename ,
// TODO: filename: attachment.filename and then use body as the caption
info : {
mimetype : attachment . content _type ,
w : attachment . width ,
h : attachment . height ,
size : attachment . size
}
}
2023-08-19 10:54:23 +00:00
} else if ( attachment . content _type ? . startsWith ( "video/" ) && attachment . width && attachment . height ) {
return {
$type : "m.room.message" ,
"m.mentions" : mentions ,
msgtype : "m.video" ,
url : await file . uploadDiscordFileToMxc ( attachment . url ) ,
external _url : attachment . url ,
body : attachment . description || attachment . filename ,
filename : attachment . filename ,
info : {
mimetype : attachment . content _type ,
w : attachment . width ,
h : attachment . height ,
size : attachment . size
}
}
2023-05-12 05:35:37 +00:00
} else {
return {
$type : "m.room.message" ,
2023-07-11 04:51:30 +00:00
"m.mentions" : mentions ,
2023-05-12 05:35:37 +00:00
msgtype : "m.text" ,
2023-07-13 05:36:20 +00:00
body : ` Unsupported attachment: \n ${ JSON . stringify ( attachment , null , 2 ) } \n ${ attachment . url } `
2023-05-12 05:35:37 +00:00
}
}
} ) )
events . push ( ... attachmentEvents )
// Then stickers
2023-06-28 12:06:56 +00:00
if ( message . sticker _items ) {
const stickerEvents = await Promise . all ( message . sticker _items . map ( async stickerItem => {
const format = file . stickerFormat . get ( stickerItem . format _type )
if ( format ? . mime ) {
let body = stickerItem . name
const sticker = guild . stickers . find ( sticker => sticker . id === stickerItem . id )
if ( sticker && sticker . description ) body += ` - ${ sticker . description } `
return {
$type : "m.sticker" ,
2023-07-11 04:51:30 +00:00
"m.mentions" : mentions ,
2023-06-28 12:06:56 +00:00
body ,
info : {
mimetype : format . mime
} ,
url : await file . uploadDiscordFileToMxc ( file . sticker ( stickerItem ) )
}
} else {
return {
$type : "m.room.message" ,
2023-07-11 04:51:30 +00:00
"m.mentions" : mentions ,
2023-06-28 12:06:56 +00:00
msgtype : "m.text" ,
body : "Unsupported sticker format. Name: " + stickerItem . name
}
}
} ) )
events . push ( ... stickerEvents )
}
2023-05-12 05:35:37 +00:00
2023-07-11 04:51:30 +00:00
// Rich replies
if ( repliedToEventId ) {
Object . assign ( events [ 0 ] , {
"m.relates_to" : {
"m.in_reply_to" : {
event _id : repliedToEventId
}
}
} )
}
2023-05-12 05:35:37 +00:00
return events
2023-04-25 20:06:08 +00:00
}
2023-05-05 05:29:08 +00:00
2023-05-08 11:37:51 +00:00
module . exports . messageToEvent = messageToEvent