From e3e38b9f24e9dac58e782ba904c56d5100ae1263 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 30 Jan 2026 19:22:13 +1300 Subject: [PATCH] Components v2 support --- src/d2m/converters/message-to-event.js | 111 ++++++++++- .../message-to-event.test.components.js | 79 ++++++++ src/matrix/utils.js | 3 +- test/data.js | 188 ++++++++++++++++++ test/test.js | 5 +- 5 files changed, 376 insertions(+), 10 deletions(-) create mode 100644 src/d2m/converters/message-to-event.test.components.js diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index ffce2f0..8a8e50f 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -107,9 +107,10 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ /** * @param {{room?: boolean, user_ids?: string[]}} mentions - * @param {DiscordTypes.APIAttachment} attachment + * @param {Omit} attachment + * @param {boolean} [alwaysLink] */ -async function attachmentToEvent(mentions, attachment) { +async function attachmentToEvent(mentions, attachment, alwaysLink) { const external_url = dUtils.getPublicUrlForCdn(attachment.url) const emoji = attachment.content_type?.startsWith("image/jp") ? "📸" @@ -130,7 +131,7 @@ async function attachmentToEvent(mentions, attachment) { } } // for large files, always link them instead of uploading so I don't use up all the space in the content repo - else if (attachment.size > reg.ooye.max_file_size) { + else if (alwaysLink || attachment.size > reg.ooye.max_file_size) { return { $type: "m.room.message", "m.mentions": mentions, @@ -228,6 +229,7 @@ async function pollToEvent(poll) { return matrixAnswer; }) return { + /** @type {"org.matrix.msc3381.poll.start"} */ $type: "org.matrix.msc3381.poll.start", "org.matrix.msc3381.poll.start": { question: { @@ -538,7 +540,7 @@ async function messageToEvent(message, guild, options = {}, di) { // 1. The replied-to event is in a different room to where the reply will be sent (i.e. a room upgrade occurred between) // 2. The replied-to message has no corresponding Matrix event (repliedToUnknownEvent is true) // This branch is optional - do NOT change anything apart from the reply fallback, since it may not be run - if ((repliedToEventRow || repliedToUnknownEvent) && options.includeReplyFallback !== false) { + if ((repliedToEventRow || repliedToUnknownEvent) && options.includeReplyFallback !== false && events.length === 0) { const latestRoomID = repliedToEventRow ? select("channel_room", "room_id", {channel_id: repliedToEventRow.channel_id}).pluck().get() : null if (latestRoomID !== repliedToEventRow?.room_id) repliedToEventInDifferentRoom = true @@ -741,7 +743,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Then attachments if (message.attachments) { - const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions))) + 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. @@ -756,6 +758,101 @@ async function messageToEvent(message, guild, options = {}, di) { } } + // Then components + if (message.components?.length) { + const stack = [new mxUtils.MatrixStringBuilder()] + /** @param {DiscordTypes.APIMessageComponent} component */ + async function processComponent(component) { + // Standalone components + if (component.type === DiscordTypes.ComponentType.TextDisplay) { + const {body, html} = await transformContent(component.content) + stack[0].addParagraph(body, html) + } + else if (component.type === DiscordTypes.ComponentType.Separator) { + stack[0].addParagraph("----", "
") + } + else if (component.type === DiscordTypes.ComponentType.File) { + const ev = await attachmentToEvent({}, {...component.file, filename: component.name, size: component.size}, true) + stack[0].addLine(ev.body, ev.formatted_body) + } + else if (component.type === DiscordTypes.ComponentType.MediaGallery) { + const description = component.items.length === 1 ? component.items[0].description || "Image:" : "Image gallery:" + const images = component.items.map(item => { + const publicURL = dUtils.getPublicUrlForCdn(item.media.url) + return { + url: publicURL, + estimatedName: item.media.url.match(/\/([^/?]+)(\?|$)/)?.[1] || publicURL + } + }) + stack[0].addLine(`🖼️ ${description} ${images.map(i => i.url).join(", ")}`, tag`🖼️ ${description} $${images.map(i => tag`${i.estimatedName}`).join(", ")}`) + } + // string select, text input, user select, role select, mentionable select, channel select + + // Components that can have things nested + else if (component.type === DiscordTypes.ComponentType.Container) { + // May contain action row, text display, section, media gallery, separator, file + stack.unshift(new mxUtils.MatrixStringBuilder()) + for (const innerComponent of component.components) { + await processComponent(innerComponent) + } + let {body, formatted_body} = stack.shift().get() + body = body.split("\n").map(l => "| " + l).join("\n") + formatted_body = `
${formatted_body}
` + if (stack[0].body) stack[0].body += "\n\n" + stack[0].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.unshift(new mxUtils.MatrixStringBuilder()) + for (const innerComponent of component.components) { + await processComponent(innerComponent) + } + if (component.accessory) { + stack.unshift(new mxUtils.MatrixStringBuilder()) + await processComponent(component.accessory) + const {body, formatted_body} = stack.shift().get() + stack[0].addLine(body, formatted_body) + } + const {body, formatted_body} = stack.shift().get() + stack[0].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[0].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[0].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) { + if (component.label) { + stack[0].add(`[${component.label} ${component.url}] `, tag`${component.label} `) + } else { + stack[0].add(component.url) + } + } + } + + // 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[0].get() + await addTextEvent(body, formatted_body, "m.text") + } + // Then polls if (message.poll) { const pollEvent = await pollToEvent(message.poll) @@ -773,7 +870,7 @@ async function messageToEvent(message, guild, options = {}, di) { continue // Matrix's own URL previews are fine for images. } - if (embed.type === "video" && !embed.title && !embed.description && message.content.includes(embed.video?.url)) { + if (embed.type === "video" && !embed.title && message.content.includes(embed.video?.url)) { continue // Doesn't add extra information and the direct video URL is already there. } @@ -904,7 +1001,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Strip formatted_body where equivalent to body if (!options.alwaysReturnFormattedBody) { for (const event of events) { - if (["m.text", "m.notice"].includes(event.msgtype) && event.body === event.formatted_body) { + 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 } diff --git a/src/d2m/converters/message-to-event.test.components.js b/src/d2m/converters/message-to-event.test.components.js new file mode 100644 index 0000000..7d875a6 --- /dev/null +++ b/src/d2m/converters/message-to-event.test.components.js @@ -0,0 +1,79 @@ +const {test} = require("supertape") +const {messageToEvent} = require("./message-to-event") +const data = require("../../../test/data") + +test("message2event components: pk question mark output", async t => { + const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + body: + "| ### Lillith (INX)" + + "\n| " + + "\n| **Display name:** Lillith (she/her)" + + "\n| **Pronouns:** She/Her" + + "\n| **Message count:** 3091" + + "\n| 🖼️ https://files.inx.moe/p/cdn/lillith.webp" + + "\n| " + + "\n| ----" + + "\n| " + + "\n| **Proxy tags:**" + + "\n| ``l;text``" + + "\n| ``l:text``" + + "\n| ``l.text``" + + "\n| ``textl.``" + + "\n| ``textl;``" + + "\n| ``textl:``" + + "\n" + + "\n-# System ID: `xffgnx` ∙ Member ID: `pphhoh`" + + "\n-# Created: 2025-12-31 03:16:45 UTC" + + "\n[View on dashboard https://dash.pluralkit.me/profile/m/pphhoh] " + + "\n" + + "\n----" + + "\n" + + "\n| **System:** INX (`xffgnx`)" + + "\n| **Member:** Lillith (`pphhoh`)" + + "\n| **Sent by:** infinidoge1337 (@unknown-user:)" + + "\n| " + + "\n| **Account Roles (7)**" + + "\n| §b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping" + + "\n| 🖼️ https://files.inx.moe/p/cdn/lillith.webp" + + "\n| " + + "\n| ----" + + "\n| " + + "\n| Same hat" + + "\n| 🖼️ Image: https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png" + + "\n" + + "\n-# Original Message ID: 1466556003645657118 · ", + format: "org.matrix.custom.html", + formatted_body: "
" + + "

Lillith (INX)

" + + "

Display name: Lillith (she/her)" + + "
Pronouns: She/Her" + + "
Message count: 3091

" + + `🖼️ https://files.inx.moe/p/cdn/lillith.webp` + + "
" + + "

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

" + + `View on dashboard ` + + "
" + + "

System: INX (xffgnx)" + + "
Member: Lillith (pphhoh)" + + "
Sent by: infinidoge1337 (@unknown-user:)" + + "

Account Roles (7)" + + "
§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping

" + + `🖼️ https://files.inx.moe/p/cdn/lillith.webp` + + "
" + + "

Same hat

" + + `🖼️ Image: image.png
` + + "

Original Message ID: 1466556003645657118 · <t:1769724599:f>

", + "m.mentions": {}, + msgtype: "m.text", + }]) +}) diff --git a/src/matrix/utils.js b/src/matrix/utils.js index f299d95..b131510 100644 --- a/src/matrix/utils.js +++ b/src/matrix/utils.js @@ -106,7 +106,8 @@ class MatrixStringBuilder { if (formattedBody == undefined) formattedBody = body if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n\n" this.body += body - formattedBody = `

${formattedBody}

` + const match = formattedBody.match(/^<([a-zA-Z]+[a-zA-Z0-9]*)/) + if (!match || !BLOCK_ELEMENTS.includes(match[1].toUpperCase())) formattedBody = `

${formattedBody}

` this.formattedBody += formattedBody } return this diff --git a/test/data.js b/test/data.js index 786737c..09749e6 100644 --- a/test/data.js +++ b/test/data.js @@ -4975,6 +4975,194 @@ module.exports = { tts: false } }, + message_with_components: { + pk_question_mark_response: { + type: 0, + content: '', + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: '2026-01-30T01:20:07.488000+00:00', + edited_timestamp: null, + flags: 32768, + author: { + id: '772659086046658620', + username: 'cadence.worm', + avatar: '466df0c98b1af1e1388f595b4c1ad1b9', + discriminator: '0', + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: 'cadence', + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: { + identity_guild_id: '532245108070809601', + identity_enabled: true, + tag: 'doll', + badge: 'dba08126b4e810a0e096cc7cd5bc37f0' + }, + primary_guild: { + identity_guild_id: '532245108070809601', + identity_enabled: true, + tag: 'doll', + badge: 'dba08126b4e810a0e096cc7cd5bc37f0' + } + }, + components: [ + { + type: 17, + id: 1, + accent_color: 1042150, + components: [ + { + type: 9, + id: 2, + components: [ + { type: 10, id: 3, content: '### Lillith (INX)' }, + { + type: 10, + id: 4, + content: '**Display name:** Lillith (she/her)\n' + + '**Pronouns:** She/Her\n' + + '**Message count:** 3091' + } + ], + accessory: { + type: 11, + id: 5, + media: { + id: '1466603856149610687', + url: 'https://files.inx.moe/p/cdn/lillith.webp', + proxy_url: 'https://images-ext-1.discordapp.net/external/Kn5b32mM4o8AAQbq0k39KOzp9-fy6D1tWKvK_XI27LI/https/files.inx.moe/p/cdn/lillith.webp', + width: 256, + height: 256, + placeholder: 'KVoKJwSnt7lZl5ecj1mal5eGWjAHZXIA', + placeholder_version: 1, + content_scan_metadata: { version: 4, flags: 0 }, + content_type: 'image/webp', + loading_state: 2, + flags: 0 + }, + description: null, + spoiler: false + } + }, + { type: 14, id: 6, spacing: 1, divider: true }, + { + type: 10, + id: 7, + content: '**Proxy tags:**\n' + + '``l;text``\n' + + '``l:text``\n' + + '``l.text``\n' + + '``textl.``\n' + + '``textl;``\n' + + '``textl:``' + } + ], + spoiler: false + }, + { + type: 9, + id: 8, + components: [ + { + type: 10, + id: 9, + content: '-# System ID: `xffgnx` ∙ Member ID: `pphhoh`\n' + + '-# Created: 2025-12-31 03:16:45 UTC' + } + ], + accessory: { + type: 2, + id: 10, + style: 5, + label: 'View on dashboard', + url: 'https://dash.pluralkit.me/profile/m/pphhoh' + } + }, + { type: 14, id: 11, spacing: 1, divider: true }, + { + type: 17, + id: 12, + accent_color: null, + components: [ + { + type: 9, + id: 13, + components: [ + { + type: 10, + id: 14, + content: '**System:** INX (`xffgnx`)\n' + + '**Member:** Lillith (`pphhoh`)\n' + + '**Sent by:** infinidoge1337 (<@197126718400626689>)\n' + + '\n' + + '**Account Roles (7)**\n' + + '§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping' + } + ], + accessory: { + type: 11, + id: 15, + media: { + id: '1466603856149610689', + url: 'https://files.inx.moe/p/cdn/lillith.webp', + proxy_url: 'https://images-ext-1.discordapp.net/external/Kn5b32mM4o8AAQbq0k39KOzp9-fy6D1tWKvK_XI27LI/https/files.inx.moe/p/cdn/lillith.webp', + width: 256, + height: 256, + placeholder: 'KVoKJwSnt7lZl5ecj1mal5eGWjAHZXIA', + placeholder_version: 1, + content_scan_metadata: { version: 4, flags: 0 }, + content_type: 'image/webp', + loading_state: 2, + flags: 0 + }, + description: null, + spoiler: false + } + }, + { type: 14, id: 16, spacing: 2, divider: true }, + { type: 10, id: 17, content: 'Same hat' }, + { + type: 12, + id: 18, + items: [ + { + media: { + id: '1466603856149610690', + url: 'https://cdn.discordapp.com/attachments/934955898965729280/1466556006527012987/image.png?ex=697d2c37&is=697bdab7&hm=09c5028be61ce01ebbdda5c79c42e4dc10d053ce0c4b12c9d84135a0708e9db6&', + proxy_url: 'https://media.discordapp.net/attachments/934955898965729280/1466556006527012987/image.png?ex=697d2c37&is=697bdab7&hm=09c5028be61ce01ebbdda5c79c42e4dc10d053ce0c4b12c9d84135a0708e9db6&', + width: 285, + height: 126, + placeholder: '0PcBA4BqSIl9t/dnn9f0rm0=', + placeholder_version: 1, + content_scan_metadata: { version: 4, flags: 0 }, + content_type: 'image/png', + loading_state: 2, + flags: 0 + }, + description: null, + spoiler: false + } + ] + } + ], + spoiler: false + }, + { + type: 10, + id: 19, + content: '-# Original Message ID: 1466556003645657118 · ' + } + ] + } + }, message_update: { edit_by_webhook: { application_id: "684280192553844747", diff --git a/test/test.js b/test/test.js index 5ae9f67..81c079a 100644 --- a/test/test.js +++ b/test/test.js @@ -160,8 +160,9 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("../src/d2m/converters/emoji-to-key.test") require("../src/d2m/converters/lottie.test") require("../src/d2m/converters/message-to-event.test") - require("../src/d2m/converters/message-to-event.embeds.test") - require("../src/d2m/converters/message-to-event.pk.test") + require("../src/d2m/converters/message-to-event.test.components") + require("../src/d2m/converters/message-to-event.test.embeds") + require("../src/d2m/converters/message-to-event.test.pk") require("../src/d2m/converters/pins-to-list.test") require("../src/d2m/converters/remove-reaction.test") require("../src/d2m/converters/thread-to-announcement.test")