, I respond: Stop drinking paint. (No), I respond: Stop drinking paint. (No), I respond: Stop drinking paint. (No), I respond: Stop drinking paint. (No)In reply to Extremity' + + '
Image
In reply to a Discord user
[Replied-to message content wasn't provided by Discord]
In reply to a Discord user
[Replied-to message content wasn't provided by Discord]
❭ Brad used /stats — interaction loading...",
- "m.mentions": {},
- msgtype: "m.notice",
- }])
-})
+const Ty = require("../../types")
test("message2event embeds: nothing but a field", async t => {
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
t.deepEqual(events, [{
+ $type: "m.room.message",
+ body: "> ↪️ @papiophidian: used `/stats`",
+ format: "org.matrix.custom.html",
+ formatted_body: "↪️ @papiophidian used /stats",
+ "m.mentions": {},
+ msgtype: "m.text",
+ }, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.notice",
- body: "❭ PapiOphidian used `/stats`"
- + "\n| ### Amanda 🎵#2192 :online:"
+ body: "| ### Amanda 🎵#2192 :online:"
+ "\n| willow tree, branch 0"
+ "\n| **❯ Uptime:**\n| 3m 55s\n| **❯ Memory:**\n| 64.45MB",
format: "org.matrix.custom.html",
- formatted_body: '❭ PapiOphidian used /stats'
- + '", "m.mentions": {} }]) - t.equal(called, 1, "should call getJoinedMembers once") + t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") }) -test("message2event embeds: extreme html is all escaped", async t => { - const events = await messageToEvent(data.message_with_embeds.extreme_html_escaping, data.guild.general) +test("message2event embeds: crazy html is all escaped", async t => { + const events = await messageToEvent(data.message_with_embeds.escaping_crazy_html_tags, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.notice", @@ -152,12 +151,17 @@ test("message2event embeds: title without url", async t => { const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", - msgtype: "m.notice", - body: "❭ PapiOphidian used `/stats`" - + "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!", + body: "> ↪️ @papiophidian: used `/stats`", format: "org.matrix.custom.html", - formatted_body: 'Amanda 🎵#2192
' + formatted_body: '
' @@ -41,9 +33,7 @@ test("message2event embeds: reply with just an embed", async t => { $type: "m.room.message", msgtype: "m.notice", "m.mentions": {}, - body: "> In reply to an unbridged message:" - + "\n> PokemonGod: https://twitter.com/dynastic/status/1707484191963648161" - + "\n\n| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145" + body: "| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145" + "\n| \n| does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?" + "\n| \n| ### Retweets" + "\n| 119" @@ -51,8 +41,7 @@ test("message2event embeds: reply with just an embed", async t => { + "\n| 5581" + "\n| — Twitter", format: "org.matrix.custom.html", - formatted_body: 'Amanda 🎵#2192
' + '
willow tree, branch 0' + '
❯ Uptime:
3m 55s' + '
❯ Memory:
64.45MBIn reply to an unbridged message from PokemonGod:' - + '
https://twitter.com/dynastic/status/1707484191963648161⏺️ dynastic (@dynastic)' + formatted_body: '
' }]) @@ -78,7 +67,7 @@ test("message2event embeds: image embed and attachment", async t => { msgtype: "m.image", url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR", body: "Screenshot_20231001_034036.jpg", - external_url: "https://bridge.example.org/download/discordcdn/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg", + external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&", filename: "Screenshot_20231001_034036.jpg", info: { h: 1170, @@ -94,7 +83,17 @@ test("message2event embeds: blockquote in embed", async t => { let called = 0 const events = await messageToEvent(data.message_with_embeds.blockquote_in_embed, data.guild.general, {}, { api: { - getEffectivePower: mockGetEffectivePower(), + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, async getJoinedMembers(roomID) { called++ t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") @@ -122,11 +121,11 @@ test("message2event embeds: blockquote in embed", async t => { formatted_body: "does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?' + '
Retweets
119Likes
— Twitter
5581reply draft
The following is a message composed via consensus of the Stinker Council.
For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.
Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.
There will be no further communication.
❭ PapiOphidian used /stats'
- + ``, + formatted_body: "Hi, I'm Amanda!
I condone pirating music!
↪️ @papiophidian used /stats",
+ "m.mentions": {},
+ msgtype: "m.text",
+ }, {
+ $type: "m.room.message",
+ msgtype: "m.notice",
+ body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
+ format: "org.matrix.custom.html",
+ formatted_body: ``, "m.mentions": {} }]) }) @@ -166,12 +170,17 @@ test("message2event embeds: url without title", async t => { const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", - msgtype: "m.notice", - body: "❭ PapiOphidian used `/stats`" - + "\n| I condone pirating music!", + body: "> ↪️ @papiophidian: used `/stats`", format: "org.matrix.custom.html", - formatted_body: 'Hi, I'm Amanda!
I condone pirating music!
❭ PapiOphidian used /stats'
- + ``, + formatted_body: "I condone pirating music!
↪️ @papiophidian used /stats",
+ "m.mentions": {},
+ msgtype: "m.text",
+ }, {
+ $type: "m.room.message",
+ msgtype: "m.notice",
+ body: "| I condone pirating music!",
+ format: "org.matrix.custom.html",
+ formatted_body: ``, "m.mentions": {} }]) }) @@ -180,12 +189,17 @@ test("message2event embeds: author without url", async t => { const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", - msgtype: "m.notice", - body: "❭ PapiOphidian used `/stats`" - + "\n| ## Amanda\n| \n| I condone pirating music!", + body: "> ↪️ @papiophidian: used `/stats`", format: "org.matrix.custom.html", - formatted_body: 'I condone pirating music!
❭ PapiOphidian used /stats'
- + ``, + formatted_body: "Amanda
I condone pirating music!
↪️ @papiophidian used /stats",
+ "m.mentions": {},
+ msgtype: "m.text",
+ }, {
+ $type: "m.room.message",
+ msgtype: "m.notice",
+ body: "| ## Amanda\n| \n| I condone pirating music!",
+ format: "org.matrix.custom.html",
+ formatted_body: ``, "m.mentions": {} }]) }) @@ -194,50 +208,17 @@ test("message2event embeds: author url without name", async t => { const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", - msgtype: "m.notice", - body: "❭ PapiOphidian used `/stats`" - + "\n| I condone pirating music!", + body: "> ↪️ @papiophidian: used `/stats`", format: "org.matrix.custom.html", - formatted_body: 'Amanda
I condone pirating music!
❭ PapiOphidian used /stats'
- + ``, - "m.mentions": {} - }]) -}) - -test("message2event embeds: 4 images", async t => { - const events = await messageToEvent(data.message_with_embeds.four_images, data.guild.general) - t.deepEqual(events, [{ - $type: "m.room.message", + formatted_body: "I condone pirating music!
↪️ @papiophidian used /stats",
+ "m.mentions": {},
msgtype: "m.text",
- body: "[↷ Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046",
- format: "org.matrix.custom.html",
- formatted_body: "↷ Forwarded messagehttps://fixupx.com/i/status/2032003668787020046", - "m.mentions": {} }, { $type: "m.room.message", msgtype: "m.notice", - body: "» | ## ⏺️ AUTOMATON WEST (@AUTOMATON_ENG) https://x.com/AUTOMATON_ENG/status/2032003668787020046" - + "\n» | " - + "\n» | 4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanity’s last non\\-AI made social network”" - + "\n» | ︀︀" - + "\n» | ︀︀[automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/](https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/)" - + "\n» | " - + "\n» | **[💬](https://x.com/intent/tweet?in_reply_to=2032003668787020046) 36 [🔁](https://x.com/intent/retweet?tweet_id=2032003668787020046) 212 [❤](https://x.com/intent/like?tweet_id=2032003668787020046) 3\\.0K 👁 131\\.7K **" - + "\n» | " - + "\n» | 📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig" - + "\n» | — FixupX" - + "\n» | 📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig" - + "\n» | 📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig" - + "\n» | 📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig", + body: "| I condone pirating music!", format: "org.matrix.custom.html", - formatted_body: "
", + formatted_body: `" - + "⏺️ AUTOMATON WEST (@AUTOMATON_ENG)
" - + "4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanity’s last non-AI made social network”" - + "
" - + "
︀︀
︀︀automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/" - + "
💬 36 🔁 212 ❤ 3.0K 👁 131.7K📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig
— FixupX📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig
" - + "📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig
" - + "📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig
`, "m.mentions": {} }]) }) @@ -337,56 +318,19 @@ test("message2event embeds: youtube video", async t => { }]) }) -test("message2event embeds: embed not bridged if its link was spoilered", async t => { - const events = await messageToEvent({ - ...data.message_with_embeds.youtube_video, - content: "||https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight||" - }, data.guild.general) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "[spoiler]", - format: "org.matrix.custom.html", - formatted_body: `https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_EI condone pirating music!
", - "m.mentions": {} - }]) -}) - -test("message2event embeds: klipy gif should send in customised format", async t => { - const events = await messageToEvent(data.message_with_embeds.klipy_gif, data.guild.general, {}, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "[GIF] Cute Corgi Waddle https://static.klipy.com/ii/d7aec6f6f171607374b2065c836f92f4/5b/5b/7ndEhcilPNKJ8O.mp4", - format: "org.matrix.custom.html", - formatted_body: "🎞️ https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4
➿ Cute Corgi Waddle", - "m.mentions": {} - }]) -}) - test("message2event embeds: if discord creates an embed preview for a discord channel link, don't copy that embed", async t => { const events = await messageToEvent(data.message_with_embeds.discord_server_included_punctuation_bad_discord, data.guild.general, {}, { api: { - getEffectivePower: mockGetEffectivePower(), + async getStateEvent(roomID, type, key) { + t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, async getJoinedMembers(roomID) { t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe") return { @@ -407,16 +351,3 @@ test("message2event embeds: if discord creates an embed preview for a discord ch "m.mentions": {} }]) }) - -test("message2event embeds: nothing generated if embeds are disabled in settings", async t => { - db.prepare("UPDATE guild_space SET url_preview = 0 WHERE guild_id = ?").run(data.guild.general.id) - const events = await messageToEvent(data.message_with_embeds.youtube_video, data.guild.general) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight", - format: "org.matrix.custom.html", - formatted_body: `https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E
${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})` + formatted_body: `
${emoji} Uploaded SPOILER file: ${publicURL} (${pb(attachment.size)})` } } // for large files, always link them instead of uploading so I don't use up all the space in the content repo - else if (alwaysLink || attachment.size > reg.ooye.max_file_size) { + else if (attachment.size > reg.ooye.max_file_size) { return { $type: "m.room.message", "m.mentions": mentions, msgtype: "m.text", - body: `${emoji} Uploaded file: ${external_url} (${pb(attachment.size)})`, + body: `${emoji} Uploaded file: ${publicURL} (${pb(attachment.size)})`, format: "org.matrix.custom.html", - formatted_body: `${emoji} Uploaded file: ${attachment.filename} (${pb(attachment.size)})` + formatted_body: `${emoji} Uploaded file: ${attachment.filename} (${pb(attachment.size)})` } } else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) { return { @@ -148,7 +138,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { "m.mentions": mentions, msgtype: "m.image", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url, + external_url: attachment.url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -164,7 +154,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { "m.mentions": mentions, msgtype: "m.video", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url, + external_url: attachment.url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -180,13 +170,13 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { "m.mentions": mentions, msgtype: "m.audio", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url, + external_url: attachment.url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { mimetype: attachment.content_type, size: attachment.size, - duration: attachment.duration_secs && Math.round(attachment.duration_secs * 1000) + duration: attachment.duration_secs ? attachment.duration_secs * 1000 : undefined } } } else { @@ -195,7 +185,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { "m.mentions": mentions, msgtype: "m.file", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url, + external_url: attachment.url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -206,100 +196,15 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { } } -/** @param {DiscordTypes.APIPoll} poll */ -async function pollToEvent(poll) { - let fallbackText = poll.question.text - if (poll.allow_multiselect) { - var maxSelections = poll.answers.length; - } else { - var maxSelections = 1; - } - let answers = poll.answers.map(answer=>{ - let matrixText = answer.poll_media.text - if (answer.poll_media.emoji) { - if (answer.poll_media.emoji.id) { - // Custom emoji. It seems like no Matrix client allows custom emoji in poll answers, so leaving this unimplemented. - } else { - matrixText = "[" + answer.poll_media.emoji.name + "] " + matrixText - } - } - let matrixAnswer = { - id: answer.answer_id.toString(), - "org.matrix.msc1767.text": matrixText - } - fallbackText = fallbackText + "\n" + answer.answer_id.toString() + ". " + matrixText - return matrixAnswer; - }) - return { - /** @type {"org.matrix.msc3381.poll.start"} */ - $type: "org.matrix.msc3381.poll.start", - "org.matrix.msc3381.poll.start": { - question: { - "org.matrix.msc1767.text": poll.question.text, - body: poll.question.text, - msgtype: "m.text" - }, - kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. - max_selections: maxSelections, - answers: answers - }, - "org.matrix.msc1767.text": fallbackText - } -} - /** - * @param {DiscordTypes.APIMessageInteraction} interaction - * @param {boolean} isThinkingInteraction - */ -function getFormattedInteraction(interaction, isThinkingInteraction) { - const mxid = select("sim", "mxid", {user_id: interaction.user.id}).pluck().get() - const username = interaction.member?.nick || interaction.user.global_name || interaction.user.username - const thinkingText = isThinkingInteraction ? " — interaction loading..." : "" - return { - body: `❭ ${username} used \`/${interaction.name}\`${thinkingText}`, - html: `
❭ ${mxid ? tag`${username}` : username} used /${interaction.name}${thinkingText}`
- }
-}
-
-/**
- * @param {any} newEvents merge into events
- * @param {any} events will be modified
- * @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc
- * @param {boolean} [forceMerge] if true, must merge event, will error if it had to append
- */
-function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false) {
- let prev = events.at(-1)
- for (const ne of newEvents) {
- const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype)
- const typesPermitted = !forceSameMsgtype || ne?.msgtype === prev?.msgtype
- if (isAllText && typesPermitted) {
- const rep = new mxUtils.MatrixStringBuilder()
- rep.body = prev.body
- rep.formattedBody = prev.formatted_body
- rep.addLine(ne.body, ne.formatted_body)
- prev.body = rep.body
- prev.formatted_body = rep.formattedBody
- } else if (forceMerge) {
- throw new Error("Unable to merge events")
- } else {
- events.push(ne)
- }
- }
-}
-
-/**
- * @param {DiscordTypes.APIMessage} message
- * @param {DiscordTypes.APIGuild} guild
- * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean, scanTextForMentions?: boolean}} options default values:
+ * @param {import("discord-api-types/v10").APIMessage} message
+ * @param {import("discord-api-types/v10").APIGuild} guild
+ * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values:
* - includeReplyFallback: true
* - includeEditFallbackStar: false
- * - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true.
- * - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned.
- * @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API
- * @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>}
+ * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
*/
async function messageToEvent(message, guild, options = {}, di) {
- message = structuredClone(message)
const events = []
/* c8 ignore next 7 */
@@ -311,38 +216,6 @@ async function messageToEvent(message, guild, options = {}, di) {
return []
}
- if (message.type === DiscordTypes.MessageType.PollResult) {
- const pollMessageID = message.message_reference?.message_id
- if (!pollMessageID) return []
- const event_id = select("event_message", "event_id", {message_id: pollMessageID}).pluck().get()
- const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
- const pollQuestionText = select("poll", "question_text", {message_id: pollMessageID}).pluck().get()
- if (!event_id || !roomID || !pollQuestionText) return [] // drop it if the corresponding poll start was not bridged
-
- const rep = new mxUtils.MatrixStringBuilder()
- rep.addLine(`The poll ${pollQuestionText} has closed.`, tag`The poll ${pollQuestionText} has closed.`)
-
- const {messageString} = pollResponses.getCombinedResults(pollMessageID, true) // poll results have already been double-checked before this point, so these totals will be accurate
- rep.addLine(markdown.toHTML(messageString, {discordOnly: true, escapeHTML: false}), markdown.toHTML(messageString, {}))
-
- const {body, formatted_body} = rep.get()
-
- return [{
- $type: "org.matrix.msc3381.poll.end",
- "m.relates_to": {
- rel_type: "m.reference",
- event_id
- },
- "org.matrix.msc3381.poll.end": {},
- "org.matrix.msc1767.text": body,
- "org.matrix.msc1767.html": formatted_body,
- body: body,
- format: "org.matrix.custom.html",
- formatted_body: formatted_body,
- msgtype: "m.text"
- }]
- }
-
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.
@@ -360,20 +233,13 @@ async function messageToEvent(message, guild, options = {}, di) {
}]
}
- if (message.type === DiscordTypes.MessageType.ChannelFollowAdd) {
- return [{
- $type: "m.room.message",
- msgtype: "m.emote",
- body: `set this room to receive announcements from ${message.content}`,
- format: "org.matrix.custom.html",
- formatted_body: tag`set this room to receive announcements from ${message.content}`,
- "m.mentions": {}
- }]
+ const interaction = message.interaction_metadata || message.interaction
+ if (message.type === DiscordTypes.MessageType.ChatInputCommand && interaction && "name" in interaction) {
+ // Commands are sent by the responding bot. Need to attach the metadata of the person using the command at the top.
+ if (message.content) message.content = `\n${message.content}`
+ message.content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${message.content}`
}
- let isInteraction = (message.type === DiscordTypes.MessageType.ChatInputCommand || message.type === DiscordTypes.MessageType.ContextMenuCommand) && message.interaction && "name" in message.interaction
- let isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading)
-
/**
@type {{room?: boolean, user_ids?: string[]}}
We should consider the following scenarios for mentions:
@@ -391,10 +257,7 @@ async function messageToEvent(message, guild, options = {}, di) {
- So make sure we don't do anything in this case.
*/
const mentions = {}
- /** @type {{event_id: string, room_id: string, source: number, channel_id: string}?} */
let repliedToEventRow = null
- let repliedToEventInDifferentRoom = false
- let repliedToUnknownEvent = false
let repliedToEventSenderMxid = null
if (message.mention_everyone) mentions.room = true
@@ -407,21 +270,9 @@ async function messageToEvent(message, guild, options = {}, di) {
// 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) {
- const row = await getHistoricalEventRow(message.message_reference?.message_id)
- if (row && "event_id" in row) {
- repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
- } else if (message.referenced_message) {
- repliedToUnknownEvent = true
- }
- } else if (message.type === DiscordTypes.MessageType.ContextMenuCommand && message.interaction && message.message_reference?.message_id) {
- // It could be a /plu/ral emulated reply
- if (message.interaction.name.startsWith("Reply ") && message.content.startsWith("-# [↪](")) {
- const row = await getHistoricalEventRow(message.message_reference?.message_id)
- if (row && "event_id" in row) {
- repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
- message.content = message.content.replace(/^.*\n/, "")
- isInteraction = false // declutter
- }
+ const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(message.message_reference.message_id)
+ if (row) {
+ repliedToEventRow = row
}
} else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) {
// It could be a PluralKit emulated reply, let's see if it has a message link
@@ -431,8 +282,8 @@ async function messageToEvent(message, guild, options = {}, di) {
assert(message.embeds[0].description)
const match = message.embeds[0].description.match(/\/channels\/[0-9]*\/[0-9]*\/([0-9]{2,})/)
if (match) {
- const row = await getHistoricalEventRow(match[1])
- if (row && "event_id" in row) {
+ const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(match[1])
+ if (row) {
/*
we generate a partial referenced_message based on what PK provided. we don't need everything, since this will only be used for further message-to-event converting.
the following properties are necessary:
@@ -450,7 +301,7 @@ async function messageToEvent(message, guild, options = {}, di) {
}
}
message.embeds.shift()
- repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
+ repliedToEventRow = row
}
}
}
@@ -477,34 +328,6 @@ async function messageToEvent(message, guild, options = {}, di) {
return promise
}
- /**
- * @param {string} messageID
- * @param {string} [timestampChannelID]
- */
- async function getHistoricalEventRow(messageID, timestampChannelID) {
- /** @type {{room_id: string} | {event_id: string, room_id: string, reference_channel_id: string, source: number} | null | undefined} */
- let row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
- .select("event_id", "room_id", "reference_channel_id", "source").where({message_id: messageID}).and("ORDER BY part ASC").get()
- if (!row && timestampChannelID) {
- const ts = dUtils.snowflakeToTimestampExact(messageID)
- const oldestRow = from("historical_channel_room").selectUnsafe("max(upgraded_timestamp)", "room_id")
- .where({reference_channel_id: timestampChannelID}).and("and upgraded_timestamp < ?").get(ts)
- if (oldestRow?.room_id) {
- row = {room_id: oldestRow.room_id}
- try {
- const {event_id} = await di.api.getEventForTimestamp(oldestRow.room_id, ts)
- row = {
- event_id,
- room_id: oldestRow.room_id,
- reference_channel_id: oldestRow.reference_channel_id,
- source: 1
- }
- } catch (e) {}
- }
- }
- return row
- }
-
/**
* Translate Discord message links to Matrix event links.
* If OOYE has handled this message in the past, this is an instant database lookup.
@@ -516,13 +339,27 @@ async function messageToEvent(message, guild, options = {}, di) {
for (const match of [...content.matchAll(/https:\/\/(?:ptb\.|canary\.|www\.)?discord(?:app)?\.com\/channels\/[0-9]+\/([0-9]+)\/([0-9]+)/g)]) {
assert(typeof match.index === "number")
const [_, channelID, messageID] = match
- const result = await (async () => {
- const row = await getHistoricalEventRow(messageID, channelID)
- if (!row) return `${match[0]} [event is from another server]`
- const via = await getViaServersMemo(row.room_id)
- if (!("event_id" in row)) return `[unknown event in https://matrix.to/#/${row.room_id}?${via}]`
- return `https://matrix.to/#/${row.room_id}/${row.event_id}?${via}`
- })()
+ let result
+
+ const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
+ if (roomID) {
+ const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
+ const via = await getViaServersMemo(roomID)
+ if (eventID && roomID) {
+ result = `https://matrix.to/#/${roomID}/${eventID}?${via}`
+ } else {
+ const ts = dUtils.snowflakeToTimestampExact(messageID)
+ try {
+ const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
+ result = `https://matrix.to/#/${roomID}/${event_id}?${via}`
+ } catch (e) {
+ // M_NOT_FOUND: Unable to find event from In reply to ${repliedToUserHtml}` - + `` - + html - body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions - + "\n\n" + body - } else if (referenced.type === DiscordTypes.MessageType.UserJoin) { - // Discord user join messages are bridged as joins, not text events. Generate substitute text for reply. - const joinerMxid = select("sim", "mxid", {user_id: referenced.author.id}).pluck().get() - const joinerHtml = joinerMxid ? tag`${repliedToDisplayName}` : tag`${repliedToDisplayName}` - html = `
${repliedToHtml}
${joinerHtml} joined the room` + html - body = `> ${repliedToDisplayName} joined the room\n\n` + body - } else { // repliedToUnknownEvent - const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp) - html = `
In reply to ${dateDisplay} from ${repliedToDisplayName}:` - + `` - + html - body = `In reply to ${dateDisplay}:\n${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") - + "\n\n" + body - } + if (repliedToEventRow && options.includeReplyFallback !== false) { + let repliedToDisplayName + let repliedToUserHtml + if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) { + const match = repliedToEventSenderMxid.match(/^@([^:]*)/) + assert(match) + repliedToDisplayName = message.referenced_message?.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever + repliedToUserHtml = `${repliedToDisplayName}` + } else { + repliedToDisplayName = message.referenced_message?.author.global_name || message.referenced_message?.author.username || "a Discord user" + repliedToUserHtml = repliedToDisplayName } - } - - if (isInteraction && !isThinkingInteraction && message.interaction && events.length === 0) { - const formattedInteraction = getFormattedInteraction(message.interaction, false) - body = `${formattedInteraction.body}\n${body}` - html = `${formattedInteraction.html}${html}` + let repliedToContent = message.referenced_message?.content + if (repliedToContent?.match(/^(-# )?> (-# )?<:L1:/)) { + // If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote. + // Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message. + // ┌──────A─────┐ A reply rep starting with >quote or -#smalltext >quote. Match until the end of the line. + // ┆ ┆┌─B─┐ There may be up to 2 reply rep lines in a row if it was created in the old format. Match all lines. + repliedToContent = repliedToContent.replace(/^((-# )?> .*\n){1,2}/, "") + } + if (repliedToContent == "") repliedToContent = "[Media]" + else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]" + const repliedToHtml = markdown.toHTML(repliedToContent, { + discordCallback: getDiscordParseCallbacks(message, guild, true) + }) + const repliedToBody = markdown.toHTML(repliedToContent, { + discordCallback: getDiscordParseCallbacks(message, guild, false), + discordOnly: true, + escapeHTML: false, + }) + html = `
${repliedToHtml}
In reply to ${repliedToUserHtml}` + + `
${repliedToHtml}
${event.formatted_body}` - } - } - - // Try to merge the forwarded content with the forwarded notice - let {body, formatted_body} = forwardedNotice.get() - if (forwardedEvents.length >= 1 && ["m.text", "m.notice"].includes(forwardedEvents[0].msgtype)) { // Try to merge the forwarded content and the forwarded notice - forwardedEvents[0].body = body + "\n" + forwardedEvents[0].body - forwardedEvents[0].formatted_body = formatted_body + "
${html}` - await addTextEvent(body, html, "m.notice") } + + // Text content appears first + const {body, html} = await transformContent(message.content) + await addTextEvent(body, html, msgtype, {scanMentions: true}) } // Then attachments if (message.attachments) { - const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment))) - - // Try to merge attachment events with the previous event - // This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together. - mergeTextEvents(attachmentEvents, events, false) - } - - // Then components - if (message.components?.length) { - const stack = new mxUtils.MatrixStringBuilderStack() - /** @param {DiscordTypes.APIMessageComponent} component */ - async function processComponent(component) { - // Standalone components - if (component.type === DiscordTypes.ComponentType.TextDisplay) { - const {body, html} = await transformContent(component.content) - stack.msb.addParagraph(body, html) - } - else if (component.type === DiscordTypes.ComponentType.Separator) { - stack.msb.addParagraph("----", "
${formatted_body}` - if (stack.msb.body) stack.msb.body += "\n\n" - stack.msb.add(body, formatted_body) - } - else if (component.type === DiscordTypes.ComponentType.Section) { - // May contain text display, possibly more in the future - // Accessory may be button or thumbnail - stack.bump() - for (const innerComponent of component.components) { - await processComponent(innerComponent) - } - if (component.accessory) { - stack.bump() - await processComponent(component.accessory) - const {body, formatted_body} = stack.shift().get() - stack.msb.addLine(body, formatted_body) - } - const {body, formatted_body} = stack.shift().get() - stack.msb.addParagraph(body, formatted_body) - } - else if (component.type === DiscordTypes.ComponentType.ActionRow) { - const linkButtons = component.components.filter(c => c.type === DiscordTypes.ComponentType.Button && c.style === DiscordTypes.ButtonStyle.Link) - if (linkButtons.length) { - stack.msb.addLine("") - for (const linkButton of linkButtons) { - await processComponent(linkButton) - } - } - } - // Components that can only be inside things - else if (component.type === DiscordTypes.ComponentType.Thumbnail) { - // May only be a section accessory - stack.msb.add(`🖼️ ${component.media.url}`, tag`🖼️ ${component.media.url}`) - } - else if (component.type === DiscordTypes.ComponentType.Button) { - // May only be a section accessory or in an action row (up to 5) - if (component.style === DiscordTypes.ButtonStyle.Link) { - assert(component.label) // required for Discord to validate link buttons - const link = await transformContentMessageLinks(component.url) - stack.msb.add(`[${component.label} ${link}] `, tag`${component.label} `) - } - } - - // Not handling file upload or label because they are modal-only components - } - - for (const component of message.components) { - await processComponent(component) - } - - const {body, formatted_body} = stack.msb.get() - if (body.trim().length) { - // Create new message if Components V2 (cannot have regular content) - if ((message.flags ?? 0) & DiscordTypes.MessageFlags.IsComponentsV2) { - await addTextEvent(body, formatted_body, "m.text") - } - // Add to existing message if legacy components https://docs.discord.com/developers/components/reference#legacy-message-component-behavior - else { - mergeTextEvents([{ - msgtype: "m.text", - body, - format: "org.matrix.custom.html", - formatted_body - }], events, false, true) - } - } - } - - // Then polls - if (message.poll) { - const pollEvent = await pollToEvent(message.poll) - events.push(pollEvent) + const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions))) + events.push(...attachmentEvents) } // Then embeds - const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1 for (const embed of message.embeds || []) { - if (!urlPreviewEnabled && !message.author?.bot) { - continue // show embeds for everyone if enabled, or bot users only if disabled (bots often send content in embeds) - } - if (embed.type === "image") { continue // Matrix's own URL previews are fine for images. } - if (embed.type === "video" && embed.video?.url && !embed.title && message.content.includes(embed.video.url)) { - continue // Doesn't add extra information and the direct video URL is already there. - } - - if (embed.type === "poll_result") { - // The code here is only for the message to be bridged to Matrix. Dealing with the Discord-side updates is in d2m/actions/poll-end.js. - } - if (embed.url?.startsWith("https://discord.com/")) { continue // If discord creates an embed preview for a discord channel link, don't copy that embed } - if (embed.url && spoilers.some(sp => sp.match(/\bhttps?:\/\/[a-z]/))) { - // If the original message had spoilered URLs, don't generate any embeds for links. - // This logic is the same as the Discord desktop client. It doesn't match specific embeds to specific spoilered text, it's all or nothing. - // It's not easy to do much better because posting a link like youtu.be generates an embed.url with youtube.com/watch, so you can't match up the text without making at least that a special case. - continue - } - // Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once const rep = new mxUtils.MatrixStringBuilder() - let isAdditionalImage = false - - if (isKlipyGIF) { - assert(embed.video?.url) - rep.add("[GIF] ", "➿ ") - if (embed.title) { - rep.add(`${embed.title} ${embed.video.url}`, tag`${embed.title}`) - } else { - rep.add(embed.video.url) - } - - let {body, formatted_body: html} = rep.get() - html = `
${html}` - await addTextEvent(body, html, "m.text") - continue - } // Provider - if (embed.provider?.name && embed.provider.name !== "Tenor") { + if (embed.provider?.name) { if (embed.provider.url) { rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`${embed.provider.name}`) } else { @@ -1105,26 +599,17 @@ async function messageToEvent(message, guild, options = {}, di) { let chosenImage = embed.image?.url // the thumbnail seems to be used for "article" type but displayed big at the bottom by discord if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url + if (chosenImage) rep.addParagraph(`📸 ${chosenImage}`) - if (chosenImage) { - isAdditionalImage = !rep.body && !!events.length - rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`) - } - - if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`) + if (embed.video?.url) rep.addParagraph(`🎞️ ${embed.video.url}`) if (embed.footer?.text) rep.addLine(`— ${embed.footer.text}`, tag`— ${embed.footer.text}`) let {body, formatted_body: html} = rep.get() body = body.split("\n").map(l => "| " + l).join("\n") html = `
${html}` - if (isAdditionalImage) { - mergeTextEvents([{...rep.get(), body, html, msgtype: "m.notice"}], events, true) - continue - } - // Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person - await addTextEvent(body, html, "m.notice") + await addTextEvent(body, html, "m.notice", {scanMentions: false}) } // Then stickers @@ -1143,7 +628,7 @@ async function messageToEvent(message, guild, options = {}, di) { } } else { let body = stickerItem.name - const sticker = guild.stickers?.find(sticker => sticker.id === stickerItem.id) + const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id) if (sticker && sticker.description) body += ` - ${sticker.description}` return { $type: "m.sticker", @@ -1160,7 +645,7 @@ async function messageToEvent(message, guild, options = {}, di) { } // Rich replies - if (repliedToEventRow && !repliedToEventInDifferentRoom) { + if (repliedToEventRow) { Object.assign(events[0], { "m.relates_to": { "m.in_reply_to": { @@ -1170,16 +655,6 @@ async function messageToEvent(message, guild, options = {}, di) { }) } - // Strip formatted_body where equivalent to body - if (!options.alwaysReturnFormattedBody) { - for (const event of events) { - if (event.$type === "m.room.message" && "msgtype" in event && ["m.text", "m.notice"].includes(event.msgtype) && event.body === event.formatted_body) { - delete event.format - delete event.formatted_body - } - } - } - return events } diff --git a/src/d2m/converters/message-to-event.test.pk.js b/src/d2m/converters/message-to-event.pk.test.js similarity index 72% rename from src/d2m/converters/message-to-event.test.pk.js rename to src/d2m/converters/message-to-event.pk.test.js index 1323280..ce83d54 100644 --- a/src/d2m/converters/message-to-event.test.pk.js +++ b/src/d2m/converters/message-to-event.pk.test.js @@ -50,7 +50,11 @@ test("message2event: pk reply to matrix is converted to native matrix reply", as ] }, msgtype: "m.text", - body: "this is a reply", + body: "> cadence [they]: now for my next experiment:\n\nthis is a reply", + format: "org.matrix.custom.html", + formatted_body: '
In reply to cadence [they]
' + + "now for my next experiment:
In reply to wing
' + + "some text
In reply to Ampflower 🌺
' + + "[Media]
" - + "" - + "Lillith (INX)
" - + "Display name: Lillith (she/her)" - + "
" - + `🖼️ https://files.inx.moe/p/cdn/lillith.webp` - + "
Pronouns: She/Her" - + "
Message count: 3091
" - + "Proxy tags:" - + "
l;text" - + "l:text" - + "l.text" - + "textl." - + "textl;" - + "textl:
System ID: xffgnx ∙ Member ID: pphhoh
"
- + "Created: 2025-12-31 03:16:45 UTC
` - + "System: INX (
" - + `🖼️ https://files.inx.moe/p/cdn/lillith.webp` - + "xffgnx)" - + "
Member: Lillith (pphhoh)" - + "
Sent by: infinidoge1337 (@unknown-user)" - + "
Account Roles (7)" - + "
§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping
" - + "Same hat
" - + `🖼️ Image: image.png
Original Message ID: 1466556003645657118 · <t:1769724599:f>
", - "m.mentions": {}, - msgtype: "m.text", - }]) -}) - -test("message2event components: pk ping message legacy components", async t => { - const events = await messageToEvent(data.message_with_components.pk_ping_components_v1, data.guild.general, {}, { - api: { - async getJoinedMembers() { - return {joined: {}} - }, - getEffectivePower: mockGetEffectivePower() - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "❭ cadence used `/🔔 Ping author`" - + "\nPsst, **Red** (@cadence.worm:), you have been pinged by @cadence.worm:." - + "\n[Jump https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe] ", - format: "org.matrix.custom.html", - formatted_body: "❭ cadence used /🔔 Ping authorPsst, Red (@cadence.worm), you have been pinged by @cadence.worm.In reply to Extremity' + + '
Image
In reply to cadence' + + '
so can you reply to my webhook uwu
In reply to cadence [they]
What about them?
In reply to Ami (she/her)
let me guess they got a lot of bug reports like "empty chest with no loot?"
In reply to a 1-day-old unbridged message from Occimyy:enigmatic`, - "m.mentions": {} - }]) -}) - -test("message2event: reply to a Discord member join (who didn't join on Matrix)", async t => { - const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party", - format: "org.matrix.custom.html", - formatted_body: "
BILLY BOB THE GREAT
PEASANT!! joined the roomwhen the broke friend who we pay to bring food shows up at the medieval lord party", - "m.mentions": {} - }]) -}) - -test("message2event: reply to a Discord member join (who did join on Matrix)", async t => { - db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES ('1461677775554478161', 'peasant321_76775', 'peasant321_76775', '@_ooye_peasant321_76775:cadence.moe')").run() - const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party", - format: "org.matrix.custom.html", - formatted_body: `
PEASANT!! joined the roomwhen the broke friend who we pay to bring food shows up at the medieval lord party`, - "m.mentions": {} + formatted_body: `
In reply to Ami (she/her)
let me guess they got a lot of bug reports like "empty chest with no loot?"
public @Nullable EntityType<?>`
- }])
-})
-
-test("message2event: written @mentions do not match in code block", async t => {
- const events = await messageToEvent({
- ...data.message.advanced_written_at_mention_for_matrix,
- content: "```java\npublic @Nullable EntityType>\n```"
- }, data.guild.general, {}, {})
- t.deepEqual(events, [{
- $type: "m.room.message",
- "m.mentions": {},
- msgtype: "m.text",
- body: "```java\npublic @Nullable EntityType>\n```",
- format: "org.matrix.custom.html",
- formatted_body: `public @Nullable EntityType<?>`
- }])
-})
-
-test("message2event: entire message may match elaborate display name", async t => {
- let called = 0
- const events = await messageToEvent({
- ...data.message.advanced_written_at_mention_for_matrix,
- content: "@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆"
- }, data.guild.general, {}, {
- api: {
- async getJoinedMembers(roomID) {
- called++
- t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
- return new Promise(resolve => {
- setTimeout(() => {
- resolve({
- joined: {
- "@wa:cadence.moe": {
- display_name: "Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
- avatar_url: "whatever"
- },
- "@huckleton:cadence.moe": {
- display_name: "huck",
- avatar_url: "whatever"
- },
- "@_ooye_botrac4r:cadence.moe": {
- display_name: "botrac4r",
- avatar_url: "whatever"
- },
- "@_ooye_bot:cadence.moe": {
- display_name: "Out Of Your Element",
- avatar_url: "whatever"
- }
- }
- })
- })
- })
- }
- }
- })
- t.deepEqual(events, [{
- $type: "m.room.message",
- "m.mentions": {
- user_ids: [
- "@wa:cadence.moe",
- ]
- },
- msgtype: "m.text",
- body: "@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
- format: "org.matrix.custom.html",
- formatted_body: `@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆`
- }])
-})
-
-test("message2event: spoilers are removed from plaintext body", async t => {
- const events = await messageToEvent({
- content: "||**beatrice**||"
- })
- t.deepEqual(events, [{
- $type: "m.room.message",
- "m.mentions": {},
- msgtype: "m.text",
- body: "[spoiler]",
- format: "org.matrix.custom.html",
- formatted_body: `beatrice`
- }])
-})
-
test("message2event: very large attachment is linked instead of being uploaded", async t => {
const events = await messageToEvent({
content: "hey",
@@ -1093,66 +842,18 @@ test("message2event: very large attachment is linked instead of being uploaded",
size: 100e6
}]
})
- t.deepEqual(events, [{
- $type: "m.room.message",
- "m.mentions": {},
- msgtype: "m.text",
- body: "hey\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)",
- format: "org.matrix.custom.html",
- formatted_body: 'hey📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg (38 KB)` - + `📄 Uploaded file: hey.jpg (100 MB)` }, { $type: "m.room.message", "m.mentions": {}, - msgtype: "m.file", - body: "my enemies.txt", - filename: "my enemies.txt", - external_url: "https://bridge.example.org/download/discordcdn/123/456/my_enemies.txt", - url: "mxc://cadence.moe/y89EOTRp2lbeOkgdsEleGOge", - info: { - mimetype: "text/plain", - size: 8911 - } + msgtype: "m.text", + body: "📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", + format: "org.matrix.custom.html", + formatted_body: '📄 Uploaded file: hey.jpg (100 MB)' }]) }) @@ -1168,19 +869,6 @@ test("message2event: type 4 channel name change", async t => { }]) }) -test("message2event: type 12 channel follow add", async t => { - const events = await messageToEvent(data.special_message.channel_follow_add, data.guild.general) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.emote", - body: "set this room to receive announcements from PluralKit #downtime", - format: "org.matrix.custom.html", - formatted_body: "set this room to receive announcements from PluralKit #downtime", - "m.mentions": {} - }]) -}) - test("message2event: thread start message reference", async t => { const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, { api: { @@ -1240,18 +928,6 @@ test("message2event: emoji that hasn't been registered yet", async t => { }]) }) -test("message2event: emojihax", async t => { - const events = await messageToEvent(data.message.emojihax, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "I only violate the don't modify our console part of terms of service :troll:", - format: "org.matrix.custom.html", - formatted_body: `I only violate the don't modify our console part of terms of service
What's cooking, good looking?`, - "m.mentions": {}, - msgtype: "m.text", - }, - { - $type: "m.room.message", - body: "100km.gif", - external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif", - filename: "100km.gif", - info: { - h: 300, - mimetype: "image/gif", - size: 2965649, - w: 300, - }, - "m.mentions": {}, - msgtype: "m.image", - url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh", - }, - { - $type: "m.room.message", - body: "» | ## This man" - + "\n» | " - + "\n» | ## This man is 100 km away from your house" - + "\n» | " - + "\n» | ### Distance away" - + "\n» | 99 km" - + "\n» | " - + "\n» | ### Distance away" - + "\n» | 98 km", - format: "org.matrix.custom.html", - formatted_body: "
", - "m.mentions": {}, - msgtype: "m.notice" - } - ]) -}) - -test("message2event: constructed forwarded text", async t => { - const events = await messageToEvent(data.message.constructed_forwarded_text, {}, {}, { - api: { - getEffectivePower: mockGetEffectivePower(), - async getJoinedMembers() { - return { - joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} - } - } - } - } - }) - t.deepEqual(events, [ - { - $type: "m.room.message", - body: "[↷ Forwarded from #amanda-spam]" - + "\n» What's cooking, good looking?", - format: "org.matrix.custom.html", - formatted_body: `↷ Forwarded from amanda-spam [jump to room]` - + `This man
This man is 100 km away from your house
Distance away
99 kmDistance away
98 km
What's cooking, good looking?`, - "m.mentions": {}, - msgtype: "m.text", - }, - { - $type: "m.room.message", - body: "What's cooking everybody ‼️", - "m.mentions": {}, - msgtype: "m.text", - } - ]) -}) - - -test("message2event: don't scan forwarded messages for mentions", async t => { - const events = await messageToEvent(data.message.forwarded_dont_scan_for_mentions, {}, {}, {}) - t.deepEqual(events, [ - { - $type: "m.room.message", - body: "[↷ Forwarded message]" - + "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114", - format: "org.matrix.custom.html", - formatted_body: `↷ Forwarded message` - + `
If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114`, - "m.mentions": {}, - msgtype: "m.text" - } - ]) -}) - -test("message2event: invite no details embed if no event", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { - snow: { - invite: { - getInvite: async () => ({...data.invite.irl, guild_scheduled_event: null}) - } - } - }) - t.deepEqual(events, [ - { - $type: "m.room.message", - body: "https://discord.gg/placeholder?event=1381190945646710824", - format: "org.matrix.custom.html", - formatted_body: "https://discord.gg/placeholder?event=1381190945646710824", - "m.mentions": {}, - msgtype: "m.text", - } - ]) -}) - -test("message2event: irl invite event renders embed", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { - snow: { - invite: { - getInvite: async () => data.invite.irl - } - } - }) - t.deepEqual(events, [ - { - $type: "m.room.message", - body: "https://discord.gg/placeholder?event=1381190945646710824", - format: "org.matrix.custom.html", - formatted_body: "https://discord.gg/placeholder?event=1381190945646710824", - "m.mentions": {}, - msgtype: "m.text", - }, - { - $type: "m.room.message", - msgtype: "m.notice", - body: `| Scheduled Event - 8 June at 10:00 pm NZT – 9 June at 12:00 am NZT` - + `\n| ## forest exploration` - + `\n| ` - + `\n| 📍 the dark forest`, - format: "org.matrix.custom.html", - formatted_body: `
`, - "m.mentions": {} - } - ]) -}) - -test("message2event: vc invite event renders embed", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { - snow: { - invite: { - getInvite: async () => data.invite.vc - } - } - }) - t.deepEqual(events, [ - { - $type: "m.room.message", - body: "https://discord.gg/placeholder?event=1381174024801095751", - format: "org.matrix.custom.html", - formatted_body: "https://discord.gg/placeholder?event=1381174024801095751", - "m.mentions": {}, - msgtype: "m.text", - }, - { - $type: "m.room.message", - msgtype: "m.notice", - body: `| Scheduled Event - 9 June at 3:00 pm NZT` - + `\n| ## Cooking (Netrunners)` - + `\n| Short circuited brain interfaces actually just means your brain is medium rare, yum.` - + `\n| ` - + `\n| 🔊 Cooking`, - format: "org.matrix.custom.html", - formatted_body: `Scheduled Event - 8 June at 10:00 pm NZT – 9 June at 12:00 am NZT
` - + `forest exploration` - + `📍 the dark forest
`, - "m.mentions": {} - } - ]) -}) - -test("message2event: vc invite event renders embed with room link", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { - api: { - getEffectivePower: mockGetEffectivePower(), - getJoinedMembers: async () => ({ - joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - } - }) - }, - snow: { - invite: { - getInvite: async () => data.invite.known_vc - } - } - }) - t.deepEqual(events, [ - { - $type: "m.room.message", - body: "https://discord.gg/placeholder?event=1381174024801095751", - format: "org.matrix.custom.html", - formatted_body: "https://discord.gg/placeholder?event=1381174024801095751", - "m.mentions": {}, - msgtype: "m.text", - }, - { - $type: "m.room.message", - msgtype: "m.notice", - body: `| Scheduled Event - 9 June at 3:00 pm NZT` - + `\n| ## Cooking (Netrunners)` - + `\n| Short circuited brain interfaces actually just means your brain is medium rare, yum.` - + `\n| ` - + `\n| 🔊 Hey. - https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe?via=cadence.moe`, - format: "org.matrix.custom.html", - formatted_body: `Scheduled Event - 9 June at 3:00 pm NZT
` - + `Cooking (Netrunners)
Short circuited brain interfaces actually just means your brain is medium rare, yum.` - + `🔊 Cooking
`, - "m.mentions": {} - } - ]) -}) - -test("message2event: expired/invalid invites are sent as-is", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { - snow: { - invite: { - async getInvite() { - throw new Error(`{"message": "Unknown Invite", "code": 10006}`) - } - } - } - }) - t.deepEqual(events, [ - { - $type: "m.room.message", - body: "https://discord.gg/placeholder?event=1381190945646710824", - format: "org.matrix.custom.html", - formatted_body: "https://discord.gg/placeholder?event=1381190945646710824", - "m.mentions": {}, - msgtype: "m.text", - } - ]) -}) - -test("message2event: channel links are converted even inside lists (parser post-processer descends into list items)", async t => { - let called = 0 - const events = await messageToEvent({ - content: "1. Don't be a dick" - + "\n2. Follow rule number 1" - + "\n3. Follow Discord TOS" - + "\n4. Do **not** post NSFW content, shock content, suggestive content" - + "\n5. Please keep <#176333891320283136> professional and helpful, no random off-topic joking" - + "\nThis list will probably change in the future" - }, data.guild.general, {}, { - api: { - getEffectivePower: mockGetEffectivePower(), - getJoinedMembers(roomID) { - called++ - t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") - return { - joined: { - "@quadradical:federated.nexus": { - membership: "join", - display_name: "quadradical" - } - } - } - } - } - }) - t.deepEqual(events, [ - { - $type: "m.room.message", - body: "1. Don't be a dick" - + "\n2. Follow rule number 1" - + "\n3. Follow Discord TOS" - + "\n4. Do **not** post NSFW content, shock content, suggestive content" - + "\n5. Please keep #wonderland professional and helpful, no random off-topic joking" - + "\nThis list will probably change in the future", - format: "org.matrix.custom.html", - formatted_body: "Scheduled Event - 9 June at 3:00 pm NZT
` - + `Cooking (Netrunners)
Short circuited brain interfaces actually just means your brain is medium rare, yum.` - + `🔊 Hey. - Hey.
In reply to Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆cross-room reply`, - "m.mentions": { - user_ids: [ - "@cadence:cadence.moe" - ] - } - } - ]) -}) - -test("message2event: forwarded message with unreferenced mention", async t => { - const events = await messageToEvent({ - type: 0, - content: "", - attachments: [], - embeds: [], - timestamp: "2026-01-20T14:14:21.281Z", - edited_timestamp: null, - flags: 16384, - components: [], - id: "1463174818823405651", - channel_id: "893634327722721290", - author: { - id: "100031256988766208", - username: "leo60228", - discriminator: "0", - avatar: "8a164f29946f23eb4f45cde71a75e5a6", - avatar_decoration_data: null, - public_flags: 768, - global_name: "leo vriska", - primary_guild: null, - collectibles: null, - display_name_styles: null - }, - bot: false, - pinned: false, - mentions: [], - mention_roles: [], - mention_everyone: false, - tts: false, - message_reference: { - type: 1, - channel_id: "937181373943382036", - message_id: "1032034158261846038", - guild_id: "936370934292549712" - }, - message_snapshots: [ - { - message: { - type: 0, - content: "<@77084495118868480>", - attachments: [ - { - id: "1463174815119704114", - filename: "2022-10-18_16-49-46.mp4", - size: 51238885, - url: "https://cdn.discordapp.com/attachments/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4?ex=6970df3c&is=696f8dbc&hm=515d3cbcc8464bdada7f4c3d9ccc8174f671cb75391ce21a46a804fcb1e4befe&", - proxy_url: "https://media.discordapp.net/attachments/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4?ex=6970df3c&is=696f8dbc&hm=515d3cbcc8464bdada7f4c3d9ccc8174f671cb75391ce21a46a804fcb1e4befe&", - width: 1920, - height: 1080, - content_type: "video/mp4", - content_scan_version: 3, - spoiler: false - } - ], - embeds: [], - timestamp: "2022-10-18T20:55:17.597Z", - edited_timestamp: null, - flags: 0, - components: [] - } - } - ] - }) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "[↷ Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)", - format: "org.matrix.custom.html", - formatted_body: "↷ Forwarded message
[Media]
@unknown-user:", - "m.mentions": {} - }]) -}) - -test("message2event: single-choice poll", async t => { - const events = await messageToEvent(data.message.poll_single_choice, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "org.matrix.msc3381.poll.start", - "org.matrix.msc3381.poll.start": { - question: { - "org.matrix.msc1767.text": "only one answer allowed!", - body: "only one answer allowed!", - msgtype: "m.text" - }, - kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. - max_selections: 1, - answers: [{ - id: "1", - "org.matrix.msc1767.text": "[\ud83d\udc4d] answer one" - }, { - id: "2", - "org.matrix.msc1767.text": "[\ud83d\udc4e] answer two" - }, { - id: "3", - "org.matrix.msc1767.text": "answer three" - }] - }, - "org.matrix.msc1767.text": "only one answer allowed!\n1. [\ud83d\udc4d] answer one\n2. [\ud83d\udc4e] answer two\n3. answer three" - }]) -}) - -test("message2event: multiple-choice poll", async t => { - const events = await messageToEvent(data.message.poll_multiple_choice, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "org.matrix.msc3381.poll.start", - "org.matrix.msc3381.poll.start": { - question: { - "org.matrix.msc1767.text": "more than one answer allowed", - body: "more than one answer allowed", - msgtype: "m.text" - }, - kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. - max_selections: 3, - answers: [{ - id: "1", - "org.matrix.msc1767.text": "[😭] no" - }, { - id: "2", - "org.matrix.msc1767.text": "oh no" - }, { - id: "3", - "org.matrix.msc1767.text": "oh noooooo" - }] - }, - "org.matrix.msc1767.text": "more than one answer allowed\n1. [😭] no\n2. oh no\n3. oh noooooo" - }]) -}) - -test("message2event: smalltext from regular user", async t => { - const events = await messageToEvent({ - content: "-# hmm", - author: { - bot: false - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - "m.mentions": {}, - body: "...hmm" - }]) -}) diff --git a/src/d2m/converters/pins-to-list.js b/src/d2m/converters/pins-to-list.js index 4ad8800..047bb9f 100644 --- a/src/d2m/converters/pins-to-list.js +++ b/src/d2m/converters/pins-to-list.js @@ -3,30 +3,17 @@ const {select} = require("../../passthrough") /** - * @param {import("discord-api-types/v10").RESTGetAPIChannelMessagesPinsResult} pins - * @param {{"m.room.pinned_events/"?: {pinned?: string[]}}} kstate + * @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins */ -function pinsToList(pins, kstate) { - /** Most recent last. */ - let alreadyPinned = kstate["m.room.pinned_events/"]?.pinned || [] - - // If any of the already pinned messages are bridged messages then remove them from the already pinned list. - // * If a bridged message is still pinned then it'll be added back in the next step. - // * If a bridged message was unpinned from Discord-side then it'll be unpinned from our side due to this step. - // * Matrix-only unbridged messages that are pinned will remain pinned. - alreadyPinned = alreadyPinned.filter(event_id => { - const messageID = select("event_message", "message_id", {event_id}).pluck().get() - return !messageID || pins.items.find(m => m.message.id === messageID) // if it is bridged then remove it from the filter - }) - +function pinsToList(pins) { /** @type {string[]} */ const result = [] - for (const pin of pins.items) { - const eventID = select("event_message", "event_id", {message_id: pin.message.id}, "ORDER BY part ASC").pluck().get() - if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID) + for (const message of pins) { + const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get() + if (eventID) result.push(eventID) } result.reverse() - return alreadyPinned.concat(result) + return result } module.exports.pinsToList = pinsToList diff --git a/src/d2m/converters/pins-to-list.test.js b/src/d2m/converters/pins-to-list.test.js index 571735e..7ee89b6 100644 --- a/src/d2m/converters/pins-to-list.test.js +++ b/src/d2m/converters/pins-to-list.test.js @@ -1,64 +1,12 @@ const {test} = require("supertape") const data = require("../../../test/data") const {pinsToList} = require("./pins-to-list") -const mixin = require("@cloudrac3r/mixin-deep") test("pins2list: converts known IDs, ignores unknown IDs", t => { - const result = pinsToList(data.pins.faked, {}) + const result = pinsToList(data.pins.faked) t.deepEqual(result, [ "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" ]) }) - -test("pins2list: already pinned duplicate items are not moved", t => { - const result = pinsToList(data.pins.faked, { - "m.room.pinned_events/": { - pinned: [ - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA" - ] - } - }) - t.deepEqual(result, [ - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", - "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" - ]) -}) - -test("pins2list: already pinned unknown items are not moved", t => { - const result = pinsToList(data.pins.faked, { - "m.room.pinned_events/": { - pinned: [ - "$unknown1", - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$unknown2" - ] - } - }) - t.deepEqual(result, [ - "$unknown1", - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$unknown2", - "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", - "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" - ]) -}) - -test("pins2list: bridged messages can be unpinned", t => { - const shortPins = mixin({}, data.pins.faked) - shortPins.items = shortPins.items.slice(0, -2) - const result = pinsToList(shortPins, { - "m.room.pinned_events/": { - pinned: [ - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4" - ] - } - }) - t.deepEqual(result, [ - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", - ]) -}) diff --git a/src/d2m/converters/remove-member-mxids.js b/src/d2m/converters/remove-member-mxids.js deleted file mode 100644 index de26662..0000000 --- a/src/d2m/converters/remove-member-mxids.js +++ /dev/null @@ -1,38 +0,0 @@ -// @ts-check - -const passthrough = require("../../passthrough") -const {db, select, from} = passthrough - -/** - * @param {string} userID discord user ID that left - * @param {string} guildID discord guild ID that they left - */ -function removeMemberMxids(userID, guildID) { - // Get sims for user and remove - let membership = from("sim").join("sim_member", "mxid").join("channel_room", "room_id") - .select("room_id", "mxid").where({user_id: userID, guild_id: guildID}).and("ORDER BY room_id, mxid").all() - membership = membership.concat(from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").join("channel_room", "room_id") - .select("room_id", "mxid").where({proxy_owner_id: userID, guild_id: guildID}).and("ORDER BY room_id, mxid").all()) - - // Get user installed apps and remove - /** @type {string[]} */ - let userAppDeletions = [] - // 1. Select apps that have 1 user remaining - /** @type {Set
🎞️ Uploaded file: 2022-10-18_16-49-46.mp4 (51 MB)
${stackLines.join("\n")}${util.inspect(gatewayMessage.d, false, 4, false)}