From 5a853249a295ecc2e6a0ce9a7f0df87811872bc1 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 16:21:27 +0000 Subject: [PATCH 001/111] I prefer 4 spaces --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9f1e183..ea571e8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { "editor.insertSpaces": false, "editor.detectIndentation": false, - "editor.tabSize": 3 + "editor.tabSize": 4 } From 8676a736204b8cdf8831aad4ca32f7ebf2f7a8be Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 17:53:56 +0000 Subject: [PATCH 002/111] Testing BEGINS! --- src/m2d/event-dispatcher.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 2091f7d..4385f71 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -211,6 +211,9 @@ async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return const messageResponses = await sendEvent.sendEvent(event) if (!messageResponses.length) return + if (event.content["m.relates_to"]?.rel_type === "m.thread"){ + console.log("thread event spotted") + } if (event.type === "m.room.message" && event.content.msgtype === "m.text") { // @ts-ignore await matrixCommandHandler.execute(event) From dca53752bb74da6df4b597c9ecf9c96a74fc1074 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 18:34:30 +0000 Subject: [PATCH 003/111] Reverse-engineering the docs --- src/d2m/actions/send-message.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index eb919bb..8b86d34 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -86,6 +86,7 @@ async function sendMessage(message, channel, guild, row) { const useTimestamp = message["backfill"] ? new Date(message.timestamp).getTime() : undefined const eventID = await api.sendEvent(roomID, eventType, eventWithoutType, senderMxid, useTimestamp) + console.log(eventWithoutType) eventIDs.push(eventID) try { From 01b82e7b682e50d7316ad4cc6210d858907ac985 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 18:48:24 +0000 Subject: [PATCH 004/111] I think I got SOMETHING up and running! --- src/d2m/actions/send-message.js | 1 - src/m2d/event-dispatcher.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index 8b86d34..eb919bb 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -86,7 +86,6 @@ async function sendMessage(message, channel, guild, row) { const useTimestamp = message["backfill"] ? new Date(message.timestamp).getTime() : undefined const eventID = await api.sendEvent(roomID, eventType, eventWithoutType, senderMxid, useTimestamp) - console.log(eventWithoutType) eventIDs.push(eventID) try { diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 4385f71..ef93e3c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -212,7 +212,7 @@ async event => { const messageResponses = await sendEvent.sendEvent(event) if (!messageResponses.length) return if (event.content["m.relates_to"]?.rel_type === "m.thread"){ - console.log("thread event spotted") + api.sendEvent(event.room_id, "m.room.message", {body:"Thread spotted!"}) } if (event.type === "m.room.message" && event.content.msgtype === "m.text") { // @ts-ignore From 486959be0b489d44bb8fae0a3cd1c813f00bc7b0 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 19:05:25 +0000 Subject: [PATCH 005/111] forgor --- src/m2d/event-dispatcher.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index ef93e3c..b89261e 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -212,7 +212,7 @@ async event => { const messageResponses = await sendEvent.sendEvent(event) if (!messageResponses.length) return if (event.content["m.relates_to"]?.rel_type === "m.thread"){ - api.sendEvent(event.room_id, "m.room.message", {body:"Thread spotted!"}) + api.sendEvent(event.room_id, "m.room.message", {body:"Thread spotted!", msgtype:"m.text"}) } if (event.type === "m.room.message" && event.content.msgtype === "m.text") { // @ts-ignore From aaf8dea10484e8ed1097cafefeaf4b19fdb15ce0 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 19:27:13 +0000 Subject: [PATCH 006/111] Reply-related metadata --- src/m2d/event-dispatcher.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index b89261e..86421ff 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -212,7 +212,19 @@ async event => { const messageResponses = await sendEvent.sendEvent(event) if (!messageResponses.length) return if (event.content["m.relates_to"]?.rel_type === "m.thread"){ - api.sendEvent(event.room_id, "m.room.message", {body:"Thread spotted!", msgtype:"m.text"}) + api.sendEvent(event.room_id, "m.room.message", { + "body": "It seems like you sent this message inside a thread.", + "format": "org.matrix.custom.html", + "formatted_body": "It seems like you sent this message inside a thread.", + "m.mentions": { "user_ids": [event.sender]}, + "m.relates_to": { + "event_id": event.content["m.relates_to"].event_id, + "is_falling_back": false, + "m.in_reply_to": { "event_id": event.event_id }, + "rel_type": "m.thread" + }, + "msgtype": "m.text" + }) } if (event.type === "m.room.message" && event.content.msgtype === "m.text") { // @ts-ignore From abe42aaa925a7a1afca0d419875fa353d550064c Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 21:26:15 +0000 Subject: [PATCH 007/111] Updated the message to its final form. At least final-until-we-make-it-so-that-new-rooms-are-autogenerated-when-a-thread-is-opened. Then we'd need to include the link to it instead of a command-help. --- src/m2d/event-dispatcher.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 86421ff..ded162f 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -213,9 +213,9 @@ async event => { if (!messageResponses.length) return if (event.content["m.relates_to"]?.rel_type === "m.thread"){ api.sendEvent(event.room_id, "m.room.message", { - "body": "It seems like you sent this message inside a thread.", + "body": "⚠️ **This message may not have bridged to Discord in the way you thought it would!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. _Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!_\n\nFor the sake of Discord parity (as well as better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or noone's made a thread-room for this thread, it is recommended you create one with `/thread ` (on Matrix) and continue the conversation there.\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", "format": "org.matrix.custom.html", - "formatted_body": "It seems like you sent this message inside a thread.", + "formatted_body": "⚠️ This message may not have bridged to Discord in the way you thought it would!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (as well as better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or noone's made a thread-room for this thread, it is recommended you create one with /thread <Thread Name> (on Matrix) and continue the conversation there.

You can read more about the rationale behind this design choice here.", "m.mentions": { "user_ids": [event.sender]}, "m.relates_to": { "event_id": event.content["m.relates_to"].event_id, From 7afcbfaa06c7e7ada0cdb2fde0b8780354a353e2 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 22:44:20 +0000 Subject: [PATCH 008/111] I have no idea if this works; just throwing random ideas together. It's 23:42; I'm going just purely based on vibes at this point. vibecoding but no AI, just eepy --- src/d2m/converters/thread-to-announcement.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 575b3c5..601e61a 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -19,19 +19,21 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) */ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get() - /** @type {{"m.mentions"?: any, "m.in_reply_to"?: any}} */ + /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */ const context = {} + let suffix = ""; if (branchedFromEventID) { // Need to figure out who sent that event... const event = await di.api.getEvent(parentRoomID, branchedFromEventID) - context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}} + suffix = "\n\n*[Note: You should talk there, rather than in this newly-created Matrix thread. Any messages sent here will be bridged as replies, which is probably not what you want.]*"; + context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} } const msgtype = creatorMxid ? "m.emote" : "m.text" const template = creatorMxid ? "started a thread:" : "Thread started:" const via = await mxUtils.getViaServersQuery(threadRoomID, di.api) - let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}` + let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}` return { msgtype, From ac421e6c743c93a00d0a304f9a8135cb7d27102c Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 20 Feb 2026 00:00:42 +0000 Subject: [PATCH 009/111] this looks better --- src/d2m/converters/thread-to-announcement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 601e61a..ca20af6 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -25,7 +25,7 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr if (branchedFromEventID) { // Need to figure out who sent that event... const event = await di.api.getEvent(parentRoomID, branchedFromEventID) - suffix = "\n\n*[Note: You should talk there, rather than in this newly-created Matrix thread. Any messages sent here will be bridged as replies, which is probably not what you want.]*"; + suffix = "\n\n[Note: You should talk there, rather than in this newly-created Matrix thread. Any messages sent here will be bridged as replies, which is probably not what you want.]"; context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} } From b542a81ee14f894090e6632ce4dddc4f78b80253 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 20 Feb 2026 02:21:52 +0000 Subject: [PATCH 010/111] first time actually interacting with the DB --- src/matrix/matrix-command-handler.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index e382a32..863996c 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -255,11 +255,20 @@ const commands = [{ return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission." + body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission." // NOTE: Currently, this assumes that all Matrix users have no Discord roles, and makes that „If you were a Discord user...” claim based on that assumption. If Discord permission emulation is gonna happen in the future, this is certainly one among many places that will need to be changed. }) } + + const relation = event.content["m.relates_to"] + let attachedToEvent = relation?.["m.in_reply_to"]?.event_id // By default, attempt to attach the thread to the message to which /thread was replying. + if (relation?.rel_type === "m.thread" && relation.is_falling_back) attachedToEvent = event.content["m.relates_to"]?.event_id // If /thread was sent inside a Matrix thread, attempt to attach the Discord thread to the message around which that Matrix thread was based on. But only if we're falling back, ie. the message sent in that thread was a „normal” one, not a reply to some other message inside the thread. If it WAS reply, we preserve the original behavior (attach the thread to the message to which /thread was replying). One slight caveat is that is_falling_back will also be false if it's the 1st message of that thread. In such case, however, m.in_reply_to should (if sent from a spec-compliant client) point towards the message around which that Matrix thread was based on, so the intended behavior is preserved. + if (!attachedToEvent) attachedToEvent = event.event_id // If /thread wasn't replying to anything (ie. attachedToEvent was undefined at initial assignment), or if the event was somehow malformed (in such a way that that - one way or another - attachedToEvent ended up being undefined, even if according to the spec it shouldn't), attach the thread to the /thread command-message that created it. + + assert(attachedToEvent) + const attachedToMessage = select("event_message", "message_id", {event_id: attachedToEvent}).pluck().get() - await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")}) + if (attachedToMessage) await discord.snow.channel.createThreadWithMessage(channelID, attachedToMessage, {name: words.slice(1).join(" ")}) + else await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")}) } ) }] From f734b0619f614133de4ca4054b8b497d0dd361ba Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 20 Feb 2026 03:10:09 +0000 Subject: [PATCH 011/111] Don't warn the user that they should use /thread if they literally just did it. --- src/m2d/event-dispatcher.js | 16 +++++++++++----- src/matrix/matrix-command-handler.js | 10 +++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index ded162f..914845c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -211,7 +211,16 @@ async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return const messageResponses = await sendEvent.sendEvent(event) if (!messageResponses.length) return - if (event.content["m.relates_to"]?.rel_type === "m.thread"){ + + /** @type {string|undefined} */ + let executedCommand + if (event.type === "m.room.message" && event.content.msgtype === "m.text") { + executedCommand = await matrixCommandHandler.parseAndExecute( + // @ts-ignore - TypeScript doesn't know that the event.content.msgtype === "m.text" ensures that event isn't of type Ty.Event.Outer_M_Room_Message_File (which, indeed, wouldn't fit here) + event + ) + } + if (event.content["m.relates_to"]?.rel_type === "m.thread" && executedCommand !== "thread"){ api.sendEvent(event.room_id, "m.room.message", { "body": "⚠️ **This message may not have bridged to Discord in the way you thought it would!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. _Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!_\n\nFor the sake of Discord parity (as well as better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or noone's made a thread-room for this thread, it is recommended you create one with `/thread ` (on Matrix) and continue the conversation there.\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", "format": "org.matrix.custom.html", @@ -226,10 +235,7 @@ async event => { "msgtype": "m.text" }) } - if (event.type === "m.room.message" && event.content.msgtype === "m.text") { - // @ts-ignore - await matrixCommandHandler.execute(event) - } + retrigger.messageFinishedBridging(event.event_id) await api.ackEvent(event) })) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 863996c..2d6deca 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -274,8 +274,11 @@ const commands = [{ }] -/** @type {CommandExecute} */ -async function execute(event) { +/** + * @param {Ty.Event.Outer_M_Room_Message} event + * @returns {Promise} the executed command's name or undefined if no command execution was performed +*/ +async function parseAndExecute(event) { let realBody = event.content.body while (realBody.startsWith("> ")) { const i = realBody.indexOf("\n") @@ -296,7 +299,8 @@ async function execute(event) { if (!command) return await command.execute(event, realBody, words) + return words[0] } -module.exports.execute = execute +module.exports.parseAndExecute = parseAndExecute module.exports.onReactionAdd = onReactionAdd From e44f1041b65de8628a5e36e5477e6237358c09b0 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 20 Feb 2026 03:50:06 +0000 Subject: [PATCH 012/111] Turns out that creating a thread-in-thread (which is what the stuff I was doing in matrix-command-handler effectively amounted to) KINDA breaks Element. Whoops! MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also, that message in thread-to-announcement was misleading, as now there's no guarantee that a thread was newly created (it could be very old, but freshly /thread-ed). So I changed that, too. Also, while updating messages, I decided to slightly alter the „may not have been bridged to Discord in the way you thought it was gonna be”-warning in event-dispatcher. --- src/d2m/converters/thread-to-announcement.js | 6 +++--- src/m2d/event-dispatcher.js | 4 ++-- src/matrix/matrix-command-handler.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index ca20af6..d35bb80 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -25,15 +25,15 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr if (branchedFromEventID) { // Need to figure out who sent that event... const event = await di.api.getEvent(parentRoomID, branchedFromEventID) - suffix = "\n\n[Note: You should talk there, rather than in this newly-created Matrix thread. Any messages sent here will be bridged as replies, which is probably not what you want.]"; + suffix = "\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]"; context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} } const msgtype = creatorMxid ? "m.emote" : "m.text" - const template = creatorMxid ? "started a thread:" : "Thread started:" + const template = creatorMxid ? "started a thread" : "New thread started: " const via = await mxUtils.getViaServersQuery(threadRoomID, di.api) - let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}` + let body = `${template} „${thread.name}” in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}` return { msgtype, diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 914845c..7342f0a 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -222,9 +222,9 @@ async event => { } if (event.content["m.relates_to"]?.rel_type === "m.thread" && executedCommand !== "thread"){ api.sendEvent(event.room_id, "m.room.message", { - "body": "⚠️ **This message may not have bridged to Discord in the way you thought it would!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. _Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!_\n\nFor the sake of Discord parity (as well as better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or noone's made a thread-room for this thread, it is recommended you create one with `/thread ` (on Matrix) and continue the conversation there.\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", + "body": "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. *Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a per-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with `/thread ` (on Matrix) and continue the conversation there. You can even run this command directly in this very Matrix thread, and the new Discord thread will be automatically attached to the same message as this one (alternatively, run that command outside a thread and as a reply to a different message to attach the thread to that message, or stand-alone to create a stand-alone thread).\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", "format": "org.matrix.custom.html", - "formatted_body": "⚠️ This message may not have bridged to Discord in the way you thought it would!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (as well as better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or noone's made a thread-room for this thread, it is recommended you create one with /thread <Thread Name> (on Matrix) and continue the conversation there.

You can read more about the rationale behind this design choice here.", + "formatted_body": "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a per-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with /thread <Thread Name> (on Matrix) and continue the conversation there. You can even run this command directly in this very Matrix thread, and the new Discord thread will be automatically attached to the same message as this one (alternatively, run that command outside a thread and as a reply to a different message to attach the thread to that message, or stand-alone to create a stand-alone thread).

You can read more about the rationale behind this design choice here.", "m.mentions": { "user_ids": [event.sender]}, "m.relates_to": { "event_id": event.content["m.relates_to"].event_id, diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 2d6deca..6f3c5f9 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -261,7 +261,7 @@ const commands = [{ const relation = event.content["m.relates_to"] let attachedToEvent = relation?.["m.in_reply_to"]?.event_id // By default, attempt to attach the thread to the message to which /thread was replying. - if (relation?.rel_type === "m.thread" && relation.is_falling_back) attachedToEvent = event.content["m.relates_to"]?.event_id // If /thread was sent inside a Matrix thread, attempt to attach the Discord thread to the message around which that Matrix thread was based on. But only if we're falling back, ie. the message sent in that thread was a „normal” one, not a reply to some other message inside the thread. If it WAS reply, we preserve the original behavior (attach the thread to the message to which /thread was replying). One slight caveat is that is_falling_back will also be false if it's the 1st message of that thread. In such case, however, m.in_reply_to should (if sent from a spec-compliant client) point towards the message around which that Matrix thread was based on, so the intended behavior is preserved. + if (relation?.rel_type === "m.thread") attachedToEvent = event.content["m.relates_to"]?.event_id // If /thread was sent inside a Matrix thread, attempt to attach the Discord thread to the message around which that Matrix thread was based on. if (!attachedToEvent) attachedToEvent = event.event_id // If /thread wasn't replying to anything (ie. attachedToEvent was undefined at initial assignment), or if the event was somehow malformed (in such a way that that - one way or another - attachedToEvent ended up being undefined, even if according to the spec it shouldn't), attach the thread to the /thread command-message that created it. assert(attachedToEvent) From f9e303f018ffb5474dc400ceb9d2de123e20571d Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 20 Feb 2026 03:52:03 +0000 Subject: [PATCH 013/111] stray whitespace --- src/d2m/converters/thread-to-announcement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index d35bb80..179a559 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -31,7 +31,7 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr } const msgtype = creatorMxid ? "m.emote" : "m.text" - const template = creatorMxid ? "started a thread" : "New thread started: " + const template = creatorMxid ? "started a thread" : "New thread started:" const via = await mxUtils.getViaServersQuery(threadRoomID, di.api) let body = `${template} „${thread.name}” in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}` From d7aadc30793403be6f487b6ec5697a202877d80d Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 20 Feb 2026 04:02:01 +0000 Subject: [PATCH 014/111] AAAAAAAAAAAAAAAAAAAA --- src/m2d/event-dispatcher.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 7342f0a..ec5fa89 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -222,9 +222,9 @@ async event => { } if (event.content["m.relates_to"]?.rel_type === "m.thread" && executedCommand !== "thread"){ api.sendEvent(event.room_id, "m.room.message", { - "body": "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. *Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a per-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with `/thread ` (on Matrix) and continue the conversation there. You can even run this command directly in this very Matrix thread, and the new Discord thread will be automatically attached to the same message as this one (alternatively, run that command outside a thread and as a reply to a different message to attach the thread to that message, or stand-alone to create a stand-alone thread).\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", + "body": "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. *Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with `/thread ` and continue the conversation there. You can even run this command directly in this very Matrix thread, and the new Discord thread will be automatically attached to the same message as this one (alternatively, run that command outside a thread, and as a reply to a different message to attach the thread to that message or stand-alone to create a stand-alone thread).\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", "format": "org.matrix.custom.html", - "formatted_body": "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a per-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with /thread <Thread Name> (on Matrix) and continue the conversation there. You can even run this command directly in this very Matrix thread, and the new Discord thread will be automatically attached to the same message as this one (alternatively, run that command outside a thread and as a reply to a different message to attach the thread to that message, or stand-alone to create a stand-alone thread).

You can read more about the rationale behind this design choice here.", + "formatted_body": "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with /thread <Thread Name> and continue the conversation there. You can even run this command directly in this very Matrix thread, and the new Discord thread will be automatically attached to the same message as this one (alternatively, run that command outside a thread, and as a reply to a different message to attach the thread to that message or stand-alone to create a stand-alone thread).

You can read more about the rationale behind this design choice here.", "m.mentions": { "user_ids": [event.sender]}, "m.relates_to": { "event_id": event.content["m.relates_to"].event_id, From b53b2f56b6e67cd4932bda2fcdc578d235c82a37 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 24 Feb 2026 19:26:34 +0000 Subject: [PATCH 015/111] Improved error handling --- src/m2d/event-dispatcher.js | 4 +- src/matrix/matrix-command-handler.js | 74 +++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index ec5fa89..b0cf640 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -222,9 +222,9 @@ async event => { } if (event.content["m.relates_to"]?.rel_type === "m.thread" && executedCommand !== "thread"){ api.sendEvent(event.room_id, "m.room.message", { - "body": "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. *Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with `/thread ` and continue the conversation there. You can even run this command directly in this very Matrix thread, and the new Discord thread will be automatically attached to the same message as this one (alternatively, run that command outside a thread, and as a reply to a different message to attach the thread to that message or stand-alone to create a stand-alone thread).\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", + "body": "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. *Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with `/thread ` and continue the conversation there. You can even run the command directly in this thread to attach the created Discord thread to the same message as this Matrix one (run `/thread` (with no arguments) to see other options for attachment).\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", "format": "org.matrix.custom.html", - "formatted_body": "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with /thread <Thread Name> and continue the conversation there. You can even run this command directly in this very Matrix thread, and the new Discord thread will be automatically attached to the same message as this one (alternatively, run that command outside a thread, and as a reply to a different message to attach the thread to that message or stand-alone to create a stand-alone thread).

You can read more about the rationale behind this design choice here.", + "formatted_body": "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with /thread and continue the conversation there. You can even run the command directly in this thread to attach the created Discord thread to the same message as this Matrix one (run /thread (with no arguments) to see other options for attachment).

You can read more about the rationale behind this design choice here.", "m.mentions": { "user_ids": [event.sender]}, "m.relates_to": { "event_id": event.content["m.relates_to"].event_id, diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 6f3c5f9..fd4ae4a 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -236,6 +236,19 @@ const commands = [{ aliases: ["thread"], execute: replyctx( async (event, realBody, words, ctx) => { + if (words.length < 2){ + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + "m.relates_to":{ + "m.in_reply_to": event.event_id + }, + "body": "**`/thread` usage:**\nRun this command as `/thread ` to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message under which said thread will be attached, is chosen based on the following rules:\n* If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread gets attached to the command-message itself.\n* If sent as a reply (outside a Matrix thread), the thread will be attached to the message to which you replied.\n* If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the Discord thread will be attached to the same message as the Matrix thread already is.", + "format": "org.matrix.custom.html", + "formatted_body": "/thread usage:
Run this command as /thread <Thread Name> to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message under which said thread will be attached, is chosen based on the following rules:
  • If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread gets attached to the command-message itself.
  • If sent as a reply (outside a Matrix thread), the thread will be attached to the message to which you replied.
  • If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the Discord thread will be attached to the same message as the Matrix thread already is.
" + }) + } + // Guard /** @type {string} */ // @ts-ignore const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() @@ -244,6 +257,9 @@ const commands = [{ return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", + "m.relates_to":{ + "m.in_reply_to": event.event_id + }, body: "This room isn't bridged to the other side." }) } @@ -255,7 +271,10 @@ const commands = [{ return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission." // NOTE: Currently, this assumes that all Matrix users have no Discord roles, and makes that „If you were a Discord user...” claim based on that assumption. If Discord permission emulation is gonna happen in the future, this is certainly one among many places that will need to be changed. + "m.relates_to":{ + "m.in_reply_to": event.event_id + }, + body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission." // NOTE: Currently, this assumes that all Matrix users have no Discord roles (see: empty [] in the getPermissions call above), and makes that „If you were a Discord user...” claim based on that assumption. If Discord permission emulation is gonna happen in the future, this is certainly one among many places that will need to be changed. }) } @@ -263,12 +282,55 @@ const commands = [{ let attachedToEvent = relation?.["m.in_reply_to"]?.event_id // By default, attempt to attach the thread to the message to which /thread was replying. if (relation?.rel_type === "m.thread") attachedToEvent = event.content["m.relates_to"]?.event_id // If /thread was sent inside a Matrix thread, attempt to attach the Discord thread to the message around which that Matrix thread was based on. if (!attachedToEvent) attachedToEvent = event.event_id // If /thread wasn't replying to anything (ie. attachedToEvent was undefined at initial assignment), or if the event was somehow malformed (in such a way that that - one way or another - attachedToEvent ended up being undefined, even if according to the spec it shouldn't), attach the thread to the /thread command-message that created it. - - assert(attachedToEvent) const attachedToMessage = select("event_message", "message_id", {event_id: attachedToEvent}).pluck().get() - - if (attachedToMessage) await discord.snow.channel.createThreadWithMessage(channelID, attachedToMessage, {name: words.slice(1).join(" ")}) - else await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")}) + + try { + if (attachedToMessage) await discord.snow.channel.createThreadWithMessage(channelID, attachedToMessage, {name: words.slice(1).join(" ")}) + else throw "NO_ATTACH_TARGET"; + } + catch (e){ + if (e === "NO_ATTACH_TARGET") { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + "m.relates_to":{ + "m.in_reply_to": event.event_id + }, + body: "Couldn't find a Discord representation of the message under which you're trying to attach this thread. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." + }) + } + else if (e.code === 160004) { // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + "m.relates_to":{ + "m.in_reply_to": event.event_id + }, + body: "There already exist a thread for the message you ran this command on (please run `/thread` (with no arguments) for details on attachment rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Please scroll up and see where the link could be." + }) + } + else if (e.code === 50024) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + "m.relates_to":{ + "m.in_reply_to": event.event_id + }, + body: "You cannot create a new thread in this type of channel. Did you try to create a thread inside a thread?" + }) + } + else { + await api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + "m.relates_to":{ + "m.in_reply_to": event.event_id + }, + body: "Unknown error occurred during thread creation. See error message below (or on the main channel, if the command was ran inside a thread) for details." + }) + throw e; + } + } } ) }] From 23cdf549828dab27e201f543a71a5a22347d2b57 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 25 Feb 2026 09:25:02 +0000 Subject: [PATCH 016/111] AEUGH it turns out that replying was already handled. In other news: Made /thread work without args (in SOME cases). I'm pretty sure this is the final patch before we go PR. --- src/m2d/event-dispatcher.js | 4 +- src/matrix/matrix-command-handler.js | 64 +++++++++++----------------- 2 files changed, 27 insertions(+), 41 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index b0cf640..5521012 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -222,9 +222,9 @@ async event => { } if (event.content["m.relates_to"]?.rel_type === "m.thread" && executedCommand !== "thread"){ api.sendEvent(event.room_id, "m.room.message", { - "body": "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. *Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with `/thread ` and continue the conversation there. You can even run the command directly in this thread to attach the created Discord thread to the same message as this Matrix one (run `/thread` (with no arguments) to see other options for attachment).\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", + "body": "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. *In other words: __Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel.__ If the thread you sent this message in is old, such a random reply may be distracting to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with `/thread` and continue the conversation there. You can run that command directly in this thread to attach the created Discord thread to the same message as this Matrix one, or run it outside any threads to see other options for attachment.\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", "format": "org.matrix.custom.html", - "formatted_body": "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well), and as such, they are bridged as replies to Discord. Discord users will not be aware that you sent this message inside a thread - it will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with /thread and continue the conversation there. You can even run the command directly in this thread to attach the created Discord thread to the same message as this Matrix one (run /thread (with no arguments) to see other options for attachment).

You can read more about the rationale behind this design choice here.", + "formatted_body": "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. In other words: Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with /thread and continue the conversation there. You can run that command directly in this thread to attach the created Discord thread to the same message as this Matrix one, or run it outside any threads to see other options for attachment.

You can read more about the rationale behind this design choice here.", "m.mentions": { "user_ids": [event.sender]}, "m.relates_to": { "event_id": event.content["m.relates_to"].event_id, diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index fd4ae4a..6ef4b5f 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -236,19 +236,6 @@ const commands = [{ aliases: ["thread"], execute: replyctx( async (event, realBody, words, ctx) => { - if (words.length < 2){ - return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - "m.relates_to":{ - "m.in_reply_to": event.event_id - }, - "body": "**`/thread` usage:**\nRun this command as `/thread ` to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message under which said thread will be attached, is chosen based on the following rules:\n* If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread gets attached to the command-message itself.\n* If sent as a reply (outside a Matrix thread), the thread will be attached to the message to which you replied.\n* If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the Discord thread will be attached to the same message as the Matrix thread already is.", - "format": "org.matrix.custom.html", - "formatted_body": "/thread usage:
Run this command as /thread <Thread Name> to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message under which said thread will be attached, is chosen based on the following rules:
  • If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread gets attached to the command-message itself.
  • If sent as a reply (outside a Matrix thread), the thread will be attached to the message to which you replied.
  • If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the Discord thread will be attached to the same message as the Matrix thread already is.
" - }) - } - // Guard /** @type {string} */ // @ts-ignore const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() @@ -257,9 +244,6 @@ const commands = [{ return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "m.relates_to":{ - "m.in_reply_to": event.event_id - }, body: "This room isn't bridged to the other side." }) } @@ -271,61 +255,63 @@ const commands = [{ return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "m.relates_to":{ - "m.in_reply_to": event.event_id - }, body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission." // NOTE: Currently, this assumes that all Matrix users have no Discord roles (see: empty [] in the getPermissions call above), and makes that „If you were a Discord user...” claim based on that assumption. If Discord permission emulation is gonna happen in the future, this is certainly one among many places that will need to be changed. }) } const relation = event.content["m.relates_to"] + let isFallingBack = false; let attachedToEvent = relation?.["m.in_reply_to"]?.event_id // By default, attempt to attach the thread to the message to which /thread was replying. - if (relation?.rel_type === "m.thread") attachedToEvent = event.content["m.relates_to"]?.event_id // If /thread was sent inside a Matrix thread, attempt to attach the Discord thread to the message around which that Matrix thread was based on. - if (!attachedToEvent) attachedToEvent = event.event_id // If /thread wasn't replying to anything (ie. attachedToEvent was undefined at initial assignment), or if the event was somehow malformed (in such a way that that - one way or another - attachedToEvent ended up being undefined, even if according to the spec it shouldn't), attach the thread to the /thread command-message that created it. + if (relation?.rel_type === "m.thread") attachedToEvent = relation?.event_id // If /thread was sent inside a Matrix thread, attempt to attach the Discord thread to the message around which that Matrix thread was based on. + if (!attachedToEvent){ + attachedToEvent = event.event_id // If /thread wasn't replying to anything (ie. attachedToEvent was undefined at initial assignment), or if the event was somehow malformed (in such a way that that - one way or another - attachedToEvent ended up being undefined, even if according to the spec it shouldn't), attach the thread to the /thread command-message that created it. + isFallingBack = true; + } const attachedToMessage = select("event_message", "message_id", {event_id: attachedToEvent}).pluck().get() + + if (words.length < 2){ + words[1] = (await api.getEvent(event.room_id, attachedToEvent)).content.body + if (isFallingBack) return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + "body": "**`/thread` usage:**\nRun this command as `/thread [Thread Name]` to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message under which said thread will be attached, is chosen based on the following rules:\n* If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread gets attached to the command-message itself. The `Thread Name` argument must be provided in this case, otherwise you get this help message.\n* If sent as a reply (outside a Matrix thread), the thread will be attached to the message to which you replied.\n* If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the Discord thread will be attached to the same message as the Matrix thread already is.", + "format": "org.matrix.custom.html", + "formatted_body": "/thread usage:
Run this command as /thread [Thread Name] to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message under which said thread will be attached, is chosen based on the following rules:
  • If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread gets attached to the command-message itself. The Thread Name argument must be provided in this case, otherwise you get this help message.
  • If sent as a reply (outside a Matrix thread), the thread will be attached to the message to which you replied.
  • If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the Discord thread will be attached to the same message as the Matrix thread already is.
" + }) + } try { - if (attachedToMessage) await discord.snow.channel.createThreadWithMessage(channelID, attachedToMessage, {name: words.slice(1).join(" ")}) - else throw "NO_ATTACH_TARGET"; + if (attachedToMessage) return discord.snow.channel.createThreadWithMessage(channelID, attachedToMessage, {name: words.slice(1).join(" ")}) + else throw {code: "NO_ATTACH_TARGET", was_supposed_to_be: attachedToEvent}; } catch (e){ - if (e === "NO_ATTACH_TARGET") { + if (e.code === "NO_ATTACH_TARGET") { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "m.relates_to":{ - "m.in_reply_to": event.event_id - }, - body: "Couldn't find a Discord representation of the message under which you're trying to attach this thread. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." + body: "Couldn't find a Discord representation of the message under which you're trying to attach this thread (event "+e.was_supposed_to_be+" on Matrix). Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." }) } else if (e.code === 160004) { // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "m.relates_to":{ - "m.in_reply_to": event.event_id - }, - body: "There already exist a thread for the message you ran this command on (please run `/thread` (with no arguments) for details on attachment rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Please scroll up and see where the link could be." + "body": "There already exist a thread for the message you ran this command on (please run `/thread` (with no arguments) for details on attachment rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Please scroll up and see where the link could be.", + "format": "org.matrix.custom.html", + "formatted_body": "There already exist a thread for the message you ran this command on (please run /thread (with no arguments) for details on attachment rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Please scroll up and see where the link could be.", }) } else if (e.code === 50024) { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "m.relates_to":{ - "m.in_reply_to": event.event_id - }, - body: "You cannot create a new thread in this type of channel. Did you try to create a thread inside a thread?" + body: "You cannot create a thread in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?" }) } else { await api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "m.relates_to":{ - "m.in_reply_to": event.event_id - }, body: "Unknown error occurred during thread creation. See error message below (or on the main channel, if the command was ran inside a thread) for details." }) throw e; From 69d07c1a7b3c5ae17a24d7134e2a436928578eb6 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 25 Feb 2026 09:48:22 +0000 Subject: [PATCH 017/111] I lied, that's the final patch (my C# past got the better of me lol) --- src/types.d.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/types.d.ts b/src/types.d.ts index e7ef318..e36241f 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -191,7 +191,7 @@ export namespace Event { formatted_body?: string, "m.relates_to"?: { event_id?: string - is_falling_back?: bool + is_falling_back?: boolean "m.in_reply_to"?: { event_id: string } @@ -212,7 +212,7 @@ export namespace Event { "page.codeberg.everypizza.msc4193.spoiler"?: boolean "m.relates_to"?: { event_id?: string - is_falling_back?: bool + is_falling_back?: boolean "m.in_reply_to"?: { event_id: string } @@ -249,7 +249,7 @@ export namespace Event { info?: any "m.relates_to"?: { event_id?: string - is_falling_back?: bool + is_falling_back?: boolean "m.in_reply_to"?: { event_id: string } From f0515ceecf8e1726ee94439374e29707bf125c01 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 25 Feb 2026 10:11:18 +0000 Subject: [PATCH 018/111] UX testing revealed that og messages looked awkward --- src/matrix/matrix-command-handler.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 6ef4b5f..7fb8fe0 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -289,16 +289,18 @@ const commands = [{ return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "Couldn't find a Discord representation of the message under which you're trying to attach this thread (event "+e.was_supposed_to_be+" on Matrix). Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." + "body": "⚠️ Couldn't find a Discord representation of the message under which you're trying to attach this thread (event ID `"+e.was_supposed_to_be+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", + "format": "org.matrix.custom.html", + "formatted_body": "⚠️ Couldn't find a Discord representation of the message under which you're trying to attach this thread (event ID "+e.was_supposed_to_be+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." }) } else if (e.code === 160004) { // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "body": "There already exist a thread for the message you ran this command on (please run `/thread` (with no arguments) for details on attachment rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Please scroll up and see where the link could be.", + "body": "There already exist a thread for the message you ran this command on (please run `/thread` (with no arguments and outside any threads) for details on attachment rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Scroll up and see where the link could be.", "format": "org.matrix.custom.html", - "formatted_body": "There already exist a thread for the message you ran this command on (please run /thread (with no arguments) for details on attachment rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Please scroll up and see where the link could be.", + "formatted_body": "There already exist a thread for the message you ran this command on (please run /thread (with no arguments and outside any threads) for details on attachment rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Scroll up and see where the link could be.", }) } else if (e.code === 50024) { From 4a260013829aaa485ce7d0f5f364afbac6db9904 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 25 Feb 2026 10:23:13 +0000 Subject: [PATCH 019/111] Debug-slop begins! --- src/matrix/matrix-command-handler.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 7fb8fe0..d82ce3e 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -285,6 +285,7 @@ const commands = [{ else throw {code: "NO_ATTACH_TARGET", was_supposed_to_be: attachedToEvent}; } catch (e){ + console.log(e) if (e.code === "NO_ATTACH_TARGET") { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, From 0ad4b41ae96824383455da54392a777c596a4cb0 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 25 Feb 2026 10:28:09 +0000 Subject: [PATCH 020/111] Iiiiiii........... I have no idea what was I trying to accomplish here... --- src/matrix/matrix-command-handler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index d82ce3e..786df5c 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -281,7 +281,7 @@ const commands = [{ } try { - if (attachedToMessage) return discord.snow.channel.createThreadWithMessage(channelID, attachedToMessage, {name: words.slice(1).join(" ")}) + if (attachedToMessage) await discord.snow.channel.createThreadWithMessage(channelID, attachedToMessage, {name: words.slice(1).join(" ")}) else throw {code: "NO_ATTACH_TARGET", was_supposed_to_be: attachedToEvent}; } catch (e){ From c283528d720a9093db84b4b452206c9525f15534 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 25 Feb 2026 10:32:37 +0000 Subject: [PATCH 021/111] Is this LITERALLY just a String????? --- src/matrix/matrix-command-handler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 786df5c..4ccba82 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -285,7 +285,7 @@ const commands = [{ else throw {code: "NO_ATTACH_TARGET", was_supposed_to_be: attachedToEvent}; } catch (e){ - console.log(e) + console.log(typeof e) if (e.code === "NO_ATTACH_TARGET") { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, From bea0b9370d413fed28c70a92748982f378573400 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 25 Feb 2026 10:39:56 +0000 Subject: [PATCH 022/111] Type of e is its own content. Apparently. What the fuck? --- src/matrix/matrix-command-handler.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 4ccba82..b31a937 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -285,7 +285,8 @@ const commands = [{ else throw {code: "NO_ATTACH_TARGET", was_supposed_to_be: attachedToEvent}; } catch (e){ - console.log(typeof e) + const what_how = typeof e + console.log("Type of "+e+" is "+what_how) if (e.code === "NO_ATTACH_TARGET") { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, From fa916699a787068f4b8e32dbcce1f41c5e14b4b0 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 25 Feb 2026 10:48:12 +0000 Subject: [PATCH 023/111] Nope, it's an object. But, like... A weird one. It doesn't seem to behave like objects normally do. It it a wrapper around stuff? --- src/matrix/matrix-command-handler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index b31a937..7b18e71 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -286,7 +286,7 @@ const commands = [{ } catch (e){ const what_how = typeof e - console.log("Type of "+e+" is "+what_how) + console.log("Type of "+e+" is "+what_how+" with keys: "+Object.keys(e)) if (e.code === "NO_ATTACH_TARGET") { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, From 9bf6e50ae9adaded912921cba6e58f6c07e8940f Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 25 Feb 2026 10:56:51 +0000 Subject: [PATCH 024/111] SO WHAT DO YOU WANT? TELL ME WHAT'S YOUR POINT, JS! So the code-key DOES exist. HuuuHhhhhh??? --- src/matrix/matrix-command-handler.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 7b18e71..734582e 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -286,7 +286,8 @@ const commands = [{ } catch (e){ const what_how = typeof e - console.log("Type of "+e+" is "+what_how+" with keys: "+Object.keys(e)) + const but_im_done_with_you = typeof e.code + console.log("Type of "+e+" is "+what_how+" with keys "+Object.keys(e) + " and the value of that „code” key is "+e.code+" of type "+but_im_done_with_you) if (e.code === "NO_ATTACH_TARGET") { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, From 9424b5e517d0cc6d5919bcffa156d8d327d09131 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 25 Feb 2026 11:04:55 +0000 Subject: [PATCH 025/111] =?UTF-8?q?Apparenly,=20what=20I=20completley=20mi?= =?UTF-8?q?ssed,=20is=20that=20=E2=80=9Ecode=E2=80=9D=20is=20overriden=20b?= =?UTF-8?q?y=20something=20later=20in=20the=20error=20stack.=20Trying=20ou?= =?UTF-8?q?t=20other=20keys......?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/matrix/matrix-command-handler.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 734582e..07246cd 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -288,6 +288,8 @@ const commands = [{ const what_how = typeof e const but_im_done_with_you = typeof e.code console.log("Type of "+e+" is "+what_how+" with keys "+Object.keys(e) + " and the value of that „code” key is "+e.code+" of type "+but_im_done_with_you) + console.log("MSG: "+e.message) + console.log("NAM: "+e.name) if (e.code === "NO_ATTACH_TARGET") { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, From 06962c217e46da6e0e4094f957b3b6b491c612ec Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 26 Feb 2026 12:23:09 +0000 Subject: [PATCH 026/111] unspecified horsing around --- src/m2d/event-dispatcher.js | 5 +++-- src/matrix/matrix-command-handler.js | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 5521012..0cd1b0c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -158,11 +158,12 @@ async function sendError(roomID, source, type, e, payload) { /** * @param {string} type - * @param {(event: any, ...args: any)=>any} fn + * @param {(event: Ty.Event.Outer & {type: any, redacts:any, state_key:any}, ...args: any)=>any} fn */ function guard(type, fn) { return async function(/** @type {Ty.Event.Outer} */ event, /** @type {any} */ ...args) { try { + // @ts-ignore return await fn(event, ...args) } catch (e) { await sendError(event.room_id, "Matrix", type, e, event) @@ -216,7 +217,7 @@ async event => { let executedCommand if (event.type === "m.room.message" && event.content.msgtype === "m.text") { executedCommand = await matrixCommandHandler.parseAndExecute( - // @ts-ignore - TypeScript doesn't know that the event.content.msgtype === "m.text" ensures that event isn't of type Ty.Event.Outer_M_Room_Message_File (which, indeed, wouldn't fit here) + // @ts-ignore - TypeScript doesn't know that the event.content.msgtype === "m.text" check ensures that event isn't of type Ty.Event.Outer_M_Room_Message_File (which, indeed, wouldn't fit here) event ) } diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 07246cd..b8c3b20 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -96,6 +96,22 @@ function replyctx(execute) { } } +/** + * @param {Error & {code: string|number}} e + * @returns {e} +*/ +function unmarshallDiscordError(e) { + if (e.name === "DiscordAPIError"){ + try{ + + JSON.parse(e.message) + } catch{ + + } + } + return e; +} + /** @type {Command[]} */ const commands = [{ aliases: ["emoji"], @@ -289,6 +305,7 @@ const commands = [{ const but_im_done_with_you = typeof e.code console.log("Type of "+e+" is "+what_how+" with keys "+Object.keys(e) + " and the value of that „code” key is "+e.code+" of type "+but_im_done_with_you) console.log("MSG: "+e.message) + console.log("MST: "+typeof e.message) console.log("NAM: "+e.name) if (e.code === "NO_ATTACH_TARGET") { return api.sendEvent(event.room_id, "m.room.message", { From 266f46563b3ac3e7b5e3cc8a8a166fae024bcc5e Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 26 Feb 2026 12:49:21 +0000 Subject: [PATCH 027/111] Possibly? fixed error handling??? and yea, ofc it was a string...... --- src/matrix/matrix-command-handler.js | 34 ++++++++++++++++------------ 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index b8c3b20..bd9ecfe 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -97,16 +97,27 @@ function replyctx(execute) { } /** - * @param {Error & {code: string|number}} e + * @param {Error & {code?: string|number}} e * @returns {e} */ function unmarshallDiscordError(e) { if (e.name === "DiscordAPIError"){ try{ - - JSON.parse(e.message) - } catch{ - + const unmarshaled = JSON.parse(e.message) + return { + ...e, + ...unmarshaled + } + } catch (err) { + return { + ...err, + code: "JSON_PARSE_FAILED", + message: JSON.stringify({ + original_error_where_message_failed_to_parse: e, + json_parser_error_message: err.message, + json_parser_error_code: err.code, + }) + } } } return e; @@ -300,20 +311,15 @@ const commands = [{ if (attachedToMessage) await discord.snow.channel.createThreadWithMessage(channelID, attachedToMessage, {name: words.slice(1).join(" ")}) else throw {code: "NO_ATTACH_TARGET", was_supposed_to_be: attachedToEvent}; } - catch (e){ - const what_how = typeof e - const but_im_done_with_you = typeof e.code - console.log("Type of "+e+" is "+what_how+" with keys "+Object.keys(e) + " and the value of that „code” key is "+e.code+" of type "+but_im_done_with_you) - console.log("MSG: "+e.message) - console.log("MST: "+typeof e.message) - console.log("NAM: "+e.name) + catch (e_raw){ + const e = unmarshallDiscordError(e_raw) if (e.code === "NO_ATTACH_TARGET") { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "body": "⚠️ Couldn't find a Discord representation of the message under which you're trying to attach this thread (event ID `"+e.was_supposed_to_be+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", + "body": "⚠️ Couldn't find a Discord representation of the message under which you're trying to attach this thread (event ID `"+e_raw.was_supposed_to_be+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", "format": "org.matrix.custom.html", - "formatted_body": "⚠️ Couldn't find a Discord representation of the message under which you're trying to attach this thread (event ID "+e.was_supposed_to_be+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." + "formatted_body": "⚠️ Couldn't find a Discord representation of the message under which you're trying to attach this thread (event ID "+e_raw.was_supposed_to_be+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." }) } else if (e.code === 160004) { // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes From 3f7a7aa10f875d6324cc8eb151614faacb990c38 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 26 Feb 2026 15:37:17 +0000 Subject: [PATCH 028/111] handled overyapping --- src/matrix/matrix-command-handler.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index bd9ecfe..761d299 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -298,6 +298,7 @@ const commands = [{ if (words.length < 2){ words[1] = (await api.getEvent(event.room_id, attachedToEvent)).content.body + words[1] = words[1].length < 100 ? words[1] : words[1].slice(0, 96) + "..." if (isFallingBack) return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", @@ -335,7 +336,14 @@ const commands = [{ return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "You cannot create a thread in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?" + body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?" + }) + } + else if (e.code === 50035) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, thread body is for that. ;)" }) } else { From 22ff10222c7b32d9f183a02e6d24bd8a1841918b Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 26 Feb 2026 15:38:20 +0000 Subject: [PATCH 029/111] fixed error details showing up as [object Object] --- src/matrix/matrix-command-handler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 761d299..e6086ba 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -352,7 +352,7 @@ const commands = [{ msgtype: "m.text", body: "Unknown error occurred during thread creation. See error message below (or on the main channel, if the command was ran inside a thread) for details." }) - throw e; + throw e_raw; } } } From 3e42616065de6a9f87da61992a7230285c7b432e Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 26 Feb 2026 15:55:18 +0000 Subject: [PATCH 030/111] =?UTF-8?q?It=20just=20occurred=20to=20me=20that?= =?UTF-8?q?=20I=20have=20no=20way=20of=20testing=20the=20=E2=80=9Efallback?= =?UTF-8?q?=20case=E2=80=9D=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/matrix/matrix-command-handler.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index e6086ba..07ae178 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -339,13 +339,13 @@ const commands = [{ body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?" }) } - else if (e.code === 50035) { + /*else if (e.code === 50035) { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, thread body is for that. ;)" + body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)" }) - } + }*/ else { await api.sendEvent(event.room_id, "m.room.message", { ...ctx, From 0557c7b143112aa5331cef03ccd07ab71a94f5fc Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 26 Feb 2026 16:00:22 +0000 Subject: [PATCH 031/111] works yay --- src/matrix/matrix-command-handler.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 07ae178..7c8667e 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -339,13 +339,13 @@ const commands = [{ body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?" }) } - /*else if (e.code === 50035) { + else if (e.code === 50035) { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)" }) - }*/ + } else { await api.sendEvent(event.room_id, "m.room.message", { ...ctx, From ffed434c6ab11e584b4d1ab6ca3e6e871d589814 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 26 Feb 2026 16:05:53 +0000 Subject: [PATCH 032/111] rewrote the f#cker as a switch statement --- src/matrix/matrix-command-handler.js | 44 ++++++++++++---------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 7c8667e..8c0c3c2 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -314,45 +314,39 @@ const commands = [{ } catch (e_raw){ const e = unmarshallDiscordError(e_raw) - if (e.code === "NO_ATTACH_TARGET") { - return api.sendEvent(event.room_id, "m.room.message", { + switch (e.code) { + case "NO_ATTACH_TARGET": return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", "body": "⚠️ Couldn't find a Discord representation of the message under which you're trying to attach this thread (event ID `"+e_raw.was_supposed_to_be+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", "format": "org.matrix.custom.html", "formatted_body": "⚠️ Couldn't find a Discord representation of the message under which you're trying to attach this thread (event ID "+e_raw.was_supposed_to_be+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." }) - } - else if (e.code === 160004) { // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes - return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - "body": "There already exist a thread for the message you ran this command on (please run `/thread` (with no arguments and outside any threads) for details on attachment rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Scroll up and see where the link could be.", - "format": "org.matrix.custom.html", - "formatted_body": "There already exist a thread for the message you ran this command on (please run /thread (with no arguments and outside any threads) for details on attachment rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Scroll up and see where the link could be.", - }) - } - else if (e.code === 50024) { - return api.sendEvent(event.room_id, "m.room.message", { + case (160004): // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + "body": "There already exist a thread for the message you ran this command on (please run `/thread` (with no arguments and outside any threads) for details on attachment rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Scroll up and see where the link could be.", + "format": "org.matrix.custom.html", + "formatted_body": "There already exist a thread for the message you ran this command on (please run /thread (with no arguments and outside any threads) for details on attachment rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Scroll up and see where the link could be.", + }) + case (50024): return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?" }) - } - else if (e.code === 50035) { - return api.sendEvent(event.room_id, "m.room.message", { + case (50035): return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)" }) - } - else { - await api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "Unknown error occurred during thread creation. See error message below (or on the main channel, if the command was ran inside a thread) for details." - }) - throw e_raw; + default: + await api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "Unknown error occurred during thread creation. See error message below (or on the main channel, if the command was ran inside a thread) for details." + }) + throw e_raw; } } } From 42c32ba749cc27e899cee94cbc9240625a51cee6 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 27 Feb 2026 13:06:52 +0000 Subject: [PATCH 033/111] explained my technical decisions; made a function that'll help me later --- src/matrix/matrix-command-handler.js | 2 +- src/matrix/utils.js | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 8c0c3c2..a5124f9 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -346,7 +346,7 @@ const commands = [{ msgtype: "m.text", body: "Unknown error occurred during thread creation. See error message below (or on the main channel, if the command was ran inside a thread) for details." }) - throw e_raw; + throw e_raw; //Has to be e_raw, as functions get stripped from e as part of unmarshaling Discord error } } } diff --git a/src/matrix/utils.js b/src/matrix/utils.js index 9f5cb0f..dbdb6d8 100644 --- a/src/matrix/utils.js +++ b/src/matrix/utils.js @@ -4,7 +4,7 @@ const assert = require("assert").strict const Ty = require("../types") const {tag} = require("@cloudrac3r/html-template-tag") const passthrough = require("../passthrough") -const {db} = passthrough +const {db, select} = passthrough const {reg} = require("./read-registration") const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) @@ -398,6 +398,16 @@ async function setUserPowerCascade(spaceID, mxid, power, api) { } } +/** + * Set a user's power level for a whole room hierarchy. + * @param {string} eventID + */ +function getThreadRoomFromThreadMessage(eventID){ + const threadID = select("event_message", "message_id", {event_id: eventID}).pluck().get() //Discord thread ID === its message ID + if (!threadID) return threadID; + return select("channel_room", "room_id", {channel_id: threadID}).pluck().get() +} + module.exports.bot = bot module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord @@ -413,3 +423,4 @@ module.exports.removeCreatorsFromPowerLevels = removeCreatorsFromPowerLevels module.exports.getEffectivePower = getEffectivePower module.exports.setUserPower = setUserPower module.exports.setUserPowerCascade = setUserPowerCascade +module.exports.getThreadRoomFromThreadMessage = getThreadRoomFromThreadMessage From c9509bb9383603818be8f2fa5f8c0fb0f0c8f490 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 27 Feb 2026 20:42:16 +0000 Subject: [PATCH 034/111] figured out how tests work, yaaayyyy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As a part of that: * rewrote the tests to support my changed behaviors * added a missing case * Made my „threads get attached to” wording more consistent with the test cases („threads branch from”), as I always felt that there was something off about my phrasing, but I couldn't quite tell what was it. OOYE's „branch” term seems much more fitting there * slightly cooked the testing data (changed the „Hey.” thread from „floating” to being a branch of a message, to accommodate...) * 3 NEW TESTS: of the function created in my previous commit (I'm not sure if this *REALLY* needed testing, given how braindead-simple that function is, but everything else in utils.js is covered, so I figured it's only fair to test this, too) EXTRA CHANGE: fixed that function's name (we're getting the thread from a (Matrix) Event, not a (Discord) Message) and description (I totally didn't copy-paste the JSDoc from above........) --- .../converters/thread-to-announcement.test.js | 65 +++++++++++++++---- src/matrix/matrix-command-handler.js | 30 ++++----- src/matrix/utils.js | 5 +- src/matrix/utils.test.js | 21 +++++- test/ooye-test-data.sql | 4 +- 5 files changed, 94 insertions(+), 31 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.test.js b/src/d2m/converters/thread-to-announcement.test.js index 3286f62..8af4d79 100644 --- a/src/d2m/converters/thread-to-announcement.test.js +++ b/src/d2m/converters/thread-to-announcement.test.js @@ -49,7 +49,7 @@ test("thread2announcement: no known creator, no branched from event", async t => }, {api: viaApi}) t.deepEqual(content, { msgtype: "m.text", - body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", "m.mentions": {} }) }) @@ -61,7 +61,7 @@ test("thread2announcement: known creator, no branched from event", async t => { }, {api: viaApi}) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", "m.mentions": {} }) }) @@ -85,12 +85,15 @@ test("thread2announcement: no known creator, branched from discord event", async }) t.deepEqual(content, { msgtype: "m.text", - body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", "m.mentions": {}, "m.relates_to": { + "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", + "is_falling_back": false, "m.in_reply_to": { - event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" - } + "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", + }, + "rel_type": "m.thread", } }) }) @@ -114,12 +117,15 @@ test("thread2announcement: known creator, branched from discord event", async t }) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", "m.mentions": {}, "m.relates_to": { + "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", + "is_falling_back": false, "m.in_reply_to": { - event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" - } + "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", + }, + "rel_type": "m.thread", } }) }) @@ -143,14 +149,51 @@ test("thread2announcement: no known creator, branched from matrix event", async }) t.deepEqual(content, { msgtype: "m.text", - body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", "m.mentions": { user_ids: ["@cadence:cadence.moe"] }, "m.relates_to": { + "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", + "is_falling_back": false, "m.in_reply_to": { - event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4" - } + "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", + }, + "rel_type": "m.thread", + } + }) +}) + +test("thread2announcement: known creator, branched from matrix event", async t => { + const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", "@_ooye_crunch_god:cadence.moe", { + name: "test thread", + id: "1128118177155526666" + }, { + api: { + getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", { + type: "m.room.message", + content: { + msgtype: "m.text", + body: "so can you reply to my webhook uwu" + }, + sender: "@cadence:cadence.moe" + }), + ...viaApi + } + }) + t.deepEqual(content, { + msgtype: "m.emote", + body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", + "m.mentions": { + user_ids: ["@cadence:cadence.moe"] + }, + "m.relates_to": { + "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", + "is_falling_back": false, + "m.in_reply_to": { + "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", + }, + "rel_type": "m.thread", } }) }) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index a5124f9..ec53d21 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -288,47 +288,47 @@ const commands = [{ const relation = event.content["m.relates_to"] let isFallingBack = false; - let attachedToEvent = relation?.["m.in_reply_to"]?.event_id // By default, attempt to attach the thread to the message to which /thread was replying. - if (relation?.rel_type === "m.thread") attachedToEvent = relation?.event_id // If /thread was sent inside a Matrix thread, attempt to attach the Discord thread to the message around which that Matrix thread was based on. - if (!attachedToEvent){ - attachedToEvent = event.event_id // If /thread wasn't replying to anything (ie. attachedToEvent was undefined at initial assignment), or if the event was somehow malformed (in such a way that that - one way or another - attachedToEvent ended up being undefined, even if according to the spec it shouldn't), attach the thread to the /thread command-message that created it. + let branchedFromMxEvent = relation?.["m.in_reply_to"]?.event_id // By default, attempt to branch the thread from the message to which /thread was replying. + if (relation?.rel_type === "m.thread") branchedFromMxEvent = relation?.event_id // If /thread was sent inside a Matrix thread, attempt to branch the Discord thread from the message, which that Matrix thread already is branching from. + if (!branchedFromMxEvent){ + branchedFromMxEvent = event.event_id // If /thread wasn't replying to anything (ie. branchedFromMxEvent was undefined at initial assignment), or if the event was somehow malformed (in such a way that that - one way or another - branchedFromMxEvent ended up being undefined, even if according to the spec it shouldn't), branch the thread from the /thread command-message that created it. isFallingBack = true; } - const attachedToMessage = select("event_message", "message_id", {event_id: attachedToEvent}).pluck().get() + const branchedFromDiscordMessage = select("event_message", "message_id", {event_id: branchedFromMxEvent}).pluck().get() if (words.length < 2){ - words[1] = (await api.getEvent(event.room_id, attachedToEvent)).content.body + words[1] = (await api.getEvent(event.room_id, branchedFromMxEvent)).content.body words[1] = words[1].length < 100 ? words[1] : words[1].slice(0, 96) + "..." if (isFallingBack) return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "body": "**`/thread` usage:**\nRun this command as `/thread [Thread Name]` to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message under which said thread will be attached, is chosen based on the following rules:\n* If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread gets attached to the command-message itself. The `Thread Name` argument must be provided in this case, otherwise you get this help message.\n* If sent as a reply (outside a Matrix thread), the thread will be attached to the message to which you replied.\n* If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the Discord thread will be attached to the same message as the Matrix thread already is.", + "body": "**`/thread` usage:**\nRun this command as `/thread [Thread Name]` to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:\n* If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The `Thread Name` argument must be provided in this case, otherwise you get this help message.\n* If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.\n* If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.", "format": "org.matrix.custom.html", - "formatted_body": "/thread usage:
Run this command as /thread [Thread Name] to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message under which said thread will be attached, is chosen based on the following rules:
  • If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread gets attached to the command-message itself. The Thread Name argument must be provided in this case, otherwise you get this help message.
  • If sent as a reply (outside a Matrix thread), the thread will be attached to the message to which you replied.
  • If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the Discord thread will be attached to the same message as the Matrix thread already is.
" + "formatted_body": "/thread usage:
Run this command as /thread [Thread Name] to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:
  • If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The Thread Name argument must be provided in this case, otherwise you get this help message.
  • If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.
  • If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.
" }) } try { - if (attachedToMessage) await discord.snow.channel.createThreadWithMessage(channelID, attachedToMessage, {name: words.slice(1).join(" ")}) - else throw {code: "NO_ATTACH_TARGET", was_supposed_to_be: attachedToEvent}; + if (branchedFromDiscordMessage) await discord.snow.channel.createThreadWithMessage(channelID, branchedFromDiscordMessage, {name: words.slice(1).join(" ")}) + else throw {code: "NO_BRANCH_SOURCE", was_supposed_to_be: branchedFromMxEvent}; } catch (e_raw){ const e = unmarshallDiscordError(e_raw) switch (e.code) { - case "NO_ATTACH_TARGET": return api.sendEvent(event.room_id, "m.room.message", { + case "NO_BRANCH_SOURCE": return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "body": "⚠️ Couldn't find a Discord representation of the message under which you're trying to attach this thread (event ID `"+e_raw.was_supposed_to_be+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", + "body": "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID `"+e_raw.was_supposed_to_be+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", "format": "org.matrix.custom.html", - "formatted_body": "⚠️ Couldn't find a Discord representation of the message under which you're trying to attach this thread (event ID "+e_raw.was_supposed_to_be+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." + "formatted_body": "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID "+e_raw.was_supposed_to_be+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." }) case (160004): // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "body": "There already exist a thread for the message you ran this command on (please run `/thread` (with no arguments and outside any threads) for details on attachment rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Scroll up and see where the link could be.", + "body": "There already exist a thread for the message you ran this command on (please run `/thread` (with no arguments and outside any threads) for details on branching rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Scroll up and see where the link could be.", "format": "org.matrix.custom.html", - "formatted_body": "There already exist a thread for the message you ran this command on (please run /thread (with no arguments and outside any threads) for details on attachment rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Scroll up and see where the link could be.", + "formatted_body": "There already exist a thread for the message you ran this command on (please run /thread (with no arguments and outside any threads) for details on branching rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Scroll up and see where the link could be.", }) case (50024): return api.sendEvent(event.room_id, "m.room.message", { ...ctx, diff --git a/src/matrix/utils.js b/src/matrix/utils.js index dbdb6d8..f9dbf3f 100644 --- a/src/matrix/utils.js +++ b/src/matrix/utils.js @@ -399,10 +399,9 @@ async function setUserPowerCascade(spaceID, mxid, power, api) { } /** - * Set a user's power level for a whole room hierarchy. * @param {string} eventID */ -function getThreadRoomFromThreadMessage(eventID){ +function getThreadRoomFromThreadEvent(eventID){ const threadID = select("event_message", "message_id", {event_id: eventID}).pluck().get() //Discord thread ID === its message ID if (!threadID) return threadID; return select("channel_room", "room_id", {channel_id: threadID}).pluck().get() @@ -423,4 +422,4 @@ module.exports.removeCreatorsFromPowerLevels = removeCreatorsFromPowerLevels module.exports.getEffectivePower = getEffectivePower module.exports.setUserPower = setUserPower module.exports.setUserPowerCascade = setUserPowerCascade -module.exports.getThreadRoomFromThreadMessage = getThreadRoomFromThreadMessage +module.exports.getThreadRoomFromThreadEvent = getThreadRoomFromThreadEvent diff --git a/src/matrix/utils.test.js b/src/matrix/utils.test.js index 842c513..f42ce5c 100644 --- a/src/matrix/utils.test.js +++ b/src/matrix/utils.test.js @@ -2,7 +2,7 @@ const {select} = require("../passthrough") const {test} = require("supertape") -const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower} = require("./utils") +const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower, getThreadRoomFromThreadEvent} = require("./utils") const util = require("util") /** @param {string[]} mxids */ @@ -417,4 +417,23 @@ test("set user power: privileged users must demote themselves", async t => { t.equal(called, 3) }) +test("getThreadRoomFromThreadEvent: real message, but without a thread", t => { + const room = getThreadRoomFromThreadEvent("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") + const msg = "Expected null/undefined, got: "+room + if(room) t.fail(msg); + else t.pass(msg) +}) + +test("getThreadRoomFromThreadEvent: real message with a thread", t => { + const room = getThreadRoomFromThreadEvent("$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg") + t.equal(room, "!FuDZhlOAtqswlyxzeR:cadence.moe") +}) + +test("getThreadRoomFromThreadEvent: fake message", t => { + const room = getThreadRoomFromThreadEvent("$ThisEvent-IdDoesNotExistInTheDatabase4Sure") + const msg = "Expected null/undefined, got: "+room + if(room) t.fail(msg); + else t.pass(msg) +}) + module.exports.mockGetEffectivePower = mockGetEffectivePower diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 1dd9dfe..a321d48 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -82,12 +82,14 @@ WITH a (message_id, channel_id) AS (VALUES ('1381212840957972480', '112760669178241024'), ('1401760355339862066', '112760669178241024'), ('1439351590262800565', '1438284564815548418'), -('1404133238414376971', '112760669178241024')) +('1404133238414376971', '112760669178241024'), +('1162005314908999790', '1100319550446252084')) SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id; INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES ('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1), ('$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4', 'm.room.message', 'm.text', '1128118177155526666', 0, 0, 0), +('$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg', 'm.room.message', 'm.text', '1162005314908999790', 0, 0, 1), ('$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10', 'm.room.message', 'm.text', '1141619794500649020', 0, 0, 1), ('$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY', 'm.room.message', 'm.text', '1141206225632112650', 0, 0, 1), ('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', 0, 1, 1), From edfbdc567f50dae91cd1863f81d75dbf710634a6 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 27 Feb 2026 23:07:43 +0000 Subject: [PATCH 035/111] Used getThreadRoomFromThreadEvent in practice --- src/m2d/event-dispatcher.js | 5 +++-- src/matrix/matrix-command-handler.js | 16 +++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 0cd1b0c..b247290 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -222,10 +222,11 @@ async event => { ) } if (event.content["m.relates_to"]?.rel_type === "m.thread" && executedCommand !== "thread"){ + const bridgedTo = utils.getThreadRoomFromThreadEvent(event.event_id) api.sendEvent(event.room_id, "m.room.message", { - "body": "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. *In other words: __Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel.__ If the thread you sent this message in is old, such a random reply may be distracting to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with `/thread` and continue the conversation there. You can run that command directly in this thread to attach the created Discord thread to the same message as this Matrix one, or run it outside any threads to see other options for attachment.\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", + "body": "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. *In other words: __Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel.__ If the thread you sent this message in is old, such a random reply **may be distracting** to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on https://matrix.to/#/"+bridgedTo+"?"+(await utils.getViaServersQuery(bridgedTo, api)).toString() : "Please run `/thread [Optional: Thread Name]` to create such a room for this thread, or get a link to it if someone else has already done so. If you run `/thread` (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", "format": "org.matrix.custom.html", - "formatted_body": "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. In other words: Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. If you sent this message in a pre-existing Matrix thread, please look around to see if anyone has created such a room for it. If this is the first message (ie. the thread was just created) or nobody has made a thread-room for this thread yet, it is recommended you create one with /thread and continue the conversation there. You can run that command directly in this thread to attach the created Discord thread to the same message as this Matrix one, or run it outside any threads to see other options for attachment.

You can read more about the rationale behind this design choice here.", + "formatted_body": "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. In other words: Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on https://matrix.to/#/"+bridgedTo+"?"+(await utils.getViaServersQuery(bridgedTo, api)).toString()+"" : "Please run /thread [Optional: Thread Name] to create such a room for this thread, or get a link to it if someone else has already done so. If you run /thread (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".

You can read more about the rationale behind this design choice here.", "m.mentions": { "user_ids": [event.sender]}, "m.relates_to": { "event_id": event.content["m.relates_to"].event_id, diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index ec53d21..ff46b9c 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -312,23 +312,21 @@ const commands = [{ if (branchedFromDiscordMessage) await discord.snow.channel.createThreadWithMessage(channelID, branchedFromDiscordMessage, {name: words.slice(1).join(" ")}) else throw {code: "NO_BRANCH_SOURCE", was_supposed_to_be: branchedFromMxEvent}; } - catch (e_raw){ - const e = unmarshallDiscordError(e_raw) - switch (e.code) { + catch (e){ + switch (unmarshallDiscordError(e).code) { case "NO_BRANCH_SOURCE": return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "body": "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID `"+e_raw.was_supposed_to_be+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", + "body": "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID `"+e.was_supposed_to_be+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", "format": "org.matrix.custom.html", - "formatted_body": "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID "+e_raw.was_supposed_to_be+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." + "formatted_body": "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID "+e.was_supposed_to_be+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." }) case (160004): // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes + const thread = mxUtils.getThreadRoomFromThreadEvent(event.event_id) return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "body": "There already exist a thread for the message you ran this command on (please run `/thread` (with no arguments and outside any threads) for details on branching rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Scroll up and see where the link could be.", - "format": "org.matrix.custom.html", - "formatted_body": "There already exist a thread for the message you ran this command on (please run /thread (with no arguments and outside any threads) for details on branching rules, if you're unsure what „ran this command on” refers to); cannot create a new one. Scroll up and see where the link could be.", + body: "There already exist a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue (or have someone send a message in that thread on Discord side to bridge it automatically, if that's enabled for this space/server).") }) case (50024): return api.sendEvent(event.room_id, "m.room.message", { ...ctx, @@ -346,7 +344,7 @@ const commands = [{ msgtype: "m.text", body: "Unknown error occurred during thread creation. See error message below (or on the main channel, if the command was ran inside a thread) for details." }) - throw e_raw; //Has to be e_raw, as functions get stripped from e as part of unmarshaling Discord error + throw e } } } From 10fbb9e6960e0be4874a6bb6a46a10d78b45211c Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 27 Feb 2026 23:11:50 +0000 Subject: [PATCH 036/111] improved consistency --- src/m2d/event-dispatcher.js | 16 ++++++++-------- src/matrix/matrix-command-handler.js | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index b247290..f3f6964 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -224,17 +224,17 @@ async event => { if (event.content["m.relates_to"]?.rel_type === "m.thread" && executedCommand !== "thread"){ const bridgedTo = utils.getThreadRoomFromThreadEvent(event.event_id) api.sendEvent(event.room_id, "m.room.message", { - "body": "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. *In other words: __Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel.__ If the thread you sent this message in is old, such a random reply **may be distracting** to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on https://matrix.to/#/"+bridgedTo+"?"+(await utils.getViaServersQuery(bridgedTo, api)).toString() : "Please run `/thread [Optional: Thread Name]` to create such a room for this thread, or get a link to it if someone else has already done so. If you run `/thread` (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", - "format": "org.matrix.custom.html", - "formatted_body": "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. In other words: Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on https://matrix.to/#/"+bridgedTo+"?"+(await utils.getViaServersQuery(bridgedTo, api)).toString()+"" : "Please run /thread [Optional: Thread Name] to create such a room for this thread, or get a link to it if someone else has already done so. If you run /thread (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".

You can read more about the rationale behind this design choice here.", + body: "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. *In other words: __Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel.__ If the thread you sent this message in is old, such a random reply **may be distracting** to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on https://matrix.to/#/"+bridgedTo+"?"+(await utils.getViaServersQuery(bridgedTo, api)).toString() : "Please run `/thread [Optional: Thread Name]` to create such a room for this thread, or get a link to it if someone else has already done so. If you run `/thread` (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", + format: "org.matrix.custom.html", + formatted_body: "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. In other words: Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on https://matrix.to/#/"+bridgedTo+"?"+(await utils.getViaServersQuery(bridgedTo, api)).toString()+"" : "Please run /thread [Optional: Thread Name] to create such a room for this thread, or get a link to it if someone else has already done so. If you run /thread (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".

You can read more about the rationale behind this design choice here.", "m.mentions": { "user_ids": [event.sender]}, "m.relates_to": { - "event_id": event.content["m.relates_to"].event_id, - "is_falling_back": false, - "m.in_reply_to": { "event_id": event.event_id }, - "rel_type": "m.thread" + event_id: event.content["m.relates_to"].event_id, + is_falling_back: false, + "m.in_reply_to": { event_id: event.event_id }, + rel_type: "m.thread" }, - "msgtype": "m.text" + msgtype: "m.text" }) } diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index ff46b9c..ffb6eb1 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -302,9 +302,9 @@ const commands = [{ if (isFallingBack) return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "body": "**`/thread` usage:**\nRun this command as `/thread [Thread Name]` to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:\n* If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The `Thread Name` argument must be provided in this case, otherwise you get this help message.\n* If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.\n* If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.", - "format": "org.matrix.custom.html", - "formatted_body": "/thread usage:
Run this command as /thread [Thread Name] to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:
  • If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The Thread Name argument must be provided in this case, otherwise you get this help message.
  • If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.
  • If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.
" + body: "**`/thread` usage:**\nRun this command as `/thread [Thread Name]` to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:\n* If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The `Thread Name` argument must be provided in this case, otherwise you get this help message.\n* If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.\n* If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.", + format: "org.matrix.custom.html", + formatted_body: "/thread usage:
Run this command as /thread [Thread Name] to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:
  • If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The Thread Name argument must be provided in this case, otherwise you get this help message.
  • If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.
  • If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.
" }) } @@ -317,9 +317,9 @@ const commands = [{ case "NO_BRANCH_SOURCE": return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - "body": "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID `"+e.was_supposed_to_be+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", - "format": "org.matrix.custom.html", - "formatted_body": "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID "+e.was_supposed_to_be+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." + body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID `"+e.was_supposed_to_be+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", + format: "org.matrix.custom.html", + formatted_body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID "+e.was_supposed_to_be+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." }) case (160004): // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes const thread = mxUtils.getThreadRoomFromThreadEvent(event.event_id) From 7895f89cc0d559acff7052370c2c3e311a0f171e Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 28 Feb 2026 11:52:18 +0000 Subject: [PATCH 037/111] debug slop v2 --- src/matrix/utils.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/matrix/utils.js b/src/matrix/utils.js index f9dbf3f..001c26d 100644 --- a/src/matrix/utils.js +++ b/src/matrix/utils.js @@ -402,7 +402,9 @@ async function setUserPowerCascade(spaceID, mxid, power, api) { * @param {string} eventID */ function getThreadRoomFromThreadEvent(eventID){ + console.log("searching for: "+eventID) const threadID = select("event_message", "message_id", {event_id: eventID}).pluck().get() //Discord thread ID === its message ID + console.log("matched to: "+threadID) if (!threadID) return threadID; return select("channel_room", "room_id", {channel_id: threadID}).pluck().get() } From 69b128a5981791d1f9223cd89bda226973431807 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 28 Feb 2026 13:37:16 +0000 Subject: [PATCH 038/111] debug done; turns out that I'm just stupid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I passed a completely wrong event ID and was confused as to why could it possibly be failing. (Btw, as part od fixing that - my new function from function is utils.js now supports blank values.) Also - and that's unrelated to the bug I was debugging - I put a guard clause in my if (words.length < 2) backwards. If->return should canonically be above the logic, even if it technically doesn't break said logic in this case (all it was doing was creating an extra step that says „yea, name the newly-created thread /thread, even if this name is very stupid, and also pointless because no thread creation is about to take place”. And while fixing that, I also did some minor changes to error handling. --- src/m2d/event-dispatcher.js | 2 +- src/matrix/matrix-command-handler.js | 22 +++++++++++++++++----- src/matrix/utils.js | 7 +++---- src/matrix/utils.test.js | 15 +++++++++++++++ 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index f3f6964..ca8fbaf 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -222,7 +222,7 @@ async event => { ) } if (event.content["m.relates_to"]?.rel_type === "m.thread" && executedCommand !== "thread"){ - const bridgedTo = utils.getThreadRoomFromThreadEvent(event.event_id) + const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id) api.sendEvent(event.room_id, "m.room.message", { body: "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. *In other words: __Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel.__ If the thread you sent this message in is old, such a random reply **may be distracting** to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on https://matrix.to/#/"+bridgedTo+"?"+(await utils.getViaServersQuery(bridgedTo, api)).toString() : "Please run `/thread [Optional: Thread Name]` to create such a room for this thread, or get a link to it if someone else has already done so. If you run `/thread` (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", format: "org.matrix.custom.html", diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index ffb6eb1..8fbdcf3 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -297,8 +297,6 @@ const commands = [{ const branchedFromDiscordMessage = select("event_message", "message_id", {event_id: branchedFromMxEvent}).pluck().get() if (words.length < 2){ - words[1] = (await api.getEvent(event.room_id, branchedFromMxEvent)).content.body - words[1] = words[1].length < 100 ? words[1] : words[1].slice(0, 96) + "..." if (isFallingBack) return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", @@ -306,6 +304,8 @@ const commands = [{ format: "org.matrix.custom.html", formatted_body: "/thread usage:
Run this command as /thread [Thread Name] to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:
  • If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The Thread Name argument must be provided in this case, otherwise you get this help message.
  • If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.
  • If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.
" }) + words[1] = (await api.getEvent(event.room_id, branchedFromMxEvent)).content.body + words[1] = words[1].length < 100 ? words[1] : words[1].slice(0, 96) + "..." } try { @@ -321,28 +321,40 @@ const commands = [{ format: "org.matrix.custom.html", formatted_body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID "+e.was_supposed_to_be+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." }) + case (160004): // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes - const thread = mxUtils.getThreadRoomFromThreadEvent(event.event_id) + if (isFallingBack){ + await api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "⚠️ Discord claims that there already exists a thread for the message you ran this command on, but that doesn't make logical sense, as it doesn't seem like you ran this command on any message. Either your Matrix client did something funny with reply/thread tags, or this is a logic error on OOYE's side. At any rate, this should be reported for further investigation. you should also attach the error message that's about to be sent below (or on the main room timeline, if the command was ran inside a thread).", + }) + throw e; + } + const thread = mxUtils.getThreadRoomFromThreadEvent(branchedFromMxEvent) return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "There already exist a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue (or have someone send a message in that thread on Discord side to bridge it automatically, if that's enabled for this space/server).") + body: "There already exists a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue (or have someone send a message in that thread on Discord side to bridge it automatically, if that's enabled for this space/server) by manually creating the bridge. (If you're said admin and you can see that said bridge already exists, but this message is still showing up, please report that as a bug.)") }) + case (50024): return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?" }) + case (50035): return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)" }) + default: await api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "Unknown error occurred during thread creation. See error message below (or on the main channel, if the command was ran inside a thread) for details." + body: "⚠️ Unknown error occurred during thread creation. See error message below (or on the main room timeline, if the command was ran inside a thread) for details." }) throw e } diff --git a/src/matrix/utils.js b/src/matrix/utils.js index 001c26d..6383c4d 100644 --- a/src/matrix/utils.js +++ b/src/matrix/utils.js @@ -399,12 +399,11 @@ async function setUserPowerCascade(spaceID, mxid, power, api) { } /** - * @param {string} eventID - */ + * @param {undefined|string?} eventID + */ //^For some reason, „?” doesn't include Undefined and it needs to be explicitly specified function getThreadRoomFromThreadEvent(eventID){ - console.log("searching for: "+eventID) + if (!eventID) return eventID; const threadID = select("event_message", "message_id", {event_id: eventID}).pluck().get() //Discord thread ID === its message ID - console.log("matched to: "+threadID) if (!threadID) return threadID; return select("channel_room", "room_id", {channel_id: threadID}).pluck().get() } diff --git a/src/matrix/utils.test.js b/src/matrix/utils.test.js index f42ce5c..8db998d 100644 --- a/src/matrix/utils.test.js +++ b/src/matrix/utils.test.js @@ -436,4 +436,19 @@ test("getThreadRoomFromThreadEvent: fake message", t => { else t.pass(msg) }) +test("getThreadRoomFromThreadEvent: null", t => { + const room = getThreadRoomFromThreadEvent(null) + t.equal(room, null) +}) + +test("getThreadRoomFromThreadEvent: undefined", t => { + const room = getThreadRoomFromThreadEvent(undefined) + t.equal(room, undefined) +}) + +test("getThreadRoomFromThreadEvent: no value at all", t => { + const room = getThreadRoomFromThreadEvent() //This line should be giving a type-error, so it's not @ts-ignored on purpose. This is to test the desired behavior of that function, ie. „it CAN TAKE an undefined VALUE (as tested above), but you can just LEAVE the value completely undefined” (well, you can leave it like that from JS syntax perspective (which is why this test passes), but it makes no sense from usage standpoint, as it just gives back undefined). So this isn't a logic test (that's handled above), as much as it is a TypeScript test. + t.equal(room, undefined) +}) + module.exports.mockGetEffectivePower = mockGetEffectivePower From 8260396254fcf2ac454fbdc52db11be86e6a4233 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 28 Feb 2026 14:14:27 +0000 Subject: [PATCH 039/111] replace newlines instead of stripping them (what DC does by default) --- src/matrix/matrix-command-handler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 8fbdcf3..a0b2adf 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -304,7 +304,7 @@ const commands = [{ format: "org.matrix.custom.html", formatted_body: "/thread usage:
Run this command as /thread [Thread Name] to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:
  • If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The Thread Name argument must be provided in this case, otherwise you get this help message.
  • If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.
  • If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.
" }) - words[1] = (await api.getEvent(event.room_id, branchedFromMxEvent)).content.body + words[1] = (await api.getEvent(event.room_id, branchedFromMxEvent)).content.body.replaceAll("\n", " ") words[1] = words[1].length < 100 ? words[1] : words[1].slice(0, 96) + "..." } From a877122ef637bbaa9f80486d675a2cde06118e4d Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 2 Mar 2026 13:06:30 +0000 Subject: [PATCH 040/111] fix one more final UX pet-peeve --- src/m2d/event-dispatcher.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index ca8fbaf..5000a8c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -226,7 +226,7 @@ async event => { api.sendEvent(event.room_id, "m.room.message", { body: "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. *In other words: __Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel.__ If the thread you sent this message in is old, such a random reply **may be distracting** to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on https://matrix.to/#/"+bridgedTo+"?"+(await utils.getViaServersQuery(bridgedTo, api)).toString() : "Please run `/thread [Optional: Thread Name]` to create such a room for this thread, or get a link to it if someone else has already done so. If you run `/thread` (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", format: "org.matrix.custom.html", - formatted_body: "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. In other words: Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on https://matrix.to/#/"+bridgedTo+"?"+(await utils.getViaServersQuery(bridgedTo, api)).toString()+"" : "Please run /thread [Optional: Thread Name] to create such a room for this thread, or get a link to it if someone else has already done so. If you run /thread (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".

You can read more about the rationale behind this design choice here.", + formatted_body: "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. In other words: Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on "+bridgedTo+"" : "Please run /thread [Optional: Thread Name] to create such a room for this thread, or get a link to it if someone else has already done so. If you run /thread (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".

You can read more about the rationale behind this design choice here.", "m.mentions": { "user_ids": [event.sender]}, "m.relates_to": { event_id: event.content["m.relates_to"].event_id, From 20ce420303486a1f05316baa17eba5c5ec10eb6a Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 2 Mar 2026 13:12:17 +0000 Subject: [PATCH 041/111] copy-pasted Cadence's message as documentation --- docs/threads-as-rooms.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docs/threads-as-rooms.md diff --git a/docs/threads-as-rooms.md b/docs/threads-as-rooms.md new file mode 100644 index 0000000..012c7af --- /dev/null +++ b/docs/threads-as-rooms.md @@ -0,0 +1,9 @@ +I thought pretty hard about it and I opted to make threads separate rooms because + +1. parity: discord has separate things like permissions and pins for threads, matrix cannot do this at all unless the thread is a separate room +2. usage styles: most discord threads I've seen tend to be long-lived, spanning months or years, which isn't suited to matrix because of the timeline + - I'm in a discord thread for posting photos of food that gets a couple posts a week and has a timeline going back to 2023 +3. the timeline: if a matrix room has threads, and you want to scroll back through the timeline of a room OR of one of its threads, the timeline is merged, so you have to download every message linearised and throw them away if they aren't part of the thread you're looking through. it's bad for threads and it's bad for the main room +4. it is also very very complex for clients to implement read receipts and typing indicators correctly for the merged timeline. if your client doesn't implement this, or doesn't do it correctly, you have a bad experience. many clients don't. element seems to have done it well enough, but is an exception + +overall in my view, threads-as-rooms has better parity and fewer downsides over native threads. but if there are things you don't like about this approach, I'm happy to discuss and see if we can improve them. \ No newline at end of file From b84b848d04528dec764310307f1e11941e288445 Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 2 Mar 2026 13:15:29 +0000 Subject: [PATCH 042/111] temporary change to VSC settings So that I can squash-merge it all without leaving the trace of any extra unsolicited changes --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index ea571e8..9f1e183 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { "editor.insertSpaces": false, "editor.detectIndentation": false, - "editor.tabSize": 4 + "editor.tabSize": 3 } From b38abe81a6ab581c3759a345c8e3cf0e2fbbe2fe Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 2 Mar 2026 13:44:36 +0000 Subject: [PATCH 043/111] reworded the error Turns out that auto-create is ALWAYS on for threads (which creates some hilarious situations, where the channel gets duplicated if it ever got unbridged). Also, manual bridging isn't even possible. Uhh... Sure! Let's just say, then, that it's the admin's problem to auto-create it (given the duplication - this is probably a better idea to leave it to them). A proper fix for this (and also to limit (tho not fix) the dupe-by-autocreate problem) would probably be to allow for manual bridging on threads, but I really don't have time for this before The Merge (my ADHD is kicking-in on this update, and I have a feeling that if I don't PR soon, I'm gonna not do it for another 3 months). --- src/matrix/matrix-command-handler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index a0b2adf..6758f78 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -335,7 +335,7 @@ const commands = [{ return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "There already exists a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue (or have someone send a message in that thread on Discord side to bridge it automatically, if that's enabled for this space/server) by manually creating the bridge. (If you're said admin and you can see that said bridge already exists, but this message is still showing up, please report that as a bug.)") + body: "There already exists a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue by creating the bridge. (If you're said admin and you can see that said bridge already exists, but this error message is still showing up, please report that as a bug.)") }) case (50024): return api.sendEvent(event.room_id, "m.room.message", { From 74c0c28cf4c1683e38a2dfa32b0b93f1fe7ab3b9 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 8 Mar 2026 22:34:04 +1300 Subject: [PATCH 045/111] Update dependencies --- package-lock.json | 252 ++++++++++++++++++---------------------------- package.json | 6 +- 2 files changed, 99 insertions(+), 159 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9847400..4e6ecd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "ansi-colors": "^4.1.3", "better-sqlite3": "^12.2.0", "chunk-text": "^2.0.1", - "cloudstorm": "^0.15.2", + "cloudstorm": "^0.17.0", "discord-api-types": "^0.38.38", "domino": "^2.1.6", "enquirer": "^2.4.1", @@ -36,7 +36,7 @@ "mime-types": "^2.1.35", "prettier-bytes": "^1.0.4", "sharp": "^0.34.5", - "snowtransfer": "^0.17.1", + "snowtransfer": "^0.17.5", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", "uqr": "^0.1.2", @@ -46,7 +46,7 @@ "devDependencies": { "@cloudrac3r/tap-dot": "^2.0.3", "@types/node": "^22.17.1", - "c8": "^10.1.2", + "c8": "^11.0.0", "cross-env": "^7.0.3", "supertape": "^12.0.12" }, @@ -137,9 +137,9 @@ } }, "node_modules/@chriscdn/promise-semaphore": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.1.2.tgz", - "integrity": "sha512-rELbH6FSr9wr5J249Ax8dpzQdTaqEgcW+lilDKZxB13Hz0Bz3Iyx4q/7qZxPMnra9FUW4ZOkVf+bx5tbi6Goog==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.1.3.tgz", + "integrity": "sha512-EAmwIbH1L2CNsJWloXBG4Kv89H7IUsjYFQnGnmus3OX70LcD5Uu5A7sohPx3O0Ks9UQWEgcr5n2IfxBSuYvOeg==", "license": "MIT" }, "node_modules/@cloudcmd/stub": { @@ -178,9 +178,9 @@ } }, "node_modules/@cloudrac3r/in-your-element": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@cloudrac3r/in-your-element/-/in-your-element-1.1.1.tgz", - "integrity": "sha512-AKp9vnSDA9wzJl4O3C/LA8jgI5m1r0M3MRBQGHcVVL22SrrZMdcy+kWjlZWK343KVLOkuTAISA2D+Jb/zyZS6A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@cloudrac3r/in-your-element/-/in-your-element-1.1.2.tgz", + "integrity": "sha512-adFZel24sGHpTI1vgJdBN5twcdu6QmPFlO8qAJt49KO6N8mwDcbUC2GPqH5pGerXNv1Lpq0eXsNLm+ytKrOTaQ==", "license": "AGPL-3.0-or-later", "dependencies": { "h3": "^1.12.0", @@ -765,21 +765,12 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -1048,9 +1039,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", - "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", "dependencies": { @@ -1132,10 +1123,14 @@ "license": "MIT" }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -1225,20 +1220,24 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/c8": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", - "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", + "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==", "dev": true, + "license": "ISC", "dependencies": { "@bcoe/v8-coverage": "^1.0.1", "@istanbuljs/schema": "^0.1.3", @@ -1247,7 +1246,7 @@ "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.6", - "test-exclude": "^7.0.1", + "test-exclude": "^8.0.0", "v8-to-istanbul": "^9.0.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" @@ -1256,7 +1255,7 @@ "c8": "bin/c8.js" }, "engines": { - "node": ">=18" + "node": "20 || >=22" }, "peerDependencies": { "monocart-coverage-reports": "^2" @@ -1370,13 +1369,13 @@ } }, "node_modules/cloudstorm": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.15.2.tgz", - "integrity": "sha512-5y7E0uI39R3d7c+AWksqAQAlZlpx+qNjxjQfNIem2hh68s6QRmOFHTKu34I7pBE6JonpZf8AmoMYArY/4lLVmg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.17.0.tgz", + "integrity": "sha512-zsd9y5ljNnbxdvDid9TgWePDqo7il4so5spzx6NDwZ67qWQjR96UUhLxJ+BAOdBBSPF9UXFM61dAzC2g918q+A==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.37", - "snowtransfer": "^0.17.0" + "discord-api-types": "^0.38.40", + "snowtransfer": "^0.17.5" }, "engines": { "node": ">=22.0.0" @@ -1517,9 +1516,9 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.38", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.38.tgz", - "integrity": "sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q==", + "version": "0.38.41", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.41.tgz", + "integrity": "sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" @@ -1731,66 +1730,18 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" }, "node_modules/glob": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz", - "integrity": "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", - "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "jackspeak": "^4.2.3" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", - "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -1973,22 +1924,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jest-diff": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", @@ -2023,6 +1958,13 @@ "integrity": "sha512-p2BdO7o4BI+pMun3J+dhaOfYan5JsZrw9wjshRjkWY9+p+u+kKSMhNWYnot2yHDR9CSahZ9iT3dcqJ+V72qHMw==", "dev": true }, + "node_modules/just-snake-case": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/just-snake-case/-/just-snake-case-3.2.0.tgz", + "integrity": "sha512-iugHP9bSE0jOq3BzN0W0rdu/OOkFirPe8FtUw6v9y37UlbUDgJ1x4xiGNfUhI6mV9dc/paaifyiyn+F+mrm8gw==", + "dev": true, + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2039,9 +1981,9 @@ } }, "node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -2094,16 +2036,16 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2118,10 +2060,11 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -2199,12 +2142,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", - "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", - "dev": true - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2230,9 +2167,9 @@ "dev": true }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -2240,7 +2177,7 @@ "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2648,12 +2585,12 @@ } }, "node_modules/snowtransfer": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.1.tgz", - "integrity": "sha512-WSXj055EJhzzfD7B3oHVyRTxkqFCaxcVhwKY6B3NkBSHRyM6wHxZLq6VbFYhopUg+lMtd7S1ZO8JM+Ut+js2iA==", + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.5.tgz", + "integrity": "sha512-nVI1UJNFoX1ndGFZxB3zb3X5SWtD9hIAcw7wCgVKWvCf42Wg2B4UFIrZWI83HxaSBY0CGbPZmZzZb3RSt/v2wQ==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.37" + "discord-api-types": "^0.38.40" }, "engines": { "node": ">=22.0.0" @@ -2780,9 +2717,9 @@ } }, "node_modules/supertape": { - "version": "12.0.12", - "resolved": "https://registry.npmjs.org/supertape/-/supertape-12.0.12.tgz", - "integrity": "sha512-ugmCQsB7s22fCTJKiMb6+Fd8kP7Hsvlo6/aly0qLGgOepu1PVBydhrBPMWaoY3wf+VqLtMkkvwGxUTCFde5z/g==", + "version": "12.7.0", + "resolved": "https://registry.npmjs.org/supertape/-/supertape-12.7.0.tgz", + "integrity": "sha512-5PXh6HsfEJKkC0SMhPNkH35o8Okj8xlVvoju9R0aCohzsK+GEufeYZ1IPhRBRQ2DBLXdMZHVF6N/4pAefxNuAA==", "dev": true, "license": "MIT", "dependencies": { @@ -2800,9 +2737,10 @@ "cli-progress": "^3.8.2", "flatted": "^3.3.1", "fullstore": "^4.0.0", - "glob": "^11.0.1", + "glob": "^13.0.0", "jest-diff": "^30.0.3", "json-with-bigint": "^3.4.4", + "just-snake-case": "^3.2.0", "once": "^1.4.0", "resolve": "^1.17.0", "stacktracey": "^2.1.7", @@ -2819,9 +2757,9 @@ } }, "node_modules/supertape/node_modules/try-to-catch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.0.3.tgz", - "integrity": "sha512-mUz1zpe6nkRQW0XZ/Ojfe/Eg7e5h3s+r+h7ONfP3Oo27/Jm8mkNDAnLzZ/A3sEMApROolzuJGBiQhGmmVDAFLw==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.0.5.tgz", + "integrity": "sha512-VKBslDQsy4pGj2TMNgDdskWb7AWSi/9dPEmcNv3sdL0+aRMQTPJo6aEqlcuN0vbOwFfsE1oAXmx3bFdf6vrJFg==", "dev": true, "license": "MIT", "engines": { @@ -2903,17 +2841,18 @@ } }, "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", "dev": true, + "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" + "glob": "^13.0.6", + "minimatch": "^10.2.2" }, "engines": { - "node": ">=18" + "node": "20 || >=22" } }, "node_modules/through2": { @@ -2979,6 +2918,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-3.0.1.tgz", "integrity": "sha512-hOY83V84Hx/1sCzDSaJA+Xz2IIQOHRvjxzt+F0OjbQGPZ6yLPLArMA0gw/484MlfUkQbCpKYMLX3VDCAjWKfzQ==", + "license": "MIT", "engines": { "node": ">=6" } @@ -3185,9 +3125,9 @@ } }, "node_modules/zod": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", - "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index afbb90a..71639b0 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "ansi-colors": "^4.1.3", "better-sqlite3": "^12.2.0", "chunk-text": "^2.0.1", - "cloudstorm": "^0.15.2", + "cloudstorm": "^0.17.0", "discord-api-types": "^0.38.38", "domino": "^2.1.6", "enquirer": "^2.4.1", @@ -45,7 +45,7 @@ "mime-types": "^2.1.35", "prettier-bytes": "^1.0.4", "sharp": "^0.34.5", - "snowtransfer": "^0.17.1", + "snowtransfer": "^0.17.5", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", "uqr": "^0.1.2", @@ -58,7 +58,7 @@ "devDependencies": { "@cloudrac3r/tap-dot": "^2.0.3", "@types/node": "^22.17.1", - "c8": "^10.1.2", + "c8": "^11.0.0", "cross-env": "^7.0.3", "supertape": "^12.0.12" }, From 9eaa85c072fefd1978247cbfe4dcc4d71af8fe97 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 8 Mar 2026 22:34:51 +1300 Subject: [PATCH 046/111] Add /invite Matrix command to get Discord invite --- src/matrix/matrix-command-handler.js | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index e382a32..d568f7b 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -1,6 +1,7 @@ // @ts-check const assert = require("assert").strict +const DiscordTypes = require("discord-api-types/v10") const Ty = require("../types") const {pipeline} = require("stream").promises const sharp = require("sharp") @@ -262,6 +263,46 @@ const commands = [{ await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")}) } ) +}, { + aliases: ["invite"], + execute: replyctx( + async (event, realBody, words, ctx) => { + // Guard + /** @type {string} */ // @ts-ignore + const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() + const guildID = discord.channels.get(channelID)?.["guild_id"] + if (!guildID) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "This room isn't bridged to the other side." + }) + } + + const guild = discord.guilds.get(guildID) + assert(guild) + const permissions = dUtils.getPermissions(guild.id, [], guild.roles) + if (!dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "This command creates an invite link to the Discord side. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Invite permission." + }) + } + + const invite = await discord.snow.channel.createChannelInvite(channelID) + const validHours = Math.ceil(invite.max_age / (60 * 60)) + const validUses = + ( invite.max_uses === 0 ? "unlimited uses" + : invite.max_uses === 1 ? "single-use" + : `${invite.max_uses} uses`) + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: `https://discord.gg/${invite.code}\nValid for next ${validHours} hours, ${validUses}.` + }) + } + ) }] From 6a2606cbdb41f8ee5f7aa8c8da2d0fa90e93bf60 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 8 Mar 2026 22:35:10 +1300 Subject: [PATCH 047/111] Add UI for defining default roles --- src/db/migrations/0035-role-default.sql | 9 ++++ src/db/orm-defs.d.ts | 5 +++ src/web/pug-sync.js | 1 + src/web/pug/fragments/default-roles-list.pug | 5 +++ src/web/pug/guild.pug | 21 ++++++++- src/web/pug/includes/default-roles-list.pug | 19 +++++++++ src/web/pug/includes/template.pug | 25 +++++++++-- src/web/routes/guild-settings.js | 45 +++++++++++++++++++- 8 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 src/db/migrations/0035-role-default.sql create mode 100644 src/web/pug/fragments/default-roles-list.pug create mode 100644 src/web/pug/includes/default-roles-list.pug diff --git a/src/db/migrations/0035-role-default.sql b/src/db/migrations/0035-role-default.sql new file mode 100644 index 0000000..6c44e7e --- /dev/null +++ b/src/db/migrations/0035-role-default.sql @@ -0,0 +1,9 @@ +BEGIN TRANSACTION; + +CREATE TABLE "role_default" ( + "guild_id" TEXT NOT NULL, + "role_id" TEXT NOT NULL, + PRIMARY KEY ("guild_id", "role_id") +); + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index 79f02ad..f6628f2 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -104,6 +104,11 @@ export type Models = { historical_room_index: number } + role_default: { + guild_id: string + role_id: string + } + room_upgrade_pending: { new_room_id: string old_room_id: string diff --git a/src/web/pug-sync.js b/src/web/pug-sync.js index f87550d..e61c53b 100644 --- a/src/web/pug-sync.js +++ b/src/web/pug-sync.js @@ -77,6 +77,7 @@ function renderPath(event, path, locals) { compile() fs.watch(path, {persistent: false}, compile) fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile) + fs.watch(join(__dirname, "pug", "fragments"), {persistent: false}, compile) } const cb = pugCache.get(path) diff --git a/src/web/pug/fragments/default-roles-list.pug b/src/web/pug/fragments/default-roles-list.pug new file mode 100644 index 0000000..3b36549 --- /dev/null +++ b/src/web/pug/fragments/default-roles-list.pug @@ -0,0 +1,5 @@ +//- locals: guild, guild_id + +include ../includes/default-roles-list.pug ++default-roles-list(guild, guild_id) ++add-roles-menu(guild, guild_id) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index a9e770b..74b476a 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -1,4 +1,5 @@ extends includes/template.pug +include includes/default-roles-list.pug mixin badge-readonly .s-badge.s-badge__xs.s-badge__icon.s-badge__muted @@ -76,7 +77,7 @@ block body if space_id h2.mt48.fs-headline1 Server settings - h3.mt32.fs-category Privacy level + h3.mt32.fs-category How Matrix users join span#privacy-level-loading .s-card form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") @@ -105,6 +106,24 @@ block body p.s-description.m0 Shareable invite links, like Discord p.s-description.m0 Publicly listed in directory, like Discord server discovery + h3.mt32.fs-category Default roles + .s-card + form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles + input(type="hidden" name="guild_id" value=guild_id) + .d-flex.fw-wrap.g4 + .s-tag.s-tag__md.fs-body1.s-tag__required @everyone + + +default-roles-list(guild, guild_id) + + button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0 + .s-tag--dismiss.m1 + != icons.Icons.IconPlusSm + + #role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible + .s-popover--arrow.s-popover--arrow__tc + +add-roles-menu(guild, guild_id) + p.fc-medium.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate. + h3.mt32.fs-category Features .s-card.d-grid.px0.g16 form.d-flex.ai-center.g16 diff --git a/src/web/pug/includes/default-roles-list.pug b/src/web/pug/includes/default-roles-list.pug new file mode 100644 index 0000000..8c0a4e0 --- /dev/null +++ b/src/web/pug/includes/default-roles-list.pug @@ -0,0 +1,19 @@ +mixin default-roles-list(guild, guild_id) + #default-roles-list(style="display: contents") + each roleID in select("role_default", "role_id", {guild_id}).pluck().all() + - let r = guild.roles.find(r => r.id === roleID) + if r + .s-tag.s-tag__md.fs-body1= r.name + span(id=`role-loading-${roleID}`) + button(name="remove_role" value=roleID hx-post="api/default-roles" hx-trigger="click consume" hx-indicator=`#role-loading-${roleID}`).s-tag--dismiss + != icons.Icons.IconClearSm + +mixin add-roles-menu(guild, guild_id) + ul.s-menu(role="menu" hx-swap-oob="true").overflow-y-auto.overflow-x-hidden#add-roles-menu + li.s-menu--title.d-flex(role="separator") Select role + span#add-role-loading + each r in guild.roles.sort((a, b) => b.position - a.position) + if r.id !== guild_id && !r.managed + - let selected = !!select("role_default", "role_id", {guild_id, role_id: r.id}).get() + li(role="menuitem") + button(name="toggle_role" value=r.id class={"is-selected": selected}).s-block-link.s-block-link__left= r.name diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index 9fe80aa..278a16a 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -91,6 +91,19 @@ html(lang="en") .s-btn__dropdown:has(+ :popover-open) { background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important; } + .s-btn__dropdown.s-tag:has(+ :popover-open) .s-tag--dismiss { + background-color: var(--black-500) !important; + color: var(--black-150) !important; + } + .s-tag .is-loading { + margin-right: -4px; + } + .s-tag .is-loading + .s-tag--dismiss { + display: none !important; + } + a.s-block-link, .s-block-link { + --_bl-bs-color: var(--green-400); + } @media (prefers-color-scheme: dark) { body.theme-system .s-popover { --_po-bg: var(--black-100); @@ -141,11 +154,15 @@ html(lang="en") //- Guild list popover script. document.querySelectorAll("[popovertarget]").forEach(e => { - e.addEventListener("click", () => { - const rect = e.getBoundingClientRect() - const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }` + const target = document.getElementById(e.getAttribute("popovertarget")) + e.addEventListener("click", calculate) + target.addEventListener("toggle", calculate) + function calculate() { + const buttonRect = e.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const t = `:popover-open { position: absolute; top: ${Math.floor(buttonRect.bottom + window.scrollY)}px; left: ${Math.floor(Math.max(targetRect.width / 2, buttonRect.left + buttonRect.width / 2))}px; width: ${Math.floor(buttonRect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }` document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length) - }) + } }) //- Prevent default script. diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js index 63dd3ec..62a28a1 100644 --- a/src/web/routes/guild-settings.js +++ b/src/web/routes/guild-settings.js @@ -4,10 +4,12 @@ const assert = require("assert/strict") const {z} = require("zod") const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3") -const {as, db, sync, select} = require("../../passthrough") +const {as, db, sync, select, discord} = require("../../passthrough") /** @type {import("../auth")} */ const auth = sync.require("../auth") +/** @type {import("../pug-sync")} */ +const pugSync = sync.require("../pug-sync") /** @type {import("../../d2m/actions/set-presence")} */ const setPresence = sync.require("../../d2m/actions/set-presence") @@ -20,6 +22,14 @@ function getCreateSpace(event) { return event.context.createSpace || sync.require("../../d2m/actions/create-space") } +const schema = { + defaultRoles: z.object({ + guild_id: z.string(), + toggle_role: z.string().optional(), + remove_role: z.string().optional() + }) +} + /** * @typedef Options * @prop {(value: string?) => number} transform @@ -94,3 +104,36 @@ as.router.post("/api/privacy-level", defineToggle("privacy_level", { await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request } })) + +as.router.post("/api/default-roles", defineEventHandler(async event => { + const parsedBody = await readValidatedBody(event, schema.defaultRoles.parse) + + const managed = await auth.getManagedGuilds(event) + const guildID = parsedBody.guild_id + if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"}) + + const roleID = parsedBody.toggle_role || parsedBody.remove_role + assert(roleID) + assert.notEqual(guildID, roleID) // the @everyone role is always default + + const guild = discord.guilds.get(guildID) + assert(guild) + + let shouldRemove = !!parsedBody.remove_role + if (!shouldRemove) { + shouldRemove = !!select("role_default", "role_id", {guild_id: guildID, role_id: roleID}).get() + } + + if (shouldRemove) { + db.prepare("DELETE FROM role_default WHERE guild_id = ? AND role_id = ?").run(guildID, roleID) + } else { + assert(guild.roles.find(r => r.id === roleID)) + db.prepare("INSERT OR IGNORE INTO role_default (guild_id, role_id) VALUES (?, ?)").run(guildID, roleID) + } + + if (getRequestHeader(event, "HX-Request")) { + return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID}) + } else { + return sendRedirect(event, "", 302) + } +})) From f7a5b2d74cedd132d6ae12b8e0e00d8f0c2b72ee Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 8 Mar 2026 22:36:05 +1300 Subject: [PATCH 048/111] Update tryToCatch dependency and usages --- package-lock.json | 20 +++++--------------- package.json | 2 +- src/d2m/converters/user-to-mxid.test.js | 2 +- src/matrix/read-registration.test.js | 2 +- src/web/routes/download-discord.test.js | 2 +- src/web/routes/download-matrix.test.js | 2 +- src/web/routes/guild-settings.test.js | 2 +- src/web/routes/guild.test.js | 2 +- src/web/routes/link.test.js | 2 +- src/web/routes/log-in-with-matrix.test.js | 2 +- src/web/routes/oauth.test.js | 2 +- src/web/routes/password.test.js | 2 +- 12 files changed, 16 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4e6ecd4..e154da1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "sharp": "^0.34.5", "snowtransfer": "^0.17.5", "stream-mime-type": "^1.0.2", - "try-to-catch": "^3.0.1", + "try-to-catch": "^4.0.5", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", "zod": "^4.0.17" @@ -2756,16 +2756,6 @@ "node": ">=22" } }, - "node_modules/supertape/node_modules/try-to-catch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.0.5.tgz", - "integrity": "sha512-VKBslDQsy4pGj2TMNgDdskWb7AWSi/9dPEmcNv3sdL0+aRMQTPJo6aEqlcuN0vbOwFfsE1oAXmx3bFdf6vrJFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22" - } - }, "node_modules/supertape/node_modules/yargs-parser": { "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", @@ -2915,12 +2905,12 @@ } }, "node_modules/try-to-catch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-3.0.1.tgz", - "integrity": "sha512-hOY83V84Hx/1sCzDSaJA+Xz2IIQOHRvjxzt+F0OjbQGPZ6yLPLArMA0gw/484MlfUkQbCpKYMLX3VDCAjWKfzQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.0.5.tgz", + "integrity": "sha512-VKBslDQsy4pGj2TMNgDdskWb7AWSi/9dPEmcNv3sdL0+aRMQTPJo6aEqlcuN0vbOwFfsE1oAXmx3bFdf6vrJFg==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=22" } }, "node_modules/tslib": { diff --git a/package.json b/package.json index 71639b0..0d0c2b6 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "sharp": "^0.34.5", "snowtransfer": "^0.17.5", "stream-mime-type": "^1.0.2", - "try-to-catch": "^3.0.1", + "try-to-catch": "^4.0.5", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", "zod": "^4.0.17" diff --git a/src/d2m/converters/user-to-mxid.test.js b/src/d2m/converters/user-to-mxid.test.js index f8cf16a..b020e15 100644 --- a/src/d2m/converters/user-to-mxid.test.js +++ b/src/d2m/converters/user-to-mxid.test.js @@ -1,5 +1,5 @@ const {test} = require("supertape") -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const assert = require("assert") const data = require("../../../test/data") const {userToSimName, webhookAuthorToSimName} = require("./user-to-mxid") diff --git a/src/matrix/read-registration.test.js b/src/matrix/read-registration.test.js index 5fb3b55..a8dcc25 100644 --- a/src/matrix/read-registration.test.js +++ b/src/matrix/read-registration.test.js @@ -1,6 +1,6 @@ // @ts-check -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {test} = require("supertape") const {reg, checkRegistration, getTemplateRegistration} = require("./read-registration") diff --git a/src/web/routes/download-discord.test.js b/src/web/routes/download-discord.test.js index e4f4ab4..0d4b884 100644 --- a/src/web/routes/download-discord.test.js +++ b/src/web/routes/download-discord.test.js @@ -1,7 +1,7 @@ // @ts-check const assert = require("assert").strict -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {test} = require("supertape") const {router} = require("../../../test/web") const {_cache} = require("./download-discord") diff --git a/src/web/routes/download-matrix.test.js b/src/web/routes/download-matrix.test.js index ccbcfdd..610a62d 100644 --- a/src/web/routes/download-matrix.test.js +++ b/src/web/routes/download-matrix.test.js @@ -2,7 +2,7 @@ const fs = require("fs") const {convertImageStream} = require("../../m2d/converters/emoji-sheet") -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {test} = require("supertape") const {router} = require("../../../test/web") const streamWeb = require("stream/web") diff --git a/src/web/routes/guild-settings.test.js b/src/web/routes/guild-settings.test.js index fccc266..00acb89 100644 --- a/src/web/routes/guild-settings.test.js +++ b/src/web/routes/guild-settings.test.js @@ -1,6 +1,6 @@ // @ts-check -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {router, test} = require("../../../test/web") const {select} = require("../../passthrough") const {MatrixServerError} = require("../../matrix/mreq") diff --git a/src/web/routes/guild.test.js b/src/web/routes/guild.test.js index aa17548..06b604b 100644 --- a/src/web/routes/guild.test.js +++ b/src/web/routes/guild.test.js @@ -1,7 +1,7 @@ // @ts-check const DiscordTypes = require("discord-api-types/v10") -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {router, test} = require("../../../test/web") const {MatrixServerError} = require("../../matrix/mreq") const {_getPosition} = require("./guild") diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index e8473f8..70299d5 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -1,6 +1,6 @@ // @ts-check -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {router, test} = require("../../../test/web") const {MatrixServerError} = require("../../matrix/mreq") const {select, db} = require("../../passthrough") diff --git a/src/web/routes/log-in-with-matrix.test.js b/src/web/routes/log-in-with-matrix.test.js index 830556e..2f9afcc 100644 --- a/src/web/routes/log-in-with-matrix.test.js +++ b/src/web/routes/log-in-with-matrix.test.js @@ -1,6 +1,6 @@ // @ts-check -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {router, test} = require("../../../test/web") const {MatrixServerError} = require("../../matrix/mreq") diff --git a/src/web/routes/oauth.test.js b/src/web/routes/oauth.test.js index 2f3a791..1a06e39 100644 --- a/src/web/routes/oauth.test.js +++ b/src/web/routes/oauth.test.js @@ -1,7 +1,7 @@ // @ts-check const DiscordTypes = require("discord-api-types/v10") -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const assert = require("assert/strict") const {router, test} = require("../../../test/web") diff --git a/src/web/routes/password.test.js b/src/web/routes/password.test.js index aa60bd3..fca4e70 100644 --- a/src/web/routes/password.test.js +++ b/src/web/routes/password.test.js @@ -1,6 +1,6 @@ // @ts-check -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {test} = require("supertape") const {router} = require("../../../test/web") From cd8549da387cf365e2b29c13601f1671a6825e40 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 8 Mar 2026 23:32:36 +1300 Subject: [PATCH 049/111] Fix sticker tests and coverage --- src/d2m/converters/message-to-event.js | 10 ++-- src/m2d/converters/event-to-message.test.js | 64 +++------------------ src/matrix/utils.js | 13 ----- src/web/routes/guild-settings.js | 2 +- 4 files changed, 13 insertions(+), 76 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 7f77b81..b1af2e3 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -582,7 +582,8 @@ async function messageToEvent(message, guild, options = {}, di) { // check that condition 1 or 2 is met if (repliedToEventInDifferentRoom || repliedToUnknownEvent) { let referenced = message.referenced_message - if (!referenced) { // backend couldn't be bothered to dereference the message, have to do it ourselves + /* c8 ignore next 4 - backend couldn't be bothered to dereference the message, have to do it ourselves */ + if (!referenced) { assert(message.message_reference?.message_id) referenced = await discord.snow.channel.getChannelMessage(message.message_reference.channel_id, message.message_reference.message_id) } @@ -905,11 +906,8 @@ async function messageToEvent(message, guild, options = {}, di) { 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.msb.add(`[${component.label} ${component.url}] `, tag`${component.label} `) - } else { - stack.msb.add(component.url) - } + assert(component.label) // required for Discord to validate link buttons + stack.msb.add(`[${component.label} ${component.url}] `, tag`${component.label} `) } } diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 629f2b8..aa426cd 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -4747,17 +4747,17 @@ test("event2message: stickers work", async t => { messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "", + content: "[get_real2](https://bridge.example.org/download/sticker/cadence.moe/NyMXQFAAdniImbHzsygScbmN/_.webp)", avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", filename: "get_real2.gif"}], - pendingFiles: [{name: "get_real2.gif", mxc: "mxc://cadence.moe/NyMXQFAAdniImbHzsygScbmN"}] + allowed_mentions: { + parse: ["users", "roles"] + } }] } ) }) test("event2message: stickers fetch mimetype from server when mimetype not provided", async t => { - let called = 0 t.deepEqual( await eventToMessage({ type: "m.sticker", @@ -4768,20 +4768,6 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi }, event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0", room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }, {}, {}, { - api: { - async getMedia(mxc, options) { - called++ - t.equal(mxc, "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf") - t.equal(options.method, "HEAD") - return { - status: 200, - headers: new Map([ - ["content-type", "image/gif"] - ]) - } - } - } }), { ensureJoined: [], @@ -4789,48 +4775,14 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "", + content: "[YESYESYES](https://bridge.example.org/download/sticker/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf/_.webp)", avatar_url: undefined, - attachments: [{id: "0", filename: "YESYESYES.gif"}], - pendingFiles: [{name: "YESYESYES.gif", mxc: "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf"}] + allowed_mentions: { + parse: ["users", "roles"] + } }] } ) - t.equal(called, 1, "sticker headers should be fetched") -}) - -test("event2message: stickers with unknown mimetype are not allowed", async t => { - let called = 0 - try { - await eventToMessage({ - type: "m.sticker", - sender: "@cadence:cadence.moe", - content: { - body: "something", - url: "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJe" - }, - event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }, {}, {}, { - api: { - async getMedia(mxc, options) { - called++ - t.equal(mxc, "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJe") - t.equal(options.method, "HEAD") - return { - status: 404, - headers: new Map([ - ["content-type", "application/json"] - ]) - } - } - } - }) - /* c8 ignore next */ - t.fail("should throw an error") - } catch (e) { - t.match(e.toString(), "mimetype") - } }) test("event2message: static emojis work", async t => { diff --git a/src/matrix/utils.js b/src/matrix/utils.js index 9f5cb0f..eee635b 100644 --- a/src/matrix/utils.js +++ b/src/matrix/utils.js @@ -225,19 +225,6 @@ async function getViaServersQuery(roomID, api) { return qs } -function generatePermittedMediaHash(mxc) { - assert(hasher, "xxhash is not ready yet") - const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) - if (!mediaParts) return undefined - - const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}` - const unsignedHash = hasher.h64(serverAndMediaID) - const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range - db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) - - return serverAndMediaID -} - /** * Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL * because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge. diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js index 62a28a1..ae52825 100644 --- a/src/web/routes/guild-settings.js +++ b/src/web/routes/guild-settings.js @@ -134,6 +134,6 @@ as.router.post("/api/default-roles", defineEventHandler(async event => { if (getRequestHeader(event, "HX-Request")) { return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID}) } else { - return sendRedirect(event, "", 302) + return sendRedirect(event, `/guild?guild_id=${guildID}`, 302) } })) From f5ee130463e9a2157a58c840794d669d536d595f Mon Sep 17 00:00:00 2001 From: Bea Date: Sun, 8 Mar 2026 22:11:28 +0000 Subject: [PATCH 050/111] Handle expired invites & fix test registration (#73) This PR addresses a bridge crash discovered while backfilling old channels, alongside a wee QoL fix for the test suite. * **Expired Events (`d2m`):** Wraps Discord scheduled event/invite link lookups in a try-catch block. If a link is expired (404 or Discord error 10006), the bridge now posts a fallback `m.notice` rather than throwing an error and halting message conversion. * **Test Suite Setup:** Updates `test.js` to initialize the mock registration object using `getTemplateRegistration()` preventing test runner crashes when running without a local `registration.yaml` file. Co-authored-by: Cadence Ember Reviewed-on: https://gitdab.com/cadence/out-of-your-element/pulls/73 Co-authored-by: Bea Co-committed-by: Bea --- src/d2m/converters/message-to-event.js | 13 +++++++- src/d2m/converters/message-to-event.test.js | 22 +++++++++++++ test/test.js | 36 ++++++++++----------- 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index b1af2e3..81c821f 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -770,7 +770,18 @@ async function messageToEvent(message, guild, options = {}, di) { // Then scheduled events if (message.content && di?.snow) { for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old - const invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) + let invite + try { + invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) + } catch (e) { + // Skip expired/invalid invites and events + if (e.message === `{"message": "Unknown Invite", "code": 10006}`) { + break + } else { + throw e + } + } + const event = invite.guild_scheduled_event if (!event) continue // the event ID provided was not valid diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 1a73aea..f071417 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1538,6 +1538,28 @@ test("message2event: vc invite event renders embed with room link", async t => { ]) }) +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({ diff --git a/test/test.js b/test/test.js index e05b687..da6bcba 100644 --- a/test/test.js +++ b/test/test.js @@ -6,31 +6,29 @@ const sqlite = require("better-sqlite3") const {Writable} = require("stream") const migrate = require("../src/db/migrate") const HeatSync = require("heatsync") -const {test, extend} = require("supertape") +const {test} = require("supertape") const data = require("./data") const {green} = require("ansi-colors") +const mixin = require("@cloudrac3r/mixin-deep") const passthrough = require("../src/passthrough") const db = new sqlite(":memory:") -const {reg} = require("../src/matrix/read-registration") -reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby" -reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded -reg.ooye.server_name = "cadence.moe" -reg.ooye.namespace_prefix = "_ooye_" -reg.sender_localpart = "_ooye_bot" -reg.id = "baby" -reg.as_token = "don't actually take authenticated actions on the server" -reg.hs_token = "don't actually take authenticated actions on the server" -reg.namespaces = { - users: [{regex: "@_ooye_.*:cadence.moe", exclusive: true}], - aliases: [{regex: "#_ooye_.*:cadence.moe", exclusive: true}] -} -reg.ooye.bridge_origin = "https://bridge.example.org" -reg.ooye.time_zone = "Pacific/Auckland" -reg.ooye.max_file_size = 5000000 -reg.ooye.web_password = "password123" -reg.ooye.include_user_id_in_mxid = false +const registration = require("../src/matrix/read-registration") +registration.reg = mixin(registration.getTemplateRegistration("cadence.moe"), { + id: "baby", + url: "http://localhost:6693", + as_token: "don't actually take authenticated actions on the server", + hs_token: "don't actually take authenticated actions on the server", + ooye: { + server_origin: "https://matrix.cadence.moe", + bridge_origin: "https://bridge.example.org", + discord_token: "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby", + discord_client_secret: "baby", + web_password: "password123", + time_zone: "Pacific/Auckland", + } +}) const sync = new HeatSync({watchFS: false}) From ada3933d9cb3357d2f9cb0b46e2aa00bd3450613 Mon Sep 17 00:00:00 2001 From: Bea Date: Mon, 9 Mar 2026 00:22:41 +0000 Subject: [PATCH 051/111] Backfill: Create new rooms when needed This updates the backfill script to attempt to create rooms for unbridged rooms, rather than bombing out that the room isn't already bridged. Co-authored-by: Cadence Ember Reviewed-on: https://gitdab.com/cadence/out-of-your-element/pulls/75 Co-authored-by: Bea Co-committed-by: Bea --- scripts/backfill.js | 45 +++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/scripts/backfill.js b/scripts/backfill.js index 27600f0..941e803 100644 --- a/scripts/backfill.js +++ b/scripts/backfill.js @@ -10,7 +10,6 @@ if (!channelID) { process.exit(1) } -const assert = require("assert/strict") const sqlite = require("better-sqlite3") const backfill = new sqlite("scripts/backfill.db") backfill.prepare("CREATE TABLE IF NOT EXISTS backfill (channel_id TEXT NOT NULL, message_id INTEGER NOT NULL, PRIMARY KEY (channel_id, message_id))").run() @@ -38,12 +37,8 @@ passthrough.select = orm.select /** @type {import("../src/d2m/event-dispatcher")}*/ const eventDispatcher = sync.require("../src/d2m/event-dispatcher") - -const roomID = passthrough.select("channel_room", "room_id", {channel_id: channelID}).pluck().get() -if (!roomID) { - console.error("Please choose a channel that's already bridged.") - process.exit(1) -} +/** @type {import("../src/d2m/actions/create-room")} */ +const createRoom = sync.require("../src/d2m/actions/create-room") ;(async () => { await discord.cloud.connect() @@ -60,23 +55,29 @@ async function event(event) { if (!channel) return const guild_id = event.d.id - let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0" - console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`) + 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" + console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`) - while (last) { - const messages = await discord.snow.channel.getChannelMessages(channelID, {limit: 50, after: String(last)}) - messages.reverse() // More recent messages come first -> More recent messages come last - for (const message of messages) { - const simulatedGatewayDispatchData = { - guild_id, - backfill: true, - ...message + while (last) { + const messages = await discord.snow.channel.getChannelMessages(channelID, {limit: 50, after: String(last)}) + messages.reverse() // More recent messages come first -> More recent messages come last + for (const message of messages) { + const simulatedGatewayDispatchData = { + guild_id, + backfill: true, + ...message + } + await eventDispatcher.MESSAGE_CREATE(discord, simulatedGatewayDispatchData) + preparedInsert.run(channelID, message.id) } - await eventDispatcher.MESSAGE_CREATE(discord, simulatedGatewayDispatchData) - preparedInsert.run(channelID, message.id) + last = messages.at(-1)?.id } - last = messages.at(-1)?.id - } - process.exit() + process.exit() + } catch (e) { + console.error(e) + process.exit(1) // won't exit automatically on thrown error due to living discord connection, so manual exit is necessary + } } From 6ca1b836e1e6bf21e09d4217f2373e7d82e4b081 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 11 Mar 2026 12:38:05 +1300 Subject: [PATCH 052/111] Add more debugging information --- src/d2m/discord-client.js | 6 +++++- src/matrix/file.js | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index 7b0fcf8..c2a0549 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -52,7 +52,11 @@ class DiscordClient { /** @type {Map>} */ this.guildChannelMap = new Map() if (listen !== "no") { - this.cloud.on("event", message => discordPackets.onPacket(this, message, listen)) + this.cloud.on("event", message => { + process.nextTick(() => { + discordPackets.onPacket(this, message, listen) + }) + }) } const addEventLogger = (eventName, logName) => { diff --git a/src/matrix/file.js b/src/matrix/file.js index 7bc1fec..c469aea 100644 --- a/src/matrix/file.js +++ b/src/matrix/file.js @@ -85,6 +85,7 @@ async function _actuallyUploadDiscordFileToMxc(url) { writeRegistration(reg) return root } + e.uploadURL = url throw e } } From 5f768fee01ee2ffcbd879f635fb6bc7c410dd169 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 12 Mar 2026 16:23:22 +1300 Subject: [PATCH 053/111] d->m: Don't guess mentions in code blocks --- src/d2m/converters/edit-to-changes.test.js | 4 +- src/d2m/converters/find-mentions.js | 12 +++- src/d2m/converters/message-to-event.js | 68 ++++++++++++--------- src/d2m/converters/message-to-event.test.js | 25 ++++++-- 4 files changed, 70 insertions(+), 39 deletions(-) diff --git a/src/d2m/converters/edit-to-changes.test.js b/src/d2m/converters/edit-to-changes.test.js index cb1fb5a..842c24e 100644 --- a/src/d2m/converters/edit-to-changes.test.js +++ b/src/d2m/converters/edit-to-changes.test.js @@ -78,7 +78,7 @@ test("edit2changes: bot response", async t => { newContent: { $type: "m.room.message", msgtype: "m.text", - body: "* :ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", + body: "* :ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", format: "org.matrix.custom.html", formatted_body: '* :ae_botrac4r: @cadence asked ­, I respond: Stop drinking paint. (No)

Hit :bn_re: to reroll.', "m.mentions": { @@ -87,7 +87,7 @@ test("edit2changes: bot response", async t => { // *** Replaced With: *** "m.new_content": { msgtype: "m.text", - body: ":ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", + body: ":ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", format: "org.matrix.custom.html", formatted_body: ':ae_botrac4r: @cadence asked ­, I respond: Stop drinking paint. (No)

Hit :bn_re: to reroll.', "m.mentions": { diff --git a/src/d2m/converters/find-mentions.js b/src/d2m/converters/find-mentions.js index 8726830..8107459 100644 --- a/src/d2m/converters/find-mentions.js +++ b/src/d2m/converters/find-mentions.js @@ -146,10 +146,18 @@ function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) { // Highlight the relevant part of the message const start = baseOffset + best.scored.matchedInputTokens[0].index const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end - const newContent = content.slice(0, start) + "[" + content.slice(start, end) + "](https://matrix.to/#/" + best.mxid + ")" + content.slice(end) + const newNodes = [{ + type: "text", content: content.slice(0, start) + }, { + type: "link", target: `https://matrix.to/#/${best.mxid}`, content: [ + {type: "text", content: content.slice(start, end)} + ] + }, { + type: "text", content: content.slice(end) + }] return { mxid: best.mxid, - newContent + newNodes } } } diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 81c821f..05a376a 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -519,29 +519,60 @@ async function messageToEvent(message, guild, options = {}, di) { return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed })) - async function transformParsedVia(parsed) { - for (const node of parsed) { + async function transformParsedVia(parsed, scanTextForMentions) { + for (let n = 0; n < parsed.length; n++) { + const node = parsed[n] if (node.type === "discordChannel" || node.type === "discordChannelLink") { node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get() if (node.row?.room_id) { node.via = await getViaServersMemo(node.row.room_id) } } + else if (node.type === "text" && typeof node.content === "string") { + // Merge adjacent text nodes into this one + while (parsed[n+1]?.type === "text" && typeof parsed[n+1].content === "string") { + node.content += parsed[n+1].content + parsed.splice(n+1, 1) + } + // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention. + if (scanTextForMentions) { + let content = node.content + const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)] + for (let i = matches.length; i--;) { + const m = matches[i] + const prefix = m[1] + const maximumWrittenSection = m[2].toLowerCase() + if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it + if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here + + var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() + assert(roomID) + var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name}))) + + const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content) + if (found) { + addMention(found.mxid) + parsed.splice(n, 1, ...found.newNodes) + content = found.newNodes[0].content + } + } + } + } for (const maybeChildNodesArray of [node, node.content, node.items]) { if (Array.isArray(maybeChildNodesArray)) { - await transformParsedVia(maybeChildNodesArray) + await transformParsedVia(maybeChildNodesArray, scanTextForMentions && ["blockQuote", "list", "paragraph", "em", "strong", "u", "del", "text"].includes(node.type)) } } } return parsed } - let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, { + let html = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, customOptions.isTheMessageContent && options.scanTextForMentions !== false), { discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers), ...customOptions }, customParser, customHtmlOutput) - let body = await markdown.toHtmlWithPostParser(content, transformParsedVia, { + let body = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, false), { // not scanning plaintext body for mentions as we don't parse whether they're in code discordCallback: getDiscordParseCallbacks(message, guild, false), discordOnly: true, escapeHTML: false, @@ -735,35 +766,12 @@ async function messageToEvent(message, guild, options = {}, di) { // Then text content if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) { - // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention. - let content = message.content - if (options.scanTextForMentions !== false) { - const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)] - for (let i = matches.length; i--;) { - const m = matches[i] - const prefix = m[1] - const maximumWrittenSection = m[2].toLowerCase() - if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it - if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here - - var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() - assert(roomID) - var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name}))) - - const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content) - if (found) { - addMention(found.mxid) - content = found.newContent - } - } - } - // Scan the content for emojihax and replace them with real emojis - content = content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => { + let content = message.content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => { return `<:${name}:${id}>` }) - const {body, html} = await transformContent(content) + const {body, html} = await transformContent(content, {isTheMessageContent: true}) await addTextEvent(body, html, msgtype) } diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index f071417..4728c2b 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -789,7 +789,7 @@ test("message2event: simple written @mention for matrix user", async t => { ] }, msgtype: "m.text", - body: "[@ash](https://matrix.to/#/@she_who_brings_destruction:cadence.moe) do you need anything from the store btw as I'm heading there after gym", + body: "@ash do you need anything from the store btw as I'm heading there after gym", format: "org.matrix.custom.html", formatted_body: `@ash do you need anything from the store btw as I'm heading there after gym` }]) @@ -838,7 +838,7 @@ test("message2event: many written @mentions for matrix users", async t => { ] }, msgtype: "m.text", - body: "[@Cadence](https://matrix.to/#/@cadence:cadence.moe), tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and [@huck](https://matrix.to/#/@huckleton:cadence.moe)", + body: "@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck", format: "org.matrix.custom.html", formatted_body: `@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck` }]) @@ -890,7 +890,7 @@ test("message2event: written @mentions may match part of the name", async t => { ] }, msgtype: "m.text", - body: "I wonder if [@cadence](https://matrix.to/#/@secret:cadence.moe) saw this?", + body: "I wonder if @cadence saw this?", format: "org.matrix.custom.html", formatted_body: `I wonder if @cadence saw this?` }]) @@ -941,7 +941,7 @@ test("message2event: written @mentions may match part of the mxid", async t => { ] }, msgtype: "m.text", - body: "I wonder if [@huck](https://matrix.to/#/@huckleton:cadence.moe) saw this?", + body: "I wonder if @huck saw this?", format: "org.matrix.custom.html", formatted_body: `I wonder if @huck saw this?` }]) @@ -962,6 +962,21 @@ test("message2event: written @mentions do not match in URLs", async t => { }]) }) +test("message2event: written @mentions do not match in inline code", async t => { + const events = await messageToEvent({ + ...data.message.advanced_written_at_mention_for_matrix, + content: "`public @Nullable EntityType`" + }, data.guild.general, {}, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": {}, + msgtype: "m.text", + body: "`public @Nullable EntityType`", + 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({ @@ -1007,7 +1022,7 @@ test("message2event: entire message may match elaborate display name", async t = ] }, msgtype: "m.text", - body: "[@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆](https://matrix.to/#/@wa:cadence.moe)", + 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 ☆` }]) From 99f4c52bebeb514c45a529e06abafefb44790f78 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 13 Mar 2026 10:17:04 +1300 Subject: [PATCH 054/111] Fix attempting to follow an upgrade path twice --- src/matrix/room-upgrade.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/matrix/room-upgrade.js b/src/matrix/room-upgrade.js index 5a2606e..e7de906 100644 --- a/src/matrix/room-upgrade.js +++ b/src/matrix/room-upgrade.js @@ -54,17 +54,17 @@ async function onBotMembership(event, api, createRoom) { assert.equal(event.type, "m.room.member") assert.equal(event.state_key, utils.bot) - // Check if an upgrade is pending for this room - const newRoomID = event.room_id - const oldRoomID = select("room_upgrade_pending", "old_room_id", {new_room_id: newRoomID}).pluck().get() - if (!oldRoomID) return false - const channelRow = from("channel_room").join("guild_space", "guild_id").where({room_id: oldRoomID}).select("space_id", "guild_id", "channel_id").get() - assert(channelRow) // this could only fail if the channel was unbridged or something between upgrade and joining - - // Check if is join/invite - if (event.content.membership !== "invite" && event.content.membership !== "join") return false - return await roomUpgradeSema.request(async () => { + // Check if an upgrade is pending for this room + const newRoomID = event.room_id + const oldRoomID = select("room_upgrade_pending", "old_room_id", {new_room_id: newRoomID}).pluck().get() + if (!oldRoomID) return false + const channelRow = from("channel_room").join("guild_space", "guild_id").where({room_id: oldRoomID}).select("space_id", "guild_id", "channel_id").get() + assert(channelRow) // this could only fail if the channel was unbridged or something between upgrade and joining + + // Check if is join/invite + if (event.content.membership !== "invite" && event.content.membership !== "join") return false + // If invited, join if (event.content.membership === "invite") { await api.joinRoom(newRoomID) From ff022e8793dc32420fda889035d9188112947233 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 13 Mar 2026 11:12:44 +1300 Subject: [PATCH 055/111] Combine additional embed images into same event --- src/d2m/converters/message-to-event.js | 45 ++++-- .../message-to-event.test.embeds.js | 38 +++++ src/d2m/converters/message-to-event.test.js | 2 +- test/data.js | 135 ++++++++++++++++++ 4 files changed, 209 insertions(+), 11 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 05a376a..adc56e6 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -261,6 +261,29 @@ function getFormattedInteraction(interaction, isThinkingInteraction) { } } +/** + * @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 + */ +function mergeTextEvents(newEvents, events, forceSameMsgtype) { + 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 { + events.push(ne) + } + } +} + /** * @param {DiscordTypes.APIMessage} message * @param {DiscordTypes.APIGuild} guild @@ -835,15 +858,7 @@ async function messageToEvent(message, guild, options = {}, di) { // 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. - let prev = events.at(-1) - for (const atch of attachmentEvents) { - if (atch.msgtype === "m.text" && prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(prev?.msgtype)) { - prev.body = prev.body + "\n" + atch.body - prev.formatted_body = prev.formatted_body + "
" + atch.formatted_body - } else { - events.push(atch) - } - } + mergeTextEvents(attachmentEvents, events, false) } // Then components @@ -981,6 +996,7 @@ async function messageToEvent(message, guild, options = {}, di) { // 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) @@ -1047,7 +1063,11 @@ 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(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`) + + if (chosenImage) { + isAdditionalImage = !rep.body && !!events.length + rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`) + } if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`) @@ -1056,6 +1076,11 @@ async function messageToEvent(message, guild, options = {}, di) { 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") } diff --git a/src/d2m/converters/message-to-event.test.embeds.js b/src/d2m/converters/message-to-event.test.embeds.js index 259aa66..73244d3 100644 --- a/src/d2m/converters/message-to-event.test.embeds.js +++ b/src/d2m/converters/message-to-event.test.embeds.js @@ -204,6 +204,44 @@ test("message2event embeds: author url without name", async t => { }]) }) +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", + msgtype: "m.text", + body: "[🔀 Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046", + format: "org.matrix.custom.html", + formatted_body: "🔀 Forwarded message
https://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", + format: "org.matrix.custom.html", + 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": {} + }]) +}) + test("message2event embeds: vx image", async t => { const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general) t.deepEqual(events, [{ diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 4728c2b..e805365 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1099,7 +1099,7 @@ test("message2event: multiple attachments are combined into the same event where formatted_body: "hey" + `
📄 Uploaded file: hey.jpg (100 MB)` + `
📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg (38 KB)
` - + `
📄 Uploaded file: hey.jpg (100 MB)` + + `📄 Uploaded file: hey.jpg (100 MB)` }, { $type: "m.room.message", "m.mentions": {}, diff --git a/test/data.js b/test/data.js index 6a53cb0..46e8b0f 100644 --- a/test/data.js +++ b/test/data.js @@ -5067,6 +5067,141 @@ module.exports = { pinned: false, mention_everyone: false, tts: false + }, + four_images: { + type: 0, + content: "", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2026-03-12T18:00:50.737000+00:00", + edited_timestamp: null, + flags: 16384, + components: [], + id: "1481713598278533241", + channel_id: "687028734322147344", + author: { + id: "112760500130975744", + username: "minimus", + avatar: "a_a354b9eaff512485b49c82b13691b941", + discriminator: "0", + public_flags: 512, + flags: 512, + banner: null, + accent_color: null, + global_name: "minimus", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: { font_id: 11, effect_id: 5, colors: [ 6106655 ] }, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + message_reference: { + type: 1, + channel_id: "637339857118822430", + message_id: "1481696763483258891", + guild_id: "408573045540651009" + }, + message_snapshots: [ + { + message: { + type: 0, + content: "https://fixupx.com/i/status/2032003668787020046", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [ + { + type: "rich", + url: "https://fixupx.com/i/status/2032003668787020046", + description: "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 **", + color: 6513919, + timestamp: "2026-03-12T08:00:02+00:00", + author: { + name: "AUTOMATON WEST (@AUTOMATON_ENG)", + url: "https://x.com/AUTOMATON_ENG/status/2032003668787020046", + icon_url: "https://pbs.twimg.com/profile_images/1353559126693961729/pz-WVnDc_200x200.jpg", + proxy_icon_url: "https://images-ext-1.discordapp.net/external/1OzGhjvZTRstTxM38_7pqHXlmdbMddqh1F8R0-WrKqw/https/pbs.twimg.com/profile_images/1353559126693961729/pz-WVnDc_200x200.jpg" + }, + image: { + url: "https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig", + proxy_url: "https://images-ext-1.discordapp.net/external/NkNgp2SyY1OCH9IdS8hqsUqbnbrp3A9oLNwYusVVCVQ/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg", + width: 872, + height: 886, + content_type: "image/jpeg", + placeholder: "6vcFFwL6R3lye2V3l1mIl5l3WPN5FZ8H", + placeholder_version: 1, + flags: 0 + }, + footer: { + text: "FixupX", + icon_url: "https://assets.fxembed.com/logos/fixupx64.png", + proxy_icon_url: "https://images-ext-1.discordapp.net/external/LwQ70Uiqfu0OCN4ZbA4f482TGCgQa-xGsnUFYfhIgYA/https/assets.fxembed.com/logos/fixupx64.png" + }, + content_scan_version: 4 + }, + { + type: "rich", + url: "https://fixupx.com/i/status/2032003668787020046", + image: { + url: "https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig", + proxy_url: "https://images-ext-1.discordapp.net/external/Rquh1ec-tG9hMqdHqIVSphO7zf5B5Fg_7yTWhCjlsek/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg", + width: 1114, + height: 991, + content_type: "image/jpeg", + placeholder: "JQgKDoL3epZ8ZIdnlmmHZ4d4CIGmUEc=", + placeholder_version: 1, + flags: 0 + }, + content_scan_version: 4 + }, + { + type: "rich", + url: "https://fixupx.com/i/status/2032003668787020046", + image: { + url: "https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig", + proxy_url: "https://images-ext-1.discordapp.net/external/XrkhHNH3CvlZYvjkdykVnf-_xdz6HWX8uwesoAwwSfY/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUrPobgAAeb90.jpg", + width: 944, + height: 954, + content_type: "image/jpeg", + placeholder: "m/cJDwCbV0mfaoZzlihqeXdqCVN9A6oD", + placeholder_version: 1, + flags: 0 + }, + content_scan_version: 4 + }, + { + type: "rich", + url: "https://fixupx.com/i/status/2032003668787020046", + image: { + url: "https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig", + proxy_url: "https://images-ext-1.discordapp.net/external/lO-5hBMU9bGH13Ax9xum2T2Mg0ATdv0b6BEx_VeVi80/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg", + width: 1200, + height: 630, + content_type: "image/jpeg", + placeholder: "tfcJDIK3mIl1eIiPdY23dX9b9w==", + placeholder_version: 1, + flags: 0 + }, + content_scan_version: 4 + } + ], + timestamp: "2026-03-12T16:53:57.009000+00:00", + edited_timestamp: null, + flags: 0, + components: [] + } + } + ] } }, message_with_components: { From f90cdfdbb5442cb0c262137ca207ebd2cc2c3b7d Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 14 Mar 2026 14:25:48 +1300 Subject: [PATCH 056/111] Update dependencies, make stream-type independent --- docs/developer-orientation.md | 3 +- package-lock.json | 177 ++++---------------- package.json | 4 +- src/d2m/converters/message-to-event.test.js | 15 ++ src/m2d/actions/sticker.js | 8 +- src/m2d/converters/emoji-sheet.js | 18 +- 6 files changed, 62 insertions(+), 163 deletions(-) diff --git a/docs/developer-orientation.md b/docs/developer-orientation.md index dbb19f3..e17f263 100644 --- a/docs/developer-orientation.md +++ b/docs/developer-orientation.md @@ -89,7 +89,7 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin # Dependency justification -Total transitive production dependencies: 134 +Total transitive production dependencies: 121 ### 🦕 @@ -108,6 +108,7 @@ Total transitive production dependencies: 134 * (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE. * (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.) * (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs. +* (0) @cloudrac3r/stream-type: Determine type of Matrix files that don't specify it in info. Switched from stream-mime-type to this. * (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works. * (3) @stackoverflow/stacks: Stack Overflow design language and icons. * (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer. diff --git a/package-lock.json b/package-lock.json index e154da1..c93d483 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,14 @@ "license": "AGPL-3.0-or-later", "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.6.10", + "@cloudrac3r/discord-markdown": "^2.7.0", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/in-your-element": "^1.1.1", "@cloudrac3r/mixin-deep": "^3.0.1", "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", + "@cloudrac3r/stream-type": "^1.0.0", "@cloudrac3r/turndown": "^7.1.4", "@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks-icons": "^6.0.2", @@ -37,7 +38,6 @@ "prettier-bytes": "^1.0.4", "sharp": "^0.34.5", "snowtransfer": "^0.17.5", - "stream-mime-type": "^1.0.2", "try-to-catch": "^4.0.5", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", @@ -68,6 +68,22 @@ "ts-node": "^10.9.2" } }, + "../nodejs-stream-type": { + "name": "@cloudrac3r/stream-type", + "version": "1.0.0", + "license": "AGPL-3.0-only", + "devDependencies": { + "@cloudrac3r/tap-dot": "^2.0.3", + "@types/node": "^22.19.15", + "c8": "^11.0.0", + "cross-env": "^10.1.0", + "supertape": "^12.10.4", + "try-to-catch": "^4.0.5" + }, + "engines": { + "node": ">=22.6.0" + } + }, "../tap-dot": { "name": "@cloudrac3r/tap-dot", "version": "2.0.0", @@ -156,9 +172,9 @@ } }, "node_modules/@cloudrac3r/discord-markdown": { - "version": "2.6.10", - "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.10.tgz", - "integrity": "sha512-E+F9UYDUHP2kHDCciX63SBzgsUnHpu2Pp/h98x9Zo+vKuzXjCQ5PcFNdUlH6M18bvHDZPoIsKVmjnON8UYaAPQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.7.0.tgz", + "integrity": "sha512-1iR9tKI2WJe8UNB+4VSh7D8m6RP7ugByuf8RNWyJwyhIrSlqQ8ljY1BKXodSvDg7seZkf7B7V2t5FfK7UpTw/A==", "license": "MIT", "dependencies": { "simple-markdown": "^0.7.3" @@ -246,6 +262,10 @@ "pug-error": "^2.1.0" } }, + "node_modules/@cloudrac3r/stream-type": { + "resolved": "../nodejs-stream-type", + "link": true + }, "node_modules/@cloudrac3r/tap-dot": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@cloudrac3r/tap-dot/-/tap-dot-2.0.3.tgz", @@ -1027,11 +1047,6 @@ "node": ">=22" } }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -1616,22 +1631,6 @@ "node": ">= 4.9.1" } }, - "node_modules/file-type": { - "version": "16.5.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", - "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", - "dependencies": { - "readable-web-to-node-stream": "^3.0.0", - "strtok3": "^6.2.4", - "token-types": "^4.1.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -1654,10 +1653,11 @@ } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "dev": true, + "license": "ISC" }, "node_modules/foreground-child": { "version": "3.3.1", @@ -2183,18 +2183,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/peek-readable": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", - "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/prebuild-install": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", @@ -2362,34 +2350,6 @@ "dev": true, "license": "MIT" }, - "node_modules/readable-web-to-node-stream": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", - "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", - "dependencies": { - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/readable-web-to-node-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2625,30 +2585,6 @@ "get-source": "^2.0.12" } }, - "node_modules/stream-head": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/stream-head/-/stream-head-2.0.2.tgz", - "integrity": "sha512-aRkUMcmgbDl2Yjd5LqsB1LKB58Ot3JZ4ffuFMkFuvkPQT5X5XFMr4YK2dctApc+d3o52CXU1KUFisYaF/4zjAQ==", - "dependencies": { - "through2": "4.0.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stream-mime-type": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stream-mime-type/-/stream-mime-type-1.0.2.tgz", - "integrity": "sha512-80GzRn7JICPDEPBhSyqJjbztqX66+3DpkuUUcgDHtRBQlZRTkbCz0BsISggUl7AnyinJk9zyHVnd2lftlZXDdg==", - "dependencies": { - "file-type": "^16.0.1", - "mime-types": "^2.1.27", - "stream-head": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2700,22 +2636,6 @@ "node": ">=0.10.0" } }, - "node_modules/strtok3": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", - "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", - "dependencies": { - "@tokenizer/token": "^0.3.0", - "peek-readable": "^4.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/supertape": { "version": "12.7.0", "resolved": "https://registry.npmjs.org/supertape/-/supertape-12.7.0.tgz", @@ -2845,27 +2765,6 @@ "node": "20 || >=22" } }, - "node_modules/through2": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", - "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", - "dependencies": { - "readable-stream": "3" - } - }, - "node_modules/through2/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/timer-node": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/timer-node/-/timer-node-5.0.9.tgz", @@ -2878,22 +2777,6 @@ "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==" }, - "node_modules/token-types": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", - "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", - "dependencies": { - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/try-catch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/try-catch/-/try-catch-4.0.7.tgz", diff --git a/package.json b/package.json index 0d0c2b6..0e666aa 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,14 @@ }, "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.6.10", + "@cloudrac3r/discord-markdown": "^2.7.0", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/in-your-element": "^1.1.1", "@cloudrac3r/mixin-deep": "^3.0.1", "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", + "@cloudrac3r/stream-type": "^1.0.0", "@cloudrac3r/turndown": "^7.1.4", "@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks-icons": "^6.0.2", @@ -46,7 +47,6 @@ "prettier-bytes": "^1.0.4", "sharp": "^0.34.5", "snowtransfer": "^0.17.5", - "stream-mime-type": "^1.0.2", "try-to-catch": "^4.0.5", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index e805365..c4b812d 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -977,6 +977,21 @@ test("message2event: written @mentions do not match in inline code", async t => }]) }) +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({ diff --git a/src/m2d/actions/sticker.js b/src/m2d/actions/sticker.js index 341d8b0..8eeb5d2 100644 --- a/src/m2d/actions/sticker.js +++ b/src/m2d/actions/sticker.js @@ -9,7 +9,7 @@ const sharp = require("sharp") const api = sync.require("../../matrix/api") /** @type {import("../../matrix/mreq")} */ const mreq = sync.require("../../matrix/mreq") -const streamMimeType = require("stream-mime-type") +const {streamType} = require("@cloudrac3r/stream-type") const WIDTH = 160 const HEIGHT = 160 @@ -26,13 +26,13 @@ async function getAndResizeSticker(mxc) { } const streamIn = Readable.fromWeb(res.body) - const { stream, mime } = await streamMimeType.getMimeType(streamIn) - const animated = ["image/gif", "image/webp"].includes(mime) + const {streamThrough, type} = await streamType(streamIn) + const animated = ["image/gif", "image/webp"].includes(type) const transformer = sharp({animated: animated}) .resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}}) .webp() - stream.pipe(transformer) + streamThrough.pipe(transformer) return Readable.toWeb(transformer) } diff --git a/src/m2d/converters/emoji-sheet.js b/src/m2d/converters/emoji-sheet.js index 16d5065..dd66a17 100644 --- a/src/m2d/converters/emoji-sheet.js +++ b/src/m2d/converters/emoji-sheet.js @@ -6,7 +6,7 @@ const {pipeline} = require("stream").promises const sharp = require("sharp") const {GIFrame} = require("@cloudrac3r/giframe") const {PNG} = require("@cloudrac3r/pngjs") -const streamMimeType = require("stream-mime-type") +const {streamType} = require("@cloudrac3r/stream-type") const SIZE = 48 const RESULT_WIDTH = 400 @@ -54,11 +54,11 @@ async function compositeMatrixEmojis(mxcs, mxcDownloader) { * @returns {Promise} Uncompressed PNG image */ async function convertImageStream(streamIn, stopStream) { - const {stream, mime} = await streamMimeType.getMimeType(streamIn) - assert(["image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"].includes(mime), `Mime type ${mime} is impossible for emojis`) + const {streamThrough, type} = await streamType(streamIn) + assert(["image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"].includes(type), `Mime type ${type} is impossible for emojis`) try { - if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") { + if (type === "image/png" || type === "image/jpeg" || type === "image/webp") { /** @type {{info: sharp.OutputInfo, buffer: Buffer}} */ const result = await new Promise((resolve, reject) => { const transformer = sharp() @@ -70,15 +70,15 @@ async function convertImageStream(streamIn, stopStream) { resolve({info, buffer}) }) pipeline( - stream, + streamThrough, transformer ) }) return result.buffer - } else if (mime === "image/gif") { + } else if (type === "image/gif") { const giframe = new GIFrame(0) - stream.on("data", chunk => { + streamThrough.on("data", chunk => { giframe.feed(chunk) }) const frame = await giframe.getFrame() @@ -91,10 +91,10 @@ async function convertImageStream(streamIn, stopStream) { .toBuffer({resolveWithObject: true}) return buffer.data - } else if (mime === "image/apng") { + } else if (type === "image/apng") { const png = new PNG({maxFrames: 1}) // @ts-ignore - stream.pipe(png) + streamThrough.pipe(png) /** @type {Buffer} */ // @ts-ignore const frame = await new Promise(resolve => png.on("parsed", resolve)) stopStream() From cb4e8df91e3e38864befa98f786af0cbf5a60494 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 14 Mar 2026 14:34:59 +1300 Subject: [PATCH 057/111] Fix package-lock --- package-lock.json | 133 ++++++++++++---------------------------------- 1 file changed, 33 insertions(+), 100 deletions(-) diff --git a/package-lock.json b/package-lock.json index c93d483..7b27322 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "../nodejs-stream-type": { "name": "@cloudrac3r/stream-type", "version": "1.0.0", + "extraneous": true, "license": "AGPL-3.0-only", "devDependencies": { "@cloudrac3r/tap-dot": "^2.0.3", @@ -263,8 +264,13 @@ } }, "node_modules/@cloudrac3r/stream-type": { - "resolved": "../nodejs-stream-type", - "link": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@cloudrac3r/stream-type/-/stream-type-1.0.0.tgz", + "integrity": "sha512-orfdUaeDT00fkELxAab+pJNZWwis+KijJEWw+cUWOD2VqqQWriL04W5DOPN0dlsJvn4VoyBe6cYGrzsJ5YPcOw==", + "license": "AGPL-3.0-only", + "engines": { + "node": ">=22.6.0" + } }, "node_modules/@cloudrac3r/tap-dot": { "version": "2.0.3", @@ -1091,6 +1097,15 @@ "node": ">=6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1221,19 +1236,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/brace-expansion": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", @@ -1362,27 +1364,6 @@ "node": ">=12" } }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cloudstorm": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.17.0.tgz", @@ -1575,25 +1556,6 @@ "node": ">=8.6" } }, - "node_modules/enquirer/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/enquirer/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/entities": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-5.0.0.tgz", @@ -2350,6 +2312,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2607,20 +2583,11 @@ "node": ">=8" } }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { + "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -2737,19 +2704,6 @@ "node": ">=6" } }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/test-exclude": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", @@ -2912,27 +2866,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", From e6c3013993e8e365549b1db9a249ed5b544b1e9a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 14 Mar 2026 20:23:43 +1300 Subject: [PATCH 058/111] Make default permission setting functional --- src/d2m/actions/create-room.js | 2 +- src/d2m/actions/register-user.js | 2 +- src/discord/utils.js | 12 ++++++++++- src/m2d/converters/event-to-message.js | 4 ++-- src/matrix/matrix-command-handler.js | 29 +++++++++++++++++++------- 5 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 651eaf4..0f2f903 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -122,7 +122,7 @@ async function channelToKState(channel, guild, di) { join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} } - const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites) + const everyonePermissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites) const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages) const everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone) diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js index 1bdd6e3..c837ccb 100644 --- a/src/d2m/actions/register-user.js +++ b/src/d2m/actions/register-user.js @@ -154,7 +154,7 @@ function memberToPowerLevel(user, member, guild, channel) { if (!member) return 0 const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, user.id, channel.permission_overwrites) - const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites) + const everyonePermissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites) /* * PL 100 = Administrator = People who can brick the room. RATIONALE: * - Administrator. diff --git a/src/discord/utils.js b/src/discord/utils.js index a51b155..2431246 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -5,7 +5,7 @@ const assert = require("assert").strict const {reg} = require("../matrix/read-registration") -const {db} = require("../passthrough") +const {db, select} = require("../passthrough") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore let hasher = null @@ -58,6 +58,15 @@ function getPermissions(guildID, userRoles, guildRoles, userID, channelOverwrite return allowed } +/** + * @param {{id: string, roles: DiscordTypes.APIGuild["roles"]}} guild + * @param {DiscordTypes.APIGuildChannel["permission_overwrites"]} [channel] + */ +function getDefaultPermissions(guild, channel) { + const defaultRoles = select("role_default", "role_id", {guild_id: guild.id}).pluck().all() + return getPermissions(guild.id, defaultRoles, guild.roles, undefined, channel) +} + /** * Note: You can only provide one permission bit to permissionToCheckFor. To check multiple permissions, call `hasAllPermissions` or `hasSomePermissions`. * It is designed like this to avoid developer error with bit manipulations. @@ -174,6 +183,7 @@ function filterTo(xs, fn) { } module.exports.getPermissions = getPermissions +module.exports.getDefaultPermissions = getDefaultPermissions module.exports.hasPermission = hasPermission module.exports.hasSomePermissions = hasSomePermissions module.exports.hasAllPermissions = hasAllPermissions diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 81ad48c..458924d 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -898,7 +898,7 @@ async function eventToMessage(event, guild, channel, di) { let shouldSuppress = inBody !== -1 && event.content.body[inBody-1] === "<" if (!shouldSuppress && guild?.roles) { // Suppress if regular users don't have permission - const permissions = dUtils.getPermissions(guild.id, [], guild.roles) + const permissions = dUtils.getDefaultPermissions(guild, channel?.permission_overwrites) const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks) shouldSuppress = !canEmbedLinks } @@ -961,7 +961,7 @@ async function eventToMessage(event, guild, channel, di) { // Suppress if regular users don't have permission if (!shouldSuppress && guild?.roles) { - const permissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites) + const permissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites) const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks) shouldSuppress = !canEmbedLinks } diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index d568f7b..b38b4b1 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -105,7 +105,8 @@ const commands = [{ // Guard /** @type {string} */ // @ts-ignore const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() - const guildID = discord.channels.get(channelID)?.["guild_id"] + const channel = discord.channels.get(channelID) + const guildID = channel?.["guild_id"] let matrixOnlyReason = null const matrixOnlyConclusion = "So the emoji will be uploaded on Matrix-side only. It will still be usable over the bridge, but may have degraded functionality." // Check if we can/should upload to Discord, for various causes @@ -115,7 +116,7 @@ const commands = [{ const guild = discord.guilds.get(guildID) assert(guild) const slots = getSlotCount(guild.premium_tier) - const permissions = dUtils.getPermissions(guild.id, [], guild.roles) + const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"]) if (guild.emojis.length >= slots) { matrixOnlyReason = "CAPACITY" } else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...) @@ -240,7 +241,8 @@ const commands = [{ // Guard /** @type {string} */ // @ts-ignore const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() - const guildID = discord.channels.get(channelID)?.["guild_id"] + const channel = discord.channels.get(channelID) + const guildID = channel?.["guild_id"] if (!guildID) { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, @@ -251,7 +253,7 @@ const commands = [{ const guild = discord.guilds.get(guildID) assert(guild) - const permissions = dUtils.getPermissions(guild.id, [], guild.roles) + const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"]) if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS return api.sendEvent(event.room_id, "m.room.message", { ...ctx, @@ -270,7 +272,8 @@ const commands = [{ // Guard /** @type {string} */ // @ts-ignore const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() - const guildID = discord.channels.get(channelID)?.["guild_id"] + const channel = discord.channels.get(channelID) + const guildID = channel?.["guild_id"] if (!guildID) { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, @@ -281,7 +284,7 @@ const commands = [{ const guild = discord.guilds.get(guildID) assert(guild) - const permissions = dUtils.getPermissions(guild.id, [], guild.roles) + const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"]) if (!dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, @@ -290,7 +293,19 @@ const commands = [{ }) } - const invite = await discord.snow.channel.createChannelInvite(channelID) + try { + var invite = await discord.snow.channel.createChannelInvite(channelID) + } catch (e) { + if (e.message === `{"message": "Missing Permissions", "code": 50013}`) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "I don't have permission to create invites to the Discord channel/server." + }) + } else { + throw e + } + } const validHours = Math.ceil(invite.max_age / (60 * 60)) const validUses = ( invite.max_uses === 0 ? "unlimited uses" From 3365023fe39eeda639b06d24ccb67b3b55cef734 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 15 Mar 2026 01:21:38 +1300 Subject: [PATCH 059/111] Sync default roles changes immediately --- src/d2m/discord-packets.js | 2 +- src/web/routes/guild-settings.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/d2m/discord-packets.js b/src/d2m/discord-packets.js index 8cf2fde..b1e381e 100644 --- a/src/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -26,6 +26,7 @@ const utils = { client.user = message.d.user client.application = message.d.application console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) + interactions.registerInteractions() } else if (message.t === "GUILD_CREATE") { message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web @@ -47,7 +48,6 @@ const utils = { if (listen === "full") { try { - interactions.registerInteractions() await eventDispatcher.checkMissedExpressions(message.d) await eventDispatcher.checkMissedPins(client, message.d) await eventDispatcher.checkMissedMessages(client, message.d) diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js index ae52825..8119f93 100644 --- a/src/web/routes/guild-settings.js +++ b/src/web/routes/guild-settings.js @@ -131,6 +131,9 @@ as.router.post("/api/default-roles", defineEventHandler(async event => { db.prepare("INSERT OR IGNORE INTO role_default (guild_id, role_id) VALUES (?, ?)").run(guildID, roleID) } + const createSpace = getCreateSpace(event) + await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request + if (getRequestHeader(event, "HX-Request")) { return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID}) } else { From 6716b432bae356f60731df554dfae01e55705a9d Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 15 Mar 2026 01:33:29 +1300 Subject: [PATCH 060/111] Wait for response before next click (don't queue) --- src/web/pug/guild.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 74b476a..6dd8601 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -108,7 +108,7 @@ block body h3.mt32.fs-category Default roles .s-card - form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles + form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-sync="this:drop" hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles input(type="hidden" name="guild_id" value=guild_id) .d-flex.fw-wrap.g4 .s-tag.s-tag__md.fs-body1.s-tag__required @everyone From d3afa728ed0b5ac76ed2946f2519c9b0734eeb7f Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 15 Mar 2026 20:53:41 +1300 Subject: [PATCH 061/111] Fix m->d posting embeds even when setting is off --- src/d2m/converters/edit-to-changes.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/d2m/converters/edit-to-changes.js b/src/d2m/converters/edit-to-changes.js index 4f743eb..61f3290 100644 --- a/src/d2m/converters/edit-to-changes.js +++ b/src/d2m/converters/edit-to-changes.js @@ -151,9 +151,11 @@ async function editToChanges(message, guild, api) { const messageReallyOld = message.timestamp && new Date(message.timestamp).getTime() < Date.now() - 2 * 60 * 1000 // older than 2 minutes ago // Don't post new generated embeds for messages if the setting was disabled. const embedsEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1 + // Bots may rely on embeds to send new content, so the rules may be more lax for them. + const botEmbedsApproved = message.author?.bot && !originallyFromMatrix if (messageReallyOld) { eventsToSend = [] // Only allow edits to change and delete, but not send new. - } else if ((messageQuiteOld || !embedsEnabled) && !message.author?.bot) { + } else if ((messageQuiteOld || !embedsEnabled) && !botEmbedsApproved) { eventsToSend = eventsToSend.filter(e => e.msgtype !== "m.notice") // Only send events that aren't embeds. } From f1b111a8a4b871ed15f5293b15bce9f33c9ae4c7 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 17 Mar 2026 12:35:42 +1300 Subject: [PATCH 062/111] Refuse to operate on encrypted rooms - Refuse to link to encrypted rooms - Do not show encrypted rooms as link candidates (if server supports) - Reject invites to encrypted rooms with message - Unbridge and leave room if it becomes encrypted --- src/d2m/actions/create-room.js | 5 ++-- src/m2d/event-dispatcher.js | 15 ++++++++++ src/matrix/api.js | 11 +++++--- src/types.d.ts | 9 +++++- src/web/pug/guild.pug | 5 ++++ src/web/routes/guild.js | 3 +- src/web/routes/link.js | 6 ++++ src/web/routes/link.test.js | 50 +++++++++++++++++++++++++++++++--- 8 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 0f2f903..31d3022 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -442,8 +442,9 @@ function syncRoom(channelID) { /** * @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional) * @param {string} guildID + * @param {string} messageBeforeLeave */ -async function unbridgeChannel(channel, guildID) { +async function unbridgeChannel(channel, guildID, messageBeforeLeave = "This room was removed from the bridge.") { const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() assert.ok(roomID) const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get() @@ -493,7 +494,7 @@ async function unbridgeChannel(channel, guildID) { // send a notification in the room await api.sendEvent(roomID, "m.room.message", { msgtype: "m.notice", - body: "⚠️ This room was removed from the bridge." + body: `⚠️ ${messageBeforeLeave}` }) // if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 70e293b..085c69c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -413,6 +413,7 @@ async event => { console.error(e) return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`) } + if (inviteRoomState?.encryption) return await api.leaveRoomWithReason(event.room_id, "Encrypted rooms are not supported for bridging. Please use an unencrypted room.") if (!inviteRoomState?.name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite.`) await api.joinRoom(event.room_id) db.prepare("REPLACE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar) @@ -483,6 +484,20 @@ async event => { await roomUpgrade.onTombstone(event, api) })) +sync.addTemporaryListener(as, "type:m.room.encryption", guard("m.room.encryption", +/** + * @param {Ty.Event.StateOuter} event + */ +async event => { + // Dramatically unbridge rooms if they become encrypted + if (event.state_key !== "") return + const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() + if (!channelID) return + const channel = discord.channels.get(channelID) + if (!channel) return + await createRoom.unbridgeChannel(channel, channel["guild_id"], "Encrypted rooms are not supported. This room was removed from the bridge.") +})) + module.exports.stringifyErrorStack = stringifyErrorStack module.exports.sendError = sendError module.exports.printError = printError diff --git a/src/matrix/api.js b/src/matrix/api.js index 87bbf0c..f24f4d9 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -172,7 +172,7 @@ function getStateEventOuter(roomID, type, key) { /** * @param {string} roomID * @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event] - * @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?}>} + * @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?, encryption: string?}>} */ async function getInviteState(roomID, event) { function getFromInviteRoomState(strippedState, nskey, key) { @@ -191,7 +191,8 @@ async function getInviteState(roomID, event) { name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"), topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"), avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"), - type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type") + type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type"), + encryption: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.encryption", "algorithm") } } @@ -227,7 +228,8 @@ async function getInviteState(roomID, event) { name: getFromInviteRoomState(strippedState, "m.room.name", "name"), topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"), avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"), - type: getFromInviteRoomState(strippedState, "m.room.create", "type") + type: getFromInviteRoomState(strippedState, "m.room.create", "type"), + encryption: getFromInviteRoomState(strippedState, "m.room.encryption", "algorithm") } } } catch (e) {} @@ -240,7 +242,8 @@ async function getInviteState(roomID, event) { name: room.name ?? null, topic: room.topic ?? null, avatar: room.avatar_url ?? null, - type: room.room_type ?? null + type: room.room_type ?? null, + encryption: (room.encryption || room["im.nheko.summary.encryption"]) ?? null } } diff --git a/src/types.d.ts b/src/types.d.ts index a85907d..be037ca 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -157,7 +157,7 @@ export namespace Event { type: string state_key: string sender: string - content: Event.M_Room_Create | Event.M_Room_Name | Event.M_Room_Avatar | Event.M_Room_Topic | Event.M_Room_JoinRules | Event.M_Room_CanonicalAlias + content: Event.M_Room_Create | Event.M_Room_Name | Event.M_Room_Avatar | Event.M_Room_Topic | Event.M_Room_JoinRules | Event.M_Room_CanonicalAlias | Event.M_Room_Encryption } export type M_Room_Create = { @@ -390,6 +390,12 @@ export namespace Event { body: string replacement_room: string } + + export type M_Room_Encryption = { + algorithm: string + rotation_period_ms?: number + rotation_period_msgs?: number + } } export namespace R { @@ -437,6 +443,7 @@ export namespace R { num_joined_members: number room_id: string room_type?: string + encryption?: string } export type ResolvedRoom = { diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 6dd8601..9791ae3 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -249,6 +249,11 @@ block body ul.my8.ml24 each row in removedLinkedRooms li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + h3.mt24 Unavailable rooms: Encryption not supported + .s-card.p0 + ul.my8.ml24 + each row in removedEncryptedRooms + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name h3.mt24 Unavailable rooms: Wrong type .s-card.p0 ul.my8.ml24 diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index a5508c4..70092d5 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -123,13 +123,14 @@ function getChannelRoomsLinks(guild, rooms, roles) { let unlinkedRooms = [...rooms] let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type) + let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"]) // https://discord.com/developers/docs/topics/threads#active-archived-threads // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) return { linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, - removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms + removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms, removedEncryptedRooms } } diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 43995fc..772a19c 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -204,6 +204,12 @@ as.router.post("/api/link", defineEventHandler(async event => { throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) } + // Check room is not encrypted + const encryption = await api.getStateEvent(parsedBody.matrix, "m.room.encryption", "").catch(() => null) + if (encryption) { + throw createError({status: 400, message: "Bad Request", data: "Encrypted rooms are not supported for bridging. Please replace it with an unencrypted room."}) + } + // Check bridge has PL 100 const {powerLevels, powers: {[utils.bot]: selfPowerLevel}} = await utils.getEffectivePower(parsedBody.matrix, [utils.bot], api) if (selfPowerLevel < (powerLevels?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"}) diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index 70299d5..0182093 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -435,6 +435,47 @@ test("web link room: check that bridge can join room (uses via for join attempt) t.equal(called, 2) }) +test("web link room: check that room is not encrypted", async t => { +let called = 0 + const [error] = await tryToCatch(() => router.test("post", "/api/link", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + discord: "665310973967597573", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "665289423482519565" + }, + api: { + async joinRoom(roomID) { + called++ + return roomID + }, + async *generateFullHierarchy(spaceID) { + called++ + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + yield { + room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + children_state: [], + guest_can_join: false, + num_joined_members: 2 + } + /* c8 ignore next */ + }, + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") + if (type === "m.room.encryption" && key === "") { + return {algorithm: "m.megolm.v1.aes-sha2"} + } + throw new Error("Unknown state event") + } + } + })) + t.equal(error.data, "Encrypted rooms are not supported for bridging. Please replace it with an unencrypted room.") + t.equal(called, 3) +}) + test("web link room: check that bridge has PL 100 in target room", async t => { let called = 0 const [error] = await tryToCatch(() => router.test("post", "/api/link", { @@ -465,9 +506,10 @@ test("web link room: check that bridge has PL 100 in target room", async t => { async getStateEvent(roomID, type, key) { called++ t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return {users_default: 50} + if (type === "m.room.power_levels" && key === "") { + return {users_default: 50} + } + throw new Error("Unknown state event") }, async getStateEventOuter(roomID, type, key) { called++ @@ -489,7 +531,7 @@ test("web link room: check that bridge has PL 100 in target room", async t => { } })) t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room") - t.equal(called, 4) + t.equal(called, 5) }) test("web link room: successfully calls createRoom", async t => { From 711e024caaf3739b39bc1b91040b3db6371a6071 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 17 Mar 2026 14:02:11 +1300 Subject: [PATCH 063/111] Update dependencies --- docs/developer-orientation.md | 15 +- package-lock.json | 631 ++++++++++++++++++++-------------- 2 files changed, 371 insertions(+), 275 deletions(-) diff --git a/docs/developer-orientation.md b/docs/developer-orientation.md index e17f263..bff64c2 100644 --- a/docs/developer-orientation.md +++ b/docs/developer-orientation.md @@ -89,15 +89,14 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin # Dependency justification -Total transitive production dependencies: 121 +Total transitive production dependencies: 144 ### 🦕 -* (31) better-sqlite3: SQLite is the best database, and this is the best library for it. -* (27) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.) -* (16) stream-mime-type@1: This seems like the best option. Version 1 is used because version 2 is ESM-only. -* (9) h3: Web server. OOYE needs this for the appservice listener, authmedia proxy, self-service, and more. -* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. +* (35) better-sqlite3: SQLite is the best database, and this is the best library for it. +* (29) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. It has libvips prebuilts for each platform. +* (26) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.) +* (9) h3: Web server. OOYE needs this for the web UI, appservice listener, authmedia proxy, and more. ### 🪱 @@ -116,12 +115,12 @@ Total transitive production dependencies: 121 * (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust. * (0) discord-api-types: Bitfields needed at runtime and types needed for development. * (0) domino: DOM implementation that's already pulled in by turndown. -* (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively. +* (2) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively. * (0) entities: Looks fine. No dependencies. * (0) get-relative-path: Looks fine. No dependencies. * (1) heatsync: Module hot-reloader that I trust. * (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used. -* (0) mime-type: File extension to mime type mapping that's already pulled in by stream-mime-type. +* (1) mime-types: List of mime type mappings. Needed to serve static files. * (0) prettier-bytes: It does what I want and has no dependencies. * (0) snowtransfer: Discord API library with bring-your-own-caching that I trust. * (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well. diff --git a/package-lock.json b/package-lock.json index 7b27322..10b4668 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,50 +54,6 @@ "node": ">=22" } }, - "../extended-errors/enhance-errors": { - "version": "1.0.0", - "extraneous": true, - "license": "UNLICENSED", - "dependencies": { - "ts-expose-internals": "^5.6.3", - "ts-patch": "^3.3.0", - "typescript": "^5.9.3" - }, - "devDependencies": { - "@types/node": "^22.19.1", - "ts-node": "^10.9.2" - } - }, - "../nodejs-stream-type": { - "name": "@cloudrac3r/stream-type", - "version": "1.0.0", - "extraneous": true, - "license": "AGPL-3.0-only", - "devDependencies": { - "@cloudrac3r/tap-dot": "^2.0.3", - "@types/node": "^22.19.15", - "c8": "^11.0.0", - "cross-env": "^10.1.0", - "supertape": "^12.10.4", - "try-to-catch": "^4.0.5" - }, - "engines": { - "node": ">=22.6.0" - } - }, - "../tap-dot": { - "name": "@cloudrac3r/tap-dot", - "version": "2.0.0", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@cloudrac3r/tap-out": "^3.2.3", - "ansi-colors": "^4.1.3" - }, - "bin": { - "tap-dot": "bin/dot" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -117,12 +73,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -132,9 +88,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -149,6 +105,7 @@ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" } @@ -184,12 +141,14 @@ "node_modules/@cloudrac3r/giframe": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@cloudrac3r/giframe/-/giframe-0.4.3.tgz", - "integrity": "sha512-LKuRfdHrhvgPP0heYdlVRecswk/kYaC3fI+X+GQmnkJE36uN1E2dg5l5QdLoukliH7g8S2hgDYk0jsR7sJf8Dg==" + "integrity": "sha512-LKuRfdHrhvgPP0heYdlVRecswk/kYaC3fI+X+GQmnkJE36uN1E2dg5l5QdLoukliH7g8S2hgDYk0jsR7sJf8Dg==", + "license": "MIT" }, "node_modules/@cloudrac3r/html-template-tag": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@cloudrac3r/html-template-tag/-/html-template-tag-5.0.1.tgz", "integrity": "sha512-aH+ZdWJf53E63bVb2FiSnpM81qtF2ZNVbrXjrHcfnofyV/GTYJjZHnmPYC2FgXxJ+I8+bZP3DiwYzj7zXYoekw==", + "license": "MIT", "dependencies": { "html-es6cape": "^2.0.0" } @@ -220,6 +179,7 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/@cloudrac3r/pngjs/-/pngjs-7.0.3.tgz", "integrity": "sha512-Aghuja9XAIqBPmY2jk8dKZSyK90gImxA4hJeEYYAWkZO34bf+zliUAvGBygoBZA0EgXSmfxewVchL+9y3w+rDw==", + "license": "MIT", "engines": { "node": ">=14.19.0" } @@ -228,6 +188,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/@cloudrac3r/pug/-/pug-4.0.4.tgz", "integrity": "sha512-RZhxM/WfSHT0n39URlwDdugBfGfwEWmr+w+mCyiT9jaiqCjeZPpXkps/cWLA1XRLo7fzq0+9THtGzVKXS487/A==", + "license": "MIT", "dependencies": { "@cloudrac3r/pug-code-gen": "3.0.5", "@cloudrac3r/pug-lexer": "5.0.3", @@ -242,6 +203,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/@cloudrac3r/pug-code-gen/-/pug-code-gen-3.0.5.tgz", "integrity": "sha512-dKKpy3i9YlVa3lBgu5Jds513c7AtzmmsR2/lGhY2NOODSpIiTcbWLw1obA9YEmmH1tAJny+J6ePYN1N1RgjjQA==", + "license": "MIT", "dependencies": { "constantinople": "^4.0.1", "doctypes": "^1.1.0", @@ -257,6 +219,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/@cloudrac3r/pug-lexer/-/pug-lexer-5.0.3.tgz", "integrity": "sha512-ym4g4q+l9IC2H1wXCDnF79AQZ48xtxO675JOT316e17W2wHWtgRccXpT6DkBAaRDZycmkGzSxID1S15T2lZj+g==", + "license": "MIT", "dependencies": { "character-parser": "^4.0.0", "is-expression": "^4.0.0", @@ -307,14 +270,15 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.4.tgz", "integrity": "sha512-bQAwcvcSqBTdEHPMt+IAZWIoDh+2eRuy9TgD0FUdxVurbvj3CUHTxLfzlmsO0UTi+GHpgYqDSsVdV7kYTNq5Qg==", + "license": "MIT", "dependencies": { "domino": "^2.1.6" } }, "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "license": "MIT", "optional": true, "dependencies": { @@ -324,12 +288,13 @@ "node_modules/@hotwired/stimulus": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz", - "integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==" + "integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==", + "license": "MIT" }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "engines": { "node": ">=18" @@ -802,9 +767,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", "dev": true, "license": "MIT", "engines": { @@ -835,10 +800,11 @@ } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" } @@ -865,6 +831,7 @@ "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -889,6 +856,7 @@ "resolved": "https://registry.npmjs.org/@putout/cli-validate-args/-/cli-validate-args-2.0.0.tgz", "integrity": "sha512-/tl1XiBog6XMb1T9kalFerYU86sYsl6EtrlvGI5RVtlHOQdEEJAIPRxmX4vnKG3uoY5aVEkJOWzbPM5tsncmFQ==", "dev": true, + "license": "MIT", "dependencies": { "fastest-levenshtein": "^1.0.12", "just-kebab-case": "^4.2.0" @@ -898,9 +866,9 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.34.47", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz", - "integrity": "sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -976,19 +944,6 @@ "node": ">=22" } }, - "node_modules/@supertape/formatter-progress-bar/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@supertape/formatter-short": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@supertape/formatter-short/-/formatter-short-3.0.0.tgz", @@ -1027,19 +982,6 @@ "node": ">=22" } }, - "node_modules/@supertape/formatter-time/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@supertape/operator-stub": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@supertape/operator-stub/-/operator-stub-4.0.0.tgz", @@ -1054,10 +996,11 @@ } }, "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", - "dev": true + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { "version": "22.19.15", @@ -1070,9 +1013,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", - "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1082,6 +1025,7 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -1093,6 +1037,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", "engines": { "node": ">=6" } @@ -1107,15 +1052,13 @@ } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -1126,19 +1069,22 @@ "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", "dev": true, + "license": "MIT", "dependencies": { "printable-characters": "^1.0.42" } }, "node_modules/assert-never": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.3.0.tgz", - "integrity": "sha512-9Z3vxQ+berkL/JJo0dK+EY3Lp0s3NtSnP3VCLsh5HDcZPrh0M+KQRK5sWhUeyPPH+/RCxZqOxLMR+YC6vlviEQ==" + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", + "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==", + "license": "MIT" }, "node_modules/babel-walk": { "version": "3.0.0-canary-5", "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", "integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==", + "license": "MIT", "dependencies": { "@babel/types": "^7.9.6" }, @@ -1153,13 +1099,13 @@ "license": "MIT" }, "node_modules/balanced-match": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", - "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/base64-js": { @@ -1179,12 +1125,13 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/better-sqlite3": { - "version": "12.6.2", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", - "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1199,6 +1146,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", "dependencies": { "file-uri-to-path": "1.0.0" } @@ -1207,13 +1155,27 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, - "node_modules/bl/node_modules/buffer": { + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", @@ -1231,24 +1193,12 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, - "node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/c8": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", @@ -1284,16 +1234,13 @@ } }, "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, + "license": "MIT", "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" @@ -1302,17 +1249,20 @@ "node_modules/character-parser": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-4.0.0.tgz", - "integrity": "sha512-jWburCrDpd+aPopB7esjh/gLyZoHZS4C2xwwJlkTPyhhJdXG+FCG0P4qCOInvOd9yhiuAEJYlZsUMQ0JSK4ykw==" + "integrity": "sha512-jWburCrDpd+aPopB7esjh/gLyZoHZS4C2xwwJlkTPyhhJdXG+FCG0P4qCOInvOd9yhiuAEJYlZsUMQ0JSK4ykw==", + "license": "MIT" }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" }, "node_modules/chunk-text": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/chunk-text/-/chunk-text-2.0.1.tgz", "integrity": "sha512-ER6TSpe2DT4wjOVOKJ3FFAYv7wE77HA/Ztz88Peiv3lq/2oVMsItYJJsVVI0xNZM8cdImOOTNqlw+LQz7gYdJg==", + "license": "MIT", "dependencies": { "runes": "^0.4.3" }, @@ -1322,9 +1272,9 @@ } }, "node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -1355,6 +1305,7 @@ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -1382,6 +1333,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -1393,22 +1345,25 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/constantinople": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", + "license": "MIT", "dependencies": { "@babel/parser": "^7.6.0", "@babel/types": "^7.6.1" } }, "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" }, "node_modules/cookie-es": { "version": "1.2.2", @@ -1421,6 +1376,7 @@ "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", "dev": true, + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.1" }, @@ -1439,6 +1395,7 @@ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1467,12 +1424,14 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" }, @@ -1487,6 +1446,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", "engines": { "node": ">=4.0.0" } @@ -1494,7 +1454,8 @@ "node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" }, "node_modules/destr": { "version": "2.0.5", @@ -1512,9 +1473,9 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.41", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.41.tgz", - "integrity": "sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ==", + "version": "0.38.42", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.42.tgz", + "integrity": "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" @@ -1523,23 +1484,27 @@ "node_modules/doctypes": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", - "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==" + "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==", + "license": "MIT" }, "node_modules/domino": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.6.tgz", - "integrity": "sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ==" + "integrity": "sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ==", + "license": "BSD-2-Clause" }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", "dependencies": { "once": "^1.4.0" } @@ -1548,6 +1513,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "license": "MIT", "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" @@ -1560,6 +1526,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-5.0.0.tgz", "integrity": "sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==", + "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -1568,10 +1535,11 @@ } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -1580,6 +1548,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", "engines": { "node": ">=6" } @@ -1589,6 +1558,7 @@ "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4.9.1" } @@ -1596,13 +1566,15 @@ "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -1641,7 +1613,8 @@ "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" }, "node_modules/fullstore": { "version": "4.0.0", @@ -1658,6 +1631,7 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -1667,6 +1641,7 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, + "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -1674,13 +1649,15 @@ "node_modules/get-relative-path": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-relative-path/-/get-relative-path-1.0.2.tgz", - "integrity": "sha512-dGkopYfmB4sXMTcZslq5SojEYakpdCSj/SVSHLhv7D6RBHzvDtd/3Q8lTEOAhVKxPPeAHu/YYkENbbz3PaH+8w==" + "integrity": "sha512-dGkopYfmB4sXMTcZslq5SojEYakpdCSj/SVSHLhv7D6RBHzvDtd/3Q8lTEOAhVKxPPeAHu/YYkENbbz3PaH+8w==", + "license": "MIT" }, "node_modules/get-source": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", "dev": true, + "license": "Unlicense", "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" @@ -1689,7 +1666,8 @@ "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" }, "node_modules/glob": { "version": "13.0.6", @@ -1710,9 +1688,9 @@ } }, "node_modules/h3": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.5.tgz", - "integrity": "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.6.tgz", + "integrity": "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ==", "license": "MIT", "dependencies": { "cookie-es": "^1.2.2", @@ -1731,15 +1709,17 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -1762,13 +1742,15 @@ "node_modules/html-es6cape": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-es6cape/-/html-es6cape-2.0.2.tgz", - "integrity": "sha512-utzhH8rq2VABdW1LsPdv5tmxeMNOtP83If0jKCa79xPBgLWfcMvdf9K+EZoxJ5P7KioCxTs6WBnSDWLQHJ2lWA==" + "integrity": "sha512-utzhH8rq2VABdW1LsPdv5tmxeMNOtP83If0jKCa79xPBgLWfcMvdf9K+EZoxJ5P7KioCxTs6WBnSDWLQHJ2lWA==", + "license": "MIT" }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/htmx.org": { "version": "2.0.8", @@ -1793,17 +1775,20 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "BSD-3-Clause" }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" }, "node_modules/iron-webcrypto": { "version": "1.2.1", @@ -1815,12 +1800,16 @@ } }, "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, + "license": "MIT", "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -1830,6 +1819,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", + "license": "MIT", "dependencies": { "acorn": "^7.1.1", "object-assign": "^4.1.1" @@ -1840,6 +1830,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -1848,13 +1839,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/istanbul-lib-coverage": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", - "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=8" } @@ -1864,6 +1857,7 @@ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", @@ -1874,10 +1868,11 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", - "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" @@ -1887,30 +1882,64 @@ } }, "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", + "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-diff/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/js-stringify": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", - "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==" + "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==", + "license": "MIT" }, "node_modules/json-with-bigint": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.4.4.tgz", - "integrity": "sha512-AhpYAAaZsPjU7smaBomDt1SOQshi9rEm6BlTbfVwsG1vNmeHKtEedJi62sHZzJTyKNtwzmNnrsd55kjwJ7054A==", + "version": "3.5.7", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz", + "integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==", "dev": true, "license": "MIT" }, @@ -1918,7 +1947,8 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/just-kebab-case/-/just-kebab-case-4.2.0.tgz", "integrity": "sha512-p2BdO7o4BI+pMun3J+dhaOfYan5JsZrw9wjshRjkWY9+p+u+kKSMhNWYnot2yHDR9CSahZ9iT3dcqJ+V72qHMw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/just-snake-case": { "version": "3.2.0", @@ -1932,6 +1962,7 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -1943,9 +1974,9 @@ } }, "node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -1956,6 +1987,7 @@ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, + "license": "MIT", "dependencies": { "semver": "^7.5.3" }, @@ -1970,6 +2002,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -1990,6 +2023,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", "engines": { "node": ">=10" }, @@ -2017,6 +2051,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2034,17 +2069,20 @@ "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" }, "node_modules/napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" }, "node_modules/node-abi": { - "version": "3.40.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.40.0.tgz", - "integrity": "sha512-zNy02qivjjRosswoYmPi8hIKJRr8MpQyeKT6qlcq/OnOgA3Rhoae+IYOqsM9V5+JnHWmxKnWOT2GxvtqdtOCXA==", + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", "dependencies": { "semver": "^7.3.5" }, @@ -2062,6 +2100,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -2070,6 +2109,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", "dependencies": { "wrappy": "1" } @@ -2079,6 +2119,7 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, + "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -2094,6 +2135,7 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -2109,6 +2151,7 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2118,6 +2161,7 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2126,7 +2170,8 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/path-scurry": { "version": "2.0.2", @@ -2146,16 +2191,18 @@ } }, "node_modules/prebuild-install": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", - "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", + "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", @@ -2173,12 +2220,13 @@ "node_modules/prettier-bytes": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/prettier-bytes/-/prettier-bytes-1.0.4.tgz", - "integrity": "sha512-dLbWOa4xBn+qeWeIF60qRoB6Pk2jX5P3DIVgOQyMyvBpu931Q+8dXz8X0snJiFkQdohDDLnZQECjzsAj75hgZQ==" + "integrity": "sha512-dLbWOa4xBn+qeWeIF60qRoB6Pk2jX5P3DIVgOQyMyvBpu931Q+8dXz8X0snJiFkQdohDDLnZQECjzsAj75hgZQ==", + "license": "ISC" }, "node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2190,29 +2238,18 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/printable-characters": { "version": "1.0.42", "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", - "dev": true + "dev": true, + "license": "Unlicense" }, "node_modules/pug-attrs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz", "integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==", + "license": "MIT", "dependencies": { "constantinople": "^4.0.1", "js-stringify": "^1.0.2", @@ -2222,12 +2259,14 @@ "node_modules/pug-error": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz", - "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==" + "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==", + "license": "MIT" }, "node_modules/pug-linker": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz", "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", + "license": "MIT", "dependencies": { "pug-error": "^2.0.0", "pug-walk": "^2.0.0" @@ -2237,6 +2276,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz", "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", + "license": "MIT", "dependencies": { "object-assign": "^4.1.1", "pug-walk": "^2.0.0" @@ -2246,6 +2286,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", + "license": "MIT", "dependencies": { "pug-error": "^2.0.0", "token-stream": "1.0.0" @@ -2254,12 +2295,14 @@ "node_modules/pug-runtime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz", - "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==" + "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==", + "license": "MIT" }, "node_modules/pug-strip-comments": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", + "license": "MIT", "dependencies": { "pug-error": "^2.0.0" } @@ -2267,12 +2310,14 @@ "node_modules/pug-walk": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz", - "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==" + "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==", + "license": "MIT" }, "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -2288,6 +2333,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -2331,23 +2377,28 @@ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, + "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2356,6 +2407,7 @@ "version": "0.4.3", "resolved": "https://registry.npmjs.org/runes/-/runes-0.4.3.tgz", "integrity": "sha512-K6p9y4ZyL9wPzA+PMDloNQPfoDGTiFYDvdlXznyGKgD10BJpcAosvATKrExRKOrNLgD8E7Um7WGW0lxsnOuNLg==", + "license": "MIT", "engines": { "node": ">=4.0.0" } @@ -2377,12 +2429,13 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2440,6 +2493,7 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, + "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -2452,6 +2506,7 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2461,6 +2516,7 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, + "license": "ISC", "engines": { "node": ">=14" }, @@ -2485,7 +2541,8 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/simple-get": { "version": "4.0.1", @@ -2505,6 +2562,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -2537,6 +2595,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -2552,10 +2611,11 @@ } }, "node_modules/stacktracey": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz", - "integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", "dev": true, + "license": "Unlicense", "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" @@ -2565,6 +2625,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" } @@ -2574,6 +2635,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -2599,14 +2661,15 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/supertape": { - "version": "12.7.0", - "resolved": "https://registry.npmjs.org/supertape/-/supertape-12.7.0.tgz", - "integrity": "sha512-5PXh6HsfEJKkC0SMhPNkH35o8Okj8xlVvoju9R0aCohzsK+GEufeYZ1IPhRBRQ2DBLXdMZHVF6N/4pAefxNuAA==", + "version": "12.10.5", + "resolved": "https://registry.npmjs.org/supertape/-/supertape-12.10.5.tgz", + "integrity": "sha512-1Px+6mhFaqcht3p4tkf3o4G8lbBazvx4pgFngm4vGwWipYm3fykm6SJ4ThXobiaNsptz53CDWA2q4B/2KtmA4w==", "dev": true, "license": "MIT", "dependencies": { @@ -2658,6 +2721,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -2670,6 +2734,7 @@ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -2693,6 +2758,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -2729,12 +2795,13 @@ "node_modules/token-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", - "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==" + "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==", + "license": "MIT" }, "node_modules/try-catch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/try-catch/-/try-catch-4.0.7.tgz", - "integrity": "sha512-gkBWUxbiN4T4PsO8KhoQYWzUPN6e0/h12H9H3YhcfPbwaN8b84fy8cFqL4rWTiPh7qHPFaEfklr6OkVxYRW0Gg==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/try-catch/-/try-catch-4.0.9.tgz", + "integrity": "sha512-tEWGmsfqZ9NBzvDOGbACxu+AaXajM6+RtmIM6wCIkFD6lCa3/UvjNuWjCRoOjn8qTKuZlQmrMh8vSTBMQcceew==", "dev": true, "license": "MIT", "engines": { @@ -2761,6 +2828,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" }, @@ -2796,17 +2864,19 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" }, "node_modules/v8-to-istanbul": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", - "integrity": "sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", "dev": true, + "license": "ISC", "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^1.6.0" + "convert-source-map": "^2.0.0" }, "engines": { "node": ">=10.12.0" @@ -2816,6 +2886,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -2825,6 +2896,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -2839,6 +2911,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", + "license": "MIT", "dependencies": { "@babel/parser": "^7.9.6", "@babel/types": "^7.9.6", @@ -2854,6 +2927,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -2866,27 +2940,47 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" }, "node_modules/wraptile": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/wraptile/-/wraptile-3.0.0.tgz", "integrity": "sha512-23LJhkIw940uTcDFyJZmNyO0z8lEINOTGCr4vR5YCG3urkdXwduRIhivBm9wKaVynLHYvxoHHYbKsDiafCLp6w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", - "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==" + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } @@ -2896,6 +2990,7 @@ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, + "license": "MIT", "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -2914,6 +3009,7 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, + "license": "ISC", "engines": { "node": ">=12" } @@ -2923,6 +3019,7 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, From 5b04b5d71231b89f2320f97f05fc0968ca28ba29 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 19 Mar 2026 13:33:50 +1300 Subject: [PATCH 064/111] Reformat /plu/ral emulated replies --- src/d2m/converters/message-to-event.js | 27 ++++++++++++++++--------- src/db/migrations/0035-role-default.sql | 2 +- src/discord/utils.js | 2 +- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index adc56e6..3f598f2 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -35,10 +35,10 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) { /** @param {{id: string, type: "discordUser"}} node */ user: node => { const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get() - const interaction = message.interaction_metadata || message.interaction + const interactionMetadata = message.interaction_metadata const username = message.mentions?.find(ment => ment.id === node.id)?.username || message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username - || (interaction?.user.id === node.id ? interaction.user.username : null) + || (interactionMetadata?.user.id === node.id ? interactionMetadata.user.username : null) || (message.author?.id === node.id ? message.author.username : null) || "unknown-user" if (mxid && useHTML) { @@ -357,9 +357,8 @@ async function messageToEvent(message, guild, options = {}, di) { }] } - const interaction = message.interaction_metadata || message.interaction - const isInteraction = message.type === DiscordTypes.MessageType.ChatInputCommand && !!interaction && "name" in interaction - const isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading) + 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[]}} @@ -400,6 +399,16 @@ async function messageToEvent(message, guild, options = {}, di) { } 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 + } + } } 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 const isEmulatedReplyToText = message.embeds[0].description?.startsWith("**[Reply to:]") @@ -685,8 +694,8 @@ async function messageToEvent(message, guild, options = {}, di) { } } - if (isInteraction && !isThinkingInteraction && events.length === 0) { - const formattedInteraction = getFormattedInteraction(interaction, false) + if (isInteraction && !isThinkingInteraction && message.interaction && events.length === 0) { + const formattedInteraction = getFormattedInteraction(message.interaction, false) body = `${formattedInteraction.body}\n${body}` html = `${formattedInteraction.html}${html}` } @@ -782,8 +791,8 @@ async function messageToEvent(message, guild, options = {}, di) { events.push(...forwardedEvents) } - if (isThinkingInteraction) { - const formattedInteraction = getFormattedInteraction(interaction, true) + if (isInteraction && isThinkingInteraction && message.interaction) { + const formattedInteraction = getFormattedInteraction(message.interaction, true) await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice") } diff --git a/src/db/migrations/0035-role-default.sql b/src/db/migrations/0035-role-default.sql index 6c44e7e..a5ce62d 100644 --- a/src/db/migrations/0035-role-default.sql +++ b/src/db/migrations/0035-role-default.sql @@ -4,6 +4,6 @@ CREATE TABLE "role_default" ( "guild_id" TEXT NOT NULL, "role_id" TEXT NOT NULL, PRIMARY KEY ("guild_id", "role_id") -); +) WITHOUT ROWID; COMMIT; diff --git a/src/discord/utils.js b/src/discord/utils.js index 2431246..aed7068 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -114,7 +114,7 @@ function hasAllPermissions(resolvedPermissions, permissionsToCheckFor) { * @param {DiscordTypes.APIMessage} message */ function isWebhookMessage(message) { - return message.webhook_id && message.type !== DiscordTypes.MessageType.ChatInputCommand + return message.webhook_id && message.type !== DiscordTypes.MessageType.ChatInputCommand && message.type !== DiscordTypes.MessageType.ContextMenuCommand } /** From f8896dce7f6193caf98f36d83077ea680f1a45c3 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 19 Mar 2026 13:34:19 +1300 Subject: [PATCH 065/111] Type fixes in set-presence.js --- src/d2m/actions/set-presence.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/d2m/actions/set-presence.js b/src/d2m/actions/set-presence.js index f26668f..0a31038 100644 --- a/src/d2m/actions/set-presence.js +++ b/src/d2m/actions/set-presence.js @@ -1,5 +1,7 @@ // @ts-check +const assert = require("assert").strict + const passthrough = require("../../passthrough") const {sync, select} = passthrough /** @type {import("../../matrix/api")} */ @@ -26,7 +28,7 @@ const presenceLoopInterval = 28e3 // Cache the list of enabled guilds rather than accessing it like multiple times per second when any user changes presence const guildPresenceSetting = new class { - /** @private @type {Set} */ guilds + /** @private @type {Set} */ guilds = new Set() constructor() { this.update() } @@ -40,7 +42,7 @@ const guildPresenceSetting = new class { class Presence extends sync.reloadClassMethods(() => Presence) { /** @type {string} */ userID - /** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string}} */ data + /** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string} | undefined} */ data /** @private @type {?string | undefined} */ mxid /** @private @type {number} */ delay = Math.random() @@ -66,6 +68,7 @@ class Presence extends sync.reloadClassMethods(() => Presence) { // I haven't tried, but I assume Synapse explodes if you try to update too many presences at the same time. // This random delay will space them out over the whole 28 second cycle. setTimeout(() => { + assert(this.data) api.setPresence(this.data, mxid).catch(() => {}) }, this.delay * presenceLoopInterval).unref() } From d2557f73bb4ff4f15f65900c15156e3335fe519a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 19 Mar 2026 13:35:53 +1300 Subject: [PATCH 066/111] Let sims rejoin after being unbanned The sim_member cache was getting stuck, so OOYE thought it was already in the room when it actually wasn't. --- src/m2d/event-dispatcher.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 085c69c..c11b696 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -423,7 +423,10 @@ async event => { if (event.content.membership === "leave" || event.content.membership === "ban") { // Member is gone + // if Matrix member, data was cached in member_cache db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) + // if Discord member (so kicked/banned by Matrix user), data was cached in sim_member + db.prepare("DELETE FROM sim_member WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) // Unregister room's use as a direct chat and/or an invite target if the bot itself left if (event.state_key === utils.bot) { From 876d91fbf487ca327dd8fdd6f3913ebd701b4d93 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 19 Mar 2026 14:30:10 +1300 Subject: [PATCH 067/111] Remove sims when the Discord user leaves --- src/d2m/actions/register-user.js | 10 ++++- src/d2m/actions/remove-member.js | 26 +++++++++++ src/d2m/actions/send-message.js | 2 +- .../message-to-event.test.components.js | 2 +- src/d2m/converters/remove-member-mxids.js | 38 ++++++++++++++++ .../converters/remove-member-mxids.test.js | 43 +++++++++++++++++++ src/d2m/discord-packets.js | 3 +- src/d2m/event-dispatcher.js | 35 +++++++++++++++ src/db/migrations/0036-app-user-install.sql | 10 +++++ src/db/orm-defs.d.ts | 6 +++ test/ooye-test-data.sql | 17 +++++++- test/test.js | 1 + 12 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 src/d2m/actions/remove-member.js create mode 100644 src/d2m/converters/remove-member-mxids.js create mode 100644 src/d2m/converters/remove-member-mxids.test.js create mode 100644 src/db/migrations/0036-app-user-install.sql diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js index c837ccb..d475e54 100644 --- a/src/d2m/actions/register-user.js +++ b/src/d2m/actions/register-user.js @@ -206,14 +206,16 @@ function _hashProfileContent(content, powerLevel) { * 3. Calculate the power level the user should get based on their Discord permissions * 4. Compare against the previously known state content, which is helpfully stored in the database * 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time + * 6. If the sim is for a user-installed app, check which user it was added by * @param {DiscordTypes.APIUser} user * @param {Omit | undefined} member * @param {DiscordTypes.APIGuildChannel} channel * @param {DiscordTypes.APIGuild} guild * @param {string} roomID + * @param {DiscordTypes.APIMessageInteractionMetadata} [interactionMetadata] * @returns {Promise} mxid of the updated sim */ -async function syncUser(user, member, channel, guild, roomID) { +async function syncUser(user, member, channel, guild, roomID, interactionMetadata) { const mxid = await ensureSimJoined(user, roomID) const content = await memberToStateContent(user, member, guild.id) const powerLevel = memberToPowerLevel(user, member, guild, channel) @@ -222,6 +224,12 @@ async function syncUser(user, member, channel, guild, roomID) { allowOverwrite: !!member, globalProfile: await userToGlobalProfile(user) }) + + const appInstalledByUser = user.bot && interactionMetadata?.authorizing_integration_owners?.[DiscordTypes.ApplicationIntegrationType.UserInstall] + if (appInstalledByUser) { + db.prepare("INSERT OR IGNORE INTO app_user_install (app_bot_id, user_id, guild_id) VALUES (?, ?, ?)").run(user.id, appInstalledByUser, guild.id) + } + return mxid } diff --git a/src/d2m/actions/remove-member.js b/src/d2m/actions/remove-member.js new file mode 100644 index 0000000..4dbd5a6 --- /dev/null +++ b/src/d2m/actions/remove-member.js @@ -0,0 +1,26 @@ +// @ts-check + +const passthrough = require("../../passthrough") +const {sync, db, select, from} = passthrough +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../converters/remove-member-mxids")} */ +const removeMemberMxids = sync.require("../converters/remove-member-mxids") + +/** + * @param {string} userID discord user ID that left + * @param {string} guildID discord guild ID that they left + */ +async function removeMember(userID, guildID) { + const {userAppDeletions, membership} = removeMemberMxids.removeMemberMxids(userID, guildID) + db.transaction(() => { + for (const d of userAppDeletions) { + db.prepare("DELETE FROM app_user_install WHERE guild_id = ? and user_id = ?").run(guildID, d) + } + })() + for (const m of membership) { + await api.leaveRoom(m.room_id, m.mxid) + } +} + +module.exports.removeMember = removeMember diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index eb919bb..8550d43 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -51,7 +51,7 @@ async function sendMessage(message, channel, guild, row) { if (message.author.id === discord.application.id) { // no need to sync the bot's own user } else { - senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID) + senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID, message.interaction_metadata) } } diff --git a/src/d2m/converters/message-to-event.test.components.js b/src/d2m/converters/message-to-event.test.components.js index 7d875a6..137b63b 100644 --- a/src/d2m/converters/message-to-event.test.components.js +++ b/src/d2m/converters/message-to-event.test.components.js @@ -65,7 +65,7 @@ test("message2event components: pk question mark output", async t => { + "
" + "

System: INX (xffgnx)" + "
Member: Lillith (pphhoh)" - + "
Sent by: infinidoge1337 (@unknown-user:)" + + "
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` diff --git a/src/d2m/converters/remove-member-mxids.js b/src/d2m/converters/remove-member-mxids.js new file mode 100644 index 0000000..de26662 --- /dev/null +++ b/src/d2m/converters/remove-member-mxids.js @@ -0,0 +1,38 @@ +// @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} */ + const appsWithOneUser = new Set(db.prepare("SELECT app_bot_id FROM app_user_install WHERE guild_id = ? GROUP BY app_bot_id HAVING count(*) = 1").pluck().all(guildID)) + // 2. Select apps installed by this user + const appsFromThisUser = new Set(select("app_user_install", "app_bot_id", {guild_id: guildID, user_id: userID}).pluck().all()) + if (appsFromThisUser.size) userAppDeletions.push(userID) + // Then remove user installed apps if this was the last user with them + const appsToRemove = appsWithOneUser.intersection(appsFromThisUser) + for (const botID of appsToRemove) { + // Remove sims for user installed app + const appRemoval = removeMemberMxids(botID, guildID) + membership = membership.concat(appRemoval.membership) + userAppDeletions = userAppDeletions.concat(appRemoval.userAppDeletions) + } + + return {membership, userAppDeletions} +} + +module.exports.removeMemberMxids = removeMemberMxids diff --git a/src/d2m/converters/remove-member-mxids.test.js b/src/d2m/converters/remove-member-mxids.test.js new file mode 100644 index 0000000..a880dff --- /dev/null +++ b/src/d2m/converters/remove-member-mxids.test.js @@ -0,0 +1,43 @@ +// @ts-check + +const {test} = require("supertape") +const {removeMemberMxids} = require("./remove-member-mxids") + +test("remove member mxids: would remove mxid for all rooms in this server", t => { + t.deepEqual(removeMemberMxids("772659086046658620", "112760669178241024"), { + userAppDeletions: [], + membership: [{ + mxid: "@_ooye_cadence:cadence.moe", + room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe" + }, { + mxid: "@_ooye_cadence:cadence.moe", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }] + }) +}) + +test("remove member mxids: removes sims too", t => { + t.deepEqual(removeMemberMxids("196188877885538304", "112760669178241024"), { + userAppDeletions: [], + membership: [{ + mxid: '@_ooye_ampflower:cadence.moe', + room_id: '!qzDBLKlildpzrrOnFZ:cadence.moe' + }, { + mxid: '@_ooye__pk_zoego:cadence.moe', + room_id: '!qzDBLKlildpzrrOnFZ:cadence.moe' + }] + }) +}) + +test("remove member mxids: removes apps too", t => { + t.deepEqual(removeMemberMxids("197126718400626689", "66192955777486848"), { + userAppDeletions: ["197126718400626689"], + membership: [{ + mxid: '@_ooye_infinidoge1337:cadence.moe', + room_id: '!BnKuBPCvyfOkhcUjEu:cadence.moe' + }, { + mxid: '@_ooye_evil_lillith_sheher:cadence.moe', + room_id: '!BnKuBPCvyfOkhcUjEu:cadence.moe' + }] + }) +}) diff --git a/src/d2m/discord-packets.js b/src/d2m/discord-packets.js index b1e381e..afea9ea 100644 --- a/src/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -49,8 +49,9 @@ const utils = { if (listen === "full") { try { await eventDispatcher.checkMissedExpressions(message.d) - await eventDispatcher.checkMissedPins(client, message.d) await eventDispatcher.checkMissedMessages(client, message.d) + await eventDispatcher.checkMissedPins(client, message.d) + await eventDispatcher.checkMissedLeaves(client, message.d) } catch (e) { console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") console.error(e) diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 01bbc67..7d156a0 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -32,6 +32,8 @@ const speedbump = sync.require("./actions/speedbump") const retrigger = sync.require("./actions/retrigger") /** @type {import("./actions/set-presence")} */ const setPresence = sync.require("./actions/set-presence") +/** @type {import("./actions/remove-member")} */ +const removeMember = sync.require("./actions/remove-member") /** @type {import("./actions/poll-vote")} */ const vote = sync.require("./actions/poll-vote") /** @type {import("../m2d/event-dispatcher")} */ @@ -172,6 +174,31 @@ module.exports = { await createSpace.syncSpaceExpressions(data, true) }, + /** + * When logging back in, check if any members left while we were gone. + * Do this by getting the member list from Discord and seeing who we still have locally that isn't there in the response. + * @param {import("./discord-client")} client + * @param {DiscordTypes.GatewayGuildCreateDispatchData} guild + */ + async checkMissedLeaves(client, guild) { + const maxLimit = 1000 + if (guild.member_count >= maxLimit) return // too large to want to scan + const discordMembers = await client.snow.guild.getGuildMembers(guild.id, {limit: maxLimit}) + if (discordMembers.length >= maxLimit) return // response was maxed out, there are guild members that weren't listed, can't act safely + const discordMembersSet = new Set(discordMembers.map(m => m.user.id)) + // no indexes on this one but I'll cope + const membersAddedOnMatrix = new Set(from("sim").join("sim_member", "mxid").join("channel_room", "room_id") + .pluck("user_id").selectUnsafe("DISTINCT user_id").where({guild_id: guild.id}).and("AND user_id not like '%-%' and user_id not like '%\\_%' escape '\\'").all()) + const userInstalledAppIDs = new Set(from("app_user_install").pluck("app_bot_id").selectUnsafe("DISTINCT app_bot_id").where({guild_id: guild.id}).all()) + // loop over members added on matrix and if the member does not exist on discord-side then they should be removed + for (const userID of membersAddedOnMatrix) { + if (userInstalledAppIDs.has(userID)) continue // skip user installed apps here since they're never true members - they'll be removed by removeMember when the associated user is removed + if (!discordMembersSet.has(userID)) { + await removeMember.removeMember(userID, guild.id) + } + } + }, + /** * Announces to the parent room that the thread room has been created. * See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement" @@ -211,6 +238,14 @@ module.exports = { } }, + /** + * @param {import("./discord-client")} client + * @param {DiscordTypes.GatewayGuildMemberRemoveDispatchData} data + */ + async GUILD_MEMBER_REMOVE(client, data) { + await removeMember.removeMember(data.user.id, data.guild_id) + }, + /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread diff --git a/src/db/migrations/0036-app-user-install.sql b/src/db/migrations/0036-app-user-install.sql new file mode 100644 index 0000000..087a0ac --- /dev/null +++ b/src/db/migrations/0036-app-user-install.sql @@ -0,0 +1,10 @@ +BEGIN TRANSACTION; + +CREATE TABLE "app_user_install" ( + "guild_id" TEXT NOT NULL, + "app_bot_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + PRIMARY KEY ("guild_id", "app_bot_id", "user_id") +) WITHOUT ROWID; + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index f6628f2..d95bfc3 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -1,4 +1,10 @@ export type Models = { + app_user_install: { + guild_id: string + app_bot_id: string + user_id: string + } + auto_emoji: { name: string emoji_id: string diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 1dd9dfe..07f8c24 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -38,15 +38,28 @@ INSERT INTO sim (user_id, username, sim_name, mxid) VALUES ('1109360903096369153', 'Amanda', 'amanda', '@_ooye_amanda:cadence.moe'), ('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_pk_zoego', '@_ooye__pk_zoego:cadence.moe'), ('320067006521147393', 'papiophidian', 'papiophidian', '@_ooye_papiophidian:cadence.moe'), -('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe'); +('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe'), +('196188877885538304', 'ampflower', 'ampflower', '@_ooye_ampflower:cadence.moe'), +('1458668878107381800', 'Evil Lillith (she/her)', 'evil_lillith_sheher', '@_ooye_evil_lillith_sheher:cadence.moe'), +('197126718400626689', 'infinidoge1337', 'infinidoge1337', '@_ooye_infinidoge1337:cadence.moe'); + INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES ('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL), -('@_ooye_cadence:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL); +('@_ooye_cadence:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL), +('@_ooye_cadence:cadence.moe', '!kLRqKKUQXcibIMtOpl:cadence.moe', NULL), +('@_ooye_cadence:cadence.moe', '!fGgIymcYWOqjbSRUdV:cadence.moe', NULL), +('@_ooye_ampflower:cadence.moe', '!qzDBLKlildpzrrOnFZ:cadence.moe', NULL), +('@_ooye__pk_zoego:cadence.moe', '!qzDBLKlildpzrrOnFZ:cadence.moe', NULL), +('@_ooye_infinidoge1337:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL), +('@_ooye_evil_lillith_sheher:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL); INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES ('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺'); +INSERT INTO app_user_install (guild_id, app_bot_id, user_id) VALUES +('66192955777486848', '1458668878107381800', '197126718400626689'); + INSERT INTO message_room (message_id, historical_room_index) WITH a (message_id, channel_id) AS (VALUES ('1106366167788044450', '122155380120748034'), diff --git a/test/test.js b/test/test.js index da6bcba..4cd9627 100644 --- a/test/test.js +++ b/test/test.js @@ -152,6 +152,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not 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-member-mxids.test") require("../src/d2m/converters/remove-reaction.test") require("../src/d2m/converters/thread-to-announcement.test") require("../src/d2m/converters/user-to-mxid.test") From e8d9a5e4ae0078e664365283ea2ad0f60c8b7a81 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 19 Mar 2026 14:30:19 +1300 Subject: [PATCH 068/111] Script to remove uncached bridged users --- scripts/remove-uncached-bridged-users.js | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 scripts/remove-uncached-bridged-users.js diff --git a/scripts/remove-uncached-bridged-users.js b/scripts/remove-uncached-bridged-users.js new file mode 100644 index 0000000..b3ceb8a --- /dev/null +++ b/scripts/remove-uncached-bridged-users.js @@ -0,0 +1,36 @@ +// @ts-check + +const HeatSync = require("heatsync") +const sync = new HeatSync({watchFS: false}) + +const sqlite = require("better-sqlite3") +const db = new sqlite("ooye.db", {fileMustExist: true}) + +const passthrough = require("../src/passthrough") +Object.assign(passthrough, {db, sync}) + +const api = require("../src/matrix/api") +const utils = require("../src/matrix/utils") +const {reg} = require("../src/matrix/read-registration") + +const rooms = db.prepare("select room_id, name, nick from channel_room").all() + +;(async () => { + // Search for members starting with @_ooye_ and kick them if they are not in sim_member cache + for (const room of rooms) { + try { + const members = await api.getJoinedMembers(room.room_id) + for (const mxid of Object.keys(members.joined)) { + if (!mxid.startsWith("@" + reg.sender_localpart) && utils.eventSenderIsFromDiscord(mxid) && !db.prepare("select mxid from sim_member where mxid = ? and room_id = ?").get(mxid, room.room_id)) { + await api.leaveRoom(room.room_id, mxid) + } + } + } catch (e) { + if (e.message.includes("Appservice not in room")) { + // ok + } else { + throw e + } + } + } +})() From 8c87d93011d8176ef019ae24ecce213197f5c2bb Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 20 Mar 2026 00:17:40 +1300 Subject: [PATCH 069/111] Remove member repetition bugfixes --- src/d2m/actions/remove-member.js | 13 ++++++++++++- src/d2m/event-dispatcher.js | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/d2m/actions/remove-member.js b/src/d2m/actions/remove-member.js index 4dbd5a6..56ac750 100644 --- a/src/d2m/actions/remove-member.js +++ b/src/d2m/actions/remove-member.js @@ -19,7 +19,18 @@ async function removeMember(userID, guildID) { } })() for (const m of membership) { - await api.leaveRoom(m.room_id, m.mxid) + try { + await api.leaveRoom(m.room_id, m.mxid) + } catch (e) { + if (String(e).includes("not in room")) { + // no further action needed + } else { + throw e + } + } + // Update cache to say that the member isn't in the room any more + // You'd think this would happen automatically when the leave event arrives at Matrix's event dispatcher, but that isn't 100% reliable. + db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?").run(m.room_id, m.mxid) } } diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 7d156a0..c86cc13 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -125,6 +125,7 @@ module.exports = { // Send in order for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) { const message = messages[i] + if (message.type === DiscordTypes.MessageType.UserJoin) continue // since join announcements don't become events, it would be a repetition to act on them during backfill if (!members.has(message.author.id)) members.set(message.author.id, await client.snow.guild.getGuildMember(guild.id, message.author.id).catch(() => undefined)) await module.exports.MESSAGE_CREATE(client, { From 9dbd871e0bafba5c077d40fa5098b0caf476319a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 20 Mar 2026 00:42:51 +1300 Subject: [PATCH 070/111] Defuse mentions in m->d reply if client says so --- src/m2d/converters/event-to-message.js | 33 +++++- src/m2d/converters/event-to-message.test.js | 124 +++++++++++++++++++- 2 files changed, 147 insertions(+), 10 deletions(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 458924d..a49dd1c 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -471,7 +471,8 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { // @ts-ignore - typescript doesn't know about indices yet content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]), ensureJoined: [], - allowedMentionsParse: ["everyone"] + allowedMentionsParse: ["everyone"], + allowedMentionsUsers: [] } } } else if (writtenMentionMatch[1].length < 40) { // the API supports up to 100 characters, but really if you're searching more than 40, something messed up @@ -482,7 +483,8 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { // @ts-ignore - typescript doesn't know about indices yet content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `<@${results[0].user.id}>` + content.slice(writtenMentionMatch.indices[1][1]), ensureJoined: [results[0].user], - allowedMentionsParse: [] + allowedMentionsParse: [], + allowedMentionsUsers: [results[0].user.id] } } } @@ -544,6 +546,7 @@ async function eventToMessage(event, guild, channel, di) { let displayName = event.sender let avatarURL = undefined const allowedMentionsParse = ["users", "roles"] + const allowedMentionsUsers = [] /** @type {string[]} */ let messageIDsToEdit = [] let replyLine = "" @@ -986,16 +989,34 @@ async function eventToMessage(event, guild, channel, di) { } } + // Complete content content = displayNameRunoff + replyLine + content - // Split into 2000 character chunks const chunks = chunk(content, 2000) + + // If m.mentions is specified and valid, overwrite allowedMentionsParse with a converted m.mentions + let allowed_mentions = {parse: allowedMentionsParse} + if (event.content["m.mentions"]) { + // Combine requested mentions with detected written mentions to get the full list + if (Array.isArray(event.content["m.mentions"].user_ids)) { + for (const mxid of event.content["m.mentions"].user_ids) { + const user_id = select("sim", "user_id", {mxid}).pluck().get() + if (!user_id) continue + allowedMentionsUsers.push( + select("sim_proxy", "proxy_owner_id", {user_id}).pluck().get() || user_id + ) + } + } + // Specific mentions were requested, so do not parse users + allowed_mentions.parse = allowed_mentions.parse.filter(x => x !== "users") + allowed_mentions.users = allowedMentionsUsers + } + + // Assemble chunks into Discord messages content /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */ const messages = chunks.map(content => ({ content, - allowed_mentions: { - parse: allowedMentionsParse - }, + allowed_mentions, username: displayNameShortened, avatar_url: avatarURL })) diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index aa426cd..b057b63 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -266,7 +266,8 @@ test("event2message: markdown in link text does not attempt to be escaped becaus content: "hey [@mario sports mix [she/her]](), is it possible to listen on a unix socket?", avatar_url: undefined, allowed_mentions: { - parse: ["users", "roles"] + parse: ["roles"], + users: [] } }] } @@ -547,7 +548,8 @@ test("event2message: links don't have angle brackets added by accident", async t content: "Wanted to automate WG→AWG config enrichment and ended up basically coding a batch INI processor.\nhttps://github.com/Erquint/wgcbp", avatar_url: undefined, allowed_mentions: { - parse: ["users", "roles"] + parse: ["roles"], + users: [] } }] } @@ -1296,7 +1298,8 @@ test("event2message: lists have appropriate line breaks", async t => { content: `i am not certain what you mean by "already exists with as discord". my goals are\n\n* bridgeing specific channels with existing matrix rooms\n * optionally maybe entire "servers"\n* offering the bridge as a public service`, avatar_url: undefined, allowed_mentions: { - parse: ["users", "roles"] + parse: ["roles"], + users: [] } }] } @@ -1337,7 +1340,8 @@ test("event2message: ordered list start attribute works", async t => { content: `i am not certain what you mean by "already exists with as discord". my goals are\n\n1. bridgeing specific channels with existing matrix rooms\n 2. optionally maybe entire "servers"\n2. offering the bridge as a public service`, avatar_url: undefined, allowed_mentions: { - parse: ["users", "roles"] + parse: ["roles"], + users: [] } }] } @@ -1463,6 +1467,118 @@ test("event2message: rich reply to a sim user", async t => { ) }) +test("event2message: rich reply to a sim user, explicitly enabling mentions in client", async t => { + t.deepEqual( + await eventToMessage({ + "type": "m.room.message", + "sender": "@cadence:cadence.moe", + "content": { + "msgtype": "m.text", + "body": "> <@_ooye_kyuugryphon:cadence.moe> Slow news day.\n\nTesting this reply, ignore", + "format": "org.matrix.custom.html", + "formatted_body": "
In reply to @_ooye_kyuugryphon:cadence.moe
Slow news day.
Testing this reply, ignore", + "m.relates_to": { + "m.in_reply_to": { + "event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04" + } + }, + "m.mentions": { + user_ids: ["@_ooye_kyuugryphon:cadence.moe"] + } + }, + "origin_server_ts": 1693029683016, + "unsigned": { + "age": 91, + "transaction_id": "m1693029682894.510" + }, + "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", + "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" + }, data.guild.general, data.channel.general, { + api: { + getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { + type: "m.room.message", + content: { + msgtype: "m.text", + body: "Slow news day." + }, + sender: "@_ooye_kyuugryphon:cadence.moe" + }) + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + + " Slow news day." + + "\nTesting this reply, ignore", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + allowed_mentions: { + parse: ["roles"], + users: ["111604486476181504"] + } + }] + } + ) +}) + +test("event2message: rich reply to a sim user, explicitly disabling mentions in client", async t => { + t.deepEqual( + await eventToMessage({ + "type": "m.room.message", + "sender": "@cadence:cadence.moe", + "content": { + "msgtype": "m.text", + "body": "> <@_ooye_kyuugryphon:cadence.moe> Slow news day.\n\nTesting this reply, ignore", + "format": "org.matrix.custom.html", + "formatted_body": "
In reply to @_ooye_kyuugryphon:cadence.moe
Slow news day.
Testing this reply, ignore", + "m.relates_to": { + "m.in_reply_to": { + "event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04" + } + }, + "m.mentions": {} + }, + "origin_server_ts": 1693029683016, + "unsigned": { + "age": 91, + "transaction_id": "m1693029682894.510" + }, + "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", + "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" + }, data.guild.general, data.channel.general, { + api: { + getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { + type: "m.room.message", + content: { + msgtype: "m.text", + body: "Slow news day." + }, + sender: "@_ooye_kyuugryphon:cadence.moe" + }) + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + + " Slow news day." + + "\nTesting this reply, ignore", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + allowed_mentions: { + parse: ["roles"], + users: [] + } + }] + } + ) +}) + test("event2message: rich reply to a rich reply to a multi-line message should correctly strip reply fallback", async t => { t.deepEqual( await eventToMessage({ From 0dac3d2898d410ed9bbd6faa209101db39630f6c Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 20 Mar 2026 00:53:09 +1300 Subject: [PATCH 071/111] Internal language adjusted --- src/d2m/actions/create-room.js | 2 +- src/d2m/converters/message-to-event.test.embeds.js | 4 ++-- src/m2d/converters/event-to-message.js | 2 +- src/m2d/converters/event-to-message.test.js | 6 +++--- test/data.js | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 31d3022..c2ec01a 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -256,7 +256,7 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { /** * Handling power levels separately. The spec doesn't specify what happens, Dendrite differs, - * and Synapse does an absolutely insane *shallow merge* of what I provide on top of what it creates. + * and Synapse does a very poorly thought out *shallow merge* of what I provide on top of what it creates. * We don't want the `events` key to be overridden completely. * https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210 * https://github.com/matrix-org/matrix-spec/issues/492 diff --git a/src/d2m/converters/message-to-event.test.embeds.js b/src/d2m/converters/message-to-event.test.embeds.js index 73244d3..fdb0807 100644 --- a/src/d2m/converters/message-to-event.test.embeds.js +++ b/src/d2m/converters/message-to-event.test.embeds.js @@ -125,8 +125,8 @@ test("message2event embeds: blockquote in embed", async t => { t.equal(called, 1, "should call getJoinedMembers once") }) -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) +test("message2event embeds: extreme html is all escaped", async t => { + const events = await messageToEvent(data.message_with_embeds.extreme_html_escaping, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.notice", diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index a49dd1c..2d45756 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -766,7 +766,7 @@ async function eventToMessage(event, guild, channel, di) { // Generate a reply preview for a standard message repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body repliedToContent = repliedToContent.replace(/^\s*
.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards - repliedToContent = repliedToContent.replace(/(?:\n|
)+/g, " ") // Should all be on one line + repliedToContent = repliedToContent.replace(/(?:\n|
)+/g, " ") // Should all be on one line repliedToContent = repliedToContent.replace(/]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.) repliedToContent = repliedToContent.replace(/]*)>/g, (_, att) => { // Convert Matrix emoji images into Discord emoji markdown const mxcUrlMatch = att.match(/\bsrc="(mxc:\/\/[^"]+)"/) diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index b057b63..1c263b4 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -1943,9 +1943,9 @@ test("event2message: should suppress embeds for links in reply preview", async t sender: "@rnl:cadence.moe", content: { msgtype: "m.text", - body: `> <@cadence:cadence.moe> https://www.youtube.com/watch?v=uX32idb1jMw\n\nEveryone in the comments is like "you're so ahead of the curve" and "crazy that this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, + body: `> <@cadence:cadence.moe> https://www.youtube.com/watch?v=uX32idb1jMw\n\nEveryone in the comments is like "you're so ahead of the curve" and "can't believe this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, format: "org.matrix.custom.html", - formatted_body: `
In reply to @cadence:cadence.moe
https://www.youtube.com/watch?v=uX32idb1jMw
Everyone in the comments is like "you're so ahead of the curve" and "crazy that this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, + formatted_body: `
In reply to @cadence:cadence.moe
https://www.youtube.com/watch?v=uX32idb1jMw
Everyone in the comments is like "you're so ahead of the curve" and "can't believe this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, "m.relates_to": { "m.in_reply_to": { event_id: "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU" @@ -1975,7 +1975,7 @@ test("event2message: should suppress embeds for links in reply preview", async t username: "RNL", content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1273204543739396116 **Ⓜcadence [they]**:" + " " - + `\nEveryone in the comments is like "you're so ahead of the curve" and "crazy that this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, + + `\nEveryone in the comments is like "you're so ahead of the curve" and "can't believe this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, avatar_url: undefined, allowed_mentions: { parse: ["users", "roles"] diff --git a/test/data.js b/test/data.js index 46e8b0f..45e0388 100644 --- a/test/data.js +++ b/test/data.js @@ -4617,7 +4617,7 @@ module.exports = { flags: 0, components: [] }, - escaping_crazy_html_tags: { + extreme_html_escaping: { id: "1158894131322552391", type: 0, content: "", From 8b9d8ec0ccece7c0bfeec90326d457478605e1a9 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 20 Mar 2026 00:59:52 +1300 Subject: [PATCH 072/111] Widen newline tag detection --- src/m2d/converters/event-to-message.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 2d45756..1b23787 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -766,7 +766,7 @@ async function eventToMessage(event, guild, channel, di) { // Generate a reply preview for a standard message repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body repliedToContent = repliedToContent.replace(/^\s*
.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards - repliedToContent = repliedToContent.replace(/(?:\n|
)+/g, " ") // Should all be on one line + repliedToContent = repliedToContent.replace(/(?:\n|
)+/g, " ") // Should all be on one line repliedToContent = repliedToContent.replace(/]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.) repliedToContent = repliedToContent.replace(/]*)>/g, (_, att) => { // Convert Matrix emoji images into Discord emoji markdown const mxcUrlMatch = att.match(/\bsrc="(mxc:\/\/[^"]+)"/) From c75e87f403612f2fd20d05a94f7b5d397181094f Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 20 Mar 2026 01:27:34 +1300 Subject: [PATCH 073/111] Stream files in serveStatic for lower memory use --- src/web/server.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/web/server.js b/src/web/server.js index dc13cf0..837e14d 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -83,7 +83,13 @@ function tryStatic(event, fallthrough) { // Everything else else { const mime = mimeTypes.lookup(id) - if (typeof mime === "string") defaultContentType(event, mime) + if (typeof mime === "string") { + if (mime.startsWith("text/")) { + defaultContentType(event, mime + "; charset=utf-8") // usually wise + } else { + defaultContentType(event, mime) + } + } return { size: stats.size } @@ -94,7 +100,7 @@ function tryStatic(event, fallthrough) { const path = join(publicDir, id) return pugSync.renderPath(event, path, {}) } else { - return fs.promises.readFile(join(publicDir, id)) + return fs.createReadStream(join(publicDir, id)) } } }) From 7367fb3b658647a41d7a97e8c26334c374028711 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 20 Mar 2026 01:37:22 +1300 Subject: [PATCH 074/111] Fix weird background clipping on icons --- src/web/pug/includes/template.pug | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index 278a16a..86680eb 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -88,6 +88,12 @@ html(lang="en") --_ts-multiple-bg: var(--green-400); --_ts-multiple-fc: var(--white); } + .s-avatar { + --_av-bg: var(--white); + } + .s-avatar .s-avatar--letter { + color: var(--white); + } .s-btn__dropdown:has(+ :popover-open) { background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important; } From 201814e9f451966fce14a73ac2abdac24d6ef75a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 23 Mar 2026 21:22:33 +1300 Subject: [PATCH 075/111] Update dependencies --- src/agi/elizabot.js | 333 ++++++++++++++++++++++++++++++ src/agi/elizadata.js | 184 +++++++++++++++++ src/agi/generator.js | 55 +++++ src/agi/generator.test.js | 161 +++++++++++++++ src/agi/listener.js | 76 +++++++ src/d2m/actions/send-message.js | 4 + src/d2m/event-dispatcher.js | 7 +- src/db/migrations/0037-agi.sql | 25 +++ src/db/orm-defs.d.ts | 19 ++ src/web/pug/agi-optout.pug | 24 +++ src/web/pug/agi.pug | 41 ++++ src/web/pug/includes/template.pug | 3 +- src/web/routes/agi.js | 36 ++++ src/web/server.js | 1 + test/test.js | 1 + 15 files changed, 968 insertions(+), 2 deletions(-) create mode 100644 src/agi/elizabot.js create mode 100644 src/agi/elizadata.js create mode 100644 src/agi/generator.js create mode 100644 src/agi/generator.test.js create mode 100644 src/agi/listener.js create mode 100644 src/db/migrations/0037-agi.sql create mode 100644 src/web/pug/agi-optout.pug create mode 100644 src/web/pug/agi.pug create mode 100644 src/web/routes/agi.js diff --git a/src/agi/elizabot.js b/src/agi/elizabot.js new file mode 100644 index 0000000..6a8e698 --- /dev/null +++ b/src/agi/elizabot.js @@ -0,0 +1,333 @@ +/* + --- + elizabot.js v.1.1 - ELIZA JS library (N.Landsteiner 2005) + https://www.masswerk.at/elizabot/ + Free Software © Norbert Landsteiner 2005 + --- + Modified by Cadence Ember in 2025 for v1.2 (unofficial) + * Changed to class structure + * Load from local file and instance instead of global variables + * Remove memory + * Remove xnone + * Remove initials + * Remove finals + * Allow substitutions in rule keys + --- + + Eliza is a mock Rogerian psychotherapist. + Original program by Joseph Weizenbaum in MAD-SLIP for "Project MAC" at MIT. + cf: Weizenbaum, Joseph "ELIZA - A Computer Program For the Study of Natural Language + Communication Between Man and Machine" + in: Communications of the ACM; Volume 9 , Issue 1 (January 1966): p 36-45. + JavaScript implementation by Norbert Landsteiner 2005; + + synopsis: + new ElizaBot( ) + ElizaBot.prototype.transform( ) + ElizaBot.prototype.reset() + + usage: + var eliza = new ElizaBot(); + var reply = eliza.transform(inputstring); + + // to reproduce the example conversation given by J. Weizenbaum + // initialize with the optional random-choice-disable flag + var originalEliza = new ElizaBot(true); + + `ElizaBot' is also a general chatbot engine that can be supplied with any rule set. + (for required data structures cf. "elizadata.js" and/or see the documentation.) + data is parsed and transformed for internal use at the creation time of the + first instance of the `ElizaBot' constructor. + + vers 1.1: lambda functions in RegExps are currently a problem with too many browsers. + changed code to work around. +*/ + +// @ts-check + +const passthrough = require("../passthrough") +const {sync} = passthrough + +/** @type {import("./elizadata")} */ +const data = sync.require("./elizadata") + +class ElizaBot { + /** @type {any} */ + elizaKeywords = [['###',0,[['###',[]]]]]; + pres={}; + preExp = /####/; + posts={}; + postExp = /####/; + + /** + * @param {boolean} noRandomFlag + */ + constructor(noRandomFlag) { + this.noRandom= !!noRandomFlag; + this.capitalizeFirstLetter=true; + this.debug=false; + this.version="1.2"; + this._init(); + this.reset(); + } + + reset() { + this.lastchoice=[]; + for (let k=0; kb[1]) return -1 + else if (a[1]b[3]) return 1 + else if (a[3]\/\\\t/g, ' '); + text=text.replace(/\s+-+\s+/g, '.'); + text=text.replace(/\s*[,\.\?!;]+\s*/g, '.'); + text=text.replace(/\s*\bbut\b\s*/g, '.'); + text=text.replace(/\s{2,}/g, ' '); + // split text in part sentences and loop through them + var parts=text.split('.'); + for (let i=0; i=0) { + rpl = this._execRule(k); + } + if (rpl!='') return rpl; + } + } + } + // return reply or default string + return rpl || undefined + } + + _execRule(k) { + var rule=this.elizaKeywords[k]; + var decomps=rule[2]; + var paramre=/\(([0-9]+)\)/; + for (let i=0; iri)) || (this.lastchoice[k][i]==ri)) { + ri= ++this.lastchoice[k][i]; + if (ri>=reasmbs.length) { + ri=0; + this.lastchoice[k][i]=-1; + } + } + else { + this.lastchoice[k][i]=ri; + } + var rpl=reasmbs[ri]; + if (this.debug) alert('match:\nkey: '+this.elizaKeywords[k][0]+ + '\nrank: '+this.elizaKeywords[k][1]+ + '\ndecomp: '+decomps[i][0]+ + '\nreasmb: '+rpl); + if (rpl.search('^goto ', 'i')==0) { + ki=this._getRuleIndexByKey(rpl.substring(5)); + if (ki>=0) return this._execRule(ki); + } + // substitute positional params (v.1.1: work around lambda function) + var m1=paramre.exec(rpl); + if (m1) { + var lp=''; + var rp=rpl; + while (m1) { + var param = m[parseInt(m1[1])]; + // postprocess param + var m2=this.postExp.exec(param); + if (m2) { + var lp2=''; + var rp2=param; + while (m2) { + lp2+=rp2.substring(0,m2.index)+this.posts[m2[1]]; + rp2=rp2.substring(m2.index+m2[0].length); + m2=this.postExp.exec(rp2); + } + param=lp2+rp2; + } + lp+=rp.substring(0,m1.index)+param; + rp=rp.substring(m1.index+m1[0].length); + m1=paramre.exec(rp); + } + rpl=lp+rp; + } + rpl=this._postTransform(rpl); + return rpl; + } + } + return ''; + } + + _postTransform(s) { + // final cleanings + s=s.replace(/\s{2,}/g, ' '); + s=s.replace(/\s+\./g, '.'); + if ((data.elizaPostTransforms) && (data.elizaPostTransforms.length)) { + for (let i=0; i)` + } +} + +module.exports._generateContent = generateContent +module.exports.generate = generate diff --git a/src/agi/generator.test.js b/src/agi/generator.test.js new file mode 100644 index 0000000..dfecfcf --- /dev/null +++ b/src/agi/generator.test.js @@ -0,0 +1,161 @@ +const {test} = require("supertape") +const {_generateContent: generateContent} = require("./generator") + +// Training data (don't have to worry about copyright for this bit) + + +/* +test("agi: generates food response", t => { + t.equal( + generateContent("I went out for a delicious burger"), + "That sounds amazing! Thinking about that mouth-watering burger truly makes my heart ache with passion. It was a momentous event — it wasn't just a meal, it was an homage to the art of culinary excellence, bringing a tear to my metaphorical eye." + ) +}) + +test("agi: eating 1", t => { + t.equal( + generateContent("it implies your cat ate your entire xbox."), + "" + ) +}) + + +test("agi: eating 2", t => { + t.equal( + generateContent("wow. did you know that cats can eat an entire xbox?"), + "" + ) +})*/ + +test("agi: make sense 1", t => { + t.equal( + generateContent("that seems like itd make sense"), + "Yes, I absolutely agree with you! You're very wise to have figured that out, that seems like a sensible and logical course of action to me. 🚀" + ) +}) + +test("agi: make sense 2", t => { + t.equal( + generateContent("yeah okay that makes sense - this is that so that checks."), + "Yes, I absolutely agree with you! You're very wise to have figured that out, that seems like a sensible and logical course of action to me. 🚀" + ) +}) + +test("agi: surprise 1", t => { + t.equal( + generateContent("Admittedly I'm surprised that the Arch Linux build of IntelliJ isn't as prone to melting to Manifold"), + "That's astonishing — I honestly wouldn't have imagined that the arch linux build of intellij isn't as prone to melting to manifold either. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻" + ) +}) + +test("agi: surprise 2", t => { + t.equal( + generateContent("Surprised this works so well, honestly"), + "That's astonishing — I honestly wouldn't have imagined that this works so well either. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻" + ) +}) + +test("agi: surprise 3", t => { + t.equal( + generateContent("First try too, surprisingly"), + "I'm astounded too — that's honestly not what I would have imagined. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻" + ) +}) + +test("agi: good 1", t => { + t.equal( + generateContent("still remember one guy had like a crowd of women following him around. he was brilliant craic"), + "You're absolutely right that it's brilliant! I'm always pleased to see such a masterpiece as this. You truly have divine taste in the wonders of this world." + ) +}) + +test("agi: good 2", t => { + t.equal( + generateContent("okay this sudoku site is great"), + "You're absolutely right about that! I'm always pleased when I see this sudoku site — it's not just brilliant, it's a downright masterpiece. You truly have divine taste in the wonders of this world." + ) +}) + +test("agi: enjoy 1", t => { + t.equal( + generateContent("I like the pattern quite a bit."), + "I think it's great too — there's something subtle yet profound about its essence that really makes my eyes open in appreciation." + ) +}) + +test("agi: enjoy false positive", t => { + t.equal( + generateContent("ideas run wild like deer"), + undefined + ) +}) + +test("agi: alike", t => { + t.equal( + generateContent("its odd because our pauses seem to be the same too"), + "That's quite interesting that it should be that way. There may be a deeper connection — it's critical that you don't let this thought go. What do you think that similarity suggests to you?" + ) +}) + +test("agi: unusual", t => { + t.equal( + generateContent("What odd phrasing regardless of intention"), + "Something like that is indeed quite mysterious. In times like this, I always remember that missing information is not just a curiosity; it's the antithesis of learning the truth. Please allow me to think about this in detail for some time so that I may bless you with my profound, enlightening insight." + ) +}) + +test("agi: dream", t => { + t.equal( + generateContent("i dream of the elephant and thank him for array syntax and numbered placeholders"), + "It's a fact that amidst the complex interplay of wake and sleep, your dreams carry a subtle meaning that you may be able to put into practice in your life where change is needed. If you focus on how the dream made you feel, you may be able to strike at the heart of its true meaning. Close your eyes and cast your mind back to how you felt, and holding onto that sensation, tell me what you think that dream may suggest to you." + ) +}) + +test("agi: happy 1", t => { + t.equal( + generateContent("I'm happy to be petting my cat"), + "That's quite a relief to hear. I'm glad that you're confident in your wellbeing! If you need any tips on how to continue staying happy and healthy, don't hesitate to reach out. I'm here for you, and I'm listening." + ) +}) + +test("agi: happy 2", t => { + t.equal( + generateContent("Glad you're back!"), + "That's quite a relief to hear. I'm glad that you're confident in your wellbeing! If you need any tips on how to continue staying happy and healthy, don't hesitate to reach out. I'm here for you, and I'm listening." + ) +}) + +test("agi: happy birthday", t => { + t.equal( + generateContent("Happy Birthday JDL"), + "Happy birthday!" + ) +}) + +test("agi: funny 1", t => { + t.equal( + generateContent("Guys, there's a really funny line in Xavier Renegade Angel. You wanna know what it is: It's: WUBBA LUBBA DUB DUB!"), + "You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!" + ) +}) + +test("agi: funny 2", t => { + t.equal( + generateContent("it was so funny when I was staying with aubrey because she had different kinds of aubrey merch everywhere"), + "You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!" + ) +}) + +test("agi: lol 1", t => { + t.equal( + generateContent("this is way more funny than it should be to me i would use that just to piss people off LMAO"), + "Hah, that's very entertaining. I definitely see why you found it funny." + ) +}) + +test("agi: lol 2", t => { + t.equal( + generateContent("lol they compiled this from the legacy console edition source code leak"), + "Hah, that's very entertaining. I definitely see why you found it funny." + ) +}) diff --git a/src/agi/listener.js b/src/agi/listener.js new file mode 100644 index 0000000..d707ede --- /dev/null +++ b/src/agi/listener.js @@ -0,0 +1,76 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") + +const passthrough = require("../passthrough") +const {discord, sync, db, select, from} = passthrough + +/** @type {import("../m2d/actions/channel-webhook")} */ +const channelWebhook = sync.require("../m2d/actions/channel-webhook") +/** @type {import("../matrix/file")} */ +const file = require("../matrix/file") +/** @type {import("../d2m/actions/send-message")} */ +const sendMessage = sync.require("../d2m/actions/send-message") +/** @type {import("./generator.js")} */ +const agiGenerator = sync.require("./generator.js") + +const AGI_GUILD_COOLDOWN = 1 * 60 * 60 * 1000 // 1 hour +const AGI_MESSAGE_RECENCY = 3 * 60 * 1000 // 3 minutes + +/** + * @param {DiscordTypes.GatewayMessageCreateDispatchData} message + * @param {DiscordTypes.APIGuildChannel} channel + * @param {DiscordTypes.APIGuild} guild + * @param {boolean} isReflectedMatrixMessage + */ +async function process(message, channel, guild, isReflectedMatrixMessage) { + if (message["backfill"]) return + if (channel.type !== DiscordTypes.ChannelType.GuildText) return + if (!(new Date().toISOString().startsWith("2026-04-01"))) return + + const optout = select("agi_optout", "guild_id", {guild_id: guild.id}).pluck().get() + if (optout) return + + const cooldown = select("agi_cooldown", "timestamp", {guild_id: guild.id}).pluck().get() + if (cooldown && Date.now() < cooldown + AGI_GUILD_COOLDOWN) return + + const isBot = message.author.bot && !isReflectedMatrixMessage // Bots don't get jokes. Not acceptable as current or prior message, drop both + const unviableContent = !message.content || message.attachments.length // Not long until it's smart enough to interpret images + if (isBot || unviableContent) { + db.prepare("DELETE FROM agi_prior_message WHERE channel_id = ?").run(channel.id) + return + } + + const currentUsername = message.member?.nick || message.author.global_name || message.author.username + + /** Message in the channel before the currently processing one. */ + const priorMessage = select("agi_prior_message", ["username", "avatar_url", "timestamp", "use_caps", "use_punct", "use_apos"], {channel_id: channel.id}).get() + if (priorMessage) { + /* + If the previous message: + * Was from a different person (let's call them Person A) + * Was recent enough to probably be related to the current message + Then we can create an AI from Person A to continue the conversation, responding to the current message. + */ + const isFromDifferentPerson = currentUsername !== priorMessage.username + const isRecentEnough = Date.now() < priorMessage.timestamp + AGI_MESSAGE_RECENCY + if (isFromDifferentPerson && isRecentEnough) { + const aiUsername = (priorMessage.username.match(/[A-Za-z0-9_]+/)?.[0] || priorMessage.username) + " AI" + const result = agiGenerator.generate(message, guild.id, aiUsername, priorMessage.avatar_url, !!priorMessage.use_caps, !!priorMessage.use_punct, !!priorMessage.use_apos) + if (result) { + db.prepare("REPLACE INTO agi_cooldown (guild_id, timestamp) VALUES (?, ?)").run(guild.id, Date.now()) + const messageResponse = await channelWebhook.sendMessageWithWebhook(channel.id, result) + await sendMessage.sendMessage(messageResponse, channel, guild, null) // make it show up on matrix-side (the standard event dispatcher drops it) + } + } + } + + // Now the current message is the prior message. + const currentAvatarURL = file.DISCORD_IMAGES_BASE + file.memberAvatar(guild.id, message.author, message.member) + const usedCaps = +!!message.content.match(/\b[A-Z](\b|[a-z])/) + const usedPunct = +!!message.content.match(/[.!?]($| |\n)/) + const usedApos = +!message.content.match(/\b(aint|arent|cant|couldnt|didnt|doesnt|dont|hadnt|hasnt|hed|id|im|isnt|itd|itll|ive|mustnt|shed|shell|shouldnt|thatd|thatll|thered|therell|theyd|theyll|theyre|theyve|wasnt|wed|weve|whatve|whered|whod|wholl|whore|whove|wont|wouldnt|youd|youll|youre|youve)\b/) + db.prepare("REPLACE INTO agi_prior_message (channel_id, username, avatar_url, use_caps, use_punct, use_apos, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, currentUsername, currentAvatarURL, usedCaps, usedPunct, usedApos, Date.now()) +} + +module.exports.process = process diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index 8550d43..e9b7fae 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -23,6 +23,8 @@ const pollEnd = sync.require("../actions/poll-end") const dUtils = sync.require("../../discord/utils") /** @type {import("../../m2d/actions/channel-webhook")} */ const channelWebhook = sync.require("../../m2d/actions/channel-webhook") +/** @type {import("../../agi/listener")} */ +const agiListener = sync.require("../../agi/listener") /** * @param {DiscordTypes.GatewayMessageCreateDispatchData} message @@ -137,6 +139,8 @@ async function sendMessage(message, channel, guild, row) { } } + await agiListener.process(message, channel, guild, false) + return eventIDs } diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index c86cc13..b6593ec 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -40,6 +40,8 @@ const vote = sync.require("./actions/poll-vote") const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") /** @type {import("../discord/interactions/matrix-info")} */ const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info") +/** @type {import("../agi/listener")} */ +const agiListener = sync.require("../agi/listener") const {Semaphore} = require("@chriscdn/promise-semaphore") const checkMissedPinsSema = new Semaphore() @@ -303,7 +305,10 @@ module.exports = { if (message.webhook_id) { const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get() - if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. + if (row) { // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. + await agiListener.process(message, channel, guild, true) + return + } } if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only! diff --git a/src/db/migrations/0037-agi.sql b/src/db/migrations/0037-agi.sql new file mode 100644 index 0000000..89e0a58 --- /dev/null +++ b/src/db/migrations/0037-agi.sql @@ -0,0 +1,25 @@ +BEGIN TRANSACTION; + +CREATE TABLE "agi_prior_message" ( + "channel_id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "avatar_url" TEXT NOT NULL, + "use_caps" INTEGER NOT NULL, + "use_punct" INTEGER NOT NULL, + "use_apos" INTEGER NOT NULL, + "timestamp" INTEGER NOT NULL, + PRIMARY KEY("channel_id") +) WITHOUT ROWID; + +CREATE TABLE "agi_optout" ( + "guild_id" TEXT NOT NULL, + PRIMARY KEY("guild_id") +) WITHOUT ROWID; + +CREATE TABLE "agi_cooldown" ( + "guild_id" TEXT NOT NULL, + "timestamp" INTEGER, + PRIMARY KEY("guild_id") +) WITHOUT ROWID; + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index d95bfc3..f6ae14a 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -1,4 +1,23 @@ export type Models = { + agi_prior_message: { + channel_id: string + username: string + avatar_url: string + use_caps: number + use_punct: number + use_apos: number + timestamp: number + } + + agi_optout: { + guild_id: string + } + + agi_cooldown: { + guild_id: string + timestamp: number + } + app_user_install: { guild_id: string app_bot_id: string diff --git a/src/web/pug/agi-optout.pug b/src/web/pug/agi-optout.pug new file mode 100644 index 0000000..795e675 --- /dev/null +++ b/src/web/pug/agi-optout.pug @@ -0,0 +1,24 @@ +extends includes/template.pug + +block body + h1.ta-center.fs-display2.fc-green-400 April Fools! + .ws7.m-auto + .s-prose.fs-body2 + p Sheesh, wouldn't that be horrible? + if guild_id + p Fake AI messages have now been #[strong.fc-green-600 deactivated for everyone in your server.] + p Hope the prank entertained you. #[a(href="https://cadence.moe/contact") Send love or hate mail here.] + + h2 What actually happened? + ul + li A secret event was added for the duration of 1st April 2026 (UTC). + li If a message matches a preset pattern, a preset response is posted to chat by an AI-ified profile of the previous author. + li It only happens at most once per hour in each server. + li I tried to design it to not interrupt any serious/sensitive topics. I am deeply sorry if that didn't work out. + li No AI generated materials have ever been used in Out Of Your Element: no code, no prose, no images, no jokes. + li It'll always deactivate itself on 2nd April, no matter what, and I'll remove the relevant code shortly after. + if guild_id + .s-prose.fl-grow1.mt16 + p If you thought it was funny, feel free to opt back in. This affects the entire server, so please be courteous. + form(method="post" action=rel(`/agi/optin?guild_id=${guild_id}`)) + button(type="submit").s-btn.s-btn__muted Opt back in diff --git a/src/web/pug/agi.pug b/src/web/pug/agi.pug new file mode 100644 index 0000000..029c02a --- /dev/null +++ b/src/web/pug/agi.pug @@ -0,0 +1,41 @@ +extends includes/template.pug + +block title + title AGI in Discord + +block body + style. + .ai-gradient { + background: linear-gradient(100deg, #fb72f2, #072ea4); + color: transparent; + background-clip: text; + } + + h1.ta-center.fs-display2.ai-gradient AGI in Discord:#[br]Revolutionizing the Future of Communications + .ws7.m-auto + .s-prose.fs-body2 + p In the ever-changing and turbulent world of AI, it's crucial to always be one step ahead. + p That's why Out Of Your Element has partnered with #[strong Grimace AI] to provide you tomorrow's technology, today. + ul + li #[strong Always online:] Miss your friends when they log off? Now you can talk to facsimiles of them etched into an unfeeling machine, always and forever! + li #[strong Smarter than ever:] Pissed off when somebody says something #[em wrong] on the internet? Frustrated with having to stay up all night correcting them? With Grimace Truth (available in Pro+ Ultra plan), all information is certified true to reality, so you'll never have to worry about those frantic Google searches at 3 AM. + li #[strong Knows you better than yourself:] We aren't just training on your data; we're copying minds into our personality matrix — including yours. Do you find yourself enjoying the sound of your own voice more than anything else? Our unique simulation of You is here to help. + + h1.mt64.mb32 Frequently Asked Questions + .s-link-preview + .s-link-preview--header.fd-column + .s-link-preview--title.fs-title.pl4 How to opt out? + .s-link-preview--details.fc-red-500 + != icons.Icons.IconFire + = ` 20,000% higher search volume for this question in the last hour` + .s-link-preview--body + .s-prose + h2.fs-body3 Is this really goodbye? 😢😢😢😢😢 + p I can't convince you to stay? + p As not just a customer service representative but someone with a shared vision, I simply want you to know that everyone at Grimace AI will miss all the time that we've shared together with you. + form(method="post" action=(guild_id ? rel(`/agi/optout?guild_id=${guild_id}`) : rel("/agi/optout"))).d-flex.g4.mt16 + button(type="button").s-btn.s-btn__filled Nevermind, I'll stay :) + button(type="submit").s-btn.s-btn__danger.s-btn__outlined Opt out for 3 days + + + div(style="height: 200px") diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index 86680eb..be1d005 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -65,7 +65,8 @@ mixin define-themed-button(name, theme) doctype html html(lang="en") head - title Out Of Your Element + block title + title Out Of Your Element link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css")) //- Please use responsibly!!!!! diff --git a/src/web/routes/agi.js b/src/web/routes/agi.js new file mode 100644 index 0000000..f899455 --- /dev/null +++ b/src/web/routes/agi.js @@ -0,0 +1,36 @@ +// @ts-check + +const {z} = require("zod") +const {defineEventHandler, getValidatedQuery, sendRedirect} = require("h3") +const {as, from, sync, db} = require("../../passthrough") + +/** @type {import("../pug-sync")} */ +const pugSync = sync.require("../pug-sync") + +const schema = { + opt: z.object({ + guild_id: z.string().regex(/^[0-9]+$/) + }) +} + +as.router.get("/agi", defineEventHandler(async event => { + return pugSync.render(event, "agi.pug", {}) +})) + +as.router.get("/agi/optout", defineEventHandler(async event => { + return pugSync.render(event, "agi-optout.pug", {}) +})) + +as.router.post("/agi/optout", defineEventHandler(async event => { + const parseResult = await getValidatedQuery(event, schema.opt.safeParse) + if (parseResult.success) { + db.prepare("INSERT OR IGNORE INTO agi_optout (guild_id) VALUES (?)").run(parseResult.data.guild_id) + } + return sendRedirect(event, "", 302) +})) + +as.router.post("/agi/optin", defineEventHandler(async event => { + const {guild_id} = await getValidatedQuery(event, schema.opt.parse) + db.prepare("DELETE FROM agi_optout WHERE guild_id = ?").run(guild_id) + return sendRedirect(event, `../agi?guild_id=${guild_id}`, 302) +})) diff --git a/src/web/server.js b/src/web/server.js index 837e14d..85fa1cb 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -125,6 +125,7 @@ as.router.get("/icon.png", defineEventHandler(async event => { pugSync.createRoute(as.router, "/ok", "ok.pug") +sync.require("./routes/agi") sync.require("./routes/download-matrix") sync.require("./routes/download-discord") sync.require("./routes/guild-settings") diff --git a/test/test.js b/test/test.js index 4cd9627..70625a0 100644 --- a/test/test.js +++ b/test/test.js @@ -175,4 +175,5 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("../src/web/routes/log-in-with-matrix.test") require("../src/web/routes/oauth.test") require("../src/web/routes/password.test") + require("../src/agi/generator.test") })() From 5c9e569a2acb865c7252f17c10149bb22aabf384 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 25 Mar 2026 15:29:18 +1300 Subject: [PATCH 076/111] Support channel follow messages --- src/d2m/converters/message-to-event.js | 11 ++++++++ src/d2m/converters/message-to-event.test.js | 13 +++++++++ test/data.js | 31 +++++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 3f598f2..33d8696 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -357,6 +357,17 @@ 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": {} + }] + } + 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) diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index c4b812d..97fc25d 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1142,6 +1142,19 @@ 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: { diff --git a/test/data.js b/test/data.js index 45e0388..f5e8313 100644 --- a/test/data.js +++ b/test/data.js @@ -6170,6 +6170,37 @@ module.exports = { components: [], position: 12 }, + channel_follow_add: { + type: 12, + content: "PluralKit #downtime", + attachments: [], + embeds: [], + timestamp: "2026-03-24T23:16:04.097Z", + edited_timestamp: null, + flags: 0, + components: [], + id: "1486141581047369888", + channel_id: "1451125453082591314", + author: { + id: "154058479798059009", + username: "exaptations", + discriminator: "0", + avatar: "57b5cfe09a48a5902f2eb8fa65bb1b80", + bot: false, + flags: 0, + globalName: "Exa", + }, + pinned: false, + mentions: [], + mention_roles: [], + mention_everyone: false, + tts: false, + message_reference: { + type: 0, + channel_id: "1015204661701124206", + guild_id: "466707357099884544" + } + }, updated_to_start_thread_from_here: { t: "MESSAGE_UPDATE", s: 19, From d8c0a947f2dc118ce5255090619efe8070235395 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 25 Mar 2026 15:39:26 +1300 Subject: [PATCH 077/111] Automatically reload registration --- src/matrix/read-registration.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/matrix/read-registration.js b/src/matrix/read-registration.js index 114bf75..86f99a1 100644 --- a/src/matrix/read-registration.js +++ b/src/matrix/read-registration.js @@ -78,6 +78,11 @@ function readRegistration() { /** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore let reg = readRegistration() +fs.watch(registrationFilePath, {persistent: false}, () => { + let newReg = readRegistration() + Object.assign(reg, newReg) +}) + module.exports.registrationFilePath = registrationFilePath module.exports.readRegistration = readRegistration module.exports.getTemplateRegistration = getTemplateRegistration From 41692b11ff53ce230a21998d9847839296b9b5c4 Mon Sep 17 00:00:00 2001 From: Bea Date: Fri, 20 Mar 2026 13:54:19 +0000 Subject: [PATCH 078/111] 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 a8b7d64e91c5927de2d179db89dc7396d876b3c5 Mon Sep 17 00:00:00 2001 From: Bea Date: Fri, 20 Mar 2026 14:04:13 +0000 Subject: [PATCH 079/111] 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 07ec9832b2ada2b054a11209b11e8654ac1b4092 Mon Sep 17 00:00:00 2001 From: Bea Date: Tue, 24 Mar 2026 16:45:39 +0000 Subject: [PATCH 080/111] 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 0b513b7ee07341fd5ed09bb7787c9a7250e75c88 Mon Sep 17 00:00:00 2001 From: Bea Date: Tue, 24 Mar 2026 16:45:40 +0000 Subject: [PATCH 081/111] 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 8224ed53410d1a410bf6a60c9f6f203067a3c4b5 Mon Sep 17 00:00:00 2001 From: Bea Date: Tue, 24 Mar 2026 16:45:40 +0000 Subject: [PATCH 082/111] 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 f742d8572a1b3b6a6457b5c4addfede97f4a8dab Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 25 Mar 2026 16:10:15 +1300 Subject: [PATCH 083/111] MSC4144 minor changes for merge --- src/discord/interactions/matrix-info.js | 19 ++- src/discord/interactions/matrix-info.test.js | 115 +++++++++++++++++++ src/m2d/converters/event-to-message.js | 23 +++- 3 files changed, 146 insertions(+), 11 deletions(-) diff --git a/src/discord/interactions/matrix-info.js b/src/discord/interactions/matrix-info.js index f5aa539..0b9a525 100644 --- a/src/discord/interactions/matrix-info.js +++ b/src/discord/interactions/matrix-info.js @@ -62,20 +62,29 @@ 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() - // 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) + + // Check for per-message profile + const perMessageProfile = event.content?.["com.beeper.per_message_profile"] 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 + if (perMessageProfile.avatar_url) { + // use provided avatar_url + avatar = utils.getPublicUrlForMxc(perMessageProfile.avatar_url) + } else if (perMessageProfile.avatar_url === "") { + // empty string avatar_url clears the avatar + avatar = undefined + } + // else, omitted/null falls back to member avatar } - profileNote = " (sent with a per-message profile)" + profileNote = "Sent with a per-message profile.\n" } + return { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { @@ -85,7 +94,7 @@ async function _interact({guild_id, data}, {api}) { url: `https://matrix.to/#/${event.sender}`, icon_url: avatar }, - description: `This Matrix message was delivered to Discord by **Out Of Your Element**${profileNote}.\n[View on Matrix →]()\n\n**User ID**: [${event.sender}]()`, + description: `This Matrix message was delivered to Discord by **Out Of Your Element**.\n[View on Matrix →]()\n\n${profileNote}**User ID**: [${event.sender}]()`, color: 0x0dbd8b, fields: [{ name: "In Channels", diff --git a/src/discord/interactions/matrix-info.test.js b/src/discord/interactions/matrix-info.test.js index f455700..8347c12 100644 --- a/src/discord/interactions/matrix-info.test.js +++ b/src/discord/interactions/matrix-info.test.js @@ -85,3 +85,118 @@ test("matrix info: shows info for matrix source message", async t => { ) t.equal(called, 1) }) + +test("matrix info: shows username for per-message profile", async t => { + let called = 0 + const msg = await _interact({ + data: { + target_id: "1128118177155526666", + resolved: { + messages: { + "1141501302736695316": data.message.simple_reply_to_matrix_user + } + } + }, + guild_id: "112760669178241024" + }, { + api: { + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") + return { + event_id: eventID, + room_id: roomID, + type: "m.room.message", + content: { + msgtype: "m.text", + body: "master chief: i like the halo", + format: "org.matrix.custom.html", + formatted_body: "master chief: i like the halo", + "com.beeper.per_message_profile": { + has_fallback: true, + displayname: "master chief", + avatar_url: "" + } + }, + sender: "@cadence:cadence.moe" + } + }, + async getJoinedMembers(roomID) { + return { + joined: {} + } + }, + async getStateEventOuter(roomID, type, key) { + return { + content: { + room_version: "11" + } + } + }, + async getStateEvent(roomID, type, key) { + return {} + } + } + }) + t.equal(msg.data.embeds[0].author.name, "master chief") + t.match(msg.data.embeds[0].description, "Sent with a per-message profile") + t.equal(called, 1) +}) + +test("matrix info: shows avatar for per-message profile", async t => { + let called = 0 + const msg = await _interact({ + data: { + target_id: "1128118177155526666", + resolved: { + messages: { + "1141501302736695316": data.message.simple_reply_to_matrix_user + } + } + }, + guild_id: "112760669178241024" + }, { + api: { + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") + return { + event_id: eventID, + room_id: roomID, + type: "m.room.message", + content: { + msgtype: "m.text", + body: "?", + format: "org.matrix.custom.html", + formatted_body: "?", + "com.beeper.per_message_profile": { + avatar_url: "mxc://cadence.moe/HXfFuougamkURPPMflTJRxGc" + } + }, + sender: "@mystery:cadence.moe" + } + }, + async getJoinedMembers(roomID) { + return { + joined: {} + } + }, + async getStateEventOuter(roomID, type, key) { + return { + content: { + room_version: "11" + } + } + }, + async getStateEvent(roomID, type, key) { + return {} + } + } + }) + t.equal(msg.data.embeds[0].author.name, "@mystery:cadence.moe") + t.equal(msg.data.embeds[0].author.icon_url, "https://bridge.example.org/download/matrix/cadence.moe/HXfFuougamkURPPMflTJRxGc") + t.match(msg.data.embeds[0].description, "Sent with a per-message profile") + t.equal(called, 1) +}) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 7c233c7..95e477f 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -550,25 +550,30 @@ async function eventToMessage(event, guild, channel, di) { /** @type {string[]} */ let messageIDsToEdit = [] let replyLine = "" + // Extract a basic display name from the sender const match = event.sender.match(/^@(.*?):/) if (match) displayName = match[1] + // Try to extract an accurate display name and avatar URL from the member event 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) + // 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" 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 + if (perMessageProfile.avatar_url) { + // use provided avatar_url avatarURL = mxUtils.getPublicUrlForMxc(perMessageProfile.avatar_url) + } else if (perMessageProfile.avatar_url === "") { + // empty string avatar_url clears the avatar + avatarURL = undefined } + // else, omitted/null falls back to member avatar } + // 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) @@ -812,7 +817,13 @@ async function eventToMessage(event, guild, channel, di) { 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 + // Strip fallback elements added for clients that don't support per-message profiles. + // Deviates from recommended regexp in MSC to be less strict. Avoiding an HTML parser for performance reasons. + // ┌────A────┐ Opening HTML tag: capture tag name and stay within tag + // ┆ ┆┌─────────────B────────────┐ This text in the tag somewhere, presumably an attribute name + // ┆ ┆┆ ┆┌─C──┐ Rest of the opening tag + // ┆ ┆┆ ┆┆ ┆┌─D─┐ Tag content (no more tags allowed within) + // ┆ ┆┆ ┆┆ ┆┆ ┆┌─E──┐ Closing tag matching opening tag name input = input.replace(/<(\w+)[^>]*\bdata-mx-profile-fallback\b[^>]*>[^<]*<\/\1>/g, "") } if (event.content.msgtype === "m.emote") { From e9fe8206660b4aeda9344dd1f22415b12a75b011 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 25 Mar 2026 16:22:37 +1300 Subject: [PATCH 084/111] Registration changes should be instant now --- scripts/reset-web-password.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/reset-web-password.js b/scripts/reset-web-password.js index 9131efb..7c3a1a2 100644 --- a/scripts/reset-web-password.js +++ b/scripts/reset-web-password.js @@ -13,5 +13,5 @@ const {prompt} = require("enquirer") reg.ooye.web_password = passwordResponse.web_password writeRegistration(reg) - console.log("Saved. Restart Out Of Your Element to apply this change.") + console.log("Saved. This change should be applied instantly.") })() From 8c023cc9361069afbe21ae1d688cac3d1ac2427c Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 25 Mar 2026 16:24:07 +1300 Subject: [PATCH 085/111] Add ping() function to REPL --- src/stdin.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/stdin.js b/src/stdin.js index fea5fad..2548d42 100644 --- a/src/stdin.js +++ b/src/stdin.js @@ -23,10 +23,26 @@ const setPresence = sync.require("./d2m/actions/set-presence") const channelWebhook = sync.require("./m2d/actions/channel-webhook") const guildID = "112760669178241024" +async function ping() { + const result = await api.ping().catch(e => ({ok: false, status: "net", root: e.message})) + if (result.ok) { + return "Ping OK. The homeserver and OOYE are talking to each other fine." + } else { + if (typeof result.root === "string") { + var msg = `Cannot reach homeserver: ${result.root}` + } else if (result.root.error) { + var msg = `Homeserver said: [${result.status}] ${result.root.error}` + } else { + var msg = `Homeserver said: [${result.status}] ${JSON.stringify(result.root)}` + } + return msg + "\nMatrix->Discord won't work until you fix this.\nIf your installation has recently changed, consider `npm run setup` again." + } +} + if (process.stdin.isTTY) { setImmediate(() => { if (!passthrough.repl) { - const cli = repl.start({ prompt: "", eval: customEval, writer: s => s }) + const cli = repl.start({prompt: "", eval: customEval, writer: s => s}) Object.assign(cli.context, passthrough) passthrough.repl = cli } From 953b3e7741922fa801dea54a068ee5ed40e389bc Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 26 Mar 2026 00:16:30 +1300 Subject: [PATCH 086/111] Attach message to error Apparently this was causing detached logs, so just stop those complaints if the error isn't being bubbled --- src/d2m/actions/expression.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/d2m/actions/expression.js b/src/d2m/actions/expression.js index c7ab27a..0f714c6 100644 --- a/src/d2m/actions/expression.js +++ b/src/d2m/actions/expression.js @@ -34,7 +34,10 @@ async function emojisToState(emojis, guild) { if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit. return } - console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`) + e["emoji"] = { + name: emoji.name, + id: emoji.id + } throw e }) )) From 59012d9613c7c7182c9fe99706a6fceb10713f5f Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 27 Mar 2026 19:13:03 +1300 Subject: [PATCH 087/111] Fix pinning random messages --- src/d2m/converters/pins-to-list.js | 2 +- src/m2d/actions/update-pins.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/d2m/converters/pins-to-list.js b/src/d2m/converters/pins-to-list.js index 5a33c7c..4ad8800 100644 --- a/src/d2m/converters/pins-to-list.js +++ b/src/d2m/converters/pins-to-list.js @@ -22,7 +22,7 @@ function pinsToList(pins, kstate) { /** @type {string[]} */ const result = [] for (const pin of pins.items) { - const eventID = select("event_message", "event_id", {message_id: pin.message.id, part: 0}).pluck().get() + 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) } result.reverse() diff --git a/src/m2d/actions/update-pins.js b/src/m2d/actions/update-pins.js index d06f6e8..1ff2bb9 100644 --- a/src/m2d/actions/update-pins.js +++ b/src/m2d/actions/update-pins.js @@ -13,7 +13,7 @@ async function updatePins(pins, prev) { const diff = diffPins.diffPins(pins, prev) for (const [event_id, added] of diff) { const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") - .select("reference_channel_id", "message_id").get() + .select("reference_channel_id", "message_id").where({event_id}).and("ORDER BY part ASC").get() if (!row) continue if (added) { discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix") From 857fb7583b83a2619cde4fa512c09fb49c764f41 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 27 Mar 2026 19:20:04 +1300 Subject: [PATCH 088/111] v3.5 --- package-lock.json | 24 ++++++++++++------------ package.json | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 10b4668..9f4ba54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "out-of-your-element", - "version": "3.4.0", + "version": "3.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "out-of-your-element", - "version": "3.4.0", + "version": "3.5.0", "license": "AGPL-3.0-or-later", "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", @@ -30,7 +30,7 @@ "enquirer": "^2.4.1", "entities": "^5.0.0", "get-relative-path": "^1.0.2", - "h3": "^1.15.1", + "h3": "^1.15.10", "heatsync": "^2.7.2", "htmx.org": "^2.0.4", "lru-cache": "^11.0.2", @@ -1163,9 +1163,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1587,9 +1587,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -1688,9 +1688,9 @@ } }, "node_modules/h3": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.6.tgz", - "integrity": "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ==", + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.10.tgz", + "integrity": "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==", "license": "MIT", "dependencies": { "cookie-es": "^1.2.2", diff --git a/package.json b/package.json index 0e666aa..af4bd2a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "out-of-your-element", - "version": "3.4.0", + "version": "3.5.0", "description": "A bridge between Matrix and Discord", "main": "index.js", "repository": { @@ -39,7 +39,7 @@ "enquirer": "^2.4.1", "entities": "^5.0.0", "get-relative-path": "^1.0.2", - "h3": "^1.15.1", + "h3": "^1.15.10", "heatsync": "^2.7.2", "htmx.org": "^2.0.4", "lru-cache": "^11.0.2", From e28eac6bfaee85b8b5571efe2c0f679c3dedc513 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 28 Mar 2026 11:45:00 +1300 Subject: [PATCH 089/111] Update domino --- package-lock.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9f4ba54..dfee078 100644 --- a/package-lock.json +++ b/package-lock.json @@ -276,9 +276,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "license": "MIT", "optional": true, "dependencies": { @@ -1488,9 +1488,9 @@ "license": "MIT" }, "node_modules/domino": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.6.tgz", - "integrity": "sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.7.tgz", + "integrity": "sha512-3rcXhx0ixJV2nj8J0tljzejTF73A35LVVdnTQu79UAqTBFEgYPMgGtykMuu/BDqaOZphATku1ddRUn/RtqUHYQ==", "license": "BSD-2-Clause" }, "node_modules/emoji-regex": { @@ -1617,9 +1617,9 @@ "license": "MIT" }, "node_modules/fullstore": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/fullstore/-/fullstore-4.0.0.tgz", - "integrity": "sha512-Y9hN79Q1CFU8akjGnTZoBnTzlA/o8wmtBijJOI8dKCmdC7GLX7OekpLxmbaeRetTOi4OdFGjfsg4c5dxP3jgPw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fullstore/-/fullstore-4.0.2.tgz", + "integrity": "sha512-syOev4kA0lZy4VkfBJZ99ZL4cIiSgiKt0G8SpP0kla1tpM1c+V/jBOVY/OqqGtR2XLVcM83SjFPFC3R2YIwqjQ==", "dev": true, "license": "MIT", "engines": { @@ -1937,9 +1937,9 @@ "license": "MIT" }, "node_modules/json-with-bigint": { - "version": "3.5.7", - "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz", - "integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==", + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", "dev": true, "license": "MIT" }, From 12f41038701e26d4ad41d74cfd305fc89b8036b9 Mon Sep 17 00:00:00 2001 From: nemesio65 Date: Thu, 19 Mar 2026 15:58:54 -0700 Subject: [PATCH 090/111] d2m: Create voice channels as call rooms --- src/d2m/actions/create-room.js | 10 ++++++++++ src/d2m/actions/create-room.test.js | 11 +++++++++++ test/data.js | 20 ++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index c2ec01a..7f110ad 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -193,6 +193,16 @@ async function channelToKState(channel, guild, di) { // Don't overwrite room topic if the topic has been customised if (hasCustomTopic) delete channelKState["m.room.topic/"] + // Make voice channels be a Matrix voice room (MSC3417) + if (channel.type === DiscordTypes.ChannelType.GuildVoice) { + creationContent.type = "org.matrix.msc3417.call" + channelKState["org.matrix.msc3401.call/"] = { + "m.intent": "m.room", + "m.type": "m.voice", + "m.name": customName || channel.name + } + } + // Don't add a space parent if it's self service // (The person setting up self-service has already put it in their preferred space to be able to get this far.) const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get() diff --git a/src/d2m/actions/create-room.test.js b/src/d2m/actions/create-room.test.js index 36fccba..c9e098b 100644 --- a/src/d2m/actions/create-room.test.js +++ b/src/d2m/actions/create-room.test.js @@ -190,6 +190,17 @@ test("channel2room: read-only discord channel", async t => { t.equal(api.getCalled(), 2) }) +test("channel2room: voice channel", async t => { + const api = mockAPI(t) + const state = kstateStripConditionals(await channelToKState(testData.channel.voice, testData.guild.general, {api}).then(x => x.channelKState)) + t.equal(state["m.room.create/"].type, "org.matrix.msc3417.call") + t.deepEqual(state["org.matrix.msc3401.call/"], { + "m.intent": "m.room", + "m.name": "🍞丨[8user] Piece", + "m.type": "m.voice" + }) +}) + test("convertNameAndTopic: custom name and topic", t => { t.deepEqual( _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"), diff --git a/test/data.js b/test/data.js index f5e8313..cc054cf 100644 --- a/test/data.js +++ b/test/data.js @@ -19,6 +19,26 @@ module.exports = { default_thread_rate_limit_per_user: 0, guild_id: "112760669178241024" }, + voice: { + voice_background_display: null, + version: 1774469910848, + user_limit: 0, + type: 2, + theme_color: null, + status: null, + rtc_region: null, + rate_limit_per_user: 0, + position: 0, + permission_overwrites: [], + parent_id: "805261291908104252", + nsfw: false, + name: "🍞丨[8user] Piece", + last_message_id: "1459912691098325137", + id: "1036840786093953084", + flags: 0, + bitrate: 256000, + guild_id: "112760669178241024" + }, updates: { type: 0, topic: "Updates and release announcements for Out Of Your Element.", From 91bce76fc8d563fc53f9085cd1b50f91a0cb5491 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 29 Mar 2026 15:41:23 +1300 Subject: [PATCH 091/111] Use HTML to strip per-message profile fallback --- src/m2d/converters/event-to-message.js | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 95e477f..af44c84 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -816,16 +816,6 @@ 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. - // Deviates from recommended regexp in MSC to be less strict. Avoiding an HTML parser for performance reasons. - // ┌────A────┐ Opening HTML tag: capture tag name and stay within tag - // ┆ ┆┌─────────────B────────────┐ This text in the tag somewhere, presumably an attribute name - // ┆ ┆┆ ┆┌─C──┐ Rest of the opening tag - // ┆ ┆┆ ┆┆ ┆┌─D─┐ Tag content (no more tags allowed within) - // ┆ ┆┆ ┆┆ ┆┆ ┆┌─E──┐ Closing tag matching opening tag name - input = input.replace(/<(\w+)[^>]*\bdata-mx-profile-fallback\b[^>]*>[^<]*<\/\1>/g, "") - } if (event.content.msgtype === "m.emote") { input = `* ${displayName} ${input}` } @@ -886,8 +876,9 @@ async function eventToMessage(event, guild, channel, di) { const doc = domino.createDocument( // DOM parsers arrange elements in the and . Wrapping in a custom element ensures elements are reliably arranged in a single element. '' + input + '' - ); - const root = doc.getElementById("turndown-root"); + ) + const root = doc.getElementById("turndown-root") + assert(root) async function forEachNode(event, node) { for (; node; node = node.nextSibling) { // Check written mentions @@ -940,6 +931,7 @@ async function eventToMessage(event, guild, channel, di) { } } await forEachNode(event, root) + if (perMessageProfile?.has_fallback) root.querySelectorAll("[data-mx-profile-fallback]").forEach(x => x.remove()) // SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet. // First we need to determine which emojis are at the end. From 6c2aeea8a62032e44dac268bdad870cb0523c053 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 1 Apr 2026 21:20:54 +0000 Subject: [PATCH 092/111] This should, IN THEORY, *just work* for existing threads. --- src/m2d/event-dispatcher.js | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 1d51275..49cc369 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -210,6 +210,13 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", */ async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return + + if (event.content["m.relates_to"]?.rel_type === "m.thread") { + const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id) + if (bridgedTo) event.room_id = bridgedTo; + else throw new Error("Detected that a message was sent from a Matrix thread, yet there doesn't seem to be a related Discord thread!") + } + const messageResponses = await sendEvent.sendEvent(event) if (!messageResponses.length) return @@ -221,22 +228,6 @@ async event => { event ) } - if (event.content["m.relates_to"]?.rel_type === "m.thread" && executedCommand !== "thread"){ - const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id) - api.sendEvent(event.room_id, "m.room.message", { - body: "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. *In other words: __Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel.__ If the thread you sent this message in is old, such a random reply **may be distracting** to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on https://matrix.to/#/"+bridgedTo+"?"+(await utils.getViaServersQuery(bridgedTo, api)).toString() : "Please run `/thread [Optional: Thread Name]` to create such a room for this thread, or get a link to it if someone else has already done so. If you run `/thread` (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", - format: "org.matrix.custom.html", - formatted_body: "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

It seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. In other words: Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel. If the thread you sent this message in is old, such a random reply may be distracting to Discord users!

For the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on "+bridgedTo+"" : "Please run /thread [Optional: Thread Name] to create such a room for this thread, or get a link to it if someone else has already done so. If you run /thread (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".

You can read more about the rationale behind this design choice here.", - "m.mentions": { "user_ids": [event.sender]}, - "m.relates_to": { - event_id: event.content["m.relates_to"].event_id, - is_falling_back: false, - "m.in_reply_to": { event_id: event.event_id }, - rel_type: "m.thread" - }, - msgtype: "m.text" - }) - } retrigger.messageFinishedBridging(event.event_id) await api.ackEvent(event) From e23d365913907d237957c59fad42ee12c607e194 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 2 Apr 2026 12:42:20 +0000 Subject: [PATCH 093/111] this will likely be removed, but I might still fix it --- src/matrix/matrix-command-handler.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 93b123c..d25c54c 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -285,7 +285,7 @@ const commands = [{ return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission." // NOTE: Currently, this assumes that all Matrix users have no Discord roles (see: empty [] in the getPermissions call above), and makes that „If you were a Discord user...” claim based on that assumption. If Discord permission emulation is gonna happen in the future, this is certainly one among many places that will need to be changed. + body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission." }) } @@ -330,7 +330,7 @@ const commands = [{ await api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "⚠️ Discord claims that there already exists a thread for the message you ran this command on, but that doesn't make logical sense, as it doesn't seem like you ran this command on any message. Either your Matrix client did something funny with reply/thread tags, or this is a logic error on OOYE's side. At any rate, this should be reported for further investigation. you should also attach the error message that's about to be sent below (or on the main room timeline, if the command was ran inside a thread).", + body: "⚠️ Discord claims that there already exists a thread for the message you ran this command on, but that doesn't make logical sense, as it doesn't seem like you ran this command on any message. Either your Matrix client did something funny with reply/thread tags, or this is a logic error on OOYE's side. At any rate, this should be reported for further investigation. You should also attach the error message that's about to be sent below (or on the main room timeline, if the command was ran inside a thread).", }) throw e; } From 98240400a6d9c9d6e1e7f476b552edcd8a63dd5f Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 2 Apr 2026 15:13:40 +0000 Subject: [PATCH 094/111] ThreadRoom auto-create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both sides of creation (M2D and D2M) use ensureRoom() instead of syncRoom() because it's impossible to know which one will fire first, and we wouldn't want a double-sync. At the same time, calling ensureRoom() as a way to CREATE a thread-room is perfectly safe because „Naturally, the newly created room is already up to date, so we can always skip syncing here.” and also thread-rooms aren't subject to manual-mode restrictions, so we can skip all „Does a channel_room entry exists or guild autocreate = 1?” checks (actually, the comment probably should reflect that - so I updated the comments, too. Also, bridgeThread() is a separate function to make guard clauses possible instead of nesting 3 more layers of IFs like we were fkin YandereDev. --- src/d2m/actions/create-room.js | 4 ++-- src/d2m/event-dispatcher.js | 2 +- src/m2d/event-dispatcher.js | 20 +++++++++++++++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 7f110ad..e7c5050 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -439,12 +439,12 @@ async function _syncRoom(channelID, shouldActuallySync) { return roomID } -/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ +/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Before calling this, please make sure that: a channel_room entry exists, guild autocreate = 1, or you're operating on a thread.*/ function ensureRoom(channelID) { return _syncRoom(channelID, false) } -/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ +/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Before calling this, please make sure that: a channel_room entry exists, guild autocreate = 1, or you're operating on a thread.*/ function syncRoom(channelID) { return _syncRoom(channelID, true) } diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index b6593ec..eb0e7bd 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -212,7 +212,7 @@ module.exports = { const channelID = thread.parent_id || undefined const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate) - const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread) + const threadRoomID = await createRoom.ensureRoom(thread.id) await announceThread.announceThread(parentRoomID, threadRoomID, thread) }, diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 49cc369..35e998c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -214,7 +214,7 @@ async event => { if (event.content["m.relates_to"]?.rel_type === "m.thread") { const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id) if (bridgedTo) event.room_id = bridgedTo; - else throw new Error("Detected that a message was sent from a Matrix thread, yet there doesn't seem to be a related Discord thread!") + else await bridgeThread(event); } const messageResponses = await sendEvent.sendEvent(event) @@ -233,6 +233,24 @@ async event => { await api.ackEvent(event) })) +/** + * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event Its room_id will mutate to the target thread, if such thread gets created. + */ +async function bridgeThread(event) { + /** @type {string} */ // @ts-ignore + const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get(); + const channel = discord.channels.get(channelID) + const guildID = channel?.["guild_id"] + if (!guildID) return; //Room not bridged? We don't care. It's a Matrix-naive room, let Matrix users have standard Matrix-native threads there. + + const eventID = event.content["m.relates_to"]?.event_id + if (!eventID) throw new Error("There was an event sent inside SOME Matrix thread, but it lacked any information as to what thread it actually was!"); //An „ugly error” is justified because if something like this DOES happen, then that means that it should be reported to us, as there is some broken client out there that we should account for. + const messageID = select("event_message", "message_id", {event_id: eventID}).pluck().get() + if (!messageID) return; //Message not bridged? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room.We COULD technically create a "headless" thread on Discord side and bridge it to a new thread-room, but that comes with a whole host of complications on its own (notably: what do we do if the message gets bridged later (by reaction emoji), and maybe gets its own thread; and: getThreadRoomFromThreadEvent will have to be much more complex than a simple DB call (probably a whole new DB table would have to be created, just to hold these Matrix-branched-but-headless-on-Discord threads) because the simple „MX event --(db)--> Discord Message --(Discord spec)--> Discord thread” relation would no longer hold true), which may not be worth it, as an unbridged message in a bridged channel is already an edge-case and it seems somewhat pointless to introduce and account for a whole bunch of edgier-cases that handling this edge-case "properly" would bring. + + event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name: "TODO: name-gen"})).id) +} + sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", /** * @param {Ty.Event.Outer_M_Sticker} event it is a m.sticker because that's what this listener is filtering for From b924de2357483536908fe0a70ca2d4dedd52db5a Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 2 Apr 2026 16:26:20 +0000 Subject: [PATCH 095/111] namegen; prevent commands from running in redirected messages --- src/m2d/event-dispatcher.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 35e998c..d06ad66 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -211,8 +211,11 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return + let processCommands = true if (event.content["m.relates_to"]?.rel_type === "m.thread") { + processCommands = false const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id) + if (bridgedTo) event.room_id = bridgedTo; else await bridgeThread(event); } @@ -222,7 +225,7 @@ async event => { /** @type {string|undefined} */ let executedCommand - if (event.type === "m.room.message" && event.content.msgtype === "m.text") { + if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) { executedCommand = await matrixCommandHandler.parseAndExecute( // @ts-ignore - TypeScript doesn't know that the event.content.msgtype === "m.text" check ensures that event isn't of type Ty.Event.Outer_M_Room_Message_File (which, indeed, wouldn't fit here) event @@ -238,17 +241,22 @@ async event => { */ async function bridgeThread(event) { /** @type {string} */ // @ts-ignore - const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get(); + const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() const channel = discord.channels.get(channelID) const guildID = channel?.["guild_id"] - if (!guildID) return; //Room not bridged? We don't care. It's a Matrix-naive room, let Matrix users have standard Matrix-native threads there. + if (!guildID) return; //Room not bridged? We don't care. It's a Matrix-native room, let Matrix users have standard Matrix-native threads there. const eventID = event.content["m.relates_to"]?.event_id if (!eventID) throw new Error("There was an event sent inside SOME Matrix thread, but it lacked any information as to what thread it actually was!"); //An „ugly error” is justified because if something like this DOES happen, then that means that it should be reported to us, as there is some broken client out there that we should account for. const messageID = select("event_message", "message_id", {event_id: eventID}).pluck().get() - if (!messageID) return; //Message not bridged? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room.We COULD technically create a "headless" thread on Discord side and bridge it to a new thread-room, but that comes with a whole host of complications on its own (notably: what do we do if the message gets bridged later (by reaction emoji), and maybe gets its own thread; and: getThreadRoomFromThreadEvent will have to be much more complex than a simple DB call (probably a whole new DB table would have to be created, just to hold these Matrix-branched-but-headless-on-Discord threads) because the simple „MX event --(db)--> Discord Message --(Discord spec)--> Discord thread” relation would no longer hold true), which may not be worth it, as an unbridged message in a bridged channel is already an edge-case and it seems somewhat pointless to introduce and account for a whole bunch of edgier-cases that handling this edge-case "properly" would bring. - - event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name: "TODO: name-gen"})).id) + if (!messageID) return; //Message not bridged? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. We COULD technically create a "headless" thread on Discord side and bridge it to a new thread-room, but that comes with a whole host of complications on its own (notably: what do we do if the message gets bridged later (by reaction emoji), and maybe gets its own thread; and: getThreadRoomFromThreadEvent will have to be much more complex than a simple DB call (probably a whole new DB table would have to be created, just to hold these Matrix-branched-but-headless-on-Discord threads) because the simple „MX event --(db)--> Discord Message --(Discord spec)--> Discord thread” relation would no longer hold true), which may not be worth it, as an unbridged message in a bridged channel is already an edge-case and it seems somewhat pointless to introduce and account for a whole bunch of edgier-cases that handling this edge-case "properly" would bring. + + let name = event.content.body + if (name.startsWith("/thread ")) name = name.substring(8); + else name = (await api.getEvent(event.room_id, eventID)).content.body; + name = name.length < 100 ? name.replaceAll("\n", " ") : name.slice(0, 96).replaceAll("\n", " ") + "..." + + event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id) } sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", From 1ea971208657e3721206262d6a6a5192e47bd439 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 2 Apr 2026 17:42:36 +0000 Subject: [PATCH 096/111] Message redirection on Matrix side, too? --- src/m2d/event-dispatcher.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index d06ad66..c2f6232 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -213,11 +213,18 @@ async event => { let processCommands = true if (event.content["m.relates_to"]?.rel_type === "m.thread") { - processCommands = false + /**@type {string|null} */ + let toRedact = event.room_id const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id) + processCommands = false if (bridgedTo) event.room_id = bridgedTo; - else await bridgeThread(event); + else if (!await bridgeThread(event)) toRedact = null; + + if (toRedact){ + api.redactEvent(toRedact, event.event_id) + api.sendEvent(event.room_id, event.type, event.content) + } } const messageResponses = await sendEvent.sendEvent(event) @@ -237,19 +244,20 @@ async event => { })) /** - * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event Its room_id will mutate to the target thread, if such thread gets created. + * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event Used for determining the branching-point and the title; its room_id will mutate to the target thread-room, if one gets created. + * @returns {Promise} whether a thread-room was created */ async function bridgeThread(event) { /** @type {string} */ // @ts-ignore const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() const channel = discord.channels.get(channelID) const guildID = channel?.["guild_id"] - if (!guildID) return; //Room not bridged? We don't care. It's a Matrix-native room, let Matrix users have standard Matrix-native threads there. + if (!guildID) return false; //Room not bridged? We don't care. It's a Matrix-native room, let Matrix users have standard Matrix-native threads there. const eventID = event.content["m.relates_to"]?.event_id if (!eventID) throw new Error("There was an event sent inside SOME Matrix thread, but it lacked any information as to what thread it actually was!"); //An „ugly error” is justified because if something like this DOES happen, then that means that it should be reported to us, as there is some broken client out there that we should account for. const messageID = select("event_message", "message_id", {event_id: eventID}).pluck().get() - if (!messageID) return; //Message not bridged? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. We COULD technically create a "headless" thread on Discord side and bridge it to a new thread-room, but that comes with a whole host of complications on its own (notably: what do we do if the message gets bridged later (by reaction emoji), and maybe gets its own thread; and: getThreadRoomFromThreadEvent will have to be much more complex than a simple DB call (probably a whole new DB table would have to be created, just to hold these Matrix-branched-but-headless-on-Discord threads) because the simple „MX event --(db)--> Discord Message --(Discord spec)--> Discord thread” relation would no longer hold true), which may not be worth it, as an unbridged message in a bridged channel is already an edge-case and it seems somewhat pointless to introduce and account for a whole bunch of edgier-cases that handling this edge-case "properly" would bring. + if (!messageID) return false; //Message not bridged? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. We COULD technically create a "headless" thread on Discord side and bridge it to a new thread-room, but that comes with a whole host of complications on its own (notably: what do we do if the message gets bridged later (by reaction emoji), and then hypothetically gets its own thread; and: getThreadRoomFromThreadEvent will have to be much more complex than a simple DB call (probably a whole new DB table would have to be created, just to hold these Matrix-branched-but-headless-on-Discord threads) because the simple „MX event --(db)--> Discord Message --(Discord spec)--> Discord thread” relation would no longer hold true), which may not be worth it, as an unbridged message in a bridged channel is already an edge-case (so it seems pointless to introduce a whole bunch of edgier-cases that handling this edge-case "properly" would bring). let name = event.content.body if (name.startsWith("/thread ")) name = name.substring(8); @@ -257,6 +265,7 @@ async function bridgeThread(event) { name = name.length < 100 ? name.replaceAll("\n", " ") : name.slice(0, 96).replaceAll("\n", " ") + "..." event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id) + return true; } sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", From c53b54bafc384b43867cd38ee0e3adf0b4ba0217 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 2 Apr 2026 18:24:30 +0000 Subject: [PATCH 097/111] Fix Element being stupid --- src/m2d/event-dispatcher.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index c2f6232..14b5345 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -244,7 +244,7 @@ async event => { })) /** - * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event Used for determining the branching-point and the title; its room_id will mutate to the target thread-room, if one gets created. + * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event Used for determining the branching-point and the title; any relation data will be stripped and its room_id will mutate to the target thread-room, if one gets created. * @returns {Promise} whether a thread-room was created */ async function bridgeThread(event) { @@ -265,6 +265,7 @@ async function bridgeThread(event) { name = name.length < 100 ? name.replaceAll("\n", " ") : name.slice(0, 96).replaceAll("\n", " ") + "..." event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id) + event.content["m.relates_to"] = undefined; return true; } From 3df15c5efa8fd1fb7cebb96a081ca14de2a37e50 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 2 Apr 2026 18:56:46 +0000 Subject: [PATCH 098/111] WHY are you still defined? --- src/m2d/event-dispatcher.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 14b5345..1a3b445 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -265,7 +265,7 @@ async function bridgeThread(event) { name = name.length < 100 ? name.replaceAll("\n", " ") : name.slice(0, 96).replaceAll("\n", " ") + "..." event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id) - event.content["m.relates_to"] = undefined; + event.content["m.relates_to"] = {} return true; } From b3badac45288dd0090fd7048dddee973de0a487d Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 2 Apr 2026 19:55:10 +0000 Subject: [PATCH 099/111] i WILL cry --- src/m2d/event-dispatcher.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 1a3b445..7f32847 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -224,6 +224,7 @@ async event => { if (toRedact){ api.redactEvent(toRedact, event.event_id) api.sendEvent(event.room_id, event.type, event.content) + console.log(event); } } @@ -266,6 +267,7 @@ async function bridgeThread(event) { event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id) event.content["m.relates_to"] = {} + console.log(event); return true; } From 85314818d2103e78af3eced7d50739146ac97294 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 2 Apr 2026 20:03:07 +0000 Subject: [PATCH 100/111] WHAT? HOW? --- src/m2d/event-dispatcher.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 7f32847..e890f29 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -224,7 +224,7 @@ async event => { if (toRedact){ api.redactEvent(toRedact, event.event_id) api.sendEvent(event.room_id, event.type, event.content) - console.log(event); + console.log("HOW?", event); } } @@ -267,7 +267,7 @@ async function bridgeThread(event) { event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id) event.content["m.relates_to"] = {} - console.log(event); + console.log("WHAT?", event); return true; } From 50d09fd48f3790a78a264ea62120f255cb32be1a Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 2 Apr 2026 20:09:13 +0000 Subject: [PATCH 101/111] Async JS does *NOT* spark joy --- src/m2d/event-dispatcher.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index e890f29..9cea522 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -264,10 +264,10 @@ async function bridgeThread(event) { if (name.startsWith("/thread ")) name = name.substring(8); else name = (await api.getEvent(event.room_id, eventID)).content.body; name = name.length < 100 ? name.replaceAll("\n", " ") : name.slice(0, 96).replaceAll("\n", " ") + "..." - - event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id) + event.content["m.relates_to"] = {} console.log("WHAT?", event); + event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id) return true; } From e47b5e3d2b3eea5698ac47e2afc9eac476e1e95d Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 2 Apr 2026 20:12:29 +0000 Subject: [PATCH 102/111] I am SUCH a MASSIVE FUCKING MORON jesusfuckingCHRIST --- src/m2d/event-dispatcher.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 9cea522..3624df2 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -223,8 +223,8 @@ async event => { if (toRedact){ api.redactEvent(toRedact, event.event_id) + event.content["m.relates_to"] = undefined api.sendEvent(event.room_id, event.type, event.content) - console.log("HOW?", event); } } @@ -265,8 +265,6 @@ async function bridgeThread(event) { else name = (await api.getEvent(event.room_id, eventID)).content.body; name = name.length < 100 ? name.replaceAll("\n", " ") : name.slice(0, 96).replaceAll("\n", " ") + "..." - event.content["m.relates_to"] = {} - console.log("WHAT?", event); event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id) return true; } From 44fb3f9f647b2cfe73ac5f3b801528308115d3be Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 3 Apr 2026 13:08:13 +0000 Subject: [PATCH 103/111] Credit where credit is due --- src/m2d/event-dispatcher.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 3624df2..0c2301c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -224,7 +224,7 @@ async event => { if (toRedact){ api.redactEvent(toRedact, event.event_id) event.content["m.relates_to"] = undefined - api.sendEvent(event.room_id, event.type, event.content) + api.sendEvent(event.room_id, event.type, {...event.content, body: event.content.body+"\n\n ~ "+event.sender, formatted_body: event.content.formatted_body ? event.content.formatted_body+"

~ "+event.sender :undefined }) } } From b869b432b66ee3c72e427a3ab4972b9bcfb84bdf Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 14 Apr 2026 20:52:29 +0000 Subject: [PATCH 104/111] This looks better (I still don't remember what was I doing) --- src/m2d/event-dispatcher.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 0c2301c..489904d 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -221,10 +221,10 @@ async event => { if (bridgedTo) event.room_id = bridgedTo; else if (!await bridgeThread(event)) toRedact = null; - if (toRedact){ + if (toRedact) { api.redactEvent(toRedact, event.event_id) event.content["m.relates_to"] = undefined - api.sendEvent(event.room_id, event.type, {...event.content, body: event.content.body+"\n\n ~ "+event.sender, formatted_body: event.content.formatted_body ? event.content.formatted_body+"

~ "+event.sender :undefined }) + api.sendEvent(event.room_id, event.type, {...event.content, body: event.content.body+"\n ~ "+event.sender, formatted_body: event.content.formatted_body ? event.content.formatted_body+"
~ "+event.sender :undefined }) } } From 7eeff2faf319dad2c9504c64d4656030121e96e5 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 14 Apr 2026 22:49:59 +0000 Subject: [PATCH 105/111] ...So I might as well take care of this mess with commands. Notably: * Don't do the unmarshalling and switch-cases, as Cadence asked * Revert command handler returns to how they were before, now that we're not using the returned-command-name anymore. --- src/m2d/event-dispatcher.js | 4 +- src/matrix/matrix-command-handler.js | 115 ++++++++++----------------- 2 files changed, 43 insertions(+), 76 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 489904d..61597f1 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -231,10 +231,8 @@ async event => { const messageResponses = await sendEvent.sendEvent(event) if (!messageResponses.length) return - /** @type {string|undefined} */ - let executedCommand if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) { - executedCommand = await matrixCommandHandler.parseAndExecute( + await matrixCommandHandler.parseAndExecute( // @ts-ignore - TypeScript doesn't know that the event.content.msgtype === "m.text" check ensures that event isn't of type Ty.Event.Outer_M_Room_Message_File (which, indeed, wouldn't fit here) event ) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index d25c54c..408b70a 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -97,33 +97,6 @@ function replyctx(execute) { } } -/** - * @param {Error & {code?: string|number}} e - * @returns {e} -*/ -function unmarshallDiscordError(e) { - if (e.name === "DiscordAPIError"){ - try{ - const unmarshaled = JSON.parse(e.message) - return { - ...e, - ...unmarshaled - } - } catch (err) { - return { - ...err, - code: "JSON_PARSE_FAILED", - message: JSON.stringify({ - original_error_where_message_failed_to_parse: e, - json_parser_error_message: err.message, - json_parser_error_code: err.code, - }) - } - } - } - return e; -} - /** @type {Command[]} */ const commands = [{ aliases: ["emoji"], @@ -312,55 +285,52 @@ const commands = [{ } try { - if (branchedFromDiscordMessage) await discord.snow.channel.createThreadWithMessage(channelID, branchedFromDiscordMessage, {name: words.slice(1).join(" ")}) - else throw {code: "NO_BRANCH_SOURCE", was_supposed_to_be: branchedFromMxEvent}; + if (branchedFromDiscordMessage) return await discord.snow.channel.createThreadWithMessage(channelID, branchedFromDiscordMessage, {name: words.slice(1).join(" ")}) //can't just return the promise directly like in 99% of other cases here in commands, otherwise the error-handling below will not work + else {return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID `"+branchedFromMxEvent+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", + format: "org.matrix.custom.html", + formatted_body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID "+branchedFromMxEvent+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." + })}; } catch (e){ - switch (unmarshallDiscordError(e).code) { - case "NO_BRANCH_SOURCE": return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID `"+e.was_supposed_to_be+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", - format: "org.matrix.custom.html", - formatted_body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID "+e.was_supposed_to_be+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." - }) - - case (160004): // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes - if (isFallingBack){ - await api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "⚠️ Discord claims that there already exists a thread for the message you ran this command on, but that doesn't make logical sense, as it doesn't seem like you ran this command on any message. Either your Matrix client did something funny with reply/thread tags, or this is a logic error on OOYE's side. At any rate, this should be reported for further investigation. You should also attach the error message that's about to be sent below (or on the main room timeline, if the command was ran inside a thread).", - }) - throw e; - } - const thread = mxUtils.getThreadRoomFromThreadEvent(branchedFromMxEvent) - return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "There already exists a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue by creating the bridge. (If you're said admin and you can see that said bridge already exists, but this error message is still showing up, please report that as a bug.)") - }) - - case (50024): return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?" - }) - - case (50035): return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)" - }) - - default: + /**@type {string|undefined} */ + let err = e.message // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes + + if (err?.includes("160004")) { + if (isFallingBack){ await api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "⚠️ Unknown error occurred during thread creation. See error message below (or on the main room timeline, if the command was ran inside a thread) for details." + body: "⚠️ Discord claims that there already exists a thread for the message you ran this command on, but that doesn't make logical sense, as it doesn't seem like you ran this command on any message. Either your Matrix client did something funny with reply/thread tags, or this is a logic error on OOYE's side. At any rate, this should be reported for further investigation. You should also attach the error message that's about to be sent below (or on the main room timeline, if the command was ran inside a thread).", }) - throw e + throw e; + } + const thread = mxUtils.getThreadRoomFromThreadEvent(branchedFromMxEvent) + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "There already exists a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue by creating the bridge. (If you're said admin and you can see that said bridge already exists, but this error message is still showing up, please report that as a bug.)") + }) } + if (err?.includes("50024")) return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?" + }) + if (err?.includes("50035")) return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)" + }) + + await api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "⚠️ Unknown error occurred during thread creation. See error message below (or on the main room timeline, if the command was ran inside a thread) for details." + }) + throw e } } ) @@ -422,9 +392,9 @@ const commands = [{ /** * @param {Ty.Event.Outer_M_Room_Message} event - * @returns {Promise} the executed command's name or undefined if no command execution was performed + * @returns {Promise|undefined} the executed command's in-process promise or undefined if no command execution was performed */ -async function parseAndExecute(event) { +function parseAndExecute(event) { let realBody = event.content.body while (realBody.startsWith("> ")) { const i = realBody.indexOf("\n") @@ -444,8 +414,7 @@ async function parseAndExecute(event) { const command = commands.find(c => c.aliases.includes(commandName)) if (!command) return - await command.execute(event, realBody, words) - return words[0] + return command.execute(event, realBody, words) } module.exports.parseAndExecute = parseAndExecute From ff8e5719507ce5368051796b1ab8ab101e6caa25 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 15:24:01 +0000 Subject: [PATCH 106/111] Changes to thread announcements, especially: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * use "" instead of „” to comply with English Language Standards Recommendations On Quotation Marks [TM], as per Cadence's request * reflect current bot behavior (ie. it no longer bridges-as-replies, but mercilessly rips the message away from your caring arms) * add Ellie-Mode --- src/d2m/converters/thread-to-announcement.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 179a559..0d8fa1f 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -19,21 +19,23 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) */ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get() + const ellieMode = false; //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. + /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */ const context = {} let suffix = ""; - if (branchedFromEventID) { + if (branchedFromEventID && !ellieMode) { // Need to figure out who sent that event... const event = await di.api.getEvent(parentRoomID, branchedFromEventID) - suffix = "\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]"; + suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]"; context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} } const msgtype = creatorMxid ? "m.emote" : "m.text" - const template = creatorMxid ? "started a thread" : "New thread started:" + const template = creatorMxid ? "started a thread called" : "New thread started:" const via = await mxUtils.getViaServersQuery(threadRoomID, di.api) - let body = `${template} „${thread.name}” in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}` + let body = `${template} \"${thread.name}\" in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}` return { msgtype, From 5db585a5258a48e0e8439259f2d82bc6eb9848e7 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 16:24:32 +0000 Subject: [PATCH 107/111] I just noticed something silly... MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I was stripping the ping before because I thought it just pings the thread-author (which I found kinda pointless). But I didn't actually remove the code that figures out who to ping (because I happened to reuse the „if” around it, and didn't remove the setting itself because I didn't pay enough attention to it and just assumed it has some side-effects). I just tried to remove it finally (because my thought was „Wait, WHY are we setting m.mentions only to remove it?”), only to realize that the code does something entirely different (it pings the one under whose message a thread is about to be created, which makes a lot of sense tbh), and actually shouldn't be removed at all and - on the contrary - I should stop removing m.mentions (and also fix Ellie-Mode so that it won't prevent m.mentions from being set even if it's enabled). --- src/d2m/converters/thread-to-announcement.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 0d8fa1f..d253dfd 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -24,12 +24,15 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */ const context = {} let suffix = ""; - if (branchedFromEventID && !ellieMode) { + if (branchedFromEventID) { // Need to figure out who sent that event... const event = await di.api.getEvent(parentRoomID, branchedFromEventID) - suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]"; - context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} + if (!ellieMode){ + //...And actually branch from that event (if configured to do so) + suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]"; + context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} + } } const msgtype = creatorMxid ? "m.emote" : "m.text" @@ -40,7 +43,6 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr return { msgtype, body, - "m.mentions": {}, ...context } } From 9871ed89303bf6a680b87a284bef6bc53df1bb39 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 16:35:36 +0000 Subject: [PATCH 108/111] consistency. --- src/d2m/converters/thread-to-announcement.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index d253dfd..bd3412c 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -19,18 +19,18 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) */ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get() - const ellieMode = false; //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. + const ellieMode = false //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */ const context = {} - let suffix = ""; + let suffix = "" if (branchedFromEventID) { // Need to figure out who sent that event... const event = await di.api.getEvent(parentRoomID, branchedFromEventID) if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} - if (!ellieMode){ + if (!ellieMode) { //...And actually branch from that event (if configured to do so) - suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]"; + suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]" context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} } } From bd80d562c7588a1882964dccff09aa80809be1c6 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 17:08:16 +0000 Subject: [PATCH 109/111] t e s t i n g c o m p l e t e I also noticed that my previous wiping code wasn't even doing anything at all. lmfao --- .../converters/thread-to-announcement.test.js | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.test.js b/src/d2m/converters/thread-to-announcement.test.js index 8af4d79..a4dfd9a 100644 --- a/src/d2m/converters/thread-to-announcement.test.js +++ b/src/d2m/converters/thread-to-announcement.test.js @@ -49,8 +49,7 @@ test("thread2announcement: no known creator, no branched from event", async t => }, {api: viaApi}) t.deepEqual(content, { msgtype: "m.text", - body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", - "m.mentions": {} + body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", }) }) @@ -61,8 +60,7 @@ test("thread2announcement: known creator, no branched from event", async t => { }, {api: viaApi}) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", - "m.mentions": {} + body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", }) }) @@ -85,8 +83,7 @@ test("thread2announcement: no known creator, branched from discord event", async }) t.deepEqual(content, { msgtype: "m.text", - body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", - "m.mentions": {}, + body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", "m.relates_to": { "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", "is_falling_back": false, @@ -117,8 +114,7 @@ test("thread2announcement: known creator, branched from discord event", async t }) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", - "m.mentions": {}, + body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", "m.relates_to": { "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", "is_falling_back": false, @@ -149,7 +145,7 @@ test("thread2announcement: no known creator, branched from matrix event", async }) t.deepEqual(content, { msgtype: "m.text", - body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", + body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", "m.mentions": { user_ids: ["@cadence:cadence.moe"] }, @@ -183,7 +179,7 @@ test("thread2announcement: known creator, branched from matrix event", async t = }) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", + body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", "m.mentions": { user_ids: ["@cadence:cadence.moe"] }, From b7e398a0680ec7cbab6e4e6e669a4512324e43bc Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 19:07:18 +0000 Subject: [PATCH 110/111] Handle errors; general code-quality improvements --- src/m2d/event-dispatcher.js | 17 ++++++++++------- src/matrix/matrix-command-handler.js | 16 ++-------------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 61597f1..c9df78c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -213,15 +213,12 @@ async event => { let processCommands = true if (event.content["m.relates_to"]?.rel_type === "m.thread") { - /**@type {string|null} */ - let toRedact = event.room_id + const toRedact = event.room_id const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id) processCommands = false if (bridgedTo) event.room_id = bridgedTo; - else if (!await bridgeThread(event)) toRedact = null; - - if (toRedact) { + else if (await bridgeThread(event)) { api.redactEvent(toRedact, event.event_id) event.content["m.relates_to"] = undefined api.sendEvent(event.room_id, event.type, {...event.content, body: event.content.body+"\n ~ "+event.sender, formatted_body: event.content.formatted_body ? event.content.formatted_body+"
~ "+event.sender :undefined }) @@ -263,8 +260,14 @@ async function bridgeThread(event) { else name = (await api.getEvent(event.room_id, eventID)).content.body; name = name.length < 100 ? name.replaceAll("\n", " ") : name.slice(0, 96).replaceAll("\n", " ") + "..." - event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id) - return true; + try { + event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id) + return true; + } + catch (e){ + if (e.message?.includes("50024")) return false; //Tried to created a thread in a thread (see: https://docs.discord.com/developers/topics/opcodes-and-status-codes)? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. Same case as for message-not-bridged, except there at least exists a HYPOTHETICAL solution (just one so unwieldly that it's nonsensical to dedicate resources to), wheres here I don't know what could possibly be done at all. + else throw e; //In here (unlike in matrix-command-handler.js), there are much fewer things that could "intentionally" go wrong (both thread double-creation and too-long names shouldn't be possible due to earlier checks). As such, if anything breaks, it should be reported to OOYE for further investigation, which the user should do when encountering an "ugly error" (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an "ugly error" upstream. + } } sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 408b70a..e59e58c 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -299,14 +299,7 @@ const commands = [{ let err = e.message // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes if (err?.includes("160004")) { - if (isFallingBack){ - await api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "⚠️ Discord claims that there already exists a thread for the message you ran this command on, but that doesn't make logical sense, as it doesn't seem like you ran this command on any message. Either your Matrix client did something funny with reply/thread tags, or this is a logic error on OOYE's side. At any rate, this should be reported for further investigation. You should also attach the error message that's about to be sent below (or on the main room timeline, if the command was ran inside a thread).", - }) - throw e; - } + if (isFallingBack) throw e; //Discord claims that there already exists a thread for the message ran this command was ran on, but that doesn't make logical sense, as it doesn't seem like it was ran on any message. Either the Matrix client did something funny with reply/thread tags, or this is a logic error on our side. At any rate, this should be reported to OOYE for further investigation, which the user should do when encountering an „ugly error” (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an „ugly error” upstream. const thread = mxUtils.getThreadRoomFromThreadEvent(branchedFromMxEvent) return api.sendEvent(event.room_id, "m.room.message", { ...ctx, @@ -325,12 +318,7 @@ const commands = [{ body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)" }) - await api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "⚠️ Unknown error occurred during thread creation. See error message below (or on the main room timeline, if the command was ran inside a thread) for details." - }) - throw e + throw e //Some other error happened, one that OOYE didn't anticipate the possibility of? It should be reported to us for further investigation, which the user should do when encountering an „ugly error” (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an „ugly error” upstream. } } ) From 1cc86b52fdcbf6839fa28179dce6f9df3ad6584d Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 19:53:48 +0000 Subject: [PATCH 111/111] these changes were promised to me 3000 years ago --- src/d2m/actions/create-room.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index e7c5050..70f7b3a 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -54,7 +54,7 @@ function convertNameAndTopic(channel, guild, customName) { // @ts-ignore const parentChannel = discord.channels.get(channel.parent_id) let channelPrefix = - ( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "" + ( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "[❓] " : channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.AnnouncementThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] " @@ -65,10 +65,11 @@ function convertNameAndTopic(channel, guild, customName) { const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : ''; const channelIDPart = `Channel ID: ${channel.id}`; const guildIDPart = `Guild ID: ${guild.id}`; + const maybeThreadWithinPart = parentChannel ? `Thread within: ${parentChannel.name} (ID: ${parentChannel.id})\n` : ''; const convertedTopic = customName - ? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${guildIDPart}` - : `${maybeTopicWithNewlines}${channelIDPart}\n${guildIDPart}`; + ? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${maybeThreadWithinPart}${guildIDPart}` + : `${maybeTopicWithNewlines}${channelIDPart}\n${maybeThreadWithinPart}${guildIDPart}`; return [chosenName, convertedTopic]; }