From b1513a6fd169b9960e91cd732ec263035427075c Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 11:30:54 +0000 Subject: [PATCH 1/4] 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 2/4] =?UTF-8?q?Undone=20some=20of=20the=20=E2=80=9Equality?= =?UTF-8?q?=20improvements=E2=80=9D=20from=20yesterday=20because=20I=20not?= =?UTF-8?q?iced=20they'd=20break=20auto-removing=20for=20already=20existin?= =?UTF-8?q?g=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 3/4] 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 4/4] 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(