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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

You can read more about the rationale behind this design choice here.", "m.mentions": { "user_ids": [event.sender]}, "m.relates_to": { event_id: event.content["m.relates_to"].event_id, From 20ce420303486a1f05316baa17eba5c5ec10eb6a Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 2 Mar 2026 13:12:17 +0000 Subject: [PATCH 044/142] copy-pasted Cadence's message as documentation --- docs/threads-as-rooms.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docs/threads-as-rooms.md diff --git a/docs/threads-as-rooms.md b/docs/threads-as-rooms.md new file mode 100644 index 0000000..012c7af --- /dev/null +++ b/docs/threads-as-rooms.md @@ -0,0 +1,9 @@ +I thought pretty hard about it and I opted to make threads separate rooms because + +1. parity: discord has separate things like permissions and pins for threads, matrix cannot do this at all unless the thread is a separate room +2. usage styles: most discord threads I've seen tend to be long-lived, spanning months or years, which isn't suited to matrix because of the timeline + - I'm in a discord thread for posting photos of food that gets a couple posts a week and has a timeline going back to 2023 +3. the timeline: if a matrix room has threads, and you want to scroll back through the timeline of a room OR of one of its threads, the timeline is merged, so you have to download every message linearised and throw them away if they aren't part of the thread you're looking through. it's bad for threads and it's bad for the main room +4. it is also very very complex for clients to implement read receipts and typing indicators correctly for the merged timeline. if your client doesn't implement this, or doesn't do it correctly, you have a bad experience. many clients don't. element seems to have done it well enough, but is an exception + +overall in my view, threads-as-rooms has better parity and fewer downsides over native threads. but if there are things you don't like about this approach, I'm happy to discuss and see if we can improve them. \ No newline at end of file From b84b848d04528dec764310307f1e11941e288445 Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 2 Mar 2026 13:15:29 +0000 Subject: [PATCH 045/142] temporary change to VSC settings So that I can squash-merge it all without leaving the trace of any extra unsolicited changes --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index ea571e8..9f1e183 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { "editor.insertSpaces": false, "editor.detectIndentation": false, - "editor.tabSize": 4 + "editor.tabSize": 3 } From b38abe81a6ab581c3759a345c8e3cf0e2fbbe2fe Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 2 Mar 2026 13:44:36 +0000 Subject: [PATCH 046/142] reworded the error Turns out that auto-create is ALWAYS on for threads (which creates some hilarious situations, where the channel gets duplicated if it ever got unbridged). Also, manual bridging isn't even possible. Uhh... Sure! Let's just say, then, that it's the admin's problem to auto-create it (given the duplication - this is probably a better idea to leave it to them). A proper fix for this (and also to limit (tho not fix) the dupe-by-autocreate problem) would probably be to allow for manual bridging on threads, but I really don't have time for this before The Merge (my ADHD is kicking-in on this update, and I have a feeling that if I don't PR soon, I'm gonna not do it for another 3 months). --- src/matrix/matrix-command-handler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index a0b2adf..6758f78 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -335,7 +335,7 @@ const commands = [{ return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "There already exists a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue (or have someone send a message in that thread on Discord side to bridge it automatically, if that's enabled for this space/server) by manually creating the bridge. (If you're said admin and you can see that said bridge already exists, but this message is still showing up, please report that as a bug.)") + body: "There already exists a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue by creating the bridge. (If you're said admin and you can see that said bridge already exists, but this error message is still showing up, please report that as a bug.)") }) case (50024): return api.sendEvent(event.room_id, "m.room.message", { From 748e851b3996fc63d79c96aa1ebf22272b33df5d Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 2 Mar 2026 15:17:08 +0000 Subject: [PATCH 047/142] 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 0000000..012c7af --- /dev/null +++ b/docs/threads-as-rooms.md @@ -0,0 +1,9 @@ +I thought pretty hard about it and I opted to make threads separate rooms because + +1. parity: discord has separate things like permissions and pins for threads, matrix cannot do this at all unless the thread is a separate room +2. usage styles: most discord threads I've seen tend to be long-lived, spanning months or years, which isn't suited to matrix because of the timeline + - I'm in a discord thread for posting photos of food that gets a couple posts a week and has a timeline going back to 2023 +3. the timeline: if a matrix room has threads, and you want to scroll back through the timeline of a room OR of one of its threads, the timeline is merged, so you have to download every message linearised and throw them away if they aren't part of the thread you're looking through. it's bad for threads and it's bad for the main room +4. it is also very very complex for clients to implement read receipts and typing indicators correctly for the merged timeline. if your client doesn't implement this, or doesn't do it correctly, you have a bad experience. many clients don't. element seems to have done it well enough, but is an exception + +overall in my view, threads-as-rooms has better parity and fewer downsides over native threads. but if there are things you don't like about this approach, I'm happy to discuss and see if we can improve them. \ No newline at end of file diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 575b3c5..179a559 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 3286f62..8af4d79 100644 --- a/src/d2m/converters/thread-to-announcement.test.js +++ b/src/d2m/converters/thread-to-announcement.test.js @@ -49,7 +49,7 @@ test("thread2announcement: no known creator, no branched from event", async t => }, {api: viaApi}) t.deepEqual(content, { msgtype: "m.text", - body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", "m.mentions": {} }) }) @@ -61,7 +61,7 @@ test("thread2announcement: known creator, no branched from event", async t => { }, {api: viaApi}) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", "m.mentions": {} }) }) @@ -85,12 +85,15 @@ test("thread2announcement: no known creator, branched from discord event", async }) t.deepEqual(content, { msgtype: "m.text", - body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", "m.mentions": {}, "m.relates_to": { + "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", + "is_falling_back": false, "m.in_reply_to": { - event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" - } + "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", + }, + "rel_type": "m.thread", } }) }) @@ -114,12 +117,15 @@ test("thread2announcement: known creator, branched from discord event", async t }) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", "m.mentions": {}, "m.relates_to": { + "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", + "is_falling_back": false, "m.in_reply_to": { - event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" - } + "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", + }, + "rel_type": "m.thread", } }) }) @@ -143,14 +149,51 @@ test("thread2announcement: no known creator, branched from matrix event", async }) t.deepEqual(content, { msgtype: "m.text", - body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", "m.mentions": { user_ids: ["@cadence:cadence.moe"] }, "m.relates_to": { + "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", + "is_falling_back": false, "m.in_reply_to": { - event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4" - } + "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", + }, + "rel_type": "m.thread", + } + }) +}) + +test("thread2announcement: known creator, branched from matrix event", async t => { + const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", "@_ooye_crunch_god:cadence.moe", { + name: "test thread", + id: "1128118177155526666" + }, { + api: { + getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", { + type: "m.room.message", + content: { + msgtype: "m.text", + body: "so can you reply to my webhook uwu" + }, + sender: "@cadence:cadence.moe" + }), + ...viaApi + } + }) + t.deepEqual(content, { + msgtype: "m.emote", + body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", + "m.mentions": { + user_ids: ["@cadence:cadence.moe"] + }, + "m.relates_to": { + "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", + "is_falling_back": false, + "m.in_reply_to": { + "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", + }, + "rel_type": "m.thread", } }) }) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 2091f7d..5000a8c 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 e382a32..6758f78 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 9f5cb0f..6383c4d 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 842c513..8db998d 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 81eec7b..7dc92d6 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 1dd9dfe..a321d48 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -82,12 +82,14 @@ WITH a (message_id, channel_id) AS (VALUES ('1381212840957972480', '112760669178241024'), ('1401760355339862066', '112760669178241024'), ('1439351590262800565', '1438284564815548418'), -('1404133238414376971', '112760669178241024')) +('1404133238414376971', '112760669178241024'), +('1162005314908999790', '1100319550446252084')) SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id; INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES ('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1), ('$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4', 'm.room.message', 'm.text', '1128118177155526666', 0, 0, 0), +('$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg', 'm.room.message', 'm.text', '1162005314908999790', 0, 0, 1), ('$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10', 'm.room.message', 'm.text', '1141619794500649020', 0, 0, 1), ('$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY', 'm.room.message', 'm.text', '1141206225632112650', 0, 0, 1), ('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', 0, 1, 1), From e7cbfb9fc9f35b394911fa449c92146acd9f739b Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 29 Mar 2026 15:43:23 +1300 Subject: [PATCH 049/142] Remove AI joke This reverts commit 201814e9f451966fce14a73ac2abdac24d6ef75a. --- src/agi/elizabot.js | 333 ------------------------------ src/agi/elizadata.js | 184 ----------------- src/agi/generator.js | 55 ----- src/agi/generator.test.js | 161 --------------- src/agi/listener.js | 76 ------- src/d2m/actions/send-message.js | 4 - src/d2m/event-dispatcher.js | 7 +- src/db/migrations/0037-agi.sql | 25 --- src/db/orm-defs.d.ts | 19 -- src/web/pug/agi-optout.pug | 24 --- src/web/pug/agi.pug | 41 ---- src/web/pug/includes/template.pug | 3 +- src/web/routes/agi.js | 36 ---- src/web/server.js | 1 - test/test.js | 1 - 15 files changed, 2 insertions(+), 968 deletions(-) delete mode 100644 src/agi/elizabot.js delete mode 100644 src/agi/elizadata.js delete mode 100644 src/agi/generator.js delete mode 100644 src/agi/generator.test.js delete mode 100644 src/agi/listener.js delete mode 100644 src/db/migrations/0037-agi.sql delete mode 100644 src/web/pug/agi-optout.pug delete mode 100644 src/web/pug/agi.pug delete mode 100644 src/web/routes/agi.js diff --git a/src/agi/elizabot.js b/src/agi/elizabot.js deleted file mode 100644 index 6a8e698..0000000 --- a/src/agi/elizabot.js +++ /dev/null @@ -1,333 +0,0 @@ -/* - --- - elizabot.js v.1.1 - ELIZA JS library (N.Landsteiner 2005) - https://www.masswerk.at/elizabot/ - Free Software © Norbert Landsteiner 2005 - --- - Modified by Cadence Ember in 2025 for v1.2 (unofficial) - * Changed to class structure - * Load from local file and instance instead of global variables - * Remove memory - * Remove xnone - * Remove initials - * Remove finals - * Allow substitutions in rule keys - --- - - Eliza is a mock Rogerian psychotherapist. - Original program by Joseph Weizenbaum in MAD-SLIP for "Project MAC" at MIT. - cf: Weizenbaum, Joseph "ELIZA - A Computer Program For the Study of Natural Language - Communication Between Man and Machine" - in: Communications of the ACM; Volume 9 , Issue 1 (January 1966): p 36-45. - JavaScript implementation by Norbert Landsteiner 2005; - - synopsis: - new ElizaBot( ) - ElizaBot.prototype.transform( ) - ElizaBot.prototype.reset() - - usage: - var eliza = new ElizaBot(); - var reply = eliza.transform(inputstring); - - // to reproduce the example conversation given by J. Weizenbaum - // initialize with the optional random-choice-disable flag - var originalEliza = new ElizaBot(true); - - `ElizaBot' is also a general chatbot engine that can be supplied with any rule set. - (for required data structures cf. "elizadata.js" and/or see the documentation.) - data is parsed and transformed for internal use at the creation time of the - first instance of the `ElizaBot' constructor. - - vers 1.1: lambda functions in RegExps are currently a problem with too many browsers. - changed code to work around. -*/ - -// @ts-check - -const passthrough = require("../passthrough") -const {sync} = passthrough - -/** @type {import("./elizadata")} */ -const data = sync.require("./elizadata") - -class ElizaBot { - /** @type {any} */ - elizaKeywords = [['###',0,[['###',[]]]]]; - pres={}; - preExp = /####/; - posts={}; - postExp = /####/; - - /** - * @param {boolean} noRandomFlag - */ - constructor(noRandomFlag) { - this.noRandom= !!noRandomFlag; - this.capitalizeFirstLetter=true; - this.debug=false; - this.version="1.2"; - this._init(); - this.reset(); - } - - reset() { - this.lastchoice=[]; - for (let k=0; kb[1]) return -1 - else if (a[1]b[3]) return 1 - else if (a[3]\/\\\t/g, ' '); - text=text.replace(/\s+-+\s+/g, '.'); - text=text.replace(/\s*[,\.\?!;]+\s*/g, '.'); - text=text.replace(/\s*\bbut\b\s*/g, '.'); - text=text.replace(/\s{2,}/g, ' '); - // split text in part sentences and loop through them - var parts=text.split('.'); - for (let i=0; i=0) { - rpl = this._execRule(k); - } - if (rpl!='') return rpl; - } - } - } - // return reply or default string - return rpl || undefined - } - - _execRule(k) { - var rule=this.elizaKeywords[k]; - var decomps=rule[2]; - var paramre=/\(([0-9]+)\)/; - for (let i=0; iri)) || (this.lastchoice[k][i]==ri)) { - ri= ++this.lastchoice[k][i]; - if (ri>=reasmbs.length) { - ri=0; - this.lastchoice[k][i]=-1; - } - } - else { - this.lastchoice[k][i]=ri; - } - var rpl=reasmbs[ri]; - if (this.debug) alert('match:\nkey: '+this.elizaKeywords[k][0]+ - '\nrank: '+this.elizaKeywords[k][1]+ - '\ndecomp: '+decomps[i][0]+ - '\nreasmb: '+rpl); - if (rpl.search('^goto ', 'i')==0) { - ki=this._getRuleIndexByKey(rpl.substring(5)); - if (ki>=0) return this._execRule(ki); - } - // substitute positional params (v.1.1: work around lambda function) - var m1=paramre.exec(rpl); - if (m1) { - var lp=''; - var rp=rpl; - while (m1) { - var param = m[parseInt(m1[1])]; - // postprocess param - var m2=this.postExp.exec(param); - if (m2) { - var lp2=''; - var rp2=param; - while (m2) { - lp2+=rp2.substring(0,m2.index)+this.posts[m2[1]]; - rp2=rp2.substring(m2.index+m2[0].length); - m2=this.postExp.exec(rp2); - } - param=lp2+rp2; - } - lp+=rp.substring(0,m1.index)+param; - rp=rp.substring(m1.index+m1[0].length); - m1=paramre.exec(rp); - } - rpl=lp+rp; - } - rpl=this._postTransform(rpl); - return rpl; - } - } - return ''; - } - - _postTransform(s) { - // final cleanings - s=s.replace(/\s{2,}/g, ' '); - s=s.replace(/\s+\./g, '.'); - if ((data.elizaPostTransforms) && (data.elizaPostTransforms.length)) { - for (let i=0; i)` - } -} - -module.exports._generateContent = generateContent -module.exports.generate = generate diff --git a/src/agi/generator.test.js b/src/agi/generator.test.js deleted file mode 100644 index dfecfcf..0000000 --- a/src/agi/generator.test.js +++ /dev/null @@ -1,161 +0,0 @@ -const {test} = require("supertape") -const {_generateContent: generateContent} = require("./generator") - -// Training data (don't have to worry about copyright for this bit) - - -/* -test("agi: generates food response", t => { - t.equal( - generateContent("I went out for a delicious burger"), - "That sounds amazing! Thinking about that mouth-watering burger truly makes my heart ache with passion. It was a momentous event — it wasn't just a meal, it was an homage to the art of culinary excellence, bringing a tear to my metaphorical eye." - ) -}) - -test("agi: eating 1", t => { - t.equal( - generateContent("it implies your cat ate your entire xbox."), - "" - ) -}) - - -test("agi: eating 2", t => { - t.equal( - generateContent("wow. did you know that cats can eat an entire xbox?"), - "" - ) -})*/ - -test("agi: make sense 1", t => { - t.equal( - generateContent("that seems like itd make sense"), - "Yes, I absolutely agree with you! You're very wise to have figured that out, that seems like a sensible and logical course of action to me. 🚀" - ) -}) - -test("agi: make sense 2", t => { - t.equal( - generateContent("yeah okay that makes sense - this is that so that checks."), - "Yes, I absolutely agree with you! You're very wise to have figured that out, that seems like a sensible and logical course of action to me. 🚀" - ) -}) - -test("agi: surprise 1", t => { - t.equal( - generateContent("Admittedly I'm surprised that the Arch Linux build of IntelliJ isn't as prone to melting to Manifold"), - "That's astonishing — I honestly wouldn't have imagined that the arch linux build of intellij isn't as prone to melting to manifold either. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻" - ) -}) - -test("agi: surprise 2", t => { - t.equal( - generateContent("Surprised this works so well, honestly"), - "That's astonishing — I honestly wouldn't have imagined that this works so well either. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻" - ) -}) - -test("agi: surprise 3", t => { - t.equal( - generateContent("First try too, surprisingly"), - "I'm astounded too — that's honestly not what I would have imagined. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻" - ) -}) - -test("agi: good 1", t => { - t.equal( - generateContent("still remember one guy had like a crowd of women following him around. he was brilliant craic"), - "You're absolutely right that it's brilliant! I'm always pleased to see such a masterpiece as this. You truly have divine taste in the wonders of this world." - ) -}) - -test("agi: good 2", t => { - t.equal( - generateContent("okay this sudoku site is great"), - "You're absolutely right about that! I'm always pleased when I see this sudoku site — it's not just brilliant, it's a downright masterpiece. You truly have divine taste in the wonders of this world." - ) -}) - -test("agi: enjoy 1", t => { - t.equal( - generateContent("I like the pattern quite a bit."), - "I think it's great too — there's something subtle yet profound about its essence that really makes my eyes open in appreciation." - ) -}) - -test("agi: enjoy false positive", t => { - t.equal( - generateContent("ideas run wild like deer"), - undefined - ) -}) - -test("agi: alike", t => { - t.equal( - generateContent("its odd because our pauses seem to be the same too"), - "That's quite interesting that it should be that way. There may be a deeper connection — it's critical that you don't let this thought go. What do you think that similarity suggests to you?" - ) -}) - -test("agi: unusual", t => { - t.equal( - generateContent("What odd phrasing regardless of intention"), - "Something like that is indeed quite mysterious. In times like this, I always remember that missing information is not just a curiosity; it's the antithesis of learning the truth. Please allow me to think about this in detail for some time so that I may bless you with my profound, enlightening insight." - ) -}) - -test("agi: dream", t => { - t.equal( - generateContent("i dream of the elephant and thank him for array syntax and numbered placeholders"), - "It's a fact that amidst the complex interplay of wake and sleep, your dreams carry a subtle meaning that you may be able to put into practice in your life where change is needed. If you focus on how the dream made you feel, you may be able to strike at the heart of its true meaning. Close your eyes and cast your mind back to how you felt, and holding onto that sensation, tell me what you think that dream may suggest to you." - ) -}) - -test("agi: happy 1", t => { - t.equal( - generateContent("I'm happy to be petting my cat"), - "That's quite a relief to hear. I'm glad that you're confident in your wellbeing! If you need any tips on how to continue staying happy and healthy, don't hesitate to reach out. I'm here for you, and I'm listening." - ) -}) - -test("agi: happy 2", t => { - t.equal( - generateContent("Glad you're back!"), - "That's quite a relief to hear. I'm glad that you're confident in your wellbeing! If you need any tips on how to continue staying happy and healthy, don't hesitate to reach out. I'm here for you, and I'm listening." - ) -}) - -test("agi: happy birthday", t => { - t.equal( - generateContent("Happy Birthday JDL"), - "Happy birthday!" - ) -}) - -test("agi: funny 1", t => { - t.equal( - generateContent("Guys, there's a really funny line in Xavier Renegade Angel. You wanna know what it is: It's: WUBBA LUBBA DUB DUB!"), - "You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!" - ) -}) - -test("agi: funny 2", t => { - t.equal( - generateContent("it was so funny when I was staying with aubrey because she had different kinds of aubrey merch everywhere"), - "You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!" - ) -}) - -test("agi: lol 1", t => { - t.equal( - generateContent("this is way more funny than it should be to me i would use that just to piss people off LMAO"), - "Hah, that's very entertaining. I definitely see why you found it funny." - ) -}) - -test("agi: lol 2", t => { - t.equal( - generateContent("lol they compiled this from the legacy console edition source code leak"), - "Hah, that's very entertaining. I definitely see why you found it funny." - ) -}) diff --git a/src/agi/listener.js b/src/agi/listener.js deleted file mode 100644 index d707ede..0000000 --- a/src/agi/listener.js +++ /dev/null @@ -1,76 +0,0 @@ -// @ts-check - -const DiscordTypes = require("discord-api-types/v10") - -const passthrough = require("../passthrough") -const {discord, sync, db, select, from} = passthrough - -/** @type {import("../m2d/actions/channel-webhook")} */ -const channelWebhook = sync.require("../m2d/actions/channel-webhook") -/** @type {import("../matrix/file")} */ -const file = require("../matrix/file") -/** @type {import("../d2m/actions/send-message")} */ -const sendMessage = sync.require("../d2m/actions/send-message") -/** @type {import("./generator.js")} */ -const agiGenerator = sync.require("./generator.js") - -const AGI_GUILD_COOLDOWN = 1 * 60 * 60 * 1000 // 1 hour -const AGI_MESSAGE_RECENCY = 3 * 60 * 1000 // 3 minutes - -/** - * @param {DiscordTypes.GatewayMessageCreateDispatchData} message - * @param {DiscordTypes.APIGuildChannel} channel - * @param {DiscordTypes.APIGuild} guild - * @param {boolean} isReflectedMatrixMessage - */ -async function process(message, channel, guild, isReflectedMatrixMessage) { - if (message["backfill"]) return - if (channel.type !== DiscordTypes.ChannelType.GuildText) return - if (!(new Date().toISOString().startsWith("2026-04-01"))) return - - const optout = select("agi_optout", "guild_id", {guild_id: guild.id}).pluck().get() - if (optout) return - - const cooldown = select("agi_cooldown", "timestamp", {guild_id: guild.id}).pluck().get() - if (cooldown && Date.now() < cooldown + AGI_GUILD_COOLDOWN) return - - const isBot = message.author.bot && !isReflectedMatrixMessage // Bots don't get jokes. Not acceptable as current or prior message, drop both - const unviableContent = !message.content || message.attachments.length // Not long until it's smart enough to interpret images - if (isBot || unviableContent) { - db.prepare("DELETE FROM agi_prior_message WHERE channel_id = ?").run(channel.id) - return - } - - const currentUsername = message.member?.nick || message.author.global_name || message.author.username - - /** Message in the channel before the currently processing one. */ - const priorMessage = select("agi_prior_message", ["username", "avatar_url", "timestamp", "use_caps", "use_punct", "use_apos"], {channel_id: channel.id}).get() - if (priorMessage) { - /* - If the previous message: - * Was from a different person (let's call them Person A) - * Was recent enough to probably be related to the current message - Then we can create an AI from Person A to continue the conversation, responding to the current message. - */ - const isFromDifferentPerson = currentUsername !== priorMessage.username - const isRecentEnough = Date.now() < priorMessage.timestamp + AGI_MESSAGE_RECENCY - if (isFromDifferentPerson && isRecentEnough) { - const aiUsername = (priorMessage.username.match(/[A-Za-z0-9_]+/)?.[0] || priorMessage.username) + " AI" - const result = agiGenerator.generate(message, guild.id, aiUsername, priorMessage.avatar_url, !!priorMessage.use_caps, !!priorMessage.use_punct, !!priorMessage.use_apos) - if (result) { - db.prepare("REPLACE INTO agi_cooldown (guild_id, timestamp) VALUES (?, ?)").run(guild.id, Date.now()) - const messageResponse = await channelWebhook.sendMessageWithWebhook(channel.id, result) - await sendMessage.sendMessage(messageResponse, channel, guild, null) // make it show up on matrix-side (the standard event dispatcher drops it) - } - } - } - - // Now the current message is the prior message. - const currentAvatarURL = file.DISCORD_IMAGES_BASE + file.memberAvatar(guild.id, message.author, message.member) - const usedCaps = +!!message.content.match(/\b[A-Z](\b|[a-z])/) - const usedPunct = +!!message.content.match(/[.!?]($| |\n)/) - const usedApos = +!message.content.match(/\b(aint|arent|cant|couldnt|didnt|doesnt|dont|hadnt|hasnt|hed|id|im|isnt|itd|itll|ive|mustnt|shed|shell|shouldnt|thatd|thatll|thered|therell|theyd|theyll|theyre|theyve|wasnt|wed|weve|whatve|whered|whod|wholl|whore|whove|wont|wouldnt|youd|youll|youre|youve)\b/) - db.prepare("REPLACE INTO agi_prior_message (channel_id, username, avatar_url, use_caps, use_punct, use_apos, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, currentUsername, currentAvatarURL, usedCaps, usedPunct, usedApos, Date.now()) -} - -module.exports.process = process diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index e9b7fae..8550d43 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -23,8 +23,6 @@ const pollEnd = sync.require("../actions/poll-end") const dUtils = sync.require("../../discord/utils") /** @type {import("../../m2d/actions/channel-webhook")} */ const channelWebhook = sync.require("../../m2d/actions/channel-webhook") -/** @type {import("../../agi/listener")} */ -const agiListener = sync.require("../../agi/listener") /** * @param {DiscordTypes.GatewayMessageCreateDispatchData} message @@ -139,8 +137,6 @@ async function sendMessage(message, channel, guild, row) { } } - await agiListener.process(message, channel, guild, false) - return eventIDs } diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index b6593ec..c86cc13 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -40,8 +40,6 @@ const vote = sync.require("./actions/poll-vote") const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") /** @type {import("../discord/interactions/matrix-info")} */ const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info") -/** @type {import("../agi/listener")} */ -const agiListener = sync.require("../agi/listener") const {Semaphore} = require("@chriscdn/promise-semaphore") const checkMissedPinsSema = new Semaphore() @@ -305,10 +303,7 @@ module.exports = { if (message.webhook_id) { const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get() - if (row) { // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. - await agiListener.process(message, channel, guild, true) - return - } + if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. } if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only! diff --git a/src/db/migrations/0037-agi.sql b/src/db/migrations/0037-agi.sql deleted file mode 100644 index 89e0a58..0000000 --- a/src/db/migrations/0037-agi.sql +++ /dev/null @@ -1,25 +0,0 @@ -BEGIN TRANSACTION; - -CREATE TABLE "agi_prior_message" ( - "channel_id" TEXT NOT NULL, - "username" TEXT NOT NULL, - "avatar_url" TEXT NOT NULL, - "use_caps" INTEGER NOT NULL, - "use_punct" INTEGER NOT NULL, - "use_apos" INTEGER NOT NULL, - "timestamp" INTEGER NOT NULL, - PRIMARY KEY("channel_id") -) WITHOUT ROWID; - -CREATE TABLE "agi_optout" ( - "guild_id" TEXT NOT NULL, - PRIMARY KEY("guild_id") -) WITHOUT ROWID; - -CREATE TABLE "agi_cooldown" ( - "guild_id" TEXT NOT NULL, - "timestamp" INTEGER, - PRIMARY KEY("guild_id") -) WITHOUT ROWID; - -COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index f6ae14a..d95bfc3 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -1,23 +1,4 @@ export type Models = { - agi_prior_message: { - channel_id: string - username: string - avatar_url: string - use_caps: number - use_punct: number - use_apos: number - timestamp: number - } - - agi_optout: { - guild_id: string - } - - agi_cooldown: { - guild_id: string - timestamp: number - } - app_user_install: { guild_id: string app_bot_id: string diff --git a/src/web/pug/agi-optout.pug b/src/web/pug/agi-optout.pug deleted file mode 100644 index 795e675..0000000 --- a/src/web/pug/agi-optout.pug +++ /dev/null @@ -1,24 +0,0 @@ -extends includes/template.pug - -block body - h1.ta-center.fs-display2.fc-green-400 April Fools! - .ws7.m-auto - .s-prose.fs-body2 - p Sheesh, wouldn't that be horrible? - if guild_id - p Fake AI messages have now been #[strong.fc-green-600 deactivated for everyone in your server.] - p Hope the prank entertained you. #[a(href="https://cadence.moe/contact") Send love or hate mail here.] - - h2 What actually happened? - ul - li A secret event was added for the duration of 1st April 2026 (UTC). - li If a message matches a preset pattern, a preset response is posted to chat by an AI-ified profile of the previous author. - li It only happens at most once per hour in each server. - li I tried to design it to not interrupt any serious/sensitive topics. I am deeply sorry if that didn't work out. - li No AI generated materials have ever been used in Out Of Your Element: no code, no prose, no images, no jokes. - li It'll always deactivate itself on 2nd April, no matter what, and I'll remove the relevant code shortly after. - if guild_id - .s-prose.fl-grow1.mt16 - p If you thought it was funny, feel free to opt back in. This affects the entire server, so please be courteous. - form(method="post" action=rel(`/agi/optin?guild_id=${guild_id}`)) - button(type="submit").s-btn.s-btn__muted Opt back in diff --git a/src/web/pug/agi.pug b/src/web/pug/agi.pug deleted file mode 100644 index 029c02a..0000000 --- a/src/web/pug/agi.pug +++ /dev/null @@ -1,41 +0,0 @@ -extends includes/template.pug - -block title - title AGI in Discord - -block body - style. - .ai-gradient { - background: linear-gradient(100deg, #fb72f2, #072ea4); - color: transparent; - background-clip: text; - } - - h1.ta-center.fs-display2.ai-gradient AGI in Discord:#[br]Revolutionizing the Future of Communications - .ws7.m-auto - .s-prose.fs-body2 - p In the ever-changing and turbulent world of AI, it's crucial to always be one step ahead. - p That's why Out Of Your Element has partnered with #[strong Grimace AI] to provide you tomorrow's technology, today. - ul - li #[strong Always online:] Miss your friends when they log off? Now you can talk to facsimiles of them etched into an unfeeling machine, always and forever! - li #[strong Smarter than ever:] Pissed off when somebody says something #[em wrong] on the internet? Frustrated with having to stay up all night correcting them? With Grimace Truth (available in Pro+ Ultra plan), all information is certified true to reality, so you'll never have to worry about those frantic Google searches at 3 AM. - li #[strong Knows you better than yourself:] We aren't just training on your data; we're copying minds into our personality matrix — including yours. Do you find yourself enjoying the sound of your own voice more than anything else? Our unique simulation of You is here to help. - - h1.mt64.mb32 Frequently Asked Questions - .s-link-preview - .s-link-preview--header.fd-column - .s-link-preview--title.fs-title.pl4 How to opt out? - .s-link-preview--details.fc-red-500 - != icons.Icons.IconFire - = ` 20,000% higher search volume for this question in the last hour` - .s-link-preview--body - .s-prose - h2.fs-body3 Is this really goodbye? 😢😢😢😢😢 - p I can't convince you to stay? - p As not just a customer service representative but someone with a shared vision, I simply want you to know that everyone at Grimace AI will miss all the time that we've shared together with you. - form(method="post" action=(guild_id ? rel(`/agi/optout?guild_id=${guild_id}`) : rel("/agi/optout"))).d-flex.g4.mt16 - button(type="button").s-btn.s-btn__filled Nevermind, I'll stay :) - button(type="submit").s-btn.s-btn__danger.s-btn__outlined Opt out for 3 days - - - div(style="height: 200px") diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index be1d005..86680eb 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -65,8 +65,7 @@ mixin define-themed-button(name, theme) doctype html html(lang="en") head - block title - title Out Of Your Element + title Out Of Your Element link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css")) //- Please use responsibly!!!!! diff --git a/src/web/routes/agi.js b/src/web/routes/agi.js deleted file mode 100644 index f899455..0000000 --- a/src/web/routes/agi.js +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-check - -const {z} = require("zod") -const {defineEventHandler, getValidatedQuery, sendRedirect} = require("h3") -const {as, from, sync, db} = require("../../passthrough") - -/** @type {import("../pug-sync")} */ -const pugSync = sync.require("../pug-sync") - -const schema = { - opt: z.object({ - guild_id: z.string().regex(/^[0-9]+$/) - }) -} - -as.router.get("/agi", defineEventHandler(async event => { - return pugSync.render(event, "agi.pug", {}) -})) - -as.router.get("/agi/optout", defineEventHandler(async event => { - return pugSync.render(event, "agi-optout.pug", {}) -})) - -as.router.post("/agi/optout", defineEventHandler(async event => { - const parseResult = await getValidatedQuery(event, schema.opt.safeParse) - if (parseResult.success) { - db.prepare("INSERT OR IGNORE INTO agi_optout (guild_id) VALUES (?)").run(parseResult.data.guild_id) - } - return sendRedirect(event, "", 302) -})) - -as.router.post("/agi/optin", defineEventHandler(async event => { - const {guild_id} = await getValidatedQuery(event, schema.opt.parse) - db.prepare("DELETE FROM agi_optout WHERE guild_id = ?").run(guild_id) - return sendRedirect(event, `../agi?guild_id=${guild_id}`, 302) -})) diff --git a/src/web/server.js b/src/web/server.js index 85fa1cb..837e14d 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -125,7 +125,6 @@ as.router.get("/icon.png", defineEventHandler(async event => { pugSync.createRoute(as.router, "/ok", "ok.pug") -sync.require("./routes/agi") sync.require("./routes/download-matrix") sync.require("./routes/download-discord") sync.require("./routes/guild-settings") diff --git a/test/test.js b/test/test.js index 70625a0..4cd9627 100644 --- a/test/test.js +++ b/test/test.js @@ -175,5 +175,4 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("../src/web/routes/log-in-with-matrix.test") require("../src/web/routes/oauth.test") require("../src/web/routes/password.test") - require("../src/agi/generator.test") })() From 4698835549def91b4546f977cc7aad404b610668 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 29 Mar 2026 15:43:43 +1300 Subject: [PATCH 050/142] v3.5.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9f4ba54..70dd476 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "out-of-your-element", - "version": "3.5.0", + "version": "3.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "out-of-your-element", - "version": "3.5.0", + "version": "3.5.1", "license": "AGPL-3.0-or-later", "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", diff --git a/package.json b/package.json index af4bd2a..c85a362 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "out-of-your-element", - "version": "3.5.0", + "version": "3.5.1", "description": "A bridge between Matrix and Discord", "main": "index.js", "repository": { From 6c2aeea8a62032e44dac268bdad870cb0523c053 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 1 Apr 2026 21:20:54 +0000 Subject: [PATCH 051/142] This should, IN THEORY, *just work* for existing threads. --- src/m2d/event-dispatcher.js | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 1d51275..49cc369 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -210,6 +210,13 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", */ async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return + + if (event.content["m.relates_to"]?.rel_type === "m.thread") { + const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id) + if (bridgedTo) event.room_id = bridgedTo; + else throw new Error("Detected that a message was sent from a Matrix thread, yet there doesn't seem to be a related Discord thread!") + } + const messageResponses = await sendEvent.sendEvent(event) if (!messageResponses.length) return @@ -221,22 +228,6 @@ async event => { event ) } - if (event.content["m.relates_to"]?.rel_type === "m.thread" && executedCommand !== "thread"){ - const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id) - api.sendEvent(event.room_id, "m.room.message", { - body: "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. *In other words: __Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel.__ If the thread you sent this message in is old, such a random reply **may be distracting** to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on https://matrix.to/#/"+bridgedTo+"?"+(await utils.getViaServersQuery(bridgedTo, api)).toString() : "Please run `/thread [Optional: Thread Name]` to create such a room for this thread, or get a link to it if someone else has already done so. If you run `/thread` (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", - format: "org.matrix.custom.html", - formatted_body: "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

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

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

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

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

