From 8ff61a54999dc30d2a1e8b7bdf31317221e503b5 Mon Sep 17 00:00:00 2001 From: Bea Date: Tue, 10 Mar 2026 23:23:23 +0000 Subject: [PATCH 1/8] Look up thread IDs if channels cannot be found --- scripts/backfill.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/backfill.js b/scripts/backfill.js index 941e803..7135ec2 100644 --- a/scripts/backfill.js +++ b/scripts/backfill.js @@ -51,10 +51,24 @@ const preparedInsert = backfill.prepare("INSERT INTO backfill (channel_id, messa async function event(event) { if (event.t !== "GUILD_CREATE") return - const channel = event.d.channels.find(c => c.id === channelID) - if (!channel) return + let channel = event.d.channels.find(c => c.id === channelID) || (event.d.threads || []).find(c => c.id === channelID) const guild_id = event.d.id + if (!channel) { + // May be an archived thread not present in GUILD_CREATE data - try fetching via API + try { + const fetched = await discord.snow.channel.getChannel(channelID) + if (!fetched.guild_id || fetched.guild_id !== guild_id) return + fetched.guild_id = guild_id + discord.channels.set(fetched.id, fetched) + channel = fetched + } catch (e) { + return + } + } + + if (!channel) return + try { await createRoom.syncRoom(channelID) let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0" From 714e990bef4286cc15abceeff48c75c7b0abca83 Mon Sep 17 00:00:00 2001 From: Bea Date: Fri, 20 Mar 2026 13:54:19 +0000 Subject: [PATCH 2/8] feat(m2d): support MSC4144 per-message profiles Override webhook username and avatar_url from m.per_message_profile (and unstable com.beeper.per_message_profile) when present. The stable key takes priority over the unstable prefix. --- src/m2d/converters/event-to-message.js | 4 + src/m2d/converters/event-to-message.test.js | 102 ++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 1b23787..7fdbb15 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -557,6 +557,10 @@ async function eventToMessage(event, guild, channel, di) { const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api) if (member.displayname) displayName = member.displayname if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) + // Override display name and avatar from MSC4144 per-message profile if present + const perMessageProfile = event.content["m.per_message_profile"] || event.content["com.beeper.per_message_profile"] + if (perMessageProfile?.displayname) displayName = perMessageProfile.displayname + if (perMessageProfile?.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(perMessageProfile.avatar_url) // If the display name is too long to be put into the webhook (80 characters is the maximum), // put the excess characters into displayNameRunoff, later to be put at the top of the message let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName) diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 1c263b4..b283d82 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -5526,6 +5526,108 @@ test("event2message: known and unknown emojis in the end are used for sprite she ) }) +test("event2message: m.per_message_profile overrides displayname and avatar_url", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "hello from a custom profile", + "m.per_message_profile": { + id: "custom-id", + displayname: "Custom Name", + avatar_url: "mxc://maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo" + } + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "Custom Name", + content: "hello from a custom profile", + avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + +test("event2message: com.beeper.per_message_profile (unstable prefix) overrides displayname and avatar_url", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "hello from unstable profile", + "com.beeper.per_message_profile": { + id: "custom-id", + displayname: "Unstable Name", + avatar_url: "mxc://maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo" + } + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "Unstable Name", + content: "hello from unstable profile", + avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + +test("event2message: m.per_message_profile takes priority over com.beeper.per_message_profile", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "stable wins", + "m.per_message_profile": { + id: "stable-id", + displayname: "Stable Name" + }, + "com.beeper.per_message_profile": { + id: "unstable-id", + displayname: "Unstable Name" + } + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "Stable Name", + content: "stable wins", + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: all unknown chess emojis are used for sprite sheet", async t => { t.deepEqual( await eventToMessage({ From cfa319eaa361ef711d6fa52375c9ef04f2490253 Mon Sep 17 00:00:00 2001 From: Bea Date: Fri, 20 Mar 2026 14:04:13 +0000 Subject: [PATCH 3/8] feat(m2d): strip per-message profile fallbacks from message content Remove data-mx-profile-fallback elements from formatted_body and displayname prefix from plain body when per-message profile is used. --- src/m2d/converters/event-to-message.js | 8 +++ src/m2d/converters/event-to-message.test.js | 69 +++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 7fdbb15..96732ec 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -803,6 +803,10 @@ async function eventToMessage(event, guild, channel, di) { if (shouldProcessTextEvent) { if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) { let input = event.content.formatted_body + if (perMessageProfile?.has_fallback) { + // Strip fallback elements added for clients that don't support per-message profiles + input = input.replace(/<(\w+)[^>]*\bdata-mx-profile-fallback\b[^>]*>[^<]*<\/\1>/g, "") + } if (event.content.msgtype === "m.emote") { input = `* ${displayName} ${input}` } @@ -948,6 +952,10 @@ async function eventToMessage(event, guild, channel, di) { } else { // Looks like we're using the plaintext body! content = event.content.body + if (perMessageProfile?.has_fallback && perMessageProfile.displayname && content.startsWith(perMessageProfile.displayname + ": ")) { + // Strip the display name prefix fallback added for clients that don't support per-message profiles + content = content.slice(perMessageProfile.displayname.length + 2) + } if (event.content.msgtype === "m.emote") { content = `* ${displayName} ${content}` diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index b283d82..1c37b7a 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -5628,6 +5628,75 @@ test("event2message: m.per_message_profile takes priority over com.beeper.per_me ) }) +test("event2message: data-mx-profile-fallback element is stripped from formatted_body when per-message profile is present", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "Tidus Herboren: one more test", + format: "org.matrix.custom.html", + formatted_body: "Tidus Herboren: one more test", + "com.beeper.per_message_profile": { + id: "tidus", + displayname: "Tidus Herboren", + avatar_url: "mxc://maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo", + has_fallback: true + } + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "Tidus Herboren", + content: "one more test", + avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + +test("event2message: displayname prefix is stripped from plain body when per-message profile has_fallback", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "Tidus Herboren: one more test", + "com.beeper.per_message_profile": { + id: "tidus", + displayname: "Tidus Herboren", + has_fallback: true + } + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "Tidus Herboren", + content: "one more test", + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: all unknown chess emojis are used for sprite sheet", async t => { t.deepEqual( await eventToMessage({ From e25f788738023a138fab1a74237f5b7f1e3b228f Mon Sep 17 00:00:00 2001 From: Bea Date: Tue, 24 Mar 2026 16:45:39 +0000 Subject: [PATCH 4/8] fix(m2d): only use unstable com.beeper.per_message_profile prefix --- src/m2d/converters/event-to-message.js | 2 +- src/m2d/converters/event-to-message.test.js | 71 +-------------------- 2 files changed, 2 insertions(+), 71 deletions(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 96732ec..5b7d0f4 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -558,7 +558,7 @@ async function eventToMessage(event, guild, channel, di) { if (member.displayname) displayName = member.displayname if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) // Override display name and avatar from MSC4144 per-message profile if present - const perMessageProfile = event.content["m.per_message_profile"] || event.content["com.beeper.per_message_profile"] + const perMessageProfile = event.content["com.beeper.per_message_profile"] if (perMessageProfile?.displayname) displayName = perMessageProfile.displayname if (perMessageProfile?.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(perMessageProfile.avatar_url) // If the display name is too long to be put into the webhook (80 characters is the maximum), diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 1c37b7a..2a204e9 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -5526,40 +5526,7 @@ test("event2message: known and unknown emojis in the end are used for sprite she ) }) -test("event2message: m.per_message_profile overrides displayname and avatar_url", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "hello from a custom profile", - "m.per_message_profile": { - id: "custom-id", - displayname: "Custom Name", - avatar_url: "mxc://maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo" - } - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "Custom Name", - content: "hello from a custom profile", - avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo", - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - -test("event2message: com.beeper.per_message_profile (unstable prefix) overrides displayname and avatar_url", async t => { +test("event2message: com.beeper.per_message_profile overrides displayname and avatar_url", async t => { t.deepEqual( await eventToMessage({ type: "m.room.message", @@ -5592,42 +5559,6 @@ test("event2message: com.beeper.per_message_profile (unstable prefix) overrides ) }) -test("event2message: m.per_message_profile takes priority over com.beeper.per_message_profile", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "stable wins", - "m.per_message_profile": { - id: "stable-id", - displayname: "Stable Name" - }, - "com.beeper.per_message_profile": { - id: "unstable-id", - displayname: "Unstable Name" - } - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "Stable Name", - content: "stable wins", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - test("event2message: data-mx-profile-fallback element is stripped from formatted_body when per-message profile is present", async t => { t.deepEqual( await eventToMessage({ From 015bedab69df4aff1a9e50485bd6ed867be5cc66 Mon Sep 17 00:00:00 2001 From: Bea Date: Tue, 24 Mar 2026 16:45:40 +0000 Subject: [PATCH 5/8] fix(m2d): implement MSC4144 avatar clearing algorithm - Empty string "" -> undefined (Discord uses default avatar) - Valid MXC URI -> convert to public URL - Omitted/null -> keep member avatar --- src/m2d/converters/event-to-message.js | 12 ++++++-- src/m2d/converters/event-to-message.test.js | 33 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 5b7d0f4..7c233c7 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -557,10 +557,18 @@ async function eventToMessage(event, guild, channel, di) { const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api) if (member.displayname) displayName = member.displayname if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) - // Override display name and avatar from MSC4144 per-message profile if present + // MSC4144: Override display name and avatar from per-message profile if present const perMessageProfile = event.content["com.beeper.per_message_profile"] if (perMessageProfile?.displayname) displayName = perMessageProfile.displayname - if (perMessageProfile?.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(perMessageProfile.avatar_url) + if (perMessageProfile && "avatar_url" in perMessageProfile) { + if (perMessageProfile.avatar_url === "") { + // empty string avatar_url clears the avatar (use default) + avatarURL = undefined + } else if (perMessageProfile.avatar_url) { + // omitted/null falls back to member avatar + avatarURL = mxUtils.getPublicUrlForMxc(perMessageProfile.avatar_url) + } + } // If the display name is too long to be put into the webhook (80 characters is the maximum), // put the excess characters into displayNameRunoff, later to be put at the top of the message let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName) diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 2a204e9..bc73df7 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -5559,6 +5559,39 @@ test("event2message: com.beeper.per_message_profile overrides displayname and av ) }) +test("event2message: com.beeper.per_message_profile empty avatar_url clears avatar", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "hello with cleared avatar", + "com.beeper.per_message_profile": { + id: "no-avatar", + displayname: "No Avatar User", + avatar_url: "" + } + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "No Avatar User", + content: "hello with cleared avatar", + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: data-mx-profile-fallback element is stripped from formatted_body when per-message profile is present", async t => { t.deepEqual( await eventToMessage({ From 87fcdb18abf35e5b0bee7f6f1d7ee30a5d955d08 Mon Sep 17 00:00:00 2001 From: Bea Date: Tue, 24 Mar 2026 16:45:40 +0000 Subject: [PATCH 6/8] feat(discord): show per-message profile info in matrix info command --- src/discord/interactions/matrix-info.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/discord/interactions/matrix-info.js b/src/discord/interactions/matrix-info.js index c85cec2..f5aa539 100644 --- a/src/discord/interactions/matrix-info.js +++ b/src/discord/interactions/matrix-info.js @@ -62,7 +62,20 @@ async function _interact({guild_id, data}, {api}) { .sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels)) .filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get()) const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get() - const name = matrixMember?.displayname || event.sender + // Check for per-message profile + const perMessageProfile = event.content?.["com.beeper.per_message_profile"] + let name = matrixMember?.displayname || event.sender + let avatar = utils.getPublicUrlForMxc(matrixMember?.avatar_url) + let profileNote = "" + if (perMessageProfile) { + if (perMessageProfile.displayname) { + name = perMessageProfile.displayname + } + if ("avatar_url" in perMessageProfile) { + avatar = perMessageProfile.avatar_url ? utils.getPublicUrlForMxc(perMessageProfile.avatar_url) : undefined + } + profileNote = " (sent with a per-message profile)" + } return { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { @@ -70,9 +83,9 @@ async function _interact({guild_id, data}, {api}) { author: { name, url: `https://matrix.to/#/${event.sender}`, - icon_url: utils.getPublicUrlForMxc(matrixMember?.avatar_url) + icon_url: avatar }, - description: `This Matrix message was delivered to Discord by **Out Of Your Element**.\n[View on Matrix →]()\n\n**User ID**: [${event.sender}]()`, + description: `This Matrix message was delivered to Discord by **Out Of Your Element**${profileNote}.\n[View on Matrix →]()\n\n**User ID**: [${event.sender}]()`, color: 0x0dbd8b, fields: [{ name: "In Channels", From 6a9e89496656eb9b4aa8a459e7346d4156dc31ec Mon Sep 17 00:00:00 2001 From: Bea Date: Tue, 24 Mar 2026 18:19:15 +0000 Subject: [PATCH 7/8] chore: editorconfig matching existing code format styles An editor config has been written that matches existing indentation and formatting used by this project, which should extend the courtesy of intended tab use to other editors like NeoVim which are often conigured to adhere to `.editorconfig` by default. As part of this change, the official EditorConfig extension has been added to VSCode recommendations, which should additionally enable Code-like editors to additionally adhere to the rules of other files like json and markdown which are the only types that deviate from Cadence's global default. --- .editorconfig | 16 ++++++++++++++++ .vscode/extensions.json | 5 +++++ 2 files changed, 21 insertions(+) create mode 100644 .editorconfig create mode 100644 .vscode/extensions.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8920fb1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +indent_style = tab +tab_width = 3 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.json] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..fd5bdfe --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "editorconfig.editorconfig" + ] +} From a97a41a75d2f6f6f5f01e9d1afc6d10ee46e4131 Mon Sep 17 00:00:00 2001 From: Bea Date: Tue, 24 Mar 2026 19:00:41 +0000 Subject: [PATCH 8/8] chore: add global prettier ignore file As prettier is not configured for this project, and the style doesn't meet prettier's opinionated defaults, it's worth dropping this blanket ignore until such a time (if ever) a prettier config is produced that matches the styleguide of this project. --- .prettierignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..72e8ffc --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +*