Components v2 support

This commit is contained in:
Cadence Ember
2026-01-30 19:22:13 +13:00
parent fca4c75522
commit e3e38b9f24
5 changed files with 376 additions and 10 deletions

View File

@@ -107,9 +107,10 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
/**
* @param {{room?: boolean, user_ids?: string[]}} mentions
* @param {DiscordTypes.APIAttachment} attachment
* @param {Omit<DiscordTypes.APIAttachment, "id" | "proxy_url">} 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("----", "<hr>")
}
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`<a href="${i.url}">${i.estimatedName}</a>`).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 = `<blockquote>${formatted_body}</blockquote>`
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`🖼️ <a href="${component.media.url}">${component.media.url}</a>`)
}
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`<a href="${component.url}">${component.label}</a> `)
} 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
}

View File

@@ -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 · <t:1769724599:f>",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>"
+ "<h3>Lillith (INX)</h3>"
+ "<p><strong>Display name:</strong> Lillith (she/her)"
+ "<br><strong>Pronouns:</strong> She/Her"
+ "<br><strong>Message count:</strong> 3091</p>"
+ `🖼️ <a href="https://files.inx.moe/p/cdn/lillith.webp">https://files.inx.moe/p/cdn/lillith.webp</a>`
+ "<hr>"
+ "<p><strong>Proxy tags:</strong>"
+ "<br><code>l;text</code>"
+ "<br><code>l:text</code>"
+ "<br><code>l.text</code>"
+ "<br><code>textl.</code>"
+ "<br><code>textl;</code>"
+ "<br><code>textl:</code></p></blockquote>"
+ "<p><sub>System ID: <code>xffgnx</code> ∙ Member ID: <code>pphhoh</code></sub><br>"
+ "<sub>Created: 2025-12-31 03:16:45 UTC</sub></p>"
+ `<a href="https://dash.pluralkit.me/profile/m/pphhoh">View on dashboard</a> `
+ "<hr>"
+ "<blockquote><p><strong>System:</strong> INX (<code>xffgnx</code>)"
+ "<br><strong>Member:</strong> Lillith (<code>pphhoh</code>)"
+ "<br><strong>Sent by:</strong> infinidoge1337 (@unknown-user:)"
+ "<br><br><strong>Account Roles (7)</strong>"
+ "<br>§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping</p>"
+ `🖼️ <a href="https://files.inx.moe/p/cdn/lillith.webp">https://files.inx.moe/p/cdn/lillith.webp</a>`
+ "<hr>"
+ "<p>Same hat</p>"
+ `🖼️ Image: <a href="https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png">image.png</a></blockquote>`
+ "<p><sub>Original Message ID: 1466556003645657118 · &lt;t:1769724599:f&gt;</sub></p>",
"m.mentions": {},
msgtype: "m.text",
}])
})

View File

@@ -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 = `<p>${formattedBody}</p>`
const match = formattedBody.match(/^<([a-zA-Z]+[a-zA-Z0-9]*)/)
if (!match || !BLOCK_ELEMENTS.includes(match[1].toUpperCase())) formattedBody = `<p>${formattedBody}</p>`
this.formattedBody += formattedBody
}
return this

View File

@@ -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 · <t:1769724599:f>'
}
]
}
},
message_update: {
edit_by_webhook: {
application_id: "684280192553844747",

View File

@@ -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")