~ "+event.sender :undefined }) + api.sendEvent(event.room_id, event.type, {...event.content, body: event.content.body+"\n ~ "+event.sender, formatted_body: event.content.formatted_body ? event.content.formatted_body+"
~ "+event.sender :undefined }) } } From 7eeff2faf319dad2c9504c64d4656030121e96e5 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 14 Apr 2026 22:49:59 +0000 Subject: [PATCH 064/142] ...So I might as well take care of this mess with commands. Notably: * Don't do the unmarshalling and switch-cases, as Cadence asked * Revert command handler returns to how they were before, now that we're not using the returned-command-name anymore. --- src/m2d/event-dispatcher.js | 4 +- src/matrix/matrix-command-handler.js | 115 ++++++++++----------------- 2 files changed, 43 insertions(+), 76 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 489904d..61597f1 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -231,10 +231,8 @@ async event => { const messageResponses = await sendEvent.sendEvent(event) if (!messageResponses.length) return - /** @type {string|undefined} */ - let executedCommand if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) { - executedCommand = await matrixCommandHandler.parseAndExecute( + await matrixCommandHandler.parseAndExecute( // @ts-ignore - TypeScript doesn't know that the event.content.msgtype === "m.text" check ensures that event isn't of type Ty.Event.Outer_M_Room_Message_File (which, indeed, wouldn't fit here) event ) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index d25c54c..408b70a 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -97,33 +97,6 @@ function replyctx(execute) { } } -/** - * @param {Error & {code?: string|number}} e - * @returns {e} -*/ -function unmarshallDiscordError(e) { - if (e.name === "DiscordAPIError"){ - try{ - const unmarshaled = JSON.parse(e.message) - return { - ...e, - ...unmarshaled - } - } catch (err) { - return { - ...err, - code: "JSON_PARSE_FAILED", - message: JSON.stringify({ - original_error_where_message_failed_to_parse: e, - json_parser_error_message: err.message, - json_parser_error_code: err.code, - }) - } - } - } - return e; -} - /** @type {Command[]} */ const commands = [{ aliases: ["emoji"], @@ -312,55 +285,52 @@ const commands = [{ } try { - if (branchedFromDiscordMessage) await discord.snow.channel.createThreadWithMessage(channelID, branchedFromDiscordMessage, {name: words.slice(1).join(" ")}) - else throw {code: "NO_BRANCH_SOURCE", was_supposed_to_be: branchedFromMxEvent}; + if (branchedFromDiscordMessage) return await discord.snow.channel.createThreadWithMessage(channelID, branchedFromDiscordMessage, {name: words.slice(1).join(" ")}) //can't just return the promise directly like in 99% of other cases here in commands, otherwise the error-handling below will not work + else {return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID `"+branchedFromMxEvent+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", + format: "org.matrix.custom.html", + formatted_body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID "+branchedFromMxEvent+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." + })}; } catch (e){ - switch (unmarshallDiscordError(e).code) { - case "NO_BRANCH_SOURCE": return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID `"+e.was_supposed_to_be+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", - format: "org.matrix.custom.html", - formatted_body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID "+e.was_supposed_to_be+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." - }) - - case (160004): // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes - if (isFallingBack){ - await api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "⚠️ Discord claims that there already exists a thread for the message you ran this command on, but that doesn't make logical sense, as it doesn't seem like you ran this command on any message. Either your Matrix client did something funny with reply/thread tags, or this is a logic error on OOYE's side. At any rate, this should be reported for further investigation. You should also attach the error message that's about to be sent below (or on the main room timeline, if the command was ran inside a thread).", - }) - throw e; - } - const thread = mxUtils.getThreadRoomFromThreadEvent(branchedFromMxEvent) - return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "There already exists a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue by creating the bridge. (If you're said admin and you can see that said bridge already exists, but this error message is still showing up, please report that as a bug.)") - }) - - case (50024): return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?" - }) - - case (50035): return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)" - }) - - default: + /**@type {string|undefined} */ + let err = e.message // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes + + if (err?.includes("160004")) { + if (isFallingBack){ await api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "⚠️ Unknown error occurred during thread creation. See error message below (or on the main room timeline, if the command was ran inside a thread) for details." + body: "⚠️ Discord claims that there already exists a thread for the message you ran this command on, but that doesn't make logical sense, as it doesn't seem like you ran this command on any message. Either your Matrix client did something funny with reply/thread tags, or this is a logic error on OOYE's side. At any rate, this should be reported for further investigation. You should also attach the error message that's about to be sent below (or on the main room timeline, if the command was ran inside a thread).", }) - throw e + throw e; + } + const thread = mxUtils.getThreadRoomFromThreadEvent(branchedFromMxEvent) + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "There already exists a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue by creating the bridge. (If you're said admin and you can see that said bridge already exists, but this error message is still showing up, please report that as a bug.)") + }) } + if (err?.includes("50024")) return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?" + }) + if (err?.includes("50035")) return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)" + }) + + await api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "⚠️ Unknown error occurred during thread creation. See error message below (or on the main room timeline, if the command was ran inside a thread) for details." + }) + throw e } } ) @@ -422,9 +392,9 @@ const commands = [{ /** * @param {Ty.Event.Outer_M_Room_Message} event - * @returns {Promise} the executed command's name or undefined if no command execution was performed + * @returns {Promise|undefined} the executed command's in-process promise or undefined if no command execution was performed */ -async function parseAndExecute(event) { +function parseAndExecute(event) { let realBody = event.content.body while (realBody.startsWith("> ")) { const i = realBody.indexOf("\n") @@ -444,8 +414,7 @@ async function parseAndExecute(event) { const command = commands.find(c => c.aliases.includes(commandName)) if (!command) return - await command.execute(event, realBody, words) - return words[0] + return command.execute(event, realBody, words) } module.exports.parseAndExecute = parseAndExecute From ff8e5719507ce5368051796b1ab8ab101e6caa25 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 15:24:01 +0000 Subject: [PATCH 065/142] Changes to thread announcements, especially: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * use "" instead of „” to comply with English Language Standards Recommendations On Quotation Marks [TM], as per Cadence's request * reflect current bot behavior (ie. it no longer bridges-as-replies, but mercilessly rips the message away from your caring arms) * add Ellie-Mode --- src/d2m/converters/thread-to-announcement.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 179a559..0d8fa1f 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -19,21 +19,23 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) */ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get() + const ellieMode = false; //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. + /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */ const context = {} let suffix = ""; - if (branchedFromEventID) { + if (branchedFromEventID && !ellieMode) { // Need to figure out who sent that event... const event = await di.api.getEvent(parentRoomID, branchedFromEventID) - suffix = "\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]"; + suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]"; context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} } const msgtype = creatorMxid ? "m.emote" : "m.text" - const template = creatorMxid ? "started a thread" : "New thread started:" + const template = creatorMxid ? "started a thread called" : "New thread started:" const via = await mxUtils.getViaServersQuery(threadRoomID, di.api) - let body = `${template} „${thread.name}” in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}` + let body = `${template} \"${thread.name}\" in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}` return { msgtype, From 5db585a5258a48e0e8439259f2d82bc6eb9848e7 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 16:24:32 +0000 Subject: [PATCH 066/142] I just noticed something silly... MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I was stripping the ping before because I thought it just pings the thread-author (which I found kinda pointless). But I didn't actually remove the code that figures out who to ping (because I happened to reuse the „if” around it, and didn't remove the setting itself because I didn't pay enough attention to it and just assumed it has some side-effects). I just tried to remove it finally (because my thought was „Wait, WHY are we setting m.mentions only to remove it?”), only to realize that the code does something entirely different (it pings the one under whose message a thread is about to be created, which makes a lot of sense tbh), and actually shouldn't be removed at all and - on the contrary - I should stop removing m.mentions (and also fix Ellie-Mode so that it won't prevent m.mentions from being set even if it's enabled). --- src/d2m/converters/thread-to-announcement.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 0d8fa1f..d253dfd 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -24,12 +24,15 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */ const context = {} let suffix = ""; - if (branchedFromEventID && !ellieMode) { + if (branchedFromEventID) { // Need to figure out who sent that event... const event = await di.api.getEvent(parentRoomID, branchedFromEventID) - suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]"; - context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} + if (!ellieMode){ + //...And actually branch from that event (if configured to do so) + suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]"; + context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} + } } const msgtype = creatorMxid ? "m.emote" : "m.text" @@ -40,7 +43,6 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr return { msgtype, body, - "m.mentions": {}, ...context } } From 9871ed89303bf6a680b87a284bef6bc53df1bb39 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 16:35:36 +0000 Subject: [PATCH 067/142] consistency. --- src/d2m/converters/thread-to-announcement.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index d253dfd..bd3412c 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -19,18 +19,18 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) */ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get() - const ellieMode = false; //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. + const ellieMode = false //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */ const context = {} - let suffix = ""; + let suffix = "" if (branchedFromEventID) { // Need to figure out who sent that event... const event = await di.api.getEvent(parentRoomID, branchedFromEventID) if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} - if (!ellieMode){ + if (!ellieMode) { //...And actually branch from that event (if configured to do so) - suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]"; + suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]" context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} } } From bd80d562c7588a1882964dccff09aa80809be1c6 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 17:08:16 +0000 Subject: [PATCH 068/142] t e s t i n g c o m p l e t e I also noticed that my previous wiping code wasn't even doing anything at all. lmfao --- .../converters/thread-to-announcement.test.js | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.test.js b/src/d2m/converters/thread-to-announcement.test.js index 8af4d79..a4dfd9a 100644 --- a/src/d2m/converters/thread-to-announcement.test.js +++ b/src/d2m/converters/thread-to-announcement.test.js @@ -49,8 +49,7 @@ test("thread2announcement: no known creator, no branched from event", async t => }, {api: viaApi}) t.deepEqual(content, { msgtype: "m.text", - body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", - "m.mentions": {} + body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", }) }) @@ -61,8 +60,7 @@ test("thread2announcement: known creator, no branched from event", async t => { }, {api: viaApi}) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", - "m.mentions": {} + body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", }) }) @@ -85,8 +83,7 @@ test("thread2announcement: no known creator, branched from discord event", async }) t.deepEqual(content, { msgtype: "m.text", - body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", - "m.mentions": {}, + body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", "m.relates_to": { "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", "is_falling_back": false, @@ -117,8 +114,7 @@ test("thread2announcement: known creator, branched from discord event", async t }) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", - "m.mentions": {}, + body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", "m.relates_to": { "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", "is_falling_back": false, @@ -149,7 +145,7 @@ test("thread2announcement: no known creator, branched from matrix event", async }) t.deepEqual(content, { msgtype: "m.text", - body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", + body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", "m.mentions": { user_ids: ["@cadence:cadence.moe"] }, @@ -183,7 +179,7 @@ test("thread2announcement: known creator, branched from matrix event", async t = }) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", + body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", "m.mentions": { user_ids: ["@cadence:cadence.moe"] }, From b7e398a0680ec7cbab6e4e6e669a4512324e43bc Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 19:07:18 +0000 Subject: [PATCH 069/142] Handle errors; general code-quality improvements --- src/m2d/event-dispatcher.js | 17 ++++++++++------- src/matrix/matrix-command-handler.js | 16 ++-------------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 61597f1..c9df78c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -213,15 +213,12 @@ async event => { let processCommands = true if (event.content["m.relates_to"]?.rel_type === "m.thread") { - /**@type {string|null} */ - let toRedact = event.room_id + const toRedact = event.room_id const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id) processCommands = false if (bridgedTo) event.room_id = bridgedTo; - else if (!await bridgeThread(event)) toRedact = null; - - if (toRedact) { + else if (await bridgeThread(event)) { api.redactEvent(toRedact, event.event_id) event.content["m.relates_to"] = undefined api.sendEvent(event.room_id, event.type, {...event.content, body: event.content.body+"\n ~ "+event.sender, formatted_body: event.content.formatted_body ? event.content.formatted_body+"
~ "+event.sender :undefined }) @@ -263,8 +260,14 @@ async function bridgeThread(event) { else name = (await api.getEvent(event.room_id, eventID)).content.body; name = name.length < 100 ? name.replaceAll("\n", " ") : name.slice(0, 96).replaceAll("\n", " ") + "..." - event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id) - return true; + try { + event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id) + return true; + } + catch (e){ + if (e.message?.includes("50024")) return false; //Tried to created a thread in a thread (see: https://docs.discord.com/developers/topics/opcodes-and-status-codes)? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. Same case as for message-not-bridged, except there at least exists a HYPOTHETICAL solution (just one so unwieldly that it's nonsensical to dedicate resources to), wheres here I don't know what could possibly be done at all. + else throw e; //In here (unlike in matrix-command-handler.js), there are much fewer things that could "intentionally" go wrong (both thread double-creation and too-long names shouldn't be possible due to earlier checks). As such, if anything breaks, it should be reported to OOYE for further investigation, which the user should do when encountering an "ugly error" (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an "ugly error" upstream. + } } sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 408b70a..e59e58c 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -299,14 +299,7 @@ const commands = [{ let err = e.message // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes if (err?.includes("160004")) { - if (isFallingBack){ - await api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "⚠️ Discord claims that there already exists a thread for the message you ran this command on, but that doesn't make logical sense, as it doesn't seem like you ran this command on any message. Either your Matrix client did something funny with reply/thread tags, or this is a logic error on OOYE's side. At any rate, this should be reported for further investigation. You should also attach the error message that's about to be sent below (or on the main room timeline, if the command was ran inside a thread).", - }) - throw e; - } + if (isFallingBack) throw e; //Discord claims that there already exists a thread for the message ran this command was ran on, but that doesn't make logical sense, as it doesn't seem like it was ran on any message. Either the Matrix client did something funny with reply/thread tags, or this is a logic error on our side. At any rate, this should be reported to OOYE for further investigation, which the user should do when encountering an „ugly error” (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an „ugly error” upstream. const thread = mxUtils.getThreadRoomFromThreadEvent(branchedFromMxEvent) return api.sendEvent(event.room_id, "m.room.message", { ...ctx, @@ -325,12 +318,7 @@ const commands = [{ body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)" }) - await api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "⚠️ Unknown error occurred during thread creation. See error message below (or on the main room timeline, if the command was ran inside a thread) for details." - }) - throw e + throw e //Some other error happened, one that OOYE didn't anticipate the possibility of? It should be reported to us for further investigation, which the user should do when encountering an „ugly error” (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an „ugly error” upstream. } } ) From 1cc86b52fdcbf6839fa28179dce6f9df3ad6584d Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 19:53:48 +0000 Subject: [PATCH 070/142] these changes were promised to me 3000 years ago --- src/d2m/actions/create-room.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index e7c5050..70f7b3a 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -54,7 +54,7 @@ function convertNameAndTopic(channel, guild, customName) { // @ts-ignore const parentChannel = discord.channels.get(channel.parent_id) let channelPrefix = - ( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "" + ( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "[❓] " : channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.AnnouncementThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] " @@ -65,10 +65,11 @@ function convertNameAndTopic(channel, guild, customName) { const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : ''; const channelIDPart = `Channel ID: ${channel.id}`; const guildIDPart = `Guild ID: ${guild.id}`; + const maybeThreadWithinPart = parentChannel ? `Thread within: ${parentChannel.name} (ID: ${parentChannel.id})\n` : ''; const convertedTopic = customName - ? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${guildIDPart}` - : `${maybeTopicWithNewlines}${channelIDPart}\n${guildIDPart}`; + ? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${maybeThreadWithinPart}${guildIDPart}` + : `${maybeTopicWithNewlines}${channelIDPart}\n${maybeThreadWithinPart}${guildIDPart}`; return [chosenName, convertedTopic]; } From 0c6a5008e31854746fbf457e495e4d8136579189 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 22:52:11 +0000 Subject: [PATCH 071/142] 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 70f7b3a..0fb102b 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 70092d5..8dc59f4 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 072/142] 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 8dc59f4..0d4c744 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 073/142] =?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 0d4c744..94f1a5c 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 074/142] 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 772a19c..cf01f9f 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 075/142] =?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 c9df78c..f7d3d92 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 076/142] 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 f7d3d92..49dc8c8 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 077/142] 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 49dc8c8..77e0b5b 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 078/142] =?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 77e0b5b..bb1fcc3 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 079/142] 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 0000000..0f6e145 --- /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 bb1fcc3..95a3157 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 080/142] 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 95a3157..0849a93 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 081/142] 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 0f6e145..b9aff85 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 082/142] 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 b9aff85..661d53b 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 083/142] 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 661d53b..3b8d9b0 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 084/142] 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 3b8d9b0..de9d180 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 085/142] 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 de9d180..5de43a6 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 086/142] 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 5de43a6..d46ef49 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 087/142] 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 d46ef49..a75b7e4 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 088/142] 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 a75b7e4..dcf7909 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 e59e58c..ba72d5a 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 089/142] =?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 dcf7909..10aa8c0 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 ba72d5a..2b99200 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 090/142] =?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 10aa8c0..05760b4 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 091/142] 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 bd3412c..be56d7e 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 092/142] 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 be56d7e..bd3412c 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 093/142] 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 bd3412c..be56d7e 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 094/142] 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 be56d7e..bd3412c 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 095/142] 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 bd3412c..b3a4487 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 096/142] 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 8db998d..6887252 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 097/142] 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 b3a4487..6571619 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 098/142] 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 cf01f9f..aa97f72 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 099/142] 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 0fb102b..2a76f56 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 494cba2..db209ce 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 0000000..d280ba5 --- /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 9791ae3..990477a 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 94f1a5c..7a7c4ac 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 aa97f72..2992249 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 100/142] 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 990477a..7992b21 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 101/142] 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 7992b21..f7c046e 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 102/142] =?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 f7c046e..4303784 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 103/142] 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 d280ba5..037114b 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 4303784..5f34b7f 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 7a7c4ac..a8be516 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 104/142] 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 5f34b7f..3cad5ca 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 105/142] 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 037114b..d280ba5 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 106/142] 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 a8be516..78cf7a5 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 2992249..8394a9e 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 107/142] 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 78cf7a5..adf85d9 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 8394a9e..1ec34a5 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 108/142] 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 1ec34a5..b8c69c1 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 109/142] 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 b8c69c1..89ee3ca 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 110/142] 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 89ee3ca..1b8d773 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 111/142] 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 3cad5ca..7314cea 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 adf85d9..3cd003e 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 112/142] 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 7314cea..a65efad 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 3cd003e..c4fd13c 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 113/142] 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 2a76f56..f78ab27 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 114/142] 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 0849a93..f7191be 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 115/142] 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 c4fd13c..0cfec6d 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 1b8d773..64afcc1 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 2aff1fbd0656347a00a5134a2cd2f7266d41bf03 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 12 May 2026 14:06:43 +1200 Subject: [PATCH 116/142] Code block attachments use Discord supported types --- src/discord/utils.js | 389 ++++++++++++++++++++ src/m2d/converters/event-to-message.js | 3 +- src/m2d/converters/event-to-message.test.js | 32 ++ 3 files changed, 423 insertions(+), 1 deletion(-) diff --git a/src/discord/utils.js b/src/discord/utils.js index aed7068..0d400f1 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -182,6 +182,394 @@ function filterTo(xs, fn) { return filtered } +const supportedPlaintextPreviewExtensions = new Set([ + "4d", + "abnf", + "accesslog", + "actionscript", + "ada", + "adoc", + "alan", + "angelscript", + "ansi", + "apache", + "apacheconf", + "applescript", + "arcade", + "arduino", + "arm", + "armasm", + "as", + "asc", + "asciidoc", + "aspectj", + "ass", + "atom", + "autohotkey", + "autoit", + "avrasm", + "awk", + "axapta", + "bash", + "basic", + "bat", + "bbcode", + "bf", + "bind", + "blade", + "bnf", + "brainfuck", + "c", + "c++", + "cal", + "capnp", + "capnproto", + "cc", + "chaos", + "chapel", + "chpl", + "cisco", + "clj", + "clojure", + "cls", + "cmake.in", + "cmake", + "cmd", + "coffee", + "coffeescript", + "console", + "coq", + "cos", + "cpc", + "cpp", + "cr", + "craftcms", + "crm", + "crmsh", + "crystal", + "cs", + "csharp", + "cshtml", + "cson", + "csp", + "css", + "csv", + "cxx", + "cypher", + "d", + "dart", + "delphi", + "dfm", + "diff", + "django", + "dns", + "docker", + "dockerfile", + "dos", + "dpr", + "dsconfig", + "dst", + "dts", + "dust", + "dylan", + "ebnf", + "elixir", + "elm", + "erl", + "erlang", + "ex", + "extempore", + "f90", + "f95", + "fix", + "fortran", + "freepascal", + "fs", + "fsharp", + "gams", + "gauss", + "gawk", + "gcode", + "gdscript", + "gemspec", + "gf", + "gherkin", + "glsl", + "gms", + "gn", + "gni", + "go", + "godot", + "golang", + "golo", + "gololang", + "gradle", + "graph", + "groovy", + "gss", + "gyp", + "h", + "h++", + "haml", + "handlebars", + "haskell", + "haxe", + "hbs", + "hcl", + "hh", + "hpp", + "hs", + "html.handlebars", + "html.hbs", + "html", + "http", + "https", + "hx", + "hxx", + "hy", + "hylang", + "i", + "i7", + "iced", + "iecst", + "inform7", + "ini", + "ino", + "instances", + "iol", + "irb", + "irpf90", + "java", + "javascript", + "jinja", + "jolie", + "js", + "json", + "jsp", + "jsx", + "julia-repl", + "julia", + "k", + "kaos", + "kdb", + "kotlin", + "kt", + "lasso", + "lassoscript", + "lazarus", + "ldif", + "leaf", + "lean", + "less", + "lfm", + "lisp", + "livecodeserver", + "livescript", + "ln", + "lock", + "log", + "lpr", + "ls", + "ls", + "lua", + "mak", + "make", + "makefile", + "markdown", + "mathematica", + "matlab", + "mawk", + "maxima", + "md", + "mel", + "mercury", + "mirc", + "mizar", + "mk", + "mkd", + "mkdown", + "ml", + "ml", + "mm", + "mma", + "mojolicious", + "monkey", + "moon", + "moonscript", + "mrc", + "n1ql", + "nawk", + "nc", + "never", + "nginx", + "nginxconf", + "nim", + "nimrod", + "nix", + "nsis", + "obj-c", + "obj-c++", + "objc", + "objective-c++", + "objectivec", + "ocaml", + "ocl", + "ol", + "openscad", + "osascript", + "oxygene", + "p21", + "parser3", + "pas", + "pascal", + "patch", + "pcmk", + "perl", + "pf.conf", + "pf", + "pgsql", + "php", + "php3", + "php4", + "php5", + "php6", + "php7", + "pl", + "plaintext", + "plist", + "pm", + "podspec", + "pony", + "postgres", + "postgresql", + "powershell", + "pp", + "processing", + "profile", + "prolog", + "properties", + "proto", + "protobuf", + "ps", + "ps1", + "puppet", + "py", + "pycon", + "python-repl", + "python", + "qml", + "r", + "razor-cshtml", + "razor", + "rb", + "re", + "reasonml", + "rebol", + "red-system", + "red", + "redbol", + "rf", + "rib", + "robot", + "rpm-spec", + "rpm-specfile", + "rpm", + "rs", + "rsl", + "rss", + "ruby", + "ruleslanguage", + "rust", + "sas", + "SAS", + "sc", + "scad", + "scala", + "scheme", + "sci", + "scilab", + "scl", + "scss", + "sh", + "shell", + "shexc", + "smali", + "smalltalk", + "sml", + "sol", + "solidity", + "spec", + "specfile", + "sql", + "srt", + "ssa", + "st", + "stan", + "stanfuncs", + "stata", + "step", + "stp", + "structured-text", + "styl", + "stylus", + "subunit", + "supercollider", + "svelte", + "svg", + "swift", + "tao", + "tap", + "tcl", + "terraform", + "tex", + "text", + "tf", + "thor", + "thrift", + "tk", + "toml", + "tp", + "ts", + "tsql", + "tsx", + "ttml", + "twig", + "txt", + "typescript", + "unicorn-rails-log", + "v", + "vala", + "vb", + "vba", + "vbnet", + "vbs", + "vbscript", + "verilog", + "vhdl", + "vim", + "vtt", + "wl", + "x++", + "x86asm", + "xhtml", + "xjb", + "xl", + "xml", + "xpath", + "xq", + "xquery", + "xsd", + "xsl", + "xtlang", + "xtm", + "yaml", + "yml", + "zep", + "zephir", + "zone", + "zsh" +]) + module.exports.getPermissions = getPermissions module.exports.getDefaultPermissions = getDefaultPermissions module.exports.hasPermission = hasPermission @@ -194,3 +582,4 @@ module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact module.exports.getPublicUrlForCdn = getPublicUrlForCdn module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage module.exports.filterTo = filterTo +module.exports.supportedPlaintextPreviewExtensions = supportedPlaintextPreviewExtensions diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index af44c84..31caef0 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -894,7 +894,8 @@ async function eventToMessage(event, guild, channel, di) { let preNode if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) { if (preNode.firstChild?.nodeName === "CODE") { - const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt" + let ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] + if (!dUtils.supportedPlaintextPreviewExtensions.has(ext)) ext = "txt" const filename = `inline_code.${ext}` // Build the replacement node const replacementCode = doc.createElement("code") diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index bc73df7..68d519a 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -1155,6 +1155,38 @@ test("event2message: code blocks are uploaded as attachments instead if they con ) }) +test("event2message: code blocks are uploaded as attachments instead if they contain incompatible backticks (use txt extension if discord does not recognise the language)", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: 'So if you run code like this
System.out.println("```");
it should print a markdown formatted code block' + }, + event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "So if you run code like this `[inline_code.txt]` it should print a markdown formatted code block", + attachments: [{id: "0", filename: "inline_code.txt"}], + pendingFiles: [{name: "inline_code.txt", buffer: Buffer.from('System.out.println("```");')}], + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: code blocks are uploaded as attachments instead if they contain incompatible backticks (default to txt file extension)", async t => { t.deepEqual( await eventToMessage({ From 678a1b77bb1273c08d5f02802d75aac54dd06961 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 12 May 2026 14:08:58 +1200 Subject: [PATCH 117/142] Cap length of channels report --- src/discord/interactions/matrix-info.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/discord/interactions/matrix-info.js b/src/discord/interactions/matrix-info.js index 0b9a525..dcc9943 100644 --- a/src/discord/interactions/matrix-info.js +++ b/src/discord/interactions/matrix-info.js @@ -54,6 +54,7 @@ async function _interact({guild_id, data}, {api}) { // from Matrix const event = await api.getEvent(message.room_id, message.event_id) const via = await utils.getViaServersQuery(message.room_id, api) + const channelsInGuild = discord.guildChannelMap.get(guild_id) assert(channelsInGuild) const inChannels = channelsInGuild @@ -61,6 +62,11 @@ async function _interact({guild_id, data}, {api}) { .map(/** @returns {DiscordTypes.APIGuildChannel} */ cid => discord.channels.get(cid)) .sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels)) .filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get()) + let inChannelsText = inChannels.map(c => `<#${c.id}>`).join(" • ") + if (inChannelsText.length > 1024) { + inChannelsText = `In ${inChannels.length} channels` + } + const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get() let name = matrixMember?.displayname || event.sender let avatar = utils.getPublicUrlForMxc(matrixMember?.avatar_url) @@ -98,7 +104,7 @@ async function _interact({guild_id, data}, {api}) { color: 0x0dbd8b, fields: [{ name: "In Channels", - value: inChannels.map(c => `<#${c.id}>`).join(" • ") + value: inChannelsText }, { name: "\u200b", value: idInfo From 191a98e1dcf339f0601c4f1ff0c405edb6c00a8a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 12 May 2026 14:11:06 +1200 Subject: [PATCH 118/142] Fix watching registration file before creation --- src/matrix/read-registration.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/matrix/read-registration.js b/src/matrix/read-registration.js index 86f99a1..d1243a7 100644 --- a/src/matrix/read-registration.js +++ b/src/matrix/read-registration.js @@ -78,10 +78,14 @@ function readRegistration() { /** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore let reg = readRegistration() -fs.watch(registrationFilePath, {persistent: false}, () => { - let newReg = readRegistration() - Object.assign(reg, newReg) -}) +if (reg) { + fs.watch(registrationFilePath, {persistent: false}, () => { + let newReg = readRegistration() + if (newReg) { + Object.assign(reg, newReg) + } + }) +} module.exports.registrationFilePath = registrationFilePath module.exports.readRegistration = readRegistration From 4815d28aa49af422320b21c42d509b31e6123af2 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 13 May 2026 14:38:14 +1200 Subject: [PATCH 119/142] Code blocks uploaded as attachments when too long --- src/m2d/converters/event-to-message.js | 4 ++- src/m2d/converters/event-to-message.test.js | 32 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 31caef0..fd00827 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -892,7 +892,9 @@ async function eventToMessage(event, guild, channel, di) { } // Check for incompatible backticks in code blocks let preNode - if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) { + let isBackticksTextInPre = node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"])) + let isLongPre = node.tagName === "PRE" && node.textContent.length > 1800 && (preNode = node) + if (isBackticksTextInPre || isLongPre) { if (preNode.firstChild?.nodeName === "CODE") { let ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] if (!dUtils.supportedPlaintextPreviewExtensions.has(ext)) ext = "txt" diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 68d519a..70b53d3 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -1219,6 +1219,38 @@ test("event2message: code blocks are uploaded as attachments instead if they con ) }) +test("event2message: code blocks are uploaded as attachments instead if they are really long", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: `So if you run code like this
${"A".repeat(2000)}
it should print a markdown formatted code block` + }, + event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "So if you run code like this `[inline_code.js]` it should print a markdown formatted code block", + attachments: [{id: "0", filename: "inline_code.js"}], + pendingFiles: [{name: "inline_code.js", buffer: Buffer.from("A".repeat(2000))}], + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: characters are encoded properly in code blocks", async t => { t.deepEqual( await eventToMessage({ From eb676256e45466cd53d3a2fc187eee38294c654f Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 14 May 2026 18:20:32 +1200 Subject: [PATCH 120/142] Fix Discord mentions with extra HTML attributes --- src/m2d/converters/event-to-message.js | 2 +- src/m2d/converters/event-to-message.test.js | 46 ++++++++++++++++++++- test/ooye-test-data.sql | 1 + 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index fd00827..3609d0d 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -821,7 +821,7 @@ async function eventToMessage(event, guild, channel, di) { } // Handling mentions of Discord users - input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => { + input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")/g, (whole, attributeValue, mxid) => { mxid = decodeURIComponent(mxid) if (mxUtils.eventSenderIsFromDiscord(mxid)) { // Handle mention of an OOYE sim user by their mxid diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 70b53d3..14dbf97 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -3425,6 +3425,50 @@ test("event2message: mentioning discord users works", async t => { ) }) +test("event2message: mentioning discord users with extra html attributes works", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@lavender.pet:queer.sh", + content: { + msgtype: "m.text", + body: "also @_ooye_ampflower:cadence.moe fun fact at some point there is plans for FTE to have a built in map editor", + "m.mentions": { + user_ids: [ + "@_ooye_ampflower:cadence.moe" + ] + }, + format: "org.matrix.custom.html", + formatted_body: "

also @Ampflower fun fact at some point there is plans for FTE to have a built in map editor

" + }, + room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", + origin_server_ts: 1778616745263, + unsigned: { + age: 100363692, + membership: "join" + }, + event_id: "$AHCkieLEVCrCEA3INTCl0rh44V29fCASlZpBKw7DzQU", + user_id: "@lavender.pet:queer.sh", + age: 100363692 + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "lavender.pet", + avatar_url: undefined, + content: "also <@196188877885538304> fun fact at some point there is plans for FTE to have a built in map editor", + allowed_mentions: { + parse: ["roles"], + users: ["196188877885538304"] + } + }] + } + ) +}) + + test("event2message: mentioning discord users works when URL encoded", async t => { t.deepEqual( await eventToMessage({ @@ -4260,7 +4304,7 @@ test("event2message: caches the member if the member is not known", async t => { } ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe"}).all(), [ + t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", mxid: "@should_be_newly_cached:cadence.moe"}).all(), [ {avatar_url: "mxc://cadence.moe/this_is_the_avatar", displayname: null, mxid: "@should_be_newly_cached:cadence.moe"} ]) t.equal(called, 1, "getStateEvent should be called once") diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 07f8c24..8dd71cd 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -192,6 +192,7 @@ INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) V ('!TqlyQmifxGUggEmdBN:cadence.moe', '@ampflower:matrix.org', 'Ampflower 🌺', 'mxc://cadence.moe/PRfhXYBTOalvgQYtmCLeUXko', 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@aflower:syndicated.gay', 'Rose', 'mxc://syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP', 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0), +('!qzDBLKlildpzrrOnFZ:cadence.moe', '@lavender.pet:queer.sh', 'lavender.pet', NULL, 0), ('!iSyXgNxQcEuXoXpsSn:pussthecat.org', '@austin:tchncs.de', 'Austin Huang', 'mxc://tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e', 0), ('!zq94fae5bVKUubZLp7:agiadn.org', '@underscore_x:agiadn.org', 'underscore_x', NULL, 100); From 43b8b02b4071b13e97209122975e71fd71cc5492 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 21 May 2026 17:59:52 +1200 Subject: [PATCH 121/142] Remove webhook tokens from error messages --- src/m2d/event-dispatcher.js | 8 +++++++- src/m2d/event-dispatcher.test.js | 29 ++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index c11b696..0d1c529 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -94,6 +94,11 @@ function printError(type, source, e, payload) { console.dir(payload, {depth: null}) } +/** @param {string} stack */ +function cleanErrorStack(stack) { + return stack.replace(/(\/webhooks\/[0-9]+\/)[a-zA-Z0-9_-]+/g, "$1(redacted)") +} + /** * @param {string} roomID * @param {"Discord" | "Matrix"} source @@ -134,7 +139,7 @@ async function sendError(roomID, source, type, e, payload) { builder.addLine(errorIntroLine) // Where - const stack = stringifyErrorStack(e) + const stack = cleanErrorStack(stringifyErrorStack(e)) builder.addLine(`Error trace:\n${stack}`, tag`
Error trace
${stack}
`) // How @@ -502,5 +507,6 @@ async event => { })) module.exports.stringifyErrorStack = stringifyErrorStack +module.exports.cleanErrorStack = cleanErrorStack module.exports.sendError = sendError module.exports.printError = printError diff --git a/src/m2d/event-dispatcher.test.js b/src/m2d/event-dispatcher.test.js index de754da..2de6381 100644 --- a/src/m2d/event-dispatcher.test.js +++ b/src/m2d/event-dispatcher.test.js @@ -1,7 +1,7 @@ // @ts-check const {test} = require("supertape") -const {stringifyErrorStack} = require("./event-dispatcher") +const {stringifyErrorStack, cleanErrorStack} = require("./event-dispatcher") test("stringify error stack: works", t => { function a() { @@ -21,3 +21,30 @@ test("stringify error stack: works", t => { t.match(str, /^ \[prop\]: 2.1$/m) } }) + +test("clean error stack: removes webhook token", t => { + t.notMatch( + cleanErrorStack(` + DiscordAPIError: Service resource is being rate limited. + at fn (/var/home/cadence/out-of-your-element/node_modules/snowtransfer/src/RequestHandler.ts:591:13) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + at exports.RequestHandler.request (/var/home/cadence/out-of-your-element/node_modules/snowtransfer/src/RequestHandler.ts:546:17) + at WebhookMethods.executeWebhook (/var/home/cadence/out-of-your-element/node_modules/snowtransfer/src/methods/Webhook.ts:249:35) + at /var/home/cadence/out-of-your-element/src/m2d/actions/channel-webhook.js:65:31 + at withWebhook (/var/home/cadence/out-of-your-element/src/m2d/actions/channel-webhook.js:47:9) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + at async Object.sendMessageWithWebhook (/var/home/cadence/out-of-your-element/src/m2d/actions/channel-webhook.js:64:17) + at async Object.sendEvent (/var/home/cadence/out-of-your-element/src/m2d/actions/send-event.js:132:27) + at async /var/home/cadence/out-of-your-element/src/m2d/event-dispatcher.js:208:27 + at async AppService. (/var/home/cadence/out-of-your-element/src/m2d/event-dispatcher.js:162:11) { + [method]: "POST" + [path]: "/webhooks/1160903754728611841/pfRqHl9vVZImdqwWWSZxxH8T-JJMnauxroMnHsvC6ARA-3B9_STH_bnHB9pd7QQaUVCG" + [code]: 40062 + [httpStatus]: 429 + [request]: {"endpoint":"/webhooks/1160903754728611841/pfRqHl9vVZImdqwWWSZxxH8T-JJMnauxroMnHsvC6ARA-3B9_STH_bnHB9pd7QQaUVCG","method":"POST","dataType":"json","data":{"content":"https://discordstatus.com/#day\nOnly what discord tell us right now","allowed_mentions":{"parse":["roles"],"users":[]},"username":"lewri","avatar_url":"https://bridge.cadence.moe/download/matrix/matrix.org/URWwrtSUONGOYhfMsdUzcrir"}} + [response]: {} + [name]: "DiscordAPIError"` + ), + /pfRqHl9v/ + ) +}) From 93bbc5ea0fd943eb583dc200ce09537e18ed6abe Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 21 May 2026 18:28:11 +1200 Subject: [PATCH 122/142] Revoke webhooks that might have been compromised --- .../migrations/0037-remove-leaked-webhooks.js | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/db/migrations/0037-remove-leaked-webhooks.js diff --git a/src/db/migrations/0037-remove-leaked-webhooks.js b/src/db/migrations/0037-remove-leaked-webhooks.js new file mode 100644 index 0000000..79fad16 --- /dev/null +++ b/src/db/migrations/0037-remove-leaked-webhooks.js @@ -0,0 +1,41 @@ +const {discord, db, from, select, sync} = require("../../passthrough") +/** @type {import("../../discord/utils")} */ +const dUtils = sync.require("../../discord/utils") + +const ones = "₀₁₂₃₄₅₆₇₈₉" +const tens = "0123456789" + +/* c8 ignore start */ + +module.exports = async function(db) { + // added tolerance to https://discordstatus.com/incidents/4hpm4454hxtx + const OUTAGE_START = 1778263200000 + const OUTAGE_END = 1778284800000 + + const startSnowflake = dUtils.timestampToSnowflakeInexact(OUTAGE_START) + const endSnowflake = dUtils.timestampToSnowflakeInexact(OUTAGE_END) + + const affectedChannels = from("message_room").join("historical_channel_room", "historical_room_index") + .pluck("reference_channel_id").selectUnsafe("DISTINCT reference_channel_id") + .and("WHERE message_id >= ? AND message_id <= ? AND length(message_id) = ?").all(startSnowflake, endSnowflake, startSnowflake.length) + const affectedWebhooks = select("webhook", ["channel_id", "webhook_id", "webhook_token"], {channel_id: affectedChannels}).all() + + if (affectedWebhooks.length) { + process.stdout.write(` revoking ${affectedWebhooks.length} possibly compromised webhooks... `) + for (let counter = 1; counter <= affectedWebhooks.length; counter++) { + const webhook = affectedWebhooks[counter-1] + + await discord.snow.webhook.deleteWebhookToken(webhook.webhook_id, webhook.webhook_token, "Webhook token possibly compromised during 8th May 2026 outage").catch(e => { + if (e.message === `{"message": "Unknown Webhook", "code": 10015}`) { + // OK + } else { + throw e + } + }) + db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(webhook.channel_id) + + process.stdout.write(String(counter).at(-1) === "0" ? tens[(counter/10)%10] : ones[counter%10]) + } + process.stdout.write("\n") + } +} From 7781d1e34dbc490e3c1282a3d48f8e319ee2b603 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 21 May 2026 18:44:48 +1200 Subject: [PATCH 123/142] Increase d->m catch-up limit to 100 --- docs/user-guide.md | 2 +- src/d2m/event-dispatcher.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index d360806..d1beea1 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -84,7 +84,7 @@ Discord display names for normal users are limited to 32 characters. For webhook If the bridge software was restarted, it will attempt to catch up on messages missed while it was offline. -From Discord, for any given channel, if fewer than 50 messages were missed in that given channel, the bridge will catch up and transfer all of them to Matrix. If more than 50 messages were missed in that given channel, the bridge will only bridge the latest message. Happenings that aren't messages, such as edits and reactions to prior messages, might be missed during catch-up. +From Discord, for any given channel, if fewer than 100 messages were missed in that given channel, the bridge will catch up and transfer all of them to Matrix. If more than 100 messages were missed in that given channel, the bridge will only bridge the latest message. Happenings that aren't messages, such as edits and reactions to prior messages, might be missed during catch-up. From Matrix, all events should be bridged to Discord. diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index c86cc13..90824ac 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -102,7 +102,7 @@ module.exports = { // console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`) let messages try { - messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 50}) + messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 100}) } catch (e) { if (e.message === `{"message": "Missing Access", "code": 50001}`) { // pathetic error handling from SnowTransfer console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`) From dec216c0c2318af6812a02fbca65993fa235df06 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 21 May 2026 19:04:03 +1200 Subject: [PATCH 124/142] Update dependencies --- package-lock.json | 88 +++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d04cbb..30dfe15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "@types/node": "^22.17.1", "c8": "^11.0.0", "cross-env": "^7.0.3", - "supertape": "^12.0.12" + "supertape": "^13.2.0" }, "engines": { "node": ">=22" @@ -1003,9 +1003,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", "dev": true, "license": "MIT", "dependencies": { @@ -1129,9 +1129,9 @@ "license": "MIT" }, "node_modules/better-sqlite3": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", - "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1139,7 +1139,7 @@ "prebuild-install": "^7.1.1" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, "node_modules/bindings": { @@ -1163,9 +1163,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -1366,9 +1366,9 @@ "license": "MIT" }, "node_modules/cookie-es": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", - "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", "license": "MIT" }, "node_modules/cross-env": { @@ -1452,9 +1452,9 @@ } }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, "node_modules/destr": { @@ -1473,9 +1473,9 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.42", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.42.tgz", - "integrity": "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ==", + "version": "0.38.47", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" @@ -1688,14 +1688,14 @@ } }, "node_modules/h3": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.10.tgz", - "integrity": "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", "license": "MIT", "dependencies": { - "cookie-es": "^1.2.2", + "cookie-es": "^1.2.3", "crossws": "^0.3.5", - "defu": "^6.1.4", + "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", @@ -1753,9 +1753,9 @@ "license": "MIT" }, "node_modules/htmx.org": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz", - "integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz", + "integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==", "license": "0BSD" }, "node_modules/ieee754": { @@ -1974,9 +1974,9 @@ } }, "node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -2579,12 +2579,12 @@ } }, "node_modules/snowtransfer": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.5.tgz", - "integrity": "sha512-nVI1UJNFoX1ndGFZxB3zb3X5SWtD9hIAcw7wCgVKWvCf42Wg2B4UFIrZWI83HxaSBY0CGbPZmZzZb3RSt/v2wQ==", + "version": "0.17.7", + "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.7.tgz", + "integrity": "sha512-scbOjYezo1Ycfk21atCEkeXIISTT7R7JTHCdiZ/7m7k4XbSb6o5q8Mu2fev5IqFpNyqIVjA0d/MZQ+eP/gtwfg==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.40" + "discord-api-types": "^0.38.47" }, "engines": { "node": ">=22.0.0" @@ -2667,9 +2667,9 @@ } }, "node_modules/supertape": { - "version": "12.10.5", - "resolved": "https://registry.npmjs.org/supertape/-/supertape-12.10.5.tgz", - "integrity": "sha512-1Px+6mhFaqcht3p4tkf3o4G8lbBazvx4pgFngm4vGwWipYm3fykm6SJ4ThXobiaNsptz53CDWA2q4B/2KtmA4w==", + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/supertape/-/supertape-13.2.0.tgz", + "integrity": "sha512-UoxZnyoMOdSJHvbcmD8i28MaGXsA7I0cJ0jr8anT4CkmfaE9M1y5mt9EoXyzfC8UdnQZwXOnJLUwqyKLAeUOug==", "dev": true, "license": "MIT", "dependencies": { @@ -2856,9 +2856,9 @@ "license": "MIT" }, "node_modules/uqr": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz", - "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz", + "integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==", "license": "MIT" }, "node_modules/util-deprecate": { @@ -3028,9 +3028,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index c85a362..59c3a0a 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@types/node": "^22.17.1", "c8": "^11.0.0", "cross-env": "^7.0.3", - "supertape": "^12.0.12" + "supertape": "^13.2.0" }, "scripts": { "start": "node --enable-source-maps start.js", From d76936b157fb380515049dc834b2efe44b25d892 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 21 May 2026 19:09:02 +1200 Subject: [PATCH 125/142] Change emoji for forwards/crossposts --- src/d2m/converters/message-to-event.js | 16 ++++++------- .../message-to-event.test.embeds.js | 4 ++-- src/d2m/converters/message-to-event.test.js | 24 +++++++++---------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 6e9ce7b..643c84d 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -640,8 +640,8 @@ async function messageToEvent(message, guild, options = {}, di) { const flags = message.flags || 0 if (flags & DiscordTypes.MessageFlags.IsCrosspost) { - body = `[🔀 ${message.author.username}]\n` + body - html = `🔀 ${message.author.username}
` + html + body = `[↷ ${message.author.username}]\n` + body + html = `↷ ${message.author.username}
` + html } // Fallback body/formatted_body for replies @@ -768,20 +768,20 @@ async function messageToEvent(message, guild, options = {}, di) { if (row && "event_id" in row) { const via = await getViaServersMemo(row.room_id) forwardedNotice.addLine( - `[🔀 Forwarded from #${roomName}]`, - tag`🔀 Forwarded from ${roomName} [jump to event]` + `[↷ Forwarded from #${roomName}]`, + tag`↷ Forwarded from ${roomName} [jump to event]` ) } else { const via = await getViaServersMemo(room.room_id) forwardedNotice.addLine( - `[🔀 Forwarded from #${roomName}]`, - tag`🔀 Forwarded from ${roomName} [jump to room]` + `[↷ Forwarded from #${roomName}]`, + tag`↷ Forwarded from ${roomName} [jump to room]` ) } } else { forwardedNotice.addLine( - `[🔀 Forwarded message]`, - tag`🔀 Forwarded message` + `[↷ Forwarded message]`, + tag`↷ Forwarded message` ) } diff --git a/src/d2m/converters/message-to-event.test.embeds.js b/src/d2m/converters/message-to-event.test.embeds.js index fdb0807..b193931 100644 --- a/src/d2m/converters/message-to-event.test.embeds.js +++ b/src/d2m/converters/message-to-event.test.embeds.js @@ -209,9 +209,9 @@ test("message2event embeds: 4 images", async t => { t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.text", - body: "[🔀 Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046", + body: "[↷ Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046", format: "org.matrix.custom.html", - formatted_body: "🔀 Forwarded message
https://fixupx.com/i/status/2032003668787020046
", + formatted_body: "↷ Forwarded message
https://fixupx.com/i/status/2032003668787020046
", "m.mentions": {} }, { $type: "m.room.message", diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index b7f0867..be1d99f 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1273,9 +1273,9 @@ test("message2event: crossposted announcements say where they are crossposted fr $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "[🔀 Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands", + body: "[↷ Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands", format: "org.matrix.custom.html", - formatted_body: "🔀 Chewey Bot Official Server #announcements
All text based commands are now inactive on Chewey Bot
To continue using commands you'll need to use them as slash commands" + formatted_body: "↷ Chewey Bot Official Server #announcements
All text based commands are now inactive on Chewey Bot
To continue using commands you'll need to use them as slash commands" }]) }) @@ -1344,9 +1344,9 @@ test("message2event: forwarded image", async t => { t.deepEqual(events, [ { $type: "m.room.message", - body: "[🔀 Forwarded message]", + body: "[↷ Forwarded message]", format: "org.matrix.custom.html", - formatted_body: "🔀 Forwarded message", + formatted_body: "↷ Forwarded message", "m.mentions": {}, msgtype: "m.notice", }, @@ -1385,10 +1385,10 @@ test("message2event: constructed forwarded message", async t => { t.deepEqual(events, [ { $type: "m.room.message", - body: "[🔀 Forwarded from #wonderland]" + body: "[↷ Forwarded from #wonderland]" + "\n» What's cooking, good looking? :hipposcope:", format: "org.matrix.custom.html", - formatted_body: `🔀 Forwarded from wonderland [jump to event]` + formatted_body: `↷ Forwarded from wonderland [jump to event]` + `
What's cooking, good looking? :hipposcope:
`, "m.mentions": {}, msgtype: "m.text", @@ -1444,10 +1444,10 @@ test("message2event: constructed forwarded text", async t => { t.deepEqual(events, [ { $type: "m.room.message", - body: "[🔀 Forwarded from #amanda-spam]" + body: "[↷ Forwarded from #amanda-spam]" + "\n» What's cooking, good looking?", format: "org.matrix.custom.html", - formatted_body: `🔀 Forwarded from amanda-spam [jump to room]` + formatted_body: `↷ Forwarded from amanda-spam [jump to room]` + `
What's cooking, good looking?
`, "m.mentions": {}, msgtype: "m.text", @@ -1467,10 +1467,10 @@ test("message2event: don't scan forwarded messages for mentions", async t => { t.deepEqual(events, [ { $type: "m.room.message", - body: "[🔀 Forwarded message]" + body: "[↷ Forwarded message]" + "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114", format: "org.matrix.custom.html", - formatted_body: `🔀 Forwarded message` + formatted_body: `↷ Forwarded message` + `
If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114
`, "m.mentions": {}, msgtype: "m.text" @@ -1820,9 +1820,9 @@ test("message2event: forwarded message with unreferenced mention", async t => { t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.text", - body: "[🔀 Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)", + body: "[↷ Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)", format: "org.matrix.custom.html", - formatted_body: "🔀 Forwarded message
@unknown-user:
🎞️ Uploaded file: 2022-10-18_16-49-46.mp4 (51 MB)
", + formatted_body: "↷ Forwarded message
@unknown-user:
🎞️ Uploaded file: 2022-10-18_16-49-46.mp4 (51 MB)
", "m.mentions": {} }]) }) From e435b78e2804fbcb7bef5738ee168b0bbae49601 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 21 May 2026 19:13:03 +1200 Subject: [PATCH 126/142] Do not revoke newer webhooks --- src/db/migrations/0037-remove-leaked-webhooks.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/db/migrations/0037-remove-leaked-webhooks.js b/src/db/migrations/0037-remove-leaked-webhooks.js index 79fad16..0228053 100644 --- a/src/db/migrations/0037-remove-leaked-webhooks.js +++ b/src/db/migrations/0037-remove-leaked-webhooks.js @@ -18,7 +18,8 @@ module.exports = async function(db) { const affectedChannels = from("message_room").join("historical_channel_room", "historical_room_index") .pluck("reference_channel_id").selectUnsafe("DISTINCT reference_channel_id") .and("WHERE message_id >= ? AND message_id <= ? AND length(message_id) = ?").all(startSnowflake, endSnowflake, startSnowflake.length) - const affectedWebhooks = select("webhook", ["channel_id", "webhook_id", "webhook_token"], {channel_id: affectedChannels}).all() + let affectedWebhooks = select("webhook", ["channel_id", "webhook_id", "webhook_token"], {channel_id: affectedChannels}).all() + affectedWebhooks = affectedWebhooks.filter(w => BigInt(w.webhook_id) < BigInt(endSnowflake)) // if webhook ID is already newly generated then no need to replace if (affectedWebhooks.length) { process.stdout.write(` revoking ${affectedWebhooks.length} possibly compromised webhooks... `) From e0eb7deb2f83906e80c287420bb01d80f780fe81 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 21 May 2026 23:19:03 +1200 Subject: [PATCH 127/142] Change arrow to chevron for commands --- src/d2m/converters/message-to-event.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 643c84d..7229d3d 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -109,7 +109,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ /** * @param {{room?: boolean, user_ids?: string[]}} mentions - * @param {Omit} attachment + * @param {Omit} attachment * @param {boolean} [alwaysLink] */ async function attachmentToEvent(mentions, attachment, alwaysLink) { @@ -256,8 +256,8 @@ function getFormattedInteraction(interaction, isThinkingInteraction) { const username = interaction.member?.nick || interaction.user.global_name || interaction.user.username const thinkingText = isThinkingInteraction ? " — interaction loading..." : "" return { - body: `↪️ ${username} used \`/${interaction.name}\`${thinkingText}`, - html: `
↪️ ${mxid ? tag`${username}` : username} used /${interaction.name}${thinkingText}
` + body: `❭ ${username} used \`/${interaction.name}\`${thinkingText}`, + html: `
❭ ${mxid ? tag`${username}` : username} used /${interaction.name}${thinkingText}
` } } @@ -1127,7 +1127,7 @@ async function messageToEvent(message, guild, options = {}, di) { } } else { let body = stickerItem.name - const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id) + const sticker = guild.stickers?.find(sticker => sticker.id === stickerItem.id) if (sticker && sticker.description) body += ` - ${sticker.description}` return { $type: "m.sticker", From 99eacd8c47802fc16de85dba2dcb7f342b0570e0 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 22 May 2026 14:28:30 +1200 Subject: [PATCH 128/142] Generate letter avatars if no avatar --- src/m2d/converters/event-to-message.js | 7 + src/m2d/converters/event-to-message.test.js | 196 ++++++++++---------- src/web/routes/letter-avatar.js | 117 ++++++++++++ src/web/server.js | 1 + 4 files changed, 223 insertions(+), 98 deletions(-) create mode 100644 src/web/routes/letter-avatar.js diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 3609d0d..cc37084 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -29,6 +29,8 @@ const pollComponents = sync.require("./poll-components") const setupEmojis = sync.require("../actions/setup-emojis") /** @type {import("../../d2m/converters/user-to-mxid")} */ const userToMxid = sync.require("../../d2m/converters/user-to-mxid") +/** @type {import("../../web/routes/letter-avatar")} */ +const letterAvatar = sync.require("../../web/routes/letter-avatar") /** @type {[RegExp, string][]} */ const markdownEscapes = [ @@ -582,6 +584,11 @@ async function eventToMessage(event, guild, channel, di) { displayNameRunoff = "" } + // If undefined, generate letter avatar instead of using Discord default + if (avatarURL == undefined) { + avatarURL = letterAvatar.getLetterAvatarURL(event.sender, displayNameShortened) + } + let content = event.content["body"] || "" // ultimate fallback /** @type {{id: string, filename: string}[]} */ const attachments = [] diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 14dbf97..30e7aff 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -61,7 +61,7 @@ test("event2message: body is used when there is no formatted_body", async t => { messagesToSend: [{ username: "cadence [they]", content: "testing plaintext", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -99,7 +99,7 @@ test("event2message: any markdown in body is escaped, except strikethrough", asy messagesToSend: [{ username: "cadence [they]", content: "testing \\*\\*special\\*\\* ~~things~~ which \\_should\\_ \\*not\\* \\`trigger\\` @any , except strikethrough", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -134,7 +134,7 @@ test("event2message: links in formatted body are not broken", async t => { messagesToSend: [{ username: "cadence [they]", content: "<@111604486476181504> I wonder what the midjourney text description of this photo is https://upload.wikimedia.org/wikipedia/commons/f/f3/After_gay_pride%2C_rainbow_flags_flying_along_Beach_Street_%2814853144744%29.jpg", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -167,7 +167,7 @@ test("event2message: links in plaintext body are not broken", async t => { messagesToSend: [{ username: "cadence [they]", content: "I wonder what the midjourney text description of this photo is https://upload.wikimedia.org/wikipedia/commons/f/f3/After_gay_pride%2C_rainbow_flags_flying_along_Beach_Street_%2814853144744%29.jpg", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -195,7 +195,7 @@ test("event2message: links in plaintext body are not broken when preceded by a n messagesToSend: [{ username: "cadence [they]", content: "java redstoners will be like \"I hate bedrock edition redstone!!\" meanwhile java edition:\nhttps://youtu.be/g_ORb7bN3CM", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -225,7 +225,7 @@ test("event2message: links in formatted body where the text & href are the same, messagesToSend: [{ username: "cadence [they]", content: "https://privatebin.net/?9111cb16f28da21b#62CKkEr6WvXZ1gQv2M6agazsA7tGYX8ZP8drETYujYZr", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -264,7 +264,7 @@ test("event2message: markdown in link text does not attempt to be escaped becaus messagesToSend: [{ username: "cadence [they]", content: "hey [@mario sports mix [she/her]](), is it possible to listen on a unix socket?", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["roles"], users: [] @@ -293,7 +293,7 @@ test("event2message: markdown in link url does not attempt to be escaped (plaint messagesToSend: [{ username: "cadence [they]", content: "the wikimedia commons freaks are gonna love this one https://commons.wikimedia.org/wiki/File:Car_covered_in_traffic_cones.jpg", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -328,7 +328,7 @@ test("event2message: markdown in link url does not attempt to be escaped (plaint messagesToSend: [{ username: "cadence [they]", content: "the wikimedia commons freaks are gonna love this one ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -366,7 +366,7 @@ test("event2message: embeds are suppressed if the guild does not have embed link messagesToSend: [{ username: "cadence [they]", content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -402,7 +402,7 @@ test("event2message: embeds are suppressed if the guild does not have embed link messagesToSend: [{ username: "cadence [they]", content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -445,7 +445,7 @@ test("event2message: embeds are suppressed if the channel does not have embed li messagesToSend: [{ username: "cadence [they]", content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -476,7 +476,7 @@ test("event2message: links retain angle brackets (formatted body)", async t => { messagesToSend: [{ username: "cadence [they]", content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -505,7 +505,7 @@ test("event2message: links retain angle brackets (plaintext body)", async t => { messagesToSend: [{ username: "cadence [they]", content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -546,7 +546,7 @@ test("event2message: links don't have angle brackets added by accident", async t messagesToSend: [{ username: "Erquint", content: "Wanted to automate WG→AWG config enrichment and ended up basically coding a batch INI processor.\nhttps://github.com/Erquint/wgcbp", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=E&hue=180", allowed_mentions: { parse: ["roles"], users: [] @@ -581,7 +581,7 @@ test("event2message: basic html is converted to markdown", async t => { messagesToSend: [{ username: "cadence [they]", content: "this **is** a _**test** __of___ ~~_formatting_~~", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -615,7 +615,7 @@ test("event2message: spoilers work", async t => { messagesToSend: [{ username: "cadence [they]", content: "this **is** a ||_test_|| of ||spoilers||", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -649,7 +649,7 @@ test("event2message: spoiler reasons work", async t => { messagesToSend: [{ username: "cadence [they]", content: "\\(cw crossword spoilers you'll never believe. don't tell anybody\\) ||zoe kills a 5 letter noun at the end||", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -689,7 +689,7 @@ test("event2message: media spoilers work", async t => { messagesToSend: [{ username: "underscore_x", content: "", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=U&hue=270", attachments: [{id: "0", filename: "SPOILER_pitstop.png"}], pendingFiles: [{ mxc: "mxc://agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT", @@ -735,7 +735,7 @@ test("event2message: media spoilers with reason work", async t => { parse: ["users", "roles"] }, content: "(Spoiler: golden witch solutions)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=U&hue=270", attachments: [{id: "0", filename: "SPOILER_pitstop.png"}], pendingFiles: [{ mxc: "mxc://agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT", @@ -781,7 +781,7 @@ test("event2message: spoiler files too large for Discord are linked and retain r parse: ["users", "roles"] }, content: "(Spoiler: golden witch secrets)\n🖼️ _Uploaded **SPOILER** file: ||[pitstop.png](https://bridge.example.org/download/matrix/agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT )|| (40 MB)_", - avatar_url: undefined + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=U&hue=270" }] } ) @@ -812,7 +812,7 @@ test("event2message: markdown syntax is escaped", async t => { messagesToSend: [{ username: "cadence [they]", content: "this \\*\\*is\\*\\* an **_extreme_** \\\\\\*test\\\\\\* of", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -846,7 +846,7 @@ test("event2message: html lines are bridged correctly", async t => { messagesToSend: [{ username: "cadence [they]", content: "paragraph one\nline _two_\nline three\n\nparagraph two\nline _two_\nline three\n\nparagraph three\n\nparagraph four\nline two\nline three\nline four\n\nparagraph five", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -880,7 +880,7 @@ test("event2message: html lines are bridged correctly", async t => { messagesToSend: [{ username: "cadence [they]", content: "line one: test test\nline two: **test** **test**\nline three: **test test**\nline four: test test\n line five", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -915,7 +915,7 @@ test("event2message: whitespace is collapsed", async t => { messagesToSend: [{ username: "cadence [they]", content: "line one: test test\nline two: **test** **test**\nline three: **test test**\nline four: test test\nline five", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -951,7 +951,7 @@ test("event2message: lists are bridged correctly", async t => { messagesToSend: [{ username: "cadence [they]", content: "* line one\n* line two\n* line three\n * nested one\n * nested two\n* line four", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -983,14 +983,14 @@ test("event2message: long messages are split", async t => { messagesToSend: [{ username: "cadence [they]", content: (("a".repeat(130) + " ").repeat(15)).slice(0, -1), - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } }, { username: "cadence [they]", content: (("a".repeat(130) + " ").repeat(4)).slice(0, -1), - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1024,7 +1024,7 @@ test("event2message: code blocks work", async t => { messagesToSend: [{ username: "cadence [they]", content: "preceding\n\n```\ncode block\n```\n\nfollowing `code` is inline", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1054,7 +1054,7 @@ test("event2message: code block contents are formatted correctly and not escaped messagesToSend: [{ username: "cadence [they]", content: "```\ninput = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n_input_ = input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n```\n\n`input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,`", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1084,7 +1084,7 @@ test("event2message: code blocks use double backtick as delimiter when necessary messagesToSend: [{ username: "cadence [they]", content: "``backtick in ` the middle``, `` backtick at the edge` ``", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1114,7 +1114,7 @@ test("event2message: inline code is converted to code block if it contains both messagesToSend: [{ username: "cadence [they]", content: "``` ` one two `` ```", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1146,7 +1146,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con content: "So if you run code like this `[inline_code.java]` it should print a markdown formatted code block", attachments: [{id: "0", filename: "inline_code.java"}], pendingFiles: [{name: "inline_code.java", buffer: Buffer.from('System.out.println("```");')}], - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1178,7 +1178,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con content: "So if you run code like this `[inline_code.txt]` it should print a markdown formatted code block", attachments: [{id: "0", filename: "inline_code.txt"}], pendingFiles: [{name: "inline_code.txt", buffer: Buffer.from('System.out.println("```");')}], - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1210,7 +1210,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con content: "So if you run code like this `[inline_code.txt]` it should print a markdown formatted code block", attachments: [{id: "0", filename: "inline_code.txt"}], pendingFiles: [{name: "inline_code.txt", buffer: Buffer.from('System.out.println("```");')}], - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1242,7 +1242,7 @@ test("event2message: code blocks are uploaded as attachments instead if they are content: "So if you run code like this `[inline_code.js]` it should print a markdown formatted code block", attachments: [{id: "0", filename: "inline_code.js"}], pendingFiles: [{name: "inline_code.js", buffer: Buffer.from("A".repeat(2000))}], - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1285,7 +1285,7 @@ test("event2message: characters are encoded properly in code blocks", async t => + '\n .map(|c| c.get(1).unwrap().as_str())' + '\n .collect::();' )}], - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1319,7 +1319,7 @@ test("event2message: quotes have an appropriate amount of whitespace", async t = messagesToSend: [{ username: "cadence [they]", content: "> Chancellor of Germany Angela Merkel, on March 17, 2017: they did not shake hands\n🤨", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1360,7 +1360,7 @@ test("event2message: lists have appropriate line breaks", async t => { messagesToSend: [{ username: "Milan", content: `i am not certain what you mean by "already exists with as discord". my goals are\n\n* bridgeing specific channels with existing matrix rooms\n * optionally maybe entire "servers"\n* offering the bridge as a public service`, - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=M&hue=210", allowed_mentions: { parse: ["roles"], users: [] @@ -1402,7 +1402,7 @@ test("event2message: ordered list start attribute works", async t => { messagesToSend: [{ username: "Milan", content: `i am not certain what you mean by "already exists with as discord". my goals are\n\n1. bridgeing specific channels with existing matrix rooms\n 2. optionally maybe entire "servers"\n2. offering the bridge as a public service`, - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=M&hue=210", allowed_mentions: { parse: ["roles"], users: [] @@ -1435,7 +1435,7 @@ test("event2message: m.emote plaintext works", async t => { messagesToSend: [{ username: "cadence [they]", content: "\\* cadence \\[they\\] tests an m.emote message", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1469,7 +1469,7 @@ test("event2message: m.emote markdown syntax is escaped", async t => { messagesToSend: [{ username: "cadence [they]", content: "\\* cadence \\[they\\] shows you \\*\\*her\\*\\* **_extreme_** \\\\\\*test\\\\\\* of", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1706,7 +1706,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + " I just checked in a fix that will probably work..." + "\nwill try later (tomorrow if I don't forgor)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -2040,7 +2040,7 @@ test("event2message: should suppress embeds for links in reply preview", async t content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1273204543739396116 **Ⓜcadence [they]**:" + " " + `\nEveryone in the comments is like "you're so ahead of the curve" and "can't believe this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=R&hue=240", allowed_mentions: { parse: ["users", "roles"] } @@ -2316,7 +2316,7 @@ test("event2message: reply preview converts emoji formatting when replying to a content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + " <:hippo:230201364309868544>" + "\nreply", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -2366,7 +2366,7 @@ test("event2message: reply preview can guess custom emoji based on the name if i content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + " <:hippo:230201364309868544>" + "\nreply", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -2416,7 +2416,7 @@ test("event2message: reply preview uses emoji title text when replying to an unk content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + " :svkftngur_gkdne:" + "\nreply", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -2466,7 +2466,7 @@ test("event2message: reply preview ignores garbage image", async t => { content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + " I am having a nice day" + "\nreply", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -2515,7 +2515,7 @@ test("event2message: reply to empty message doesn't show an extra line or anythi username: "cadence [they]", content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**" + "\nreply", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3311,7 +3311,7 @@ test("event2message: rich reply with an image", async t => { id: "0", }, ], - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", pendingFiles: [ { mxc: "mxc://cadence.moe/yxMobQMbSqNHpajxgSHtaooG", @@ -3348,7 +3348,7 @@ test("event2message: raw mentioning discord users in plaintext body works", asyn messagesToSend: [{ username: "cadence [they]", content: "<@114147806469554185> what do you think?", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3382,7 +3382,7 @@ test("event2message: raw mentioning discord users in formatted body works", asyn messagesToSend: [{ username: "cadence [they]", content: "<@114147806469554185> what do you think?", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3416,7 +3416,7 @@ test("event2message: mentioning discord users works", async t => { messagesToSend: [{ username: "cadence [they]", content: "I'm just <@114147806469554185> testing mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3457,7 +3457,7 @@ test("event2message: mentioning discord users with extra html attributes works", messagesToEdit: [], messagesToSend: [{ username: "lavender.pet", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=L&hue=30", content: "also <@196188877885538304> fun fact at some point there is plans for FTE to have a built in map editor", allowed_mentions: { parse: ["roles"], @@ -3494,7 +3494,7 @@ test("event2message: mentioning discord users works when URL encoded", async t = messagesToSend: [{ username: "cadence [they]", content: "<@771520384671416320> a sample message", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3528,7 +3528,7 @@ test("event2message: mentioning PK discord users works", async t => { messagesToSend: [{ username: "cadence [they]", content: "I'm just **@Azalea &flwr; 🌺** (<@196188877885538304>) testing mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3562,7 +3562,7 @@ test("event2message: mentioning matrix users works", async t => { messagesToSend: [{ username: "cadence [they]", content: "I'm just [@▲]() testing mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3603,7 +3603,7 @@ test("event2message: matrix mentions are not double-escaped when embed links per messagesToSend: [{ username: "cadence [they]", content: "I'm just [@▲]() testing mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3637,7 +3637,7 @@ test("event2message: multiple mentions are both escaped", async t => { messagesToSend: [{ username: "cadence [they]", content: "[@cadence:cadence.moe]() can you kick my old account over there [@amyiscoolz:matrix.atiusamy.com]()", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3667,7 +3667,7 @@ test("event2message: mentioning matrix users works even when Element disambiguat messagesToSend: [{ username: "cadence [they]", content: "[@unascribed]() if you want to run some experimental software, `11864f80cf` branch of OOYE has _vastly_ improved handling of PluralKit users. feel free to try it out, if you find bugs I'd appreciate you letting me know (just tag me at the place in chat where something went wrong)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3701,7 +3701,7 @@ test("event2message: mentioning bridged rooms works", async t => { messagesToSend: [{ username: "cadence [they]", content: "I'm just <#1100319550446252084> testing channel mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3733,7 +3733,7 @@ test("event2message: mentioning bridged rooms works (plaintext body)", async t = messagesToSend: [{ username: "cadence [they]", content: "I'm just <#1100319550446252084> testing channel mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3776,7 +3776,7 @@ test("event2message: mentioning bridged rooms by alias works", async t => { messagesToSend: [{ username: "cadence [they]", content: "I'm just <#1100319550446252084> testing channel mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3818,7 +3818,7 @@ test("event2message: mentioning bridged rooms by alias works (plaintext body)", messagesToSend: [{ username: "cadence [they]", content: "I'm just <#1100319550446252084> testing channel mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3859,7 +3859,7 @@ test("event2message: mentioning bridged rooms by alias skips the link when alias messagesToSend: [{ username: "cadence [they]", content: "I'm just and <#1100319550446252084> testing channel mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3892,7 +3892,7 @@ test("event2message: mentioning known bridged events works (plaintext body)", as messagesToSend: [{ username: "cadence [they]", content: "it was uploaded earlier in https://discord.com/channels/497159726455455754/497161350934560778/1141619794500649020, take a look!", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3926,7 +3926,7 @@ test("event2message: mentioning known bridged events works (partially formatted messagesToSend: [{ username: "cadence [they]", content: "it was uploaded earlier in https://discord.com/channels/497159726455455754/497161350934560778/1141619794500649020", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3960,7 +3960,7 @@ test("event2message: mentioning known bridged events works (formatted body)", as messagesToSend: [{ username: "cadence [they]", content: "it was uploaded earlier in https://discord.com/channels/497159726455455754/497161350934560778/1141619794500649020", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3994,7 +3994,7 @@ test("event2message: mentioning known bridged events followed by line break and messagesToSend: [{ username: "cadence [they]", content: "https://discord.com/channels/497159726455455754/497161350934560778/1141619794500649020<@114147806469554185>", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4040,7 +4040,7 @@ test("event2message: mentioning unknown bridged events can approximate with time messagesToSend: [{ username: "cadence [they]", content: "it was uploaded years ago in https://discord.com/channels/497159726455455754/497161350934560778/753895613661184000", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4085,7 +4085,7 @@ test("event2message: mentioning events falls back to original link when server d messagesToSend: [{ username: "cadence [they]", content: "it was uploaded years ago in [amanda-spam]()", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4129,7 +4129,7 @@ test("event2message: mentioning events falls back to original link when the chan messagesToSend: [{ username: "cadence [they]", content: "it was uploaded years ago in [ex-room-doesnt-exist-any-more]()", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4159,7 +4159,7 @@ test("event2message: link to event in an unknown room (href link)", async t => { messagesToSend: [{ username: "cadence [they]", content: "ah yeah, here's where the bug was reported: ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4189,7 +4189,7 @@ test("event2message: link to event in an unknown room (bare link)", async t => { messagesToSend: [{ username: "cadence [they]", content: "PK API failure, tho idk how you'd handle that ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4217,7 +4217,7 @@ test("event2message: link to event in an unknown room (plaintext)", async t => { messagesToSend: [{ username: "cadence [they]", content: "ah yeah, here's where the bug was reported: ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4251,7 +4251,7 @@ test("event2message: colon after mentions is stripped", async t => { messagesToSend: [{ username: "cadence [they]", content: "<@114147806469554185> hey, I'm just [@▲]() testing mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4392,7 +4392,7 @@ test("event2message: skips caching the member if the member does not exist, some messagesToSend: [{ username: "not_real", content: "should honestly never happen", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=N&hue=180", allowed_mentions: { parse: ["users", "roles"] } @@ -4439,7 +4439,7 @@ test("event2message: overly long usernames are shifted into the message content" messagesToSend: [{ username: "I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS", content: "**IMPORTANT and I DON'T MATTER**\ntesting the member state cache", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=I&hue=270", allowed_mentions: { parse: ["users", "roles"] } @@ -4475,7 +4475,7 @@ test("event2message: overly long usernames are not treated specially when the ms messagesToSend: [{ username: "I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS", content: "\\* I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS IMPORTANT and I DON'T MATTER looks at the start of the message", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=I&hue=270", allowed_mentions: { parse: ["users", "roles"] } @@ -5000,7 +5000,7 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi messagesToSend: [{ username: "cadence [they]", content: "[YESYESYES](https://bridge.example.org/download/sticker/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf/_.webp)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5030,7 +5030,7 @@ test("event2message: static emojis work", async t => { messagesToSend: [{ username: "cadence [they]", content: "<:hippo:230201364309868544>", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5090,7 +5090,7 @@ test("event2message: animated emojis work", async t => { messagesToSend: [{ username: "cadence [they]", content: "", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5120,7 +5120,7 @@ test("event2message: unknown emojis in the middle are linked", async t => { messagesToSend: [{ username: "cadence [they]", content: "a [:ms_robot_grin:](https://bridge.example.org/download/matrix/cadence.moe/RLMgJGfgTPjIQtvvWZsYjhjy) b", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5169,7 +5169,7 @@ test("event2message: guessed @mentions in plaintext may join members to mention" messagesToSend: [{ username: "cadence [they]", content: "hey <@321876634777218072>, what food would you like to order?", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5222,7 +5222,7 @@ test("event2message: guessed @mentions in formatted body may join members to men messagesToSend: [{ username: "cadence [they]", content: "**_HEY <@321876634777218072>, WHAT FOOD WOULD YOU LIKE TO ORDER??_**", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5265,7 +5265,7 @@ test("event2message: guessed @mentions feature will not activate on links or cod messagesToSend: [{ username: "cadence [they]", content: "in link [view timeline](https://example.com/social/@subtext) in autolink https://example.com/social/@subtext in pre-code```\n@subtext\n```", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5295,7 +5295,7 @@ test("event2message: guessed @mentions work with other matrix bridge old users", messagesToSend: [{ username: "cadence [they]", content: "<@114147806469554185> <@176943908762006200> back me up on this sentiment, if not necessarily the phrasing", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5352,7 +5352,7 @@ test("event2message: @room converts to @everyone and is allowed when the room do messagesToSend: [{ username: "cadence [they]", content: "@everyone dinner's ready", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles", "everyone"] } @@ -5409,7 +5409,7 @@ test("event2message: @room converts to @everyone but is not allowed when the roo messagesToSend: [{ username: "cadence [they]", content: "@room dinner's ready", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5468,7 +5468,7 @@ test("event2message: @room converts to @everyone and is allowed if the user has messagesToSend: [{ username: "cadence [they]", content: "@everyone dinner's ready", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles", "everyone"] } @@ -5498,7 +5498,7 @@ test("event2message: @room in the middle of a link is not converted", async t => messagesToSend: [{ username: "cadence [they]", content: "https://github.com/@room/repositories https://github.com/@room/repositories", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5534,7 +5534,7 @@ test("event2message: table", async t => { + "\nAardvark Bee Crocodile" + "\nArgon Boron Carbon ```" + "more content", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5564,7 +5564,7 @@ test("event2message: unknown emoji at the end is used for sprite sheet", async t messagesToSend: [{ username: "cadence [they]", content: "a b [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FRLMgJGfgTPjIQtvvWZsYjhjy)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5594,7 +5594,7 @@ test("event2message: known emoji from an unreachable server at the end is used f messagesToSend: [{ username: "cadence [they]", content: "a b [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FbZFuuUSEebJYXUMSxuuSuLTa)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5624,7 +5624,7 @@ test("event2message: known and unknown emojis in the end are used for sprite she messagesToSend: [{ username: "cadence [they]", content: "known unknown: <:hippo:230201364309868544> [:ms_robot_dress:](https://bridge.example.org/download/matrix/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA) and known unknown: [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FWbYqNlACRuicynBfdnPYtmvc&e=cadence.moe%2FHYcztccFIPgevDvoaWNsEtGJ)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5691,7 +5691,7 @@ test("event2message: com.beeper.per_message_profile empty avatar_url clears avat messagesToSend: [{ username: "No Avatar User", content: "hello with cleared avatar", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=N&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5760,7 +5760,7 @@ test("event2message: displayname prefix is stripped from plain body when per-mes messagesToSend: [{ username: "Tidus Herboren", content: "one more test", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=T&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5789,7 +5789,7 @@ test("event2message: all unknown chess emojis are used for sprite sheet", async messagesToSend: [{ username: "cadence [they]", content: "testing [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FlHfmJpzgoNyNtYHdAmBHxXix&e=cadence.moe%2FMtRdXixoKjKKOyHJGWLsWLNU&e=cadence.moe%2FHXfFuougamkURPPMflTJRxGc&e=cadence.moe%2FikYKbkhGhMERAuPPbsnQzZiX&e=cadence.moe%2FAYPpqXzVJvZdzMQJGjioIQBZ&e=cadence.moe%2FUVuzvpVUhqjiueMxYXJiFEAj&e=cadence.moe%2FlHfmJpzgoNyNtYHdAmBHxXix&e=cadence.moe%2FMtRdXixoKjKKOyHJGWLsWLNU&e=cadence.moe%2FHXfFuougamkURPPMflTJRxGc&e=cadence.moe%2FikYKbkhGhMERAuPPbsnQzZiX&e=cadence.moe%2FAYPpqXzVJvZdzMQJGjioIQBZ&e=cadence.moe%2FUVuzvpVUhqjiueMxYXJiFEAj)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } diff --git a/src/web/routes/letter-avatar.js b/src/web/routes/letter-avatar.js new file mode 100644 index 0000000..d12b004 --- /dev/null +++ b/src/web/routes/letter-avatar.js @@ -0,0 +1,117 @@ +// @ts-check + +const h3 = require("h3") +const {defineEventHandler, getValidatedQuery, setResponseHeader} = h3 +const sharp = require("sharp") +const {z} = require("zod") + +const {as} = require("../../passthrough") +const {reg} = require("../../matrix/read-registration") + +/* + Create a 300x300 avatar image consisting of a dark coloured background, and a single character in a lighter colour centered in the middle. + Note: Where dimensions are changed, font size must also be changed too to produce an identical image as before. + Simply put, 100px = 60pt for font. +*/ + +const SIZE = 300 +const POSSIBLE_HUES = 12 + +/** Helper function: To get accurate complimenting colours we need to work in HSL, then convert back to RGB at the end */ +function hslToRgb(h, s, l) { + s /= 100; + l /= 100; + + const a = s * Math.min(l, 1 - l); + + const f = n => { + const k = (n + h / 30) % 12; + return l - a * Math.max(-1, Math.min(Math.min(k - 3, 9 - k), 1)); + }; + + return { + r: Math.round(255 * f(0)), + g: Math.round(255 * f(8)), + b: Math.round(255 * f(4)) + }; +} + +/** + * Use the MXID to generate deterministic avatar colours for each user. + * Here, we use the string hash code as a hue value, with a 360 wrap modulo. + * @param {string} mxid + */ +function mxidToHue(mxid) { + // Element Classic string hasher + let hash = 0; + let i; + let chr; + if (mxid.length === 0) { + return hash; + } + for (i = 0; i < mxid.length; i++) { + chr = mxid.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; + hash |= 0; + } + hash = Math.abs(hash) + return (hash % POSSIBLE_HUES) * (360 / POSSIBLE_HUES) +} + +/** + * Get first useful character in username to put in the avatar. + * @param {string} username + */ +function usernameToLetter(username) { + return (username.match(/[a-z0-9]/i)?.[0] || "#").toUpperCase() +} + +/** + * @param {string} mxid + * @param {string} username + */ +function getLetterAvatarURL(mxid, username) { + const p = new URLSearchParams({letter: usernameToLetter(username), hue: String(mxidToHue(mxid))}) + return `${reg.ooye.bridge_origin}/download/letter-avatar?${p}` +} + +const schema = { + letterAvatar: z.object({ + hue: z.coerce.number().min(0).max(360), + letter: z.string().regex(/^[A-Z0-9#]$/) + }) +} + +/** + * Produce a PNG letter-avatar from given parameters. + * @param {string} letter + * @param {number} hue + */ +as.router.get("/download/letter-avatar", defineEventHandler(async event => { + const {letter, hue} = await getValidatedQuery(event, schema.letterAvatar.parse) + + const bg_rgb = hslToRgb(hue, 65, 18); + const text_rgb = hslToRgb(hue, 70, 65); + const text_rgbahex = `#${text_rgb.r.toString(16).padStart(2, "0")}${text_rgb.g.toString(16).padStart(2, "0")}${text_rgb.b.toString(16).padStart(2, "0")}ff` + + const streamOut = sharp({ + create: { + width: SIZE, height: SIZE, channels: 4, + background: { + r: bg_rgb.r, g: bg_rgb.g, b: bg_rgb.b, alpha: 1 + } + } + }).composite([{ + input: { + text: { + text: `${letter}`, + font: "Noto Sans Bold 180", align: "center", rgba: true + } + } + }]).png() + + setResponseHeader(event, "content-type", "image/png") + return streamOut +})) + +module.exports.getLetterAvatarURL = getLetterAvatarURL diff --git a/src/web/server.js b/src/web/server.js index 837e14d..77ba3ed 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -130,6 +130,7 @@ sync.require("./routes/download-discord") sync.require("./routes/guild-settings") sync.require("./routes/guild") sync.require("./routes/info") +sync.require("./routes/letter-avatar") sync.require("./routes/link") sync.require("./routes/log-in-with-matrix") sync.require("./routes/oauth") From 7f7a366cd541e6ef98d652c2031f2fadecd136cb Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 22 May 2026 14:34:59 +1200 Subject: [PATCH 129/142] Fix tests for command emoji change --- .../message-to-event.test.embeds.js | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/d2m/converters/message-to-event.test.embeds.js b/src/d2m/converters/message-to-event.test.embeds.js index b193931..91bbe2b 100644 --- a/src/d2m/converters/message-to-event.test.embeds.js +++ b/src/d2m/converters/message-to-event.test.embeds.js @@ -8,9 +8,9 @@ test("message2event embeds: interaction loading", async t => { const events = await messageToEvent(data.interaction_message.thinking_interaction, data.guild.general, {}) t.deepEqual(events, [{ $type: "m.room.message", - body: "↪️ Brad used `/stats` — interaction loading...", + body: "❭ Brad used `/stats` — interaction loading...", format: "org.matrix.custom.html", - formatted_body: "
↪️ Brad used /stats — interaction loading...
", + formatted_body: "
Brad used /stats — interaction loading...
", "m.mentions": {}, msgtype: "m.notice", }]) @@ -22,12 +22,12 @@ test("message2event embeds: nothing but a field", async t => { $type: "m.room.message", "m.mentions": {}, msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" + body: "❭ PapiOphidian used `/stats`" + "\n| ### Amanda 🎵#2192 :online:" + "\n| willow tree, branch 0" + "\n| **❯ Uptime:**\n| 3m 55s\n| **❯ Memory:**\n| 64.45MB", format: "org.matrix.custom.html", - formatted_body: '
↪️ PapiOphidian used /stats
' + formatted_body: '
PapiOphidian used /stats
' + '

Amanda 🎵#2192 \":online:\"' + '
willow tree, branch 0
' + '
❯ Uptime:
3m 55s' @@ -153,10 +153,10 @@ test("message2event embeds: title without url", async t => { t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" + body: "❭ PapiOphidian used `/stats`" + "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!", format: "org.matrix.custom.html", - formatted_body: '

↪️ PapiOphidian used /stats
' + formatted_body: '
PapiOphidian used /stats
' + `

Hi, I'm Amanda!

I condone pirating music!

`, "m.mentions": {} }]) @@ -167,10 +167,10 @@ test("message2event embeds: url without title", async t => { t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" + body: "❭ PapiOphidian used `/stats`" + "\n| I condone pirating music!", format: "org.matrix.custom.html", - formatted_body: '
↪️ PapiOphidian used /stats
' + formatted_body: '
PapiOphidian used /stats
' + `

I condone pirating music!

`, "m.mentions": {} }]) @@ -181,10 +181,10 @@ test("message2event embeds: author without url", async t => { t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" + body: "❭ PapiOphidian used `/stats`" + "\n| ## Amanda\n| \n| I condone pirating music!", format: "org.matrix.custom.html", - formatted_body: '
↪️ PapiOphidian used /stats
' + formatted_body: '
PapiOphidian used /stats
' + `

Amanda

I condone pirating music!

`, "m.mentions": {} }]) @@ -195,10 +195,10 @@ test("message2event embeds: author url without name", async t => { t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" + body: "❭ PapiOphidian used `/stats`" + "\n| I condone pirating music!", format: "org.matrix.custom.html", - formatted_body: '
↪️ PapiOphidian used /stats
' + formatted_body: '
PapiOphidian used /stats
' + `

I condone pirating music!

`, "m.mentions": {} }]) From 9b37705a73a8781fa523f2a05d9fb6947b12dc0e Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 28 May 2026 13:18:18 +1200 Subject: [PATCH 130/142] Indicate that errors may be retried --- src/m2d/event-dispatcher.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 0d1c529..352ca41 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -148,7 +148,7 @@ async function sendError(roomID, source, type, e, payload) { // Send try { - await api.sendEvent(roomID, "m.room.message", { + const errorEventID = await api.sendEvent(roomID, "m.room.message", { ...builder.get(), "moe.cadence.ooye.error": { source: source.toLowerCase(), @@ -158,6 +158,14 @@ async function sendError(roomID, source, type, e, payload) { user_ids: ["@cadence:cadence.moe"] } }) + // Add reaction indicating that errors may be retried + await api.sendEvent(roomID, "m.reaction", { + "m.relates_to": { + rel_type: "m.annotation", + event_id: errorEventID, + key: "🔁" + } + }) } catch (e) {} } @@ -177,6 +185,7 @@ const errorRetrySema = new Semaphore() * @param {Ty.Event.Outer} reactionEvent */ async function onRetryReactionAdd(reactionEvent) { + if (reactionEvent.sender === `@${reg.sender_localpart}:${reg.ooye.server_name}`) return // Don't respond to the bot's own indicative reaction const roomID = reactionEvent.room_id await errorRetrySema.request(async () => { const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id) From ee406caf24fa3d8be4adfb15d6edc2693ad06aa2 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 28 May 2026 13:20:35 +1200 Subject: [PATCH 131/142] Update CloudStorm --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 30dfe15..eb07b4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "ansi-colors": "^4.1.3", "better-sqlite3": "^12.2.0", "chunk-text": "^2.0.1", - "cloudstorm": "^0.17.0", + "cloudstorm": "^0.17.1", "discord-api-types": "^0.38.38", "domino": "^2.1.6", "enquirer": "^2.4.1", @@ -1316,12 +1316,12 @@ } }, "node_modules/cloudstorm": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.17.0.tgz", - "integrity": "sha512-zsd9y5ljNnbxdvDid9TgWePDqo7il4so5spzx6NDwZ67qWQjR96UUhLxJ+BAOdBBSPF9UXFM61dAzC2g918q+A==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.17.1.tgz", + "integrity": "sha512-LYUwzHagRYRd93XocOqi+HCHdzPYI9cW7Yf7pYqinxgG+Qka1OiqBKWTCcLiEuiqXaOV30kr8c6aZ/c1QcDP4Q==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.40", + "discord-api-types": "^0.38.47", "snowtransfer": "^0.17.5" }, "engines": { diff --git a/package.json b/package.json index 59c3a0a..9dfd2a8 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "ansi-colors": "^4.1.3", "better-sqlite3": "^12.2.0", "chunk-text": "^2.0.1", - "cloudstorm": "^0.17.0", + "cloudstorm": "^0.17.1", "discord-api-types": "^0.38.38", "domino": "^2.1.6", "enquirer": "^2.4.1", From aecfde54c88d5f300ce64dde1f28ab1b3efe5235 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 29 May 2026 20:10:01 +1200 Subject: [PATCH 132/142] Resize avatars before sending to Discord --- src/m2d/converters/event-to-message.js | 6 +- src/m2d/converters/event-to-message.test.js | 80 ++++++++++----------- src/web/routes/download-matrix.js | 31 +++++++- 3 files changed, 72 insertions(+), 45 deletions(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index cc37084..0a18a14 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -584,8 +584,10 @@ async function eventToMessage(event, guild, channel, di) { displayNameRunoff = "" } - // If undefined, generate letter avatar instead of using Discord default - if (avatarURL == undefined) { + // Avatar post-processing. Use a thumbnail for media, or generate letter avatar if none present. + if (avatarURL) { + avatarURL = avatarURL + "?preset=avatar" + } else { avatarURL = letterAvatar.getLetterAvatarURL(event.sender, displayNameShortened) } diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 30e7aff..650e442 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -1522,7 +1522,7 @@ test("event2message: rich reply to a sim user", async t => { content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + " Slow news day." + "\nTesting this reply, ignore", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -1578,7 +1578,7 @@ test("event2message: rich reply to a sim user, explicitly enabling mentions in c content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + " Slow news day." + "\nTesting this reply, ignore", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["roles"], users: ["111604486476181504"] @@ -1633,7 +1633,7 @@ test("event2message: rich reply to a sim user, explicitly disabling mentions in content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + " Slow news day." + "\nTesting this reply, ignore", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["roles"], users: [] @@ -1786,7 +1786,7 @@ test("event2message: rich reply to an already-edited message will quote the new content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647><@111604486476181504>:" + " this is the new content. heya!" + "\nhiiiii....", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -1839,7 +1839,7 @@ test("event2message: rich reply to a missing event will quote from formatted_bod username: "cadence [they]", content: "-# > But who sees the seashells she sells sitting..." + "\nWhat a tongue-bender...", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -1888,7 +1888,7 @@ test("event2message: rich reply to a missing event without formatted_body will u messagesToSend: [{ username: "cadence [they]", content: "Testing this reply, ignore", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -1939,7 +1939,7 @@ test("event2message: rich reply to a missing event and no reply fallback will no messagesToSend: [{ username: "cadence [they]", content: "Testing this reply, ignore.", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -1991,7 +1991,7 @@ test("event2message: should avoid using blockquote contents as reply preview in content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + " that can't be true! there's no way :o" + "\nI agree!", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2129,7 +2129,7 @@ test("event2message: should include a reply preview when message ends with a blo content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜ_ooye_cookie**:" + " tanget: @..." + "\naichmophobia", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2213,7 +2213,7 @@ test("event2message: should include a reply preview when replying to a descripti content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/497161350934560778/1162625810109317170 <@1109360903096369153>:" + " It looks like this queue has ended." + `\nso you're saying on matrix side I would have to edit ^this^ to add "Timed out" before the blockquote?`, - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2266,7 +2266,7 @@ test("event2message: entities are not escaped in main message or reply preview", content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + " Testing? \"':.`[]&things" + "\n_Testing?_ \"':.\\`\\[\\]&things", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2598,7 +2598,7 @@ test("event2message: editing a rich reply to a sim user", async t => { content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + " Slow news day." + "\nEditing this reply, which is also a test", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2654,7 +2654,7 @@ test("event2message: editing a plaintext body message", async t => { message: { username: "cadence [they]", content: "well, I guess it's no longer brand new... it's existed for mere seconds...", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2709,7 +2709,7 @@ test("event2message: editing a plaintext message to be longer", async t => { message: { content: "aaaaaaaaa ".repeat(198) + "well, I guess it's", username: "cadence [they]", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2718,7 +2718,7 @@ test("event2message: editing a plaintext message to be longer", async t => { messagesToSend: [{ content: "no longer brand new... it's existed for mere seconds..." + ("aaaaaaaaa ".repeat(20)).slice(0, -1), username: "cadence [they]", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2771,7 +2771,7 @@ test("event2message: editing a plaintext message to be shorter", async t => { message: { username: "cadence [they]", content: "well, I guess it's no longer brand new... it's existed for mere seconds...", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2832,7 +2832,7 @@ test("event2message: editing a formatted body message", async t => { message: { username: "cadence [they]", content: "**well, I guess it's no longer brand new... it's existed for mere seconds...**", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2889,7 +2889,7 @@ test("event2message: rich reply to a matrix user's long message with formatting" content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 **Ⓜcadence [they]**:" + " i should have a little happy test list bold em..." + "\n**no you can't!!!**", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2951,7 +2951,7 @@ test("event2message: rich reply to an image", async t => { username: "cadence [they]", content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504> 🖼️" + "\nCaught in 8K UHD VR QLED Epic Edition", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -3007,7 +3007,7 @@ test("event2message: rich reply to a spoiler should ensure the spoiler is hidden content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + " [spoiler] cw crossword spoilers you'll never..." + "\nomg NO WAY!!", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -3062,7 +3062,7 @@ test("event2message: with layered rich replies, the preview should only be the r content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 **Ⓜcadence [they]**:" + " two" + "\nthree", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -3119,7 +3119,7 @@ test("event2message: if event is a reply and starts with a quote, they should be + " i have a feeling that clients are meant to strip..." + "\n" + "\n> To strip the fallback on the `body`, the client should iterate over each line of the string, removing any lines that start with the fallback prefix (\"> “, including the space, without quotes) and stopping when a line is encountered without the prefix. This prefix is known as the “fallback prefix sequence”.", - avatar_url: "https://bridge.example.org/download/matrix/syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP", + avatar_url: "https://bridge.example.org/download/matrix/syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -3177,7 +3177,7 @@ test("event2message: rich reply to a deleted event", async t => { username: "Ampflower 🌺", content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**ⓂAmpflower 🌺** (in reply to a deleted message)" + "\nHuh it did the same thing here too", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -3223,7 +3223,7 @@ test("event2message: rich reply to a state event with no body", async t => { messagesToSend: [{ username: "Ampflower 🌺", content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647> (channel details edited)\nnice room topic", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -4296,7 +4296,7 @@ test("event2message: caches the member if the member is not known", async t => { messagesToSend: [{ username: "should_be_newly_cached", content: "testing the member state cache", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -4346,7 +4346,7 @@ test("event2message: does not cache the member if the room is not known", async messagesToSend: [{ username: "should_not_be_cached", content: "testing the member state cache", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -4508,7 +4508,7 @@ test("event2message: text attachments work", async t => { messagesToSend: [{ username: "cadence [they]", content: "", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", attachments: [{id: "0", filename: "chiki-powerups.txt"}], pendingFiles: [{name: "chiki-powerups.txt", mxc: "mxc://cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}] }] @@ -4544,7 +4544,7 @@ test("event2message: image attachments work", async t => { messagesToSend: [{ username: "cadence [they]", content: "", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", attachments: [{id: "0", filename: "cool cat.png"}], pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] }] @@ -4580,7 +4580,7 @@ test("event2message: image attachments can have a plaintext caption", async t => messagesToSend: [{ username: "cadence [they]", content: "Cat emoji surrounded by pink hearts", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", attachments: [{id: "0", filename: "cool cat.png"}], pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}], allowed_mentions: { @@ -4629,7 +4629,7 @@ test("event2message: image attachments can have a formatted caption", async t => messagesToSend: [{ username: "cadence [they]", content: "this event has `formatting`", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", attachments: [{id: "0", filename: "5740.jpg"}], pendingFiles: [{name: "5740.jpg", mxc: "mxc://thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh"}], allowed_mentions: { @@ -4682,7 +4682,7 @@ test("event2message: encrypted image attachments work", async t => { messagesToSend: [{ username: "cadence [they]", content: "", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", attachments: [{id: "0", filename: "image.png"}], pendingFiles: [{ name: "image.png", @@ -4767,7 +4767,7 @@ test("event2message: evil encrypted image attachment works", async t => { messagesToSend: [{ username: "Austin Huang", content: "", - avatar_url: "https://bridge.example.org/download/matrix/tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e", + avatar_url: "https://bridge.example.org/download/matrix/tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e?preset=avatar", attachments: [{id: "0", filename: "Screenshot 2025-06-29 at 13.36.46.png"}], pendingFiles: [{ name: "Screenshot 2025-06-29 at 13.36.46.png", @@ -4810,7 +4810,7 @@ test("event2message: large attachments are uploaded if the server boost level is messagesToSend: [{ username: "cadence [they]", content: "", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", attachments: [{id: "0", filename: "cool cat.png"}], pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] }] @@ -4846,7 +4846,7 @@ test("event2message: files too large for Discord are linked as as URL", async t messagesToSend: [{ username: "cadence [they]", content: "🖼️ _Uploaded file: [cool cat.png](https://bridge.example.org/download/matrix/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn) (40 MB)_", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -4883,7 +4883,7 @@ test("event2message: files too large for Discord can have a plaintext caption", messagesToSend: [{ username: "cadence [they]", content: "Cat emoji surrounded by pink hearts\n🖼️ _Uploaded file: [cool cat.png](https://bridge.example.org/download/matrix/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn) (40 MB)_", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -4930,7 +4930,7 @@ test("event2message: files too large for Discord can have a formatted caption", messagesToSend: [{ username: "cadence [they]", content: "this event has `formatting`\n🖼️ _Uploaded file: [5740.jpg](https://bridge.example.org/download/matrix/thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh) (40 MB)_", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -4972,7 +4972,7 @@ test("event2message: stickers work", async t => { messagesToSend: [{ username: "cadence [they]", content: "[get_real2](https://bridge.example.org/download/sticker/cadence.moe/NyMXQFAAdniImbHzsygScbmN/_.webp)", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -5060,7 +5060,7 @@ test("event2message: emojis in other servers are reused if they have the same ti messagesToSend: [{ username: "cadence [they]", content: "<:hippo:230201364309868544>", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -5658,7 +5658,7 @@ test("event2message: com.beeper.per_message_profile overrides displayname and av messagesToSend: [{ username: "Unstable Name", content: "hello from unstable profile", - avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo", + avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -5727,7 +5727,7 @@ test("event2message: data-mx-profile-fallback element is stripped from formatted messagesToSend: [{ username: "Tidus Herboren", content: "one more test", - avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo", + avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } diff --git a/src/web/routes/download-matrix.js b/src/web/routes/download-matrix.js index 82e2f7e..6d2772b 100644 --- a/src/web/routes/download-matrix.js +++ b/src/web/routes/download-matrix.js @@ -3,6 +3,9 @@ const assert = require("assert/strict") const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, createError, H3Event, getValidatedQuery} = require("h3") const {z} = require("zod") +const {ReadableStream} = require("stream/web") +const {Readable} = require("stream") +const sharp = require("sharp") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore let hasher = null @@ -19,11 +22,27 @@ const emojiSheetConverter = sync.require("../../m2d/converters/emoji-sheet") /** @type {import("../../m2d/actions/sticker")} */ const sticker = sync.require("../../m2d/actions/sticker") +// Resizing client-side because server-side is too slow, at least with Synapse. Really need it to be fast because webhook avatars show a placeholder in the interim. +/** @type {{[presetKey: string]: (body: ReadableStream) => ReadableStream}} */ +const MEDIA_THUMBNAIL_PRESETS = { + avatar: body => + Readable.toWeb( + Readable.fromWeb(body).pipe( + sharp() + .resize({height: 210, width: 210, fit: "cover"}) // the largest display of the webhook pfp on Discord Android in screen pixels + .jpeg({force: false, quality: 90}) // File size works out to up to ~110k for a PNG, less for a JPEG + ) + ) +} + const schema = { - params: z.object({ + media: z.object({ server_name: z.string(), media_id: z.string() }), + mediaQuery: z.object({ + preset: z.enum(Object.keys(MEDIA_THUMBNAIL_PRESETS)) // list of possible thumbnail presets + }), sheet: z.object({ e: z.array(z.string()).or(z.string()) }), @@ -65,7 +84,8 @@ function verifyMediaHash(serverAndMediaID) { } as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => { - const params = await getValidatedRouterParams(event, schema.params.parse) + const params = await getValidatedRouterParams(event, schema.media.parse) + const query = await getValidatedQuery(event, schema.mediaQuery.safeParse) verifyMediaHash(`${params.server_name}/${params.media_id}`) const api = getAPI(event) @@ -77,7 +97,12 @@ as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(asyn setResponseStatus(event, res.status) setResponseHeader(event, "Content-Type", contentType) setResponseHeader(event, "Transfer-Encoding", "chunked") - return res.body + + if (res.ok && query.success) { + return MEDIA_THUMBNAIL_PRESETS[query.data.preset](res.body) + } else { + return res.body + } })) as.router.get(`/download/sheet`, defineEventHandler(async event => { From 16867d57fb1b9fbfc1b3f11e63ddb93b678d265f Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 29 May 2026 20:10:32 +1200 Subject: [PATCH 133/142] Rework how getMedia does thumbnails --- src/matrix/api.js | 22 +++++++++++++++++----- src/stdin.js | 2 ++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/matrix/api.js b/src/matrix/api.js index f24f4d9..9b7f280 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -463,17 +463,29 @@ async function ping() { } /** - * Given an mxc:// URL, and an optional height for thumbnailing, get the file from the content repository. Returns res. + * Given an mxc:// URL, and optional parameters for thumbnailing, get the file from the content repository. Returns res. + * + * Note that Synapse currently doesn't support animated thumbnails: https://github.com/element-hq/synapse/pull/18831 + * @see https://spec.matrix.org/v1.18/client-server-api/#get_matrixclientv1mediathumbnailservernamemediaid * @param {string} mxc - * @param {RequestInit & {height?: number | string}} [init] + * @param {RequestInit & {thumbnail?: {height?: number | string, width?: number | string, animated?: boolean, method?: "crop" | "scale"}}} [init] * @return {Promise}>} */ async function getMedia(mxc, init = {}) { + init = {...init} + const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) assert(mediaParts) - const downloadOrThumbnail = init.height ? "thumbnail" : "download" - let url = `${mreq.baseUrl}/client/v1/media/${downloadOrThumbnail}/${mediaParts[1]}/${mediaParts[2]}` - if (init.height) url += "?" + new URLSearchParams({height: String(init.height), width: String(init.height)}) + + let route = "download" + let query = "" + + if (init.thumbnail) { + route = "thumbnail" + query = "?" + new URLSearchParams(Object.keys(init.thumbnail).map(k => [k, String(init.thumbnail?.[k])])) + } + + let url = `${mreq.baseUrl}/client/v1/media/${route}/${mediaParts[1]}/${mediaParts[2]}${query}` const res = await fetch(url, { headers: { Authorization: `Bearer ${reg.as_token}` diff --git a/src/stdin.js b/src/stdin.js index 2548d42..04b0151 100644 --- a/src/stdin.js +++ b/src/stdin.js @@ -21,6 +21,8 @@ const speedbump = sync.require("./d2m/actions/speedbump") const ks = sync.require("./matrix/kstate") const setPresence = sync.require("./d2m/actions/set-presence") const channelWebhook = sync.require("./m2d/actions/channel-webhook") +const dUtils = sync.require("./discord/utils") +const mUtils = sync.require("./matrix/utils") const guildID = "112760669178241024" async function ping() { From 24c2dee7d346316428e7b2ef980d77046edb604e Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 30 May 2026 15:16:54 +1200 Subject: [PATCH 134/142] Fix m->d custom emoji reactions on some clients --- src/db/migrations/0038-fix-emoji-file-format.sql | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/db/migrations/0038-fix-emoji-file-format.sql diff --git a/src/db/migrations/0038-fix-emoji-file-format.sql b/src/db/migrations/0038-fix-emoji-file-format.sql new file mode 100644 index 0000000..9e63150 --- /dev/null +++ b/src/db/migrations/0038-fix-emoji-file-format.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +DELETE FROM emoji WHERE mxc_url NOT IN (SELECT mxc_url FROM file WHERE discord_url LIKE 'https://cdn.discordapp.com/emojis/%.webp%'); + +COMMIT; From af6ea072f344648f06ce511ff05baa487570f785 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 30 May 2026 15:28:26 +1200 Subject: [PATCH 135/142] Add stats Just adding this early version for now so I can iterate. --- src/web/routes/stats.js | 85 +++++++++++++++++++++++++++++++++++++++++ src/web/server.js | 1 + 2 files changed, 86 insertions(+) create mode 100644 src/web/routes/stats.js diff --git a/src/web/routes/stats.js b/src/web/routes/stats.js new file mode 100644 index 0000000..1bfd300 --- /dev/null +++ b/src/web/routes/stats.js @@ -0,0 +1,85 @@ +// @ts-check + +const {defineEventHandler, getValidatedQuery, H3Event, setResponseHeader} = require("h3") +const {as, db, sync} = require("../../passthrough") +const {reg} = require("../../matrix/read-registration") + +/** @type {import("../../discord/utils")} */ +const dUtils = sync.require("../../discord/utils") + +// Calculation takes time and is single-threaded. I could add database indexes, but this is simpler and doesn't need storage. +const STATS_CACHE_TIME = 10 * 60 * 1000 // 10 minutes + +function getMessageCountLastDuration(duration) { + const snowflake = dUtils.timestampToSnowflakeInexact(Date.now() - duration) + return db.prepare("select count(*) from message_room where message_id >= ? and length(message_id) = ?").pluck().get(snowflake, snowflake.length) +} + +function getStats() { + const durations = [ + ["week", 7 * 24 * 60 * 60 * 1000], + ["day", 1 * 24 * 60 * 60 * 1000], + ["hour", 1 * 60 * 60 * 1000] + ] + + // console.time("get stats") + let temp = { + guilds: db.prepare("select count(*) from guild_space").pluck().get(), + channels: db.prepare("select count(*) from channel_room").pluck().get(), + messages: db.prepare("select count(*) from message_room").pluck().get(), + ...durations.reduce((a, c) => (a[`messages_last_${c[0]}`] = getMessageCountLastDuration(c[1]), a), {}), + message_sources: db.prepare("select count(*) from event_message where part = 0 group by source order by source").pluck().all(), + oldest_message: new Date(dUtils.snowflakeToTimestampExact(db.prepare("select min(message_id) from event_message where source = 0").pluck().get())), // good until 2090 + discord_users: db.prepare("select count(*) from sim").pluck().get(), + matrix_users: db.prepare("select count(distinct mxid) from member_cache where mxid not like ?").pluck().get(reg.namespaces.users[0].regex.replace(/\.\*.*/, "%")), + } + // console.timeEnd("get stats") + return temp +} + +/** @type {ReturnType} */ +let stats +let statsUpdatedAt = 0 + +function updateStatsIfOld() { + if (statsUpdatedAt < Date.now() - STATS_CACHE_TIME) { + stats = getStats() + statsUpdatedAt = Date.now() + } +} + +as.router.get("/api/stats", defineEventHandler(async event => { + updateStatsIfOld() + return { + ...stats, + oldest_message: stats.oldest_message.toISOString(), + } +})) + +as.router.get("/metrics", defineEventHandler(async event => { + updateStatsIfOld() + setResponseHeader(event, "content-type", "text/plain") + return ` +# HELP guilds Total number of guilds +# TYPE guilds gauge +ooye_guilds_total ${stats.guilds} + +# HELP channels Total number of channels +# TYPE channels gauge +ooye_channels_total ${stats.channels} + +# HELP messages_total Total number of messages sent from each side +# TYPE messages_total gauge +ooye_messages_total{type="matrix"} ${stats.message_sources[0]} +ooye_messages_total{type="discord"} ${stats.message_sources[1]} + +# HELP oldest_message_timestamp Unix timestamp of the oldest message +# TYPE oldest_message_timestamp gauge +ooye_oldest_message_timestamp_seconds ${stats.oldest_message.getTime() / 1000} + +# HELP ooye_users_total Total number of users on each side +# TYPE ooye_users_total gauge +ooye_users_total{type="matrix"} ${stats.matrix_users} +ooye_users_total{type="discord"} ${stats.discord_users} +`.trimStart() +})) diff --git a/src/web/server.js b/src/web/server.js index 77ba3ed..e28060d 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -135,3 +135,4 @@ sync.require("./routes/link") sync.require("./routes/log-in-with-matrix") sync.require("./routes/oauth") sync.require("./routes/password") +sync.require("./routes/stats") From 313efb29d81c7817e028d9b3c2388f2aadfca835 Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Mon, 1 Jun 2026 04:54:38 +0000 Subject: [PATCH 136/142] Fix m->d reaction deletion counting (#85) Fixes a bug where, if multiple Matrix users had used the same reaction on a message, and then one of those Matrix users removed their reactions, the bot would forcibly remove all of that reactions. Now, we check and make sure there are no remaining reactions from Matrix before removal. This also rewrote the retrigger system to be more generic and to use promises instead of re-entry (would lose call stack). Co-authored-by: Cadence Ember Reviewed-on: https://gitdab.com/cadence/out-of-your-element/pulls/85 --- src/d2m/actions/retrigger.js | 175 ++++++++++++++++++-------- src/d2m/converters/remove-reaction.js | 2 +- src/d2m/event-dispatcher.js | 23 +++- src/db/orm.js | 10 ++ src/db/orm.test.js | 5 + src/m2d/actions/add-reaction.js | 4 +- src/m2d/actions/redact.js | 47 +++++-- src/m2d/event-dispatcher.js | 4 +- src/stdin.js | 3 +- 9 files changed, 196 insertions(+), 77 deletions(-) diff --git a/src/d2m/actions/retrigger.js b/src/d2m/actions/retrigger.js index 66ef19e..43f400d 100644 --- a/src/d2m/actions/retrigger.js +++ b/src/d2m/actions/retrigger.js @@ -2,7 +2,15 @@ const {EventEmitter} = require("events") const passthrough = require("../../passthrough") -const {select} = passthrough +const {select, sync, from} = passthrough +/** @type {import("../../matrix/utils")} */ +const utils = sync.require("../../matrix/utils") + +/* + Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives + (or before the it has finished being bridged to an event). + In this case, wait until the original message has finished bridging, then retrigger the passed function. +*/ const DEBUG_RETRIGGER = false @@ -12,81 +20,140 @@ function debugRetrigger(message) { } } -const paused = new Set() -const emitter = new EventEmitter() +const storage = new class { + /** @private @type {Set} */ + paused = new Set() + /** @private @type {Map any)[]>} id -> list of resolvers */ + resolves = new Map() + /** @private @type {Map>} id -> timer */ + timers = new Map() -/** - * Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives - * (or before the it has finished being bridged to an event). - * In this case, wait until the original message has finished bridging, then retrigger the passed function. - * @template {(...args: any[]) => any} T - * @param {string} inputID - * @param {T} fn - * @param {Parameters} rest - * @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered - */ -function eventNotFoundThenRetrigger(inputID, fn, ...rest) { - if (!paused.has(inputID)) { - if (inputID.match(/^[0-9]+$/)) { - const eventID = select("event_message", "event_id", {message_id: inputID}).pluck().get() - if (eventID) { - debugRetrigger(`[retrigger] OK mid <-> eid = ${inputID} <-> ${eventID}`) - return false // event was found so don't retrigger - } - } else if (inputID.match(/^\$/)) { - const messageID = select("event_message", "message_id", {event_id: inputID}).pluck().get() - if (messageID) { - debugRetrigger(`[retrigger] OK eid <-> mid = ${inputID} <-> ${messageID}`) - return false // message was found so don't retrigger - } + /** + * The purpose of storage is to store `resolve` and call it at a later time. + * @param {string} id + * @param {(found: Boolean) => any} resolve + */ + store(id, resolve) { + debugRetrigger(`[retrigger] STORE id = ${id}`) + this.resolves.set(id, (this.resolves.get(id) || []).concat(resolve)) // add to list in map value + if (!this.timers.has(id)) { + debugRetrigger(`[retrigger] SET TIMER id = ${id}`) + this.timers.set(id, setTimeout(() => this.resolve(id, false), 60 * 1000).unref()) // 1 minute } } + + /** @param {string} id */ + isNotPaused(id) { + return !storage.paused.has(id) + } - debugRetrigger(`[retrigger] WAIT id = ${inputID}`) - emitter.once(inputID, () => { - debugRetrigger(`[retrigger] TRIGGER id = ${inputID}`) - fn(...rest) - }) - // if the event never arrives, don't trigger the callback, just clean up - setTimeout(() => { - if (emitter.listeners(inputID).length) { - debugRetrigger(`[retrigger] EXPIRE id = ${inputID}`) + /** @param {string} id */ + pause(id) { + debugRetrigger(`[retrigger] PAUSE id = ${id}`) + this.paused.add(id) + } + + /** + * Go through `resolves` storage and resolve them all. (Also resets timer/paused.) + * @param {string} id + * @param {boolean} value + */ + resolve(id, value) { + if (this.paused.has(id)) { + debugRetrigger(`[retrigger] RESUME id = ${id}`) + this.paused.delete(id) } - emitter.removeAllListeners(inputID) - }, 60 * 1000) // 1 minute - return true // event was not found, then retrigger + + if (this.resolves.has(id)) { + debugRetrigger(`[retrigger] RESOLVE ${value} id = ${id}`) + const fns = this.resolves.get(id) || [] + this.resolves.delete(id) + for (const fn of fns) { + fn(value) + } + } + + if (this.timers.has(id)) { + clearTimeout(this.timers.get(id)) + this.timers.delete(id) + } + } +} + +/** + * @param {string} id + * @param {(found: Boolean) => any} resolve + * @param {boolean} existsInDatabase + */ +function waitFor(id, resolve, existsInDatabase) { + if (existsInDatabase && storage.isNotPaused(id)) { // if event already exists and isn't paused then resolve immediately + debugRetrigger(`[retrigger] EXISTS id = ${id}`) + return resolve(true) + } + + // doesn't exist. wait for it to exist. storage will resolve true if it exists or false if it timed out + return storage.store(id, resolve) +} + +const GET_EVENT_PREPARED = from("event_message").select("event_id").and("WHERE event_id = ?").prepare().raw() +/** + * @param {string} eventID + * @returns {Promise} if true then the message did not arrive + */ +function waitForEvent(eventID) { + const {promise, resolve} = Promise.withResolvers() + waitFor(eventID, resolve, !!GET_EVENT_PREPARED.get(eventID)) + return promise +} + +const GET_MESSAGE_PREPARED = from("event_message").select("message_id").and("WHERE message_id = ?").prepare().raw() +/** + * @param {string} messageID + * @returns {Promise} if true then the message did not arrive + */ +function waitForMessage(messageID) { + const {promise, resolve} = Promise.withResolvers() + waitFor(messageID, resolve, !!GET_MESSAGE_PREPARED.get(messageID)) + return promise +} + +const GET_REACTION_EVENT_PREPARED = from("reaction").select("hashed_event_id").and("WHERE hashed_event_id = ?").prepare().raw() +/** + * @param {string} eventID + * @returns {Promise} if true then the message did not arrive + */ +function waitForReactionEvent(eventID) { + const {promise, resolve} = Promise.withResolvers() + waitFor(eventID, resolve, !!GET_REACTION_EVENT_PREPARED.get(utils.getEventIDHash(eventID))) + return promise } /** * Anything calling retrigger during the callback will be paused and retriggered after the callback resolves. * @template T - * @param {string} messageID + * @param {string} id * @param {Promise} promise * @returns {Promise} */ -async function pauseChanges(messageID, promise) { +async function pauseChanges(id, promise) { try { - debugRetrigger(`[retrigger] PAUSE id = ${messageID}`) - paused.add(messageID) + storage.pause(id) return await promise } finally { - debugRetrigger(`[retrigger] RESUME id = ${messageID}`) - paused.delete(messageID) - messageFinishedBridging(messageID) + finishedBridging(id) } } /** * Triggers any pending operations that were waiting on the corresponding event ID. - * @param {string} messageID + * @param {string} id */ -function messageFinishedBridging(messageID) { - if (emitter.listeners(messageID).length) { - debugRetrigger(`[retrigger] EMIT id = ${messageID}`) - } - emitter.emit(messageID) +function finishedBridging(id) { + storage.resolve(id, true) } -module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger -module.exports.messageFinishedBridging = messageFinishedBridging +module.exports.waitForMessage = waitForMessage +module.exports.waitForEvent = waitForEvent +module.exports.waitForReactionEvent = waitForReactionEvent module.exports.pauseChanges = pauseChanges +module.exports.finishedBridging = finishedBridging \ No newline at end of file diff --git a/src/d2m/converters/remove-reaction.js b/src/d2m/converters/remove-reaction.js index 4ca22b6..b6b0407 100644 --- a/src/d2m/converters/remove-reaction.js +++ b/src/d2m/converters/remove-reaction.js @@ -34,7 +34,7 @@ function removeReaction(data, reactions, key) { // Even though the bridge bot only reacted once on Discord-side, multiple Matrix users may have // reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user. // Also need to clean up the database. - const hash = utils.getEventIDHash(event.event_id) + const hash = utils.getEventIDHash(eventID) removals.push({eventID, mxid: null, hash}) } if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) { diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 90824ac..8101a03 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -2,6 +2,7 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") +const {id: botID} = require("../../addbot") const {sync, db, select, from} = require("../passthrough") /** @type {import("./actions/send-message")}) */ @@ -38,6 +39,8 @@ const removeMember = sync.require("./actions/remove-member") const vote = sync.require("./actions/poll-vote") /** @type {import("../m2d/event-dispatcher")} */ const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") +/** @type {import("../m2d/actions/redact.js")} */ +const redact = sync.require("../m2d/actions/redact.js") /** @type {import("../discord/interactions/matrix-info")} */ const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info") @@ -316,7 +319,7 @@ module.exports = { // @ts-ignore await sendMessage.sendMessage(message, channel, guild, row) - retrigger.messageFinishedBridging(message.id) + retrigger.finishedBridging(message.id) }, /** @@ -337,7 +340,7 @@ module.exports = { if (!row) { // Check that the sending-to room exists, and deal with Eventual Consistency(TM) - if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return + if (!await retrigger.waitForMessage(data.id)) return } /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ @@ -375,6 +378,16 @@ module.exports = { * @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data */ async onSomeReactionsRemoved(client, data) { + // Don't attempt to double-bridge our own m2d deleted reactions back to Matrix + if ("user_id" in data && data.user_id === botID) { + const emojiIdOrName = data.emoji.id || data.emoji.name + const i = redact.m2dDeletedReactions.findIndex(x => data.message_id === x.messageID && emojiIdOrName === x.emojiIdOrName) + if (i !== -1) { + redact.m2dDeletedReactions.splice(i, 1) + return + } + } + await removeReaction.removeSomeReactions(data) }, @@ -384,7 +397,7 @@ module.exports = { */ async MESSAGE_DELETE(client, data) { speedbump.onMessageDelete(data.id) - if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_DELETE, client, data)) return + if (!await retrigger.waitForMessage(data.id)) return await deleteMessage.deleteMessage(data) }, @@ -432,12 +445,12 @@ module.exports = { * @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data */ async MESSAGE_POLL_VOTE_ADD(client, data) { - if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_ADD, client, data)) return + if (!await retrigger.waitForMessage(data.message_id)) return await vote.addVote(data) }, async MESSAGE_POLL_VOTE_REMOVE(client, data) { - if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_REMOVE, client, data)) return + if (!await retrigger.waitForMessage(data.message_id)) return await vote.removeVote(data) }, diff --git a/src/db/orm.js b/src/db/orm.js index 4d9b6f1..8763314 100644 --- a/src/db/orm.js +++ b/src/db/orm.js @@ -104,6 +104,16 @@ class From { return r } + pluckUnsafe(col) { + /** @type {Pluck} */ + // @ts-ignore + const r = this + r.cols = [col] + r.makeColsSafe = false + r.isPluck = true + return r + } + /** * @param {string} sql */ diff --git a/src/db/orm.test.js b/src/db/orm.test.js index 6f6018e..4639090 100644 --- a/src/db/orm.test.js +++ b/src/db/orm.test.js @@ -68,3 +68,8 @@ test("orm: select unsafe works (to select complex column names that can't be typ .all() t.equal(results[0].power_level, 150) }) + +test("orm: pluck unsafe works (to select complex column names that can't be type verified)", t => { + const result = from("channel_room").where({guild_id: "112760669178241024"}).pluckUnsafe("count(*)").get() + t.equal(result, 7) +}) diff --git a/src/m2d/actions/add-reaction.js b/src/m2d/actions/add-reaction.js index e4981fb..c453244 100644 --- a/src/m2d/actions/add-reaction.js +++ b/src/m2d/actions/add-reaction.js @@ -17,7 +17,7 @@ const retrigger = sync.require("../../d2m/actions/retrigger") */ async function addReaction(event) { // Wait until the corresponding channel and message have already been bridged - if (retrigger.eventNotFoundThenRetrigger(event.content["m.relates_to"].event_id, () => as.emit("type:m.reaction", event))) return + if (!await retrigger.waitForEvent(event.content["m.relates_to"].event_id)) return // These will exist because it passed retrigger const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") @@ -50,6 +50,8 @@ async function addReaction(event) { } db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji, original_encoding) VALUES (?, ?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding, key) + + retrigger.finishedBridging(event.event_id) } module.exports.addReaction = addReaction diff --git a/src/m2d/actions/redact.js b/src/m2d/actions/redact.js index 3135d31..7e49753 100644 --- a/src/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -10,6 +10,9 @@ const utils = sync.require("../../matrix/utils") /** @type {import("../../d2m/actions/retrigger")} */ const retrigger = sync.require("../../d2m/actions/retrigger") +/** @type {{messageID: string, emojiIdOrName: string}[]} */ +const m2dDeletedReactions = [] + /** * @param {Ty.Event.Outer_M_Room_Redaction} event */ @@ -24,6 +27,21 @@ async function deleteMessage(event) { db.prepare("DELETE FROM message_room WHERE message_id = ?").run(rows[0].message_id) } +/** + * @param {Ty.Event.Outer_M_Room_Redaction} event + */ +async function removeMessageEvent(event) { + // Could be for removing a message or suppressing embeds. For more information, the message needs to be bridged first. + if (!await retrigger.waitForEvent(event.redacts)) return + + const row = select("event_message", ["event_type", "event_subtype", "part"], {event_id: event.redacts}).get() + if (row && row.event_type === "m.room.message" && row.event_subtype === "m.notice" && row.part === 1) { + await suppressEmbeds(event) + } else { + await deleteMessage(event) + } +} + /** * @param {Ty.Event.Outer_M_Room_Redaction} event */ @@ -41,11 +59,20 @@ async function suppressEmbeds(event) { * @param {Ty.Event.Outer_M_Room_Redaction} event */ async function removeReaction(event) { + if (!await retrigger.waitForReactionEvent(event.redacts)) return + const hash = utils.getEventIDHash(event.redacts) const row = from("reaction").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") .select("reference_channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get() if (!row) return - await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji) + // See how many Matrix-side reactions there are, and delete if it's the last one + const numberOfReactions = from("reaction").where({message_id: row.message_id, encoded_emoji: row.encoded_emoji}).pluckUnsafe("count(*)").get() + if (numberOfReactions === 1) { + // If a unicode emoji, the name is already the Discord preferred version because that's what was added and stored to encoded_emoji + const emojiIdOrName = decodeURIComponent(row.encoded_emoji).split(":").slice(-1)[0] + m2dDeletedReactions.push({messageID: row.message_id, emojiIdOrName}) + await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji) + } db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(hash) } @@ -54,18 +81,12 @@ async function removeReaction(event) { * @param {Ty.Event.Outer_M_Room_Redaction} event */ async function handle(event) { - // If this is for removing a reaction, try it - await removeReaction(event) - - // Or, it might be for removing a message or suppressing embeds. But to do that, the message needs to be bridged first. - if (retrigger.eventNotFoundThenRetrigger(event.redacts, () => as.emit("type:m.room.redaction", event))) return - - const row = select("event_message", ["event_type", "event_subtype", "part"], {event_id: event.redacts}).get() - if (row && row.event_type === "m.room.message" && row.event_subtype === "m.notice" && row.part === 1) { - await suppressEmbeds(event) - } else { - await deleteMessage(event) - } + // Don't know if it's a redaction for a reaction or an event, try both at the same time (otherwise waitFor will block) + await Promise.all([ + removeMessageEvent(event), + removeReaction(event) + ]) } module.exports.handle = handle +module.exports.m2dDeletedReactions = m2dDeletedReactions \ No newline at end of file diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 352ca41..3580d1b 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -225,7 +225,7 @@ async event => { // @ts-ignore await matrixCommandHandler.execute(event) } - retrigger.messageFinishedBridging(event.event_id) + retrigger.finishedBridging(event.event_id) await api.ackEvent(event) })) @@ -236,7 +236,7 @@ sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return const messageResponses = await sendEvent.sendEvent(event) - retrigger.messageFinishedBridging(event.event_id) + retrigger.finishedBridging(event.event_id) await api.ackEvent(event) })) diff --git a/src/stdin.js b/src/stdin.js index 04b0151..43f9607 100644 --- a/src/stdin.js +++ b/src/stdin.js @@ -15,6 +15,7 @@ const mreq = sync.require("./matrix/mreq") const api = sync.require("./matrix/api") const file = sync.require("./matrix/file") const sendEvent = sync.require("./m2d/actions/send-event") +const redact = sync.require("./m2d/actions/redact") const eventDispatcher = sync.require("./d2m/event-dispatcher") const updatePins = sync.require("./d2m/actions/update-pins") const speedbump = sync.require("./d2m/actions/speedbump") @@ -22,7 +23,7 @@ const ks = sync.require("./matrix/kstate") const setPresence = sync.require("./d2m/actions/set-presence") const channelWebhook = sync.require("./m2d/actions/channel-webhook") const dUtils = sync.require("./discord/utils") -const mUtils = sync.require("./matrix/utils") +const mxUtils = sync.require("./matrix/utils") const guildID = "112760669178241024" async function ping() { From 18b6efdd1863dbd88519305862fcfb2587fb5eb4 Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Mon, 1 Jun 2026 00:08:36 -0400 Subject: [PATCH 137/142] Fix editing permissions interactions not working Co-authored-by: Cadence Ember --- src/discord/register-interactions.js | 54 ++++++++++++---------------- 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/src/discord/register-interactions.js b/src/discord/register-interactions.js index e3d58c4..66012b4 100644 --- a/src/discord/register-interactions.js +++ b/src/discord/register-interactions.js @@ -91,40 +91,32 @@ function registerInteractions() { async function dispatchInteraction(interaction) { const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"] try { - if (interaction.type === DiscordTypes.InteractionType.MessageComponent || interaction.type === DiscordTypes.InteractionType.ModalSubmit) { - // All we get is custom_id, don't know which context the button was clicked in. - // So we namespace these ourselves in the custom_id. Currently the only existing namespace is POLL_. - if (interaction.data.custom_id.startsWith("POLL_")) { - await poll.interact(interaction) + if (interactionId === "Matrix info") { + await matrixInfo.interact(interaction) + } else if (interactionId === "invite") { + await invite.interact(interaction) + } else if (interactionId === "invite_channel") { + await invite.interactButton(interaction) + } else if (interactionId === "Permissions") { + await permissions.interact(interaction) + } else if (interactionId === "permissions_edit") { + await permissions.interactEdit(interaction) + } else if (interactionId === "Responses") { + /** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore + const messageInteraction = interaction + if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) { + await pollResponses.interact(messageInteraction) } else { - throw new Error(`Unknown message component ${interaction.data.custom_id}`) + await reactions.interact(messageInteraction) } + } else if (interactionId === "ping") { + await ping.interact(interaction) + } else if (interactionId === "privacy") { + await privacy.interact(interaction) + } else if (interactionId.startsWith("POLL_")) { + await poll.interact(interaction) } else { - if (interactionId === "Matrix info") { - await matrixInfo.interact(interaction) - } else if (interactionId === "invite") { - await invite.interact(interaction) - } else if (interactionId === "invite_channel") { - await invite.interactButton(interaction) - } else if (interactionId === "Permissions") { - await permissions.interact(interaction) - } else if (interactionId === "permissions_edit") { - await permissions.interactEdit(interaction) - } else if (interactionId === "Responses") { - /** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore - const messageInteraction = interaction - if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) { - await pollResponses.interact(messageInteraction) - } else { - await reactions.interact(messageInteraction) - } - } else if (interactionId === "ping") { - await ping.interact(interaction) - } else if (interactionId === "privacy") { - await privacy.interact(interaction) - } else { - throw new Error(`Unknown interaction ${interactionId}`) - } + throw new Error(`Unknown interaction ${interactionId}`) } } catch (e) { let stackLines = null From e2ab9fa9bf0c8ae9a3ac5dc88f423fded5bff09e Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 3 Jun 2026 00:02:48 +1200 Subject: [PATCH 138/142] Improve PK ping message --- src/d2m/converters/message-to-event.js | 22 ++- .../message-to-event.test.components.js | 22 +++ test/data.js | 183 ++++++++++++++++++ test/ooye-test-data.sql | 6 +- 4 files changed, 228 insertions(+), 5 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 7229d3d..83fab1b 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -265,8 +265,9 @@ function getFormattedInteraction(interaction, isThinkingInteraction) { * @param {any} newEvents merge into events * @param {any} events will be modified * @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc + * @param {boolean} [forceMerge] if true, must merge event, will error if it had to append */ -function mergeTextEvents(newEvents, events, forceSameMsgtype) { +function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false) { let prev = events.at(-1) for (const ne of newEvents) { const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype) @@ -278,6 +279,8 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype) { rep.addLine(ne.body, ne.formatted_body) prev.body = rep.body prev.formatted_body = rep.formattedBody + } else if (forceMerge) { + throw new Error("Unable to merge events") } else { events.push(ne) } @@ -967,7 +970,8 @@ async function messageToEvent(message, guild, options = {}, di) { // May only be a section accessory or in an action row (up to 5) if (component.style === DiscordTypes.ButtonStyle.Link) { assert(component.label) // required for Discord to validate link buttons - stack.msb.add(`[${component.label} ${component.url}] `, tag`${component.label} `) + const link = await transformContentMessageLinks(component.url) + stack.msb.add(`[${component.label} ${link}] `, tag`${component.label} `) } } @@ -980,7 +984,19 @@ async function messageToEvent(message, guild, options = {}, di) { const {body, formatted_body} = stack.msb.get() if (body.trim().length) { - await addTextEvent(body, formatted_body, "m.text") + // Create new message if Components V2 (cannot have regular content) + if ((message.flags ?? 0) & DiscordTypes.MessageFlags.IsComponentsV2) { + await addTextEvent(body, formatted_body, "m.text") + } + // Add to existing message if legacy components https://docs.discord.com/developers/components/reference#legacy-message-component-behavior + else { + mergeTextEvents([{ + msgtype: "m.text", + body, + format: "org.matrix.custom.html", + formatted_body + }], events, false, true) + } } } diff --git a/src/d2m/converters/message-to-event.test.components.js b/src/d2m/converters/message-to-event.test.components.js index 137b63b..1ef83c3 100644 --- a/src/d2m/converters/message-to-event.test.components.js +++ b/src/d2m/converters/message-to-event.test.components.js @@ -1,6 +1,7 @@ const {test} = require("supertape") const {messageToEvent} = require("./message-to-event") const data = require("../../../test/data") +const {mockGetEffectivePower} = require("../../matrix/utils.test") test("message2event components: pk question mark output", async t => { const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {}) @@ -77,3 +78,24 @@ test("message2event components: pk question mark output", async t => { msgtype: "m.text", }]) }) + +test("message2event components: pk ping message legacy components", async t => { + const events = await messageToEvent(data.message_with_components.pk_ping_components_v1, data.guild.general, {}, { + api: { + async getJoinedMembers() { + return {joined: {}} + }, + getEffectivePower: mockGetEffectivePower() + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "❭ cadence used `/🔔 Ping author`" + + "\nPsst, **Red** (@cadence.worm:), you have been pinged by @cadence.worm:." + + "\n[Jump https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe] ", + format: "org.matrix.custom.html", + formatted_body: "
cadence used /🔔 Ping author
Psst, Red (@cadence.worm), you have been pinged by @cadence.worm.
Jump ", + "m.mentions": {} + }]) +}) diff --git a/test/data.js b/test/data.js index f3092bc..eab9a63 100644 --- a/test/data.js +++ b/test/data.js @@ -5473,6 +5473,189 @@ module.exports = { content: '-# Original Message ID: 1466556003645657118 · ' } ] + }, + pk_ping_components_v1: { + type: 23, + content: "Psst, **Red** (<@772659086046658620>), you have been pinged by <@772659086046658620>.", + mentions: [ + { + id: "772659086046658620", + username: "cadence.worm", + avatar: "466df0c98b1af1e1388f595b4c1ad1b9", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "cadence", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: { + identity_guild_id: "532245108070809601", + identity_enabled: true, + tag: "doll", + badge: "dba08126b4e810a0e096cc7cd5bc37f0" + }, + primary_guild: { + identity_guild_id: "532245108070809601", + identity_enabled: true, + tag: "doll", + badge: "dba08126b4e810a0e096cc7cd5bc37f0" + } + } + ], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2026-03-25T07:07:02.626000+00:00", + edited_timestamp: null, + flags: 0, + components: [ + { + type: 1, + id: 1, + components: [ + { + type: 2, + id: 2, + style: 5, + label: "Jump", + url: "https://discord.com/channels/1160893336324931584/1160894080998461480/1440549403667468320" + } + ] + } + ], + id: "1486260105908457653", + channel_id: "1160894080998461480", + author: { + id: "466378653216014359", + username: "PluralKit", + avatar: "b78ef67a081737a830b60aa47d9ebcd9", + discriminator: "4020", + public_flags: 65536, + flags: 65536, + bot: true, + banner: null, + accent_color: null, + global_name: null, + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + application_id: "466378653216014359", + interaction: { + id: "1486260103928614932", + type: 2, + name: "🔔 Ping author", + user: { + id: "772659086046658620", + username: "cadence.worm", + avatar: "466df0c98b1af1e1388f595b4c1ad1b9", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "cadence", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: { + identity_guild_id: "532245108070809601", + identity_enabled: true, + tag: "doll", + badge: "dba08126b4e810a0e096cc7cd5bc37f0" + }, + primary_guild: { + identity_guild_id: "532245108070809601", + identity_enabled: true, + tag: "doll", + badge: "dba08126b4e810a0e096cc7cd5bc37f0" + } + } + }, + webhook_id: "466378653216014359", + message_reference: { + type: 0, + channel_id: "1160894080998461480", + message_id: "1440549403667468320", + guild_id: "1160893336324931584" + }, + interaction_metadata: { + id: "1486260103928614932", + type: 2, + user: { + id: "772659086046658620", + username: "cadence.worm", + avatar: "466df0c98b1af1e1388f595b4c1ad1b9", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "cadence", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: { + identity_guild_id: "532245108070809601", + identity_enabled: true, + tag: "doll", + badge: "dba08126b4e810a0e096cc7cd5bc37f0" + }, + primary_guild: { + identity_guild_id: "532245108070809601", + identity_enabled: true, + tag: "doll", + badge: "dba08126b4e810a0e096cc7cd5bc37f0" + } + }, + authorizing_integration_owners: { "0": "1160893336324931584" }, + name: "🔔 Ping author", + command_type: 3, + target_message_id: "1440549403667468320" + }, + referenced_message: { + type: 0, + content: "test", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2025-11-19T03:49:01.948000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1440549403667468320", + channel_id: "1160894080998461480", + author: { + id: "1195662438662680720", + username: "special name", + avatar: "a82347890f2739e5880cd82b8c1a708e", + discriminator: "0000", + public_flags: 0, + flags: 0, + bot: true, + global_name: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + application_id: "466378653216014359", + webhook_id: "1195662438662680720" + } } }, message_update: { diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 8dd71cd..1662320 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -95,7 +95,8 @@ WITH a (message_id, channel_id) AS (VALUES ('1381212840957972480', '112760669178241024'), ('1401760355339862066', '112760669178241024'), ('1439351590262800565', '1438284564815548418'), -('1404133238414376971', '112760669178241024')) +('1404133238414376971', '112760669178241024'), +('1440549403667468320', '1160894080998461480')) 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 @@ -143,7 +144,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part ('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0), ('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0), ('$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I', 'm.room.message', 'm.text', '1404133238414376971', 0, 1, 1), -('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1); +('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1), +('$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM', 'm.room.message', 'm.text', '1440549403667468320', 0, 0, 1); INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'), From fbade33ff0168ccd702fa4554ce82eab26d64e78 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 3 Jun 2026 00:34:37 +1200 Subject: [PATCH 139/142] Update language to sound more warningcore --- 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 9791ae3..2614e6b 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -191,14 +191,14 @@ block body 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. + .s-empty-state.p8 No Discord channels available. .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. + .s-empty-state.p8 No Matrix rooms available. input(type="hidden" name="guild_id" value=guild_id) div button.s-btn.s-btn__icon.s-btn__filled#link-button From 47dc0504ffbd8017dcfc70d17c29ad8d85d5dd5e Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 3 Jun 2026 00:36:51 +1200 Subject: [PATCH 140/142] Consistent font colour --- 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 2614e6b..7411a1e 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -122,7 +122,7 @@ block body #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. + p.fc-light.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 From b5768697644ef64717641693e20fc730604fa7b6 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 4 Jun 2026 18:07:39 +1200 Subject: [PATCH 141/142] v3.6 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index eb07b4d..ed438d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "out-of-your-element", - "version": "3.5.1", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "out-of-your-element", - "version": "3.5.1", + "version": "3.6.0", "license": "AGPL-3.0-or-later", "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", diff --git a/package.json b/package.json index 9dfd2a8..73fd43d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "out-of-your-element", - "version": "3.5.1", + "version": "3.6.0", "description": "A bridge between Matrix and Discord", "main": "index.js", "repository": { From f7609b204019ed81c4562f8896e4a53b867820b9 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 6 Jun 2026 23:38:49 +1200 Subject: [PATCH 142/142] Only speedbump users that have used PK --- src/d2m/actions/speedbump.js | 14 +++++++++----- src/d2m/event-dispatcher.js | 4 ++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/d2m/actions/speedbump.js b/src/d2m/actions/speedbump.js index 218f046..4a5f782 100644 --- a/src/d2m/actions/speedbump.js +++ b/src/d2m/actions/speedbump.js @@ -1,6 +1,5 @@ // @ts-check -const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") const {discord, select, db} = passthrough @@ -70,12 +69,17 @@ async function doSpeedbump(messageID) { * Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted. * @param {string} channelID * @param {string} messageID + * @param {string} [userID] if provided, only slow down the message when the user has used PK before * @returns whether it was deleted, and data about the channel's (not thread's) speedbump */ -async function maybeDoSpeedbump(channelID, messageID) { - let row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get() - if (row?.thread_parent) row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread - if (!row?.speedbump_webhook_id) return {affected: false, row: null} // not affected, no speedbump +async function maybeDoSpeedbump(channelID, messageID, userID) { + let row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get() + if (row?.thread_parent) row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread + if (!row?.speedbump_webhook_id) return {affected: false, row: null} // channel not affected, no speedbump + if (userID) { + const userHasProxy = select("sim_proxy", "user_id", {proxy_owner_id: userID}).pluck().get() + if (!userHasProxy) return {affected: false, row: null} // user has not used PK before, no speedbump + } const affected = await doSpeedbump(messageID) return {affected, row} // maybe affected, and there is a speedbump } diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 8101a03..d52a340 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -313,7 +313,7 @@ module.exports = { if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable - const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id) + const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id, message.author.id) if (affected) return // @ts-ignore @@ -335,7 +335,7 @@ module.exports = { if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only! // Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from. - const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id) + const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id, data.author.id) if (affected) return if (!row) {