From ae6b730c2655c78c3a44a31be97a37638a5bf2ec Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 13:03:16 +0000 Subject: [PATCH 001/137] Update .gitignore --- .gitignore | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index c38dd88f..7b0c60d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,18 @@ -# Secrets +# Personal config.js registration.yaml ooye.db* events.db* backfill.db* custom-webroot +icon.svg +.devcontainer # Automatically generated node_modules coverage test/res/* !test/res/lottie* -icon.svg *~ .#* \#*# From 2c7831c587d0f797e34d50fd44d373d67a2feaf9 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 16:19:39 +0000 Subject: [PATCH 002/137] Small TypeScript coverage expansion * The guard() function in m2d/event-dispatcher.js no longer takes (any, any), but a string and a function. * m2d/send-event.js no longer complains that res.body has some missing fields. It would appear as though those missing fields weren't revelant to the fromWeb() function (into which res.body is passed), given that this code worked before and still contunes to work, so I just @ts-ignore'd res.body This commit's developer's off-topic personal comment, related to this commit: This has nothing to do with improving thread UX, even tho this is what I was supposed to work on. However, in my attempts to discover in what file should I start, I stumbled upon those errors from m2d/send-event.js, so I fixed them. And after establishing that m2d/event-dispatcher.js is the file that I'm looking for, I also noticed that guard()'s @parm definitions could be improved, so I did that. Now - back to thread work... --- src/m2d/actions/send-event.js | 10 ++++++++-- src/m2d/event-dispatcher.js | 6 +++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index 00557a11..bce45c63 100644 --- a/src/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -39,14 +39,20 @@ async function resolvePendingFiles(message) { if ("key" in p) { // Encrypted file const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url")) - await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body).pipe(d)) + await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb( + // @ts-ignore + res.body + ).pipe(d)) return { name: p.name, file: d } } else { // Unencrypted file - const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body)) + const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb( + // @ts-ignore + res.body + )) return { name: p.name, file: body diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 70e293b1..2091f7de 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -156,8 +156,12 @@ async function sendError(roomID, source, type, e, payload) { } catch (e) {} } +/** + * @param {string} type + * @param {(event: any, ...args: any)=>any} fn + */ function guard(type, fn) { - return async function(event, ...args) { + return async function(/** @type {Ty.Event.Outer} */ event, /** @type {any} */ ...args) { try { return await fn(event, ...args) } catch (e) { From 5a853249a295ecc2e6a0ce9a7f0df87811872bc1 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 16:21:27 +0000 Subject: [PATCH 003/137] 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 9f1e1831..ea571e8f 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 aedd30ab4a0080083475d436af9a9ec813661b4c Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 17:48:32 +0000 Subject: [PATCH 004/137] Update "m.relates_to" type definition @ types.d.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * To better reflect reality ("m.in_reply_to" will not always be present - it's not (always?) found on "rel_type":"m.replace" relation-events) * To support "rel_type":"m.replace" relation-events (added "m.replace" option to existing key "rel_type" and a new "is_falling_back" key) AFFECTED TYPES: M_Room_Message, M_Room_Message_File, M_Room_Message_Encrypted_File BREAKS: Nothing, as .d.ts files don't affect buisness logic. In terms of lint errors: Marking "m.in_reply_to" as optional is indeed technically a "breaking change" (TypeScript may complain about „is probably undefined” in places where it didn't before), but from early "testing" (ie. looking at VSCode's errors tab), it doesn't seem like anything broke, as no file that imports any of those 3 types (Or their Outer_ counterparts) has „lit up” with errors (unless I missed something). There was one type error found in m2d/converters/event-to-message.js, at line 1009, but that seemed unrelated to types.d.ts - nevertheless, that error was also corrected in this commit, by adding proper type annotations somewhere else in the affected file. --- src/m2d/converters/event-to-message.js | 2 ++ src/types.d.ts | 21 ++++++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 2add279f..7eee6597 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -471,6 +471,7 @@ 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: [], + /**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that "everyone" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that "everyone" is a valid enum value. allowedMentionsParse: ["everyone"] } } @@ -543,6 +544,7 @@ async function getL1L2ReplyLine(called = false) { async function eventToMessage(event, guild, channel, di) { let displayName = event.sender let avatarURL = undefined + /**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that neither "users" no "roles" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that both are valid enum values. const allowedMentionsParse = ["users", "roles"] /** @type {string[]} */ let messageIDsToEdit = [] diff --git a/src/types.d.ts b/src/types.d.ts index 6ee2eb13..e7ef3186 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -190,11 +190,12 @@ export namespace Event { format?: "org.matrix.custom.html" formatted_body?: string, "m.relates_to"?: { - "m.in_reply_to": { + event_id?: string + is_falling_back?: bool + "m.in_reply_to"?: { event_id: string } - rel_type?: "m.replace" - event_id?: string + rel_type?: "m.replace"|"m.thread" } } @@ -210,11 +211,12 @@ export namespace Event { info?: any "page.codeberg.everypizza.msc4193.spoiler"?: boolean "m.relates_to"?: { - "m.in_reply_to": { + event_id?: string + is_falling_back?: bool + "m.in_reply_to"?: { event_id: string } - rel_type?: "m.replace" - event_id?: string + rel_type?: "m.replace"|"m.thread" } } @@ -246,11 +248,12 @@ export namespace Event { }, info?: any "m.relates_to"?: { - "m.in_reply_to": { + event_id?: string + is_falling_back?: bool + "m.in_reply_to"?: { event_id: string } - rel_type?: "m.replace" - event_id?: string + rel_type?: "m.replace"|"m.thread" } } From 8676a736204b8cdf8831aad4ca32f7ebf2f7a8be Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 17:53:56 +0000 Subject: [PATCH 005/137] 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 2091f7de..4385f71a 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 006/137] 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 eb919bb5..8b86d340 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 007/137] 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 8b86d340..eb919bb5 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 4385f71a..ef93e3c1 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 008/137] 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 ef93e3c1..b89261e6 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 009/137] 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 b89261e6..86421ffa 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 010/137] 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 86421ffa..ded162f4 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 011/137] 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 575b3c58..601e61ac 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 012/137] 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 601e61ac..ca20af6a 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 013/137] 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 e382a329..863996c3 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 014/137] 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 ded162f4..914845c7 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 863996c3..2d6decab 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 015/137] 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 ca20af6a..d35bb80a 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 914845c7..7342f0a0 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 2d6decab..6f3c5f9f 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 016/137] 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 d35bb80a..179a559d 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 017/137] 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 7342f0a0..ec5fa893 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 018/137] 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 ec5fa893..b0cf6405 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 6f3c5f9f..fd4ae4a5 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 019/137] 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 b0cf6405..55210127 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 fd4ae4a5..6ef4b5fb 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 020/137] 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 e7ef3186..e36241f7 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 021/137] 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 6ef4b5fb..7fb8fe0c 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 022/137] 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 7fb8fe0c..d82ce3e7 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 023/137] 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 d82ce3e7..786df5c5 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 024/137] 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 786df5c5..4ccba821 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 025/137] 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 4ccba821..b31a9370 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 026/137] 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 b31a9370..7b18e71d 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 027/137] 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 7b18e71d..734582e9 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 028/137] =?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 734582e9..07246cd9 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 029/137] 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 55210127..0cd1b0cf 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 07246cd9..b8c3b20d 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 030/137] 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 b8c3b20d..bd9ecfe0 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 031/137] 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 bd9ecfe0..761d2990 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 032/137] 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 761d2990..e6086ba4 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 033/137] =?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 e6086ba4..07ae1782 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 034/137] 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 07ae1782..7c8667e1 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 035/137] 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 7c8667e1..8c0c3c2c 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 036/137] 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 8c0c3c2c..a5124f9a 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 9f5cb0f6..dbdb6d89 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 037/137] 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 3286f62d..8af4d798 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 a5124f9a..ec53d21f 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 dbdb6d89..f9dbf3ff 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 842c5130..f42ce5c0 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 1dd9dfed..a321d488 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 038/137] 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 0cd1b0cf..b2472900 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 ec53d21f..ff46b9c8 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 039/137] 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 b2472900..f3f69642 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 ff46b9c8..ffb6eb10 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 040/137] 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 f9dbf3ff..001c26d4 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 041/137] 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 f3f69642..ca8fbaf1 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 ffb6eb10..8fbdcf39 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 001c26d4..6383c4d1 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 f42ce5c0..8db998d9 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 042/137] 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 8fbdcf39..a0b2adfc 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 043/137] 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 ca8fbaf1..5000a8c3 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 044/137] 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 00000000..012c7aff --- /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 045/137] 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 ea571e8f..9f1e1831 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 046/137] 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 a0b2adfc..6758f785 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 748e851b3996fc63d79c96aa1ebf22272b33df5d Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 2 Mar 2026 15:17:08 +0000 Subject: [PATCH 047/137] Improve threads UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /This is a squash-commit - the following is a rough summary of all sub-commits, written in style of commit messages (not necessarily those commits themselves), ie. short and in present tense./ * Document design choice to not bridge Discord threads as Matrix threads [by directly quoting Cadence] * Alter thread-to-announcement, so that it replies in-thread [with this, Matrix users will get a list of almost all (exl. those that don't branch from anything) open threads on a given channel, whereas before it wasn't possible. Also features slight alterations to the text] * Notify the user, whenever an in-thread message on Matrix is sent, that this isn't how they're supposed to do threads on OOYE * Detect /thread being ran as a reply or in-thread to branch the thread from the relevant message * Handle various /thread errors [notably being ran without args (infer the title if ran in the context above, simply show help if not)] * Whenever possible, direct the user to an already-existing thread-room [if /thread was ran as a reply or in-thread, or as part of the notification mentioned in point 3 (feat. a new utility method)] AUXILIARY TYPE CHANGES (not always relevant to UX-improvement-related changes): * Fix „boolean” being referred to as „bool” in types.d.ts * Rename execute(event) in matrix-command-handler is now parseAndExecute(event) [and it is no longer of type CommandExecute, but has its own custom definition, because a) it has a different return now (returns what command was ran (needed for point 3 in section above) instead of always undefined and b) other params from CommandExecute (like ctx or words) weren't being used - quite the contrary, their values were only being created at that stage (as part of command parsing - hence the rename, too), so telling that they're values you pass into execute() was at least somewhat confusing] * Further narrow-down the type of guard() in m2d event-dispatcher TEST CHANGES: * Create 7 new tests, all pass * Update 4 existing threads, all pass * Pass all other relevant tests [and almost all other tests, too - there are some issues with event2message for stickers, but given the fact that this commit does not touch the stickers subsystem in any way at all, it does not seem like they are any way related to my changes and they must've been failing before] * Do extensive manual testing and debugging Co-authored-by: Guzio Co-committed-by: Guzio --- docs/threads-as-rooms.md | 9 ++ src/d2m/converters/thread-to-announcement.js | 10 +- .../converters/thread-to-announcement.test.js | 65 ++++++++-- src/m2d/event-dispatcher.js | 29 ++++- src/matrix/matrix-command-handler.js | 113 +++++++++++++++++- src/matrix/utils.js | 13 +- src/matrix/utils.test.js | 36 +++++- src/types.d.ts | 6 +- test/ooye-test-data.sql | 4 +- 9 files changed, 256 insertions(+), 29 deletions(-) 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 00000000..012c7aff --- /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 diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 575b3c58..179a559d 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[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()}` + let body = `${template} „${thread.name}” in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}` return { msgtype, diff --git a/src/d2m/converters/thread-to-announcement.test.js b/src/d2m/converters/thread-to-announcement.test.js index 3286f62d..8af4d798 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/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 2091f7de..5000a8c3 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) @@ -211,10 +212,32 @@ async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return 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") { - // @ts-ignore - await matrixCommandHandler.execute(event) + 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 + ) } + 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) })) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index e382a329..6758f785 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -96,6 +96,33 @@ 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"], @@ -255,18 +282,93 @@ 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 (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 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 branchedFromDiscordMessage = select("event_message", "message_id", {event_id: branchedFromMxEvent}).pluck().get() - await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")}) + if (words.length < 2){ + 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.
" + }) + 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) + "..." + } + + 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}; + } + 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: + 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 + } + } } ) }] -/** @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") @@ -287,7 +389,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 diff --git a/src/matrix/utils.js b/src/matrix/utils.js index 9f5cb0f6..6383c4d1 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) { } } +/** + * @param {undefined|string?} eventID + */ //^For some reason, „?” doesn't include Undefined and it needs to be explicitly specified +function getThreadRoomFromThreadEvent(eventID){ + if (!eventID) return 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.getThreadRoomFromThreadEvent = getThreadRoomFromThreadEvent diff --git a/src/matrix/utils.test.js b/src/matrix/utils.test.js index 842c5130..8db998d9 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,38 @@ 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) +}) + +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 diff --git a/src/types.d.ts b/src/types.d.ts index 81eec7b1..7dc92d6d 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 } diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 1dd9dfed..a321d488 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 6c2aeea8a62032e44dac268bdad870cb0523c053 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 1 Apr 2026 21:20:54 +0000 Subject: [PATCH 049/137] 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 1d51275b..49cc3691 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 050/137] 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 93b123c3..d25c54cf 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 051/137] 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 7f110ad6..e7c50503 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 b6593ecf..eb0e7bd9 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 49cc3691..35e998c7 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 052/137] 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 35e998c7..d06ad663 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 053/137] 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 d06ad663..c2f62323 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 054/137] 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 c2f62323..14b5345d 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 055/137] 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 14b5345d..1a3b4459 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 056/137] 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 1a3b4459..7f328478 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 057/137] 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 7f328478..e890f29f 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 058/137] 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 e890f29f..9cea522e 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 059/137] 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 9cea522e..3624df29 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 060/137] 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 3624df29..0c2301c4 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 061/137] 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 0c2301c4..489904d9 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 062/137] ...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 489904d9..61597f1b 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 d25c54cf..408b70a4 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 063/137] 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 179a559d..0d8fa1fd 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 064/137] 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 0d8fa1fd..d253dfdf 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 065/137] 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 d253dfdf..bd3412ca 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 066/137] 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 8af4d798..a4dfd9a3 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 067/137] 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 61597f1b..c9df78c9 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 408b70a4..e59e58c2 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 068/137] 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 e7c50503..70f7b3ab 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]; } From 0c6a5008e31854746fbf457e495e4d8136579189 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 22:52:11 +0000 Subject: [PATCH 069/137] MOAR channels!!!1!1!!!11!!!!! also, switched to working on this branch for now; I think that's the easiest option for the time being --- src/d2m/actions/create-room.js | 2 +- src/web/routes/guild.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 70f7b3ab..0fb102b2 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -195,7 +195,7 @@ async function channelToKState(channel, guild, di) { if (hasCustomTopic) delete channelKState["m.room.topic/"] // Make voice channels be a Matrix voice room (MSC3417) - if (channel.type === DiscordTypes.ChannelType.GuildVoice) { + if (channel.type === DiscordTypes.ChannelType.GuildVoice || channel.type === DiscordTypes.ChannelType.GuildStageVoice) { creationContent.type = "org.matrix.msc3417.call" channelKState["org.matrix.msc3401.call/"] = { "m.intent": "m.room", diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 70092d50..8dc59f45 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -112,7 +112,7 @@ function getChannelRoomsLinks(guild, rooms, roles) { let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c)) /** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)) - let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type)) + let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 2, 5, 13, 15, 16].includes(c.type)) let removedPrivateChannels = dUtils.filterTo(unlinkedChannels, c => { const permissions = dUtils.getPermissions(guild.id, roles, guild.roles, botID, c["permission_overwrites"]) return dUtils.hasSomePermissions(permissions, ["Administrator", "ViewChannel"]) From f6b9614277bb04c748790163b1c56a4d28f87eb9 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 23:42:51 +0000 Subject: [PATCH 070/137] Only 2 things are eternal: Doom, and temporary solutions --- src/web/routes/guild.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 8dc59f45..0d4c744f 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -122,7 +122,7 @@ function getChannelRoomsLinks(guild, rooms, roles) { let linkedRoomIDs = linkedChannels.map(c => c.room_id) let unlinkedRooms = [...rooms] let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) - let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type) + let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type || r.room_type === "org.matrix.msc3417.call") //TODO: Replace with one of the routes described in https://matrix.to/#/!cBxtVRxDlZvSVhJXVK:cadence.moe/$7zas1wGRL9JFBL_pE01f9tFnU8lAu55k__a3fFsOlzI?via=cadence.moe&via=matrix.org&via=agiadn.org 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 From 369370d0ad566298735ee14730d2183d61103919 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 16 Apr 2026 00:01:43 +0000 Subject: [PATCH 071/137] =?UTF-8?q?=F0=9F=8E=B6=20the=20unenlightened=20ma?= =?UTF-8?q?sses,=20they=20cannot=20make=20a=20judgement=20call=F0=9F=8E=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🎵Give up free will forever - their voices won't be heard at all!🎶 🎶Display obedience...🎵 Where was I, again? Ah, right. I'm supposed to make the judgment call (I am the unenlightened masses) ((Someone tries to link their, fkin, smart-bidet smart-home-controller-room to a Stage or something and imma be cooked)) --- src/web/routes/guild.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 0d4c744f..94f1a5c0 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -122,7 +122,7 @@ function getChannelRoomsLinks(guild, rooms, roles) { let linkedRoomIDs = linkedChannels.map(c => c.room_id) let unlinkedRooms = [...rooms] let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) - let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type || r.room_type === "org.matrix.msc3417.call") //TODO: Replace with one of the routes described in https://matrix.to/#/!cBxtVRxDlZvSVhJXVK:cadence.moe/$7zas1wGRL9JFBL_pE01f9tFnU8lAu55k__a3fFsOlzI?via=cadence.moe&via=matrix.org&via=agiadn.org + let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !(r.room_type && r.room_type === "m.space")) 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 From 81bf0b935f08012d07dacf057a4fdce453a91aed Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 16 Apr 2026 20:26:20 +0000 Subject: [PATCH 072/137] Extra changes for compat with previous commit --- src/web/routes/link.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 772a19c8..cf01f9f1 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -173,8 +173,9 @@ as.router.post("/api/link", defineEventHandler(async event => { const row = from("channel_room").select("channel_id", "room_id").and("WHERE channel_id = ? OR room_id = ?").get(channel.id, parsedBody.matrix) if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`}) - // Check room is part of the guild's space + // Check room is an actual room (not space) and is part of the guild's space let foundRoom = false + let foundSpace = false /** @type {string[]?} */ let foundVia = null for await (const room of api.generateFullHierarchy(spaceID)) { @@ -186,13 +187,18 @@ as.router.post("/api/link", defineEventHandler(async event => { } // When finding a room during iteration, see if it was the requested room (to confirm that the room is in the space) - if (room.room_id === parsedBody.matrix && !room.room_type) { + if (room.room_id === parsedBody.matrix) { foundRoom = true } if (foundRoom && foundVia) break + + if (room.room_type && room.room_type === "m.space") { + foundSpace = true + } } if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) + else if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space"}) // Check room exists and bridge is joined try { From f17c070175500af4b64ee448c5fb78de6ddcd025 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 16 Apr 2026 21:10:54 +0000 Subject: [PATCH 073/137] =?UTF-8?q?Account=20for=20the=20hypothetical=20?= =?UTF-8?q?=E2=80=9E/thread=20=E2=80=9D=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 c9df78c9..f7d3d927 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -256,7 +256,7 @@ async function bridgeThread(event) { 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); + if (name.startsWith("/thread ") && name.length > 8) 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", " ") + "..." From 3a74dfb78f05cf5cfbd818355c34ab9fafa4720b Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 16 Apr 2026 21:27:01 +0000 Subject: [PATCH 074/137] Make sure it's actually possible to create a /thread in a Forum channel without guard()'s interference. Also, while at it, let the users know that /thread usage is a possibility in Forums. --- src/m2d/event-dispatcher.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index f7d3d927..49dc8c81 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -225,8 +225,19 @@ async event => { } } - const messageResponses = await sendEvent.sendEvent(event) - if (!messageResponses.length) return + try { + const messageResponses = await sendEvent.sendEvent(event) + if (!messageResponses.length) return + } + catch (e){ //This had to have been caught outside the regular guard()->sendError() loop, otherwise commands wouldn't get processed. + if (e.message?.includes("220001")) { //see: https://docs.discord.com/developers/topics/opcodes-and-status-codes + if(!event.content.body.startsWith("/thread")) api.sendEvent(event.room_id, "m.room.message", { + msgtype: "m.text", + body: "You cannot send regular messages in rooms bridged to forum channels! Please create a /thread instead." + }) + } + else throw e + } if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) { await matrixCommandHandler.parseAndExecute( @@ -265,7 +276,7 @@ async function bridgeThread(event) { 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. + if (e.message?.includes("50024")) return false; //Tried to created a thread in a thread? 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. } } From b1513a6fd169b9960e91cd732ec263035427075c Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 11:30:54 +0000 Subject: [PATCH 075/137] idea acquired --- src/m2d/event-dispatcher.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 49dc8c81..77e0b5b6 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -276,8 +276,22 @@ async function bridgeThread(event) { return true; } catch (e){ - if (e.message?.includes("50024")) return false; //Tried to created a thread in a thread? 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. + if (e.message?.includes("50024")){ + api.sendEvent(event.room_id, "m.room.message", { + body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. The user will just see a regular reply.", + "m.mentions": { "user_ids": [event.sender]}, + "m.relates_to": { + event_id: eventID, + is_falling_back: false, + "m.in_reply_to": { event_id: event.event_id }, + rel_type: "m.thread" + }, + msgtype: "m.text" + }) + + return false; + } + else throw e } } From 10b6cf5bdbf3e9cb673a58ed5d84616a4f0c7eab Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 13:27:46 +0000 Subject: [PATCH 076/137] =?UTF-8?q?Undone=20some=20of=20the=20=E2=80=9Equa?= =?UTF-8?q?lity=20improvements=E2=80=9D=20from=20yesterday=20because=20I?= =?UTF-8?q?=20noticed=20they'd=20break=20auto-removing=20for=20already=20e?= =?UTF-8?q?xisting=20threads.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/m2d/event-dispatcher.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 77e0b5b6..bb1fcc3b 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -213,12 +213,15 @@ async event => { let processCommands = true if (event.content["m.relates_to"]?.rel_type === "m.thread") { - const toRedact = event.room_id + /**@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 if (await bridgeThread(event)) { + else if (!await bridgeThread(event)) toRedact = null; //Don't remove anything, if there is nowhere to relocate it to. + + 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 ~ "+event.sender, formatted_body: event.content.formatted_body ? event.content.formatted_body+"
~ "+event.sender :undefined }) From 86c58f169ef44cd43bafdd26a728f530ee133f26 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 14:21:33 +0000 Subject: [PATCH 077/137] stupid emigrants... The code is always greener in the other file, or something --- src/m2d/converters/threads-and-forums.js | 111 +++++++++++++++++++++++ src/m2d/event-dispatcher.js | 46 +--------- 2 files changed, 112 insertions(+), 45 deletions(-) create mode 100644 src/m2d/converters/threads-and-forums.js diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js new file mode 100644 index 00000000..0f6e1459 --- /dev/null +++ b/src/m2d/converters/threads-and-forums.js @@ -0,0 +1,111 @@ +//@ts-check + +/* + * Misc. utils for transforming various Matrix events (eg. those sent in Forum-bridged channels; those sent) so that they're usable as threads, and for creating said threads. + */ + +const util = require("util") +const Ty = require("../../types") +const {discord, db, sync, as, select, from} = require("../../passthrough") +const {tag} = require("@cloudrac3r/html-template-tag") +const {Semaphore} = require("@chriscdn/promise-semaphore") +const assert = require("assert").strict +const DiscordTypes = require("discord-api-types/v10") + +/** @type {import("../actions/send-event")} */ +const sendEvent = sync.require("../actions/send-event") +/** @type {import("../actions/add-reaction")} */ +const addReaction = sync.require("../actions/add-reaction") +/** @type {import("../actions/redact")} */ +const redact = sync.require("../actions/redact") +/** @type {import("../actions/update-pins")}) */ +const updatePins = sync.require("../actions/update-pins") +/** @type {import("../actions/vote")}) */ +const vote = sync.require("../actions/vote") +/** @type {import("../../matrix/matrix-command-handler")} */ +const matrixCommandHandler = sync.require("../../matrix/matrix-command-handler") +/** @type {import("../../matrix/utils")} */ +const utils = sync.require("../../matrix/utils") +/** @type {import("../../matrix/api")}) */ +const api = sync.require("../../matrix/api") +/** @type {import("../../d2m/actions/create-room")} */ +const createRoom = sync.require("../../d2m/actions/create-room") +/** @type {import("../../matrix/room-upgrade")} */ +const roomUpgrade = require("../../matrix/room-upgrade") +/** @type {import("../../d2m/actions/retrigger")} */ +const retrigger = sync.require("../../d2m/actions/retrigger") +const {reg} = require("../../matrix/read-registration") + +/** + * @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) { + /** @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 false; //Room not bridged? We don't care. It's a Matrix-native room, let Matrix users have standard Matrix-native threads there. + + const threadEventID = event.content["m.relates_to"]?.event_id + if (!threadEventID) 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: threadEventID}).pluck().get() + 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). + + try { + event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name: computeName(event, await api.getEvent(event.room_id, threadEventID)).name})).id) + return true; + } + catch (e){ + if (e.message?.includes("50024")){ + api.sendEvent(event.room_id, "m.room.message", { + body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. The user will just see a regular reply.", + "m.mentions": { "user_ids": [event.sender]}, + "m.relates_to": { + event_id: threadEventID, + is_falling_back: false, + "m.in_reply_to": { event_id: event.event_id }, + rel_type: "m.thread" + }, + msgtype: "m.text" + }) + + return false; + } + else throw e + } +} + +/** + * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event + * @returns {Promise} true if a forum-thread-room was created + */ +async function handleForums(event) { + if (event.content.body === "/thread") return false; //Let the help be shown normally + + const row = from("channel_room").where({room_id: event.room_id}).select("channel_id").get() + /** @type {string}*/ //@ts-ignore the possibility that it's undefined: get() will return back an undefined if it's fed one (so that's undefined-safe), and createThreadWithoutMessage() won't be reached because "undefined" is neither DiscordTypes.ChannelType.GuildMedia nor DiscordTypes.ChannelType.GuildForum, so the guard clause kicks in. + let channelID = row?.channel_id + const type = discord.channels.get(channelID)?.type + if (type != DiscordTypes.ChannelType.GuildForum && type != DiscordTypes.ChannelType.GuildMedia) return false + + const name = computeName(event) + await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, type:11}) + if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. + return true +} + +/** + * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} from event + * @param {Ty.Event.Outer | null | false | undefined} fallback Reuses the "from" param value if empty. + * @returns {{name: string, truncated: boolean}} + */ +function computeName(from, fallback=null){ + let name = from.content.body + if (name.startsWith("/thread ") && name.length > 8) name = name.substring(8); + else name = (fallback ? fallback : from).content.body; + return name.length < 100 ? {name: name.replaceAll("\n", " "), truncated: false} : {name: name.slice(0, 96).replaceAll("\n", " ") + "...", truncated: true} +} + +module.exports.handleForums = handleForums +module.exports.bridgeThread = bridgeThread \ No newline at end of file diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index bb1fcc3b..95a31573 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -9,6 +9,7 @@ const Ty = require("../types") const {discord, db, sync, as, select} = require("../passthrough") const {tag} = require("@cloudrac3r/html-template-tag") const {Semaphore} = require("@chriscdn/promise-semaphore") +const { bridgeThread, handleForums } = require("./converters/threads-and-forums") /** @type {import("./actions/send-event")} */ const sendEvent = sync.require("./actions/send-event") @@ -253,51 +254,6 @@ async event => { await api.ackEvent(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; 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) { - /** @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 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 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.length > 8) 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", " ") + "..." - - 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")){ - api.sendEvent(event.room_id, "m.room.message", { - body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. The user will just see a regular reply.", - "m.mentions": { "user_ids": [event.sender]}, - "m.relates_to": { - event_id: eventID, - is_falling_back: false, - "m.in_reply_to": { event_id: event.event_id }, - rel_type: "m.thread" - }, - msgtype: "m.text" - }) - - return false; - } - else throw e - } -} - 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 d52794e22ce2b14f3eebd5801824a81348e4eace Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 17:00:16 +0000 Subject: [PATCH 078/137] ACTUALLY handle forums turns out my handling of it from yesterday was still broken --- src/m2d/event-dispatcher.js | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 95a31573..0849a93d 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -211,6 +211,7 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", */ async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return + if (await handleForums(event)) return let processCommands = true if (event.content["m.relates_to"]?.rel_type === "m.thread") { @@ -229,19 +230,8 @@ async event => { } } - try { - const messageResponses = await sendEvent.sendEvent(event) - if (!messageResponses.length) return - } - catch (e){ //This had to have been caught outside the regular guard()->sendError() loop, otherwise commands wouldn't get processed. - if (e.message?.includes("220001")) { //see: https://docs.discord.com/developers/topics/opcodes-and-status-codes - if(!event.content.body.startsWith("/thread")) api.sendEvent(event.room_id, "m.room.message", { - msgtype: "m.text", - body: "You cannot send regular messages in rooms bridged to forum channels! Please create a /thread instead." - }) - } - else throw e - } + const messageResponses = await sendEvent.sendEvent(event) + if (!messageResponses.length) return if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) { await matrixCommandHandler.parseAndExecute( From 2d82734a06ecadb540068487b26c79541f560f68 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 17:48:42 +0000 Subject: [PATCH 079/137] just appreciated how useful these links are after getting an error and having to go all the way to the Command Handler for links --- src/m2d/converters/threads-and-forums.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index 0f6e1459..b9aff853 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -57,7 +57,7 @@ async function bridgeThread(event) { return true; } catch (e){ - if (e.message?.includes("50024")){ + if (e.message?.includes("50024")){ // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes api.sendEvent(event.room_id, "m.room.message", { body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. The user will just see a regular reply.", "m.mentions": { "user_ids": [event.sender]}, @@ -82,7 +82,7 @@ async function bridgeThread(event) { */ async function handleForums(event) { if (event.content.body === "/thread") return false; //Let the help be shown normally - + const row = from("channel_room").where({room_id: event.room_id}).select("channel_id").get() /** @type {string}*/ //@ts-ignore the possibility that it's undefined: get() will return back an undefined if it's fed one (so that's undefined-safe), and createThreadWithoutMessage() won't be reached because "undefined" is neither DiscordTypes.ChannelType.GuildMedia nor DiscordTypes.ChannelType.GuildForum, so the guard clause kicks in. let channelID = row?.channel_id From e95df09c5d30a756304c951456644533fd9b67ac Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 17:59:02 +0000 Subject: [PATCH 080/137] heyyyyy~~~~~~~~ --- src/m2d/converters/threads-and-forums.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index b9aff853..661d53be 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -84,13 +84,14 @@ async function handleForums(event) { if (event.content.body === "/thread") return false; //Let the help be shown normally const row = from("channel_room").where({room_id: event.room_id}).select("channel_id").get() - /** @type {string}*/ //@ts-ignore the possibility that it's undefined: get() will return back an undefined if it's fed one (so that's undefined-safe), and createThreadWithoutMessage() won't be reached because "undefined" is neither DiscordTypes.ChannelType.GuildMedia nor DiscordTypes.ChannelType.GuildForum, so the guard clause kicks in. + /** @type {string}*/ //@ts-ignore the possibility that it's undefined - get() will return back an undefined if it's fed one (so that's undefined-safe), and createThreadWithoutMessage() won't be reached because "undefined" is neither DiscordTypes.ChannelType.GuildMedia nor DiscordTypes.ChannelType.GuildForum, so the guard clause kicks in. let channelID = row?.channel_id const type = discord.channels.get(channelID)?.type if (type != DiscordTypes.ChannelType.GuildForum && type != DiscordTypes.ChannelType.GuildMedia) return false const name = computeName(event) - await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, type:11}) + //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) + await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"heyy~"}}) if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. return true } From 81758529bacd58ddbfb8ad60f6eec864b9dafef0 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 18:46:57 +0000 Subject: [PATCH 081/137] Debugging tags-breaking --- src/m2d/converters/threads-and-forums.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index 661d53be..3b8d9b04 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -91,6 +91,7 @@ async function handleForums(event) { const name = computeName(event) //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) + console.log(discord.channels.get(channelID)?.flags) await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"heyy~"}}) if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. return true From ab482a82fe45d1385739edfbaf8ef57d7118a7f7 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 19:43:31 +0000 Subject: [PATCH 082/137] actually, GIMME DA OBJECT! --- src/m2d/converters/threads-and-forums.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index 3b8d9b04..de9d180d 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -91,7 +91,7 @@ async function handleForums(event) { const name = computeName(event) //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) - console.log(discord.channels.get(channelID)?.flags) + console.log(JSON.stringify(discord.channels.get(channelID))) await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"heyy~"}}) if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. return true From 804a6ecb746aa0bbf3eca3fc597059dac391f2f1 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 20:04:10 +0000 Subject: [PATCH 083/137] This isn't gonna work, is it? I'm 99% sure I need to do {...channel, flags:(channel.flags^DiscordTypes.ChannelFlags.RequireTag)} but TypeScript won't let me --- src/m2d/converters/threads-and-forums.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index de9d180d..5de43a6e 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -86,14 +86,19 @@ async function handleForums(event) { const row = from("channel_room").where({room_id: event.room_id}).select("channel_id").get() /** @type {string}*/ //@ts-ignore the possibility that it's undefined - get() will return back an undefined if it's fed one (so that's undefined-safe), and createThreadWithoutMessage() won't be reached because "undefined" is neither DiscordTypes.ChannelType.GuildMedia nor DiscordTypes.ChannelType.GuildForum, so the guard clause kicks in. let channelID = row?.channel_id - const type = discord.channels.get(channelID)?.type - if (type != DiscordTypes.ChannelType.GuildForum && type != DiscordTypes.ChannelType.GuildMedia) return false + const channel = discord.channels.get(channelID) + if (channel?.type != DiscordTypes.ChannelType.GuildForum && channel?.type != DiscordTypes.ChannelType.GuildMedia) return false const name = computeName(event) + let resetNeeded = false + if(channel.flags && channel.flags & DiscordTypes.ChannelFlags.RequireTag){ + await discord.snow.channel.updateChannel(channelID, {flags:(channel.flags^DiscordTypes.ChannelFlags.RequireTag)}, "Temporary flag reset, to override tagging requirements for Matrix threads that can't be tagged.") + resetNeeded = true + } //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) - console.log(JSON.stringify(discord.channels.get(channelID))) await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"heyy~"}}) if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. + if (resetNeeded) discord.snow.channel.updateChannel(channelID, channel, "Restoring flags to their original state.") return true } From 5aa13a2a92e8865a0cf3b172921f5e1abb69d091 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 21:27:14 +0000 Subject: [PATCH 084/137] Somehow, it almost did! 2 caveats remain (and neither has anything to do with not passing ...channel): * ugly-error with permissions (fixed) * no auto-reset (maybe fixed??? - it's either because I DID pass channel (ironic) or because of no await, testing option 1 now) Also, improved comment consistency --- src/m2d/converters/threads-and-forums.js | 29 ++++++++++++++++-------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index 5de43a6e..d46ef49d 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -57,7 +57,7 @@ async function bridgeThread(event) { return true; } catch (e){ - if (e.message?.includes("50024")){ // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes + if (e.message?.includes("50024")){ //see: https://docs.discord.com/developers/topics/opcodes-and-status-codes api.sendEvent(event.room_id, "m.room.message", { body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. The user will just see a regular reply.", "m.mentions": { "user_ids": [event.sender]}, @@ -78,7 +78,7 @@ async function bridgeThread(event) { /** * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event - * @returns {Promise} true if a forum-thread-room was created + * @returns {Promise} whether a forum-thread-room was created */ async function handleForums(event) { if (event.content.body === "/thread") return false; //Let the help be shown normally @@ -91,14 +91,25 @@ async function handleForums(event) { const name = computeName(event) let resetNeeded = false - if(channel.flags && channel.flags & DiscordTypes.ChannelFlags.RequireTag){ - await discord.snow.channel.updateChannel(channelID, {flags:(channel.flags^DiscordTypes.ChannelFlags.RequireTag)}, "Temporary flag reset, to override tagging requirements for Matrix threads that can't be tagged.") - resetNeeded = true + try { + if(channel.flags && channel.flags & DiscordTypes.ChannelFlags.RequireTag){ + await discord.snow.channel.updateChannel(channelID, {flags:(channel.flags^DiscordTypes.ChannelFlags.RequireTag)}, "Temporary override of tagging requirements because Matrix threads that can't be tagged yet.") + resetNeeded = true + } + //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) + await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"heyy~"}}) + if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. + if (resetNeeded) discord.snow.channel.updateChannel(channelID, {flags:channel.flags}, "Restoring flags to their original state.") + } + catch (e){ + if (e.message?.includes("50013")){ + api.sendEvent(event.room_id, "m.room.message", { + body: "You can't create threads in this forum right now! This forum is configured to require tags on post (Matrix users can't yet use tags yet), and OOYE doesn't have the permission to edit this channel on Discord (needed to bypass the requirement of tags). Unless this is intentional (see room description - the admins may have left a note), please ask someone on the Discord side to either grant OOYE the necessary permissions, or to remove tagging requirements.", + msgtype: "m.text" + }) + } + else throw e } - //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) - await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"heyy~"}}) - if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. - if (resetNeeded) discord.snow.channel.updateChannel(channelID, channel, "Restoring flags to their original state.") return true } From 88b25e0482d1d92ac1e4e43747f6b268c7749092 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 22:07:41 +0000 Subject: [PATCH 085/137] imports. --- src/m2d/converters/threads-and-forums.js | 25 +----------------------- 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index d46ef49d..a75b7e4d 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -4,37 +4,14 @@ * Misc. utils for transforming various Matrix events (eg. those sent in Forum-bridged channels; those sent) so that they're usable as threads, and for creating said threads. */ -const util = require("util") const Ty = require("../../types") -const {discord, db, sync, as, select, from} = require("../../passthrough") -const {tag} = require("@cloudrac3r/html-template-tag") -const {Semaphore} = require("@chriscdn/promise-semaphore") -const assert = require("assert").strict +const {discord, sync, select, from} = require("../../passthrough") const DiscordTypes = require("discord-api-types/v10") -/** @type {import("../actions/send-event")} */ -const sendEvent = sync.require("../actions/send-event") -/** @type {import("../actions/add-reaction")} */ -const addReaction = sync.require("../actions/add-reaction") -/** @type {import("../actions/redact")} */ -const redact = sync.require("../actions/redact") -/** @type {import("../actions/update-pins")}) */ -const updatePins = sync.require("../actions/update-pins") -/** @type {import("../actions/vote")}) */ -const vote = sync.require("../actions/vote") -/** @type {import("../../matrix/matrix-command-handler")} */ -const matrixCommandHandler = sync.require("../../matrix/matrix-command-handler") -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") /** @type {import("../../matrix/api")}) */ const api = sync.require("../../matrix/api") /** @type {import("../../d2m/actions/create-room")} */ const createRoom = sync.require("../../d2m/actions/create-room") -/** @type {import("../../matrix/room-upgrade")} */ -const roomUpgrade = require("../../matrix/room-upgrade") -/** @type {import("../../d2m/actions/retrigger")} */ -const retrigger = sync.require("../../d2m/actions/retrigger") -const {reg} = require("../../matrix/read-registration") /** * @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. From b6a68936ecc5c4551426f31fd54242767f14cacd Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 22:10:50 +0000 Subject: [PATCH 086/137] text. --- src/m2d/converters/threads-and-forums.js | 2 +- src/matrix/matrix-command-handler.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index a75b7e4d..dcf79094 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -36,7 +36,7 @@ async function bridgeThread(event) { catch (e){ if (e.message?.includes("50024")){ //see: https://docs.discord.com/developers/topics/opcodes-and-status-codes api.sendEvent(event.room_id, "m.room.message", { - body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. The user will just see a regular reply.", + body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. DC users will just see a regular reply, which is distracting and also probably not what you want.", "m.mentions": { "user_ids": [event.sender]}, "m.relates_to": { event_id: threadEventID, diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index e59e58c2..ba72d5aa 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -276,7 +276,7 @@ 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.", + body: "**`/thread` usage:**\nRun this command as `/thread [Thread Name]` to create a thread. 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.
" }) From 189ea7e769607ce921455891d23597160a7a1171 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 22:30:14 +0000 Subject: [PATCH 087/137] =?UTF-8?q?text=C2=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/m2d/converters/threads-and-forums.js | 2 +- src/matrix/matrix-command-handler.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index dcf79094..10aa8c02 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -36,7 +36,7 @@ async function bridgeThread(event) { catch (e){ if (e.message?.includes("50024")){ //see: https://docs.discord.com/developers/topics/opcodes-and-status-codes api.sendEvent(event.room_id, "m.room.message", { - body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. DC users will just see a regular reply, which is distracting and also probably not what you want.", + body: "Hey, please don't do that! This room is already a thread on Discord (or it could also be a voice-chat or something adjacent) - trying to embed threads inside it, like you just did, will not work. DC users will just see a regular reply, which is distracting and also probably not what you want.", "m.mentions": { "user_ids": [event.sender]}, "m.relates_to": { event_id: threadEventID, diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index ba72d5aa..2b99200c 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -310,7 +310,7 @@ const commands = [{ 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?" + body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. It could be something like a VC, or perhaps... Did you try to embed a thread inside a thread, silly?" }) if (err?.includes("50035")) return api.sendEvent(event.room_id, "m.room.message", { ...ctx, From e92bda4a2a73ba57f9045c28f948c4818bbde2f1 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 22:39:32 +0000 Subject: [PATCH 088/137] =?UTF-8?q?Credit=20where=20credit=20is=20due(TRUE?= =?UTF-8?q?)=C2=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/m2d/converters/threads-and-forums.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index 10aa8c02..05760b47 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -74,7 +74,7 @@ async function handleForums(event) { resetNeeded = true } //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) - await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"heyy~"}}) + await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"**Created by: `"+ event.sender +"`**"}}) if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. if (resetNeeded) discord.snow.channel.updateChannel(channelID, {flags:channel.flags}, "Restoring flags to their original state.") } From cc906d5fb7b3fc3ecf288941d1afdd4e7ab6b759 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 22 Apr 2026 12:18:38 +0000 Subject: [PATCH 089/137] Does EM even work? --- 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 bd3412ca..be56d7e9 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -19,7 +19,7 @@ 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 = true //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 = {} From a7aad4281df2b70eb43256b66903ab3e29572405 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 22 Apr 2026 12:27:46 +0000 Subject: [PATCH 090/137] Well, it DOES WORK, alright.....! thanks, I hate it --- 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 be56d7e9..bd3412ca 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -19,7 +19,7 @@ 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 = true //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 = {} From a54809155f1df3a6b60230364be85da35e1fff8c Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 22 Apr 2026 12:31:02 +0000 Subject: [PATCH 091/137] wait, I wanna test 1 more thing on mobile... --- 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 bd3412ca..be56d7e9 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -19,7 +19,7 @@ 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 = true //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 = {} From cd2b5ebb13a28d5f58c14efca90ae503338957c7 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 22 Apr 2026 12:41:19 +0000 Subject: [PATCH 092/137] Yep! It does exactly what I thought it would. --- 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 be56d7e9..bd3412ca 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -19,7 +19,7 @@ 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 = true //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 = {} From 7943f33dbb1e342620f00f33c4c9a26eb0282000 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 22 Apr 2026 13:50:27 +0000 Subject: [PATCH 093/137] What were those imports for, anyway? --- src/d2m/converters/thread-to-announcement.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index bd3412ca..b3a4487c 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -1,9 +1,7 @@ // @ts-check -const assert = require("assert").strict - const passthrough = require("../../passthrough") -const {discord, sync, db, select} = passthrough +const {sync, select} = passthrough /** @type {import("../../matrix/utils")} */ const mxUtils = sync.require("../../matrix/utils") const {reg} = require("../../matrix/read-registration.js") From b007822174401473be56c929d06d0bfc5f554899 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 22 Apr 2026 13:54:49 +0000 Subject: [PATCH 094/137] Okey, let's be real: Those tests were an embarrassment. --- src/matrix/utils.test.js | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/matrix/utils.test.js b/src/matrix/utils.test.js index 8db998d9..68872522 100644 --- a/src/matrix/utils.test.js +++ b/src/matrix/utils.test.js @@ -417,6 +417,11 @@ test("set user power: privileged users must demote themselves", async t => { t.equal(called, 3) }) +test("getThreadRoomFromThreadEvent: real message with a thread", t => { + const room = getThreadRoomFromThreadEvent("$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg") + t.equal(room, "!FuDZhlOAtqswlyxzeR:cadence.moe") +}) + test("getThreadRoomFromThreadEvent: real message, but without a thread", t => { const room = getThreadRoomFromThreadEvent("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") const msg = "Expected null/undefined, got: "+room @@ -424,11 +429,6 @@ test("getThreadRoomFromThreadEvent: real message, but without a thread", t => { 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 @@ -436,19 +436,4 @@ 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 1b9f99c4fd679316daa54773d8d7f67a3641ae53 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 22 Apr 2026 14:11:06 +0000 Subject: [PATCH 095/137] fix regression in Ellie Mode; add the announcement to it Apparently, the context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}} has always been there... The more you know! I forgot I didn't, apparently, add it myself. Also, while fixing the regression, I may as well introduce the note to EM, too. --- src/d2m/converters/thread-to-announcement.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index b3a4487c..6571619c 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -25,6 +25,8 @@ 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[Note: You really should move the conversation to that room, rather than continuing to reply via a Matrix thread. Any messages sent in threads will be DELETED and instead moved to that room 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}} 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) From e301a1f3b09727f284e082a532aa3ddc097a7349 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 13:12:58 +0000 Subject: [PATCH 096/137] oh lol Wrong IF xD This is getting nuked soon, but I still find if funny that I got it wrong. --- src/web/routes/link.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index cf01f9f1..aa97f72a 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -198,7 +198,7 @@ as.router.post("/api/link", defineEventHandler(async event => { } } if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) - else if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space"}) + else if (foundSpace) throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space"}) // Check room exists and bridge is joined try { From dc03dbd5f4a051f61de70a4bf8dfcbbadab6d5f8 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 23:01:02 +0000 Subject: [PATCH 097/137] New system of linking --- src/d2m/actions/create-room.js | 12 ++++++++-- src/types.d.ts | 2 ++ src/web/pug/explain.pug | 5 +++++ src/web/pug/guild.pug | 22 ++++++++++--------- src/web/routes/guild.js | 40 +++++++++++++++++++++++++++++++--- src/web/routes/link.js | 9 ++++++-- 6 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 src/web/pug/explain.pug diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 0fb102b2..2a76f564 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -35,6 +35,7 @@ const PRIVACY_ENUMS = { ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after are visible, but for world_readable anybody can read without even joining GUEST_ACCESS: ["can_join", "forbidden", "forbidden"], // whether guests can join space if other conditions are met SPACE_JOIN_RULES: ["invite", "public", "public"], + /** @type {import("../../types").JoinRule[]} */ ROOM_JOIN_RULES: ["restricted", "public", "public"] } @@ -88,7 +89,7 @@ async function channelToKState(channel, guild, di) { const guildSpaceID = await createSpace.ensureSpace(guild) /** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */ let parentSpaceID = guildSpaceID - if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { + if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum || parentChannel?.type === DiscordTypes.ChannelType.GuildMedia) { //TODO: Once Ellie's and Guzio's MSC for room-in-room embedding starts being implemented, make this check for whether THIS channel (not its parent) is a thread of ANY type (not just threads in forum/media channels) - thus making it so that threads always appear embedded under their parent. parentSpaceID = await ensureRoom(channel.parent_id) assert(typeof parentSpaceID === "string") } @@ -111,7 +112,7 @@ async function channelToKState(channel, guild, di) { let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel] if (channel["thread_metadata"]) history_visibility = "world_readable" - /** @type {{join_rule: string, allow?: any}} */ + /** @type {{join_rule: import("../../types").JoinRule, allow?: {type: "m.room_membership", room_id: string}[]}} */ let join_rules = { join_rule: "restricted", allow: [{ @@ -119,6 +120,13 @@ async function channelToKState(channel, guild, di) { room_id: guildSpaceID }] } + if (guildSpaceID !== parentSpaceID) { + //@ts-ignore - join_rules.allow most certainly IS defined because we literally define it ~5 lines earlier + join_rules.allow[1] = { + type: "m.room_membership", + room_id: parentSpaceID + } + } if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") { join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} } diff --git a/src/types.d.ts b/src/types.d.ts index 494cba23..db209ce5 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -505,6 +505,8 @@ export namespace R { export type Membership = "invite" | "knock" | "join" | "leave" | "ban" +export type JoinRule = "public" | "knock" | "invite" | "private" | "restricted" | "knock_restricted" + export type Pagination = { chunk: T[] next_batch?: string diff --git a/src/web/pug/explain.pug b/src/web/pug/explain.pug new file mode 100644 index 00000000..d280ba55 --- /dev/null +++ b/src/web/pug/explain.pug @@ -0,0 +1,5 @@ +extends includes/template.pug + +block body + .ta-center.wmx5.p48.mx-auto#ok + p.mt24.fs-body2= msg diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 9791ae3d..990477ab 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -238,6 +238,8 @@ block body ul.my8.ml24 each row in removedWrongTypeChannels li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} + "|" + a(href=`/explain?type=${row.type}`) Why? h3.mt24 Unavailable channels: Discord bot can't access .s-card.p0 ul.my8.ml24 @@ -254,13 +256,13 @@ block body 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 - each row in removedWrongTypeRooms - li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} - h3.mt24 Unavailable rooms: Archived thread - .s-card.p0 - ul.my8.ml24 - each row in removedArchivedThreadRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name +// h3.mt24 Unavailable rooms: Wrong type +// .s-card.p0 +// ul.my8.ml24 +// each row in removedWrongTypeRooms +// li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} +// h3.mt24 Unavailable rooms: Archived thread +// .s-card.p0 +// ul.my8.ml24 +// each row in removedArchivedThreadRooms +// li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 94f1a5c0..7a7c4acf 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -38,6 +38,9 @@ const schema = { }), inviteNonce: z.object({ nonce: z.string() + }), + explain: z.object({ + type: z.number() }) } @@ -53,6 +56,27 @@ function getAPI(event) { /** @type {LRUCache} nonce to guild id */ const validNonce = new LRUCache({max: 200}) +/** + * TYPING = Channels on which Discord messages can be sent. They should be bridgeable to anything other than an m.space (because if it did end up as a space, no one would be able to actually see the text messages sent there). + * SPACE = Channels on which Discord messages cannot be received. They should be bridgeable to m.space only (because not only does m.space make sending messages impossible on any sane client (thus preventing Discord-caused errors), but it also just-so-happens that both currently-existing message-unsupporting channel types (Categories and School hubs) are sort of "indexes", which fits nicely to m.space). + * MIXED = Forum-like channels. They can be bridged to both m.space and anything other than an m.space - hence the name. + * @type {Map}*/ +const linkRules = new Map([ + [0, {type: "TYPING", humanName:"Normal text channels"}], + [1, {type: "TYPING", humanName:"Normal DMs", unsupported: "OOYE won't support DMs until a good way of doing it can be figured out. Please see https://gitdab.com/cadence/out-of-your-element#caveats for more."}], + [2, {type: "TYPING", humanName:"Normal VCs"}], + [3, {type: "TYPING", humanName:"Group DMs", unsupported: "OOYE won't support DMs until a good way of doing it can be figured out. Please see https://gitdab.com/cadence/out-of-your-element#caveats for more."}], + [4, {type: "SPACE", humanName:"Categories", unsupported: "There is no concept of categories on Matrix."}], //...at least officially. In practice, some clients will render sub-spaces as categories. TODO: Bridge categories to sub-spaces. + [5, {type: "TYPING", humanName:"Announcement text channels"}], + [10, {type: "TYPING", humanName:"Announcement threads"}], //Currently testing: Letting users bridge threads however they like. In case this doesn't work: , unsupported: "Threads must be bridged automatically, to ensure proper lifecycle management (so that archiving threads won't break them). Please send a message in this thread to bridge it automatically." + [11, {type: "TYPING", humanName:"Normal threads"}], + [12, {type: "TYPING", humanName:"Private threads"}], + [13, {type: "TYPING", humanName:"Stage VCs"}], + [14, {type: "SPACE", humanName:"School hubs", unsupported: "Bots cannot be members of school hubs. How in the sweet hell did you manage to put OOYE on one, anyway??? ~~Emma, please stop breaking Discord API in cursed ways again.~~"}], + [15, {type: "MIXED", humanName:"Normal forums"}], + [16, {type: "MIXED", humanName:"Media forums"}], +]) + /** * @param {{type: number, parent_id?: string | null, position?: number}} channel * @param {Map} channels @@ -112,7 +136,10 @@ function getChannelRoomsLinks(guild, rooms, roles) { let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c)) /** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)) - let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 2, 5, 13, 15, 16].includes(c.type)) + let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => { + const rule = linkRules.get(c?.type) + return rule && !rule.unsupported + }) let removedPrivateChannels = dUtils.filterTo(unlinkedChannels, c => { const permissions = dUtils.getPermissions(guild.id, roles, guild.roles, botID, c["permission_overwrites"]) return dUtils.hasSomePermissions(permissions, ["Administrator", "ViewChannel"]) @@ -122,11 +149,11 @@ function getChannelRoomsLinks(guild, rooms, roles) { let linkedRoomIDs = linkedChannels.map(c => c.room_id) let unlinkedRooms = [...rooms] let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) - let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !(r.room_type && r.room_type === "m.space")) + let removedWrongTypeRooms = [] 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(/^\[(🔒)?⛓️\]/)) + let removedArchivedThreadRooms = [] //dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) COMMENTED OUT - Currently testing: Letting users bridge threads however they like. return { linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, @@ -182,6 +209,12 @@ as.router.get("/guild", defineEventHandler(async event => { return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) })) +as.router.get("/explain", defineEventHandler(async event => { + const {type} = await getValidatedQuery(event, schema.explain.parse) + const msg = type+" is unsupported" //TODO: actually explain (I'm sure I messed something up anyway, I'll leave it for now) + return pugSync.render(event, "explain.pug", {msg}) +})) + as.router.get("/qr", defineEventHandler(async event => { const {guild_id} = await getValidatedQuery(event, schema.qr.parse) const managed = await auth.getManagedGuilds(event) @@ -267,3 +300,4 @@ as.router.post("/api/invite", defineEventHandler(async event => { module.exports._getPosition = getPosition module.exports.getInviteTargetSpaces = getInviteTargetSpaces +module.exports.linkRules = linkRules diff --git a/src/web/routes/link.js b/src/web/routes/link.js index aa97f72a..29922492 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -173,7 +173,7 @@ as.router.post("/api/link", defineEventHandler(async event => { const row = from("channel_room").select("channel_id", "room_id").and("WHERE channel_id = ? OR room_id = ?").get(channel.id, parsedBody.matrix) if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`}) - // Check room is an actual room (not space) and is part of the guild's space + // Check whether the room is an actual room or a space, and if it's a part of the guild's space let foundRoom = false let foundSpace = false /** @type {string[]?} */ @@ -198,7 +198,12 @@ as.router.post("/api/link", defineEventHandler(async event => { } } if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) - else if (foundSpace) throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space"}) + + //Ensure link rules are upheld + const rule = guildRoute.linkRules.get(channel.type) + if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-" + channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) + else if (foundSpace && rule.type === "TYPING") throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space when bridging to "+rule.humanName}) + else if (!foundSpace && rule.type === "SPACE") throw createError({status: 400, message: "Bad Request", data: "Matrix room must be of type m.space when bridging to "+rule.humanName}) // Check room exists and bridge is joined try { From ae5ea2e936b1f88f2fac965559cfe497475ae48e Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 23:03:47 +0000 Subject: [PATCH 098/137] that died quickly... --- src/web/pug/guild.pug | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 990477ab..7992b21a 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -238,8 +238,8 @@ block body ul.my8.ml24 each row in removedWrongTypeChannels li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} - "|" - a(href=`/explain?type=${row.type}`) Why? + "|" + a(href=`/explain?type=${row.type}`) Why? h3.mt24 Unavailable channels: Discord bot can't access .s-card.p0 ul.my8.ml24 From 00bca38bbe87d82e946917898895f5f9fc7f6b82 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 23:06:44 +0000 Subject: [PATCH 099/137] pug --- 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 7992b21a..f7c046ee 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -238,7 +238,7 @@ block body ul.my8.ml24 each row in removedWrongTypeChannels li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} - "|" + span | a(href=`/explain?type=${row.type}`) Why? h3.mt24 Unavailable channels: Discord bot can't access .s-card.p0 From 69f63792709686f8d39ad80309c6c81d237146ca Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 23:09:43 +0000 Subject: [PATCH 100/137] =?UTF-8?q?WHO=20TF=20LOOKED=20AT=20COMMENTS=20AND?= =?UTF-8?q?=20WAS=20LIKE=20=E2=80=9EOh,=20yea!=20We=20should=20make=20them?= =?UTF-8?q?=20syntax-significant!=E2=80=9D=20That's=20like...=20The=20very?= =?UTF-8?q?=20fucking=20opposite=20of=20what=20comments=20are.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/web/pug/guild.pug | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index f7c046ee..43037846 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -256,13 +256,13 @@ block body 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 -// each row in removedWrongTypeRooms -// li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} -// h3.mt24 Unavailable rooms: Archived thread -// .s-card.p0 -// ul.my8.ml24 -// each row in removedArchivedThreadRooms -// li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + //h3.mt24 Unavailable rooms: Wrong type + //.s-card.p0 + // ul.my8.ml24 + // each row in removedWrongTypeRooms + // li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} + //h3.mt24 Unavailable rooms: Archived thread + //.s-card.p0 + // ul.my8.ml24 + // each row in removedArchivedThreadRooms + // li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name From ed651b41cfad0c7a58441ed1d658840e4ea7e261 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 23:27:13 +0000 Subject: [PATCH 101/137] I can feel it, it's getting somewhere! --- src/web/pug/explain.pug | 3 +-- src/web/pug/guild.pug | 7 ++++--- src/web/routes/guild.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/web/pug/explain.pug b/src/web/pug/explain.pug index d280ba55..037114b6 100644 --- a/src/web/pug/explain.pug +++ b/src/web/pug/explain.pug @@ -1,5 +1,4 @@ extends includes/template.pug block body - .ta-center.wmx5.p48.mx-auto#ok - p.mt24.fs-body2= msg + #(msg) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 43037846..5f34b7fe 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -237,9 +237,10 @@ block body .s-card.p0 ul.my8.ml24 each row in removedWrongTypeChannels - li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} - span | - a(href=`/explain?type=${row.type}`) Why? + li + a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} + span | + a(href=rel(`/explain?type=${row.type}`)) Why? h3.mt24 Unavailable channels: Discord bot can't access .s-card.p0 ul.my8.ml24 diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 7a7c4acf..a8be516e 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -40,7 +40,7 @@ const schema = { nonce: z.string() }), explain: z.object({ - type: z.number() + type: z.string() }) } From c7378d47ce23954882fc7b4fde51fac555e09285 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 23:29:21 +0000 Subject: [PATCH 102/137] fuck you vsc --- 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 5f34b7fe..3cad5ca9 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -238,7 +238,7 @@ block body ul.my8.ml24 each row in removedWrongTypeChannels li - a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} + a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} span | a(href=rel(`/explain?type=${row.type}`)) Why? h3.mt24 Unavailable channels: Discord bot can't access From 82c6f0ab194f060426f5071f2ca4fecd0dfbdd88 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 23:32:09 +0000 Subject: [PATCH 103/137] guild.pug works! --- src/web/pug/explain.pug | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/web/pug/explain.pug b/src/web/pug/explain.pug index 037114b6..d280ba55 100644 --- a/src/web/pug/explain.pug +++ b/src/web/pug/explain.pug @@ -1,4 +1,5 @@ extends includes/template.pug block body - #(msg) + .ta-center.wmx5.p48.mx-auto#ok + p.mt24.fs-body2= msg From 9cbeef9efb6970b12ff2cf76b5d1e3ed3d6b7e38 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 09:42:11 +0000 Subject: [PATCH 104/137] fell asleep --- src/web/routes/guild.js | 5 ++++- src/web/routes/link.js | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index a8be516e..78cf7a57 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -211,7 +211,10 @@ as.router.get("/guild", defineEventHandler(async event => { as.router.get("/explain", defineEventHandler(async event => { const {type} = await getValidatedQuery(event, schema.explain.parse) - const msg = type+" is unsupported" //TODO: actually explain (I'm sure I messed something up anyway, I'll leave it for now) + const rule = linkRules.get(Number.parseInt(type)) + if (!rule) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge type-" + type + " channels because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) + else if (rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-" + channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) + else throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-" + channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) return pugSync.render(event, "explain.pug", {msg}) })) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 29922492..8394a9e8 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -201,7 +201,7 @@ as.router.post("/api/link", defineEventHandler(async event => { //Ensure link rules are upheld const rule = guildRoute.linkRules.get(channel.type) - if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-" + channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) + if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-"+channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) else if (foundSpace && rule.type === "TYPING") throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space when bridging to "+rule.humanName}) else if (!foundSpace && rule.type === "SPACE") throw createError({status: 400, message: "Bad Request", data: "Matrix room must be of type m.space when bridging to "+rule.humanName}) From 511138e31fbb23b5bf10e663a752e2dbcc4aee4e Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 09:57:26 +0000 Subject: [PATCH 105/137] Link Rules system DONE! --- src/web/routes/guild.js | 7 +++---- src/web/routes/link.js | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 78cf7a57..adf85d9c 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -212,10 +212,9 @@ as.router.get("/guild", defineEventHandler(async event => { as.router.get("/explain", defineEventHandler(async event => { const {type} = await getValidatedQuery(event, schema.explain.parse) const rule = linkRules.get(Number.parseInt(type)) - if (!rule) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge type-" + type + " channels because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) - else if (rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-" + channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) - else throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-" + channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) - return pugSync.render(event, "explain.pug", {msg}) + if (!rule) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge to type-" + type + " channels because OOYE doesn't even know what they are yet."}) + else if (rule.unsupported) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge to " + rule.humanName + " (type-" + type + " channels) because: " + rule.unsupported}) + else return pugSync.render(event, "explain.pug", {msg: "You can bridge to " + rule.humanName + " (type-" + type + " channels) just fine. Why are you even here?"}) })) as.router.get("/qr", defineEventHandler(async event => { diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 8394a9e8..1ec34a5e 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -201,7 +201,7 @@ as.router.post("/api/link", defineEventHandler(async event => { //Ensure link rules are upheld const rule = guildRoute.linkRules.get(channel.type) - if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-"+channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) + if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge to " + (rule ? (rule.humanName+"(type-"+channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) else if (foundSpace && rule.type === "TYPING") throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space when bridging to "+rule.humanName}) else if (!foundSpace && rule.type === "SPACE") throw createError({status: 400, message: "Bad Request", data: "Matrix room must be of type m.space when bridging to "+rule.humanName}) From 75fec8819df3fa7d96add8591aed95b455b88922 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 10:32:51 +0000 Subject: [PATCH 106/137] Somehow, everything is a space... --- src/web/routes/link.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 1ec34a5e..b8c69c13 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -196,6 +196,7 @@ as.router.post("/api/link", defineEventHandler(async event => { if (room.room_type && room.room_type === "m.space") { foundSpace = true } + console.log(room.room_id + " ==> "+ foundSpace) } if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) From b0d4b4c39d2ff4ffa72530c42aefc8375992b1fe Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 10:36:18 +0000 Subject: [PATCH 107/137] forgor to print the most important thing --- src/web/routes/link.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index b8c69c13..89ee3ca0 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -196,7 +196,7 @@ as.router.post("/api/link", defineEventHandler(async event => { if (room.room_type && room.room_type === "m.space") { foundSpace = true } - console.log(room.room_id + " ==> "+ foundSpace) + console.log(room.room_type+"@"+room.room_id + " ==> "+ foundSpace) } if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) From f53190f186dd4d56cc8d99a704e1a8bc662f5c9f Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 10:56:50 +0000 Subject: [PATCH 108/137] OH, I'm SUCH an IDIOT! This code runs in a loop. And it always starts with the root space. So it always finds a space. And it then never clears it out. Also, it was after the break;, so EVEN IF it somehow reached our room as false, and then was gonna update it to true because it was a space - it now would not have done that because the loop will had been ended. Fixed by moving the code to where it made any sense. --- src/web/routes/link.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 89ee3ca0..1b8d7734 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -189,18 +189,15 @@ as.router.post("/api/link", defineEventHandler(async event => { // When finding a room during iteration, see if it was the requested room (to confirm that the room is in the space) if (room.room_id === parsedBody.matrix) { foundRoom = true + // And also, now that we know that the room object is our intended room - we can test for its type. + if (room.room_type && room.room_type === "m.space") foundSpace = true } if (foundRoom && foundVia) break - - if (room.room_type && room.room_type === "m.space") { - foundSpace = true - } - console.log(room.room_type+"@"+room.room_id + " ==> "+ foundSpace) } if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) - //Ensure link rules are upheld + // Ensure link rules are upheld const rule = guildRoute.linkRules.get(channel.type) if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge to " + (rule ? (rule.humanName+"(type-"+channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) else if (foundSpace && rule.type === "TYPING") throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space when bridging to "+rule.humanName}) From c3f2fbbeb1718106173a28a7614ef9da91413e53 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 12:22:45 +0000 Subject: [PATCH 109/137] Settled on a reasonable compromise for this --- src/web/pug/guild.pug | 534 ++++++++++++++++++++-------------------- src/web/routes/guild.js | 4 +- 2 files changed, 267 insertions(+), 271 deletions(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 3cad5ca9..7314cea8 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -1,269 +1,265 @@ -extends includes/template.pug -include includes/default-roles-list.pug - -mixin badge-readonly - .s-badge.s-badge__xs.s-badge__icon.s-badge__muted - != icons.Icons.IconEyeSm - | Read-only - -mixin badge-private - .s-badge.s-badge__xs.s-badge__icon.s-badge__warning - != icons.Icons.IconLockSm - | Private - -mixin discord(channel, radio=false) - //- Previously, we passed guild.roles as the second parameter, but this doesn't quite match Discord's behaviour. See issue #42 for why this was changed. - //- Basically we just want to assign badges based on the channel overwrites, without considering the guild's base permissions. /shrug - - let permissions = dUtils.getPermissions(guild_id, [], [{id: guild_id, name: "@everyone", permissions: 1<<10 | 1<<11}], null, channel.permission_overwrites) - .s-user-card.s-user-card__small - if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) - != icons.Icons.IconLock - else if channel.type === 5 - != icons.Icons.IconBullhorn - else if channel.type === 2 - != icons.Icons.IconPhone - else if channel.type === 11 || channel.type === 12 - != icons.Icons.IconCollection - else - include includes/hash.svg - .s-user-card--info.ws-nowrap - if radio - = channel.name - else - .s-user-card--link.fs-body1 - a(href=`https://discord.com/channels/${channel.guild_id}/${channel.id}`)= channel.name - if channel.parent_id - .s-user-card--location= discord.channels.get(channel.parent_id).name - if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) - +badge-private - else if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages) - +badge-readonly - -mixin matrix(row, radio=false, badge="") - .s-user-card.s-user-card__small - != icons.Icons.IconMessage - .s-user-card--info.ws-nowrap - if radio - = row.nick || row.name - else - .s-user-card--link.fs-body1 - a(href=`https://matrix.to/#/${row.room_id}`)= row.nick || row.name - if row.join_rule === "invite" - +badge-private - -block body - .s-page-title.mb24 - h1.s-page-title--header= guild.name - - .d-flex.g16(class="sm:fw-wrap") - .fl-grow1 - h2.fs-headline1 Invite a Matrix user - - form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-trigger="submit" hx-swap="none" hx-on::after-request="if (event.detail.successful) this.reset()" hx-disabled-elt="input, button" hx-indicator="#invite-button") - label.s-label(for="mxid") Matrix ID - input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)") - label.s-label(for="permissions") Permissions - .s-select - select#permissions(name="permissions") - option(value="default") Default - option(value="moderator") Moderator - option(value="admin") Admin - input(type="hidden" name="guild_id" value=guild_id) - .grid--row-start2 - button.s-btn.s-btn__filled#invite-button Invite - div - .s-card.d-flex.ai-center.jc-center(style="min-width: 132px; min-height: 132px;") - button.s-btn(class=space_id ? "s-btn__muted" : "s-btn__filled" hx-get=rel(`/qr?guild_id=${guild_id}`) hx-indicator="closest button" hx-swap="outerHTML" hx-disabled-elt="this") Show QR - - if space_id - h2.mt48.fs-headline1 Server settings - 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") - input(type="hidden" name="guild_id" value=guild_id) - - .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") - input(type="radio" name="privacy_level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) - label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") - != icons.Icons.IconPlusSm - != icons.Icons.IconInternationalSm - .fl-grow1 Directory - - input(type="radio" name="privacy_level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) - label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") - != icons.Icons.IconPlusSm - != icons.Icons.IconLinkSm - .fl-grow1 Link - - input(type="radio" name="privacy_level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) - label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") - svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") - != icons.Icons.IconLockSm - .fl-grow1 Invite - - p.s-description.m0 In-app direct invite from another user - 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-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 - - +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 - #url-preview-loading.p8 - - let value = !!select("guild_space", "url_preview", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#url-preview(name="url_preview" type="checkbox" hx-post=rel("/api/url-preview") hx-indicator="#url-preview-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label.fl-grow1(for="url-preview") - | Show Discord's URL previews on Matrix - p.s-description Shows info about links posted to chat. Discord's previews are generally better quality than Synapse's, especially for social media and videos. - - form.d-flex.ai-center.g16 - #presence-loading.p8 - - value = !!select("guild_space", "presence", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#presence(name="presence" type="checkbox" hx-post=rel("/api/presence") hx-indicator="#presence-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label(for="presence") - | Show online statuses on Matrix - p.s-description This might cause lag on really big Discord servers. - - form.d-flex.ai-center.g16 - #webhook-profile-loading.p8 - - value = !!select("guild_space", "webhook_profile", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#webhook-profile(name="webhook_profile" type="checkbox" hx-post=rel("/api/webhook-profile") hx-indicator="#webhook-profile-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label(for="webhook-profile") - | Create persistent Matrix sims for webhooks - p.s-description Useful when using other Discord bridges. Otherwise, not ideal, as sims will clutter the Matrix user list and will never be cleaned up. - - if space_id - h2.mt48.fs-headline1 Channel setup - - h3.mt32.fs-category Linked channels - .s-card.bs-sm.p0 - form.s-table-container(method="post" action=rel("/api/unlink")) - input(type="hidden" name="guild_id" value=guild_id) - table.s-table.s-table__bx-simple - each row in linkedChannelsWithDetails - tr - td.w40: +discord(row.channel) - td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" cx-prevent-default hx-post=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources." value=row.channel.id hx-indicator="this" hx-disabled-elt="this")!= icons.Icons.IconLinkSm - td: +matrix(row) - else - tr - td(colspan="3") - .s-empty-state No channels linked between Discord and Matrix yet... - - h3.fs-category.mt32 Auto-create - .s-card.d-grid.px0 - form.d-flex.ai-center.g16 - #autocreate-loading.p8 - - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#autocreate(name="autocreate" type="checkbox" hx-post=rel("/api/autocreate") hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label.fl-grow1(for="autocreate") - | Create new Matrix rooms automatically - p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in. - - if space_id - h3.mt32.fs-category Manually link channels - form.d-flex.g16.ai-start(hx-post=rel("/api/link") hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button") - .fl-grow2.s-btn-group.fd-column.w40 - each channel in unlinkedChannels - input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id) - label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) - +discord(channel, true, "Announcement") - else - .s-empty-state.p8 All Discord channels are linked. - .fl-grow1.s-btn-group.fd-column.w30 - each room in unlinkedRooms - input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) - label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) - +matrix(room, true) - else - .s-empty-state.p8 All Matrix rooms are linked. - input(type="hidden" name="guild_id" value=guild_id) - div - button.s-btn.s-btn__icon.s-btn__filled#link-button - != icons.Icons.IconMerge - = ` Link` - - h3.mt32.fs-category Unlink server - form.s-card.d-flex.gx16.ai-center(method="post" action=rel("/api/unlink-space")) - input(type="hidden" name="guild_id" value=guild.id) - .fl-grow1.s-prose.s-prose__sm.lh-lg - p.fc-medium. - Not using this bridge, or just made a mistake? You can unlink the whole server and all its channels.#[br] - This may take a minute to process. Please be patient and wait until the page refreshes. - div - button.s-btn.s-btn__icon.s-btn__danger.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server and all its channels?\nIt may take a minute to clean up Matrix resources." hx-indicator="this" hx-disabled-elt="this") - != icons.Icons.IconUnsync - span.ml4= ` Unlink` - - if space_id - details.mt48 - summary Debug room list - .d-grid.grid__2.gx24 - div - h3.mt24 Channels - p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked. - div - h3.mt24 Rooms - p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked. - div - h3.mt24 Unavailable channels: Deleted from Discord - .s-card.p0 - ul.my8.ml24 - each row in removedUncachedChannels - li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.nick || row.name - h3.mt24 Unavailable channels: Wrong type - .s-card.p0 - ul.my8.ml24 - each row in removedWrongTypeChannels - li - a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} - span | - a(href=rel(`/explain?type=${row.type}`)) Why? - h3.mt24 Unavailable channels: Discord bot can't access - .s-card.p0 - ul.my8.ml24 - each row in removedPrivateChannels - li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.name - div- // Rooms - h3.mt24 Unavailable rooms: Already linked - .s-card.p0 - 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 - // each row in removedWrongTypeRooms - // li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} - //h3.mt24 Unavailable rooms: Archived thread - //.s-card.p0 - // ul.my8.ml24 - // each row in removedArchivedThreadRooms - // li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name +extends includes/template.pug +include includes/default-roles-list.pug + +mixin badge-readonly + .s-badge.s-badge__xs.s-badge__icon.s-badge__muted + != icons.Icons.IconEyeSm + | Read-only + +mixin badge-private + .s-badge.s-badge__xs.s-badge__icon.s-badge__warning + != icons.Icons.IconLockSm + | Private + +mixin discord(channel, radio=false) + //- Previously, we passed guild.roles as the second parameter, but this doesn't quite match Discord's behaviour. See issue #42 for why this was changed. + //- Basically we just want to assign badges based on the channel overwrites, without considering the guild's base permissions. /shrug + - let permissions = dUtils.getPermissions(guild_id, [], [{id: guild_id, name: "@everyone", permissions: 1<<10 | 1<<11}], null, channel.permission_overwrites) + .s-user-card.s-user-card__small + if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) + != icons.Icons.IconLock + else if channel.type === 5 + != icons.Icons.IconBullhorn + else if channel.type === 2 + != icons.Icons.IconPhone + else if channel.type === 11 || channel.type === 12 + != icons.Icons.IconCollection + else + include includes/hash.svg + .s-user-card--info.ws-nowrap + if radio + = channel.name + else + .s-user-card--link.fs-body1 + a(href=`https://discord.com/channels/${channel.guild_id}/${channel.id}`)= channel.name + if channel.parent_id + .s-user-card--location= discord.channels.get(channel.parent_id).name + if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) + +badge-private + else if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages) + +badge-readonly + +mixin matrix(row, radio=false, badge="") + .s-user-card.s-user-card__small + != icons.Icons.IconMessage + .s-user-card--info.ws-nowrap + if radio + = row.nick || row.name + else + .s-user-card--link.fs-body1 + a(href=`https://matrix.to/#/${row.room_id}`)= row.nick || row.name + if row.join_rule === "invite" + +badge-private + +block body + .s-page-title.mb24 + h1.s-page-title--header= guild.name + + .d-flex.g16(class="sm:fw-wrap") + .fl-grow1 + h2.fs-headline1 Invite a Matrix user + + form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-trigger="submit" hx-swap="none" hx-on::after-request="if (event.detail.successful) this.reset()" hx-disabled-elt="input, button" hx-indicator="#invite-button") + label.s-label(for="mxid") Matrix ID + input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)") + label.s-label(for="permissions") Permissions + .s-select + select#permissions(name="permissions") + option(value="default") Default + option(value="moderator") Moderator + option(value="admin") Admin + input(type="hidden" name="guild_id" value=guild_id) + .grid--row-start2 + button.s-btn.s-btn__filled#invite-button Invite + div + .s-card.d-flex.ai-center.jc-center(style="min-width: 132px; min-height: 132px;") + button.s-btn(class=space_id ? "s-btn__muted" : "s-btn__filled" hx-get=rel(`/qr?guild_id=${guild_id}`) hx-indicator="closest button" hx-swap="outerHTML" hx-disabled-elt="this") Show QR + + if space_id + h2.mt48.fs-headline1 Server settings + 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") + input(type="hidden" name="guild_id" value=guild_id) + + .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") + input(type="radio" name="privacy_level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) + label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") + != icons.Icons.IconPlusSm + != icons.Icons.IconInternationalSm + .fl-grow1 Directory + + input(type="radio" name="privacy_level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) + label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") + != icons.Icons.IconPlusSm + != icons.Icons.IconLinkSm + .fl-grow1 Link + + input(type="radio" name="privacy_level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) + label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") + svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") + != icons.Icons.IconLockSm + .fl-grow1 Invite + + p.s-description.m0 In-app direct invite from another user + 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-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 + + +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 + #url-preview-loading.p8 + - let value = !!select("guild_space", "url_preview", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#url-preview(name="url_preview" type="checkbox" hx-post=rel("/api/url-preview") hx-indicator="#url-preview-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label.fl-grow1(for="url-preview") + | Show Discord's URL previews on Matrix + p.s-description Shows info about links posted to chat. Discord's previews are generally better quality than Synapse's, especially for social media and videos. + + form.d-flex.ai-center.g16 + #presence-loading.p8 + - value = !!select("guild_space", "presence", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#presence(name="presence" type="checkbox" hx-post=rel("/api/presence") hx-indicator="#presence-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label(for="presence") + | Show online statuses on Matrix + p.s-description This might cause lag on really big Discord servers. + + form.d-flex.ai-center.g16 + #webhook-profile-loading.p8 + - value = !!select("guild_space", "webhook_profile", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#webhook-profile(name="webhook_profile" type="checkbox" hx-post=rel("/api/webhook-profile") hx-indicator="#webhook-profile-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label(for="webhook-profile") + | Create persistent Matrix sims for webhooks + p.s-description Useful when using other Discord bridges. Otherwise, not ideal, as sims will clutter the Matrix user list and will never be cleaned up. + + if space_id + h2.mt48.fs-headline1 Channel setup + + h3.mt32.fs-category Linked channels + .s-card.bs-sm.p0 + form.s-table-container(method="post" action=rel("/api/unlink")) + input(type="hidden" name="guild_id" value=guild_id) + table.s-table.s-table__bx-simple + each row in linkedChannelsWithDetails + tr + td.w40: +discord(row.channel) + td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" cx-prevent-default hx-post=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources." value=row.channel.id hx-indicator="this" hx-disabled-elt="this")!= icons.Icons.IconLinkSm + td: +matrix(row) + else + tr + td(colspan="3") + .s-empty-state No channels linked between Discord and Matrix yet... + + h3.fs-category.mt32 Auto-create + .s-card.d-grid.px0 + form.d-flex.ai-center.g16 + #autocreate-loading.p8 + - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#autocreate(name="autocreate" type="checkbox" hx-post=rel("/api/autocreate") hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label.fl-grow1(for="autocreate") + | Create new Matrix rooms automatically + p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in. + + if space_id + h3.mt32.fs-category Manually link channels + form.d-flex.g16.ai-start(hx-post=rel("/api/link") hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button") + .fl-grow2.s-btn-group.fd-column.w40 + each channel in unlinkedChannels + input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id) + label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) + +discord(channel, true, "Announcement") + else + .s-empty-state.p8 All Discord channels are linked. + .fl-grow1.s-btn-group.fd-column.w30 + each room in unlinkedRooms + input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) + label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) + +matrix(room, true) + else + .s-empty-state.p8 All Matrix rooms are linked. + input(type="hidden" name="guild_id" value=guild_id) + div + button.s-btn.s-btn__icon.s-btn__filled#link-button + != icons.Icons.IconMerge + = ` Link` + + h3.mt32.fs-category Unlink server + form.s-card.d-flex.gx16.ai-center(method="post" action=rel("/api/unlink-space")) + input(type="hidden" name="guild_id" value=guild.id) + .fl-grow1.s-prose.s-prose__sm.lh-lg + p.fc-medium. + Not using this bridge, or just made a mistake? You can unlink the whole server and all its channels.#[br] + This may take a minute to process. Please be patient and wait until the page refreshes. + div + button.s-btn.s-btn__icon.s-btn__danger.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server and all its channels?\nIt may take a minute to clean up Matrix resources." hx-indicator="this" hx-disabled-elt="this") + != icons.Icons.IconUnsync + span.ml4= ` Unlink` + + if space_id + details.mt48 + summary Debug room list + .d-grid.grid__2.gx24 + div + h3.mt24 Channels + p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked. + div + h3.mt24 Rooms + p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked. + div + h3.mt24 Unavailable channels: Deleted from Discord + .s-card.p0 + ul.my8.ml24 + each row in removedUncachedChannels + li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.nick || row.name + h3.mt24 Unavailable channels: Wrong type + .s-card.p0 + ul.my8.ml24 + each row in removedWrongTypeChannels + li + a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} + span | + a(href=rel(`/explain?type=${row.type}`)) Why? + h3.mt24 Unavailable channels: Discord bot can't access + .s-card.p0 + ul.my8.ml24 + each row in removedPrivateChannels + li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.name + div- // Rooms + h3.mt24 Unavailable rooms: Already linked + .s-card.p0 + 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: Archived thread + p If you still want to link with any of these rooms (eg. you accidentally unlinked it and want to bring it back, or you're migrating from a different bridge that happens to use OOYE's prefixes), please remove the [⛓️] or [🔒⛓️] prefix in Matrix's room settings and refresh the page. + .s-card.p0 + ul.my8.ml24 + each row in removedArchivedThreadRooms + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index adf85d9c..3cd003e7 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -68,7 +68,7 @@ const linkRules = new Map([ [3, {type: "TYPING", humanName:"Group DMs", unsupported: "OOYE won't support DMs until a good way of doing it can be figured out. Please see https://gitdab.com/cadence/out-of-your-element#caveats for more."}], [4, {type: "SPACE", humanName:"Categories", unsupported: "There is no concept of categories on Matrix."}], //...at least officially. In practice, some clients will render sub-spaces as categories. TODO: Bridge categories to sub-spaces. [5, {type: "TYPING", humanName:"Announcement text channels"}], - [10, {type: "TYPING", humanName:"Announcement threads"}], //Currently testing: Letting users bridge threads however they like. In case this doesn't work: , unsupported: "Threads must be bridged automatically, to ensure proper lifecycle management (so that archiving threads won't break them). Please send a message in this thread to bridge it automatically." + [10, {type: "TYPING", humanName:"Announcement threads"}], [11, {type: "TYPING", humanName:"Normal threads"}], [12, {type: "TYPING", humanName:"Private threads"}], [13, {type: "TYPING", humanName:"Stage VCs"}], @@ -153,7 +153,7 @@ function getChannelRoomsLinks(guild, rooms, roles) { 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(/^\[(🔒)?⛓️\]/)) COMMENTED OUT - Currently testing: Letting users bridge threads however they like. + let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) return { linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, From c8bf730ec4a63d022c1fc9a472393a7152d70ab0 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 13:07:39 +0000 Subject: [PATCH 110/137] Filter out the root space --- src/web/pug/guild.pug | 5 +++++ src/web/routes/guild.js | 11 ++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 7314cea8..a65efade 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -257,6 +257,11 @@ block body ul.my8.ml24 each row in removedEncryptedRooms li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + h3.mt24 Unavailable rooms: Root space + .s-card.p0 + ul.my8.ml24 + each row in removedRootSpaceRooms + li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} h3.mt24 Unavailable rooms: Archived thread p If you still want to link with any of these rooms (eg. you accidentally unlinked it and want to bring it back, or you're migrating from a different bridge that happens to use OOYE's prefixes), please remove the [⛓️] or [🔒⛓️] prefix in Matrix's room settings and refresh the page. .s-card.p0 diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 3cd003e7..c4fd13cf 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -118,8 +118,9 @@ function getPosition(channel, channels) { * @param {DiscordTypes.APIGuild} guild * @param {Ty.R.Hierarchy[]} rooms * @param {string[]} roles + * @param {string?} space */ -function getChannelRoomsLinks(guild, rooms, roles) { +function getChannelRoomsLinks(guild, rooms, roles, space) { let channelIDs = discord.guildChannelMap.get(guild.id) assert(channelIDs) @@ -149,7 +150,7 @@ function getChannelRoomsLinks(guild, rooms, roles) { let linkedRoomIDs = linkedChannels.map(c => c.room_id) let unlinkedRooms = [...rooms] let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) - let removedWrongTypeRooms = [] + let removedRootSpaceRooms = dUtils.filterTo(unlinkedRooms, r => r.room_id !== space) 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 @@ -157,7 +158,7 @@ function getChannelRoomsLinks(guild, rooms, roles) { return { linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, - removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms, removedEncryptedRooms + removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedRootSpaceRooms, removedArchivedThreadRooms, removedEncryptedRooms } } @@ -198,14 +199,14 @@ as.router.get("/guild", defineEventHandler(async event => { // Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space if (!row.space_id) { - const links = getChannelRoomsLinks(guild, [], roles) + const links = getChannelRoomsLinks(guild, [], roles, row.space_id) return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) } // Linked guild const api = getAPI(event) const rooms = await api.getFullHierarchy(row.space_id) - const links = getChannelRoomsLinks(guild, rooms, roles) + const links = getChannelRoomsLinks(guild, rooms, roles, row.space_id) return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) })) From 32ba4d838563e5c5d2f788c19ac1429589af8229 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 13:31:08 +0000 Subject: [PATCH 111/137] Finishing touches..... --- src/d2m/actions/create-room.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 2a76f564..f78ab27d 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -55,7 +55,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 ? "[🔒⛓️] " @@ -64,13 +64,13 @@ function convertNameAndTopic(channel, guild, customName) { const chosenName = customName || (channelPrefix + channel.name); const maybeTopicWithPipe = channel.topic ? ` | ${channel.topic}` : ''; const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : ''; + const maybeWithin = parentChannel ? `Within: ${parentChannel.name} (ID: ${parentChannel.id})\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${maybeThreadWithinPart}${guildIDPart}` - : `${maybeTopicWithNewlines}${channelIDPart}\n${maybeThreadWithinPart}${guildIDPart}`; + ? `#${channel.name}${maybeTopicWithPipe}\n\n${maybeWithin}${channelIDPart}\n${guildIDPart}` + : `${maybeTopicWithNewlines}${maybeWithin}${channelIDPart}\n${guildIDPart}`; return [chosenName, convertedTopic]; } From ea94dfe6b9cc48961ef71dab48bfdce031f968c6 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 14:12:13 +0000 Subject: [PATCH 112/137] Fighting the demons of my past TECHNICALLY, I was asked to remove it. But now that I know that @template is a thing that exists in JSDoc (which was the one missing ingredient the last time), I think I can get this to be good enough that removal won't even be necessary, and now it just looks like regular, sane documentation (not the any-schizopost like last time). --- src/m2d/event-dispatcher.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 0849a93d..f7191bec 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -158,13 +158,17 @@ async function sendError(roomID, source, type, e, payload) { } /** - * @param {string} type - * @param {(event: Ty.Event.Outer & {type: any, redacts:any, state_key:any}, ...args: any)=>any} fn + * Wraps the function with an automated error catching and reporting mechanism + * @template {Ty.Event.Outer} EVENT The event that the wrapped function processes, its first argument. + * @template {[]} ARGS Other arguments of the wrapped function + * @template RETURNS The output of the wrapped function + * @param {string} type Type of the event, during the processing of which the error may occur. + * @param {(event: EVENT, ...args: ARGS)=>RETURNS|Promise} fn Function to wrap + * @returns {(event: EVENT, ...args: ARGS)=>Promise} Wrapped function */ function guard(type, fn) { - return async function(/** @type {Ty.Event.Outer} */ event, /** @type {any} */ ...args) { + return async function(event, ...args) { try { - // @ts-ignore return await fn(event, ...args) } catch (e) { await sendError(event.room_id, "Matrix", type, e, event) From f9fd9bd513bc23ea508f875a8a6c13e783846949 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 15:14:05 +0000 Subject: [PATCH 113/137] final text changes, READY TO MERGE!!! --- src/web/routes/guild.js | 2 +- src/web/routes/link.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index c4fd13cf..0cfec6df 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -213,7 +213,7 @@ as.router.get("/guild", defineEventHandler(async event => { as.router.get("/explain", defineEventHandler(async event => { const {type} = await getValidatedQuery(event, schema.explain.parse) const rule = linkRules.get(Number.parseInt(type)) - if (!rule) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge to type-" + type + " channels because OOYE doesn't even know what they are yet."}) + if (!rule) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge to type-" + type + " channels because OOYE doesn't even know what they are."}) else if (rule.unsupported) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge to " + rule.humanName + " (type-" + type + " channels) because: " + rule.unsupported}) else return pugSync.render(event, "explain.pug", {msg: "You can bridge to " + rule.humanName + " (type-" + type + " channels) just fine. Why are you even here?"}) })) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 1b8d7734..64afcc1d 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -199,7 +199,7 @@ as.router.post("/api/link", defineEventHandler(async event => { // Ensure link rules are upheld const rule = guildRoute.linkRules.get(channel.type) - if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge to " + (rule ? (rule.humanName+"(type-"+channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) + if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge to " + (rule ? (rule.humanName+" (type-"+channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are.")}) else if (foundSpace && rule.type === "TYPING") throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space when bridging to "+rule.humanName}) else if (!foundSpace && rule.type === "SPACE") throw createError({status: 400, message: "Bad Request", data: "Matrix room must be of type m.space when bridging to "+rule.humanName}) From 682d32bf4bcbd1d8b5e13d674d06353125859d3c Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 12:21:03 +0200 Subject: [PATCH 114/137] Btw, I think you forgot to update this, when you bumped the limit. xD --- src/d2m/event-dispatcher.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index d407991b..eeb8a72a 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -69,7 +69,7 @@ module.exports = { }, /** - * When logging back in, check if we missed any conversations in any channels. Bridge up to 49 missed messages per channel. + * When logging back in, check if we missed any conversations in any channels. Bridge up to 99 missed messages per channel. * If more messages were missed, only the latest missed message will be posted. TODO: Consider bridging more, or post a warning when skipping history? * This can ONLY detect new messages, not any other kind of event. Any missed edits, deletes, reactions, etc will not be bridged. * @param {import("./discord-client")} client From 2c1c95241307cc2bfb2c188b052b955faacd37b7 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 19:36:15 +0200 Subject: [PATCH 115/137] Possibly fixed the Git-thinks-I-redone-the-whole-Pug-file-from-scratch bug FUCK Windows; Screw it's stupid line endings --- 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 109ac9e0..db32e4c0 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -350,4 +350,4 @@ block body .s-card.p0 ul.my8.ml24 each row in removedArchivedThreadRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name \ No newline at end of file From b6017780cb88bf5fcf20bf187b16fc9d53acaf14 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 19:47:01 +0200 Subject: [PATCH 116/137] I am about to go insane. --- 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 db32e4c0..109ac9e0 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -350,4 +350,4 @@ block body .s-card.p0 ul.my8.ml24 each row in removedArchivedThreadRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name \ No newline at end of file + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name From d982a7d7d39d6fbc4f33a9e0c45a35659a6836ba Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 20:14:34 +0200 Subject: [PATCH 117/137] Let's just remove the check and see if it fixes stuff --- 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 109ac9e0..8563f72d 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -188,7 +188,7 @@ block body td(colspan="3") .s-empty-state No channels linked between Discord and Matrix yet... - if space_id && linkedChannelsWithDetails.length + if space_id //&& linkedChannelsWithDetails.length - const customMentionRooms = linkedChannelsWithDetails.filter(row => row.room_mention_role) - const defaultMentionRooms = linkedChannelsWithDetails.filter(row => !row.room_mention_role) - const mentionRoles = [...guild.roles].filter(role => role.id !== guild_id).sort((a, b) => b.position - a.position) From d5dd1d874e717a639d0e4cae1cb37cb4db7a85c9 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 20:21:04 +0200 Subject: [PATCH 118/137] =?UTF-8?q?It=20did=20not,=20in=20fact,=20"fix=20s?= =?UTF-8?q?tuff"=20=F0=9F=92=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 8563f72d..109ac9e0 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -188,7 +188,7 @@ block body td(colspan="3") .s-empty-state No channels linked between Discord and Matrix yet... - if space_id //&& linkedChannelsWithDetails.length + if space_id && linkedChannelsWithDetails.length - const customMentionRooms = linkedChannelsWithDetails.filter(row => row.room_mention_role) - const defaultMentionRooms = linkedChannelsWithDetails.filter(row => !row.room_mention_role) - const mentionRoles = [...guild.roles].filter(role => role.id !== guild_id).sort((a, b) => b.position - a.position) From c09c746628b53a6d593d23543ee77502c01c9d12 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 20:30:52 +0200 Subject: [PATCH 119/137] cries --- src/web/pug/guild.pug | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 109ac9e0..8653f93b 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -188,7 +188,9 @@ block body td(colspan="3") .s-empty-state No channels linked between Discord and Matrix yet... + p Debug1 if space_id && linkedChannelsWithDetails.length + p Debug2 - const customMentionRooms = linkedChannelsWithDetails.filter(row => row.room_mention_role) - const defaultMentionRooms = linkedChannelsWithDetails.filter(row => !row.room_mention_role) - const mentionRoles = [...guild.roles].filter(role => role.id !== guild_id).sort((a, b) => b.position - a.position) @@ -253,6 +255,7 @@ block body button.s-btn.s-btn__icon.s-btn__filled.d-inline-flex.ai-center.g4.py0.box-border(type="submit" style="min-height: 0") != icons.Icons.IconPlusSm span Add + p Debug3 h3.fs-category.mt32 Auto-create .s-card.d-grid.px0 From db874a1aa9e334834664477e879375c6c75d7b2f Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 20:34:06 +0200 Subject: [PATCH 120/137] WHY CAN'T A BASIC P-TAG RENDER, HUH???? --- src/web/pug/guild.pug | 3 +- src/web/routes/guild-nova.js | 269 +++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 src/web/routes/guild-nova.js diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 8653f93b..16a7a76a 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -257,7 +257,8 @@ block body span Add p Debug3 - h3.fs-category.mt32 Auto-create + h3.fs-category.mt32 Auto-create REAL QUESTION, WTF???? + p Debug4 .s-card.d-grid.px0 form.d-flex.ai-center.g16 #autocreate-loading.p8 diff --git a/src/web/routes/guild-nova.js b/src/web/routes/guild-nova.js new file mode 100644 index 00000000..ef2065e3 --- /dev/null +++ b/src/web/routes/guild-nova.js @@ -0,0 +1,269 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const assert = require("assert/strict") +const {z} = require("zod") +const {H3Event, defineEventHandler, sendRedirect, createError, getValidatedQuery, readValidatedBody, setResponseHeader} = require("h3") +const {randomUUID} = require("crypto") +const {LRUCache} = require("lru-cache") +const Ty = require("../../types") +const uqr = require("uqr") + +const {id: botID} = require("../../../addbot") +const {discord, as, sync, select, from, db} = require("../../passthrough") +/** @type {import("../pug-sync")} */ +const pugSync = sync.require("../pug-sync") +/** @type {import("../../d2m/actions/create-space")} */ +const createSpace = sync.require("../../d2m/actions/create-space") +/** @type {import("../auth")} */ +const auth = require("../auth") +/** @type {import("../../discord/utils")} */ +const dUtils = sync.require("../../discord/utils") +/** @type {import("../../matrix/utils")} */ +const mxUtils = sync.require("../../matrix/utils") +const {reg} = require("../../matrix/read-registration") + +const schema = { + guild: z.object({ + guild_id: z.string().optional() + }), + qr: z.object({ + guild_id: z.string().optional() + }), + invite: z.object({ + mxid: z.string().regex(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/), + permissions: z.enum(["default", "moderator", "admin"]), + guild_id: z.string().optional(), + nonce: z.string().optional() + }), + inviteNonce: z.object({ + nonce: z.string() + }) +} + +/** + * @param {H3Event} event + * @returns {import("../../matrix/api")} + */ +function getAPI(event) { + /* c8 ignore next */ + return event.context.api || sync.require("../../matrix/api") +} + +/** @type {LRUCache} nonce to guild id */ +const validNonce = new LRUCache({max: 200}) + +/** + * @param {{type: number, parent_id?: string | null, position?: number}} channel + * @param {Map} channels + */ +function getPosition(channel, channels) { + let position = 0 + + // Categories always appear below un-categorised channels. Their contents can be ordered. + // So categories, and things in them, will have their position multiplied by a big number. The category's big number. The regular position small number sorts within the category. + // Categories are size 2000. + let foundCategory = channel + while (foundCategory.parent_id) { + const f = channels.get(foundCategory.parent_id) + assert(f) + foundCategory = f + } + if (foundCategory.type === DiscordTypes.ChannelType.GuildCategory) position = ((foundCategory.position || 0) + 1) * 2000 + + // Categories always appear above what they contain. + if (channel.type === DiscordTypes.ChannelType.GuildCategory) position -= 0.5 + + // Within a category, voice channels are always sorted to the bottom. The text/voice split is size 1000 each. + if ([DiscordTypes.ChannelType.GuildVoice, DiscordTypes.ChannelType.GuildStageVoice].includes(channel.type)) position += 1000 + + // Channels are manually ordered within the text/voice split. + if (typeof channel.position === "number") position += channel.position + + // Threads appear below their channel. + if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { + position += 0.5 + let parent = channels.get(channel.parent_id || "") + if (parent && parent["position"]) position += parent["position"] + } + + return position +} + +/** + * @param {DiscordTypes.APIGuild} guild + * @param {Ty.R.Hierarchy[]} rooms + * @param {string[]} roles + */ +function getChannelRoomsLinks(guild, rooms, roles) { + let channelIDs = discord.guildChannelMap.get(guild.id) + assert(channelIDs) + + let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick", "room_mention_role"], {channel_id: channelIDs}).all() + let linkedChannelsWithDetails = linkedChannels.map(c => ({ + // @ts-ignore + /** @type {DiscordTypes.APIGuildChannel} */ channel: discord.channels.get(c.channel_id), + ...c + })) + let removedUncachedChannels = dUtils.filterTo(linkedChannelsWithDetails, c => c.channel) + let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id) + linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel, discord.channels) - getPosition(b.channel, discord.channels)) + + let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c)) + /** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore + let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)) + let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type)) + let removedPrivateChannels = dUtils.filterTo(unlinkedChannels, c => { + const permissions = dUtils.getPermissions(guild.id, roles, guild.roles, botID, c["permission_overwrites"]) + return dUtils.hasSomePermissions(permissions, ["Administrator", "ViewChannel"]) + }) + unlinkedChannels.sort((a, b) => getPosition(a, discord.channels) - getPosition(b, discord.channels)) + + let linkedRoomIDs = linkedChannels.map(c => c.room_id) + 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, removedEncryptedRooms + } +} + +/** + * @param {string} mxid + */ +function getInviteTargetSpaces(mxid) { + /** @type {{room_id: string, mxid: string, type: string, name: string, topic: string?, avatar: string?}[]} */ + const spaces = + // invited spaces + db.prepare("SELECT room_id, invite.mxid, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL AND type = 'm.space'").all(mxid) + // moderated spaces + .concat(db.prepare("SELECT room_id, invite.mxid, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id INNER JOIN member_cache USING (room_id) WHERE member_cache.mxid = ? AND power_level >= 50 AND space_id IS NULL AND type = 'm.space'").all(mxid)) + const seen = new Set(spaces.map(s => s.room_id)) + return spaces.filter(s => seen.delete(s.room_id)) +} + +as.router.get("/guild", defineEventHandler(async event => { + const {guild_id} = await getValidatedQuery(event, schema.guild.parse) + const session = await auth.useSession(event) + const managed = await auth.getManagedGuilds(event) + const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() + // @ts-ignore + const guild = discord.guilds.get(guild_id) + + // Permission problems + if (!guild_id || !guild || !managed.has(guild_id) || !row) { + return pugSync.render(event, "guild-access-denied.pug", {guild_id, row}) + } + + // Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow + if (!row.space_id && row.autocreate === 0) { + const spaces = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : [] + return pugSync.render(event, "guild-not-linked.pug", {guild, guild_id, spaces}) + } + + const roles = guild.members?.find(m => m.user.id === botID)?.roles || [] + + // Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space + if (!row.space_id) { + const links = getChannelRoomsLinks(guild, [], roles) + return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) + } + + // Linked guild + const api = getAPI(event) + const rooms = await api.getFullHierarchy(row.space_id) + const links = getChannelRoomsLinks(guild, rooms, roles) + return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) +})) + +as.router.get("/qr", defineEventHandler(async event => { + const {guild_id} = await getValidatedQuery(event, schema.qr.parse) + const managed = await auth.getManagedGuilds(event) + const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() + // @ts-ignore + const guild = discord.guilds.get(guild_id) + + // Permission problems + if (!guild_id || !guild || !managed.has(guild_id) || !row) { + return pugSync.render(event, "guild-access-denied.pug", {guild_id, row}) + } + + const nonce = randomUUID() + validNonce.set(nonce, guild_id) + + const data = `${reg.ooye.bridge_origin}/invite?nonce=${nonce}` + // necessary to scale the svg pixel-perfectly on the page + // https://github.com/unjs/uqr/blob/244952a8ba2d417f938071b61e11fb1ff95d6e75/src/svg.ts#L24 + const generatedSvg = uqr.renderSVG(data, {pixelSize: 3}) + const svg = generatedSvg.replace(/viewBox="0 0 ([0-9]+) ([0-9]+)"/, `data-nonce="${nonce}" width="$1" height="$2" $&`) + assert.notEqual(svg, generatedSvg) + + return svg +})) + +as.router.get("/invite", defineEventHandler(async event => { + const {nonce} = await getValidatedQuery(event, schema.inviteNonce.parse) + const isValid = validNonce.has(nonce) + const guild_id = validNonce.get(nonce) + const guild = discord.guilds.get(guild_id || "") + return pugSync.render(event, "invite.pug", {isValid, nonce, guild_id, guild}) +})) + +as.router.post("/api/invite", defineEventHandler(async event => { + const parsedBody = await readValidatedBody(event, schema.invite.parse) + const managed = await auth.getManagedGuilds(event) + const api = getAPI(event) + + // Check guild ID or nonce + if (parsedBody.guild_id) { + var guild_id = parsedBody.guild_id + if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't invite users to a guild you don't have Manage Server permissions in"}) + } else if (parsedBody.nonce) { + if (!validNonce.has(parsedBody.nonce)) throw createError({status: 403, message: "Nonce expired", data: "Nonce means number-used-once, and, well, you tried to use it twice..."}) + let ok = validNonce.get(parsedBody.nonce) + assert(ok) + var guild_id = ok + validNonce.delete(parsedBody.nonce) + } else { + throw createError({status: 400, message: "Missing guild ID", data: "Passing a guild ID or a nonce is required."}) + } + + // Check guild is bridged + const guild = discord.guilds.get(guild_id) + assert(guild) + const spaceID = await createSpace.ensureSpace(guild) + + // Check for existing invite to the space + let spaceMember + try { + spaceMember = await api.getStateEvent(spaceID, "m.room.member", parsedBody.mxid) + } catch (e) {} + + if (!spaceMember || !["invite", "join"].includes(spaceMember.membership)) { + // Invite + await api.inviteToRoom(spaceID, parsedBody.mxid) + } + + // Permissions + const powerLevel = + ( parsedBody.permissions === "admin" ? 100 + : parsedBody.permissions === "moderator" ? 50 + : 0) + if (powerLevel) await mxUtils.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel, api) + + if (parsedBody.guild_id) { + setResponseHeader(event, "HX-Refresh", true) + return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302) + } else { + return sendRedirect(event, "/ok?msg=User has been invited.", 302) + } +})) + +module.exports._getPosition = getPosition +module.exports.getInviteTargetSpaces = getInviteTargetSpaces From 0425c18cf3b971b48a25cc66e40b15d788e47fc2 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 20:35:17 +0200 Subject: [PATCH 121/137] sh!t, that wasn't supposed to make it there... --- src/web/routes/guild-nova.js | 269 ----------------------------------- 1 file changed, 269 deletions(-) delete mode 100644 src/web/routes/guild-nova.js diff --git a/src/web/routes/guild-nova.js b/src/web/routes/guild-nova.js deleted file mode 100644 index ef2065e3..00000000 --- a/src/web/routes/guild-nova.js +++ /dev/null @@ -1,269 +0,0 @@ -// @ts-check - -const DiscordTypes = require("discord-api-types/v10") -const assert = require("assert/strict") -const {z} = require("zod") -const {H3Event, defineEventHandler, sendRedirect, createError, getValidatedQuery, readValidatedBody, setResponseHeader} = require("h3") -const {randomUUID} = require("crypto") -const {LRUCache} = require("lru-cache") -const Ty = require("../../types") -const uqr = require("uqr") - -const {id: botID} = require("../../../addbot") -const {discord, as, sync, select, from, db} = require("../../passthrough") -/** @type {import("../pug-sync")} */ -const pugSync = sync.require("../pug-sync") -/** @type {import("../../d2m/actions/create-space")} */ -const createSpace = sync.require("../../d2m/actions/create-space") -/** @type {import("../auth")} */ -const auth = require("../auth") -/** @type {import("../../discord/utils")} */ -const dUtils = sync.require("../../discord/utils") -/** @type {import("../../matrix/utils")} */ -const mxUtils = sync.require("../../matrix/utils") -const {reg} = require("../../matrix/read-registration") - -const schema = { - guild: z.object({ - guild_id: z.string().optional() - }), - qr: z.object({ - guild_id: z.string().optional() - }), - invite: z.object({ - mxid: z.string().regex(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/), - permissions: z.enum(["default", "moderator", "admin"]), - guild_id: z.string().optional(), - nonce: z.string().optional() - }), - inviteNonce: z.object({ - nonce: z.string() - }) -} - -/** - * @param {H3Event} event - * @returns {import("../../matrix/api")} - */ -function getAPI(event) { - /* c8 ignore next */ - return event.context.api || sync.require("../../matrix/api") -} - -/** @type {LRUCache} nonce to guild id */ -const validNonce = new LRUCache({max: 200}) - -/** - * @param {{type: number, parent_id?: string | null, position?: number}} channel - * @param {Map} channels - */ -function getPosition(channel, channels) { - let position = 0 - - // Categories always appear below un-categorised channels. Their contents can be ordered. - // So categories, and things in them, will have their position multiplied by a big number. The category's big number. The regular position small number sorts within the category. - // Categories are size 2000. - let foundCategory = channel - while (foundCategory.parent_id) { - const f = channels.get(foundCategory.parent_id) - assert(f) - foundCategory = f - } - if (foundCategory.type === DiscordTypes.ChannelType.GuildCategory) position = ((foundCategory.position || 0) + 1) * 2000 - - // Categories always appear above what they contain. - if (channel.type === DiscordTypes.ChannelType.GuildCategory) position -= 0.5 - - // Within a category, voice channels are always sorted to the bottom. The text/voice split is size 1000 each. - if ([DiscordTypes.ChannelType.GuildVoice, DiscordTypes.ChannelType.GuildStageVoice].includes(channel.type)) position += 1000 - - // Channels are manually ordered within the text/voice split. - if (typeof channel.position === "number") position += channel.position - - // Threads appear below their channel. - if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { - position += 0.5 - let parent = channels.get(channel.parent_id || "") - if (parent && parent["position"]) position += parent["position"] - } - - return position -} - -/** - * @param {DiscordTypes.APIGuild} guild - * @param {Ty.R.Hierarchy[]} rooms - * @param {string[]} roles - */ -function getChannelRoomsLinks(guild, rooms, roles) { - let channelIDs = discord.guildChannelMap.get(guild.id) - assert(channelIDs) - - let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick", "room_mention_role"], {channel_id: channelIDs}).all() - let linkedChannelsWithDetails = linkedChannels.map(c => ({ - // @ts-ignore - /** @type {DiscordTypes.APIGuildChannel} */ channel: discord.channels.get(c.channel_id), - ...c - })) - let removedUncachedChannels = dUtils.filterTo(linkedChannelsWithDetails, c => c.channel) - let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id) - linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel, discord.channels) - getPosition(b.channel, discord.channels)) - - let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c)) - /** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore - let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)) - let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type)) - let removedPrivateChannels = dUtils.filterTo(unlinkedChannels, c => { - const permissions = dUtils.getPermissions(guild.id, roles, guild.roles, botID, c["permission_overwrites"]) - return dUtils.hasSomePermissions(permissions, ["Administrator", "ViewChannel"]) - }) - unlinkedChannels.sort((a, b) => getPosition(a, discord.channels) - getPosition(b, discord.channels)) - - let linkedRoomIDs = linkedChannels.map(c => c.room_id) - 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, removedEncryptedRooms - } -} - -/** - * @param {string} mxid - */ -function getInviteTargetSpaces(mxid) { - /** @type {{room_id: string, mxid: string, type: string, name: string, topic: string?, avatar: string?}[]} */ - const spaces = - // invited spaces - db.prepare("SELECT room_id, invite.mxid, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL AND type = 'm.space'").all(mxid) - // moderated spaces - .concat(db.prepare("SELECT room_id, invite.mxid, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id INNER JOIN member_cache USING (room_id) WHERE member_cache.mxid = ? AND power_level >= 50 AND space_id IS NULL AND type = 'm.space'").all(mxid)) - const seen = new Set(spaces.map(s => s.room_id)) - return spaces.filter(s => seen.delete(s.room_id)) -} - -as.router.get("/guild", defineEventHandler(async event => { - const {guild_id} = await getValidatedQuery(event, schema.guild.parse) - const session = await auth.useSession(event) - const managed = await auth.getManagedGuilds(event) - const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() - // @ts-ignore - const guild = discord.guilds.get(guild_id) - - // Permission problems - if (!guild_id || !guild || !managed.has(guild_id) || !row) { - return pugSync.render(event, "guild-access-denied.pug", {guild_id, row}) - } - - // Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow - if (!row.space_id && row.autocreate === 0) { - const spaces = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : [] - return pugSync.render(event, "guild-not-linked.pug", {guild, guild_id, spaces}) - } - - const roles = guild.members?.find(m => m.user.id === botID)?.roles || [] - - // Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space - if (!row.space_id) { - const links = getChannelRoomsLinks(guild, [], roles) - return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) - } - - // Linked guild - const api = getAPI(event) - const rooms = await api.getFullHierarchy(row.space_id) - const links = getChannelRoomsLinks(guild, rooms, roles) - return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) -})) - -as.router.get("/qr", defineEventHandler(async event => { - const {guild_id} = await getValidatedQuery(event, schema.qr.parse) - const managed = await auth.getManagedGuilds(event) - const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() - // @ts-ignore - const guild = discord.guilds.get(guild_id) - - // Permission problems - if (!guild_id || !guild || !managed.has(guild_id) || !row) { - return pugSync.render(event, "guild-access-denied.pug", {guild_id, row}) - } - - const nonce = randomUUID() - validNonce.set(nonce, guild_id) - - const data = `${reg.ooye.bridge_origin}/invite?nonce=${nonce}` - // necessary to scale the svg pixel-perfectly on the page - // https://github.com/unjs/uqr/blob/244952a8ba2d417f938071b61e11fb1ff95d6e75/src/svg.ts#L24 - const generatedSvg = uqr.renderSVG(data, {pixelSize: 3}) - const svg = generatedSvg.replace(/viewBox="0 0 ([0-9]+) ([0-9]+)"/, `data-nonce="${nonce}" width="$1" height="$2" $&`) - assert.notEqual(svg, generatedSvg) - - return svg -})) - -as.router.get("/invite", defineEventHandler(async event => { - const {nonce} = await getValidatedQuery(event, schema.inviteNonce.parse) - const isValid = validNonce.has(nonce) - const guild_id = validNonce.get(nonce) - const guild = discord.guilds.get(guild_id || "") - return pugSync.render(event, "invite.pug", {isValid, nonce, guild_id, guild}) -})) - -as.router.post("/api/invite", defineEventHandler(async event => { - const parsedBody = await readValidatedBody(event, schema.invite.parse) - const managed = await auth.getManagedGuilds(event) - const api = getAPI(event) - - // Check guild ID or nonce - if (parsedBody.guild_id) { - var guild_id = parsedBody.guild_id - if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't invite users to a guild you don't have Manage Server permissions in"}) - } else if (parsedBody.nonce) { - if (!validNonce.has(parsedBody.nonce)) throw createError({status: 403, message: "Nonce expired", data: "Nonce means number-used-once, and, well, you tried to use it twice..."}) - let ok = validNonce.get(parsedBody.nonce) - assert(ok) - var guild_id = ok - validNonce.delete(parsedBody.nonce) - } else { - throw createError({status: 400, message: "Missing guild ID", data: "Passing a guild ID or a nonce is required."}) - } - - // Check guild is bridged - const guild = discord.guilds.get(guild_id) - assert(guild) - const spaceID = await createSpace.ensureSpace(guild) - - // Check for existing invite to the space - let spaceMember - try { - spaceMember = await api.getStateEvent(spaceID, "m.room.member", parsedBody.mxid) - } catch (e) {} - - if (!spaceMember || !["invite", "join"].includes(spaceMember.membership)) { - // Invite - await api.inviteToRoom(spaceID, parsedBody.mxid) - } - - // Permissions - const powerLevel = - ( parsedBody.permissions === "admin" ? 100 - : parsedBody.permissions === "moderator" ? 50 - : 0) - if (powerLevel) await mxUtils.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel, api) - - if (parsedBody.guild_id) { - setResponseHeader(event, "HX-Refresh", true) - return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302) - } else { - return sendRedirect(event, "/ok?msg=User has been invited.", 302) - } -})) - -module.exports._getPosition = getPosition -module.exports.getInviteTargetSpaces = getInviteTargetSpaces From bf9b6b198af4b44b8be00d92d020baa8c97f5591 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 21:02:42 +0200 Subject: [PATCH 122/137] FUCK YOU, PODMAN :"< it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache --- src/web/pug/guild.pug | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 16a7a76a..109ac9e0 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -188,9 +188,7 @@ block body td(colspan="3") .s-empty-state No channels linked between Discord and Matrix yet... - p Debug1 if space_id && linkedChannelsWithDetails.length - p Debug2 - const customMentionRooms = linkedChannelsWithDetails.filter(row => row.room_mention_role) - const defaultMentionRooms = linkedChannelsWithDetails.filter(row => !row.room_mention_role) - const mentionRoles = [...guild.roles].filter(role => role.id !== guild_id).sort((a, b) => b.position - a.position) @@ -255,10 +253,8 @@ block body button.s-btn.s-btn__icon.s-btn__filled.d-inline-flex.ai-center.g4.py0.box-border(type="submit" style="min-height: 0") != icons.Icons.IconPlusSm span Add - p Debug3 - h3.fs-category.mt32 Auto-create REAL QUESTION, WTF???? - p Debug4 + h3.fs-category.mt32 Auto-create .s-card.d-grid.px0 form.d-flex.ai-center.g16 #autocreate-loading.p8 From e1b512bf090f1e16697199f1fa625b15484e0149 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 22:32:12 +0200 Subject: [PATCH 123/137] RIP Ellie Mode; fck u Commet The whole reason this woke branch up again, is that Cadence said she wants to use Matrix threads for discoverability of Discord threads. So RIP Ellie Mode (the one that eradicates threads fully). --- src/d2m/converters/thread-to-announcement.js | 15 ++++++--------- src/web/routes/guild.js | 6 +++--- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 6571619c..5ce84439 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -17,7 +17,6 @@ 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 = {} @@ -25,18 +24,16 @@ 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[Note: You really should move the conversation to that room, rather than continuing to reply via a Matrix thread. Any messages sent in threads will be DELETED and instead moved to that room by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]" + suffix = "\n[ YOU MUST continue the conversation in that thread-room, rather than in this Matrix thread, even if your client supports MX threads. Any messages sent in MX threads ARE !DELETED! and copied to thread-rooms, which makes OOYE the author, and strips you from edit/delete rights! ]" context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}} 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"} - } + //...And actually branch from that event + 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" - const template = creatorMxid ? "started a thread called" : "New thread started:" + //TODO: Wait until certain clients (*cough* Commet *cough*) figure out how to render an m.emote in a thread, and then simplify this to creatorMxid ? "m.emote" : "m.text" and creatorMxid ? "started a thread called" : "New thread started:" + const msgtype = creatorMxid && !branchedFromEventID ? "m.emote" : "m.text" + const template = creatorMxid ? `${branchedFromEventID ? `${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}` diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 33182523..6151dfbb 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -58,7 +58,7 @@ const validNonce = new LRUCache({max: 200}) /** * TYPING = Channels on which Discord messages can be sent. They should be bridgeable to anything other than an m.space (because if it did end up as a space, no one would be able to actually see the text messages sent there). - * SPACE = Channels on which Discord messages cannot be received. They should be bridgeable to m.space only (because not only does m.space make sending messages impossible on any sane client (thus preventing Discord-caused errors), but it also just-so-happens that both currently-existing message-unsupporting channel types (Categories and School hubs) are sort of "indexes", which fits nicely to m.space). + * SPACE = Channels on which Discord messages cannot be sent. They should be bridgeable to m.space only (because not only does m.space make sending messages impossible on any sane client (thus preventing Discord-caused errors), but it also just-so-happens that both currently-existing message-unsupporting channel types (Categories and School hubs) are sort of "indexes", which fits nicely to m.space). * MIXED = Forum-like channels. They can be bridged to both m.space and anything other than an m.space - hence the name. * @type {Map}*/ const linkRules = new Map([ @@ -66,13 +66,13 @@ const linkRules = new Map([ [1, {type: "TYPING", humanName:"Normal DMs", unsupported: "OOYE won't support DMs until a good way of doing it can be figured out. Please see https://gitdab.com/cadence/out-of-your-element#caveats for more."}], [2, {type: "TYPING", humanName:"Normal VCs"}], [3, {type: "TYPING", humanName:"Group DMs", unsupported: "OOYE won't support DMs until a good way of doing it can be figured out. Please see https://gitdab.com/cadence/out-of-your-element#caveats for more."}], - [4, {type: "SPACE", humanName:"Categories", unsupported: "There is no concept of categories on Matrix."}], //...at least officially. In practice, some clients will render sub-spaces as categories. TODO: Bridge categories to sub-spaces. + [4, {type: "SPACE", humanName:"Categories", unsupported: "There is no concept of categories on Matrix."}], //...at least officially. In practice, some clients will render sub-spaces as categories. TODO: Research Cadence's stance on bridging categories to sub-spaces. [5, {type: "TYPING", humanName:"Announcement text channels"}], [10, {type: "TYPING", humanName:"Announcement threads"}], [11, {type: "TYPING", humanName:"Normal threads"}], [12, {type: "TYPING", humanName:"Private threads"}], [13, {type: "TYPING", humanName:"Stage VCs"}], - [14, {type: "SPACE", humanName:"School hubs", unsupported: "Bots cannot be members of school hubs. How in the sweet hell did you manage to put OOYE on one, anyway??? ~~Emma, please stop breaking Discord API in cursed ways again.~~"}], + [14, {type: "SPACE", humanName:"School hubs", unsupported: "Bots cannot be members of school hubs. With that said, how in the sweet hell did you manage to put OOYE on one???"}], [15, {type: "MIXED", humanName:"Normal forums"}], [16, {type: "MIXED", humanName:"Media forums"}], ]) From 17cc56299dfe880a5b2cff17ba4a60dbb091e78f Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 23:29:54 +0200 Subject: [PATCH 124/137] Nevermind, Commet is *just broken* when it comes to threads. It refuses to render the 1st message of a thread, whether it's an emote, or anything else. COMMET, *WHY?* Anyway, I don't think it makes sense to add extra accommodation to what's clearly a client-level bug. So emotes are back, yay! --- src/d2m/converters/thread-to-announcement.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 5ce84439..38634e10 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -31,9 +31,8 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr 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"} } - //TODO: Wait until certain clients (*cough* Commet *cough*) figure out how to render an m.emote in a thread, and then simplify this to creatorMxid ? "m.emote" : "m.text" and creatorMxid ? "started a thread called" : "New thread started:" - const msgtype = creatorMxid && !branchedFromEventID ? "m.emote" : "m.text" - const template = creatorMxid ? `${branchedFromEventID ? `${creatorMxid} ` : ""}started a thread called` : "New thread started:" + const msgtype = creatorMxid ? "m.emote" : "m.text" + 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}` From 1382ce6791933d25e0ce02d51884e6d5f6b7dfad Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 23:32:57 +0200 Subject: [PATCH 125/137] update tests --- .../converters/thread-to-announcement.test.js | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.test.js b/src/d2m/converters/thread-to-announcement.test.js index a4dfd9a3..3d732153 100644 --- a/src/d2m/converters/thread-to-announcement.test.js +++ b/src/d2m/converters/thread-to-announcement.test.js @@ -1,8 +1,8 @@ -const {test} = require("supertape") -const {threadToAnnouncement} = require("./thread-to-announcement") +const { test } = require("supertape") +const { threadToAnnouncement } = require("./thread-to-announcement") const data = require("../../../test/data") const Ty = require("../../types") -const {mockGetEffectivePower} = require("../../matrix/utils.test") +const { mockGetEffectivePower } = require("../../matrix/utils.test") /** * @param {string} roomID @@ -10,7 +10,7 @@ const {mockGetEffectivePower} = require("../../matrix/utils.test") * @returns {(roomID: string, eventID: string) => Promise>} */ function mockGetEvent(t, roomID_in, eventID_in, outer) { - return async function(roomID, eventID) { + return async function (roomID, eventID) { t.equal(roomID, roomID_in) t.equal(eventID, eventID_in) return new Promise(resolve => { @@ -35,8 +35,8 @@ const viaApi = { async getJoinedMembers(roomID) { return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -46,7 +46,7 @@ test("thread2announcement: no known creator, no branched from event", async t => const content = await threadToAnnouncement("!parent", "!thread", null, { name: "test thread", id: "-1" - }, {api: viaApi}) + }, { 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", @@ -57,7 +57,7 @@ test("thread2announcement: known creator, no branched from event", async t => { const content = await threadToAnnouncement("!parent", "!thread", "@_ooye_crunch_god:cadence.moe", { name: "test thread", id: "-1" - }, {api: viaApi}) + }, { api: viaApi }) t.deepEqual(content, { msgtype: "m.emote", body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", @@ -83,7 +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 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!]", + body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[ YOU MUST continue the conversation in that thread-room, rather than in this Matrix thread, even if your client supports MX threads. Any messages sent in MX threads ARE !DELETED! and copied to thread-rooms, which makes OOYE the author, and strips you from edit/delete rights! ]", "m.relates_to": { "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", "is_falling_back": false, @@ -114,7 +114,7 @@ test("thread2announcement: known creator, branched from discord event", async t }) t.deepEqual(content, { msgtype: "m.emote", - 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!]", + body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[ YOU MUST continue the conversation in that thread-room, rather than in this Matrix thread, even if your client supports MX threads. Any messages sent in MX threads ARE !DELETED! and copied to thread-rooms, which makes OOYE the author, and strips you from edit/delete rights! ]", "m.relates_to": { "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", "is_falling_back": false, @@ -145,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 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!]", + body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[ YOU MUST continue the conversation in that thread-room, rather than in this Matrix thread, even if your client supports MX threads. Any messages sent in MX threads ARE !DELETED! and copied to thread-rooms, which makes OOYE the author, and strips you from edit/delete rights! ]", "m.mentions": { user_ids: ["@cadence:cadence.moe"] }, @@ -179,7 +179,7 @@ test("thread2announcement: known creator, branched from matrix event", async t = }) t.deepEqual(content, { msgtype: "m.emote", - 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!]", + body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[ YOU MUST continue the conversation in that thread-room, rather than in this Matrix thread, even if your client supports MX threads. Any messages sent in MX threads ARE !DELETED! and copied to thread-rooms, which makes OOYE the author, and strips you from edit/delete rights! ]", "m.mentions": { user_ids: ["@cadence:cadence.moe"] }, From 4852ef3b86551caeef250a3c4803b4a13e3ec705 Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 19 Oct 2026 13:12:52 +0200 Subject: [PATCH 126/137] improve consistency of my code with Cadence's --- 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 89c04067..8ecfb09b 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -9,7 +9,7 @@ const Ty = require("../types") const {discord, db, sync, as, select} = require("../passthrough") const {tag} = require("@cloudrac3r/html-template-tag") const {Semaphore} = require("@chriscdn/promise-semaphore") -const { bridgeThread, handleForums } = require("./converters/threads-and-forums") +const {bridgeThread, handleForums} = require("./converters/threads-and-forums") /** @type {import("./actions/send-event")} */ const sendEvent = sync.require("./actions/send-event") From 1a1173413b27c9182914341d404e6c4daf7cde3f Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 19 Oct 2026 13:43:39 +0200 Subject: [PATCH 127/137] spoiler fix --- src/d2m/converters/message-to-event.js | 9 +++++---- src/discord/utils.js | 9 ++++++--- src/web/routes/download-discord.js | 19 +++++++++++++++---- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index a291a5e0..62eed46c 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -135,7 +135,8 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ * @param {{fetch?: typeof fetch}} [di] */ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { - const external_url = dUtils.getPublicUrlForCdn(attachment.url) + const spoiler = attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler); + const external_url = dUtils.getPublicUrlForCdn(attachment.url, spoiler ? "spoiler/" : "") const emoji = attachment.content_type?.startsWith("image/jp") ? "📸" : attachment.content_type?.startsWith("image/") ? "🖼️" @@ -143,15 +144,15 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { : attachment.content_type?.startsWith("text/") ? "📝" : attachment.content_type?.startsWith("audio/") ? "🎶" : "📄" - // no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote - if (attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler)) { + // no native media spoilers in Element, so we'll post a spoiler (unpreviewable) link instead + if (spoiler) { return { $type: "m.room.message", "m.mentions": mentions, msgtype: "m.text", body: `${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})`, format: "org.matrix.custom.html", - formatted_body: `
${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})
` + formatted_body: `${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})` } } // attempt to convert large PNG image files to JPEG, since it's almost certainly a photo that was forced into PNG by the user copy-pasting. diff --git a/src/discord/utils.js b/src/discord/utils.js index 6fb0b433..2f7a7e0e 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -134,14 +134,17 @@ function timestampToSnowflakeInexact(timestamp) { return String((timestamp - EPOCH) * 2**22) } -/** @param {string} url */ -function getPublicUrlForCdn(url) { +/** + * @param {string} url + * @param {""|"spoiler/"} spoiler + */ +function getPublicUrlForCdn(url, spoiler="") { const match = url.match(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/) if (!match) return url const unsignedHash = hasher.h64(match[3]) // attachment ID 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 `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}` + return `${reg.ooye.bridge_origin}/download/discord${match[1]}${spoiler}/${match[2]}/${match[3]}/${match[4]}` } /** diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index 769fc9c0..f802d178 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -9,7 +9,10 @@ let hasher = null // @ts-ignore require("xxhash-wasm")().then(h => hasher = h) -const {discord, as, select} = require("../../passthrough") +const {discord, as, select, sync} = require("../../passthrough") + +/** @type {import("../web-utils")} */ +const webUtils = sync.require("../web-utils") const schema = { params: z.object({ @@ -40,10 +43,16 @@ function timeUntilExpiry(url) { if (time > 0) return time } -function defineMediaProxyHandler(domain) { +/** + * @param {string} domain + * @param {boolean} spoiler + */ +function defineMediaProxyHandler(domain, spoiler) { return defineEventHandler(async event => { const params = await getValidatedRouterParams(event, schema.params.parse) + if (spoiler) webUtils.rejectUrlPreviewer(event) + const unsignedHash = hasher.h64(params.attachment_id) const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range @@ -81,7 +90,9 @@ function defineMediaProxyHandler(domain) { }) } -as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com")) -as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net")) +as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com", false)) +as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net", false)) +as.router.get(`/download/discordcdn/:channel_id/spoiler/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com", true)) +as.router.get(`/download/discordmedia/:channel_id/spoiler/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net", true)) module.exports._cache = cache From 966095c3610b5565c2dd902805ebfb3b00dd2187 Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 19 Oct 2026 13:57:48 +0200 Subject: [PATCH 128/137] of course, I fucked up where the / goes... --- src/discord/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/discord/utils.js b/src/discord/utils.js index 2f7a7e0e..9d1775fe 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -144,7 +144,7 @@ function getPublicUrlForCdn(url, spoiler="") { const unsignedHash = hasher.h64(match[3]) // attachment ID 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 `${reg.ooye.bridge_origin}/download/discord${match[1]}${spoiler}/${match[2]}/${match[3]}/${match[4]}` + return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${spoiler}${match[2]}/${match[3]}/${match[4]}` } /** From 4350bff202e83151b25c35ba8068537d75c06bf4 Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 19 Oct 2026 14:06:33 +0200 Subject: [PATCH 129/137] kill me --- src/discord/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/discord/utils.js b/src/discord/utils.js index 9d1775fe..8effd884 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -144,7 +144,7 @@ function getPublicUrlForCdn(url, spoiler="") { const unsignedHash = hasher.h64(match[3]) // attachment ID 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 `${reg.ooye.bridge_origin}/download/discord${match[1]}/${spoiler}${match[2]}/${match[3]}/${match[4]}` + return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${spoiler}${match[3]}/${match[4]}` } /** From 727b5a3e4f97c59de6d0cd69d6adc00c6eed6d81 Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 19 Oct 2026 15:33:54 +0200 Subject: [PATCH 130/137] update tests --- src/d2m/converters/message-to-event.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 44b9fe65..233a4ba5 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -366,9 +366,9 @@ test("message2event: spoiler attachment", async t => { $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)", + body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/spoiler/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)", format: "org.matrix.custom.html", - formatted_body: "
📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)
" + formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/spoiler/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)" }]) }) @@ -1205,13 +1205,13 @@ test("message2event: multiple attachments are combined into the same event where msgtype: "m.text", body: "hey" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)" - + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg (38 KB)" + + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/spoiler/456/SPOILER_secret.jpg (38 KB)" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", format: "org.matrix.custom.html", 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 SPOILER file: https://bridge.example.org/download/discordcdn/123/spoiler/456/SPOILER_secret.jpg (38 KB)` + + `
📄 Uploaded file: hey.jpg (100 MB)` }, { $type: "m.room.message", "m.mentions": {}, From 5da1a090c6b1393be53154b77e4984b9705516fe Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 20 Oct 2026 12:35:03 +0200 Subject: [PATCH 131/137] rewrite in QP --- src/d2m/converters/message-to-event.js | 2 +- src/d2m/converters/message-to-event.test.js | 8 +++--- src/discord/utils.js | 4 +-- src/web/routes/download-discord.js | 25 ++++++++++--------- src/web/routes/download-discord.test.js | 27 +++++++++++++++++++++ 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 62eed46c..334d55eb 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -136,7 +136,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ */ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { const spoiler = attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler); - const external_url = dUtils.getPublicUrlForCdn(attachment.url, spoiler ? "spoiler/" : "") + const external_url = dUtils.getPublicUrlForCdn(attachment.url, spoiler ? "?spoiler" : "") const emoji = attachment.content_type?.startsWith("image/jp") ? "📸" : attachment.content_type?.startsWith("image/") ? "🖼️" diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 233a4ba5..59abb1ef 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -366,9 +366,9 @@ test("message2event: spoiler attachment", async t => { $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/spoiler/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)", + body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler (74 KB)", format: "org.matrix.custom.html", - formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/spoiler/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)" + formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler (74 KB)" }]) }) @@ -1205,12 +1205,12 @@ test("message2event: multiple attachments are combined into the same event where msgtype: "m.text", body: "hey" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)" - + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/spoiler/456/SPOILER_secret.jpg (38 KB)" + + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler (38 KB)" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", format: "org.matrix.custom.html", formatted_body: "hey" + `
📄 Uploaded file: hey.jpg (100 MB)` - + `
📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/spoiler/456/SPOILER_secret.jpg (38 KB)` + + `
📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler (38 KB)` + `
📄 Uploaded file: hey.jpg (100 MB)` }, { $type: "m.room.message", diff --git a/src/discord/utils.js b/src/discord/utils.js index 8effd884..19bf743c 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -136,7 +136,7 @@ function timestampToSnowflakeInexact(timestamp) { /** * @param {string} url - * @param {""|"spoiler/"} spoiler + * @param {""|"?spoiler"} spoiler */ function getPublicUrlForCdn(url, spoiler="") { const match = url.match(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/) @@ -144,7 +144,7 @@ function getPublicUrlForCdn(url, spoiler="") { const unsignedHash = hasher.h64(match[3]) // attachment ID 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 `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${spoiler}${match[3]}/${match[4]}` + return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}${spoiler}` } /** diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index f802d178..550b669b 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -1,7 +1,7 @@ // @ts-check const assert = require("assert/strict") -const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError, H3Event} = require("h3") +const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError, H3Event, getValidatedQuery} = require("h3") const {z} = require("zod") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore @@ -15,10 +15,13 @@ const {discord, as, select, sync} = require("../../passthrough") const webUtils = sync.require("../web-utils") const schema = { - params: z.object({ + router: z.object({ channel_id: z.string().regex(/^[0-9]+$/), attachment_id: z.string().regex(/^[0-9]+$/), file_name: z.string().regex(/^[-A-Za-z0-9_.,]+$/) + }), + query: z.object({ + spoiler: z.optional(z.string().max(0)) // Lets you choose between no query param, and a ?spoiler query param (H3 considers it an empty string). Booleans do NOT work here, unfortunately. }) } @@ -45,15 +48,15 @@ function timeUntilExpiry(url) { /** * @param {string} domain - * @param {boolean} spoiler */ -function defineMediaProxyHandler(domain, spoiler) { +function defineMediaProxyHandler(domain) { return defineEventHandler(async event => { - const params = await getValidatedRouterParams(event, schema.params.parse) + const routerParams = await getValidatedRouterParams(event, schema.router.parse) + const queryParams = await getValidatedQuery(event, schema.query.parse) - if (spoiler) webUtils.rejectUrlPreviewer(event) + if (queryParams.spoiler === "") webUtils.rejectUrlPreviewer(event) - const unsignedHash = hasher.h64(params.attachment_id) + const unsignedHash = hasher.h64(routerParams.attachment_id) const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range const row = select("media_proxy", "permitted_hash", {permitted_hash: signedHash}).get() @@ -64,7 +67,7 @@ function defineMediaProxyHandler(domain, spoiler) { }) } - const url = `https://${domain}/attachments/${params.channel_id}/${params.attachment_id}/${params.file_name}` + const url = `https://${domain}/attachments/${routerParams.channel_id}/${routerParams.attachment_id}/${routerParams.file_name}` let promise = cache.get(url) /** @type {string | undefined} */ let refreshed @@ -90,9 +93,7 @@ function defineMediaProxyHandler(domain, spoiler) { }) } -as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com", false)) -as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net", false)) -as.router.get(`/download/discordcdn/:channel_id/spoiler/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com", true)) -as.router.get(`/download/discordmedia/:channel_id/spoiler/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net", true)) +as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com")) +as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net")) module.exports._cache = cache diff --git a/src/web/routes/download-discord.test.js b/src/web/routes/download-discord.test.js index 0d4b8844..e08832cf 100644 --- a/src/web/routes/download-discord.test.js +++ b/src/web/routes/download-discord.test.js @@ -46,6 +46,33 @@ test("web download discord: works if a known attachment", async t => { t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) }) +test("web download discord: works with spoilers", async t => { + const event = {} + await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name?spoiler", { + params: { + channel_id: "655216173696286746", + attachment_id: "1314358913482621010", + file_name: "image.png" + }, + event, + snow: { + channel: { + async refreshAttachmentURLs(attachments) { + assert(Array.isArray(attachments)) + return { + refreshed_urls: attachments.map(a => ({ + original: a, + refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` + })) + } + } + } + } + }) + t.equal(event.node.res.statusCode, 302) + t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) +}) + test("web download discord: uses cache", async t => { let notCalled = true const event = {} From 15055b8ee09a98aaead8c48e462abb65c7d05c33 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 20 Oct 2026 13:22:18 +0200 Subject: [PATCH 132/137] Use less hacky way of setting the params --- src/d2m/converters/message-to-event.js | 2 +- src/d2m/converters/message-to-event.test.js | 226 ++++++++++---------- src/discord/utils.js | 8 +- src/web/routes/download-discord.js | 2 +- 4 files changed, 122 insertions(+), 116 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 334d55eb..65fe3e45 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -136,7 +136,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ */ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { const spoiler = attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler); - const external_url = dUtils.getPublicUrlForCdn(attachment.url, spoiler ? "?spoiler" : "") + const external_url = dUtils.getPublicUrlForCdn(attachment.url, spoiler) const emoji = attachment.content_type?.startsWith("image/jp") ? "📸" : attachment.content_type?.startsWith("image/") ? "🖼️" diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 59abb1ef..dd2449f6 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1,10 +1,10 @@ -const {test} = require("supertape") -const {messageToEvent} = require("./message-to-event") -const {MatrixServerError} = require("../../matrix/mreq") +const { test } = require("supertape") +const { messageToEvent } = require("./message-to-event") +const { MatrixServerError } = require("../../matrix/mreq") const data = require("../../../test/data") -const {mockGetEffectivePower} = require("../../matrix/utils.test") +const { mockGetEffectivePower } = require("../../matrix/utils.test") const Ty = require("../../types") -const {db} = require("../../passthrough") +const { db } = require("../../passthrough") /** * @param {string} roomID @@ -12,7 +12,7 @@ const {db} = require("../../passthrough") * @returns {(roomID: string, eventID: string) => Promise>} */ function mockGetEvent(t, roomID_in, eventID_in, outer) { - return async function(roomID, eventID) { + return async function (roomID, eventID) { t.equal(roomID, roomID_in) t.equal(eventID, eventID_in) return new Promise(resolve => { @@ -74,8 +74,8 @@ test("message2event: simple room mention", async t => { t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -102,8 +102,8 @@ test("message2event: simple room link", async t => { t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -130,8 +130,8 @@ test("message2event: nicked room mention", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -234,8 +234,8 @@ test("message2event: simple message link", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:super.invalid": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:super.invalid": { display_name: null, avatar_url: null } } } } @@ -270,8 +270,8 @@ test("message2event: message link that OOYE doesn't know about", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -308,8 +308,8 @@ test("message2event: message timestamp failed to fetch", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -366,9 +366,9 @@ test("message2event: spoiler attachment", async t => { $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler (74 KB)", + body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler= (74 KB)", format: "org.matrix.custom.html", - formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler (74 KB)" + formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler= (74 KB)" }]) }) @@ -505,7 +505,7 @@ test("message2event: simple reply to matrix user", async t => { }) test("message2event: simple reply to matrix user, reply fallbacks disabled", async t => { - const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {includeReplyFallback: false}, { + const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, { includeReplyFallback: false }, { api: { getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", { type: "m.room.message", @@ -605,17 +605,17 @@ test("message2event: voice message", async t => { const events = await messageToEvent(data.message.voice_message) t.deepEqual(events, [{ $type: "m.room.message", - body: "voice-message.ogg", - external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg", - filename: "voice-message.ogg", - info: { - duration: 3960, - mimetype: "audio/ogg", - size: 10584, + body: "voice-message.ogg", + external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg", + filename: "voice-message.ogg", + info: { + duration: 3960, + mimetype: "audio/ogg", + size: 10584, }, - "m.mentions": {}, - msgtype: "m.audio", - url: "mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB" + "m.mentions": {}, + msgtype: "m.audio", + url: "mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB" }]) }) @@ -628,16 +628,16 @@ test("message2event: misc file", async t => { "m.mentions": {} }, { $type: "m.room.message", - body: "the.yml", - external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml", - filename: "the.yml", + body: "the.yml", + external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml", + filename: "the.yml", info: { mimetype: "text/plain; charset=utf-8", size: 2274 }, - "m.mentions": {}, - msgtype: "m.file", - url: "mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP" + "m.mentions": {}, + msgtype: "m.file", + url: "mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP" }]) }) @@ -674,7 +674,7 @@ test("message2event: simple reply in thread to a matrix user's reply", async t = user_ids: ["@cadence:cadence.moe"] }, msgtype: "m.text", - body: "Well, they don't seem to..." + body: "Well, they don't seem to..." }]) }) @@ -711,7 +711,7 @@ test("message2event: infinidoge's reply to ami's matrix smalltext reply to infin user_ids: ["@ami:the-apothecary.club"] }, msgtype: "m.text", - body: `Most likely` + body: `Most likely` }]) }) @@ -748,7 +748,7 @@ test("message2event: infinidoge's reply to ami's matrix smalltext singleline rep user_ids: ["@ami:the-apothecary.club"] }, msgtype: "m.text", - body: `Most likely` + body: `Most likely` }]) }) @@ -757,11 +757,11 @@ test("message2event: reply to a Discord message that wasn't bridged", async t => t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.text", - body: `> In reply to a 1-day-old unbridged message:` + body: `> In reply to a 1-day-old unbridged message:` + `\n> Occimyy: BILLY BOB THE GREAT` + `\n\nenigmatic`, - format: "org.matrix.custom.html", - formatted_body: `
In reply to a 1-day-old unbridged message from Occimyy:
BILLY BOB THE GREAT
enigmatic`, + format: "org.matrix.custom.html", + formatted_body: `
In reply to a 1-day-old unbridged message from Occimyy:
BILLY BOB THE GREAT
enigmatic`, "m.mentions": {} }]) }) @@ -1151,10 +1151,12 @@ test("message2event: very large png is converted to jpeg for upload", async t => async fetch(url, init) { t.equal(url, "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg") t.equal(init.method, "HEAD") - return new Response("wa", {headers: { - "Content-Type": "image/jpeg", - "Content-Length": 483085 - }}) + return new Response("wa", { + headers: { + "Content-Type": "image/jpeg", + "Content-Length": 483085 + } + }) } }) t.deepEqual(events, [{ @@ -1205,12 +1207,12 @@ test("message2event: multiple attachments are combined into the same event where msgtype: "m.text", body: "hey" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)" - + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler (38 KB)" + + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler= (38 KB)" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", format: "org.matrix.custom.html", formatted_body: "hey" + `
📄 Uploaded file: hey.jpg (100 MB)` - + `
📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler (38 KB)` + + `
📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler= (38 KB)` + `
📄 Uploaded file: hey.jpg (100 MB)` }, { $type: "m.room.message", @@ -1332,7 +1334,7 @@ test("message2event: emoji triple long name", async t => { body: ":brillillillilliant_move::brillillillilliant_move::brillillillilliant_move:", format: "org.matrix.custom.html", formatted_body: - ':brillillillilliant_move:' + ':brillillillilliant_move:' + ':brillillillilliant_move:' + ':brillillillilliant_move:' }]) @@ -1446,8 +1448,8 @@ test("message2event: constructed forwarded message", async t => { async getJoinedMembers() { return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -1505,8 +1507,8 @@ test("message2event: constructed forwarded text", async t => { async getJoinedMembers() { return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -1550,10 +1552,10 @@ test("message2event: don't scan forwarded messages for mentions", async t => { }) test("message2event: invite no details embed if no event", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { + const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381190945646710824" }, {}, {}, { snow: { invite: { - getInvite: async () => ({...data.invite.irl, guild_scheduled_event: null}) + getInvite: async () => ({ ...data.invite.irl, guild_scheduled_event: null }) } } }) @@ -1570,7 +1572,7 @@ test("message2event: invite no details embed if no event", async t => { }) test("message2event: irl invite event renders embed", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { + const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381190945646710824" }, {}, {}, { snow: { invite: { getInvite: async () => data.invite.irl @@ -1603,7 +1605,7 @@ test("message2event: irl invite event renders embed", async t => { }) test("message2event: vc invite event renders embed", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { + const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381174024801095751" }, {}, {}, { snow: { invite: { getInvite: async () => data.invite.vc @@ -1637,12 +1639,12 @@ test("message2event: vc invite event renders embed", async t => { }) test("message2event: vc invite event renders embed with room link", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { + const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381174024801095751" }, {}, {}, { api: { getEffectivePower: mockGetEffectivePower(), getJoinedMembers: async () => ({ joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, } }) }, @@ -1679,7 +1681,7 @@ 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"}, {}, {}, { + const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381190945646710824" }, {}, {}, { snow: { invite: { async getInvite() { @@ -1704,11 +1706,11 @@ test("message2event: channel links are converted even inside lists (parser post- let called = 0 const events = await messageToEvent({ content: "1. Don't be a dick" - + "\n2. Follow rule number 1" - + "\n3. Follow Discord TOS" - + "\n4. Do **not** post NSFW content, shock content, suggestive content" - + "\n5. Please keep <#176333891320283136> professional and helpful, no random off-topic joking" - + "\nThis list will probably change in the future" + + "\n2. Follow rule number 1" + + "\n3. Follow Discord TOS" + + "\n4. Do **not** post NSFW content, shock content, suggestive content" + + "\n5. Please keep <#176333891320283136> professional and helpful, no random off-topic joking" + + "\nThis list will probably change in the future" }, data.guild.general, {}, { api: { getEffectivePower: mockGetEffectivePower(), @@ -1730,11 +1732,11 @@ test("message2event: channel links are converted even inside lists (parser post- { $type: "m.room.message", body: "1. Don't be a dick" - + "\n2. Follow rule number 1" - + "\n3. Follow Discord TOS" - + "\n4. Do **not** post NSFW content, shock content, suggestive content" - + "\n5. Please keep #wonderland professional and helpful, no random off-topic joking" - + "\nThis list will probably change in the future", + + "\n2. Follow rule number 1" + + "\n3. Follow Discord TOS" + + "\n4. Do **not** post NSFW content, shock content, suggestive content" + + "\n5. Please keep #wonderland professional and helpful, no random off-topic joking" + + "\nThis list will probably change in the future", format: "org.matrix.custom.html", formatted_body: "
  1. Don't be a dick
  2. Follow rule number 1
  3. Follow Discord TOS
  4. Do not post NSFW content, shock content, suggestive content
  5. Please keep #wonderland professional and helpful, no random off-topic joking
This list will probably change in the future", "m.mentions": {}, @@ -1783,31 +1785,33 @@ test("message2event: cross-room reply", async t => { } }, content: "cross-room reply" - }, {}, {}, {api: { - async getEvent(roomID, eventID) { - called++ - t.equal(roomID, "!mHmhQQPwXNananaOLD:cadence.moe") - t.equal(eventID, "$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY") - return { - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - "body": "image.png", - "info": { - "h": 738, - "mimetype": "image/png", - "org.matrix.msc4230.is_animated": false, - "size": 111189, - "w": 772, - "xyz.amorgan.blurhash": "L255Oa~qRPD$-pxuoJoLIUM{xuxu" - }, - "m.mentions": {}, - "msgtype": "m.image", - "url": "mxc://matrix.org/QbSujQjRLekzPknKlPsXbGDS" + }, {}, {}, { + api: { + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!mHmhQQPwXNananaOLD:cadence.moe") + t.equal(eventID, "$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY") + return { + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + "body": "image.png", + "info": { + "h": 738, + "mimetype": "image/png", + "org.matrix.msc4230.is_animated": false, + "size": 111189, + "w": 772, + "xyz.amorgan.blurhash": "L255Oa~qRPD$-pxuoJoLIUM{xuxu" + }, + "m.mentions": {}, + "msgtype": "m.image", + "url": "mxc://matrix.org/QbSujQjRLekzPknKlPsXbGDS" + } } } } - }}) + }) t.deepEqual(events, [ { $type: "m.room.message", @@ -1911,15 +1915,15 @@ test("message2event: single-choice poll", async t => { kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. max_selections: 1, answers: [{ - id: "1", - "org.matrix.msc1767.text": "[\ud83d\udc4d] answer one" - }, { - id: "2", - "org.matrix.msc1767.text": "[\ud83d\udc4e] answer two" - }, { - id: "3", - "org.matrix.msc1767.text": "answer three" - }] + id: "1", + "org.matrix.msc1767.text": "[\ud83d\udc4d] answer one" + }, { + id: "2", + "org.matrix.msc1767.text": "[\ud83d\udc4e] answer two" + }, { + id: "3", + "org.matrix.msc1767.text": "answer three" + }] }, "org.matrix.msc1767.text": "only one answer allowed!\n1. [\ud83d\udc4d] answer one\n2. [\ud83d\udc4e] answer two\n3. answer three" }]) @@ -1938,15 +1942,15 @@ test("message2event: multiple-choice poll", async t => { kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. max_selections: 3, answers: [{ - id: "1", - "org.matrix.msc1767.text": "[😭] no" - }, { - id: "2", - "org.matrix.msc1767.text": "oh no" - }, { - id: "3", - "org.matrix.msc1767.text": "oh noooooo" - }] + id: "1", + "org.matrix.msc1767.text": "[😭] no" + }, { + id: "2", + "org.matrix.msc1767.text": "oh no" + }, { + id: "3", + "org.matrix.msc1767.text": "oh noooooo" + }] }, "org.matrix.msc1767.text": "more than one answer allowed\n1. [😭] no\n2. oh no\n3. oh noooooo" }]) diff --git a/src/discord/utils.js b/src/discord/utils.js index 19bf743c..40231d16 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -136,15 +136,17 @@ function timestampToSnowflakeInexact(timestamp) { /** * @param {string} url - * @param {""|"?spoiler"} spoiler + * @param {boolean} spoiler */ -function getPublicUrlForCdn(url, spoiler="") { +function getPublicUrlForCdn(url, spoiler=false) { const match = url.match(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/) if (!match) return url const unsignedHash = hasher.h64(match[3]) // attachment ID 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 `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}${spoiler}` + const transformedUrl = new URL(`${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}`) + if (spoiler) transformedUrl.searchParams.set("spoiler", "") + return transformedUrl.toString() } /** diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index 550b669b..8b9c57da 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -21,7 +21,7 @@ const schema = { file_name: z.string().regex(/^[-A-Za-z0-9_.,]+$/) }), query: z.object({ - spoiler: z.optional(z.string().max(0)) // Lets you choose between no query param, and a ?spoiler query param (H3 considers it an empty string). Booleans do NOT work here, unfortunately. + spoiler: z.string().optional() }) } From 2849c7a051708e15ad605a1b2f7cb67cd9cb0bd4 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 20 Oct 2026 13:31:03 +0200 Subject: [PATCH 133/137] yesn't --- src/d2m/converters/message-to-event.test.js | 8 ++++---- src/discord/utils.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index dd2449f6..63c46115 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -366,9 +366,9 @@ test("message2event: spoiler attachment", async t => { $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler= (74 KB)", + body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler=yes (74 KB)", format: "org.matrix.custom.html", - formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler= (74 KB)" + formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler=yes (74 KB)" }]) }) @@ -1207,12 +1207,12 @@ test("message2event: multiple attachments are combined into the same event where msgtype: "m.text", body: "hey" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)" - + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler= (38 KB)" + + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler=yes (38 KB)" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", format: "org.matrix.custom.html", formatted_body: "hey" + `
📄 Uploaded file: hey.jpg (100 MB)` - + `
📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler= (38 KB)` + + `
📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler=yes (38 KB)` + `
📄 Uploaded file: hey.jpg (100 MB)` }, { $type: "m.room.message", diff --git a/src/discord/utils.js b/src/discord/utils.js index 40231d16..05ec3d11 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -145,7 +145,7 @@ function getPublicUrlForCdn(url, spoiler=false) { const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) const transformedUrl = new URL(`${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}`) - if (spoiler) transformedUrl.searchParams.set("spoiler", "") + if (spoiler) transformedUrl.searchParams.set("spoiler", "yes") return transformedUrl.toString() } From 74cf6a1d8956588184ba618cbfcc6ab83179064a Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 20 Oct 2026 13:33:47 +0200 Subject: [PATCH 134/137] shit --- src/web/routes/download-discord.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index 8b9c57da..aa03871a 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -54,7 +54,7 @@ function defineMediaProxyHandler(domain) { const routerParams = await getValidatedRouterParams(event, schema.router.parse) const queryParams = await getValidatedQuery(event, schema.query.parse) - if (queryParams.spoiler === "") webUtils.rejectUrlPreviewer(event) + if (queryParams.spoiler) webUtils.rejectUrlPreviewer(event) const unsignedHash = hasher.h64(routerParams.attachment_id) const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range From cfafac2480f2a8a8a3a64ad1a20ba6710d82211a Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 20 Oct 2026 14:12:51 +0200 Subject: [PATCH 135/137] fix format --- src/d2m/converters/message-to-event.test.js | 230 ++++++++++---------- 1 file changed, 113 insertions(+), 117 deletions(-) diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 63c46115..fe4801cc 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1,10 +1,10 @@ -const { test } = require("supertape") -const { messageToEvent } = require("./message-to-event") -const { MatrixServerError } = require("../../matrix/mreq") +const {test} = require("supertape") +const {messageToEvent} = require("./message-to-event") +const {MatrixServerError} = require("../../matrix/mreq") const data = require("../../../test/data") -const { mockGetEffectivePower } = require("../../matrix/utils.test") +const {mockGetEffectivePower} = require("../../matrix/utils.test") const Ty = require("../../types") -const { db } = require("../../passthrough") +const {db} = require("../../passthrough") /** * @param {string} roomID @@ -12,7 +12,7 @@ const { db } = require("../../passthrough") * @returns {(roomID: string, eventID: string) => Promise>} */ function mockGetEvent(t, roomID_in, eventID_in, outer) { - return async function (roomID, eventID) { + return async function(roomID, eventID) { t.equal(roomID, roomID_in) t.equal(eventID, eventID_in) return new Promise(resolve => { @@ -74,8 +74,8 @@ test("message2event: simple room mention", async t => { t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:matrix.org": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} } } } @@ -102,8 +102,8 @@ test("message2event: simple room link", async t => { t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:matrix.org": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} } } } @@ -130,8 +130,8 @@ test("message2event: nicked room mention", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:matrix.org": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} } } } @@ -214,12 +214,12 @@ test("message2event: timestamps", async t => { + "\n3 October 2026 at 9:30 am (UTC)" + "\n24 December 2026 at 11:00 pm (UTC)", format: "org.matrix.custom.html", - formatted_body: `4 Oct 2026, 9:33:33 am (UTC)` - + `
4 October 2026 at 9:33 am (UTC)` - + `
Sunday, 4 October 2026 at 9:34 am (UTC)` - + `
9:34:08 am (UTC)` - + `
3 October 2026 at 9:30 am (UTC)` - + `
24 December 2026 at 11:00 pm (UTC)`, + formatted_body: `` + + `
` + + `
` + + `
` + + `
` + + `
`, "m.mentions": {} }]) }) @@ -234,8 +234,8 @@ test("message2event: simple message link", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:super.invalid": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:super.invalid": {display_name: null, avatar_url: null} } } } @@ -270,8 +270,8 @@ test("message2event: message link that OOYE doesn't know about", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:matrix.org": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} } } } @@ -308,8 +308,8 @@ test("message2event: message timestamp failed to fetch", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:matrix.org": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} } } } @@ -505,7 +505,7 @@ test("message2event: simple reply to matrix user", async t => { }) test("message2event: simple reply to matrix user, reply fallbacks disabled", async t => { - const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, { includeReplyFallback: false }, { + const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {includeReplyFallback: false}, { api: { getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", { type: "m.room.message", @@ -605,17 +605,17 @@ test("message2event: voice message", async t => { const events = await messageToEvent(data.message.voice_message) t.deepEqual(events, [{ $type: "m.room.message", - body: "voice-message.ogg", - external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg", - filename: "voice-message.ogg", - info: { - duration: 3960, - mimetype: "audio/ogg", - size: 10584, + body: "voice-message.ogg", + external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg", + filename: "voice-message.ogg", + info: { + duration: 3960, + mimetype: "audio/ogg", + size: 10584, }, - "m.mentions": {}, - msgtype: "m.audio", - url: "mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB" + "m.mentions": {}, + msgtype: "m.audio", + url: "mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB" }]) }) @@ -628,16 +628,16 @@ test("message2event: misc file", async t => { "m.mentions": {} }, { $type: "m.room.message", - body: "the.yml", - external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml", - filename: "the.yml", + body: "the.yml", + external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml", + filename: "the.yml", info: { mimetype: "text/plain; charset=utf-8", size: 2274 }, - "m.mentions": {}, - msgtype: "m.file", - url: "mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP" + "m.mentions": {}, + msgtype: "m.file", + url: "mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP" }]) }) @@ -674,7 +674,7 @@ test("message2event: simple reply in thread to a matrix user's reply", async t = user_ids: ["@cadence:cadence.moe"] }, msgtype: "m.text", - body: "Well, they don't seem to..." + body: "Well, they don't seem to..." }]) }) @@ -711,7 +711,7 @@ test("message2event: infinidoge's reply to ami's matrix smalltext reply to infin user_ids: ["@ami:the-apothecary.club"] }, msgtype: "m.text", - body: `Most likely` + body: `Most likely` }]) }) @@ -748,7 +748,7 @@ test("message2event: infinidoge's reply to ami's matrix smalltext singleline rep user_ids: ["@ami:the-apothecary.club"] }, msgtype: "m.text", - body: `Most likely` + body: `Most likely` }]) }) @@ -757,11 +757,11 @@ test("message2event: reply to a Discord message that wasn't bridged", async t => t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.text", - body: `> In reply to a 1-day-old unbridged message:` + body: `> In reply to a 1-day-old unbridged message:` + `\n> Occimyy: BILLY BOB THE GREAT` + `\n\nenigmatic`, - format: "org.matrix.custom.html", - formatted_body: `
In reply to a 1-day-old unbridged message from Occimyy:
BILLY BOB THE GREAT
enigmatic`, + format: "org.matrix.custom.html", + formatted_body: `
In reply to a 1-day-old unbridged message from Occimyy:
BILLY BOB THE GREAT
enigmatic`, "m.mentions": {} }]) }) @@ -1151,12 +1151,10 @@ test("message2event: very large png is converted to jpeg for upload", async t => async fetch(url, init) { t.equal(url, "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg") t.equal(init.method, "HEAD") - return new Response("wa", { - headers: { - "Content-Type": "image/jpeg", - "Content-Length": 483085 - } - }) + return new Response("wa", {headers: { + "Content-Type": "image/jpeg", + "Content-Length": 483085 + }}) } }) t.deepEqual(events, [{ @@ -1334,7 +1332,7 @@ test("message2event: emoji triple long name", async t => { body: ":brillillillilliant_move::brillillillilliant_move::brillillillilliant_move:", format: "org.matrix.custom.html", formatted_body: - ':brillillillilliant_move:' + ':brillillillilliant_move:' + ':brillillillilliant_move:' + ':brillillillilliant_move:' }]) @@ -1448,8 +1446,8 @@ test("message2event: constructed forwarded message", async t => { async getJoinedMembers() { return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:matrix.org": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} } } } @@ -1507,8 +1505,8 @@ test("message2event: constructed forwarded text", async t => { async getJoinedMembers() { return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:matrix.org": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} } } } @@ -1552,10 +1550,10 @@ test("message2event: don't scan forwarded messages for mentions", async t => { }) test("message2event: invite no details embed if no event", async t => { - const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381190945646710824" }, {}, {}, { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { snow: { invite: { - getInvite: async () => ({ ...data.invite.irl, guild_scheduled_event: null }) + getInvite: async () => ({...data.invite.irl, guild_scheduled_event: null}) } } }) @@ -1572,7 +1570,7 @@ test("message2event: invite no details embed if no event", async t => { }) test("message2event: irl invite event renders embed", async t => { - const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381190945646710824" }, {}, {}, { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { snow: { invite: { getInvite: async () => data.invite.irl @@ -1605,7 +1603,7 @@ test("message2event: irl invite event renders embed", async t => { }) test("message2event: vc invite event renders embed", async t => { - const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381174024801095751" }, {}, {}, { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { snow: { invite: { getInvite: async () => data.invite.vc @@ -1639,12 +1637,12 @@ test("message2event: vc invite event renders embed", async t => { }) test("message2event: vc invite event renders embed with room link", async t => { - const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381174024801095751" }, {}, {}, { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { api: { getEffectivePower: mockGetEffectivePower(), getJoinedMembers: async () => ({ joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, } }) }, @@ -1681,7 +1679,7 @@ 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" }, {}, {}, { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { snow: { invite: { async getInvite() { @@ -1706,11 +1704,11 @@ test("message2event: channel links are converted even inside lists (parser post- let called = 0 const events = await messageToEvent({ content: "1. Don't be a dick" - + "\n2. Follow rule number 1" - + "\n3. Follow Discord TOS" - + "\n4. Do **not** post NSFW content, shock content, suggestive content" - + "\n5. Please keep <#176333891320283136> professional and helpful, no random off-topic joking" - + "\nThis list will probably change in the future" + + "\n2. Follow rule number 1" + + "\n3. Follow Discord TOS" + + "\n4. Do **not** post NSFW content, shock content, suggestive content" + + "\n5. Please keep <#176333891320283136> professional and helpful, no random off-topic joking" + + "\nThis list will probably change in the future" }, data.guild.general, {}, { api: { getEffectivePower: mockGetEffectivePower(), @@ -1732,11 +1730,11 @@ test("message2event: channel links are converted even inside lists (parser post- { $type: "m.room.message", body: "1. Don't be a dick" - + "\n2. Follow rule number 1" - + "\n3. Follow Discord TOS" - + "\n4. Do **not** post NSFW content, shock content, suggestive content" - + "\n5. Please keep #wonderland professional and helpful, no random off-topic joking" - + "\nThis list will probably change in the future", + + "\n2. Follow rule number 1" + + "\n3. Follow Discord TOS" + + "\n4. Do **not** post NSFW content, shock content, suggestive content" + + "\n5. Please keep #wonderland professional and helpful, no random off-topic joking" + + "\nThis list will probably change in the future", format: "org.matrix.custom.html", formatted_body: "
  1. Don't be a dick
  2. Follow rule number 1
  3. Follow Discord TOS
  4. Do not post NSFW content, shock content, suggestive content
  5. Please keep #wonderland professional and helpful, no random off-topic joking
This list will probably change in the future", "m.mentions": {}, @@ -1785,33 +1783,31 @@ test("message2event: cross-room reply", async t => { } }, content: "cross-room reply" - }, {}, {}, { - api: { - async getEvent(roomID, eventID) { - called++ - t.equal(roomID, "!mHmhQQPwXNananaOLD:cadence.moe") - t.equal(eventID, "$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY") - return { - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - "body": "image.png", - "info": { - "h": 738, - "mimetype": "image/png", - "org.matrix.msc4230.is_animated": false, - "size": 111189, - "w": 772, - "xyz.amorgan.blurhash": "L255Oa~qRPD$-pxuoJoLIUM{xuxu" - }, - "m.mentions": {}, - "msgtype": "m.image", - "url": "mxc://matrix.org/QbSujQjRLekzPknKlPsXbGDS" - } + }, {}, {}, {api: { + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!mHmhQQPwXNananaOLD:cadence.moe") + t.equal(eventID, "$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY") + return { + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + "body": "image.png", + "info": { + "h": 738, + "mimetype": "image/png", + "org.matrix.msc4230.is_animated": false, + "size": 111189, + "w": 772, + "xyz.amorgan.blurhash": "L255Oa~qRPD$-pxuoJoLIUM{xuxu" + }, + "m.mentions": {}, + "msgtype": "m.image", + "url": "mxc://matrix.org/QbSujQjRLekzPknKlPsXbGDS" } } } - }) + }}) t.deepEqual(events, [ { $type: "m.room.message", @@ -1915,15 +1911,15 @@ test("message2event: single-choice poll", async t => { kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. max_selections: 1, answers: [{ - id: "1", - "org.matrix.msc1767.text": "[\ud83d\udc4d] answer one" - }, { - id: "2", - "org.matrix.msc1767.text": "[\ud83d\udc4e] answer two" - }, { - id: "3", - "org.matrix.msc1767.text": "answer three" - }] + id: "1", + "org.matrix.msc1767.text": "[\ud83d\udc4d] answer one" + }, { + id: "2", + "org.matrix.msc1767.text": "[\ud83d\udc4e] answer two" + }, { + id: "3", + "org.matrix.msc1767.text": "answer three" + }] }, "org.matrix.msc1767.text": "only one answer allowed!\n1. [\ud83d\udc4d] answer one\n2. [\ud83d\udc4e] answer two\n3. answer three" }]) @@ -1942,15 +1938,15 @@ test("message2event: multiple-choice poll", async t => { kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. max_selections: 3, answers: [{ - id: "1", - "org.matrix.msc1767.text": "[😭] no" - }, { - id: "2", - "org.matrix.msc1767.text": "oh no" - }, { - id: "3", - "org.matrix.msc1767.text": "oh noooooo" - }] + id: "1", + "org.matrix.msc1767.text": "[😭] no" + }, { + id: "2", + "org.matrix.msc1767.text": "oh no" + }, { + id: "3", + "org.matrix.msc1767.text": "oh noooooo" + }] }, "org.matrix.msc1767.text": "more than one answer allowed\n1. [😭] no\n2. oh no\n3. oh noooooo" }]) From 45a105a3042757bb9a8c8ca488703be8f61712f4 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 20 Oct 2026 14:32:23 +0200 Subject: [PATCH 136/137] some renames --- src/web/routes/download-discord.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index aa03871a..6b9b4582 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -15,7 +15,7 @@ const {discord, as, select, sync} = require("../../passthrough") const webUtils = sync.require("../web-utils") const schema = { - router: z.object({ + params: z.object({ channel_id: z.string().regex(/^[0-9]+$/), attachment_id: z.string().regex(/^[0-9]+$/), file_name: z.string().regex(/^[-A-Za-z0-9_.,]+$/) @@ -51,12 +51,12 @@ function timeUntilExpiry(url) { */ function defineMediaProxyHandler(domain) { return defineEventHandler(async event => { - const routerParams = await getValidatedRouterParams(event, schema.router.parse) - const queryParams = await getValidatedQuery(event, schema.query.parse) + const params = await getValidatedRouterParams(event, schema.params.parse) + const query = await getValidatedQuery(event, schema.query.parse) - if (queryParams.spoiler) webUtils.rejectUrlPreviewer(event) + if (query.spoiler) webUtils.rejectUrlPreviewer(event) - const unsignedHash = hasher.h64(routerParams.attachment_id) + const unsignedHash = hasher.h64(params.attachment_id) const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range const row = select("media_proxy", "permitted_hash", {permitted_hash: signedHash}).get() @@ -67,7 +67,7 @@ function defineMediaProxyHandler(domain) { }) } - const url = `https://${domain}/attachments/${routerParams.channel_id}/${routerParams.attachment_id}/${routerParams.file_name}` + const url = `https://${domain}/attachments/${params.channel_id}/${params.attachment_id}/${params.file_name}` let promise = cache.get(url) /** @type {string | undefined} */ let refreshed From 11acba45e655d4209ed8035e866035299c31a64f Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 20 Oct 2026 14:32:34 +0200 Subject: [PATCH 137/137] stronger tests --- src/web/routes/download-discord.test.js | 33 +++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/web/routes/download-discord.test.js b/src/web/routes/download-discord.test.js index e08832cf..a1d65402 100644 --- a/src/web/routes/download-discord.test.js +++ b/src/web/routes/download-discord.test.js @@ -46,9 +46,9 @@ test("web download discord: works if a known attachment", async t => { t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) }) -test("web download discord: works with spoilers", async t => { +test("web download discord: works with spoilers for humans", async t => { const event = {} - await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name?spoiler", { + await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name?spoiler=yes", { params: { channel_id: "655216173696286746", attachment_id: "1314358913482621010", @@ -73,6 +73,35 @@ test("web download discord: works with spoilers", async t => { t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) }) +test("web download discord: rejects with spoilers for bots", async t => { + const event = {} + const [error] = await tryToCatch(() => router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name?spoiler=yes", { + params: { + channel_id: "655216173696286746", + attachment_id: "1314358913482621010", + file_name: "image.png" + }, + headers: { + "user-agent": "Synapse (bot; +https://github.com/matrix-org/synapse)" + }, + event, + snow: { + channel: { + async refreshAttachmentURLs(attachments) { + assert(Array.isArray(attachments)) + return { + refreshed_urls: attachments.map(a => ({ + original: a, + refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` + })) + } + } + } + } + })) + t.equal(error.data, "Sorry URL previewer, you can't have this URL.") +}) + test("web download discord: uses cache", async t => { let notCalled = true const event = {}