From e7cbfb9fc9f35b394911fa449c92146acd9f739b Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 29 Mar 2026 15:43:23 +1300 Subject: [PATCH 001/184] 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 6a8e6982..00000000 --- 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 dfecfcfe..00000000 --- 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 d707ede7..00000000 --- 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 e9b7fae8..8550d43f 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 b6593ecf..c86cc137 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 89e0a58d..00000000 --- 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 f6ae14a6..d95bfc37 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 795e6758..00000000 --- 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 029c02af..00000000 --- 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 be1d005b..86680ebb 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 f8994559..00000000 --- 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 85fa1cbd..837e14de 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 70625a07..4cd96277 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 002/184] 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 9f4ba54c..70dd476e 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 af4bd2aa..c85a3620 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 0c6a5008e31854746fbf457e495e4d8136579189 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 22:52:11 +0000 Subject: [PATCH 003/184] MOAR channels!!!1!1!!!11!!!!! also, switched to working on this branch for now; I think that's the easiest option for the time being --- src/d2m/actions/create-room.js | 2 +- src/web/routes/guild.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 70f7b3ab..0fb102b2 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -195,7 +195,7 @@ async function channelToKState(channel, guild, di) { if (hasCustomTopic) delete channelKState["m.room.topic/"] // Make voice channels be a Matrix voice room (MSC3417) - if (channel.type === DiscordTypes.ChannelType.GuildVoice) { + if (channel.type === DiscordTypes.ChannelType.GuildVoice || channel.type === DiscordTypes.ChannelType.GuildStageVoice) { creationContent.type = "org.matrix.msc3417.call" channelKState["org.matrix.msc3401.call/"] = { "m.intent": "m.room", diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 70092d50..8dc59f45 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -112,7 +112,7 @@ function getChannelRoomsLinks(guild, rooms, roles) { let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c)) /** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)) - let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type)) + let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 2, 5, 13, 15, 16].includes(c.type)) let removedPrivateChannels = dUtils.filterTo(unlinkedChannels, c => { const permissions = dUtils.getPermissions(guild.id, roles, guild.roles, botID, c["permission_overwrites"]) return dUtils.hasSomePermissions(permissions, ["Administrator", "ViewChannel"]) From f6b9614277bb04c748790163b1c56a4d28f87eb9 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 15 Apr 2026 23:42:51 +0000 Subject: [PATCH 004/184] Only 2 things are eternal: Doom, and temporary solutions --- src/web/routes/guild.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 8dc59f45..0d4c744f 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -122,7 +122,7 @@ function getChannelRoomsLinks(guild, rooms, roles) { let linkedRoomIDs = linkedChannels.map(c => c.room_id) let unlinkedRooms = [...rooms] let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) - let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type) + let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type || r.room_type === "org.matrix.msc3417.call") //TODO: Replace with one of the routes described in https://matrix.to/#/!cBxtVRxDlZvSVhJXVK:cadence.moe/$7zas1wGRL9JFBL_pE01f9tFnU8lAu55k__a3fFsOlzI?via=cadence.moe&via=matrix.org&via=agiadn.org let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"]) // https://discord.com/developers/docs/topics/threads#active-archived-threads // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name From 369370d0ad566298735ee14730d2183d61103919 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 16 Apr 2026 00:01:43 +0000 Subject: [PATCH 005/184] =?UTF-8?q?=F0=9F=8E=B6=20the=20unenlightened=20ma?= =?UTF-8?q?sses,=20they=20cannot=20make=20a=20judgement=20call=F0=9F=8E=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🎵Give up free will forever - their voices won't be heard at all!🎶 🎶Display obedience...🎵 Where was I, again? Ah, right. I'm supposed to make the judgment call (I am the unenlightened masses) ((Someone tries to link their, fkin, smart-bidet smart-home-controller-room to a Stage or something and imma be cooked)) --- src/web/routes/guild.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 0d4c744f..94f1a5c0 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -122,7 +122,7 @@ function getChannelRoomsLinks(guild, rooms, roles) { let linkedRoomIDs = linkedChannels.map(c => c.room_id) let unlinkedRooms = [...rooms] let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) - let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type || r.room_type === "org.matrix.msc3417.call") //TODO: Replace with one of the routes described in https://matrix.to/#/!cBxtVRxDlZvSVhJXVK:cadence.moe/$7zas1wGRL9JFBL_pE01f9tFnU8lAu55k__a3fFsOlzI?via=cadence.moe&via=matrix.org&via=agiadn.org + let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !(r.room_type && r.room_type === "m.space")) let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"]) // https://discord.com/developers/docs/topics/threads#active-archived-threads // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name From 81bf0b935f08012d07dacf057a4fdce453a91aed Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 16 Apr 2026 20:26:20 +0000 Subject: [PATCH 006/184] Extra changes for compat with previous commit --- src/web/routes/link.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 772a19c8..cf01f9f1 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -173,8 +173,9 @@ as.router.post("/api/link", defineEventHandler(async event => { const row = from("channel_room").select("channel_id", "room_id").and("WHERE channel_id = ? OR room_id = ?").get(channel.id, parsedBody.matrix) if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`}) - // Check room is part of the guild's space + // Check room is an actual room (not space) and is part of the guild's space let foundRoom = false + let foundSpace = false /** @type {string[]?} */ let foundVia = null for await (const room of api.generateFullHierarchy(spaceID)) { @@ -186,13 +187,18 @@ as.router.post("/api/link", defineEventHandler(async event => { } // When finding a room during iteration, see if it was the requested room (to confirm that the room is in the space) - if (room.room_id === parsedBody.matrix && !room.room_type) { + if (room.room_id === parsedBody.matrix) { foundRoom = true } if (foundRoom && foundVia) break + + if (room.room_type && room.room_type === "m.space") { + foundSpace = true + } } if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) + else if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space"}) // Check room exists and bridge is joined try { From f17c070175500af4b64ee448c5fb78de6ddcd025 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 16 Apr 2026 21:10:54 +0000 Subject: [PATCH 007/184] =?UTF-8?q?Account=20for=20the=20hypothetical=20?= =?UTF-8?q?=E2=80=9E/thread=20=E2=80=9D=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/m2d/event-dispatcher.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index c9df78c9..f7d3d927 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -256,7 +256,7 @@ async function bridgeThread(event) { if (!messageID) return false; //Message not bridged? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. We COULD technically create a "headless" thread on Discord side and bridge it to a new thread-room, but that comes with a whole host of complications on its own (notably: what do we do if the message gets bridged later (by reaction emoji), and then hypothetically gets its own thread; and: getThreadRoomFromThreadEvent will have to be much more complex than a simple DB call (probably a whole new DB table would have to be created, just to hold these Matrix-branched-but-headless-on-Discord threads) because the simple „MX event --(db)--> Discord Message --(Discord spec)--> Discord thread” relation would no longer hold true), which may not be worth it, as an unbridged message in a bridged channel is already an edge-case (so it seems pointless to introduce a whole bunch of edgier-cases that handling this edge-case "properly" would bring). let name = event.content.body - if (name.startsWith("/thread ")) name = name.substring(8); + if (name.startsWith("/thread ") && name.length > 8) name = name.substring(8); else name = (await api.getEvent(event.room_id, eventID)).content.body; name = name.length < 100 ? name.replaceAll("\n", " ") : name.slice(0, 96).replaceAll("\n", " ") + "..." From 3a74dfb78f05cf5cfbd818355c34ab9fafa4720b Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 16 Apr 2026 21:27:01 +0000 Subject: [PATCH 008/184] Make sure it's actually possible to create a /thread in a Forum channel without guard()'s interference. Also, while at it, let the users know that /thread usage is a possibility in Forums. --- src/m2d/event-dispatcher.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index f7d3d927..49dc8c81 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -225,8 +225,19 @@ async event => { } } - const messageResponses = await sendEvent.sendEvent(event) - if (!messageResponses.length) return + try { + const messageResponses = await sendEvent.sendEvent(event) + if (!messageResponses.length) return + } + catch (e){ //This had to have been caught outside the regular guard()->sendError() loop, otherwise commands wouldn't get processed. + if (e.message?.includes("220001")) { //see: https://docs.discord.com/developers/topics/opcodes-and-status-codes + if(!event.content.body.startsWith("/thread")) api.sendEvent(event.room_id, "m.room.message", { + msgtype: "m.text", + body: "You cannot send regular messages in rooms bridged to forum channels! Please create a /thread instead." + }) + } + else throw e + } if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) { await matrixCommandHandler.parseAndExecute( @@ -265,7 +276,7 @@ async function bridgeThread(event) { return true; } catch (e){ - if (e.message?.includes("50024")) return false; //Tried to created a thread in a thread (see: https://docs.discord.com/developers/topics/opcodes-and-status-codes)? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. Same case as for message-not-bridged, except there at least exists a HYPOTHETICAL solution (just one so unwieldly that it's nonsensical to dedicate resources to), wheres here I don't know what could possibly be done at all. + if (e.message?.includes("50024")) return false; //Tried to created a thread in a thread? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. Same case as for message-not-bridged, except there at least exists a HYPOTHETICAL solution (just one so unwieldly that it's nonsensical to dedicate resources to), wheres here I don't know what could possibly be done at all. else throw e; //In here (unlike in matrix-command-handler.js), there are much fewer things that could "intentionally" go wrong (both thread double-creation and too-long names shouldn't be possible due to earlier checks). As such, if anything breaks, it should be reported to OOYE for further investigation, which the user should do when encountering an "ugly error" (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an "ugly error" upstream. } } From b1513a6fd169b9960e91cd732ec263035427075c Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 11:30:54 +0000 Subject: [PATCH 009/184] idea acquired --- src/m2d/event-dispatcher.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 49dc8c81..77e0b5b6 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -276,8 +276,22 @@ async function bridgeThread(event) { return true; } catch (e){ - if (e.message?.includes("50024")) return false; //Tried to created a thread in a thread? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. Same case as for message-not-bridged, except there at least exists a HYPOTHETICAL solution (just one so unwieldly that it's nonsensical to dedicate resources to), wheres here I don't know what could possibly be done at all. - else throw e; //In here (unlike in matrix-command-handler.js), there are much fewer things that could "intentionally" go wrong (both thread double-creation and too-long names shouldn't be possible due to earlier checks). As such, if anything breaks, it should be reported to OOYE for further investigation, which the user should do when encountering an "ugly error" (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an "ugly error" upstream. + if (e.message?.includes("50024")){ + api.sendEvent(event.room_id, "m.room.message", { + body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. The user will just see a regular reply.", + "m.mentions": { "user_ids": [event.sender]}, + "m.relates_to": { + event_id: eventID, + is_falling_back: false, + "m.in_reply_to": { event_id: event.event_id }, + rel_type: "m.thread" + }, + msgtype: "m.text" + }) + + return false; + } + else throw e } } From 10b6cf5bdbf3e9cb673a58ed5d84616a4f0c7eab Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 13:27:46 +0000 Subject: [PATCH 010/184] =?UTF-8?q?Undone=20some=20of=20the=20=E2=80=9Equa?= =?UTF-8?q?lity=20improvements=E2=80=9D=20from=20yesterday=20because=20I?= =?UTF-8?q?=20noticed=20they'd=20break=20auto-removing=20for=20already=20e?= =?UTF-8?q?xisting=20threads.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/m2d/event-dispatcher.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 77e0b5b6..bb1fcc3b 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -213,12 +213,15 @@ async event => { let processCommands = true if (event.content["m.relates_to"]?.rel_type === "m.thread") { - const toRedact = event.room_id + /**@type {string|null} */ + let toRedact = event.room_id const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id) processCommands = false if (bridgedTo) event.room_id = bridgedTo; - else if (await bridgeThread(event)) { + else if (!await bridgeThread(event)) toRedact = null; //Don't remove anything, if there is nowhere to relocate it to. + + if (toRedact) { api.redactEvent(toRedact, event.event_id) event.content["m.relates_to"] = undefined api.sendEvent(event.room_id, event.type, {...event.content, body: event.content.body+"\n ~ "+event.sender, formatted_body: event.content.formatted_body ? event.content.formatted_body+"
~ "+event.sender :undefined }) From 86c58f169ef44cd43bafdd26a728f530ee133f26 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 14:21:33 +0000 Subject: [PATCH 011/184] stupid emigrants... The code is always greener in the other file, or something --- src/m2d/converters/threads-and-forums.js | 111 +++++++++++++++++++++++ src/m2d/event-dispatcher.js | 46 +--------- 2 files changed, 112 insertions(+), 45 deletions(-) create mode 100644 src/m2d/converters/threads-and-forums.js diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js new file mode 100644 index 00000000..0f6e1459 --- /dev/null +++ b/src/m2d/converters/threads-and-forums.js @@ -0,0 +1,111 @@ +//@ts-check + +/* + * Misc. utils for transforming various Matrix events (eg. those sent in Forum-bridged channels; those sent) so that they're usable as threads, and for creating said threads. + */ + +const util = require("util") +const Ty = require("../../types") +const {discord, db, sync, as, select, from} = require("../../passthrough") +const {tag} = require("@cloudrac3r/html-template-tag") +const {Semaphore} = require("@chriscdn/promise-semaphore") +const assert = require("assert").strict +const DiscordTypes = require("discord-api-types/v10") + +/** @type {import("../actions/send-event")} */ +const sendEvent = sync.require("../actions/send-event") +/** @type {import("../actions/add-reaction")} */ +const addReaction = sync.require("../actions/add-reaction") +/** @type {import("../actions/redact")} */ +const redact = sync.require("../actions/redact") +/** @type {import("../actions/update-pins")}) */ +const updatePins = sync.require("../actions/update-pins") +/** @type {import("../actions/vote")}) */ +const vote = sync.require("../actions/vote") +/** @type {import("../../matrix/matrix-command-handler")} */ +const matrixCommandHandler = sync.require("../../matrix/matrix-command-handler") +/** @type {import("../../matrix/utils")} */ +const utils = sync.require("../../matrix/utils") +/** @type {import("../../matrix/api")}) */ +const api = sync.require("../../matrix/api") +/** @type {import("../../d2m/actions/create-room")} */ +const createRoom = sync.require("../../d2m/actions/create-room") +/** @type {import("../../matrix/room-upgrade")} */ +const roomUpgrade = require("../../matrix/room-upgrade") +/** @type {import("../../d2m/actions/retrigger")} */ +const retrigger = sync.require("../../d2m/actions/retrigger") +const {reg} = require("../../matrix/read-registration") + +/** + * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event Used for determining the branching-point and the title; any relation data will be stripped and its room_id will mutate to the target thread-room, if one gets created. + * @returns {Promise} whether a thread-room was created + */ +async function bridgeThread(event) { + /** @type {string} */ // @ts-ignore + const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() + const channel = discord.channels.get(channelID) + const guildID = channel?.["guild_id"] + if (!guildID) return false; //Room not bridged? We don't care. It's a Matrix-native room, let Matrix users have standard Matrix-native threads there. + + const threadEventID = event.content["m.relates_to"]?.event_id + if (!threadEventID) throw new Error("There was an event sent inside SOME Matrix thread, but it lacked any information as to what thread it actually was!"); //An „ugly error” is justified because if something like this DOES happen, then that means that it should be reported to us, as there is some broken client out there that we should account for. + const messageID = select("event_message", "message_id", {event_id: threadEventID}).pluck().get() + if (!messageID) return false; //Message not bridged? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. We COULD technically create a "headless" thread on Discord side and bridge it to a new thread-room, but that comes with a whole host of complications on its own (notably: what do we do if the message gets bridged later (by reaction emoji), and then hypothetically gets its own thread; and: getThreadRoomFromThreadEvent will have to be much more complex than a simple DB call (probably a whole new DB table would have to be created, just to hold these Matrix-branched-but-headless-on-Discord threads) because the simple „MX event --(db)--> Discord Message --(Discord spec)--> Discord thread” relation would no longer hold true), which may not be worth it, as an unbridged message in a bridged channel is already an edge-case (so it seems pointless to introduce a whole bunch of edgier-cases that handling this edge-case "properly" would bring). + + try { + event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name: computeName(event, await api.getEvent(event.room_id, threadEventID)).name})).id) + return true; + } + catch (e){ + if (e.message?.includes("50024")){ + api.sendEvent(event.room_id, "m.room.message", { + body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. The user will just see a regular reply.", + "m.mentions": { "user_ids": [event.sender]}, + "m.relates_to": { + event_id: threadEventID, + is_falling_back: false, + "m.in_reply_to": { event_id: event.event_id }, + rel_type: "m.thread" + }, + msgtype: "m.text" + }) + + return false; + } + else throw e + } +} + +/** + * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event + * @returns {Promise} true if a forum-thread-room was created + */ +async function handleForums(event) { + if (event.content.body === "/thread") return false; //Let the help be shown normally + + const row = from("channel_room").where({room_id: event.room_id}).select("channel_id").get() + /** @type {string}*/ //@ts-ignore the possibility that it's undefined: get() will return back an undefined if it's fed one (so that's undefined-safe), and createThreadWithoutMessage() won't be reached because "undefined" is neither DiscordTypes.ChannelType.GuildMedia nor DiscordTypes.ChannelType.GuildForum, so the guard clause kicks in. + let channelID = row?.channel_id + const type = discord.channels.get(channelID)?.type + if (type != DiscordTypes.ChannelType.GuildForum && type != DiscordTypes.ChannelType.GuildMedia) return false + + const name = computeName(event) + await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, type:11}) + if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. + return true +} + +/** + * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} from event + * @param {Ty.Event.Outer | null | false | undefined} fallback Reuses the "from" param value if empty. + * @returns {{name: string, truncated: boolean}} + */ +function computeName(from, fallback=null){ + let name = from.content.body + if (name.startsWith("/thread ") && name.length > 8) name = name.substring(8); + else name = (fallback ? fallback : from).content.body; + return name.length < 100 ? {name: name.replaceAll("\n", " "), truncated: false} : {name: name.slice(0, 96).replaceAll("\n", " ") + "...", truncated: true} +} + +module.exports.handleForums = handleForums +module.exports.bridgeThread = bridgeThread \ No newline at end of file diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index bb1fcc3b..95a31573 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -9,6 +9,7 @@ const Ty = require("../types") const {discord, db, sync, as, select} = require("../passthrough") const {tag} = require("@cloudrac3r/html-template-tag") const {Semaphore} = require("@chriscdn/promise-semaphore") +const { bridgeThread, handleForums } = require("./converters/threads-and-forums") /** @type {import("./actions/send-event")} */ const sendEvent = sync.require("./actions/send-event") @@ -253,51 +254,6 @@ async event => { await api.ackEvent(event) })) -/** - * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event Used for determining the branching-point and the title; any relation data will be stripped and its room_id will mutate to the target thread-room, if one gets created. - * @returns {Promise} whether a thread-room was created - */ -async function bridgeThread(event) { - /** @type {string} */ // @ts-ignore - const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() - const channel = discord.channels.get(channelID) - const guildID = channel?.["guild_id"] - if (!guildID) return false; //Room not bridged? We don't care. It's a Matrix-native room, let Matrix users have standard Matrix-native threads there. - - const eventID = event.content["m.relates_to"]?.event_id - if (!eventID) throw new Error("There was an event sent inside SOME Matrix thread, but it lacked any information as to what thread it actually was!"); //An „ugly error” is justified because if something like this DOES happen, then that means that it should be reported to us, as there is some broken client out there that we should account for. - const messageID = select("event_message", "message_id", {event_id: eventID}).pluck().get() - if (!messageID) return false; //Message not bridged? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. We COULD technically create a "headless" thread on Discord side and bridge it to a new thread-room, but that comes with a whole host of complications on its own (notably: what do we do if the message gets bridged later (by reaction emoji), and then hypothetically gets its own thread; and: getThreadRoomFromThreadEvent will have to be much more complex than a simple DB call (probably a whole new DB table would have to be created, just to hold these Matrix-branched-but-headless-on-Discord threads) because the simple „MX event --(db)--> Discord Message --(Discord spec)--> Discord thread” relation would no longer hold true), which may not be worth it, as an unbridged message in a bridged channel is already an edge-case (so it seems pointless to introduce a whole bunch of edgier-cases that handling this edge-case "properly" would bring). - - let name = event.content.body - if (name.startsWith("/thread ") && name.length > 8) name = name.substring(8); - else name = (await api.getEvent(event.room_id, eventID)).content.body; - name = name.length < 100 ? name.replaceAll("\n", " ") : name.slice(0, 96).replaceAll("\n", " ") + "..." - - try { - event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name})).id) - return true; - } - catch (e){ - if (e.message?.includes("50024")){ - api.sendEvent(event.room_id, "m.room.message", { - body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. The user will just see a regular reply.", - "m.mentions": { "user_ids": [event.sender]}, - "m.relates_to": { - event_id: eventID, - is_falling_back: false, - "m.in_reply_to": { event_id: event.event_id }, - rel_type: "m.thread" - }, - msgtype: "m.text" - }) - - return false; - } - else throw e - } -} - sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", /** * @param {Ty.Event.Outer_M_Sticker} event it is a m.sticker because that's what this listener is filtering for From d52794e22ce2b14f3eebd5801824a81348e4eace Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 17:00:16 +0000 Subject: [PATCH 012/184] ACTUALLY handle forums turns out my handling of it from yesterday was still broken --- src/m2d/event-dispatcher.js | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 95a31573..0849a93d 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -211,6 +211,7 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", */ async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return + if (await handleForums(event)) return let processCommands = true if (event.content["m.relates_to"]?.rel_type === "m.thread") { @@ -229,19 +230,8 @@ async event => { } } - try { - const messageResponses = await sendEvent.sendEvent(event) - if (!messageResponses.length) return - } - catch (e){ //This had to have been caught outside the regular guard()->sendError() loop, otherwise commands wouldn't get processed. - if (e.message?.includes("220001")) { //see: https://docs.discord.com/developers/topics/opcodes-and-status-codes - if(!event.content.body.startsWith("/thread")) api.sendEvent(event.room_id, "m.room.message", { - msgtype: "m.text", - body: "You cannot send regular messages in rooms bridged to forum channels! Please create a /thread instead." - }) - } - else throw e - } + const messageResponses = await sendEvent.sendEvent(event) + if (!messageResponses.length) return if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) { await matrixCommandHandler.parseAndExecute( From 2d82734a06ecadb540068487b26c79541f560f68 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 17:48:42 +0000 Subject: [PATCH 013/184] just appreciated how useful these links are after getting an error and having to go all the way to the Command Handler for links --- src/m2d/converters/threads-and-forums.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index 0f6e1459..b9aff853 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -57,7 +57,7 @@ async function bridgeThread(event) { return true; } catch (e){ - if (e.message?.includes("50024")){ + if (e.message?.includes("50024")){ // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes api.sendEvent(event.room_id, "m.room.message", { body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. The user will just see a regular reply.", "m.mentions": { "user_ids": [event.sender]}, @@ -82,7 +82,7 @@ async function bridgeThread(event) { */ async function handleForums(event) { if (event.content.body === "/thread") return false; //Let the help be shown normally - + const row = from("channel_room").where({room_id: event.room_id}).select("channel_id").get() /** @type {string}*/ //@ts-ignore the possibility that it's undefined: get() will return back an undefined if it's fed one (so that's undefined-safe), and createThreadWithoutMessage() won't be reached because "undefined" is neither DiscordTypes.ChannelType.GuildMedia nor DiscordTypes.ChannelType.GuildForum, so the guard clause kicks in. let channelID = row?.channel_id From e95df09c5d30a756304c951456644533fd9b67ac Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 17:59:02 +0000 Subject: [PATCH 014/184] heyyyyy~~~~~~~~ --- src/m2d/converters/threads-and-forums.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index b9aff853..661d53be 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -84,13 +84,14 @@ async function handleForums(event) { if (event.content.body === "/thread") return false; //Let the help be shown normally const row = from("channel_room").where({room_id: event.room_id}).select("channel_id").get() - /** @type {string}*/ //@ts-ignore the possibility that it's undefined: get() will return back an undefined if it's fed one (so that's undefined-safe), and createThreadWithoutMessage() won't be reached because "undefined" is neither DiscordTypes.ChannelType.GuildMedia nor DiscordTypes.ChannelType.GuildForum, so the guard clause kicks in. + /** @type {string}*/ //@ts-ignore the possibility that it's undefined - get() will return back an undefined if it's fed one (so that's undefined-safe), and createThreadWithoutMessage() won't be reached because "undefined" is neither DiscordTypes.ChannelType.GuildMedia nor DiscordTypes.ChannelType.GuildForum, so the guard clause kicks in. let channelID = row?.channel_id const type = discord.channels.get(channelID)?.type if (type != DiscordTypes.ChannelType.GuildForum && type != DiscordTypes.ChannelType.GuildMedia) return false const name = computeName(event) - await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, type:11}) + //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) + await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"heyy~"}}) if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. return true } From 81758529bacd58ddbfb8ad60f6eec864b9dafef0 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 18:46:57 +0000 Subject: [PATCH 015/184] Debugging tags-breaking --- src/m2d/converters/threads-and-forums.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index 661d53be..3b8d9b04 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -91,6 +91,7 @@ async function handleForums(event) { const name = computeName(event) //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) + console.log(discord.channels.get(channelID)?.flags) await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"heyy~"}}) if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. return true From ab482a82fe45d1385739edfbaf8ef57d7118a7f7 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 19:43:31 +0000 Subject: [PATCH 016/184] actually, GIMME DA OBJECT! --- src/m2d/converters/threads-and-forums.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index 3b8d9b04..de9d180d 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -91,7 +91,7 @@ async function handleForums(event) { const name = computeName(event) //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) - console.log(discord.channels.get(channelID)?.flags) + console.log(JSON.stringify(discord.channels.get(channelID))) await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"heyy~"}}) if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. return true From 804a6ecb746aa0bbf3eca3fc597059dac391f2f1 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 20:04:10 +0000 Subject: [PATCH 017/184] This isn't gonna work, is it? I'm 99% sure I need to do {...channel, flags:(channel.flags^DiscordTypes.ChannelFlags.RequireTag)} but TypeScript won't let me --- src/m2d/converters/threads-and-forums.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index de9d180d..5de43a6e 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -86,14 +86,19 @@ async function handleForums(event) { const row = from("channel_room").where({room_id: event.room_id}).select("channel_id").get() /** @type {string}*/ //@ts-ignore the possibility that it's undefined - get() will return back an undefined if it's fed one (so that's undefined-safe), and createThreadWithoutMessage() won't be reached because "undefined" is neither DiscordTypes.ChannelType.GuildMedia nor DiscordTypes.ChannelType.GuildForum, so the guard clause kicks in. let channelID = row?.channel_id - const type = discord.channels.get(channelID)?.type - if (type != DiscordTypes.ChannelType.GuildForum && type != DiscordTypes.ChannelType.GuildMedia) return false + const channel = discord.channels.get(channelID) + if (channel?.type != DiscordTypes.ChannelType.GuildForum && channel?.type != DiscordTypes.ChannelType.GuildMedia) return false const name = computeName(event) + let resetNeeded = false + if(channel.flags && channel.flags & DiscordTypes.ChannelFlags.RequireTag){ + await discord.snow.channel.updateChannel(channelID, {flags:(channel.flags^DiscordTypes.ChannelFlags.RequireTag)}, "Temporary flag reset, to override tagging requirements for Matrix threads that can't be tagged.") + resetNeeded = true + } //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) - console.log(JSON.stringify(discord.channels.get(channelID))) await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"heyy~"}}) if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. + if (resetNeeded) discord.snow.channel.updateChannel(channelID, channel, "Restoring flags to their original state.") return true } From 5aa13a2a92e8865a0cf3b172921f5e1abb69d091 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 21:27:14 +0000 Subject: [PATCH 018/184] Somehow, it almost did! 2 caveats remain (and neither has anything to do with not passing ...channel): * ugly-error with permissions (fixed) * no auto-reset (maybe fixed??? - it's either because I DID pass channel (ironic) or because of no await, testing option 1 now) Also, improved comment consistency --- src/m2d/converters/threads-and-forums.js | 29 ++++++++++++++++-------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index 5de43a6e..d46ef49d 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -57,7 +57,7 @@ async function bridgeThread(event) { return true; } catch (e){ - if (e.message?.includes("50024")){ // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes + if (e.message?.includes("50024")){ //see: https://docs.discord.com/developers/topics/opcodes-and-status-codes api.sendEvent(event.room_id, "m.room.message", { body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. The user will just see a regular reply.", "m.mentions": { "user_ids": [event.sender]}, @@ -78,7 +78,7 @@ async function bridgeThread(event) { /** * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event - * @returns {Promise} true if a forum-thread-room was created + * @returns {Promise} whether a forum-thread-room was created */ async function handleForums(event) { if (event.content.body === "/thread") return false; //Let the help be shown normally @@ -91,14 +91,25 @@ async function handleForums(event) { const name = computeName(event) let resetNeeded = false - if(channel.flags && channel.flags & DiscordTypes.ChannelFlags.RequireTag){ - await discord.snow.channel.updateChannel(channelID, {flags:(channel.flags^DiscordTypes.ChannelFlags.RequireTag)}, "Temporary flag reset, to override tagging requirements for Matrix threads that can't be tagged.") - resetNeeded = true + try { + if(channel.flags && channel.flags & DiscordTypes.ChannelFlags.RequireTag){ + await discord.snow.channel.updateChannel(channelID, {flags:(channel.flags^DiscordTypes.ChannelFlags.RequireTag)}, "Temporary override of tagging requirements because Matrix threads that can't be tagged yet.") + resetNeeded = true + } + //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) + await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"heyy~"}}) + if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. + if (resetNeeded) discord.snow.channel.updateChannel(channelID, {flags:channel.flags}, "Restoring flags to their original state.") + } + catch (e){ + if (e.message?.includes("50013")){ + api.sendEvent(event.room_id, "m.room.message", { + body: "You can't create threads in this forum right now! This forum is configured to require tags on post (Matrix users can't yet use tags yet), and OOYE doesn't have the permission to edit this channel on Discord (needed to bypass the requirement of tags). Unless this is intentional (see room description - the admins may have left a note), please ask someone on the Discord side to either grant OOYE the necessary permissions, or to remove tagging requirements.", + msgtype: "m.text" + }) + } + else throw e } - //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) - await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"heyy~"}}) - if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. - if (resetNeeded) discord.snow.channel.updateChannel(channelID, channel, "Restoring flags to their original state.") return true } From 88b25e0482d1d92ac1e4e43747f6b268c7749092 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 22:07:41 +0000 Subject: [PATCH 019/184] imports. --- src/m2d/converters/threads-and-forums.js | 25 +----------------------- 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index d46ef49d..a75b7e4d 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -4,37 +4,14 @@ * Misc. utils for transforming various Matrix events (eg. those sent in Forum-bridged channels; those sent) so that they're usable as threads, and for creating said threads. */ -const util = require("util") const Ty = require("../../types") -const {discord, db, sync, as, select, from} = require("../../passthrough") -const {tag} = require("@cloudrac3r/html-template-tag") -const {Semaphore} = require("@chriscdn/promise-semaphore") -const assert = require("assert").strict +const {discord, sync, select, from} = require("../../passthrough") const DiscordTypes = require("discord-api-types/v10") -/** @type {import("../actions/send-event")} */ -const sendEvent = sync.require("../actions/send-event") -/** @type {import("../actions/add-reaction")} */ -const addReaction = sync.require("../actions/add-reaction") -/** @type {import("../actions/redact")} */ -const redact = sync.require("../actions/redact") -/** @type {import("../actions/update-pins")}) */ -const updatePins = sync.require("../actions/update-pins") -/** @type {import("../actions/vote")}) */ -const vote = sync.require("../actions/vote") -/** @type {import("../../matrix/matrix-command-handler")} */ -const matrixCommandHandler = sync.require("../../matrix/matrix-command-handler") -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") /** @type {import("../../matrix/api")}) */ const api = sync.require("../../matrix/api") /** @type {import("../../d2m/actions/create-room")} */ const createRoom = sync.require("../../d2m/actions/create-room") -/** @type {import("../../matrix/room-upgrade")} */ -const roomUpgrade = require("../../matrix/room-upgrade") -/** @type {import("../../d2m/actions/retrigger")} */ -const retrigger = sync.require("../../d2m/actions/retrigger") -const {reg} = require("../../matrix/read-registration") /** * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event Used for determining the branching-point and the title; any relation data will be stripped and its room_id will mutate to the target thread-room, if one gets created. From b6a68936ecc5c4551426f31fd54242767f14cacd Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 22:10:50 +0000 Subject: [PATCH 020/184] text. --- src/m2d/converters/threads-and-forums.js | 2 +- src/matrix/matrix-command-handler.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index a75b7e4d..dcf79094 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -36,7 +36,7 @@ async function bridgeThread(event) { catch (e){ if (e.message?.includes("50024")){ //see: https://docs.discord.com/developers/topics/opcodes-and-status-codes api.sendEvent(event.room_id, "m.room.message", { - body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. The user will just see a regular reply.", + body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. DC users will just see a regular reply, which is distracting and also probably not what you want.", "m.mentions": { "user_ids": [event.sender]}, "m.relates_to": { event_id: threadEventID, diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index e59e58c2..ba72d5aa 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -276,7 +276,7 @@ const commands = [{ if (isFallingBack) return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "**`/thread` usage:**\nRun this command as `/thread [Thread Name]` to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:\n* If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The `Thread Name` argument must be provided in this case, otherwise you get this help message.\n* If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.\n* If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.", + body: "**`/thread` usage:**\nRun this command as `/thread [Thread Name]` to create a thread. The message from which said thread will branch, is chosen based on the following rules:\n* If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The `Thread Name` argument must be provided in this case, otherwise you get this help message.\n* If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.\n* If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.", format: "org.matrix.custom.html", formatted_body: "/thread usage:
Run this command as /thread [Thread Name] to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:
  • If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The Thread Name argument must be provided in this case, otherwise you get this help message.
  • If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.
  • If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.
" }) From 189ea7e769607ce921455891d23597160a7a1171 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 22:30:14 +0000 Subject: [PATCH 021/184] =?UTF-8?q?text=C2=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/m2d/converters/threads-and-forums.js | 2 +- src/matrix/matrix-command-handler.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index dcf79094..10aa8c02 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -36,7 +36,7 @@ async function bridgeThread(event) { catch (e){ if (e.message?.includes("50024")){ //see: https://docs.discord.com/developers/topics/opcodes-and-status-codes api.sendEvent(event.room_id, "m.room.message", { - body: "Hey, please don't do that! This room is already a thread on Discord - trying to embed threads inside it will not work. DC users will just see a regular reply, which is distracting and also probably not what you want.", + body: "Hey, please don't do that! This room is already a thread on Discord (or it could also be a voice-chat or something adjacent) - trying to embed threads inside it, like you just did, will not work. DC users will just see a regular reply, which is distracting and also probably not what you want.", "m.mentions": { "user_ids": [event.sender]}, "m.relates_to": { event_id: threadEventID, diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index ba72d5aa..2b99200c 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -310,7 +310,7 @@ const commands = [{ if (err?.includes("50024")) return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?" + body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. It could be something like a VC, or perhaps... Did you try to embed a thread inside a thread, silly?" }) if (err?.includes("50035")) return api.sendEvent(event.room_id, "m.room.message", { ...ctx, From e92bda4a2a73ba57f9045c28f948c4818bbde2f1 Mon Sep 17 00:00:00 2001 From: Guzio Date: Fri, 17 Apr 2026 22:39:32 +0000 Subject: [PATCH 022/184] =?UTF-8?q?Credit=20where=20credit=20is=20due(TRUE?= =?UTF-8?q?)=C2=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/m2d/converters/threads-and-forums.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js index 10aa8c02..05760b47 100644 --- a/src/m2d/converters/threads-and-forums.js +++ b/src/m2d/converters/threads-and-forums.js @@ -74,7 +74,7 @@ async function handleForums(event) { resetNeeded = true } //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) - await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"heyy~"}}) + await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"**Created by: `"+ event.sender +"`**"}}) if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. if (resetNeeded) discord.snow.channel.updateChannel(channelID, {flags:channel.flags}, "Restoring flags to their original state.") } From cc906d5fb7b3fc3ecf288941d1afdd4e7ab6b759 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 22 Apr 2026 12:18:38 +0000 Subject: [PATCH 023/184] Does EM even work? --- src/d2m/converters/thread-to-announcement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index bd3412ca..be56d7e9 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -19,7 +19,7 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) */ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get() - const ellieMode = false //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. + const ellieMode = true //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */ const context = {} From a7aad4281df2b70eb43256b66903ab3e29572405 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 22 Apr 2026 12:27:46 +0000 Subject: [PATCH 024/184] Well, it DOES WORK, alright.....! thanks, I hate it --- src/d2m/converters/thread-to-announcement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index be56d7e9..bd3412ca 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -19,7 +19,7 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) */ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get() - const ellieMode = true //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. + const ellieMode = false //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */ const context = {} From a54809155f1df3a6b60230364be85da35e1fff8c Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 22 Apr 2026 12:31:02 +0000 Subject: [PATCH 025/184] wait, I wanna test 1 more thing on mobile... --- src/d2m/converters/thread-to-announcement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index bd3412ca..be56d7e9 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -19,7 +19,7 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) */ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get() - const ellieMode = false //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. + const ellieMode = true //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */ const context = {} From cd2b5ebb13a28d5f58c14efca90ae503338957c7 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 22 Apr 2026 12:41:19 +0000 Subject: [PATCH 026/184] Yep! It does exactly what I thought it would. --- src/d2m/converters/thread-to-announcement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index be56d7e9..bd3412ca 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -19,7 +19,7 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) */ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get() - const ellieMode = true //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. + const ellieMode = false //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */ const context = {} From 7943f33dbb1e342620f00f33c4c9a26eb0282000 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 22 Apr 2026 13:50:27 +0000 Subject: [PATCH 027/184] What were those imports for, anyway? --- src/d2m/converters/thread-to-announcement.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index bd3412ca..b3a4487c 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -1,9 +1,7 @@ // @ts-check -const assert = require("assert").strict - const passthrough = require("../../passthrough") -const {discord, sync, db, select} = passthrough +const {sync, select} = passthrough /** @type {import("../../matrix/utils")} */ const mxUtils = sync.require("../../matrix/utils") const {reg} = require("../../matrix/read-registration.js") From b007822174401473be56c929d06d0bfc5f554899 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 22 Apr 2026 13:54:49 +0000 Subject: [PATCH 028/184] Okey, let's be real: Those tests were an embarrassment. --- src/matrix/utils.test.js | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/matrix/utils.test.js b/src/matrix/utils.test.js index 8db998d9..68872522 100644 --- a/src/matrix/utils.test.js +++ b/src/matrix/utils.test.js @@ -417,6 +417,11 @@ test("set user power: privileged users must demote themselves", async t => { t.equal(called, 3) }) +test("getThreadRoomFromThreadEvent: real message with a thread", t => { + const room = getThreadRoomFromThreadEvent("$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg") + t.equal(room, "!FuDZhlOAtqswlyxzeR:cadence.moe") +}) + test("getThreadRoomFromThreadEvent: real message, but without a thread", t => { const room = getThreadRoomFromThreadEvent("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") const msg = "Expected null/undefined, got: "+room @@ -424,11 +429,6 @@ test("getThreadRoomFromThreadEvent: real message, but without a thread", t => { else t.pass(msg) }) -test("getThreadRoomFromThreadEvent: real message with a thread", t => { - const room = getThreadRoomFromThreadEvent("$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg") - t.equal(room, "!FuDZhlOAtqswlyxzeR:cadence.moe") -}) - test("getThreadRoomFromThreadEvent: fake message", t => { const room = getThreadRoomFromThreadEvent("$ThisEvent-IdDoesNotExistInTheDatabase4Sure") const msg = "Expected null/undefined, got: "+room @@ -436,19 +436,4 @@ test("getThreadRoomFromThreadEvent: fake message", t => { else t.pass(msg) }) -test("getThreadRoomFromThreadEvent: null", t => { - const room = getThreadRoomFromThreadEvent(null) - t.equal(room, null) -}) - -test("getThreadRoomFromThreadEvent: undefined", t => { - const room = getThreadRoomFromThreadEvent(undefined) - t.equal(room, undefined) -}) - -test("getThreadRoomFromThreadEvent: no value at all", t => { - const room = getThreadRoomFromThreadEvent() //This line should be giving a type-error, so it's not @ts-ignored on purpose. This is to test the desired behavior of that function, ie. „it CAN TAKE an undefined VALUE (as tested above), but you can just LEAVE the value completely undefined” (well, you can leave it like that from JS syntax perspective (which is why this test passes), but it makes no sense from usage standpoint, as it just gives back undefined). So this isn't a logic test (that's handled above), as much as it is a TypeScript test. - t.equal(room, undefined) -}) - module.exports.mockGetEffectivePower = mockGetEffectivePower From 1b9f99c4fd679316daa54773d8d7f67a3641ae53 Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 22 Apr 2026 14:11:06 +0000 Subject: [PATCH 029/184] fix regression in Ellie Mode; add the announcement to it Apparently, the context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}} has always been there... The more you know! I forgot I didn't, apparently, add it myself. Also, while fixing the regression, I may as well introduce the note to EM, too. --- src/d2m/converters/thread-to-announcement.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index b3a4487c..6571619c 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -25,6 +25,8 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr if (branchedFromEventID) { // Need to figure out who sent that event... const event = await di.api.getEvent(parentRoomID, branchedFromEventID) + suffix = "\n[Note: You really should move the conversation to that room, rather than continuing to reply via a Matrix thread. Any messages sent in threads will be DELETED and instead moved to that room by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]" + context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}} if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} if (!ellieMode) { //...And actually branch from that event (if configured to do so) From d8fb4be5099668bd3d1be084bb0d1972ede5cb73 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 24 Apr 2026 21:23:14 +1200 Subject: [PATCH 030/184] d->m: Fix reply to user join message --- src/d2m/converters/message-to-event.js | 8 ++- src/d2m/converters/message-to-event.test.js | 26 ++++++++ test/data.js | 74 +++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 33d8696f..6e9ce7b6 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -669,7 +669,7 @@ async function messageToEvent(message, guild, options = {}, di) { const match = repliedToEventSenderMxid.match(/^@([^:]*)/) assert(match) repliedToDisplayName = referenced.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever - repliedToUserHtml = `${repliedToDisplayName}` + repliedToUserHtml = tag`${repliedToDisplayName}` } else { repliedToDisplayName = referenced.author.global_name || referenced.author.username || "a Discord user" repliedToUserHtml = repliedToDisplayName @@ -694,6 +694,12 @@ async function messageToEvent(message, guild, options = {}, di) { + html body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions + "\n\n" + body + } else if (referenced.type === DiscordTypes.MessageType.UserJoin) { + // Discord user join messages are bridged as joins, not text events. Generate substitute text for reply. + const joinerMxid = select("sim", "mxid", {user_id: referenced.author.id}).pluck().get() + const joinerHtml = joinerMxid ? tag`${repliedToDisplayName}` : tag`${repliedToDisplayName}` + html = `
${joinerHtml} joined the room
` + html + body = `> ${repliedToDisplayName} joined the room\n\n` + body } else { // repliedToUnknownEvent const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp) html = `
In reply to ${dateDisplay} from ${repliedToDisplayName}:` diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 97fc25df..b7f0867a 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -4,6 +4,7 @@ const {MatrixServerError} = require("../../matrix/mreq") const data = require("../../../test/data") const {mockGetEffectivePower} = require("../../matrix/utils.test") const Ty = require("../../types") +const {db} = require("../../passthrough") /** * @param {string} roomID @@ -733,6 +734,31 @@ test("message2event: reply to a Discord message that wasn't bridged", async t => }]) }) +test("message2event: reply to a Discord member join (who didn't join on Matrix)", async t => { + const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party", + format: "org.matrix.custom.html", + formatted_body: "
PEASANT!! joined the room
when the broke friend who we pay to bring food shows up at the medieval lord party", + "m.mentions": {} + }]) +}) + +test("message2event: reply to a Discord member join (who did join on Matrix)", async t => { + db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES ('1461677775554478161', 'peasant321_76775', 'peasant321_76775', '@_ooye_peasant321_76775:cadence.moe')").run() + const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party", + format: "org.matrix.custom.html", + formatted_body: `
PEASANT!! joined the room
when the broke friend who we pay to bring food shows up at the medieval lord party`, + "m.mentions": {} + }]) +}) + test("message2event: simple written @mention for matrix user", async t => { const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, { api: { diff --git a/test/data.js b/test/data.js index cc054cf4..f3092bca 100644 --- a/test/data.js +++ b/test/data.js @@ -2035,6 +2035,80 @@ module.exports = { tts: false } }, + reply_to_member_join: { + type: 19, + content: "when the broke friend who we pay to bring food shows up at the medieval lord party", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2026-03-30T12:11:04.443000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1488148556962332692", + channel_id: "475599038536744962", + author: { + id: "576945009408999426", + username: "randomllama121", + avatar: "08510a70f957106dad1580323c40cd7a", + discriminator: "0", + public_flags: 128, + flags: 128, + banner: null, + accent_color: null, + global_name: "random :3", + 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, + message_reference: { + type: 0, + channel_id: "475599038536744962", + message_id: "1488146734352826478", + guild_id: "475599038536744960" + }, + referenced_message: { + type: 7, + content: "", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2026-03-30T12:03:49.899000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1488146734352826478", + channel_id: "475599038536744962", + author: { + id: "1461677775554478161", + username: "peasant321_76775", + avatar: null, + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "PEASANT!!", + 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 + } + }, attachment_no_content: { id: "1124628646670389348", type: 0, From e301a1f3b09727f284e082a532aa3ddc097a7349 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 13:12:58 +0000 Subject: [PATCH 031/184] oh lol Wrong IF xD This is getting nuked soon, but I still find if funny that I got it wrong. --- src/web/routes/link.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index cf01f9f1..aa97f72a 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -198,7 +198,7 @@ as.router.post("/api/link", defineEventHandler(async event => { } } if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) - else if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space"}) + else if (foundSpace) throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space"}) // Check room exists and bridge is joined try { From dc03dbd5f4a051f61de70a4bf8dfcbbadab6d5f8 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 23:01:02 +0000 Subject: [PATCH 032/184] New system of linking --- src/d2m/actions/create-room.js | 12 ++++++++-- src/types.d.ts | 2 ++ src/web/pug/explain.pug | 5 +++++ src/web/pug/guild.pug | 22 ++++++++++--------- src/web/routes/guild.js | 40 +++++++++++++++++++++++++++++++--- src/web/routes/link.js | 9 ++++++-- 6 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 src/web/pug/explain.pug diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 0fb102b2..2a76f564 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -35,6 +35,7 @@ const PRIVACY_ENUMS = { ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after are visible, but for world_readable anybody can read without even joining GUEST_ACCESS: ["can_join", "forbidden", "forbidden"], // whether guests can join space if other conditions are met SPACE_JOIN_RULES: ["invite", "public", "public"], + /** @type {import("../../types").JoinRule[]} */ ROOM_JOIN_RULES: ["restricted", "public", "public"] } @@ -88,7 +89,7 @@ async function channelToKState(channel, guild, di) { const guildSpaceID = await createSpace.ensureSpace(guild) /** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */ let parentSpaceID = guildSpaceID - if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { + if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum || parentChannel?.type === DiscordTypes.ChannelType.GuildMedia) { //TODO: Once Ellie's and Guzio's MSC for room-in-room embedding starts being implemented, make this check for whether THIS channel (not its parent) is a thread of ANY type (not just threads in forum/media channels) - thus making it so that threads always appear embedded under their parent. parentSpaceID = await ensureRoom(channel.parent_id) assert(typeof parentSpaceID === "string") } @@ -111,7 +112,7 @@ async function channelToKState(channel, guild, di) { let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel] if (channel["thread_metadata"]) history_visibility = "world_readable" - /** @type {{join_rule: string, allow?: any}} */ + /** @type {{join_rule: import("../../types").JoinRule, allow?: {type: "m.room_membership", room_id: string}[]}} */ let join_rules = { join_rule: "restricted", allow: [{ @@ -119,6 +120,13 @@ async function channelToKState(channel, guild, di) { room_id: guildSpaceID }] } + if (guildSpaceID !== parentSpaceID) { + //@ts-ignore - join_rules.allow most certainly IS defined because we literally define it ~5 lines earlier + join_rules.allow[1] = { + type: "m.room_membership", + room_id: parentSpaceID + } + } if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") { join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} } diff --git a/src/types.d.ts b/src/types.d.ts index 494cba23..db209ce5 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -505,6 +505,8 @@ export namespace R { export type Membership = "invite" | "knock" | "join" | "leave" | "ban" +export type JoinRule = "public" | "knock" | "invite" | "private" | "restricted" | "knock_restricted" + export type Pagination = { chunk: T[] next_batch?: string diff --git a/src/web/pug/explain.pug b/src/web/pug/explain.pug new file mode 100644 index 00000000..d280ba55 --- /dev/null +++ b/src/web/pug/explain.pug @@ -0,0 +1,5 @@ +extends includes/template.pug + +block body + .ta-center.wmx5.p48.mx-auto#ok + p.mt24.fs-body2= msg diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 9791ae3d..990477ab 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -238,6 +238,8 @@ block body ul.my8.ml24 each row in removedWrongTypeChannels li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} + "|" + a(href=`/explain?type=${row.type}`) Why? h3.mt24 Unavailable channels: Discord bot can't access .s-card.p0 ul.my8.ml24 @@ -254,13 +256,13 @@ block body ul.my8.ml24 each row in removedEncryptedRooms li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name - h3.mt24 Unavailable rooms: Wrong type - .s-card.p0 - ul.my8.ml24 - each row in removedWrongTypeRooms - li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} - h3.mt24 Unavailable rooms: Archived thread - .s-card.p0 - ul.my8.ml24 - each row in removedArchivedThreadRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name +// h3.mt24 Unavailable rooms: Wrong type +// .s-card.p0 +// ul.my8.ml24 +// each row in removedWrongTypeRooms +// li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} +// h3.mt24 Unavailable rooms: Archived thread +// .s-card.p0 +// ul.my8.ml24 +// each row in removedArchivedThreadRooms +// li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 94f1a5c0..7a7c4acf 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -38,6 +38,9 @@ const schema = { }), inviteNonce: z.object({ nonce: z.string() + }), + explain: z.object({ + type: z.number() }) } @@ -53,6 +56,27 @@ function getAPI(event) { /** @type {LRUCache} nonce to guild id */ const validNonce = new LRUCache({max: 200}) +/** + * TYPING = Channels on which Discord messages can be sent. They should be bridgeable to anything other than an m.space (because if it did end up as a space, no one would be able to actually see the text messages sent there). + * SPACE = Channels on which Discord messages cannot be received. They should be bridgeable to m.space only (because not only does m.space make sending messages impossible on any sane client (thus preventing Discord-caused errors), but it also just-so-happens that both currently-existing message-unsupporting channel types (Categories and School hubs) are sort of "indexes", which fits nicely to m.space). + * MIXED = Forum-like channels. They can be bridged to both m.space and anything other than an m.space - hence the name. + * @type {Map}*/ +const linkRules = new Map([ + [0, {type: "TYPING", humanName:"Normal text channels"}], + [1, {type: "TYPING", humanName:"Normal DMs", unsupported: "OOYE won't support DMs until a good way of doing it can be figured out. Please see https://gitdab.com/cadence/out-of-your-element#caveats for more."}], + [2, {type: "TYPING", humanName:"Normal VCs"}], + [3, {type: "TYPING", humanName:"Group DMs", unsupported: "OOYE won't support DMs until a good way of doing it can be figured out. Please see https://gitdab.com/cadence/out-of-your-element#caveats for more."}], + [4, {type: "SPACE", humanName:"Categories", unsupported: "There is no concept of categories on Matrix."}], //...at least officially. In practice, some clients will render sub-spaces as categories. TODO: Bridge categories to sub-spaces. + [5, {type: "TYPING", humanName:"Announcement text channels"}], + [10, {type: "TYPING", humanName:"Announcement threads"}], //Currently testing: Letting users bridge threads however they like. In case this doesn't work: , unsupported: "Threads must be bridged automatically, to ensure proper lifecycle management (so that archiving threads won't break them). Please send a message in this thread to bridge it automatically." + [11, {type: "TYPING", humanName:"Normal threads"}], + [12, {type: "TYPING", humanName:"Private threads"}], + [13, {type: "TYPING", humanName:"Stage VCs"}], + [14, {type: "SPACE", humanName:"School hubs", unsupported: "Bots cannot be members of school hubs. How in the sweet hell did you manage to put OOYE on one, anyway??? ~~Emma, please stop breaking Discord API in cursed ways again.~~"}], + [15, {type: "MIXED", humanName:"Normal forums"}], + [16, {type: "MIXED", humanName:"Media forums"}], +]) + /** * @param {{type: number, parent_id?: string | null, position?: number}} channel * @param {Map} channels @@ -112,7 +136,10 @@ function getChannelRoomsLinks(guild, rooms, roles) { let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c)) /** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)) - let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 2, 5, 13, 15, 16].includes(c.type)) + let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => { + const rule = linkRules.get(c?.type) + return rule && !rule.unsupported + }) let removedPrivateChannels = dUtils.filterTo(unlinkedChannels, c => { const permissions = dUtils.getPermissions(guild.id, roles, guild.roles, botID, c["permission_overwrites"]) return dUtils.hasSomePermissions(permissions, ["Administrator", "ViewChannel"]) @@ -122,11 +149,11 @@ function getChannelRoomsLinks(guild, rooms, roles) { let linkedRoomIDs = linkedChannels.map(c => c.room_id) let unlinkedRooms = [...rooms] let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) - let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !(r.room_type && r.room_type === "m.space")) + let removedWrongTypeRooms = [] let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"]) // https://discord.com/developers/docs/topics/threads#active-archived-threads // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name - let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) + let removedArchivedThreadRooms = [] //dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) COMMENTED OUT - Currently testing: Letting users bridge threads however they like. return { linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, @@ -182,6 +209,12 @@ as.router.get("/guild", defineEventHandler(async event => { return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) })) +as.router.get("/explain", defineEventHandler(async event => { + const {type} = await getValidatedQuery(event, schema.explain.parse) + const msg = type+" is unsupported" //TODO: actually explain (I'm sure I messed something up anyway, I'll leave it for now) + return pugSync.render(event, "explain.pug", {msg}) +})) + as.router.get("/qr", defineEventHandler(async event => { const {guild_id} = await getValidatedQuery(event, schema.qr.parse) const managed = await auth.getManagedGuilds(event) @@ -267,3 +300,4 @@ as.router.post("/api/invite", defineEventHandler(async event => { module.exports._getPosition = getPosition module.exports.getInviteTargetSpaces = getInviteTargetSpaces +module.exports.linkRules = linkRules diff --git a/src/web/routes/link.js b/src/web/routes/link.js index aa97f72a..29922492 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -173,7 +173,7 @@ as.router.post("/api/link", defineEventHandler(async event => { const row = from("channel_room").select("channel_id", "room_id").and("WHERE channel_id = ? OR room_id = ?").get(channel.id, parsedBody.matrix) if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`}) - // Check room is an actual room (not space) and is part of the guild's space + // Check whether the room is an actual room or a space, and if it's a part of the guild's space let foundRoom = false let foundSpace = false /** @type {string[]?} */ @@ -198,7 +198,12 @@ as.router.post("/api/link", defineEventHandler(async event => { } } if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) - else if (foundSpace) throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space"}) + + //Ensure link rules are upheld + const rule = guildRoute.linkRules.get(channel.type) + if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-" + channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) + else if (foundSpace && rule.type === "TYPING") throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space when bridging to "+rule.humanName}) + else if (!foundSpace && rule.type === "SPACE") throw createError({status: 400, message: "Bad Request", data: "Matrix room must be of type m.space when bridging to "+rule.humanName}) // Check room exists and bridge is joined try { From ae5ea2e936b1f88f2fac965559cfe497475ae48e Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 23:03:47 +0000 Subject: [PATCH 033/184] that died quickly... --- src/web/pug/guild.pug | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 990477ab..7992b21a 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -238,8 +238,8 @@ block body ul.my8.ml24 each row in removedWrongTypeChannels li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} - "|" - a(href=`/explain?type=${row.type}`) Why? + "|" + a(href=`/explain?type=${row.type}`) Why? h3.mt24 Unavailable channels: Discord bot can't access .s-card.p0 ul.my8.ml24 From 00bca38bbe87d82e946917898895f5f9fc7f6b82 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 23:06:44 +0000 Subject: [PATCH 034/184] pug --- src/web/pug/guild.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 7992b21a..f7c046ee 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -238,7 +238,7 @@ block body ul.my8.ml24 each row in removedWrongTypeChannels li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} - "|" + span | a(href=`/explain?type=${row.type}`) Why? h3.mt24 Unavailable channels: Discord bot can't access .s-card.p0 From 69f63792709686f8d39ad80309c6c81d237146ca Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 23:09:43 +0000 Subject: [PATCH 035/184] =?UTF-8?q?WHO=20TF=20LOOKED=20AT=20COMMENTS=20AND?= =?UTF-8?q?=20WAS=20LIKE=20=E2=80=9EOh,=20yea!=20We=20should=20make=20them?= =?UTF-8?q?=20syntax-significant!=E2=80=9D=20That's=20like...=20The=20very?= =?UTF-8?q?=20fucking=20opposite=20of=20what=20comments=20are.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/web/pug/guild.pug | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index f7c046ee..43037846 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -256,13 +256,13 @@ block body ul.my8.ml24 each row in removedEncryptedRooms li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name -// h3.mt24 Unavailable rooms: Wrong type -// .s-card.p0 -// ul.my8.ml24 -// each row in removedWrongTypeRooms -// li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} -// h3.mt24 Unavailable rooms: Archived thread -// .s-card.p0 -// ul.my8.ml24 -// each row in removedArchivedThreadRooms -// li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + //h3.mt24 Unavailable rooms: Wrong type + //.s-card.p0 + // ul.my8.ml24 + // each row in removedWrongTypeRooms + // li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} + //h3.mt24 Unavailable rooms: Archived thread + //.s-card.p0 + // ul.my8.ml24 + // each row in removedArchivedThreadRooms + // li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name From ed651b41cfad0c7a58441ed1d658840e4ea7e261 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 23:27:13 +0000 Subject: [PATCH 036/184] I can feel it, it's getting somewhere! --- src/web/pug/explain.pug | 3 +-- src/web/pug/guild.pug | 7 ++++--- src/web/routes/guild.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/web/pug/explain.pug b/src/web/pug/explain.pug index d280ba55..037114b6 100644 --- a/src/web/pug/explain.pug +++ b/src/web/pug/explain.pug @@ -1,5 +1,4 @@ extends includes/template.pug block body - .ta-center.wmx5.p48.mx-auto#ok - p.mt24.fs-body2= msg + #(msg) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 43037846..5f34b7fe 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -237,9 +237,10 @@ block body .s-card.p0 ul.my8.ml24 each row in removedWrongTypeChannels - li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} - span | - a(href=`/explain?type=${row.type}`) Why? + li + a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} + span | + a(href=rel(`/explain?type=${row.type}`)) Why? h3.mt24 Unavailable channels: Discord bot can't access .s-card.p0 ul.my8.ml24 diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 7a7c4acf..a8be516e 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -40,7 +40,7 @@ const schema = { nonce: z.string() }), explain: z.object({ - type: z.number() + type: z.string() }) } From c7378d47ce23954882fc7b4fde51fac555e09285 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 23:29:21 +0000 Subject: [PATCH 037/184] fuck you vsc --- src/web/pug/guild.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 5f34b7fe..3cad5ca9 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -238,7 +238,7 @@ block body ul.my8.ml24 each row in removedWrongTypeChannels li - a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} + a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} span | a(href=rel(`/explain?type=${row.type}`)) Why? h3.mt24 Unavailable channels: Discord bot can't access From 82c6f0ab194f060426f5071f2ca4fecd0dfbdd88 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sat, 25 Apr 2026 23:32:09 +0000 Subject: [PATCH 038/184] guild.pug works! --- src/web/pug/explain.pug | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/web/pug/explain.pug b/src/web/pug/explain.pug index 037114b6..d280ba55 100644 --- a/src/web/pug/explain.pug +++ b/src/web/pug/explain.pug @@ -1,4 +1,5 @@ extends includes/template.pug block body - #(msg) + .ta-center.wmx5.p48.mx-auto#ok + p.mt24.fs-body2= msg From 9cbeef9efb6970b12ff2cf76b5d1e3ed3d6b7e38 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 09:42:11 +0000 Subject: [PATCH 039/184] fell asleep --- src/web/routes/guild.js | 5 ++++- src/web/routes/link.js | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index a8be516e..78cf7a57 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -211,7 +211,10 @@ as.router.get("/guild", defineEventHandler(async event => { as.router.get("/explain", defineEventHandler(async event => { const {type} = await getValidatedQuery(event, schema.explain.parse) - const msg = type+" is unsupported" //TODO: actually explain (I'm sure I messed something up anyway, I'll leave it for now) + const rule = linkRules.get(Number.parseInt(type)) + if (!rule) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge type-" + type + " channels because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) + else if (rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-" + channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) + else throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-" + channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) return pugSync.render(event, "explain.pug", {msg}) })) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 29922492..8394a9e8 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -201,7 +201,7 @@ as.router.post("/api/link", defineEventHandler(async event => { //Ensure link rules are upheld const rule = guildRoute.linkRules.get(channel.type) - if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-" + channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) + if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-"+channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) else if (foundSpace && rule.type === "TYPING") throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space when bridging to "+rule.humanName}) else if (!foundSpace && rule.type === "SPACE") throw createError({status: 400, message: "Bad Request", data: "Matrix room must be of type m.space when bridging to "+rule.humanName}) From 511138e31fbb23b5bf10e663a752e2dbcc4aee4e Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 09:57:26 +0000 Subject: [PATCH 040/184] Link Rules system DONE! --- src/web/routes/guild.js | 7 +++---- src/web/routes/link.js | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 78cf7a57..adf85d9c 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -212,10 +212,9 @@ as.router.get("/guild", defineEventHandler(async event => { as.router.get("/explain", defineEventHandler(async event => { const {type} = await getValidatedQuery(event, schema.explain.parse) const rule = linkRules.get(Number.parseInt(type)) - if (!rule) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge type-" + type + " channels because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) - else if (rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-" + channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) - else throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-" + channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) - return pugSync.render(event, "explain.pug", {msg}) + if (!rule) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge to type-" + type + " channels because OOYE doesn't even know what they are yet."}) + else if (rule.unsupported) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge to " + rule.humanName + " (type-" + type + " channels) because: " + rule.unsupported}) + else return pugSync.render(event, "explain.pug", {msg: "You can bridge to " + rule.humanName + " (type-" + type + " channels) just fine. Why are you even here?"}) })) as.router.get("/qr", defineEventHandler(async event => { diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 8394a9e8..1ec34a5e 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -201,7 +201,7 @@ as.router.post("/api/link", defineEventHandler(async event => { //Ensure link rules are upheld const rule = guildRoute.linkRules.get(channel.type) - if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge " + (rule ? (rule.humanName+"(type-"+channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) + if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge to " + (rule ? (rule.humanName+"(type-"+channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) else if (foundSpace && rule.type === "TYPING") throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space when bridging to "+rule.humanName}) else if (!foundSpace && rule.type === "SPACE") throw createError({status: 400, message: "Bad Request", data: "Matrix room must be of type m.space when bridging to "+rule.humanName}) From 75fec8819df3fa7d96add8591aed95b455b88922 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 10:32:51 +0000 Subject: [PATCH 041/184] Somehow, everything is a space... --- src/web/routes/link.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 1ec34a5e..b8c69c13 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -196,6 +196,7 @@ as.router.post("/api/link", defineEventHandler(async event => { if (room.room_type && room.room_type === "m.space") { foundSpace = true } + console.log(room.room_id + " ==> "+ foundSpace) } if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) From b0d4b4c39d2ff4ffa72530c42aefc8375992b1fe Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 10:36:18 +0000 Subject: [PATCH 042/184] forgor to print the most important thing --- src/web/routes/link.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index b8c69c13..89ee3ca0 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -196,7 +196,7 @@ as.router.post("/api/link", defineEventHandler(async event => { if (room.room_type && room.room_type === "m.space") { foundSpace = true } - console.log(room.room_id + " ==> "+ foundSpace) + console.log(room.room_type+"@"+room.room_id + " ==> "+ foundSpace) } if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) From f53190f186dd4d56cc8d99a704e1a8bc662f5c9f Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 10:56:50 +0000 Subject: [PATCH 043/184] OH, I'm SUCH an IDIOT! This code runs in a loop. And it always starts with the root space. So it always finds a space. And it then never clears it out. Also, it was after the break;, so EVEN IF it somehow reached our room as false, and then was gonna update it to true because it was a space - it now would not have done that because the loop will had been ended. Fixed by moving the code to where it made any sense. --- src/web/routes/link.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 89ee3ca0..1b8d7734 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -189,18 +189,15 @@ as.router.post("/api/link", defineEventHandler(async event => { // When finding a room during iteration, see if it was the requested room (to confirm that the room is in the space) if (room.room_id === parsedBody.matrix) { foundRoom = true + // And also, now that we know that the room object is our intended room - we can test for its type. + if (room.room_type && room.room_type === "m.space") foundSpace = true } if (foundRoom && foundVia) break - - if (room.room_type && room.room_type === "m.space") { - foundSpace = true - } - console.log(room.room_type+"@"+room.room_id + " ==> "+ foundSpace) } if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) - //Ensure link rules are upheld + // Ensure link rules are upheld const rule = guildRoute.linkRules.get(channel.type) if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge to " + (rule ? (rule.humanName+"(type-"+channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) else if (foundSpace && rule.type === "TYPING") throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space when bridging to "+rule.humanName}) From c3f2fbbeb1718106173a28a7614ef9da91413e53 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 12:22:45 +0000 Subject: [PATCH 044/184] Settled on a reasonable compromise for this --- src/web/pug/guild.pug | 534 ++++++++++++++++++++-------------------- src/web/routes/guild.js | 4 +- 2 files changed, 267 insertions(+), 271 deletions(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 3cad5ca9..7314cea8 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -1,269 +1,265 @@ -extends includes/template.pug -include includes/default-roles-list.pug - -mixin badge-readonly - .s-badge.s-badge__xs.s-badge__icon.s-badge__muted - != icons.Icons.IconEyeSm - | Read-only - -mixin badge-private - .s-badge.s-badge__xs.s-badge__icon.s-badge__warning - != icons.Icons.IconLockSm - | Private - -mixin discord(channel, radio=false) - //- Previously, we passed guild.roles as the second parameter, but this doesn't quite match Discord's behaviour. See issue #42 for why this was changed. - //- Basically we just want to assign badges based on the channel overwrites, without considering the guild's base permissions. /shrug - - let permissions = dUtils.getPermissions(guild_id, [], [{id: guild_id, name: "@everyone", permissions: 1<<10 | 1<<11}], null, channel.permission_overwrites) - .s-user-card.s-user-card__small - if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) - != icons.Icons.IconLock - else if channel.type === 5 - != icons.Icons.IconBullhorn - else if channel.type === 2 - != icons.Icons.IconPhone - else if channel.type === 11 || channel.type === 12 - != icons.Icons.IconCollection - else - include includes/hash.svg - .s-user-card--info.ws-nowrap - if radio - = channel.name - else - .s-user-card--link.fs-body1 - a(href=`https://discord.com/channels/${channel.guild_id}/${channel.id}`)= channel.name - if channel.parent_id - .s-user-card--location= discord.channels.get(channel.parent_id).name - if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) - +badge-private - else if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages) - +badge-readonly - -mixin matrix(row, radio=false, badge="") - .s-user-card.s-user-card__small - != icons.Icons.IconMessage - .s-user-card--info.ws-nowrap - if radio - = row.nick || row.name - else - .s-user-card--link.fs-body1 - a(href=`https://matrix.to/#/${row.room_id}`)= row.nick || row.name - if row.join_rule === "invite" - +badge-private - -block body - .s-page-title.mb24 - h1.s-page-title--header= guild.name - - .d-flex.g16(class="sm:fw-wrap") - .fl-grow1 - h2.fs-headline1 Invite a Matrix user - - form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-trigger="submit" hx-swap="none" hx-on::after-request="if (event.detail.successful) this.reset()" hx-disabled-elt="input, button" hx-indicator="#invite-button") - label.s-label(for="mxid") Matrix ID - input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)") - label.s-label(for="permissions") Permissions - .s-select - select#permissions(name="permissions") - option(value="default") Default - option(value="moderator") Moderator - option(value="admin") Admin - input(type="hidden" name="guild_id" value=guild_id) - .grid--row-start2 - button.s-btn.s-btn__filled#invite-button Invite - div - .s-card.d-flex.ai-center.jc-center(style="min-width: 132px; min-height: 132px;") - button.s-btn(class=space_id ? "s-btn__muted" : "s-btn__filled" hx-get=rel(`/qr?guild_id=${guild_id}`) hx-indicator="closest button" hx-swap="outerHTML" hx-disabled-elt="this") Show QR - - if space_id - h2.mt48.fs-headline1 Server settings - h3.mt32.fs-category How Matrix users join - span#privacy-level-loading - .s-card - form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") - input(type="hidden" name="guild_id" value=guild_id) - - .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") - input(type="radio" name="privacy_level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) - label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") - != icons.Icons.IconPlusSm - != icons.Icons.IconInternationalSm - .fl-grow1 Directory - - input(type="radio" name="privacy_level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) - label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") - != icons.Icons.IconPlusSm - != icons.Icons.IconLinkSm - .fl-grow1 Link - - input(type="radio" name="privacy_level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) - label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") - svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") - != icons.Icons.IconLockSm - .fl-grow1 Invite - - p.s-description.m0 In-app direct invite from another user - p.s-description.m0 Shareable invite links, like Discord - p.s-description.m0 Publicly listed in directory, like Discord server discovery - - h3.mt32.fs-category Default roles - .s-card - form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-sync="this:drop" hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles - input(type="hidden" name="guild_id" value=guild_id) - .d-flex.fw-wrap.g4 - .s-tag.s-tag__md.fs-body1.s-tag__required @everyone - - +default-roles-list(guild, guild_id) - - button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0 - .s-tag--dismiss.m1 - != icons.Icons.IconPlusSm - - #role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible - .s-popover--arrow.s-popover--arrow__tc - +add-roles-menu(guild, guild_id) - p.fc-medium.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate. - - h3.mt32.fs-category Features - .s-card.d-grid.px0.g16 - form.d-flex.ai-center.g16 - #url-preview-loading.p8 - - let value = !!select("guild_space", "url_preview", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#url-preview(name="url_preview" type="checkbox" hx-post=rel("/api/url-preview") hx-indicator="#url-preview-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label.fl-grow1(for="url-preview") - | Show Discord's URL previews on Matrix - p.s-description Shows info about links posted to chat. Discord's previews are generally better quality than Synapse's, especially for social media and videos. - - form.d-flex.ai-center.g16 - #presence-loading.p8 - - value = !!select("guild_space", "presence", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#presence(name="presence" type="checkbox" hx-post=rel("/api/presence") hx-indicator="#presence-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label(for="presence") - | Show online statuses on Matrix - p.s-description This might cause lag on really big Discord servers. - - form.d-flex.ai-center.g16 - #webhook-profile-loading.p8 - - value = !!select("guild_space", "webhook_profile", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#webhook-profile(name="webhook_profile" type="checkbox" hx-post=rel("/api/webhook-profile") hx-indicator="#webhook-profile-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label(for="webhook-profile") - | Create persistent Matrix sims for webhooks - p.s-description Useful when using other Discord bridges. Otherwise, not ideal, as sims will clutter the Matrix user list and will never be cleaned up. - - if space_id - h2.mt48.fs-headline1 Channel setup - - h3.mt32.fs-category Linked channels - .s-card.bs-sm.p0 - form.s-table-container(method="post" action=rel("/api/unlink")) - input(type="hidden" name="guild_id" value=guild_id) - table.s-table.s-table__bx-simple - each row in linkedChannelsWithDetails - tr - td.w40: +discord(row.channel) - td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" cx-prevent-default hx-post=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources." value=row.channel.id hx-indicator="this" hx-disabled-elt="this")!= icons.Icons.IconLinkSm - td: +matrix(row) - else - tr - td(colspan="3") - .s-empty-state No channels linked between Discord and Matrix yet... - - h3.fs-category.mt32 Auto-create - .s-card.d-grid.px0 - form.d-flex.ai-center.g16 - #autocreate-loading.p8 - - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#autocreate(name="autocreate" type="checkbox" hx-post=rel("/api/autocreate") hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label.fl-grow1(for="autocreate") - | Create new Matrix rooms automatically - p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in. - - if space_id - h3.mt32.fs-category Manually link channels - form.d-flex.g16.ai-start(hx-post=rel("/api/link") hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button") - .fl-grow2.s-btn-group.fd-column.w40 - each channel in unlinkedChannels - input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id) - label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) - +discord(channel, true, "Announcement") - else - .s-empty-state.p8 All Discord channels are linked. - .fl-grow1.s-btn-group.fd-column.w30 - each room in unlinkedRooms - input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) - label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) - +matrix(room, true) - else - .s-empty-state.p8 All Matrix rooms are linked. - input(type="hidden" name="guild_id" value=guild_id) - div - button.s-btn.s-btn__icon.s-btn__filled#link-button - != icons.Icons.IconMerge - = ` Link` - - h3.mt32.fs-category Unlink server - form.s-card.d-flex.gx16.ai-center(method="post" action=rel("/api/unlink-space")) - input(type="hidden" name="guild_id" value=guild.id) - .fl-grow1.s-prose.s-prose__sm.lh-lg - p.fc-medium. - Not using this bridge, or just made a mistake? You can unlink the whole server and all its channels.#[br] - This may take a minute to process. Please be patient and wait until the page refreshes. - div - button.s-btn.s-btn__icon.s-btn__danger.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server and all its channels?\nIt may take a minute to clean up Matrix resources." hx-indicator="this" hx-disabled-elt="this") - != icons.Icons.IconUnsync - span.ml4= ` Unlink` - - if space_id - details.mt48 - summary Debug room list - .d-grid.grid__2.gx24 - div - h3.mt24 Channels - p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked. - div - h3.mt24 Rooms - p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked. - div - h3.mt24 Unavailable channels: Deleted from Discord - .s-card.p0 - ul.my8.ml24 - each row in removedUncachedChannels - li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.nick || row.name - h3.mt24 Unavailable channels: Wrong type - .s-card.p0 - ul.my8.ml24 - each row in removedWrongTypeChannels - li - a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} - span | - a(href=rel(`/explain?type=${row.type}`)) Why? - h3.mt24 Unavailable channels: Discord bot can't access - .s-card.p0 - ul.my8.ml24 - each row in removedPrivateChannels - li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.name - div- // Rooms - h3.mt24 Unavailable rooms: Already linked - .s-card.p0 - ul.my8.ml24 - each row in removedLinkedRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name - h3.mt24 Unavailable rooms: Encryption not supported - .s-card.p0 - ul.my8.ml24 - each row in removedEncryptedRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name - //h3.mt24 Unavailable rooms: Wrong type - //.s-card.p0 - // ul.my8.ml24 - // each row in removedWrongTypeRooms - // li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} - //h3.mt24 Unavailable rooms: Archived thread - //.s-card.p0 - // ul.my8.ml24 - // each row in removedArchivedThreadRooms - // li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name +extends includes/template.pug +include includes/default-roles-list.pug + +mixin badge-readonly + .s-badge.s-badge__xs.s-badge__icon.s-badge__muted + != icons.Icons.IconEyeSm + | Read-only + +mixin badge-private + .s-badge.s-badge__xs.s-badge__icon.s-badge__warning + != icons.Icons.IconLockSm + | Private + +mixin discord(channel, radio=false) + //- Previously, we passed guild.roles as the second parameter, but this doesn't quite match Discord's behaviour. See issue #42 for why this was changed. + //- Basically we just want to assign badges based on the channel overwrites, without considering the guild's base permissions. /shrug + - let permissions = dUtils.getPermissions(guild_id, [], [{id: guild_id, name: "@everyone", permissions: 1<<10 | 1<<11}], null, channel.permission_overwrites) + .s-user-card.s-user-card__small + if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) + != icons.Icons.IconLock + else if channel.type === 5 + != icons.Icons.IconBullhorn + else if channel.type === 2 + != icons.Icons.IconPhone + else if channel.type === 11 || channel.type === 12 + != icons.Icons.IconCollection + else + include includes/hash.svg + .s-user-card--info.ws-nowrap + if radio + = channel.name + else + .s-user-card--link.fs-body1 + a(href=`https://discord.com/channels/${channel.guild_id}/${channel.id}`)= channel.name + if channel.parent_id + .s-user-card--location= discord.channels.get(channel.parent_id).name + if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) + +badge-private + else if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages) + +badge-readonly + +mixin matrix(row, radio=false, badge="") + .s-user-card.s-user-card__small + != icons.Icons.IconMessage + .s-user-card--info.ws-nowrap + if radio + = row.nick || row.name + else + .s-user-card--link.fs-body1 + a(href=`https://matrix.to/#/${row.room_id}`)= row.nick || row.name + if row.join_rule === "invite" + +badge-private + +block body + .s-page-title.mb24 + h1.s-page-title--header= guild.name + + .d-flex.g16(class="sm:fw-wrap") + .fl-grow1 + h2.fs-headline1 Invite a Matrix user + + form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-trigger="submit" hx-swap="none" hx-on::after-request="if (event.detail.successful) this.reset()" hx-disabled-elt="input, button" hx-indicator="#invite-button") + label.s-label(for="mxid") Matrix ID + input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)") + label.s-label(for="permissions") Permissions + .s-select + select#permissions(name="permissions") + option(value="default") Default + option(value="moderator") Moderator + option(value="admin") Admin + input(type="hidden" name="guild_id" value=guild_id) + .grid--row-start2 + button.s-btn.s-btn__filled#invite-button Invite + div + .s-card.d-flex.ai-center.jc-center(style="min-width: 132px; min-height: 132px;") + button.s-btn(class=space_id ? "s-btn__muted" : "s-btn__filled" hx-get=rel(`/qr?guild_id=${guild_id}`) hx-indicator="closest button" hx-swap="outerHTML" hx-disabled-elt="this") Show QR + + if space_id + h2.mt48.fs-headline1 Server settings + h3.mt32.fs-category How Matrix users join + span#privacy-level-loading + .s-card + form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") + input(type="hidden" name="guild_id" value=guild_id) + + .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") + input(type="radio" name="privacy_level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) + label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") + != icons.Icons.IconPlusSm + != icons.Icons.IconInternationalSm + .fl-grow1 Directory + + input(type="radio" name="privacy_level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) + label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") + != icons.Icons.IconPlusSm + != icons.Icons.IconLinkSm + .fl-grow1 Link + + input(type="radio" name="privacy_level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) + label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") + svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") + != icons.Icons.IconLockSm + .fl-grow1 Invite + + p.s-description.m0 In-app direct invite from another user + p.s-description.m0 Shareable invite links, like Discord + p.s-description.m0 Publicly listed in directory, like Discord server discovery + + h3.mt32.fs-category Default roles + .s-card + form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-sync="this:drop" hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles + input(type="hidden" name="guild_id" value=guild_id) + .d-flex.fw-wrap.g4 + .s-tag.s-tag__md.fs-body1.s-tag__required @everyone + + +default-roles-list(guild, guild_id) + + button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0 + .s-tag--dismiss.m1 + != icons.Icons.IconPlusSm + + #role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible + .s-popover--arrow.s-popover--arrow__tc + +add-roles-menu(guild, guild_id) + p.fc-medium.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate. + + h3.mt32.fs-category Features + .s-card.d-grid.px0.g16 + form.d-flex.ai-center.g16 + #url-preview-loading.p8 + - let value = !!select("guild_space", "url_preview", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#url-preview(name="url_preview" type="checkbox" hx-post=rel("/api/url-preview") hx-indicator="#url-preview-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label.fl-grow1(for="url-preview") + | Show Discord's URL previews on Matrix + p.s-description Shows info about links posted to chat. Discord's previews are generally better quality than Synapse's, especially for social media and videos. + + form.d-flex.ai-center.g16 + #presence-loading.p8 + - value = !!select("guild_space", "presence", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#presence(name="presence" type="checkbox" hx-post=rel("/api/presence") hx-indicator="#presence-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label(for="presence") + | Show online statuses on Matrix + p.s-description This might cause lag on really big Discord servers. + + form.d-flex.ai-center.g16 + #webhook-profile-loading.p8 + - value = !!select("guild_space", "webhook_profile", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#webhook-profile(name="webhook_profile" type="checkbox" hx-post=rel("/api/webhook-profile") hx-indicator="#webhook-profile-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label(for="webhook-profile") + | Create persistent Matrix sims for webhooks + p.s-description Useful when using other Discord bridges. Otherwise, not ideal, as sims will clutter the Matrix user list and will never be cleaned up. + + if space_id + h2.mt48.fs-headline1 Channel setup + + h3.mt32.fs-category Linked channels + .s-card.bs-sm.p0 + form.s-table-container(method="post" action=rel("/api/unlink")) + input(type="hidden" name="guild_id" value=guild_id) + table.s-table.s-table__bx-simple + each row in linkedChannelsWithDetails + tr + td.w40: +discord(row.channel) + td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" cx-prevent-default hx-post=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources." value=row.channel.id hx-indicator="this" hx-disabled-elt="this")!= icons.Icons.IconLinkSm + td: +matrix(row) + else + tr + td(colspan="3") + .s-empty-state No channels linked between Discord and Matrix yet... + + h3.fs-category.mt32 Auto-create + .s-card.d-grid.px0 + form.d-flex.ai-center.g16 + #autocreate-loading.p8 + - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#autocreate(name="autocreate" type="checkbox" hx-post=rel("/api/autocreate") hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label.fl-grow1(for="autocreate") + | Create new Matrix rooms automatically + p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in. + + if space_id + h3.mt32.fs-category Manually link channels + form.d-flex.g16.ai-start(hx-post=rel("/api/link") hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button") + .fl-grow2.s-btn-group.fd-column.w40 + each channel in unlinkedChannels + input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id) + label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) + +discord(channel, true, "Announcement") + else + .s-empty-state.p8 All Discord channels are linked. + .fl-grow1.s-btn-group.fd-column.w30 + each room in unlinkedRooms + input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) + label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) + +matrix(room, true) + else + .s-empty-state.p8 All Matrix rooms are linked. + input(type="hidden" name="guild_id" value=guild_id) + div + button.s-btn.s-btn__icon.s-btn__filled#link-button + != icons.Icons.IconMerge + = ` Link` + + h3.mt32.fs-category Unlink server + form.s-card.d-flex.gx16.ai-center(method="post" action=rel("/api/unlink-space")) + input(type="hidden" name="guild_id" value=guild.id) + .fl-grow1.s-prose.s-prose__sm.lh-lg + p.fc-medium. + Not using this bridge, or just made a mistake? You can unlink the whole server and all its channels.#[br] + This may take a minute to process. Please be patient and wait until the page refreshes. + div + button.s-btn.s-btn__icon.s-btn__danger.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server and all its channels?\nIt may take a minute to clean up Matrix resources." hx-indicator="this" hx-disabled-elt="this") + != icons.Icons.IconUnsync + span.ml4= ` Unlink` + + if space_id + details.mt48 + summary Debug room list + .d-grid.grid__2.gx24 + div + h3.mt24 Channels + p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked. + div + h3.mt24 Rooms + p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked. + div + h3.mt24 Unavailable channels: Deleted from Discord + .s-card.p0 + ul.my8.ml24 + each row in removedUncachedChannels + li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.nick || row.name + h3.mt24 Unavailable channels: Wrong type + .s-card.p0 + ul.my8.ml24 + each row in removedWrongTypeChannels + li + a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} + span | + a(href=rel(`/explain?type=${row.type}`)) Why? + h3.mt24 Unavailable channels: Discord bot can't access + .s-card.p0 + ul.my8.ml24 + each row in removedPrivateChannels + li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.name + div- // Rooms + h3.mt24 Unavailable rooms: Already linked + .s-card.p0 + ul.my8.ml24 + each row in removedLinkedRooms + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + h3.mt24 Unavailable rooms: Encryption not supported + .s-card.p0 + ul.my8.ml24 + each row in removedEncryptedRooms + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + h3.mt24 Unavailable rooms: Archived thread + p If you still want to link with any of these rooms (eg. you accidentally unlinked it and want to bring it back, or you're migrating from a different bridge that happens to use OOYE's prefixes), please remove the [⛓️] or [🔒⛓️] prefix in Matrix's room settings and refresh the page. + .s-card.p0 + ul.my8.ml24 + each row in removedArchivedThreadRooms + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index adf85d9c..3cd003e7 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -68,7 +68,7 @@ const linkRules = new Map([ [3, {type: "TYPING", humanName:"Group DMs", unsupported: "OOYE won't support DMs until a good way of doing it can be figured out. Please see https://gitdab.com/cadence/out-of-your-element#caveats for more."}], [4, {type: "SPACE", humanName:"Categories", unsupported: "There is no concept of categories on Matrix."}], //...at least officially. In practice, some clients will render sub-spaces as categories. TODO: Bridge categories to sub-spaces. [5, {type: "TYPING", humanName:"Announcement text channels"}], - [10, {type: "TYPING", humanName:"Announcement threads"}], //Currently testing: Letting users bridge threads however they like. In case this doesn't work: , unsupported: "Threads must be bridged automatically, to ensure proper lifecycle management (so that archiving threads won't break them). Please send a message in this thread to bridge it automatically." + [10, {type: "TYPING", humanName:"Announcement threads"}], [11, {type: "TYPING", humanName:"Normal threads"}], [12, {type: "TYPING", humanName:"Private threads"}], [13, {type: "TYPING", humanName:"Stage VCs"}], @@ -153,7 +153,7 @@ function getChannelRoomsLinks(guild, rooms, roles) { let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"]) // https://discord.com/developers/docs/topics/threads#active-archived-threads // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name - let removedArchivedThreadRooms = [] //dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) COMMENTED OUT - Currently testing: Letting users bridge threads however they like. + let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) return { linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, From c8bf730ec4a63d022c1fc9a472393a7152d70ab0 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 13:07:39 +0000 Subject: [PATCH 045/184] Filter out the root space --- src/web/pug/guild.pug | 5 +++++ src/web/routes/guild.js | 11 ++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 7314cea8..a65efade 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -257,6 +257,11 @@ block body ul.my8.ml24 each row in removedEncryptedRooms li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + h3.mt24 Unavailable rooms: Root space + .s-card.p0 + ul.my8.ml24 + each row in removedRootSpaceRooms + li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} h3.mt24 Unavailable rooms: Archived thread p If you still want to link with any of these rooms (eg. you accidentally unlinked it and want to bring it back, or you're migrating from a different bridge that happens to use OOYE's prefixes), please remove the [⛓️] or [🔒⛓️] prefix in Matrix's room settings and refresh the page. .s-card.p0 diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 3cd003e7..c4fd13cf 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -118,8 +118,9 @@ function getPosition(channel, channels) { * @param {DiscordTypes.APIGuild} guild * @param {Ty.R.Hierarchy[]} rooms * @param {string[]} roles + * @param {string?} space */ -function getChannelRoomsLinks(guild, rooms, roles) { +function getChannelRoomsLinks(guild, rooms, roles, space) { let channelIDs = discord.guildChannelMap.get(guild.id) assert(channelIDs) @@ -149,7 +150,7 @@ function getChannelRoomsLinks(guild, rooms, roles) { let linkedRoomIDs = linkedChannels.map(c => c.room_id) let unlinkedRooms = [...rooms] let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) - let removedWrongTypeRooms = [] + let removedRootSpaceRooms = dUtils.filterTo(unlinkedRooms, r => r.room_id !== space) let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"]) // https://discord.com/developers/docs/topics/threads#active-archived-threads // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name @@ -157,7 +158,7 @@ function getChannelRoomsLinks(guild, rooms, roles) { return { linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, - removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms, removedEncryptedRooms + removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedRootSpaceRooms, removedArchivedThreadRooms, removedEncryptedRooms } } @@ -198,14 +199,14 @@ as.router.get("/guild", defineEventHandler(async event => { // Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space if (!row.space_id) { - const links = getChannelRoomsLinks(guild, [], roles) + const links = getChannelRoomsLinks(guild, [], roles, row.space_id) return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) } // Linked guild const api = getAPI(event) const rooms = await api.getFullHierarchy(row.space_id) - const links = getChannelRoomsLinks(guild, rooms, roles) + const links = getChannelRoomsLinks(guild, rooms, roles, row.space_id) return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) })) From 32ba4d838563e5c5d2f788c19ac1429589af8229 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 13:31:08 +0000 Subject: [PATCH 046/184] Finishing touches..... --- src/d2m/actions/create-room.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 2a76f564..f78ab27d 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -55,7 +55,7 @@ function convertNameAndTopic(channel, guild, customName) { // @ts-ignore const parentChannel = discord.channels.get(channel.parent_id) let channelPrefix = - ( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "[❓] " + ( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "" : channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.AnnouncementThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] " @@ -64,13 +64,13 @@ function convertNameAndTopic(channel, guild, customName) { const chosenName = customName || (channelPrefix + channel.name); const maybeTopicWithPipe = channel.topic ? ` | ${channel.topic}` : ''; const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : ''; + const maybeWithin = parentChannel ? `Within: ${parentChannel.name} (ID: ${parentChannel.id})\n` : ''; const channelIDPart = `Channel ID: ${channel.id}`; const guildIDPart = `Guild ID: ${guild.id}`; - const maybeThreadWithinPart = parentChannel ? `Thread within: ${parentChannel.name} (ID: ${parentChannel.id})\n` : ''; const convertedTopic = customName - ? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${maybeThreadWithinPart}${guildIDPart}` - : `${maybeTopicWithNewlines}${channelIDPart}\n${maybeThreadWithinPart}${guildIDPart}`; + ? `#${channel.name}${maybeTopicWithPipe}\n\n${maybeWithin}${channelIDPart}\n${guildIDPart}` + : `${maybeTopicWithNewlines}${maybeWithin}${channelIDPart}\n${guildIDPart}`; return [chosenName, convertedTopic]; } From ea94dfe6b9cc48961ef71dab48bfdce031f968c6 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 14:12:13 +0000 Subject: [PATCH 047/184] Fighting the demons of my past TECHNICALLY, I was asked to remove it. But now that I know that @template is a thing that exists in JSDoc (which was the one missing ingredient the last time), I think I can get this to be good enough that removal won't even be necessary, and now it just looks like regular, sane documentation (not the any-schizopost like last time). --- src/m2d/event-dispatcher.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 0849a93d..f7191bec 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -158,13 +158,17 @@ async function sendError(roomID, source, type, e, payload) { } /** - * @param {string} type - * @param {(event: Ty.Event.Outer & {type: any, redacts:any, state_key:any}, ...args: any)=>any} fn + * Wraps the function with an automated error catching and reporting mechanism + * @template {Ty.Event.Outer} EVENT The event that the wrapped function processes, its first argument. + * @template {[]} ARGS Other arguments of the wrapped function + * @template RETURNS The output of the wrapped function + * @param {string} type Type of the event, during the processing of which the error may occur. + * @param {(event: EVENT, ...args: ARGS)=>RETURNS|Promise} fn Function to wrap + * @returns {(event: EVENT, ...args: ARGS)=>Promise} Wrapped function */ function guard(type, fn) { - return async function(/** @type {Ty.Event.Outer} */ event, /** @type {any} */ ...args) { + return async function(event, ...args) { try { - // @ts-ignore return await fn(event, ...args) } catch (e) { await sendError(event.room_id, "Matrix", type, e, event) From f9fd9bd513bc23ea508f875a8a6c13e783846949 Mon Sep 17 00:00:00 2001 From: Guzio Date: Sun, 26 Apr 2026 15:14:05 +0000 Subject: [PATCH 048/184] final text changes, READY TO MERGE!!! --- src/web/routes/guild.js | 2 +- src/web/routes/link.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index c4fd13cf..0cfec6df 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -213,7 +213,7 @@ as.router.get("/guild", defineEventHandler(async event => { as.router.get("/explain", defineEventHandler(async event => { const {type} = await getValidatedQuery(event, schema.explain.parse) const rule = linkRules.get(Number.parseInt(type)) - if (!rule) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge to type-" + type + " channels because OOYE doesn't even know what they are yet."}) + if (!rule) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge to type-" + type + " channels because OOYE doesn't even know what they are."}) else if (rule.unsupported) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge to " + rule.humanName + " (type-" + type + " channels) because: " + rule.unsupported}) else return pugSync.render(event, "explain.pug", {msg: "You can bridge to " + rule.humanName + " (type-" + type + " channels) just fine. Why are you even here?"}) })) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 1b8d7734..64afcc1d 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -199,7 +199,7 @@ as.router.post("/api/link", defineEventHandler(async event => { // Ensure link rules are upheld const rule = guildRoute.linkRules.get(channel.type) - if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge to " + (rule ? (rule.humanName+"(type-"+channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are yet.")}) + if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge to " + (rule ? (rule.humanName+" (type-"+channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are.")}) else if (foundSpace && rule.type === "TYPING") throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space when bridging to "+rule.humanName}) else if (!foundSpace && rule.type === "SPACE") throw createError({status: 400, message: "Bad Request", data: "Matrix room must be of type m.space when bridging to "+rule.humanName}) From 2aff1fbd0656347a00a5134a2cd2f7266d41bf03 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 12 May 2026 14:06:43 +1200 Subject: [PATCH 049/184] 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 aed70682..0d400f11 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 af44c84a..31caef08 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 bc73df72..68d519a2 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 050/184] 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 0b9a5256..dcc99430 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 051/184] 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 86f99a19..d1243a7d 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 052/184] 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 31caef08..fd008275 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 68d519a2..70b53d32 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 053/184] 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 fd008275..3609d0dc 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 70b53d32..14dbf972 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 07f8c249..8dd71cd5 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 054/184] 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 c11b696e..0d1c5296 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 de754da3..2de6381a 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 055/184] 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 00000000..79fad160 --- /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 056/184] 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 d3608068..d1beea15 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 c86cc137..90824ac3 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 057/184] 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 7d04cbb8..30dfe15c 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 c85a3620..59c3a0a9 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 058/184] 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 6e9ce7b6..643c84d5 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 fdb0807e..b1939319 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 b7f0867a..be1d99f0 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 059/184] 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 79fad160..02280533 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 060/184] 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 643c84d5..7229d3d0 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 061/184] 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 3609d0dc..cc370849 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 14dbf972..30e7aff0 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 00000000..d12b004e --- /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 837e14de..77ba3ed7 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 062/184] 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 b1939319..91bbe2bc 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 063/184] 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 0d1c5296..352ca41e 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 064/184] 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 30dfe15c..eb07b4d5 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 59c3a0a9..9dfd2a84 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 065/184] 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 cc370849..0a18a149 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 30e7aff0..650e4429 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 82e2f7e6..6d2772bb 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 066/184] 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 f24f4d96..9b7f2800 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 2548d420..04b01512 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 067/184] 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 00000000..9e63150a --- /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 068/184] 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 00000000..1bfd300d --- /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 77ba3ed7..e28060d8 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 069/184] 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 66ef19e7..43f400dd 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 4ca22b60..b6b04071 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 90824ac3..8101a03a 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 4d9b6f1a..87633148 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 6f6018ee..46390901 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 e4981fbd..c4532448 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 3135d311..7e49753f 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 352ca41e..3580d1b2 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 04b01512..43f9607e 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 070/184] 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 e3d58c48..66012b44 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 071/184] 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 7229d3d0..83fab1b8 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 137b63be..1ef83c36 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 f3092bca..eab9a63b 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 8dd71cd5..16623203 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 072/184] 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 9791ae3d..2614e6b4 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 073/184] 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 2614e6b4..7411a1e8 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 074/184] 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 eb07b4d5..ed438d0e 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 9dfd2a84..73fd43d4 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 075/184] 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 218f046e..4a5f7822 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 8101a03a..d52a340b 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) { From 51d57051f62c5bb18f1109455102d60e13b10b6b Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 12 Jun 2026 18:08:43 +1200 Subject: [PATCH 076/184] Fix not giving speedbump info when it's bypassed --- src/d2m/actions/speedbump.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/d2m/actions/speedbump.js b/src/d2m/actions/speedbump.js index 4a5f7822..42e3a35d 100644 --- a/src/d2m/actions/speedbump.js +++ b/src/d2m/actions/speedbump.js @@ -77,8 +77,9 @@ async function maybeDoSpeedbump(channelID, messageID, userID) { 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) { + if (row.speedbump_webhook_id === userID) return {affected: false, row} // shortcut 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 + if (!userHasProxy) return {affected: false, row} // user has not used PK before, no speedbump } const affected = await doSpeedbump(messageID) return {affected, row} // maybe affected, and there is a speedbump From ab051f301fbcda5d09baabc259fd85e1807ca09a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 13 Jun 2026 20:27:48 +1200 Subject: [PATCH 077/184] Fix polls in threads --- src/d2m/actions/send-message.js | 3 +-- src/discord/utils.js | 13 +++++++++++++ src/m2d/actions/vote.js | 21 ++++++++------------- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index 8550d43f..5b3b4f3b 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -60,8 +60,7 @@ async function sendMessage(message, channel, guild, row) { const detailedResultsMessage = await pollEnd.endPoll(message) if (detailedResultsMessage) { const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get() - const channelID = threadParent ? threadParent : message.channel_id - const threadID = threadParent ? message.channel_id : undefined + const {channelID, threadID} = dUtils.swapThreadID(message.channel_id, threadParent) sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID) } } diff --git a/src/discord/utils.js b/src/discord/utils.js index 0d400f11..6fb0b433 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -182,6 +182,18 @@ function filterTo(xs, fn) { return filtered } +/** + * The parameters correspond to the columns of the channel_room table. + * @param {string} rowChannelID thread ID, OR channel ID if there is no thread + * @param {string | null | undefined} rowThreadParent channel ID if there is a thread + */ +function swapThreadID(rowChannelID, rowThreadParent) { + return { + channelID: rowThreadParent ? rowThreadParent : rowChannelID, + threadID: rowThreadParent ? rowChannelID : undefined + } +} + const supportedPlaintextPreviewExtensions = new Set([ "4d", "abnf", @@ -582,4 +594,5 @@ module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact module.exports.getPublicUrlForCdn = getPublicUrlForCdn module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage module.exports.filterTo = filterTo +module.exports.swapThreadID = swapThreadID module.exports.supportedPlaintextPreviewExtensions = supportedPlaintextPreviewExtensions diff --git a/src/m2d/actions/vote.js b/src/m2d/actions/vote.js index 926b9572..84f8cc7c 100644 --- a/src/m2d/actions/vote.js +++ b/src/m2d/actions/vote.js @@ -1,18 +1,12 @@ // @ts-check const Ty = require("../../types") -const DiscordTypes = require("discord-api-types/v10") -const {Readable} = require("stream") const assert = require("assert").strict -const crypto = require("crypto") const passthrough = require("../../passthrough") -const {sync, discord, db, select} = passthrough +const {sync, db, select} = passthrough -const {reg} = require("../../matrix/read-registration") -/** @type {import("../../matrix/api")} */ -const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") +/** @type {import("../../discord/utils")} */ +const dUtils = sync.require("../../discord/utils") /** @type {import("../converters/poll-components")} */ const pollComponents = sync.require("../converters/poll-components") /** @type {import("./channel-webhook")} */ @@ -33,10 +27,11 @@ async function updateVote(event) { // If poll was started on Matrix, the Discord version is using components, so we can update that to the current status if (messageRow.source === 0) { - const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() - assert(channelID) - await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID)) + const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get() + assert(row) + const {channelID, threadID} = dUtils.swapThreadID(row.channel_id, row.thread_parent) + await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID), threadID) } } -module.exports.updateVote = updateVote \ No newline at end of file +module.exports.updateVote = updateVote From 34fc21fa15e18f7c2dbe4db2b4472d4d83bd5d9b Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 16 Jun 2026 20:11:39 +1200 Subject: [PATCH 078/184] Fix PluralKit replies being duplicated There was a race condition on create/update that was skipping the remedial code due to a bad assumption about the speedbump. --- src/d2m/event-dispatcher.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index d52a340b..03ca72b1 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -338,10 +338,8 @@ module.exports = { const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id, data.author.id) if (affected) return - if (!row) { - // Check that the sending-to room exists, and deal with Eventual Consistency(TM) - if (!await retrigger.waitForMessage(data.id)) return - } + // Check that the sending-to room exists, and deal with Eventual Consistency(TM) + if (!await retrigger.waitForMessage(data.id)) return /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ // @ts-ignore From 73f410bcad470e3362f0792d85d12fa1b08edab2 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 21 Jun 2026 20:28:44 +1200 Subject: [PATCH 079/184] Emoji name in messages may be uncapped --- src/d2m/converters/message-to-event.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 83fab1b8..aeda572e 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -557,7 +557,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter. // So we scan the message ahead of time for all its emojis and ensure they are in the DB. - const emojiMatches = [...content.matchAll(/<(a?):([^:>]{1,64}):([0-9]+)>/g)] + const emojiMatches = [...content.matchAll(/<(a?):([^:>]+):([0-9]+)>/g)] await Promise.all(emojiMatches.map(match => { const id = match[3] const name = match[2] From 47ccf986d899b20ee408bf9bfd4a753cb121c982 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 24 Jun 2026 23:47:32 +1200 Subject: [PATCH 080/184] m->d: Create filename from mime if not specified --- src/m2d/converters/event-to-message.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 0a18a149..747d0cc2 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -10,6 +10,7 @@ const domino = require("domino") const assert = require("assert").strict const entities = require("entities") const pb = require("prettier-bytes") +const mimeTypes = require("mime-types") const {tag} = require("@cloudrac3r/html-template-tag") const passthrough = require("../../passthrough") @@ -622,13 +623,20 @@ async function eventToMessage(event, guild, channel, di) { captionContent.addLine(`(Spoiler: ${fileSpoilerReason})`) } + // If filename is not specified, create one from mimetype + let filename = event.content.filename || event.content.body + if (!filename) { + const extension = mimeTypes.extension(event.content.info?.mimetype) + if (!extension) throw new Error("Filename and mimetype missing. Please report this bug to your client.") + filename = `file.${extension}` + } + // File link as alternative to uploading if (!("file" in event.content) && event.content.info?.size > getFileSizeForGuild(guild)) { // Upload (unencrypted) file as link, because it's too large for Discord // Do this by constructing a sample Matrix message with the link and then use the text processor to convert that + the original caption. const url = mxUtils.getPublicUrlForMxc(event.content.url) assert(url) - const filename = event.content.filename || event.content.body const emoji = attachmentEmojis.has(event.content.msgtype) ? attachmentEmojis.get(event.content.msgtype) + " " : "" if (fileIsSpoiler) { captionContent.addLine(`${emoji}Uploaded SPOILER file: <${url}> (${pb(event.content.info.size)})`, tag`${emoji}Uploaded SPOILER file: ${filename} (${pb(event.content.info.size)})`) // the space is necessary to work around a bug in Discord's URL previewer. the preview still gets blurred in the client. @@ -637,7 +645,6 @@ async function eventToMessage(event, guild, channel, di) { } } else { // Upload file as file - let filename = event.content.filename || event.content.body if (fileIsSpoiler) filename = "SPOILER_" + filename if ("file" in event.content) { // Encrypted From cad815896b29ac42c69e896fc9ea89c20ee6bf14 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 3 Jul 2026 22:35:10 +1200 Subject: [PATCH 081/184] Relax mxc regexp The spec doesn't say which characters are permitted in the opaque ID, but in real life we at least need a hyphen. For lack of a better reference, I copied gomuks. --- src/matrix/api.js | 2 +- src/matrix/utils.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/api.js b/src/matrix/api.js index 9b7f2800..f68380f4 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -474,7 +474,7 @@ async function ping() { async function getMedia(mxc, init = {}) { init = {...init} - const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) + const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/([a-zA-Z0-9_-]+)$/) assert(mediaParts) let route = "download" diff --git a/src/matrix/utils.js b/src/matrix/utils.js index eee635b1..fed71c97 100644 --- a/src/matrix/utils.js +++ b/src/matrix/utils.js @@ -250,7 +250,7 @@ function getPublicUrlForMxc(mxc) { */ function makeMxcPublic(mxc) { assert(hasher, "xxhash is not ready yet") - const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) + const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/([a-zA-Z0-9_-]+)$/) if (!mediaParts) return undefined const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}` From 401985a861c0d8f134740cfaf41a58e0867d4542 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 3 Jul 2026 22:36:21 +1200 Subject: [PATCH 082/184] Fix member leaves not being propagated immediately Previously they would only be removed next time the bridge was restarted. Now it should be done in real time. --- src/d2m/discord-client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index c2a05493..9f1d1ff4 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -24,7 +24,7 @@ class DiscordClient { const intents = [ "DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING", "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS", - "MESSAGE_CONTENT" + "MESSAGE_CONTENT", "GUILD_MEMBERS" ] if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES") this.discordToken = discordToken From 8ec7b3f67a869a3cd17252a2cb560ee7e9bf91b7 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 9 Jul 2026 17:51:01 +1200 Subject: [PATCH 083/184] Improve spacing around blockquotes --- package-lock.json | 8 +-- package.json | 2 +- src/m2d/converters/event-to-message.js | 11 +--- src/m2d/converters/event-to-message.test.js | 70 ++++++++++++++++++++- 4 files changed, 75 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed438d0e..3a3a893f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/stream-type": "^1.0.0", - "@cloudrac3r/turndown": "^7.1.4", + "@cloudrac3r/turndown": "^7.1.5", "@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks-icons": "^6.0.2", "ansi-colors": "^4.1.3", @@ -267,9 +267,9 @@ } }, "node_modules/@cloudrac3r/turndown": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.4.tgz", - "integrity": "sha512-bQAwcvcSqBTdEHPMt+IAZWIoDh+2eRuy9TgD0FUdxVurbvj3CUHTxLfzlmsO0UTi+GHpgYqDSsVdV7kYTNq5Qg==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.5.tgz", + "integrity": "sha512-pENoG62B4UM2ecsg57cuSRDa1OV/YFYTFGg1aoQsKUdHBBglhE9I7EwEMYpS96QoM/auyglfaxjgTeqhcSGCIA==", "license": "MIT", "dependencies": { "domino": "^2.1.6" diff --git a/package.json b/package.json index 73fd43d4..769c6775 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/stream-type": "^1.0.0", - "@cloudrac3r/turndown": "^7.1.4", + "@cloudrac3r/turndown": "^7.1.5", "@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks-icons": "^6.0.2", "ansi-colors": "^4.1.3", diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 747d0cc2..fbaf7691 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -98,15 +98,6 @@ turndownService.addRule("underline", { } }) -turndownService.addRule("blockquote", { - filter: "blockquote", - replacement: function (content) { - content = content.replace(/^\n+|\n+$/g, "") - content = content.replace(/^/gm, "> ") - return content - } -}) - turndownService.addRule("spoiler", { filter: function (node, options) { return node.tagName === "SPAN" && node.hasAttribute("data-mx-spoiler") @@ -862,7 +853,7 @@ async function eventToMessage(event, guild, channel, di) { input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1") // Element adds a bunch of
before
but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those. - input = input.replace(/(?:\n|
\s*)*<\/blockquote>/g, "
") + input = input.replace(/(?:\n|
\s*)*<\/blockquote>(?:\n|
\s*)*/g, "") // The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason. // But I should not count it if it's between block elements. diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 650e4429..158c2efc 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -855,6 +855,40 @@ test("event2message: html lines are bridged correctly", async t => { ) }) +test("event2message: breaks between consecutive blockquotes are preserved", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "this was inspired by someone telling me\n\n>betrothed is an insane vocab pull\n\n> i think thats part of why im so scared of cadence\n\n> if an insult comes my way, not only do i not know when, but i'll have to humiliate myself and look it up", + format: "org.matrix.custom.html", + formatted_body: "

this was inspired by someone telling me

\n
\n

betrothed is an insane vocab pull

\n
\n
\n

i think thats part of why im so scared of cadence

\n
\n
\n

if an insult comes my way, not only do i not know when, but i'll have to humiliate myself and look it up

\n
" + }, + origin_server_ts: 1783571303430, + event_id: "$cdltlkLTUp-ma2uYmLKy7o3RkDgpDbtdYfngST_igWE", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + allowed_mentions: { + parse: ["users", "roles"] + }, + username: "cadence [they]", + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", + content: "this was inspired by someone telling me" + + "\n\n> betrothed is an insane vocab pull" + + "\n\n> i think thats part of why im so scared of cadence" + + "\n\n> if an insult comes my way, not only do i not know when, but i'll have to humiliate myself and look it up" + }] + } + ) +}) + /*test("event2message: whitespace is retained", async t => { t.deepEqual( await eventToMessage({ @@ -1294,7 +1328,7 @@ test("event2message: characters are encoded properly in code blocks", async t => ) }) -test("event2message: quotes have an appropriate amount of whitespace", async t => { +test("event2message: quotes have an appropriate amount of whitespace (br following)", async t => { t.deepEqual( await eventToMessage({ content: { @@ -1328,6 +1362,40 @@ test("event2message: quotes have an appropriate amount of whitespace", async t = ) }) +test("event2message: quotes have an appropriate amount of whitespace (p following)", async t => { + t.deepEqual( + await eventToMessage({ + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: "
\n

short story

\n
\n

by whose standards? LOL

\n

I basically went all the way back to childhood (which analysts always like you to do)

\n
\n

laughed out loud

" + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + origin_server_ts: 1688301929913, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + sender: "@cadence:cadence.moe", + type: "m.room.message", + unsigned: { + age: 405299 + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "> short story\nby whose standards? LOL\n\n> I basically went all the way back to childhood (which analysts always like you to do)\nlaughed out loud", + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: lists have appropriate line breaks", async t => { t.deepEqual( await eventToMessage({ From 51e7d8479b6cee10172091342739fe9b0886ac83 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 12 Jul 2026 00:33:27 +1200 Subject: [PATCH 084/184] Add HTTP status to MatrixServerError --- src/m2d/actions/emoji-sheet.js | 2 +- src/m2d/actions/sticker.js | 2 +- src/matrix/mreq.js | 15 +++++++++------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/m2d/actions/emoji-sheet.js b/src/m2d/actions/emoji-sheet.js index ed5ab883..077240cc 100644 --- a/src/m2d/actions/emoji-sheet.js +++ b/src/m2d/actions/emoji-sheet.js @@ -23,7 +23,7 @@ async function getAndConvertEmoji(mxc) { const res = await api.getMedia(mxc, {signal: abortController.signal}) if (res.status !== 200) { const root = await res.json() - throw new mreq.MatrixServerError(root, {mxc}) + throw new mreq.MatrixServerError(root, res.status, {mxc}) } const readable = stream.Readable.fromWeb(res.body) return emojiSheetConverter.convertImageStream(readable, () => { diff --git a/src/m2d/actions/sticker.js b/src/m2d/actions/sticker.js index 8eeb5d28..f5a1e160 100644 --- a/src/m2d/actions/sticker.js +++ b/src/m2d/actions/sticker.js @@ -22,7 +22,7 @@ async function getAndResizeSticker(mxc) { const res = await api.getMedia(mxc) if (res.status !== 200) { const root = await res.json() - throw new mreq.MatrixServerError(root, {mxc}) + throw new mreq.MatrixServerError(root, res.status, {mxc}) } const streamIn = Readable.fromWeb(res.body) diff --git a/src/matrix/mreq.js b/src/matrix/mreq.js index bb59506f..bf238f26 100644 --- a/src/matrix/mreq.js +++ b/src/matrix/mreq.js @@ -9,9 +9,12 @@ const {reg} = require("./read-registration.js") const baseUrl = `${reg.ooye.server_origin}/_matrix` class MatrixServerError extends Error { - constructor(data, opts) { + /** @param {number} httpStatus} */ + constructor(data, httpStatus, opts) { super(data.error || data.errcode) this.data = data + /** @type {number} */ + this.httpStatus = httpStatus /** @type {string} */ this.errcode = data.errcode this.opts = opts @@ -44,11 +47,11 @@ async function _convertBody(body) { async function makeMatrixServerError(res, opts = {}) { delete opts.headers?.["Authorization"] if (res.headers.get("content-type") === "application/json") { - return new MatrixServerError(await res.json(), opts) + return new MatrixServerError(await res.json(), res.status, opts) } else if (res.headers.get("content-type")?.startsWith("text/")) { - return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, opts) + return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, res.status, opts) } else { - return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, opts) + return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, res.status, opts) } } @@ -78,12 +81,12 @@ async function mreq(method, url, bodyIn, extra = {}) { var root = JSON.parse(text) } catch (e) { delete opts.headers?.["Authorization"] - throw new MatrixServerError(text, {baseUrl, url, ...opts}) + throw new MatrixServerError(text, res.status, {baseUrl, url, ...opts}) } if (!res.ok || root.errcode) { delete opts.headers?.["Authorization"] - throw new MatrixServerError(root, {baseUrl, url, ...opts}) + throw new MatrixServerError(root, res.status, {baseUrl, url, ...opts}) } return root } From 8779b8b1b0db31e67a1dc57c007cdcb8b9ea186d Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 12 Jul 2026 02:05:57 +1200 Subject: [PATCH 085/184] Update SnowTransfer --- package-lock.json | 38 ++++++++++++++++++---------- package.json | 5 ++-- scripts/setup.js | 2 +- src/d2m/event-dispatcher.js | 2 +- src/discord/register-interactions.js | 2 +- src/m2d/actions/update-pins.js | 4 +-- 6 files changed, 32 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3a3a893f..04dbbe4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,8 @@ "ansi-colors": "^4.1.3", "better-sqlite3": "^12.2.0", "chunk-text": "^2.0.1", - "cloudstorm": "^0.17.1", + "cloudstorm": "^0.19.0", + "denque": "^2.1.0", "discord-api-types": "^0.38.38", "domino": "^2.1.6", "enquirer": "^2.4.1", @@ -37,7 +38,7 @@ "mime-types": "^2.1.35", "prettier-bytes": "^1.0.4", "sharp": "^0.34.5", - "snowtransfer": "^0.17.5", + "snowtransfer": "^0.19.0", "try-to-catch": "^4.0.5", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", @@ -1316,13 +1317,13 @@ } }, "node_modules/cloudstorm": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.17.1.tgz", - "integrity": "sha512-LYUwzHagRYRd93XocOqi+HCHdzPYI9cW7Yf7pYqinxgG+Qka1OiqBKWTCcLiEuiqXaOV30kr8c6aZ/c1QcDP4Q==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.19.0.tgz", + "integrity": "sha512-mjuP5o2nS/CgSljajh08KjN6jYMfNBBttSPHc1YUN74f7OkmhUhv8QE8JFDXomxCfdnLNJcqBQtjRLrs8nkfVQ==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.47", - "snowtransfer": "^0.17.5" + "discord-api-types": "^0.38.49", + "snowtransfer": "^0.19.0" }, "engines": { "node": ">=22.0.0" @@ -1457,6 +1458,15 @@ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -1473,9 +1483,9 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.47", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", - "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", + "version": "0.38.49", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", + "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" @@ -2579,12 +2589,12 @@ } }, "node_modules/snowtransfer": { - "version": "0.17.7", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.7.tgz", - "integrity": "sha512-scbOjYezo1Ycfk21atCEkeXIISTT7R7JTHCdiZ/7m7k4XbSb6o5q8Mu2fev5IqFpNyqIVjA0d/MZQ+eP/gtwfg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.19.0.tgz", + "integrity": "sha512-Vebj4FCdpUsEKAzqC1p50ol5o09CLnrffvBCpTv0FNFefvXPXwogxR8kr4zJApIRt7oWQVZZVkxAq4XnGlICjQ==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.47" + "discord-api-types": "^0.38.49" }, "engines": { "node": ">=22.0.0" diff --git a/package.json b/package.json index 769c6775..c0865173 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "ansi-colors": "^4.1.3", "better-sqlite3": "^12.2.0", "chunk-text": "^2.0.1", - "cloudstorm": "^0.17.1", + "cloudstorm": "^0.19.0", + "denque": "^2.1.0", "discord-api-types": "^0.38.38", "domino": "^2.1.6", "enquirer": "^2.4.1", @@ -46,7 +47,7 @@ "mime-types": "^2.1.35", "prettier-bytes": "^1.0.4", "sharp": "^0.34.5", - "snowtransfer": "^0.17.5", + "snowtransfer": "^0.19.0", "try-to-catch": "^4.0.5", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", diff --git a/scripts/setup.js b/scripts/setup.js index 69b62a29..6ce4c9fa 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -359,7 +359,7 @@ function defineEchoHandler() { console.log("✅ Emojis are ready...") // set profile data on discord... - await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + avatarBuffer.toString("base64")}) + await discord.snow.user.editSelf({avatar: "data:image/png;base64," + avatarBuffer.toString("base64")}) console.log("✅ Discord profile updated...") // set profile data on homeserver... diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 03ca72b1..31ef7ea6 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -60,7 +60,7 @@ module.exports = { matrixEventDispatcher.printError(gatewayMessage.t, "Discord", e, gatewayMessage) - const channelID = gatewayMessage.d["channel_id"] + const channelID = gatewayMessage.d?.["channel_id"] if (!channelID) return const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() if (!roomID) return diff --git a/src/discord/register-interactions.js b/src/discord/register-interactions.js index 66012b44..dc972875 100644 --- a/src/discord/register-interactions.js +++ b/src/discord/register-interactions.js @@ -16,7 +16,7 @@ const ping = sync.require("./interactions/ping.js") // User must have EVERY permission in default_member_permissions to be able to use the command function registerInteractions() { - discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ + discord.snow.interaction.editApplicationCommands(id, [{ name: "Matrix info", contexts: [DiscordTypes.InteractionContextType.Guild], type: DiscordTypes.ApplicationCommandType.Message, diff --git a/src/m2d/actions/update-pins.js b/src/m2d/actions/update-pins.js index 1ff2bb9e..1b3f1bd9 100644 --- a/src/m2d/actions/update-pins.js +++ b/src/m2d/actions/update-pins.js @@ -16,9 +16,9 @@ async function updatePins(pins, prev) { .select("reference_channel_id", "message_id").where({event_id}).and("ORDER BY part ASC").get() if (!row) continue if (added) { - discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix") + discord.snow.channel.createChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix") } else { - discord.snow.channel.removeChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message unpinned on Matrix") + discord.snow.channel.deleteChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message unpinned on Matrix") } } } From 414da4caf48c9c5e7a6346c56ae3353eb22d8230 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 12 Jul 2026 02:12:53 +1200 Subject: [PATCH 086/184] Queue events for later if homeserver is down --- docs/developer-orientation.md | 1 + src/d2m/actions/create-space.js | 10 +- src/d2m/discord-packets.js | 329 +++++++++++++++++--------------- src/m2d/event-dispatcher.js | 11 +- src/matrix/api.js | 3 +- src/matrix/homeserver-status.js | 94 +++++++++ 6 files changed, 290 insertions(+), 158 deletions(-) create mode 100644 src/matrix/homeserver-status.js diff --git a/docs/developer-orientation.md b/docs/developer-orientation.md index bff64c23..0f5e0c01 100644 --- a/docs/developer-orientation.md +++ b/docs/developer-orientation.md @@ -113,6 +113,7 @@ Total transitive production dependencies: 144 * (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer. * (1) chunk-text: It does what I want. * (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust. +* (0) denque: Much faster than using arrays as a queue. * (0) discord-api-types: Bitfields needed at runtime and types needed for development. * (0) domino: DOM implementation that's already pulled in by turndown. * (2) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively. diff --git a/src/d2m/actions/create-space.js b/src/d2m/actions/create-space.js index 7a751e27..403fdb2e 100644 --- a/src/d2m/actions/create-space.js +++ b/src/d2m/actions/create-space.js @@ -8,6 +8,8 @@ const {reg} = require("../../matrix/read-registration") const passthrough = require("../../passthrough") const {discord, sync, db, select} = passthrough +/** @type {import("../../matrix/mreq")} */ +const mreq = sync.require("../../matrix/mreq") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") /** @type {import("../../matrix/file")} */ @@ -237,8 +239,12 @@ async function syncSpaceExpressions(data, checkBeforeSync) { try { existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey) } catch (e) { - // State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake. - existing = fn([], guild) + if (e instanceof mreq.MatrixServerError && e.httpStatus < 400) { + // State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake. + existing = fn([], guild) + } else { + throw e + } } if (isDeepStrictEqual(existing, content)) return } diff --git a/src/d2m/discord-packets.js b/src/d2m/discord-packets.js index afea9ea2..9bab1dec 100644 --- a/src/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -1,173 +1,194 @@ // @ts-check -// Discord library internals type beat - -const DiscordTypes = require("discord-api-types/v10") +const assert = require("assert") const passthrough = require("../passthrough") -const {sync, db} = passthrough +const {sync} = passthrough -const utils = { - /** - * @param {import("./discord-client")} client - * @param {import("cloudstorm").IGatewayMessage} message - * @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate - */ - async onPacket(client, message, listen) { - // requiring this later so that the client is already constructed by the time event-dispatcher is loaded - /** @type {typeof import("./event-dispatcher")} */ - const eventDispatcher = sync.require("./event-dispatcher") - /** @type {import("../discord/register-interactions")} */ - const interactions = sync.require("../discord/register-interactions") +/** @type {import("../matrix/homeserver-status")} */ +const homeserverStatus = sync.require("../matrix/homeserver-status") - // Client internals, keep track of the state we need - if (message.t === "READY") { - if (client.ready) return - client.ready = true - client.user = message.d.user - client.application = message.d.application - console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) - interactions.registerInteractions() +/** + * @param {import("./discord-client")} client + * @param {import("cloudstorm").IGatewayMessage} message + * @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate + */ +async function onPacket(client, message, listen) { + // requiring this later so that the client is already constructed by the time event-dispatcher is loaded + /** @type {typeof import("./event-dispatcher")} */ + const eventDispatcher = sync.require("./event-dispatcher") + /** @type {import("../discord/register-interactions")} */ + const interactions = sync.require("../discord/register-interactions") - } else if (message.t === "GUILD_CREATE") { - message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web - client.guilds.set(message.d.id, message.d) - const arr = [] - client.guildChannelMap.set(message.d.id, arr) - for (const channel of message.d.channels || []) { - // @ts-ignore - channel.guild_id = message.d.id - arr.push(channel.id) - client.channels.set(channel.id, channel) - } - for (const thread of message.d.threads || []) { - // @ts-ignore - thread.guild_id = message.d.id - arr.push(thread.id) - client.channels.set(thread.id, thread) + // Client internals, keep track of the state we need + if (message.t === "READY") { + if (client.ready) return + client.ready = true + client.user = message.d.user + client.application = message.d.application + console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) + interactions.registerInteractions() + + } else if (message.t === "GUILD_CREATE") { + message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web + client.guilds.set(message.d.id, message.d) + const arr = [] + client.guildChannelMap.set(message.d.id, arr) + for (const channel of message.d.channels || []) { + // @ts-ignore + channel.guild_id = message.d.id + arr.push(channel.id) + client.channels.set(channel.id, channel) + } + for (const thread of message.d.threads || []) { + // @ts-ignore + thread.guild_id = message.d.id + arr.push(thread.id) + client.channels.set(thread.id, thread) + } + + if (listen === "full") { + try { + await eventDispatcher.checkMissedExpressions(message.d) + await eventDispatcher.checkMissedMessages(client, message.d) + await eventDispatcher.checkMissedPins(client, message.d) + await eventDispatcher.checkMissedLeaves(client, message.d) + } catch (e) { + console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") + console.error(e) } + } - if (listen === "full") { - try { - await eventDispatcher.checkMissedExpressions(message.d) - await eventDispatcher.checkMissedMessages(client, message.d) - await eventDispatcher.checkMissedPins(client, message.d) - await eventDispatcher.checkMissedLeaves(client, message.d) - } catch (e) { - console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") - console.error(e) - } - } - - } else if (message.t === "GUILD_UPDATE") { - const guild = client.guilds.get(message.d.id) - if (guild) { - for (const prop of Object.keys(message.d)) { - if (!["channels", "threads"].includes(prop)) { - guild[prop] = message.d[prop] - } - } - } - - } else if (message.t === "GUILD_EMOJIS_UPDATE") { - const guild = client.guilds.get(message.d.guild_id) - if (guild) { - guild.emojis = message.d.emojis - } - - } else if (message.t === "GUILD_STICKERS_UPDATE") { - const guild = client.guilds.get(message.d.guild_id) - if (guild) { - guild.stickers = message.d.stickers - } - - } else if (message.t === "GUILD_ROLE_CREATE" || message.t === "GUILD_ROLE_UPDATE" || message.t === "GUILD_ROLE_DELETE") { - const guild = client.guilds.get(message.d.guild_id) - /** Delete this in case of UPDATE or DELETE */ - const targetID = "role_id" in message.d ? message.d.role_id : message.d.role.id - /** Add this in case of CREATE or UPDATE */ - const newRoles = [] - if ("role" in message.d) newRoles.push(message.d.role) - if (guild) { - const targetIndex = guild.roles.findIndex(r => r.id === targetID) - if (targetIndex !== -1) { - // Role already exists. Delete it and maybe replace it. - guild.roles.splice(targetIndex, 1, ...newRoles) - } else { - // Role doesn't already exist. - guild.roles.push(...newRoles) - } - } - - } else if (message.t === "GUILD_MEMBER_UPDATE") { - const guild = client.guilds.get(message.d.guild_id) - const member = guild?.members.find(m => m.user.id === message.d.user.id) - if (member) { // only update existing members (i.e. the bot's own member) - don't want to inflate the cache with new irrelevant ones - Object.assign(member, message.d) - } - - } else if (message.t === "THREAD_CREATE") { - client.channels.set(message.d.id, message.d) - if (message.d["guild_id"]) { - const channels = client.guildChannelMap.get(message.d["guild_id"]) - if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) - } - - } else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") { - client.channels.set(message.d.id, message.d) - - - } else if (message.t === "CHANNEL_PINS_UPDATE") { - const channel = client.channels.get(message.d.channel_id) - if (channel) { - channel["last_pin_timestamp"] = message.d.last_pin_timestamp - } - - - } else if (message.t === "GUILD_DELETE") { - client.guilds.delete(message.d.id) - const channels = client.guildChannelMap.get(message.d.id) - if (channels) { - for (const id of channels) client.channels.delete(id) - } - client.guildChannelMap.delete(message.d.id) - - - } else if (message.t === "CHANNEL_CREATE") { - client.channels.set(message.d.id, message.d) - if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have - const channels = client.guildChannelMap.get(message.d["guild_id"]) - if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) - } - - } else if (message.t === "CHANNEL_DELETE") { - client.channels.delete(message.d.id) - if (message.d["guild_id"]) { - const channels = client.guildChannelMap.get(message.d["guild_id"]) - if (channels) { - const previous = channels.indexOf(message.d.id) - if (previous !== -1) channels.splice(previous, 1) + } else if (message.t === "GUILD_UPDATE") { + const guild = client.guilds.get(message.d.id) + if (guild) { + for (const prop of Object.keys(message.d)) { + if (!["channels", "threads"].includes(prop)) { + guild[prop] = message.d[prop] } } } - // Event dispatcher for OOYE bridge operations - if (listen === "full" && message.t) { - try { - if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") { - await eventDispatcher.onSomeReactionsRemoved(client, message.d) + } else if (message.t === "GUILD_EMOJIS_UPDATE") { + const guild = client.guilds.get(message.d.guild_id) + if (guild) { + guild.emojis = message.d.emojis + } - } else if (message.t === "INTERACTION_CREATE") { - await interactions.dispatchInteraction(message.d) + } else if (message.t === "GUILD_STICKERS_UPDATE") { + const guild = client.guilds.get(message.d.guild_id) + if (guild) { + guild.stickers = message.d.stickers + } - } else if (message.t in eventDispatcher) { - await eventDispatcher[message.t](client, message.d) - } - } catch (e) { - // Let OOYE try to handle errors too - await eventDispatcher.onError(client, e, message) + } else if (message.t === "GUILD_ROLE_CREATE" || message.t === "GUILD_ROLE_UPDATE" || message.t === "GUILD_ROLE_DELETE") { + const guild = client.guilds.get(message.d.guild_id) + /** Delete this in case of UPDATE or DELETE */ + const targetID = "role_id" in message.d ? message.d.role_id : message.d.role.id + /** Add this in case of CREATE or UPDATE */ + const newRoles = [] + if ("role" in message.d) newRoles.push(message.d.role) + if (guild) { + const targetIndex = guild.roles.findIndex(r => r.id === targetID) + if (targetIndex !== -1) { + // Role already exists. Delete it and maybe replace it. + guild.roles.splice(targetIndex, 1, ...newRoles) + } else { + // Role doesn't already exist. + guild.roles.push(...newRoles) } } + + } else if (message.t === "GUILD_MEMBER_UPDATE") { + const guild = client.guilds.get(message.d.guild_id) + const member = guild?.members.find(m => m.user.id === message.d.user.id) + if (member) { // only update existing members (i.e. the bot's own member) - don't want to inflate the cache with new irrelevant ones + Object.assign(member, message.d) + } + + } else if (message.t === "THREAD_CREATE") { + client.channels.set(message.d.id, message.d) + if (message.d["guild_id"]) { + const channels = client.guildChannelMap.get(message.d["guild_id"]) + if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) + } + + } else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") { + client.channels.set(message.d.id, message.d) + + + } else if (message.t === "CHANNEL_PINS_UPDATE") { + const channel = client.channels.get(message.d.channel_id) + if (channel) { + channel["last_pin_timestamp"] = message.d.last_pin_timestamp + } + + + } else if (message.t === "GUILD_DELETE") { + client.guilds.delete(message.d.id) + const channels = client.guildChannelMap.get(message.d.id) + if (channels) { + for (const id of channels) client.channels.delete(id) + } + client.guildChannelMap.delete(message.d.id) + + + } else if (message.t === "CHANNEL_CREATE") { + client.channels.set(message.d.id, message.d) + if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have + const channels = client.guildChannelMap.get(message.d["guild_id"]) + if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) + } + + } else if (message.t === "CHANNEL_DELETE") { + client.channels.delete(message.d.id) + if (message.d["guild_id"]) { + const channels = client.guildChannelMap.get(message.d["guild_id"]) + if (channels) { + const previous = channels.indexOf(message.d.id) + if (previous !== -1) channels.splice(previous, 1) + } + } + } + + // Event dispatcher for OOYE bridge operations + if (listen === "full" && message.t) { + const alwaysRealTimeEvents = ["PRESENCE_UPDATE"] + if (alwaysRealTimeEvents.includes(message.t) || homeserverStatus.homeserverStatus.isRealTime()) { + dispatchPacketToBridge(client, message) + } else { + homeserverStatus.homeserverStatus.queuePacket(message) + } } } -module.exports = utils +/** + * @param {import("./discord-client")} client + * @param {import("cloudstorm").IGatewayMessage} message + */ +async function dispatchPacketToBridge(client, message) { + // requiring this later so that the client is already constructed by the time event-dispatcher is loaded + /** @type {typeof import("./event-dispatcher")} */ + const eventDispatcher = sync.require("./event-dispatcher") + /** @type {import("../discord/register-interactions")} */ + const interactions = sync.require("../discord/register-interactions") + + assert(message.t) // checked above + + try { + if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") { + await eventDispatcher.onSomeReactionsRemoved(client, message.d) + + } else if (message.t === "INTERACTION_CREATE") { + await interactions.dispatchInteraction(message.d) + + } else if (message.t in eventDispatcher) { + await eventDispatcher[message.t](client, message.d) + } + } catch (e) { + // Let OOYE try to handle errors too + await eventDispatcher.onError(client, e, message) + } +} + +module.exports.onPacket = onPacket +module.exports.dispatchPacketToBridge = dispatchPacketToBridge diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 3580d1b2..06157437 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -24,6 +24,8 @@ const vote = sync.require("./actions/vote") const matrixCommandHandler = sync.require("../matrix/matrix-command-handler") /** @type {import("../matrix/utils")} */ const utils = sync.require("../matrix/utils") +/** @type {import("../matrix/mreq")}) */ +const mreq = sync.require("../matrix/mreq") /** @type {import("../matrix/api")}) */ const api = sync.require("../matrix/api") /** @type {import("../d2m/actions/create-room")} */ @@ -32,6 +34,8 @@ const createRoom = sync.require("../d2m/actions/create-room") const roomUpgrade = require("../matrix/room-upgrade") /** @type {import("../d2m/actions/retrigger")} */ const retrigger = sync.require("../d2m/actions/retrigger") +/** @type {import("../matrix/homeserver-status")} */ +const homeserverStatus = sync.require("../matrix/homeserver-status") const {reg} = require("../matrix/read-registration") let lastReportedEvent = 0 @@ -166,7 +170,12 @@ async function sendError(roomID, source, type, e, payload) { key: "🔁" } }) - } catch (e) {} + } catch (e) { + if (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus)) { + // Matrix homeserver is down (reverse proxy indicated failure; synapse doesn't generate 502/503 when posting an event to a room) + homeserverStatus.homeserverStatus.setErrorWithPacket(payload) + } + } } function guard(type, fn) { diff --git a/src/matrix/api.js b/src/matrix/api.js index f68380f4..47fc565d 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -452,7 +452,8 @@ async function ping() { headers: { Authorization: `Bearer ${reg.as_token}` }, - body: "{}" + body: "{}", + signal: AbortSignal.timeout(15e3) }) const root = await res.json() return { diff --git a/src/matrix/homeserver-status.js b/src/matrix/homeserver-status.js new file mode 100644 index 00000000..0c5966f6 --- /dev/null +++ b/src/matrix/homeserver-status.js @@ -0,0 +1,94 @@ +// @ts-check + +const assert = require("assert") +const Denque = require("denque") +const StateMachine = require("snowtransfer").StateMachine + +const passthrough = require("../passthrough") +const {sync} = passthrough +/** @type {import("../d2m/discord-packets")} */ +const discordPackets = sync.require("../d2m/discord-packets") +/** @type {import("../matrix/api")} */ +const api = sync.require("../matrix/api") + +const DEBUG_HOMESERVER_STATUS = true + +function debugHomeserverStatus(message) { + if (DEBUG_HOMESERVER_STATUS) { + console.log(message) + } +} + +const homeserverStatus = new class HomeserverStatus { + constructor() { + /** @private */ + this.queue = new Denque() + + /** @private */ + this.pingInterval = undefined + + /** @private */ + this.sm = new StateMachine("online") + .defineState("online") + + .defineState("offline", { + onEnter: [() => { + this.pingInterval = setInterval(async () => { + const pingResult = await api.ping().catch(e => ({ok: false, status: "net", root: e.message})) + if (pingResult.ok) { + this.sm.doTransition("ping ok") + } + }, 15e3) + }], + onLeave: [() => { + clearInterval(this.pingInterval) + }], + transitions: new Map() + }) + + .defineState("recovering", { + onEnter: [async () => { // Drain queue. + while (!this.queue.isEmpty()) { + const packet = this.queue.peekFront() // same position as .shift() + debugHomeserverStatus(`homeserver status: ${new Date().toISOString()} dq packet ${packet.t} ${packet.d?.content}`) + await discordPackets.dispatchPacketToBridge(passthrough.discord, packet) + if (this.sm.currentStateName !== "recovering") return // got kicked out due to another error + this.queue.shift() + } + this.sm.doTransition("recovered") + }], + onLeave: [], + transitions: new Map() + }) + + .defineUniversalTransition("error", "offline") + .defineTransition("offline", "ping ok", "recovering") + .defineTransition("recovering", "recovered", "online") + + this.sm.on("enter", st => debugHomeserverStatus(`homeserver status: ${st}`)) + + this.sm.freeze() + } + + isRealTime() { + return this.sm.currentStateName === "online" + } + + /** + * When offline or recovering, call this for incoming packets to queue them to be sent in order later. + */ + queuePacket(packet) { + assert(["offline", "recovering"].includes(this.sm.currentStateName)) + this.queue.push(packet) + } + + setErrorWithPacket(packet) { + const wasRecovering = this.sm.currentStateName === "recovering" + this.sm.doTransition("error") + if (!wasRecovering) { // if was recovering then packet is already in the right place in queue + this.queuePacket(packet) + } + } +} + +module.exports.homeserverStatus = homeserverStatus From f92d280494d75a2157223240dd6112ccb2afc26e Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 12 Jul 2026 02:47:07 +1200 Subject: [PATCH 087/184] Wait for homeserver when bridge is first started --- src/d2m/discord-client.js | 20 +++++++++------ src/d2m/discord-packets.js | 28 +++++++++++++++++++++ src/matrix/homeserver-status.js | 44 +++++++++++++++++++++++++++++++-- start.js | 3 ++- 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index 9f1d1ff4..b9b45e21 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -14,6 +14,8 @@ const {sync} = passthrough /** @type {import("./discord-packets")} */ const discordPackets = sync.require("./discord-packets") +const CONNECTION_DEBUG = false + class DiscordClient { /** * @param {string} discordToken @@ -59,15 +61,17 @@ class DiscordClient { }) } - const addEventLogger = (eventName, logName) => { - this.cloud.on(eventName, (...args) => { - const d = new Date().toISOString().slice(0, 19) - console.error(`[${d} Client ${logName}]`, ...args) - }) + if (CONNECTION_DEBUG) { + const addEventLogger = (eventName, logName) => { + this.cloud.on(eventName, (...args) => { + const d = new Date().toISOString().slice(0, 19) + console.error(`[${d} Client ${logName}]`, ...args) + }) + } + addEventLogger("error", "Error") + addEventLogger("disconnected", "Disconnected") + addEventLogger("ready", "Ready") } - addEventLogger("error", "Error") - addEventLogger("disconnected", "Disconnected") - addEventLogger("ready", "Ready") } } diff --git a/src/d2m/discord-packets.js b/src/d2m/discord-packets.js index 9bab1dec..b17ad4e8 100644 --- a/src/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -1,12 +1,15 @@ // @ts-check const assert = require("assert") +const {scheduler} = require("timers/promises") const passthrough = require("../passthrough") const {sync} = passthrough /** @type {import("../matrix/homeserver-status")} */ const homeserverStatus = sync.require("../matrix/homeserver-status") +let checkedHomeserver = false + /** * @param {import("./discord-client")} client * @param {import("cloudstorm").IGatewayMessage} message @@ -48,6 +51,31 @@ async function onPacket(client, message, listen) { if (listen === "full") { try { + /* + Info about guilds is populated one guild at a time. + For m->d bridging to work, the guild needs to be populated, so we need to have GUILD_CREATE for the guild. + If we ping the homeserver, it will send us any pending events, so we need to wait for all GUILD_CREATES before we ping. + We must attempt a ping because we don't want to try sending missed d->m messages to an offline homeserver. + This delay can be removed if ONE of the following is done: + 1. m->d can queue incoming events until their guild exists in memory + 2. d->m missed messages can have their errors handled and added to queue, rather than pinging first + */ + let isMainCharacter = false + if (!checkedHomeserver) { + checkedHomeserver = true + isMainCharacter = true + console.log("Warming up guilds~") + } + await scheduler.wait(5000) + if (isMainCharacter) { + checkedHomeserver = true + process.stdout.write("Connecting to homeserver... ") + await homeserverStatus.homeserverStatus.waitForOnline(true) + console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.") + } else { + await homeserverStatus.homeserverStatus.waitForOnline(false) + } + await eventDispatcher.checkMissedExpressions(message.d) await eventDispatcher.checkMissedMessages(client, message.d) await eventDispatcher.checkMissedPins(client, message.d) diff --git a/src/matrix/homeserver-status.js b/src/matrix/homeserver-status.js index 0c5966f6..3ceac84e 100644 --- a/src/matrix/homeserver-status.js +++ b/src/matrix/homeserver-status.js @@ -11,7 +11,7 @@ const discordPackets = sync.require("../d2m/discord-packets") /** @type {import("../matrix/api")} */ const api = sync.require("../matrix/api") -const DEBUG_HOMESERVER_STATUS = true +const DEBUG_HOMESERVER_STATUS = false function debugHomeserverStatus(message) { if (DEBUG_HOMESERVER_STATUS) { @@ -31,6 +31,19 @@ const homeserverStatus = new class HomeserverStatus { this.sm = new StateMachine("online") .defineState("online") + .defineState("checking", { + onEnter: [async () => { + const pingResult = await api.ping().catch(e => ({ok: false})) + if (pingResult.ok) { + this.sm.doTransition("check ok") + } else { + this.sm.doTransition("check fail") + } + }], + onLeave: [], + transitions: new Map() + }) + .defineState("offline", { onEnter: [() => { this.pingInterval = setInterval(async () => { @@ -64,9 +77,12 @@ const homeserverStatus = new class HomeserverStatus { .defineUniversalTransition("error", "offline") .defineTransition("offline", "ping ok", "recovering") .defineTransition("recovering", "recovered", "online") + .defineTransition("online", "check", "checking") + .defineTransition("checking", "check ok", "recovering") + .defineTransition("checking", "check fail", "offline") this.sm.on("enter", st => debugHomeserverStatus(`homeserver status: ${st}`)) - + this.sm.setMaxListeners(101) this.sm.freeze() } @@ -74,6 +90,30 @@ const homeserverStatus = new class HomeserverStatus { return this.sm.currentStateName === "online" } + /** @param {boolean} forceCheck */ + waitForOnline(forceCheck) { + const onlinePromise = new Promise(resolve => { + // Already online? Start check or just done + if (this.sm.currentStateName === "online") { + if (forceCheck) { + this.sm.doTransition("check") + } else { + return resolve(null) + } + } + + // Checking or not online. Wait for online. + const onlineListener = stateName => { + if (stateName === "online") { + this.sm.removeListener("enter", onlineListener) + resolve(null) + } + } + this.sm.on("enter", onlineListener) + }) + return onlinePromise + } + /** * When offline or recovering, call this for incoming packets to queue them to be sent in order later. */ diff --git a/start.js b/start.js index 39e8ea09..51942c67 100755 --- a/start.js +++ b/start.js @@ -31,8 +31,9 @@ sync.require("./src/m2d/event-dispatcher") ;(async () => { await migrate.migrate(db) + process.stdout.write("Connecting to Discord... ") await discord.cloud.connect() - console.log("Discord gateway started") + console.log("ok.") sync.require("./src/web/server") await power.applyPower() From a8712b635de66b2c28c89ea39f5f2aeea6398c80 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 12 Jul 2026 20:27:42 +1200 Subject: [PATCH 088/184] Only detect non-JSON responses as server down --- 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 06157437..129fca5c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -171,8 +171,8 @@ async function sendError(roomID, source, type, e, payload) { } }) } catch (e) { - if (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus)) { - // Matrix homeserver is down (reverse proxy indicated failure; synapse doesn't generate 502/503 when posting an event to a room) + if (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus) && e.errcode === "CX_SERVER_ERROR") { + // Matrix homeserver is down (reverse proxy indicated failure) homeserverStatus.homeserverStatus.setErrorWithPacket(payload) } } From d4cd5805551e354481ad08459b30b736dd06715f Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 15 Jul 2026 16:37:23 +1200 Subject: [PATCH 089/184] Detect timeouts as server down --- src/d2m/discord-packets.js | 5 +++++ src/m2d/event-dispatcher.js | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/d2m/discord-packets.js b/src/d2m/discord-packets.js index b17ad4e8..988f981c 100644 --- a/src/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -181,9 +181,14 @@ async function onPacket(client, message, listen) { // Event dispatcher for OOYE bridge operations if (listen === "full" && message.t) { const alwaysRealTimeEvents = ["PRESENCE_UPDATE"] + const optionalEvents = ["TYPING_START"] if (alwaysRealTimeEvents.includes(message.t) || homeserverStatus.homeserverStatus.isRealTime()) { + // real time dispatchPacketToBridge(client, message) + } else if (optionalEvents.includes(message.t)) { + // when not real time, skip optional events } else { + // queue required events homeserverStatus.homeserverStatus.queuePacket(message) } } diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 129fca5c..0ca36a6a 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -171,7 +171,10 @@ async function sendError(roomID, source, type, e, payload) { } }) } catch (e) { - if (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus) && e.errcode === "CX_SERVER_ERROR") { + if ( + e.toString().includes("fetch failed") + || (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus) && e.errcode === "CX_SERVER_ERROR") + ) { // Matrix homeserver is down (reverse proxy indicated failure) homeserverStatus.homeserverStatus.setErrorWithPacket(payload) } From 6cd36464fa686763fae442d7c9d761f45fcdb6d6 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 16 Jul 2026 15:01:50 +1200 Subject: [PATCH 090/184] Pass content-disposition header through file proxy --- src/web/routes/download-matrix.js | 5 +++++ src/web/routes/download-matrix.test.js | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/web/routes/download-matrix.js b/src/web/routes/download-matrix.js index 6d2772bb..26645207 100644 --- a/src/web/routes/download-matrix.js +++ b/src/web/routes/download-matrix.js @@ -98,6 +98,11 @@ as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(asyn setResponseHeader(event, "Content-Type", contentType) setResponseHeader(event, "Transfer-Encoding", "chunked") + const contentDisposition = res.headers.get("content-disposition") + if (contentDisposition) { + setResponseHeader(event, "Content-Disposition", contentDisposition) + } + if (res.ok && query.success) { return MEDIA_THUMBNAIL_PRESETS[query.data.preset](res.body) } else { diff --git a/src/web/routes/download-matrix.test.js b/src/web/routes/download-matrix.test.js index 610a62dc..2e76002b 100644 --- a/src/web/routes/download-matrix.test.js +++ b/src/web/routes/download-matrix.test.js @@ -30,12 +30,13 @@ test("web download matrix: works if a known attachment", async t => { api: { // @ts-ignore async getMedia(mxc, init) { - return new Response("", {status: 200, headers: {"content-type": "image/png"}}) + return new Response("", {status: 200, headers: {"content-type": "image/png", "content-disposition": `attachment; filename="wa.png"`}}) } } }) t.equal(event.node.res.statusCode, 200) t.equal(event.node.res.getHeader("content-type"), "image/png") + t.equal(event.node.res.getHeader("content-disposition"), `attachment; filename="wa.png"`) }) /** From 983ea0cb52162493e3b11b571e07bb7bd27fff77 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 18 Jul 2026 12:06:03 +1200 Subject: [PATCH 091/184] m->d typing --- src/m2d/actions/typing.js | 41 +++++++++++++++++++++++++++++++++ src/m2d/event-dispatcher.js | 16 +++++++++++++ src/matrix/read-registration.js | 5 ++++ src/types.d.ts | 10 ++++++++ 4 files changed, 72 insertions(+) create mode 100644 src/m2d/actions/typing.js diff --git a/src/m2d/actions/typing.js b/src/m2d/actions/typing.js new file mode 100644 index 00000000..88ae87b1 --- /dev/null +++ b/src/m2d/actions/typing.js @@ -0,0 +1,41 @@ +// @ts-check + +const {discord} = require("../../passthrough") + +/** @type {Set} channel ids that are currently typing */ +const isTyping = new Set() +/** @type {Map} channel id -> interval */ +const intervals = new Map() + +/** @param {string} channelID */ +function startTyping(channelID) { + if (!isTyping.has(channelID)) { + // Start a new typing session + isTyping.add(channelID) + intervals.set(channelID, setInterval(refreshTyping, 8e3, channelID)) + discord.snow.channel.startChannelTyping(channelID).catch(() => {}) + } +} + +/** @param {string} channelID */ +function refreshTyping(channelID) { + if (isTyping.has(channelID)) { + // Continue typing session + discord.snow.channel.startChannelTyping(channelID).catch(() => {}) + } else { + // End typing session + clearInterval(intervals.get(channelID)) + intervals.delete(channelID) + } +} + +/** @param {string} channelID */ +function stopTyping(channelID) { + if (isTyping.has(channelID)) { + // Schedule typing session to end + isTyping.delete(channelID) + } +} + +module.exports.startTyping = startTyping +module.exports.stopTyping = stopTyping diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 0ca36a6a..7dfe102f 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -36,6 +36,8 @@ const roomUpgrade = require("../matrix/room-upgrade") const retrigger = sync.require("../d2m/actions/retrigger") /** @type {import("../matrix/homeserver-status")} */ const homeserverStatus = sync.require("../matrix/homeserver-status") +/** @type {import("./actions/typing")} */ +const typing = sync.require("./actions/typing") const {reg} = require("../matrix/read-registration") let lastReportedEvent = 0 @@ -527,6 +529,20 @@ async event => { await createRoom.unbridgeChannel(channel, channel["guild_id"], "Encrypted rooms are not supported. This room was removed from the bridge.") })) +sync.addTemporaryListener(as, "ephemeral_type:m.typing", guard("m.typing", +/** + * @param {Ty.Event.Outer_M_Typing} event + */ +async event => { + const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() + if (!channelID) return + if (event.content.user_ids.length) { + typing.startTyping(channelID) + } else { + typing.stopTyping(channelID) + } +})) + module.exports.stringifyErrorStack = stringifyErrorStack module.exports.cleanErrorStack = cleanErrorStack module.exports.sendError = sendError diff --git a/src/matrix/read-registration.js b/src/matrix/read-registration.js index d1243a7d..e6ca53f4 100644 --- a/src/matrix/read-registration.js +++ b/src/matrix/read-registration.js @@ -17,6 +17,10 @@ function checkRegistration(reg) { assert(reg.ooye?.server_origin.match(/^https?:\/\//), "server origin must start with http or https") assert.notEqual(reg.ooye?.server_origin.slice(-1), "/", "server origin must not end in slash") assert.match(reg.url, /^https?:/, "url must start with http:// or https://") + if (!reg.receive_ephemeral) { + reg.receive_ephemeral = true + writeRegistration(reg) + } } /* c8 ignore next 4 */ @@ -50,6 +54,7 @@ function getTemplateRegistration(serverName) { ], sender_localpart: `${namespace_prefix}bot`, rate_limited: false, + receive_ephemeral: true, socket: 6693, ooye: { namespace_prefix, diff --git a/src/types.d.ts b/src/types.d.ts index be037ca7..856ab1c1 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -18,6 +18,7 @@ export type AppServiceRegistrationConfig = { } protocols: [string] rate_limited: boolean + receive_ephemeral: boolean socket?: string | number, ooye: { namespace_prefix: string @@ -59,6 +60,7 @@ export type InitialAppServiceRegistrationConfig = { } protocols: [string] rate_limited: boolean + receive_ephemeral: boolean socket?: string | number, ooye: { namespace_prefix: string @@ -396,6 +398,14 @@ export namespace Event { rotation_period_ms?: number rotation_period_msgs?: number } + + export type Outer_M_Typing = { + content: { + user_ids: string[] + } + room_id: string + type: "m.typing" + } } export namespace R { From c982ca0bb636182a88fb0cdd2fb03b6736a2ab6d Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 20 Jul 2026 18:02:45 +1200 Subject: [PATCH 092/184] Fix m->d typing interval getting stuck --- src/m2d/actions/typing.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/m2d/actions/typing.js b/src/m2d/actions/typing.js index 88ae87b1..0c6732fa 100644 --- a/src/m2d/actions/typing.js +++ b/src/m2d/actions/typing.js @@ -9,9 +9,9 @@ const intervals = new Map() /** @param {string} channelID */ function startTyping(channelID) { - if (!isTyping.has(channelID)) { - // Start a new typing session - isTyping.add(channelID) + isTyping.add(channelID) + // Start a new typing session if needed + if (!intervals.has(channelID)) { intervals.set(channelID, setInterval(refreshTyping, 8e3, channelID)) discord.snow.channel.startChannelTyping(channelID).catch(() => {}) } From ed0bddaae0a97cd9c834cdb3a3fbe8e4826d5bc9 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 20 Jul 2026 18:32:00 +1200 Subject: [PATCH 093/184] Expressions http status typo 400 -> 500 --- src/d2m/actions/create-space.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/actions/create-space.js b/src/d2m/actions/create-space.js index 403fdb2e..9f5d7e30 100644 --- a/src/d2m/actions/create-space.js +++ b/src/d2m/actions/create-space.js @@ -239,7 +239,7 @@ async function syncSpaceExpressions(data, checkBeforeSync) { try { existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey) } catch (e) { - if (e instanceof mreq.MatrixServerError && e.httpStatus < 400) { + if (e instanceof mreq.MatrixServerError && e.httpStatus < 500) { // State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake. existing = fn([], guild) } else { From fa716cd239f1ee901434e8aacde4ec354be332dd Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 20 Jul 2026 21:45:44 +1200 Subject: [PATCH 094/184] Prevent typing events double-bridging --- src/d2m/event-dispatcher.js | 1 + src/m2d/actions/typing.js | 6 ++---- src/m2d/event-dispatcher.js | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 31ef7ea6..c0c33d98 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -412,6 +412,7 @@ module.exports = { * @param {DiscordTypes.GatewayTypingStartDispatchData} data */ async TYPING_START(client, data) { + if (data.user_id === client.user.id) return const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get() if (!roomID) return const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get() diff --git a/src/m2d/actions/typing.js b/src/m2d/actions/typing.js index 0c6732fa..c56ce4a5 100644 --- a/src/m2d/actions/typing.js +++ b/src/m2d/actions/typing.js @@ -31,10 +31,8 @@ function refreshTyping(channelID) { /** @param {string} channelID */ function stopTyping(channelID) { - if (isTyping.has(channelID)) { - // Schedule typing session to end - isTyping.delete(channelID) - } + // Schedule typing session to end + isTyping.delete(channelID) } module.exports.startTyping = startTyping diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 7dfe102f..0560f461 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -536,7 +536,7 @@ sync.addTemporaryListener(as, "ephemeral_type:m.typing", guard("m.typing", async event => { const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() if (!channelID) return - if (event.content.user_ids.length) { + if (event.content.user_ids.some(mxid => !utils.eventSenderIsFromDiscord(mxid))) { typing.startTyping(channelID) } else { typing.stopTyping(channelID) From 8f65b2fa4cf6b301a63591141bd1351d8e2259ba Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 24 Jul 2026 18:07:08 +1200 Subject: [PATCH 095/184] Responses: Combine data from multi-part messages --- src/discord/interactions/reactions.js | 24 +++++---- src/discord/interactions/reactions.test.js | 57 +++++++++++++++++++++- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/discord/interactions/reactions.js b/src/discord/interactions/reactions.js index bd2f8560..36853cc1 100644 --- a/src/discord/interactions/reactions.js +++ b/src/discord/interactions/reactions.js @@ -16,9 +16,9 @@ const utils = sync.require("../../matrix/utils") * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} */ async function* _interact({data}, {api}) { - const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") - .select("event_id", "room_id").where({message_id: data.target_id}).get() - if (!row) { + const events = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") + .select("event_id", "room_id").where({message_id: data.target_id}).and("ORDER BY reaction_part").all() + if (!events.length) { return yield {createInteractionResponse: { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { @@ -35,17 +35,23 @@ async function* _interact({data}, {api}) { } }} - const reactions = await api.getFullRelations(row.room_id, row.event_id, "m.annotation") + const reactions = [] + for (const event of events) { + const reactionsOnEvent = await api.getFullRelations(event.room_id, event.event_id, "m.annotation") + reactions.push(...reactionsOnEvent) + } - /** @type {Map} */ + /** @type {Map>} reaction key (the emoji) -> displaynames */ const inverted = new Map() for (const reaction of reactions) { if (utils.eventSenderIsFromDiscord(reaction.sender)) continue const key = reaction.content["m.relates_to"].key - const displayname = select("member_cache", "displayname", {mxid: reaction.sender, room_id: row.room_id}).pluck().get() || reaction.sender - if (!inverted.has(key)) inverted.set(key, []) + const displayname = select("member_cache", "displayname", {mxid: reaction.sender, room_id: events[0].room_id}).pluck().get() || reaction.sender + if (!inverted.has(key)) { + inverted.set(key, new Set()) + } // @ts-ignore - inverted.get(key).push(displayname) + inverted.get(key).add(displayname) } if (inverted.size === 0) { @@ -55,7 +61,7 @@ async function* _interact({data}, {api}) { } return yield {editOriginalInteractionResponse: { - content: [...inverted.entries()].map(([key, value]) => `${key} ⮞ ${value.join(" ⬩ ")}`).join("\n"), + content: [...inverted.entries()].sort((a, b) => b[1].size - a[1].size).map(([key, value]) => `${key} ⮞ ${[...value].join(" ⬩ ")}`).join("\n"), }} } diff --git a/src/discord/interactions/reactions.test.js b/src/discord/interactions/reactions.test.js index 50ddeca9..8d258f43 100644 --- a/src/discord/interactions/reactions.test.js +++ b/src/discord/interactions/reactions.test.js @@ -43,7 +43,7 @@ test("reactions: different response if nobody reacted", async t => { }) test("reactions: shows reactions if there are some, ignoring discord users", async t => { - let called = 1 + let called = 0 const msgs = await fromAsync(_interact({ data: { target_id: "1126786462646550579" @@ -51,6 +51,7 @@ test("reactions: shows reactions if there are some, ignoring discord users", asy }, { api: { async getFullRelations(roomID, eventID) { + called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg") return [{ @@ -97,3 +98,57 @@ test("reactions: shows reactions if there are some, ignoring discord users", asy ) t.equal(called, 1) }) + +test("reactions: combines reactions from multiple events", async t => { + let called = 0 + const msgs = await fromAsync(_interact({ + data: { + target_id: "1141501302736695316" + } + }, { + api: { + async getFullRelations(roomID, eventID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + if (eventID === "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA") { + return [{ + sender: "@cadence:cadence.moe", + content: { + "m.relates_to": { + key: "🐈", + rel_type: "m.annotation" + } + } + }, { + sender: "@rnl:cadence.moe", + content: { + "m.relates_to": { + key: "🐈", + rel_type: "m.annotation" + } + } + }] + } else if (eventID === "$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI") { + return [{ + sender: "@cadence:cadence.moe", + content: { + "m.relates_to": { + key: "🐈‍⬛", + rel_type: "m.annotation" + } + } + }] + } else { + t.fail(`tried to read reactions on wrong event ${eventID}`) + } + } + } + })) + t.equal(msgs.length, 2) + t.equal( + msgs[1].editOriginalInteractionResponse.content, + "🐈 ⮞ cadence [they] ⬩ @rnl:cadence.moe" + + "\n🐈‍⬛ ⮞ cadence [they]" + ) + t.equal(called, 2) +}) From fb1e5571a935e13265bcd4fe07bfc8faa13c9958 Mon Sep 17 00:00:00 2001 From: melody Date: Sun, 26 Jul 2026 14:09:11 +0200 Subject: [PATCH 096/184] Docs: Update melody's oci image link --- docs/docker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docker.md b/docs/docker.md index 7e3eb7d2..04b9cf4c 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -66,7 +66,7 @@ I had no hand in creating these and have not used or tested them whatsoever. I m ***The following list is distributed for your information, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.*** -- by melody: https://git.shork.ch/docker-images/out-of-your-element +- by melody: https://git.shork.ch/oci-images/out-of-your-element - by sim2kid: https://github.com/sim2kid/ooye-docker - by Katharos Technology: https://github.com/katharostech/docker_ooye - by Emma: https://cgit.rory.gay/nix/OOYE-module.git/tree From c035e49d5b8beebb00bc90d36fa76129a0651c1f Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 27 Jul 2026 23:14:05 +1200 Subject: [PATCH 097/184] Add (bot) to Matrix side displayname --- scripts/setup.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/setup.js b/scripts/setup.js index 6ce4c9fa..191ca3e3 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -364,7 +364,7 @@ function defineEchoHandler() { // set profile data on homeserver... console.log("⏩ Updating Matrix profile... (If you've joined lots of rooms, this is slow. Please allow at least 30 seconds.)") - await api.profileSetDisplayname(mxid, "Out Of Your Element") + await api.profileSetDisplayname(mxid, "Out Of Your Element (bot)") await api.profileSetAvatarUrl(mxid, avatarUrl) console.log("✅ Matrix profile updated...") From b765f564b34ff2f2d2e6a0c52504363a63ea5796 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 27 Jul 2026 23:45:17 +1200 Subject: [PATCH 098/184] Remove rather than truncate links in reply preview --- src/m2d/converters/event-to-message.js | 18 ++++++-- src/m2d/converters/event-to-message.test.js | 51 +++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index fbaf7691..18b98483 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -773,14 +773,14 @@ async function eventToMessage(event, guild, channel, di) { repliedToEvent.content = repliedToEvent.content["m.new_content"] } /** @type {string} */ - let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body + let originalRepliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype) let contentPreview if (fileReplyContentAlternative) { contentPreview = " " + fileReplyContentAlternative } else if (repliedToEvent.unsigned?.redacted_because) { contentPreview = " (in reply to a deleted message)" - } else if (typeof repliedToContent !== "string") { + } else if (typeof originalRepliedToContent !== "string") { // in reply to a weird metadata event like m.room.name, m.room.member... // I'm not implementing text fallbacks for arbitrary room events. this should cover most cases // this has never ever happened in the wild anyway @@ -788,6 +788,7 @@ async function eventToMessage(event, guild, channel, di) { contentPreview = " (channel details edited)" } else { // Generate a reply preview for a standard message + let repliedToContent = originalRepliedToContent repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body repliedToContent = repliedToContent.replace(/^\s*
.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards repliedToContent = repliedToContent.replace(/(?:\n|
)+/g, " ") // Should all be on one line @@ -802,7 +803,18 @@ async function eventToMessage(event, guild, channel, di) { const contentPreviewChunks = chunk(repliedToContent, 50) if (contentPreviewChunks.length) { contentPreview = ": " + contentPreviewChunks[0] - contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>") + contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, url => { + const originalUrlIndex = originalRepliedToContent.indexOf(url) + if (originalUrlIndex !== -1 && originalRepliedToContent[originalUrlIndex + url.length]?.match(/[^ )>"'`]/)) { // URL was truncated by chunking, replace it + try { + const u = new URL(url) + return `` + } catch (e) { + return `` + } + } + return `<${url}>` // Full URL is present, escape it + }) if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..." } else { contentPreview = "" diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 158c2efc..5dd0af7f 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -2117,6 +2117,57 @@ test("event2message: should suppress embeds for links in reply preview", async t ) }) +test("event2message: truncated links in reply preview should be unlinked to prevent broken links", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: `> <@_ooye_.wing.:cadence.moe> https://huggingface.co/blog/security-incident-july-2026\n\nthis is either written by claude or somebody who's too claude-brained and I just can't get through it`, + format: "org.matrix.custom.html", + formatted_body: `
In reply to @_ooye_.wing.:cadence.moe
https://huggingface.co/blog/security-incident-july-2026
this is either written by claude or somebody who's too claude-brained and I just can't get through it`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU" + } + } + }, + event_id: "$0Bs3rbsXaeZmSztGMx1NIsqvOrkXOpIWebN-dqr09i4", + room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe" + }, data.guild.general, data.channel.general, { + api: { + getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU", { + "type": "m.room.message", + "sender": "@_ooye_.wing.:cadence.moe", + "content": { + "m.mentions": {}, + "msgtype": "m.text", + "body": "", + "format": "org.matrix.custom.html", + "formatted_body": "https://huggingface.co/blog/security-incident-july-2026" + } + }) + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1273204543739396116 <@112890272819507200>:" + + " ..." + + `\nthis is either written by claude or somebody who's too claude-brained and I just can't get through it`, + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: should include a reply preview when message ends with a blockquote", async t => { t.deepEqual( await eventToMessage({ From 544c5206c796382f01ef8faf2c568500a6128db7 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 28 Jul 2026 01:12:39 +1200 Subject: [PATCH 099/184] Fix non-PK messages from PK users being flaky --- src/d2m/actions/edit-message.js | 8 ++-- src/d2m/actions/send-message.js | 8 ++-- src/d2m/actions/speedbump.js | 74 ++++++++++++++++++++------------- src/d2m/event-dispatcher.js | 29 +++++++------ 4 files changed, 70 insertions(+), 49 deletions(-) diff --git a/src/d2m/actions/edit-message.js b/src/d2m/actions/edit-message.js index f86a9c82..73ef4bdb 100644 --- a/src/d2m/actions/edit-message.js +++ b/src/d2m/actions/edit-message.js @@ -16,9 +16,9 @@ const mreq = sync.require("../../matrix/mreq") /** * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message * @param {import("discord-api-types/v10").APIGuild} guild - * @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel + * @param {{userID: string, webhookID: string} | null} proxyWebhook data about the webhook which is proxying messages in this channel */ -async function editMessage(message, guild, row) { +async function editMessage(message, guild, proxyWebhook) { const historicalRoomOfMessage = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: message.id}).select("room_id").get() const currentRoom = from("channel_room").join("historical_channel_room", "room_id").where({channel_id: message.channel_id}).select("room_id", "historical_room_index").get() if (!currentRoom) return @@ -27,9 +27,9 @@ async function editMessage(message, guild, row) { let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api) - if (row && row.speedbump_webhook_id === message.webhook_id) { + if (proxyWebhook && proxyWebhook.webhookID === message.webhook_id) { // Handle the PluralKit public instance - if (row.speedbump_id === "466378653216014359") { + if (proxyWebhook.userID === "466378653216014359") { senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true) } } diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index 5b3b4f3b..42a0b5ce 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -28,9 +28,9 @@ const channelWebhook = sync.require("../../m2d/actions/channel-webhook") * @param {DiscordTypes.GatewayMessageCreateDispatchData} message * @param {DiscordTypes.APIGuildChannel} channel * @param {DiscordTypes.APIGuild} guild - * @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel + * @param {{userID: string, webhookID: string} | null} proxyWebhook data about the webhook which is proxying messages in this channel */ -async function sendMessage(message, channel, guild, row) { +async function sendMessage(message, channel, guild, proxyWebhook) { const roomID = await createRoom.ensureRoom(message.channel_id) const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get() assert(historicalRoomIndex) @@ -38,9 +38,9 @@ async function sendMessage(message, channel, guild, row) { let senderMxid = null if (dUtils.isWebhookMessage(message)) { const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guild.id}).pluck().get() ?? 0 - if (row && row.speedbump_webhook_id === message.webhook_id) { + if (proxyWebhook && proxyWebhook.webhookID === message.webhook_id) { // Handle the PluralKit public instance - if (row.speedbump_id === "466378653216014359") { + if (proxyWebhook.userID === "466378653216014359") { senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true) } } else if (useWebhookProfile) { diff --git a/src/d2m/actions/speedbump.js b/src/d2m/actions/speedbump.js index 42e3a35d..22a8f5a0 100644 --- a/src/d2m/actions/speedbump.js +++ b/src/d2m/actions/speedbump.js @@ -5,9 +5,9 @@ const {discord, select, db} = passthrough const DEBUG_SPEEDBUMP = false -function debugSpeedbump(message) { +function debugSpeedbump(...args) { if (DEBUG_SPEEDBUMP) { - console.log(message) + console.log(...args) } } @@ -19,6 +19,11 @@ const KNOWN_BOTS = new Set([ "466378653216014359" // PluralKit ]) +const CONTEXT_ENUM = { + create: "create", + update: "update" +} + /** * Fetch new speedbump data for the channel and put it in the database as cache * @param {string} channelID @@ -34,55 +39,68 @@ async function updateCache(channelID, lastChecked) { db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, channelID) } -/** @type {Map} messageID -> number of gateway events currently bumping */ +/** + * @typedef BumpingEntry + * @prop {number} number number of gateway events currently bumping for this message ID + * @prop {boolean} hasCreate whether there was a message create within the events currently bumping + */ + +/** @type {Map} messageID -> BumpingEntry */ const bumping = new Map() /** * Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted. + * @param {keyof typeof CONTEXT_ENUM} context * @param {string} messageID * @returns whether it was deleted */ -async function doSpeedbump(messageID) { - let value = (bumping.get(messageID) ?? 0) + 1 - bumping.set(messageID, value) - debugSpeedbump(`[speedbump] WAIT ${messageID}++ = ${value}`) +async function doSpeedbump(context, messageID) { + const entry = bumping.get(messageID) ?? (() => { + const entry = {number: 0, hasCreate: false} + bumping.set(messageID, entry) + return entry + })() + + entry.number++ + if (context === "create") entry.hasCreate = true + + debugSpeedbump(`[speedbump] WAIT ${messageID}++ =`, entry) await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED)) if (!bumping.has(messageID)) { debugSpeedbump(`[speedbump] DELETED ${messageID}`) - return true + return {skip: true, hasCreate: null} } - value = (bumping.get(messageID) ?? 0) - 1 - if (value <= 0) { - debugSpeedbump(`[speedbump] OK ${messageID}-- = ${value}`) + + if (--entry.number <= 0) { + debugSpeedbump(`[speedbump] OK ${messageID}-- =`, entry) bumping.delete(messageID) - return false + return {skip: false, hasCreate: entry.hasCreate} } else { - debugSpeedbump(`[speedbump] MULTI ${messageID}-- = ${value}`) - bumping.set(messageID, value) - return true + debugSpeedbump(`[speedbump] MULTI ${messageID}-- =`, entry) + return {skip: true, hasCreate: null} } } /** * 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 + * @param {keyof typeof CONTEXT_ENUM} context + * @param {{id: string, channel_id: string, author: {id: string}, backfill?: boolean}} message uses the ID to identify, and the userID to 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, userID) { - let row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get() +async function maybeDoSpeedbump(context, message) { + let row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: message.channel_id}).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) { - if (row.speedbump_webhook_id === userID) return {affected: false, row} // shortcut - const userHasProxy = select("sim_proxy", "user_id", {proxy_owner_id: userID}).pluck().get() - if (!userHasProxy) return {affected: false, row} // user has not used PK before, no speedbump - } - const affected = await doSpeedbump(messageID) - return {affected, row} // maybe affected, and there is a speedbump + if (!row?.speedbump_webhook_id || !row?.speedbump_id) return {skip: false, proxyWebhook: null} // channel not affected, no speedbump + + let proxyWebhook = {userID: row.speedbump_id, webhookID: row.speedbump_webhook_id} + if (message.backfill) return {skip: false, proxyWebhook} // don't slow messages during backfill + if (proxyWebhook.webhookID === message.author.id) return {skip: false, proxyWebhook} // shortcut + const userHasProxy = select("sim_proxy", "user_id", {proxy_owner_id: message.author.id}).pluck().get() + if (!userHasProxy) return {skip: false, proxyWebhook} // user has not used PK before, no speedbump + const {skip, hasCreate} = await doSpeedbump(context, message.id) + return {skip, hasCreate, proxyWebhook} // maybe affected, and there is a speedbump } /** diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index c0c33d98..05566973 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -288,7 +288,6 @@ module.exports = { if (!guildID) return // channel must have been a DM channel or something const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() if (!roomID) return // channel wasn't being bridged in the first place - // @ts-ignore await createRoom.unbridgeChannel(channel, guildID) }, @@ -313,11 +312,10 @@ 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, message.author.id) - if (affected) return + const {skip, proxyWebhook} = await speedbump.maybeDoSpeedbump("create", message) + if (skip) return - // @ts-ignore - await sendMessage.sendMessage(message, channel, guild, row) + await sendMessage.sendMessage(message, channel, guild, proxyWebhook) retrigger.finishedBridging(message.id) }, @@ -335,22 +333,27 @@ 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, data.author.id) - if (affected) return - - // Check that the sending-to room exists, and deal with Eventual Consistency(TM) - if (!await retrigger.waitForMessage(data.id)) return + const {skip, hasCreate, proxyWebhook} = await speedbump.maybeDoSpeedbump("update", data) + if (skip) return /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ - // @ts-ignore const message = data const channel = client.channels.get(message.channel_id) if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. const guild = client.guilds.get(channel.guild_id) assert(guild) - // @ts-ignore - await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row)) + if (!hasCreate) { + // Standard path for most message updates + // Check that the target message already exists, and deal with Eventual Consistency(TM) + if (!await retrigger.waitForMessage(data.id)) return + await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, proxyWebhook)) + } + else { + // Path for edit packets that were speedbumped into the latest copy of a message that needs to be created + // Just pretend to be MESSAGE_CREATE + await sendMessage.sendMessage(message, channel, guild, proxyWebhook) + } }, /** From a3ce911218f5a4ba9de851cf5375bc90caa9b95c Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 28 Jul 2026 03:12:25 +1200 Subject: [PATCH 100/184] Support /plu/ral webhook proxying --- docs/get-started.md | 12 +- scripts/plu-ral-api.js | 17 ++ src/d2m/actions/edit-message.js | 20 +-- src/d2m/actions/register-pk-user.js | 6 +- src/d2m/actions/register-plu-ral-user.js | 170 ++++++++++++++++++ src/d2m/actions/send-message.js | 22 +-- src/d2m/actions/speedbump.js | 85 ++++++--- src/d2m/event-dispatcher.js | 10 +- src/db/migrations/0039-refactor-speedbump.sql | 19 ++ src/db/orm-defs.d.ts | 11 +- src/matrix/mreq.js | 6 +- src/types.d.ts | 23 +++ src/web/routes/link.test.js | 8 +- 13 files changed, 337 insertions(+), 72 deletions(-) create mode 100644 scripts/plu-ral-api.js create mode 100644 src/d2m/actions/register-plu-ral-user.js create mode 100644 src/db/migrations/0039-refactor-speedbump.sql diff --git a/docs/get-started.md b/docs/get-started.md index 5b14b2a7..17c98deb 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -56,8 +56,16 @@ After that, to get into the rooms on your Matrix account, use the invite form on I hope you enjoy Out Of Your Element! ----- -




+# Extras + +## /plu/ral support + +To support /plu/ral webhook proxying, OOYE needs to call the /plu/ral API authenticated. I can't provide an API key for everyone to share, so you have to make your own. Here's how: + +1. Open the [/plu/ral app on Discord](https://discord.com/discovery/applications/1291501048493768784) and "Add App" -> "Add to My Apps (use everywhere)" +2. Go to any text channel on Discord and use the new `/api` command from /plu/ral. +3. Create a new application named Out Of Your Element. Copy the displayed token. +3. Run `node scripts/plu-ral-api.js` and paste the token. # Appendix diff --git a/scripts/plu-ral-api.js b/scripts/plu-ral-api.js new file mode 100644 index 00000000..d3468a3c --- /dev/null +++ b/scripts/plu-ral-api.js @@ -0,0 +1,17 @@ +// @ts-check + +const {reg, writeRegistration} = require("../src/matrix/read-registration") +const {prompt} = require("enquirer") + +;(async () => { + /** @type {{api_key: string}} */ + const apiKeyResponse = await prompt({ + type: "text", + name: "api_key", + message: "Paste your personal /plu/ral API key" + }) + + reg.ooye.plu_ral_api_key = apiKeyResponse.api_key + writeRegistration(reg) + console.log("Saved. This change should be applied instantly.") +})() diff --git a/src/d2m/actions/edit-message.js b/src/d2m/actions/edit-message.js index 73ef4bdb..31f36273 100644 --- a/src/d2m/actions/edit-message.js +++ b/src/d2m/actions/edit-message.js @@ -1,24 +1,24 @@ // @ts-check -const assert = require("assert").strict - const passthrough = require("../../passthrough") const {sync, db, select, from} = passthrough +const {reg} = require("../../matrix/read-registration") /** @type {import("../converters/edit-to-changes")} */ const editToChanges = sync.require("../converters/edit-to-changes") -/** @type {import("./register-pk-user")} */ -const registerPkUser = sync.require("./register-pk-user") +/** @type {import("./speedbump")} */ +const speedbump = sync.require("./speedbump") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") /** @type {import("../../matrix/mreq")} */ const mreq = sync.require("../../matrix/mreq") +/** @type {import("../../discord/utils")} */ +const dUtils = sync.require("../../discord/utils") /** * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message * @param {import("discord-api-types/v10").APIGuild} guild - * @param {{userID: string, webhookID: string} | null} proxyWebhook data about the webhook which is proxying messages in this channel */ -async function editMessage(message, guild, proxyWebhook) { +async function editMessage(message, guild) { const historicalRoomOfMessage = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: message.id}).select("room_id").get() const currentRoom = from("channel_room").join("historical_channel_room", "room_id").where({channel_id: message.channel_id}).select("room_id", "historical_room_index").get() if (!currentRoom) return @@ -27,11 +27,9 @@ async function editMessage(message, guild, proxyWebhook) { let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api) - if (proxyWebhook && proxyWebhook.webhookID === message.webhook_id) { - // Handle the PluralKit public instance - if (proxyWebhook.userID === "466378653216014359") { - senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true) - } + // Sync proxy user profile (if sent by proxy) + if (dUtils.isWebhookMessage(message)) { + senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID) } // 1. Replace all the things. diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js index 6ecd0775..4946d4fe 100644 --- a/src/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -1,6 +1,6 @@ // @ts-check -const assert = require("assert") +const assert = require("assert").strict const {reg} = require("../../matrix/read-registration") const Ty = require("../../types") @@ -132,10 +132,10 @@ async function syncUser(messageID, author, roomID, shouldActuallySync) { try { // API lookup var pkMessage = await fetchMessage(messageID) - db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username) + db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname, proxy_app) VALUES (?, ?, ?, 0)").run(pkMessage.member.uuid, pkMessage.sender, author.username) } catch (e) { // Fall back to offline cache - const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID}).pluck("mxid").get() + const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID, proxy_app: 0}).pluck("mxid").get() if (!senderMxid) throw e return senderMxid } diff --git a/src/d2m/actions/register-plu-ral-user.js b/src/d2m/actions/register-plu-ral-user.js new file mode 100644 index 00000000..04fa28cd --- /dev/null +++ b/src/d2m/actions/register-plu-ral-user.js @@ -0,0 +1,170 @@ +// @ts-check + +const assert = require("assert").strict +const {reg} = require("../../matrix/read-registration") +const Ty = require("../../types") + +const passthrough = require("../../passthrough") +const {sync, db, select, from} = passthrough +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/file")} */ +const file = sync.require("../../matrix/file") +/** @type {import("./register-user")} */ +const registerUser = sync.require("./register-user") + +/** @returns {Promise} */ +async function fetchMessage(channelID, messageID) { + assert(reg.ooye.plu_ral_api_key) + try { + var res = await fetch(`https://api.plural.gg/messages/${channelID}/${messageID}?member=true`, { + headers: { + Authorization: reg.ooye.plu_ral_api_key + } + }) + } catch (networkError) { + // Network issue, raise a more readable message + throw new Error(`Failed to connect to /plu/ral API: ${networkError.toString()}`) + } + if (!res.ok) throw new Error(`/plu/ral API returned an error: ${await res.text()}`) + /** @type {any} */ + const root = await res.json() + if (!root.member) throw new Error(`/plu/ral API didn't return member data: ${JSON.stringify(root)}`) + return root +} + +/** + * Using the same sim names and fake user IDs for /plu/ral members, since unlike PluralKit they don't have a short and a long ID. + * @param {Ty.PluRalWebhookMessage} pluRalMessage + */ +function getSimName(pluRalMessage) { + return `_pl_${pluRalMessage.member_id}` +} + +/** + * A sim is an account that is being simulated by the bridge to copy events from the other side. + * @param {Ty.PluRalWebhookMessage} pluRalMessage + * @returns mxid + */ +async function createSim(pluRalMessage) { + // Choose sim name + const simName = getSimName(pluRalMessage) + const localpart = reg.ooye.namespace_prefix + simName + const mxid = `@${localpart}:${reg.ooye.server_name}` + + // Save chosen name in the database forever + db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(simName, simName, simName, mxid) + + // Register matrix user with that name + try { + await api.register(localpart) + } catch (e) { + // If user creation fails, manually undo the database change. Still isn't perfect, but should help. + // (I would prefer a transaction, but it's not safe to leave transactions open across event loop ticks.) + db.prepare("DELETE FROM sim WHERE user_id = ?").run(simName) + throw e + } + return mxid +} + +/** + * Ensure a sim is registered for the user. + * If there is already a sim, use that one. If there isn't one yet, register a new sim. + * @param {Ty.PluRalWebhookMessage} pluRalMessage + * @returns {Promise} mxid + */ +async function ensureSim(pluRalMessage) { + let mxid = null + const existing = select("sim", "mxid", {user_id: getSimName(pluRalMessage)}).pluck().get() + if (existing) { + mxid = existing + } else { + mxid = await createSim(pluRalMessage) + } + return mxid +} + +/** + * Ensure a sim is registered for the user and is joined to the room. + * @param {Ty.PluRalWebhookMessage} pluRalMessage + * @param {string} roomID + * @returns {Promise} mxid + */ +async function ensureSimJoined(pluRalMessage, roomID) { + // Ensure room ID is really an ID, not an alias + assert.ok(roomID[0] === "!") + + // Ensure user + const mxid = await ensureSim(pluRalMessage) + + // Ensure joined + const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get() + if (!existing) { + await api.inviteToRoom(roomID, mxid) + await api.joinRoom(roomID, mxid) + db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid) + } + return mxid +} + +/** + * Generate profile data based on webhook displayname and configured avatar. + * @param {Ty.PluRalWebhookMessage} pluRalMessage + * @param {Ty.WebhookAuthor} author + */ +async function memberToStateContent(pluRalMessage, author) { + // We prefer to use the member's avatar URL data since the image upload can be cached across channels, + // unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL. + const avatar = pluRalMessage.member.avatar_url || file.userAvatar(author) + + const content = { + displayname: author.username, + membership: "join", + "moe.cadence.ooye.plu_ral_member": pluRalMessage.member + } + if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar) + + return content +} + +/** + * Sync profile data for a sim user. This function follows the following process: + * 1. Look up data about proxy user from API + * 2. If this fails, try to use previously cached data (won't sync) + * 3. Create and join the sim to the room if needed + * 4. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before + * 5. Compare against the previously known state content, which is helpfully stored in the database + * 6. If the state content has changed, send it to Matrix and update it in the database for next time + * @param {string} channelID to call API with + * @param {string} messageID to call API with + * @param {Ty.WebhookAuthor} author for profile data + * @param {string} roomID room to join member to + * @param {boolean} shouldActuallySync whether to actually sync updated user data or just ensure it's joined + * @returns {Promise} mxid of the updated sim + */ +async function syncUser(channelID, messageID, author, roomID, shouldActuallySync) { + try { + // API lookup + var pluRalMessage = await fetchMessage(channelID, messageID) + const simName = getSimName(pluRalMessage) + db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname, proxy_app) VALUES (?, ?, ?, 1)").run(simName, pluRalMessage.author_id, author.username) + } catch (e) { + // Fall back to offline cache + const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID, proxy_app: 1}).pluck("mxid").get() + if (!senderMxid) throw e + return senderMxid + } + + // Create and join the sim to the room if needed + const mxid = await ensureSimJoined(pluRalMessage, roomID) + + if (shouldActuallySync) { + // Build current profile data and sync if the hash has changed + const content = await memberToStateContent(pluRalMessage, author) + await registerUser._sendSyncUser(roomID, mxid, content, null) + } + + return mxid +} + +module.exports.syncUser = syncUser diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index 42a0b5ce..41ef70d7 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -4,17 +4,16 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") -const { discord, sync, db, select, from} = passthrough +const {discord, sync, db, select, from} = passthrough +const {reg} = require("../../matrix/read-registration") /** @type {import("../converters/message-to-event")} */ const messageToEvent = sync.require("../converters/message-to-event") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") /** @type {import("./register-user")} */ const registerUser = sync.require("./register-user") -/** @type {import("./register-pk-user")} */ -const registerPkUser = sync.require("./register-pk-user") -/** @type {import("./register-webhook-user")} */ -const registerWebhookUser = sync.require("./register-webhook-user") +/** @type {import("./speedbump")} */ +const speedbump = sync.require("./speedbump") /** @type {import("../actions/create-room")} */ const createRoom = sync.require("../actions/create-room") /** @type {import("../actions/poll-end")} */ @@ -28,24 +27,15 @@ const channelWebhook = sync.require("../../m2d/actions/channel-webhook") * @param {DiscordTypes.GatewayMessageCreateDispatchData} message * @param {DiscordTypes.APIGuildChannel} channel * @param {DiscordTypes.APIGuild} guild - * @param {{userID: string, webhookID: string} | null} proxyWebhook data about the webhook which is proxying messages in this channel */ -async function sendMessage(message, channel, guild, proxyWebhook) { +async function sendMessage(message, channel, guild) { const roomID = await createRoom.ensureRoom(message.channel_id) const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get() assert(historicalRoomIndex) let senderMxid = null if (dUtils.isWebhookMessage(message)) { - const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guild.id}).pluck().get() ?? 0 - if (proxyWebhook && proxyWebhook.webhookID === message.webhook_id) { - // Handle the PluralKit public instance - if (proxyWebhook.userID === "466378653216014359") { - senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true) - } - } else if (useWebhookProfile) { - senderMxid = await registerWebhookUser.syncUser(message.author, roomID, true) - } + senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID) } else { // not a webhook if (message.author.id === discord.application.id) { diff --git a/src/d2m/actions/speedbump.js b/src/d2m/actions/speedbump.js index 22a8f5a0..88cea075 100644 --- a/src/d2m/actions/speedbump.js +++ b/src/d2m/actions/speedbump.js @@ -1,7 +1,17 @@ // @ts-check +const DiscordTypes = require("discord-api-types/v10") + const passthrough = require("../../passthrough") -const {discord, select, db} = passthrough +const {discord, from, select, db, sync} = passthrough + +const {reg} = require("../../matrix/read-registration") +/** @type {import("./register-pk-user")} */ +const registerPkUser = sync.require("./register-pk-user") +/** @type {import("./register-plu-ral-user")} */ +const registerPluRalUser = sync.require("./register-plu-ral-user") +/** @type {import("./register-webhook-user")} */ +const registerWebhookUser = sync.require("./register-webhook-user") const DEBUG_SPEEDBUMP = false @@ -16,14 +26,10 @@ const SPEEDBUMP_UPDATE_FREQUENCY = 2 * 60 * 60 // 2 hours /** @type {Set} */ const KNOWN_BOTS = new Set([ - "466378653216014359" // PluralKit + "466378653216014359", // PluralKit + "1291501048493768784", // /plu/ral ]) -const CONTEXT_ENUM = { - create: "create", - update: "update" -} - /** * Fetch new speedbump data for the channel and put it in the database as cache * @param {string} channelID @@ -33,10 +39,14 @@ async function updateCache(channelID, lastChecked) { const now = Math.floor(Date.now() / 1000) if (lastChecked && now - lastChecked < SPEEDBUMP_UPDATE_FREQUENCY) return const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID) - const found = webhooks.find(b => KNOWN_BOTS.has(b.application_id)) - const foundApplication = found?.application_id - const foundWebhook = found?.id - db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, channelID) + const found = webhooks.filter(b => KNOWN_BOTS.has(b.application_id)) + db.transaction(() => { + db.prepare("DELETE FROM channel_speedbump WHERE channel_id = ?").run(channelID) + for (const webhook of found) { + db.prepare("INSERT INTO channel_speedbump (channel_id, speedbump_webhook_id, speedbump_user_id) VALUES (?, ?, ?)").run(channelID, webhook.id, webhook.application_id) + } + db.prepare("UPDATE channel_room SET speedbump_checked = ? WHERE channel_id = ?").run(now, channelID) + })() } /** @@ -50,11 +60,11 @@ const bumping = new Map() /** * Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted. - * @param {keyof typeof CONTEXT_ENUM} context + * @param {boolean} isCreate * @param {string} messageID * @returns whether it was deleted */ -async function doSpeedbump(context, messageID) { +async function doSpeedbump(isCreate, messageID) { const entry = bumping.get(messageID) ?? (() => { const entry = {number: 0, hasCreate: false} bumping.set(messageID, entry) @@ -62,7 +72,7 @@ async function doSpeedbump(context, messageID) { })() entry.number++ - if (context === "create") entry.hasCreate = true + entry.hasCreate ||= isCreate debugSpeedbump(`[speedbump] WAIT ${messageID}++ =`, entry) @@ -83,24 +93,27 @@ async function doSpeedbump(context, messageID) { } } +function getSpeedbumpRows(channelID) { + return from("channel_room").join("channel_speedbump", "channel_id").select("thread_parent", "speedbump_user_id", "speedbump_webhook_id").where({channel_id: channelID}).all() +} + /** * 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 {keyof typeof CONTEXT_ENUM} context + * @param {boolean} isCreate * @param {{id: string, channel_id: string, author: {id: string}, backfill?: boolean}} message uses the ID to identify, and the userID to 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 + * @returns whether to skip this message, and whether the message should be created as a creation */ -async function maybeDoSpeedbump(context, message) { - let row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: message.channel_id}).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 || !row?.speedbump_id) return {skip: false, proxyWebhook: null} // channel not affected, no speedbump +async function maybeDoSpeedbump(isCreate, message) { + let rows = getSpeedbumpRows(message.channel_id) + if (rows[0]?.thread_parent) rows = getSpeedbumpRows(rows[0].thread_parent) // webhooks belong to the channel, not the thread + if (!rows.length) return {skip: false} // channel not affected, no speedbump - let proxyWebhook = {userID: row.speedbump_id, webhookID: row.speedbump_webhook_id} - if (message.backfill) return {skip: false, proxyWebhook} // don't slow messages during backfill - if (proxyWebhook.webhookID === message.author.id) return {skip: false, proxyWebhook} // shortcut + if (message.backfill) return {skip: false} // don't slow messages during backfill + if (rows.some(r => r.speedbump_webhook_id === message.author.id)) return {skip: false} // shortcut const userHasProxy = select("sim_proxy", "user_id", {proxy_owner_id: message.author.id}).pluck().get() - if (!userHasProxy) return {skip: false, proxyWebhook} // user has not used PK before, no speedbump - const {skip, hasCreate} = await doSpeedbump(context, message.id) - return {skip, hasCreate, proxyWebhook} // maybe affected, and there is a speedbump + if (!userHasProxy) return {skip: false} // user has not used PK before, no speedbump + const {skip, hasCreate} = await doSpeedbump(isCreate, message.id) + return {skip, hasCreate} // maybe affected, and there is a speedbump } /** @@ -110,7 +123,25 @@ function onMessageDelete(messageID) { bumping.delete(messageID) } +/** + * @param {DiscordTypes.APIMessage} message + * @param {string} guildID + * @param {string} roomID + */ +async function getWebhookSenderId(message, guildID, roomID) { + const speedbumpUserID = select("channel_speedbump", "speedbump_user_id", {channel_id: message.channel_id, speedbump_webhook_id: message.webhook_id}).pluck().get() + const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guildID}).pluck().get() ?? 0 + if (speedbumpUserID === "466378653216014359") { // PluralKit public instance + return await registerPkUser.syncUser(message.id, message.author, roomID, true) + } else if (speedbumpUserID === "1291501048493768784" && reg.ooye.plu_ral_api_key) { // /plu/ral public instance + return await registerPluRalUser.syncUser(message.channel_id, message.id, message.author, roomID, true) + } else if (useWebhookProfile) { + return await registerWebhookUser.syncUser(message.author, roomID, true) + } + return null +} + module.exports.updateCache = updateCache -module.exports.doSpeedbump = doSpeedbump module.exports.maybeDoSpeedbump = maybeDoSpeedbump module.exports.onMessageDelete = onMessageDelete +module.exports.getWebhookSenderId = getWebhookSenderId diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 05566973..a49453ac 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -312,10 +312,10 @@ module.exports = { if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable - const {skip, proxyWebhook} = await speedbump.maybeDoSpeedbump("create", message) + const {skip} = await speedbump.maybeDoSpeedbump(true, message) if (skip) return - await sendMessage.sendMessage(message, channel, guild, proxyWebhook) + await sendMessage.sendMessage(message, channel, guild) retrigger.finishedBridging(message.id) }, @@ -333,7 +333,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 {skip, hasCreate, proxyWebhook} = await speedbump.maybeDoSpeedbump("update", data) + const {skip, hasCreate} = await speedbump.maybeDoSpeedbump(false, data) if (skip) return /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ @@ -347,12 +347,12 @@ module.exports = { // Standard path for most message updates // Check that the target message already exists, and deal with Eventual Consistency(TM) if (!await retrigger.waitForMessage(data.id)) return - await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, proxyWebhook)) + await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild)) } else { // Path for edit packets that were speedbumped into the latest copy of a message that needs to be created // Just pretend to be MESSAGE_CREATE - await sendMessage.sendMessage(message, channel, guild, proxyWebhook) + await sendMessage.sendMessage(message, channel, guild) } }, diff --git a/src/db/migrations/0039-refactor-speedbump.sql b/src/db/migrations/0039-refactor-speedbump.sql new file mode 100644 index 00000000..de739dc8 --- /dev/null +++ b/src/db/migrations/0039-refactor-speedbump.sql @@ -0,0 +1,19 @@ +BEGIN TRANSACTION; + +CREATE TABLE "channel_speedbump" ( + "channel_id" TEXT NOT NULL, + "speedbump_webhook_id" TEXT NOT NULL, + "speedbump_user_id" TEXT NOT NULL, + PRIMARY KEY("channel_id","speedbump_webhook_id"), + FOREIGN KEY("channel_id") REFERENCES "channel_room"("channel_id") +) WITHOUT ROWID; + +INSERT INTO channel_speedbump (channel_id, speedbump_webhook_id, speedbump_user_id) + SELECT channel_id, speedbump_webhook_id, speedbump_id FROM channel_room WHERE speedbump_id IS NOT NULL AND speedbump_webhook_id IS NOT NULL; + +ALTER TABLE channel_room DROP COLUMN speedbump_id; +ALTER TABLE channel_room DROP COLUMN speedbump_webhook_id; + +ALTER TABLE sim_proxy ADD COLUMN proxy_app INTEGER DEFAULT 0; + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index d95bfc37..14f320a2 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -18,13 +18,17 @@ export type Models = { thread_parent: string | null custom_avatar: string | null last_bridged_pin_timestamp: number | null - speedbump_id: string | null - speedbump_webhook_id: string | null speedbump_checked: number | null guild_id: string | null custom_topic: number } + channel_speedbump: { + channel_id: string + speedbump_webhook_id: string + speedbump_user_id: string + } + direct: { mxid: string room_id: string @@ -44,6 +48,7 @@ export type Models = { event_subtype: string | null part: number reaction_part: number + /** 0 = Matrix, 1 = Discord */ source: number } @@ -137,6 +142,8 @@ export type Models = { user_id: string proxy_owner_id: string displayname: string + /** 0 = PluralKit, 1 = /plu/ral */ + proxy_app: number } webhook: { diff --git a/src/matrix/mreq.js b/src/matrix/mreq.js index bf238f26..b6bc5b9d 100644 --- a/src/matrix/mreq.js +++ b/src/matrix/mreq.js @@ -9,8 +9,10 @@ const {reg} = require("./read-registration.js") const baseUrl = `${reg.ooye.server_origin}/_matrix` class MatrixServerError extends Error { - /** @param {number} httpStatus} */ - constructor(data, httpStatus, opts) { + /** + * @param {number} httpStatus} + */ + constructor(data, httpStatus, opts = {}) { super(data.error || data.errcode) this.data = data /** @type {number} */ diff --git a/src/types.d.ts b/src/types.d.ts index 856ab1c1..f65ce59c 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -36,6 +36,7 @@ export type AppServiceRegistrationConfig = { web_password: string time_zone?: string receive_presences: boolean + plu_ral_api_key?: string } old_bridge?: { as_token: string @@ -124,6 +125,28 @@ export type PkMessage = { sender: string } +export type PluRalWebhookMessage = { + original_id: string | null + proxy_id: string + author_id: string + channel_id: string + member_id: string + reason: string + webhook_id: string + member: PluRalMember +} + +export type PluRalMember = { + id: string + name: string + pronouns: string + bio: string + birthday: string + color: number | null + avatar_url: string + private: boolean +} + export namespace Event { export type Outer = { type: string diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index 0182093c..32f89bfb 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -73,7 +73,7 @@ test("web link space: check that OOYE is joined", async t => { api: { async joinRoom(roomID) { called++ - throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) + throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400) } } })) @@ -368,7 +368,7 @@ test("web link room: check that bridge can join room (notices lack of via and as api: { async joinRoom(roomID) { called++ - throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) + throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400) }, async *generateFullHierarchy(spaceID) { called++ @@ -402,7 +402,7 @@ test("web link room: check that bridge can join room (uses via for join attempt) async joinRoom(roomID, _, via) { called++ t.deepEqual(via, ["cadence.moe", "hashi.re"]) - throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) + throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400) }, async *generateFullHierarchy(spaceID) { called++ @@ -710,7 +710,7 @@ test("web unlink room: checks that the channel is bridged", async t => { })) t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged") - db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_id, row.speedbump_checked, row.speedbump_webhook_id, row.guild_id, row.custom_topic) + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_checked, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_checked, row.guild_id, row.custom_topic) const new_row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get() t.deepEqual(row, new_row) }) From c8961a9beca0a9652a017806cb3269b0aa221475 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 30 Jul 2026 19:31:17 +1200 Subject: [PATCH 101/184] Update dependencies --- docs/get-started.md | 2 +- package-lock.json | 344 ++++++++++++++----------- package.json | 4 +- src/web/routes/download-matrix.test.js | 2 +- 4 files changed, 196 insertions(+), 156 deletions(-) diff --git a/docs/get-started.md b/docs/get-started.md index 17c98deb..fa67678a 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -63,7 +63,7 @@ I hope you enjoy Out Of Your Element! To support /plu/ral webhook proxying, OOYE needs to call the /plu/ral API authenticated. I can't provide an API key for everyone to share, so you have to make your own. Here's how: 1. Open the [/plu/ral app on Discord](https://discord.com/discovery/applications/1291501048493768784) and "Add App" -> "Add to My Apps (use everywhere)" -2. Go to any text channel on Discord and use the new `/api` command from /plu/ral. +2. Go to any text channel on Discord and use the `/api` command from /plu/ral. 3. Create a new application named Out Of Your Element. Copy the displayed token. 3. Run `node scripts/plu-ral-api.js` and paste the token. diff --git a/package-lock.json b/package-lock.json index 04dbbe4e..3a2231d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "chunk-text": "^2.0.1", "cloudstorm": "^0.19.0", "denque": "^2.1.0", - "discord-api-types": "^0.38.38", + "discord-api-types": "^0.38.52", "domino": "^2.1.6", "enquirer": "^2.4.1", "entities": "^5.0.0", @@ -37,7 +37,7 @@ "lru-cache": "^11.0.2", "mime-types": "^2.1.35", "prettier-bytes": "^1.0.4", - "sharp": "^0.34.5", + "sharp": "^0.35.3", "snowtransfer": "^0.19.0", "try-to-catch": "^4.0.5", "uqr": "^0.1.2", @@ -277,9 +277,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -302,9 +302,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -314,19 +314,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -336,19 +336,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -362,9 +381,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -378,9 +397,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -394,9 +413,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -410,9 +429,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -426,9 +445,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -442,9 +461,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -458,9 +477,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -474,9 +493,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -490,9 +509,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -506,9 +525,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -518,19 +537,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -540,19 +559,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -562,19 +581,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -584,19 +603,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -606,19 +625,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -628,19 +647,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -650,19 +669,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -672,38 +691,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@emnapi/runtime": "^1.11.1" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -713,16 +748,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -732,16 +767,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -751,7 +786,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1004,9 +1039,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1164,16 +1199,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/buffer": { @@ -1483,9 +1518,9 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.49", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", - "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", + "version": "0.38.52", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.52.tgz", + "integrity": "sha512-uwe9EKfbjsmgWc2fdFjvDbj+dQqx3lp7wqDCmIha0jInuU+xeQjkCK9tMMn+p7RXfdVQORCInq4cD3U2ymDmyg==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" @@ -2443,9 +2478,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2455,47 +2490,52 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shebang-command": { @@ -2819,9 +2859,9 @@ } }, "node_modules/try-to-catch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.0.5.tgz", - "integrity": "sha512-VKBslDQsy4pGj2TMNgDdskWb7AWSi/9dPEmcNv3sdL0+aRMQTPJo6aEqlcuN0vbOwFfsE1oAXmx3bFdf6vrJFg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.1.0.tgz", + "integrity": "sha512-phAezhZqLgHHTpbZYe2GgmKe8itTgEX3gfd+YeIsmllgOOpXCDPn1DkDzVWj0mGQPriJgD6AagoUEcX+M2RlGw==", "license": "MIT", "engines": { "node": ">=22" diff --git a/package.json b/package.json index c0865173..06b2a495 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "chunk-text": "^2.0.1", "cloudstorm": "^0.19.0", "denque": "^2.1.0", - "discord-api-types": "^0.38.38", + "discord-api-types": "^0.38.52", "domino": "^2.1.6", "enquirer": "^2.4.1", "entities": "^5.0.0", @@ -46,7 +46,7 @@ "lru-cache": "^11.0.2", "mime-types": "^2.1.35", "prettier-bytes": "^1.0.4", - "sharp": "^0.34.5", + "sharp": "^0.35.3", "snowtransfer": "^0.19.0", "try-to-catch": "^4.0.5", "uqr": "^0.1.2", diff --git a/src/web/routes/download-matrix.test.js b/src/web/routes/download-matrix.test.js index 2e76002b..76e8152f 100644 --- a/src/web/routes/download-matrix.test.js +++ b/src/web/routes/download-matrix.test.js @@ -65,7 +65,7 @@ test("web sheet: single emoji", async t => { mxcDownloader: mockGetAndConvertEmoji }) t.equal(event.node.res.statusCode, 200) - t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVR4nM1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH") + t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVRogc1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH") }) test("web sheet: multiple sources", async t => { From 50d5509c074e884e3f8210de0d2d60df0d1be4c8 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 4 Aug 2026 11:43:11 +1200 Subject: [PATCH 102/184] Rename files for consistency --- .../{guild_access_denied.pug => guild-access-denied.pug} | 0 src/web/pug/{guild_not_linked.pug => guild-not-linked.pug} | 0 src/web/routes/guild.js | 6 +++--- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/web/pug/{guild_access_denied.pug => guild-access-denied.pug} (100%) rename src/web/pug/{guild_not_linked.pug => guild-not-linked.pug} (100%) diff --git a/src/web/pug/guild_access_denied.pug b/src/web/pug/guild-access-denied.pug similarity index 100% rename from src/web/pug/guild_access_denied.pug rename to src/web/pug/guild-access-denied.pug diff --git a/src/web/pug/guild_not_linked.pug b/src/web/pug/guild-not-linked.pug similarity index 100% rename from src/web/pug/guild_not_linked.pug rename to src/web/pug/guild-not-linked.pug diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 70092d50..c9561e58 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -158,13 +158,13 @@ as.router.get("/guild", defineEventHandler(async event => { // Permission problems if (!guild_id || !guild || !managed.has(guild_id) || !row) { - return pugSync.render(event, "guild_access_denied.pug", {guild_id, row}) + return pugSync.render(event, "guild-access-denied.pug", {guild_id, row}) } // Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow if (!row.space_id && row.autocreate === 0) { const spaces = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : [] - return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces}) + return pugSync.render(event, "guild-not-linked.pug", {guild, guild_id, spaces}) } const roles = guild.members?.find(m => m.user.id === botID)?.roles || [] @@ -191,7 +191,7 @@ as.router.get("/qr", defineEventHandler(async event => { // Permission problems if (!guild_id || !guild || !managed.has(guild_id) || !row) { - return pugSync.render(event, "guild_access_denied.pug", {guild_id, row}) + return pugSync.render(event, "guild-access-denied.pug", {guild_id, row}) } const nonce = randomUUID() From 63a6d37ec25504376a2b3d001f70632624718a13 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 4 Aug 2026 14:31:33 +1200 Subject: [PATCH 103/184] Support redirecting out of oauth --- src/web/auth.js | 2 +- src/web/routes/oauth.js | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/web/auth.js b/src/web/auth.js index c14dcd8d..45d308d1 100644 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -23,7 +23,7 @@ async function getManagedGuilds(event) { /** * @param {h3.H3Event} event - * @returns {ReturnType>} + * @returns {ReturnType>} */ function useSession(event) { return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60}) diff --git a/src/web/routes/oauth.js b/src/web/routes/oauth.js index f4bb61f5..fc31f971 100644 --- a/src/web/routes/oauth.js +++ b/src/web/routes/oauth.js @@ -17,7 +17,8 @@ const redirect_uri = `${reg.ooye.bridge_origin}/oauth` const schema = { first: z.object({ - action: z.string().optional() + action: z.string().optional(), + next: z.string().optional() }), code: z.object({ state: z.string(), @@ -53,10 +54,10 @@ function getOauth2Token(event) { as.router.get("/oauth", defineEventHandler(async event => { const session = await auth.useSession(event) + const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse) let scope = "guilds" if (!reg.ooye.web_password || reg.ooye.web_password === session.data.password) { - const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse) if (parsedFirstQuery.data?.action === "add") { scope = "bot+guilds" await session.update({selfService: false}) @@ -69,6 +70,9 @@ as.router.get("/oauth", defineEventHandler(async event => { async function tryAgain() { const newState = randomUUID() await session.update({state: newState}) + if (parsedFirstQuery.data?.next) { + await session.update({next: parsedFirstQuery.data?.next}) + } return sendRedirect(event, `https://discord.com/oauth2/authorize?client_id=${id}&scope=${scope}&permissions=${permissions}&response_type=code&redirect_uri=${redirect_uri}&state=${newState}`) } @@ -86,8 +90,10 @@ as.router.get("/oauth", defineEventHandler(async event => { const client = getClient(event)(parsedToken.access_token) const guilds = await client.user.getGuilds() - var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id) - await session.update({managedGuilds, userID, state: undefined}) + const managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id) + + const savedNext = session.data.next + await session.update({managedGuilds, userID, state: undefined, next: undefined}) // Set auto-create for the guild // @ts-ignore @@ -100,5 +106,5 @@ as.router.get("/oauth", defineEventHandler(async event => { return sendRedirect(event, getRelativePath(event.path, `/guild?guild_id=${parsedQuery.data.guild_id}`), 302) } - return sendRedirect(event, getRelativePath(event.path, "/"), 302) + return sendRedirect(event, getRelativePath(event.path, savedNext || "/"), 302) })) From 0034193953a5862d7da456d07bd609b0718ceb46 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 4 Aug 2026 14:32:40 +1200 Subject: [PATCH 104/184] Add privacy policy & opt out (requires contact) --- src/d2m/event-dispatcher.js | 2 + src/db/migrations/0040-opt-out.sql | 9 +++++ src/db/orm-defs.d.ts | 5 +++ src/types.d.ts | 1 + src/web/pug/includes/template.pug | 4 ++ src/web/pug/opt-out.pug | 31 ++++++++++++++ src/web/pug/privacy-policy.pug | 65 ++++++++++++++++++++++++++++++ src/web/routes/opt-out.js | 43 ++++++++++++++++++++ src/web/server.js | 7 +++- 9 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 src/db/migrations/0040-opt-out.sql create mode 100644 src/web/pug/opt-out.pug create mode 100644 src/web/pug/privacy-policy.pug create mode 100644 src/web/routes/opt-out.js diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index a49453ac..ef747555 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -297,6 +297,7 @@ module.exports = { */ async MESSAGE_CREATE(client, message) { if (message.author.username === "Deleted User") return // Nothing we can do for deleted users. + if (select("opt_out", "user_id", {user_id: message.author.id}).pluck().get()) return // This user opted out. const channel = client.channels.get(message.channel_id) if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. @@ -361,6 +362,7 @@ module.exports = { * @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data */ async MESSAGE_REACTION_ADD(client, data) { + if (select("opt_out", "user_id", {user_id: data.user_id}).pluck().get()) return // This user opted out. if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix. if (data.emoji.name === "❓" && select("event_message", "message_id", {message_id: data.message_id, source: 0, part: 0}).get()) { // source 0 = matrix const guild_id = data.guild_id ?? client.channels.get(data.channel_id)?.["guild_id"] diff --git a/src/db/migrations/0040-opt-out.sql b/src/db/migrations/0040-opt-out.sql new file mode 100644 index 00000000..094dff90 --- /dev/null +++ b/src/db/migrations/0040-opt-out.sql @@ -0,0 +1,9 @@ +BEGIN TRANSACTION; + +CREATE TABLE "opt_out" ( + "user_id" TEXT NOT NULL, + "opted_out_at" INTEGER NOT NULL, + PRIMARY KEY("user_id") +) WITHOUT ROWID; + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index 14f320a2..ff63d324 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -115,6 +115,11 @@ export type Models = { historical_room_index: number } + opt_out: { + user_id: string + opted_out_at: number + } + role_default: { guild_id: string role_id: string diff --git a/src/types.d.ts b/src/types.d.ts index f65ce59c..7b94e3c4 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -37,6 +37,7 @@ export type AppServiceRegistrationConfig = { time_zone?: string receive_presences: boolean plu_ral_api_key?: string + bridge_admin_contact?: string } old_bridge?: { as_token: string diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index 86680ebb..02e02f03 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -157,6 +157,10 @@ html(lang="en") //- Body .mx-auto.w100.wmx9.py24.px8.fs-body1#content block body + + if reg.ooye.bridge_admin_contact + aside.fs-body0.mt48.pt8.bt.bc-black-350.d-inline-block + a(href=rel("/privacy-policy")) Privacy policy //- Guild list popover script. document.querySelectorAll("[popovertarget]").forEach(e => { diff --git a/src/web/pug/opt-out.pug b/src/web/pug/opt-out.pug new file mode 100644 index 00000000..f7233f44 --- /dev/null +++ b/src/web/pug/opt-out.pug @@ -0,0 +1,31 @@ +extends includes/template.pug + +block body + if !session.data.userID + .s-empty-state.wmx4.p48 + != icons.Spots.SpotKeyXL + p Please log in to access the opt-out page. + .d-flex.jc-center.g8 + a.s-btn.s-btn__icon.s-btn__featured.s-btn__filled(href=rel("/oauth?next=/opt-out")) + != icons.Icons.IconDiscord + = ` Log in with Discord` + + else + .s-page-title.mb24 + h1.s-page-title--header Opt Out + + - let value = !select("opt_out", "user_id", {user_id: session.data.userID}).pluck().get() + + #opt-in-msg(hx-swap-oob="true") + if msg + .s-notice.s-notice__info.mb16= msg + else if !value + .s-notice.s-notice__warning.mb16 You are currently opted out. + + .s-card.d-grid.px0.g16 + form.d-flex.ai-center.g16 + #opt-in-loading.p8 + input.s-toggle-switch#opt-in(name="opt_in" type="checkbox" hx-post=rel("/api/opt-out") hx-indicator="#opt-in-loading" hx-disabled-elt="this" checked=value autocomplete="off" hx-swap="none") + label.s-label.fl-grow1(for="opt-in") + | Bridge my messages to Matrix + p.s-description Turn it off to opt out. This applies to all servers. diff --git a/src/web/pug/privacy-policy.pug b/src/web/pug/privacy-policy.pug new file mode 100644 index 00000000..adff8bd3 --- /dev/null +++ b/src/web/pug/privacy-policy.pug @@ -0,0 +1,65 @@ +extends includes/template.pug + +block body + .s-page-title.mb24 + h1.s-page-title--header Privacy Policy + p.s-page-title--description Last updated 4 August 2026 + + .s-prose + h2 Summary + + p Out Of Your Element bridges messages between Matrix and Discord. It does this by forwarding all the messages from one platform to the other. Your messages aren't used for any other purpose. + + p Matrix and Discord will store these messages until you delete them, but Out Of Your Element doesn't keep its own copy. + + h2 Important information about the Matrix network + + p The public Matrix Network is a decentralised and openly federated communication network. This means that user messages are replicated on each participant's homeserver, and messages posted to a room are visible to all participants, including new joiners. Anybody can run their own homeserver, so your messages may be widely replicated onto many servers. Out Of Your Element does not control these servers. + + p Out Of Your Element does endeavour to remove the corresponding data from all of these servers when you delete a particular message, opt out, or request erasure. Due to the nature of decentralised systems, deletion might not be immediate or thorough. For example, some servers could be offline and catch up later, or they may have backups of old data. + + p Please keep this in mind when using chatrooms with Out Of Your Element, or any other bridge software. + + h2 What data is collected + + p Data provided by you in the chatroom: Your messages, edits, uploaded files, username, profile picture, emojis, reactions, commands used, online indicator, typing indicator, and generally any other information written in chat will be copied to the other platform. This happens in all chatrooms where you see the Out Of Your Element bot. + + p There is also a non-identifiable #[a(href=rel("/api/stats")) counter of how many people and chatrooms are active]. + + h2 What data isn't collected + + p No tracking here. Out Of Your Element #[strong does not] automatically access or share your IP address, device fingerprint, location, read receipts, or your real-world identity. + + h2 How long data is stored for + + p Out Of Your Element doesn't store your message content directly. It only stores non-identifiable metadata, such as message IDs and room IDs. + + p However, the messages are stored forever by Discord and Matrix, or until you delete them. Your messages will remain in the chat even if you leave the chat later. + + p Your online indicator and typing indicator are only stored briefly to show your real-time status. They can't be viewed as history. + + h2 Who can access this data + + ul + li Anybody participating in the chatroom (on either platform) can see your messages attributed to your username + li Discord, Inc., and their hosting providers, can access that too + li People running a Matrix homeserver in the room, and their hosting providers, can access that too (see #[a(href="https://matrix.org/legal/privacy-notice/") Matrix.org privacy policy]) + li Your data isn't shared with any other third parties (no analytics or monitoring) + + h2 How to control access to your data + + p To remove data in a specific message, you can delete that message. Deletions are bridged across all platforms. Discord and Matrix will discard the message. + + p However, we can't stop people in the chatroom from independently saving or sharing a screenshot of your messages. + + h3 Broad opt-out for Discord account holders + p If you don't want any future messages to be bridged across platforms, #[a(href=rel("/opt-out")) opt out (of your element) here]. Keep in mind this could make conversations confusing because people will see different things. + p Your old messages will stick around, but if you want those deleted as well, please contact the operator !{reg.ooye.bridge_admin_contact} to request erasure. + + h2 Questions + + p Out Of Your Element is open source, freely available software that anyone can run. The person running this version you're using is probably a different person from the software author. + + p For questions about #[em your] data and how #[em this] version handles it, please contact the operator !{reg.ooye.bridge_admin_contact} to discuss. + + p For general questions and clarifications about how the software handles data more broadly, please contact #[a(href="https://cadence.moe/contact") the author of the Out Of Your Element software.] diff --git a/src/web/routes/opt-out.js b/src/web/routes/opt-out.js new file mode 100644 index 00000000..412e25a5 --- /dev/null +++ b/src/web/routes/opt-out.js @@ -0,0 +1,43 @@ +// @ts-check + +const assert = require("assert/strict") +const {z} = require("zod") +const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3") + +const {as, db, sync, select, discord} = require("../../passthrough") + +/** @type {import("../auth")} */ +const auth = sync.require("../auth") +/** @type {import("../pug-sync")} */ +const pugSync = sync.require("../pug-sync") + +const schema = { + optOut: z.object({ + opt_in: z.string().optional() // switch is reversed, you turn it on to opt in + }) +} + +as.router.post("/api/opt-out", defineEventHandler(async event => { + // CSRF prevention for a more important endpoint + if (!getRequestHeader(event, "HX-Request")) { + throw createError({status: 403, message: "Forbidden", data: "JavaScript is required for the opt-out form."}) + } + + const session = await auth.useSession(event) + if (!session.data.userID) { + throw createError({status: 401, message: "Unauthorised", data: "Log in first."}) + } + + const parsedBody = await readValidatedBody(event, schema.optOut.parse) + const isOptIn = !!parsedBody.opt_in + + if (isOptIn) { + db.prepare("DELETE FROM opt_out WHERE user_id = ?").run(session.data.userID) + var msg = "You have opted in." + } else { + db.prepare("INSERT OR IGNORE INTO opt_out (user_id, opted_out_at) VALUES (?, ?)").run(session.data.userID, Date.now()) + var msg = "You have opted out." + } + + return sendRedirect(event, `../opt-out?${new URLSearchParams({msg})}`, 302) +})) diff --git a/src/web/server.js b/src/web/server.js index e28060d8..c8d363dc 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -123,7 +123,11 @@ as.router.get("/icon.png", defineEventHandler(async event => { // Routes -pugSync.createRoute(as.router, "/ok", "ok.pug") +if (reg.reg.ooye.bridge_admin_contact) { + pugSync.createRoute(as.router, "/ok", "ok.pug") +} +pugSync.createRoute(as.router, "/opt-out", "opt-out.pug") +pugSync.createRoute(as.router, "/privacy-policy", "privacy-policy.pug") sync.require("./routes/download-matrix") sync.require("./routes/download-discord") @@ -134,5 +138,6 @@ sync.require("./routes/letter-avatar") sync.require("./routes/link") sync.require("./routes/log-in-with-matrix") sync.require("./routes/oauth") +sync.require("./routes/opt-out") sync.require("./routes/password") sync.require("./routes/stats") From f42d8a80ebd1df7e2e0a8667a80a54c1aefdd153 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 4 Aug 2026 14:34:21 +1200 Subject: [PATCH 105/184] Update dependency --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3a2231d3..7d067098 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1199,9 +1199,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { From 85b9e4743f1718399bdc18eb882ccc1c92ff0689 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 6 Aug 2026 16:28:50 +1200 Subject: [PATCH 106/184] Support more unnecessary room link percent-encodes --- src/m2d/converters/event-to-message.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 18b98483..ff46e9b9 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -384,7 +384,7 @@ function linkEndOfMessageSpriteSheet(content) { */ async function handleRoomOrMessageLinks(input, di) { let offset = 0 - for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!)[^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) { + for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!|%21)[^"/, ?)]+)(?:\/((?:\$|%24)[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) { assert(typeof match.index === "number") let [_, attributeValue, roomID, eventID, endMarker] = match let result From c7dbd1720919b8529e9bef34b80aec90818a7329 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 10 Aug 2026 19:43:14 +1200 Subject: [PATCH 107/184] Move embed via line to footer --- src/d2m/converters/edit-to-changes.test.js | 11 ++-- src/d2m/converters/message-to-event.js | 22 +++---- .../message-to-event.test.embeds.js | 59 +++++++++++++------ test/data.js | 54 +++++++++++++++++ 4 files changed, 113 insertions(+), 33 deletions(-) diff --git a/src/d2m/converters/edit-to-changes.test.js b/src/d2m/converters/edit-to-changes.test.js index 842c24e4..b78ee9e0 100644 --- a/src/d2m/converters/edit-to-changes.test.js +++ b/src/d2m/converters/edit-to-changes.test.js @@ -343,19 +343,18 @@ test("edit2changes: generated embed", async t => { t.deepEqual(eventsToSend, [{ $type: "m.room.message", msgtype: "m.notice", - body: "| via hthrflwrs on cohost" - + "\n| \n| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty" + body: "| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty" + "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:" + "\n| \n| * Both players draw eight cards" + "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand" - + "\n| * Both players present their best five-or-less-card pok...", + + "\n| * Both players present their best five-or-less-card pok..." + + "\n| via hthrflwrs on cohost", format: "org.matrix.custom.html", - formatted_body: `

hthrflwrs on cohost` - + `

This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO` + formatted_body: `

This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO` + `

1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:` + `

  • Both players draw eight cards` + `
  • Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand` - + `
  • Both players present their best five-or-less-card pok...

`, + + `
  • Both players present their best five-or-less-card pok...
  • hthrflwrs on cohost
    `, "m.mentions": {} }]) t.deepEqual(promotions, [{ diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index aeda572e..b96f645f 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -1055,15 +1055,6 @@ async function messageToEvent(message, guild, options = {}, di) { continue } - // Provider - if (embed.provider?.name && embed.provider.name !== "Tenor") { - if (embed.provider.url) { - rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`${embed.provider.name}`) - } else { - rep.addParagraph(`via ${embed.provider.name}`, tag`${embed.provider.name}`) - } - } - // Author and URL into a paragraph let authorNameText = embed.author?.name || "" if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image @@ -1113,7 +1104,18 @@ async function messageToEvent(message, guild, options = {}, di) { if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`) - if (embed.footer?.text) rep.addLine(`— ${embed.footer.text}`, tag`— ${embed.footer.text}`) + // Provider and footer (moved to bottom, below author/title, to improve room preview line) + if (embed.provider?.name && embed.provider.name !== "Tenor" && embed.provider.name !== "YouTube") { + const insertableFooterText = embed.footer?.text ? ` — ${embed.footer.text}` : "" + if (embed.provider.url) { + rep.addLine(`via ${embed.provider.name}${insertableFooterText || " " + embed.provider.url}`, tag`${embed.provider.name}${insertableFooterText}`) + } else { + rep.addLine(`via ${embed.provider.name}${insertableFooterText}`, tag`${embed.provider.name}${insertableFooterText}`) + } + } else if (embed.footer?.text) { + rep.addLine(`— ${embed.footer.text}`, tag`— ${embed.footer.text}`) + } + let {body, formatted_body: html} = rep.get() body = body.split("\n").map(l => "| " + l).join("\n") html = `
    ${html}
    ` diff --git a/src/d2m/converters/message-to-event.test.embeds.js b/src/d2m/converters/message-to-event.test.embeds.js index 91bbe2bc..3764c839 100644 --- a/src/d2m/converters/message-to-event.test.embeds.js +++ b/src/d2m/converters/message-to-event.test.embeds.js @@ -204,6 +204,34 @@ test("message2event embeds: author url without name", async t => { }]) }) +test("message2event embeds: provider and footer", async t => { + const events = await messageToEvent(data.message_with_embeds.embed_provider_and_footer, data.guild.general) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "https://cohost.org/jkap/post/4794219-empty", + format: "org.matrix.custom.html", + formatted_body: `https://cohost.org/jkap/post/4794219-empty`, + "m.mentions": {} + }, { + $type: "m.room.message", + msgtype: "m.notice", + body: "| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty" + + "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:" + + "\n| \n| * Both players draw eight cards" + + "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand" + + "\n| * Both players present their best five-or-less-card pok..." + + "\n| via hthrflwrs on cohost — 29/2/2024, 2:17 PM", + format: "org.matrix.custom.html", + formatted_body: `

    This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO` + + `

    1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:` + + `

    • Both players draw eight cards` + + `
    • Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand` + + `
    • Both players present their best five-or-less-card pok...

    hthrflwrs on cohost — 29/2/2024, 2:17 PM
    `, + "m.mentions": {} + }]) +}) + test("message2event embeds: 4 images", async t => { const events = await messageToEvent(data.message_with_embeds.four_images, data.guild.general) t.deepEqual(events, [{ @@ -254,9 +282,7 @@ test("message2event embeds: vx image", async t => { }, { $type: "m.room.message", msgtype: "m.notice", - body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix" - + "\n| " - + "\n| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875" + body: "| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875" + "\n| " + "\n| ## Tomorrow Corporation (@TomorrowCorp) https://vxtwitter.com/TomorrowCorp/status/1760330671074287875" + "\n| " @@ -266,15 +292,16 @@ test("message2event embeds: vx image", async t => { + "\n| " + "\n| 💖 123 🔁 36" + "\n| " - + "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg", + + "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg" + + "\n| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix", format: "org.matrix.custom.html", - formatted_body: `

    vxTwitter / fixvx` - + `

    Twitter` + formatted_body: `

    Twitter` + `

    Tomorrow Corporation (@TomorrowCorp)` + `

    Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and http://WorldOfGoo2.com (Win/Mac/Linux).` + `

    https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms` + `

    💖 123 🔁 36` - + `

    📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg

    `, + + `

    📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg

    ` + + `vxTwitter / fixvx
    `, "m.mentions": {} }]) }) @@ -291,16 +318,16 @@ test("message2event embeds: vx video", async t => { }, { $type: "m.room.message", msgtype: "m.notice", - body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix" - + "\n| \n| ## McDonald’s🤝@studiopierrot" + body: "| ## McDonald’s🤝@studiopierrot" + "\n| \n| 💖 89 🔁 21 https://twitter.com/McDonalds/status/1759971752254341417" + "\n| \n| ## McDonald's (@McDonalds) https://vxtwitter.com/McDonalds/status/1759971752254341417" - + "\n| \n| 🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12", + + "\n| \n| 🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12" + + "\n| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix", format: "org.matrix.custom.html", - formatted_body: `

    vxTwitter / fixvx` - + `

    McDonald’s🤝@studiopierrot\n\n💖 89 🔁 21` + formatted_body: `

    McDonald’s🤝@studiopierrot\n\n💖 89 🔁 21` + `

    McDonald's (@McDonalds)` - + `

    🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12

    `, + + `

    🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12

    ` + + `vxTwitter / fixvx
    `, "m.mentions": {} }]) }) @@ -317,8 +344,7 @@ test("message2event embeds: youtube video", async t => { }, { $type: "m.room.message", msgtype: "m.notice", - body: "| via YouTube https://www.youtube.com" - + "\n| \n| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg" + body: "| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg" + "\n| \n| ## Shoebill stork clattering sounds like machine guun~!! (Japan Matsue... https://www.youtube.com/watch?v=kDMHHw8JqLE" + "\n| \n| twitter" + "\n| https://twitter.com/matsuevogelpark" @@ -327,8 +353,7 @@ test("message2event embeds: youtube video", async t => { + "\n| \n| #shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun..." + "\n| \n| 🎞️ https://www.youtube.com/embed/kDMHHw8JqLE", format: "org.matrix.custom.html", - formatted_body: `

    YouTube

    ` - + `

    Happy O Funny` + formatted_body: `

    Happy O Funny` + `

    Shoebill stork clattering sounds like machine guun~!! (Japan Matsue...` + `

    twitter
    https://twitter.com/matsuevogelpark

    The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill
    some people also called them the living dinosaur~~

    #shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun...` + `

    🎞️ https://www.youtube.com/embed/kDMHHw8JqLE` diff --git a/test/data.js b/test/data.js index eab9a63b..8b6e0528 100644 --- a/test/data.js +++ b/test/data.js @@ -5296,6 +5296,60 @@ module.exports = { } } ] + }, + embed_provider_and_footer: { + type: 0, + content: "https://cohost.org/jkap/post/4794219-empty", + author: { + id: "113340068197859328", + username: "kumaccino", + avatar: "b48302623a12bc7c59a71328f72ccb39", + discriminator: "0", + public_flags: 128, + premium_type: 0, + flags: 128, + banner: null, + accent_color: null, + global_name: "kumaccino", + avatar_decoration_data: null, + banner_color: null + }, + attachments: [], + channel_id: "112760669178241024", + embeds: [ + { + color: 8594767, + description: "1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:\n\n * Both players draw eight cards\n * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand\n * Both players present their best five-or-less-card pok...", + provider: { + name: "hthrflwrs on cohost" + }, + footer: { + text: "29/2/2024, 2:17 PM" + }, + thumbnail: { + height: 1587, + placeholder: "GpoKP5BJZphshnhwmmmYlmh3l7+m+mwJ", + placeholder_version: 1, + proxy_url: "https://images-ext-2.discordapp.net/external/9vTXIzlXU4wyUZvWfmlmQkck8nGLUL-A090W4lWsZ48/https/staging.cohostcdn.org/avatar/292-6b64b03c-4ada-42f6-8452-109275bfe68d-profile.png", + url: "https://staging.cohostcdn.org/avatar/292-6b64b03c-4ada-42f6-8452-109275bfe68d-profile.png", + width: 1644 + }, + title: "This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO", + type: "link", + url: "https://cohost.org/jkap/post/4794219-empty" + } + ], + guild_id: "112760669178241024", + id: "1210387798297682020", + mentions: [], + mention_roles: [], + pinned: false, + mention_everyone: false, + tts: false, + timestamp: "2024-02-21T18:15:43.353000+00:00", + edited_timestamp: null, + flags: 0, + components: [] } }, message_with_components: { From db2c038f408ca75914711e80d6e8ba434daa07f7 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 11 Aug 2026 22:05:58 +1200 Subject: [PATCH 108/184] Support attachment spoiler flag --- package-lock.json | 6 +++--- src/d2m/converters/message-to-event.js | 13 ++++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d067098..986e6cae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1518,9 +1518,9 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.52", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.52.tgz", - "integrity": "sha512-uwe9EKfbjsmgWc2fdFjvDbj+dQqx3lp7wqDCmIha0jInuU+xeQjkCK9tMMn+p7RXfdVQORCInq4cD3U2ymDmyg==", + "version": "0.38.53", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.53.tgz", + "integrity": "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index b96f645f..c0b9622f 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) { @@ -122,7 +122,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { : attachment.content_type?.startsWith("audio/") ? "🎶" : "📄" // no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote - if (attachment.filename.startsWith("SPOILER_")) { + if (attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler)) { return { $type: "m.room.message", "m.mentions": mentions, @@ -907,7 +907,14 @@ async function messageToEvent(message, guild, options = {}, di) { /** @type {{[k in keyof DiscordTypes.APIUnfurledMediaItem]-?: NonNullable}} */ // @ts-ignore const file = component.file assert(component.name && component.size && file.content_type) - const ev = await attachmentToEvent({}, {...file, filename: component.name, size: component.size}, true) + const ev = await attachmentToEvent({}, { + filename: component.name, + content_type: file.content_type, + size: component.size, + url: file.url, + height: file.height, + width: file.width, + }, true) stack.msb.addLine(ev.body, ev.formatted_body) } else if (component.type === DiscordTypes.ComponentType.MediaGallery) { From b7fa7c3e2ddd865933b02434f8a949235a2278b9 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 14 Aug 2026 09:50:38 +1200 Subject: [PATCH 109/184] Map PluralKit colour to MSC4522 colour --- src/d2m/actions/register-pk-user.js | 7 +++++++ src/d2m/actions/register-user.js | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js index 4946d4fe..e68d2cc0 100644 --- a/src/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -103,6 +103,7 @@ async function memberToStateContent(pkMessage, author) { // We prefer to use the member's avatar URL data since the image upload can be cached across channels, // unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL. const avatar = pkMessage.member.avatar_url || pkMessage.member.webhook_avatar_url || pkMessage.system.avatar_url || file.userAvatar(author) + const color = pkMessage.member.color ?? pkMessage.system.color const content = { displayname: author.username, @@ -110,6 +111,12 @@ async function memberToStateContent(pkMessage, author) { "moe.cadence.ooye.pk_member": pkMessage.member } if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar) + if (color) { + content["eu.she-a.color"] = { + on_dark: `#${color}`, + on_light: `#${color}` + } + } return content } diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js index d475e54d..bd10d5eb 100644 --- a/src/d2m/actions/register-user.js +++ b/src/d2m/actions/register-user.js @@ -194,7 +194,9 @@ function memberToPowerLevel(user, member, guild, channel) { * @param {number} powerLevel */ function _hashProfileContent(content, powerLevel) { - const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}\u0000${powerLevel}`) + let toHash = `${content.displayname}\u0000${content.avatar_url}\u0000${powerLevel}` + if (content["eu.she-a.color"]?.on_dark) toHash += `\u0000${content["eu.she-a.color"].on_dark}` + const unsignedHash = hasher.h64(toHash) const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range return signedHash } @@ -243,6 +245,7 @@ async function syncUser(user, member, channel, guild, roomID, interactionMetadat async function _sendSyncUser(roomID, mxid, content, powerLevel, options) { const currentHash = _hashProfileContent(content, powerLevel ?? 0) const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() + console.log(roomID, mxid, existingHash, currentHash) // only do the actual sync if the hash has changed since we last looked const hashHasChanged = existingHash !== currentHash // always okay to add new data. for overwriting, restrict based on options.allowOverwrite, if present From 7229206d09b007059407f5b52407220cec195d6a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 14 Aug 2026 09:51:00 +1200 Subject: [PATCH 110/184] Fix error when forwarding a crosspost lacks author --- src/d2m/converters/message-to-event.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index c0b9622f..79c02981 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -641,8 +641,7 @@ async function messageToEvent(message, guild, options = {}, di) { html = "* " + html } - const flags = message.flags || 0 - if (flags & DiscordTypes.MessageFlags.IsCrosspost) { + if (((message.flags || 0) & DiscordTypes.MessageFlags.IsCrosspost) && message.author?.username) { body = `[↷ ${message.author.username}]\n` + body html = `↷ ${message.author.username}
    ` + html } From b3ef973568cd6bee817a8bc6368160bb39e7784b Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 14 Aug 2026 16:52:51 +1200 Subject: [PATCH 111/184] Add noscript notice to opt-out page --- src/web/pug/opt-out.pug | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/web/pug/opt-out.pug b/src/web/pug/opt-out.pug index f7233f44..3cb21147 100644 --- a/src/web/pug/opt-out.pug +++ b/src/web/pug/opt-out.pug @@ -16,6 +16,9 @@ block body - let value = !select("opt_out", "user_id", {user_id: session.data.userID}).pluck().get() + noscript + .s-notice.s-notice__danger.mb16 You must enable JavaScript to use this form. Please do that now! + #opt-in-msg(hx-swap-oob="true") if msg .s-notice.s-notice__info.mb16= msg From 674cd532cdcaa58bb78268d04c326bede25fd364 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 16 Aug 2026 22:32:10 +1200 Subject: [PATCH 112/184] Fix m->d poll questions over 45 characters --- src/discord/interactions/poll.js | 4 ++-- src/m2d/converters/poll-components.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/discord/interactions/poll.js b/src/discord/interactions/poll.js index 6d7a0153..7190d63d 100644 --- a/src/discord/interactions/poll.js +++ b/src/discord/interactions/poll.js @@ -55,10 +55,10 @@ async function* _interact({data, message, member, user}, {api}) { title: "Poll", components: [{ type: DiscordTypes.ComponentType.TextDisplay, - content: `-# ${pollComponents.getMultiSelectString(pollRow.max_selections, options.length)}` + content: `## ${pollRow.question_text}` }, { type: DiscordTypes.ComponentType.Label, - label: pollRow.question_text, + label: pollComponents.getMultiSelectString(pollRow.max_selections, options.length), component: { type: DiscordTypes.ComponentType.RadioGroup, custom_id: "POLL_MODAL_SELECTION", diff --git a/src/m2d/converters/poll-components.js b/src/m2d/converters/poll-components.js index a8233e08..67953f91 100644 --- a/src/m2d/converters/poll-components.js +++ b/src/m2d/converters/poll-components.js @@ -14,7 +14,7 @@ const setupEmojis = sync.require("../actions/setup-emojis") */ function getMedal(topAnswers, count) { const winningOrTied = count && topAnswers[0].count === count - const secondOrTied = !winningOrTied && count && topAnswers[1]?.count === count && topAnswers.slice(-1)[0].count !== count + const secondOrTied = !winningOrTied && count && topAnswers[1]?.count === count && topAnswers.slice(-1)[0].count !== count const thirdOrTied = !winningOrTied && !secondOrTied && count && topAnswers[2]?.count === count && topAnswers.slice(-1)[0].count !== count const medal = ( winningOrTied ? "🥇" From c0c6419bd1f67f1878ff1577ef61f557deac2ae6 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 16 Aug 2026 22:37:01 +1200 Subject: [PATCH 113/184] Remove mx-reply earlier to skip tree walking it --- src/m2d/converters/event-to-message.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index ff46e9b9..c4f72a08 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -900,6 +900,12 @@ async function eventToMessage(event, guild, channel, di) { assert(root) async function forEachNode(event, node) { for (; node; node = node.nextSibling) { + // Don't process mx-reply further + if (node.nodeType === 1 && node.tagName === "MX-REPLY") { + node.remove() + continue + } + // Check written mentions if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) { const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di) From 1dcd5620c43a6bd1767aa4eae6c55f8abb4ca813 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 17 Aug 2026 17:30:06 +1200 Subject: [PATCH 114/184] SnowTransfer error handling is no longer pathetic --- src/d2m/actions/update-pins.js | 2 +- src/d2m/converters/message-to-event.js | 2 +- src/d2m/event-dispatcher.js | 2 +- src/db/migrations/0037-remove-leaked-webhooks.js | 2 +- src/m2d/actions/channel-webhook.js | 2 +- src/matrix/matrix-command-handler.js | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/d2m/actions/update-pins.js b/src/d2m/actions/update-pins.js index 56c9642f..0ecbed0a 100644 --- a/src/d2m/actions/update-pins.js +++ b/src/d2m/actions/update-pins.js @@ -28,7 +28,7 @@ async function updatePins(channelID, roomID, convertedTimestamp) { try { var discordPins = await discord.snow.channel.getChannelPinnedMessages(channelID) } catch (e) { - if (e.message === `{"message": "Missing Access", "code": 50001}`) { + if (e.message === "Missing Access") { return // Discord sends channel pins update events even for channels that the bot can't view/get pins in, just ignore it } throw e diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 79c02981..1f055e38 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -834,7 +834,7 @@ async function messageToEvent(message, guild, options = {}, di) { invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) } catch (e) { // Skip expired/invalid invites and events - if (e.message === `{"message": "Unknown Invite", "code": 10006}`) { + if (e.message === "Unknown Invite") { break } else { throw e diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index ef747555..02cb20fa 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -107,7 +107,7 @@ module.exports = { try { 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 + if (e.message === "Missing Access") { console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`) continue // Sucks. } else { diff --git a/src/db/migrations/0037-remove-leaked-webhooks.js b/src/db/migrations/0037-remove-leaked-webhooks.js index 02280533..b839bd79 100644 --- a/src/db/migrations/0037-remove-leaked-webhooks.js +++ b/src/db/migrations/0037-remove-leaked-webhooks.js @@ -27,7 +27,7 @@ module.exports = async function(db) { 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}`) { + if (e.message === "Unknown Webhook") { // OK } else { throw e diff --git a/src/m2d/actions/channel-webhook.js b/src/m2d/actions/channel-webhook.js index 09c642bb..01b3dd4a 100644 --- a/src/m2d/actions/channel-webhook.js +++ b/src/m2d/actions/channel-webhook.js @@ -45,7 +45,7 @@ async function ensureWebhook(channelID, forceCreate = false) { async function withWebhook(channelID, callback) { const webhook = await ensureWebhook(channelID, false) return callback(webhook).catch(async e => { - if (e.message === `{"message": "Unknown Webhook", "code": 10015}`) { // pathetic error handling from SnowTransfer + if (e.message === "Unknown Webhook") { // Our webhook is gone. Maybe somebody deleted it, or removed and re-added OOYE from the guild. const newWebhook = await ensureWebhook(channelID, true) return callback(newWebhook) // not caught; if the error happens again just throw it instead of looping diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index b38b4b12..aa80b9c3 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -296,7 +296,7 @@ const commands = [{ try { var invite = await discord.snow.channel.createChannelInvite(channelID) } catch (e) { - if (e.message === `{"message": "Missing Permissions", "code": 50013}`) { + if (e.message === "Missing Permissions") { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", From b9be8b5887475abd7507751d2958dd26929082af Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 19 Aug 2026 20:40:45 +1200 Subject: [PATCH 115/184] Remove mistaken logging --- src/d2m/actions/register-user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js index bd10d5eb..3427bbd8 100644 --- a/src/d2m/actions/register-user.js +++ b/src/d2m/actions/register-user.js @@ -245,7 +245,7 @@ async function syncUser(user, member, channel, guild, roomID, interactionMetadat async function _sendSyncUser(roomID, mxid, content, powerLevel, options) { const currentHash = _hashProfileContent(content, powerLevel ?? 0) const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() - console.log(roomID, mxid, existingHash, currentHash) + // console.log(roomID, mxid, existingHash, currentHash) // only do the actual sync if the hash has changed since we last looked const hashHasChanged = existingHash !== currentHash // always okay to add new data. for overwriting, restrict based on options.allowOverwrite, if present From 0d398be60acd2ef0008f822fd8ecdce3dc4b06a0 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 19 Aug 2026 20:42:50 +1200 Subject: [PATCH 116/184] Fix logging in to Matrix on web --- start.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/start.js b/start.js index 51942c67..2ab79cbe 100755 --- a/start.js +++ b/start.js @@ -14,6 +14,10 @@ const sync = new HeatSync({watchFunction: fs.watchFile}) Object.assign(passthrough, {sync, db}) +const orm = sync.require("./src/db/orm") +passthrough.from = orm.from +passthrough.select = orm.select + const DiscordClient = require("./src/d2m/discord-client") const discord = new DiscordClient(reg.ooye.discord_token) @@ -22,10 +26,6 @@ passthrough.discord = discord const {as} = require("./src/matrix/appservice") passthrough.as = as -const orm = sync.require("./src/db/orm") -passthrough.from = orm.from -passthrough.select = orm.select - const power = require("./src/matrix/power.js") sync.require("./src/m2d/event-dispatcher") From 2e84c71d9683f8dcb3d054cd1c90efff877123b0 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 19 Aug 2026 22:21:46 +1200 Subject: [PATCH 117/184] Fix creating webhooks for matrix users --- src/d2m/actions/speedbump.js | 2 ++ src/d2m/converters/message-to-event.test.js | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/d2m/actions/speedbump.js b/src/d2m/actions/speedbump.js index 88cea075..6e62541b 100644 --- a/src/d2m/actions/speedbump.js +++ b/src/d2m/actions/speedbump.js @@ -129,6 +129,8 @@ function onMessageDelete(messageID) { * @param {string} roomID */ async function getWebhookSenderId(message, guildID, roomID) { + const isMatrixWebhook = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get() + if (isMatrixWebhook) return null const speedbumpUserID = select("channel_speedbump", "speedbump_user_id", {channel_id: message.channel_id, speedbump_webhook_id: message.webhook_id}).pluck().get() const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guildID}).pluck().get() ?? 0 if (speedbumpUserID === "466378653216014359") { // PluralKit public instance diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index be1d99f0..0742c7b1 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1612,7 +1612,7 @@ test("message2event: expired/invalid invites are sent as-is", async t => { snow: { invite: { async getInvite() { - throw new Error(`{"message": "Unknown Invite", "code": 10006}`) + throw new Error(`Unknown Invite`) } } } From cf12293d5ce73aff11f3f12c8ad3961e25bc057d Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 20 Aug 2026 12:57:35 +1200 Subject: [PATCH 118/184] Allow anyone to respond to polls --- src/d2m/actions/create-room.js | 1 + src/d2m/actions/create-room.test.js | 1 + test/data.js | 1 + 3 files changed, 3 insertions(+) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 7f110ad6..a6230b2e 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -163,6 +163,7 @@ async function channelToKState(channel, guild, di) { events_default: everyoneCanSend ? 0 : READ_ONLY_ROOM_EVENTS_DEFAULT_POWER, events: { "m.reaction": 0, + "org.matrix.msc3381.poll.response": 0, "m.room.redaction": 0, // only affects redactions of own events, required to be able to un-react ...pollStartPowerLevel }, diff --git a/src/d2m/actions/create-room.test.js b/src/d2m/actions/create-room.test.js index c9e098bb..fec5c557 100644 --- a/src/d2m/actions/create-room.test.js +++ b/src/d2m/actions/create-room.test.js @@ -147,6 +147,7 @@ test("channel2room: read-only discord channel", async t => { events_default: 50, // <-- it should be read-only! events: { "m.reaction": 0, + "org.matrix.msc3381.poll.response": 0, "m.room.redaction": 0 }, notifications: { diff --git a/test/data.js b/test/data.js index 8b6e0528..60e9bd88 100644 --- a/test/data.js +++ b/test/data.js @@ -144,6 +144,7 @@ module.exports = { events_default: 0, events: { "m.reaction": 0, + "org.matrix.msc3381.poll.response": 0, "m.room.redaction": 0 }, users: { From c7389ff2d6feb1c33a7f5963984c468691dc1b72 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 23 Aug 2026 02:58:00 +1200 Subject: [PATCH 119/184] Improve d->m highlighting for toki pona community --- src/d2m/converters/find-mentions.js | 11 +++++-- src/d2m/converters/find-mentions.test.js | 41 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/d2m/converters/find-mentions.js b/src/d2m/converters/find-mentions.js index 8107459e..5135d172 100644 --- a/src/d2m/converters/find-mentions.js +++ b/src/d2m/converters/find-mentions.js @@ -107,7 +107,6 @@ function tokenise(name) { * @returns {ProcessedJoined} */ function processJoined(joined) { - joined = joined.filter(j => !userRegex.some(rx => j.mxid.match(rx))) return { mxids: joined.map(j => { const localpart = j.mxid.match(/@([^:]*)/) @@ -141,8 +140,14 @@ function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) { /** @type {{mxid: string, scored: {score: number, matchedInputTokens: Token[]}}[]} */ let allItems = pjr.mxids.map(mxid => ({...mxid, scored: scoreLocalpart(mxid.localpart, maximumWrittenSection, mxid.displayname)})) allItems = allItems.concat(pjr.names.map(name => ({...name, scored: scoreName(name.displaynameTokens, maximumWrittenSectionTokens)}))) - const best = allItems.sort((a, b) => b.scored.score - a.scored.score)[0] - if (best.scored.score > 4) { // requires in smallest case perfect match of 2 characters, or in largest case a partial middle match of 5+ characters in a row + // Tiebreaker bonus for Matrix users + for (const item of allItems) { + if (!userRegex.some(rx => item.mxid.match(rx))) item.scored.score += 1 + } + const [best, secondBest] = allItems.sort((a, b) => b.scored.score - a.scored.score) + if (userRegex.some(rx => best.mxid.match(rx))) return // Discord user was best match, so it was just a written non-pinging @ for a Discord user. Don't highlight a Matrix user. + if (secondBest && secondBest.scored.score === best.scored.score) return // all tied up + if (best.scored.score > 5) { // requires in smallest case Matrix user tiebreaker + perfect match of 2 characters, or in largest case a partial middle match of 5+ characters in a row // Highlight the relevant part of the message const start = baseOffset + best.scored.matchedInputTokens[0].index const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end diff --git a/src/d2m/converters/find-mentions.test.js b/src/d2m/converters/find-mentions.test.js index 8f2be094..d0a3de90 100644 --- a/src/d2m/converters/find-mentions.test.js +++ b/src/d2m/converters/find-mentions.test.js @@ -117,6 +117,47 @@ test("score name: finds match location", t => { t.equal(message.slice(startLocation, endLocation), "evil lillith") }) +test("score name: matches the correct jan", t => { + const names = [ + "jan Kanoli", + "jan Mijawi", + "jan Mukin" + ] + t.deepEqual(names.map(n => scoreName(tokenise(n), tokenise("jan Mijawi")).score), [ + 6, + 18.4, + 6 + ]) +}) + +test("find mention: highlights nothing if a Discord user scored highest", t => { + const found = findMention(processJoined([{ + mxid: "@jankanoli:matrix.org", + displayname: "jan Kanoli" + }, { + mxid: "@_ooye_jan_mijawi:cadence.moe", + displayname: "jan Mijawi" + }, { + mxid: "@_ooye_jan_mukin:cadence.moe", + displayname: "jan Mukin" + }]), "jan Mijawi, run this\n.def", 0, "@", "jan Mijawi, run this\n.def") + t.equal(found, undefined) +}) + +test("find mention: highlights nothing if all tied", t => { + const found = findMention(processJoined([{ + mxid: "@jankanoli:matrix.org", + displayname: "jan Kanoli" + }, { + mxid: "@_ooye_jan_mijawi:cadence.moe", + displayname: "jan Mijawi" + }, { + mxid: "@_ooye_jan_mukin:cadence.moe", + displayname: "jan Mukin" + }]), "jan Misali, run this\n.def", 0, "@", "jan Misali, run this\n.def") + t.equal(found, undefined) +}) + test("find mention: test various tiebreakers", t => { const found = findMention(processJoined([{ mxid: "@emma:conduit.rory.gay", From 9b01e522cc4dc4533f4c1c6a69a2914525057464 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 5 Sep 2026 11:53:24 +1200 Subject: [PATCH 120/184] Discord default file size limit is now 20 MB --- src/m2d/converters/event-to-message.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index c4f72a08..cff08f0e 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -509,7 +509,7 @@ function getFileSizeForGuild(guild) { if (fileSizeFeature) { return fileSizeFeature * 1024 * 1024 // discord uses big megabytes } else { - return 10 * 1024 * 1024 // default file size is 10 MB + return 20 * 1024 * 1024 // default file size is 20 MB } } From 70b18c8833a76df4f6b4d729a43d5eafc916d382 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 8 Sep 2026 18:40:54 +1200 Subject: [PATCH 121/184] Guard against hopefully impossible situation --- src/d2m/converters/message-to-event.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 1f055e38..619588ec 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -1168,7 +1168,7 @@ async function messageToEvent(message, guild, options = {}, di) { } // Rich replies - if (repliedToEventRow && !repliedToEventInDifferentRoom) { + if (repliedToEventRow && !repliedToEventInDifferentRoom && events[0]) { Object.assign(events[0], { "m.relates_to": { "m.in_reply_to": { From 82f921caee27bbe34fe904de14117bfa4107c069 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 8 Sep 2026 18:41:07 +1200 Subject: [PATCH 122/184] Make poll vote counters more verbose --- src/m2d/converters/poll-components.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/m2d/converters/poll-components.js b/src/m2d/converters/poll-components.js index 67953f91..03ac3db2 100644 --- a/src/m2d/converters/poll-components.js +++ b/src/m2d/converters/poll-components.js @@ -34,6 +34,7 @@ function optionsToComponents(isClosed, pollOptions) { /** @type {DiscordTypes.APIMessageTopLevelComponent[]} */ return pollOptions.map(option => { const medal = getMedal(topAnswers, option.count) + const label = option.count === 1 ? `${option.count} vote\u2002\u200b` : `${option.count} votes` return { type: DiscordTypes.ComponentType.Container, components: [{ @@ -45,7 +46,7 @@ function optionsToComponents(isClosed, pollOptions) { accessory: { type: DiscordTypes.ComponentType.Button, style: medal === "🥇" && isClosed ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary, - label: option.count.toString(), + label: label, custom_id: `POLL_OPTION#${option.matrix_option}`, disabled: isClosed } From 1bb22e315a47567300ea643b2fb8565b8e86814a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 8 Sep 2026 23:53:07 +1200 Subject: [PATCH 123/184] Fix m->d m.mentions for webhooks --- src/m2d/converters/event-to-message.js | 10 ++-- src/m2d/converters/event-to-message.test.js | 57 +++++++++++++++++++++ test/ooye-test-data.sql | 4 +- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index cff08f0e..c014a3ef 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -1051,11 +1051,11 @@ async function eventToMessage(event, guild, channel, di) { // Combine requested mentions with detected written mentions to get the full list if (Array.isArray(event.content["m.mentions"].user_ids)) { for (const mxid of event.content["m.mentions"].user_ids) { - const user_id = select("sim", "user_id", {mxid}).pluck().get() - if (!user_id) continue - allowedMentionsUsers.push( - select("sim_proxy", "proxy_owner_id", {user_id}).pluck().get() || user_id - ) + let user_id = select("sim", "user_id", {mxid}).pluck().get() + if (!user_id) continue // only for discord users + user_id = select("sim_proxy", "proxy_owner_id", {user_id}).pluck().get() || user_id // convert to sim proxy owner's ID + if (!user_id.match(/^[0-9]+$/)) continue // mentioned sim does not have a real discord user ID (i.e. it's a webhook) + allowedMentionsUsers.push(user_id) } } // Specific mentions were requested, so do not parse users diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 5dd0af7f..6b227731 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -1711,6 +1711,63 @@ test("event2message: rich reply to a sim user, explicitly disabling mentions in ) }) +test("event2message: rich reply to a webhook with m.mentions", async t => { + t.deepEqual( + await eventToMessage({ + "type": "m.room.message", + "sender": "@cadence:cadence.moe", + "content": { + "msgtype": "m.text", + "body": "> <@_ooye_webhook_spidey_bot:cadence.moe:cadence.moe> Slow news day.\n\nTesting this reply, ignore", + "format": "org.matrix.custom.html", + "formatted_body": "

    In reply to @_ooye_webhook_spidey_bot:cadence.moe:cadence.moe
    Slow news day.
    Testing this reply, ignore", + "m.relates_to": { + "m.in_reply_to": { + "event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04" + } + }, + "m.mentions": { + user_ids: ["@_ooye_webhook_spidey_bot:cadence.moe"] + } + }, + "origin_server_ts": 1693029683016, + "unsigned": { + "age": 91, + "transaction_id": "m1693029682894.510" + }, + "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", + "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" + }, data.guild.general, data.channel.general, { + api: { + getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { + type: "m.room.message", + content: { + msgtype: "m.text", + body: "Slow news day." + }, + sender: "@_ooye_webhook_spidey_bot:cadence.moe" + }) + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 **@Spidey Bot**:" + + " Slow news day." + + "\nTesting this reply, ignore", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", + allowed_mentions: { + parse: ["roles"], + users: [] + } + }] + } + ) +}) + test("event2message: rich reply to a rich reply to a multi-line message should correctly strip reply fallback", async t => { t.deepEqual( await eventToMessage({ diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 16623203..524e1fe7 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -41,8 +41,8 @@ INSERT INTO sim (user_id, username, sim_name, mxid) VALUES ('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe'), ('196188877885538304', 'ampflower', 'ampflower', '@_ooye_ampflower:cadence.moe'), ('1458668878107381800', 'Evil Lillith (she/her)', 'evil_lillith_sheher', '@_ooye_evil_lillith_sheher:cadence.moe'), -('197126718400626689', 'infinidoge1337', 'infinidoge1337', '@_ooye_infinidoge1337:cadence.moe'); - +('197126718400626689', 'infinidoge1337', 'infinidoge1337', '@_ooye_infinidoge1337:cadence.moe'), +('webhook_spidey_bot', 'Spidey Bot', 'webhook_spidey_bot', '@_ooye_webhook_spidey_bot:cadence.moe'); INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES ('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL), From 7faa8a3c9e29bbef93bf2e7c31c76c6011ef9167 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 11 Sep 2026 12:47:15 +1200 Subject: [PATCH 124/184] Remove unwise nextTick delay --- src/d2m/discord-client.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index b9b45e21..408c21c8 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -55,9 +55,7 @@ class DiscordClient { this.guildChannelMap = new Map() if (listen !== "no") { this.cloud.on("event", message => { - process.nextTick(() => { - discordPackets.onPacket(this, message, listen) - }) + discordPackets.onPacket(this, message, listen) }) } From 5e9f3cd532e325f869b26d3a8e74c011b1415b40 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 11 Sep 2026 12:51:29 +1200 Subject: [PATCH 125/184] Add retries back to PluralKit API --- src/d2m/actions/register-pk-user.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js index e68d2cc0..bb8335b4 100644 --- a/src/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -3,6 +3,7 @@ const assert = require("assert").strict const {reg} = require("../../matrix/read-registration") const Ty = require("../../types") +const {scheduler} = require("timers/promises") const passthrough = require("../../passthrough") const {sync, db, select, from} = passthrough @@ -14,14 +15,21 @@ const file = sync.require("../../matrix/file") const registerUser = sync.require("./register-user") /** @returns {Promise} */ -async function fetchMessage(messageID) { +async function fetchMessage(messageID, attempt = 1) { try { var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) } catch (networkError) { // Network issue, raise a more readable message throw new Error(`Failed to connect to PK API: ${networkError.toString()}`) } - if (!res.ok) throw new Error(`PK API returned an error: ${await res.text()}`) + if (!res.ok) { + if (attempt < 2) { + await scheduler.wait(5000) + return fetchMessage(messageID, attempt + 1) + } else { + throw new Error(`PK API returned an error: ${await res.text()}`) + } + } /** @type {any} */ const root = await res.json() if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`) From cf95f7d915cc9edf859b397de22fdf1fd11fba8e Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 11 Sep 2026 12:57:46 +1200 Subject: [PATCH 126/184] Add AI policy --- readme.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/readme.md b/readme.md index e8a8e7e7..deb98da1 100644 --- a/readme.md +++ b/readme.md @@ -8,6 +8,10 @@ Modern Matrix-to-Discord appservice bridge, created by [@cadence:cadence.moe](ht ![](https://cadence.moe/i/f42a3f) +## AI policy + +Out Of Your Element is 100% organically cultivated ethical hand-fed code. Let's work together to conserve that! + ## Why a new bridge? * Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership. From 965fcab646dd3fb1eb1f63994a364541d8ba3719 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 11 Sep 2026 20:07:36 +1200 Subject: [PATCH 127/184] m->d edits now wait for original message --- src/d2m/actions/retrigger.js | 10 +++++----- src/m2d/actions/send-event.js | 2 +- src/m2d/converters/event-to-message.js | 3 +++ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/d2m/actions/retrigger.js b/src/d2m/actions/retrigger.js index 43f400dd..d7ac1a6e 100644 --- a/src/d2m/actions/retrigger.js +++ b/src/d2m/actions/retrigger.js @@ -41,7 +41,7 @@ const storage = new class { this.timers.set(id, setTimeout(() => this.resolve(id, false), 60 * 1000).unref()) // 1 minute } } - + /** @param {string} id */ isNotPaused(id) { return !storage.paused.has(id) @@ -98,7 +98,7 @@ function waitFor(id, resolve, existsInDatabase) { 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 + * @returns {Promise} if false then the message did not arrive */ function waitForEvent(eventID) { const {promise, resolve} = Promise.withResolvers() @@ -109,7 +109,7 @@ function waitForEvent(eventID) { 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 + * @returns {Promise} if false then the message did not arrive */ function waitForMessage(messageID) { const {promise, resolve} = Promise.withResolvers() @@ -120,7 +120,7 @@ function waitForMessage(messageID) { 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 + * @returns {Promise} if false then the message did not arrive */ function waitForReactionEvent(eventID) { const {promise, resolve} = Promise.withResolvers() @@ -156,4 +156,4 @@ 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 +module.exports.finishedBridging = finishedBridging diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index 00557a11..2e6bf5cd 100644 --- a/src/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -153,7 +153,7 @@ async function sendEvent(event) { channel_id: messageResponse.channel_id, guild_id: guild.id, embeds: messageResponse.embeds - }, guild, null) + }, guild) ) } } diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index c014a3ef..a017e99c 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -32,6 +32,8 @@ const setupEmojis = sync.require("../actions/setup-emojis") const userToMxid = sync.require("../../d2m/converters/user-to-mxid") /** @type {import("../../web/routes/letter-avatar")} */ const letterAvatar = sync.require("../../web/routes/letter-avatar") +/** @type {import("../../d2m/actions/retrigger")} */ +const retrigger = sync.require("../../d2m/actions/retrigger") /** @type {[RegExp, string][]} */ const markdownEscapes = [ @@ -696,6 +698,7 @@ async function eventToMessage(event, guild, channel, di) { // Check if we have a pointer to what was edited const originalEventId = relatesTo.event_id if (!originalEventId) return + if (!await retrigger.waitForEvent(originalEventId)) return messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all() if (!messageIDsToEdit.length) return From c71120e463a43c113338265d1d1e902bfdb0f935 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 11 Sep 2026 23:27:39 +1200 Subject: [PATCH 128/184] Better Discord startup checking --- src/d2m/discord-packets.js | 75 ++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/src/d2m/discord-packets.js b/src/d2m/discord-packets.js index 988f981c..992689ff 100644 --- a/src/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -1,6 +1,6 @@ // @ts-check -const assert = require("assert") +const assert = require("assert").strict const {scheduler} = require("timers/promises") const passthrough = require("../passthrough") const {sync} = passthrough @@ -8,7 +8,32 @@ const {sync} = passthrough /** @type {import("../matrix/homeserver-status")} */ const homeserverStatus = sync.require("../matrix/homeserver-status") -let checkedHomeserver = false +const guildReadyStatus = new class { + /** @type {Set | null} */ + unavailableGuilds = null + _allReady = Promise.withResolvers() + + /** + * @param {string} guildID + * @returns {boolean} true if it was the last one + */ + makeReady(guildID) { + assert(this.unavailableGuilds) + if (this.unavailableGuilds.delete(guildID) && this.unavailableGuilds.size === 0) { + this._allReady.resolve(null) + return true + } + return false + } + + allReady() { + return this.unavailableGuilds && this.unavailableGuilds.size === 0 + } + + waitForAllReady() { + return this._allReady.promise + } +} /** * @param {import("./discord-client")} client @@ -28,12 +53,27 @@ async function onPacket(client, message, listen) { client.ready = true client.user = message.d.user client.application = message.d.application + guildReadyStatus.unavailableGuilds = new Set(message.d.guilds.filter(g => g.unavailable).map(g => g.id)) console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) + process.stdout.write("Waiting for guilds to warm up... ") interactions.registerInteractions() } else if (message.t === "GUILD_CREATE") { - message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web + message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot account's member - it's needed for roles to determine private channels on web client.guilds.set(message.d.id, message.d) + + /* + Info about guilds is populated one guild at a time. + For m->d bridging to work, the guild needs to be populated, so we need to have GUILD_CREATE for the guild. + If we ping the homeserver, it will send us any pending events, so we need to wait for all GUILD_CREATES before we ping. + We must attempt a ping because we don't want to try sending missed d->m messages to an offline homeserver. + The "all guilds ready" delay can be removed if ONE of the following is done: + 1. m->d can queue incoming events until their guild exists in memory + 2. d->m missed messages can have their errors handled and added to queue, rather than pinging first + */ + const firstReady = guildReadyStatus.allReady() + const lastGuildReady = guildReadyStatus.makeReady(message.d.id) + const arr = [] client.guildChannelMap.set(message.d.id, arr) for (const channel of message.d.channels || []) { @@ -51,28 +91,14 @@ async function onPacket(client, message, listen) { if (listen === "full") { try { - /* - Info about guilds is populated one guild at a time. - For m->d bridging to work, the guild needs to be populated, so we need to have GUILD_CREATE for the guild. - If we ping the homeserver, it will send us any pending events, so we need to wait for all GUILD_CREATES before we ping. - We must attempt a ping because we don't want to try sending missed d->m messages to an offline homeserver. - This delay can be removed if ONE of the following is done: - 1. m->d can queue incoming events until their guild exists in memory - 2. d->m missed messages can have their errors handled and added to queue, rather than pinging first - */ - let isMainCharacter = false - if (!checkedHomeserver) { - checkedHomeserver = true - isMainCharacter = true - console.log("Warming up guilds~") - } - await scheduler.wait(5000) - if (isMainCharacter) { - checkedHomeserver = true - process.stdout.write("Connecting to homeserver... ") + // Wait for guilds to be connected and homeserver to be online. If this is the last guild, a different code path is used to trigger the homeserver check. + if (lastGuildReady) { + process.stdout.write(`ok, ${client.guilds.size} available.\nConnecting to homeserver... `) + // await guildReadyStatus.waitForAllReady() - no need, we already checked this is the last guild await homeserverStatus.homeserverStatus.waitForOnline(true) console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.") } else { + await guildReadyStatus.waitForAllReady() await homeserverStatus.homeserverStatus.waitForOnline(false) } @@ -81,7 +107,9 @@ async function onPacket(client, message, listen) { await eventDispatcher.checkMissedPins(client, message.d) await eventDispatcher.checkMissedLeaves(client, message.d) } catch (e) { - console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") + if (firstReady) { + console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") + } console.error(e) } } @@ -225,3 +253,4 @@ async function dispatchPacketToBridge(client, message) { module.exports.onPacket = onPacket module.exports.dispatchPacketToBridge = dispatchPacketToBridge +module.exports.guildReadyStatus = guildReadyStatus From 021f02f9813e2962c9629b406ec72bb5a58418f8 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 14 Sep 2026 00:30:03 +1200 Subject: [PATCH 129/184] d->m: Try too-large PNGs as JPEG for upload Large PNGs were almost certainly originally JPEG photos that were converted to PNG by the user copy-pasting. If the PNG is just supposed to be that large, you can still get the original through the external URL option. Had to change _removeExpiryParams to be based on URL parsing instead of regexp because regexp didn't work with &format=jpeg on the end. --- src/d2m/actions/send-message.js | 2 +- src/d2m/converters/edit-to-changes.js | 4 +- src/d2m/converters/message-to-event.js | 77 +++++++++++++++------ src/d2m/converters/message-to-event.test.js | 39 +++++++++++ src/matrix/file.js | 11 ++- src/matrix/file.test.js | 6 ++ test/ooye-test-data.sql | 3 +- 7 files changed, 113 insertions(+), 29 deletions(-) diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index 41ef70d7..6ad58c5f 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -55,7 +55,7 @@ async function sendMessage(message, channel, guild) { } } - const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow}) + const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow, fetch}) const eventIDs = [] if (events.length) { db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(message.id, historicalRoomIndex) diff --git a/src/d2m/converters/edit-to-changes.js b/src/d2m/converters/edit-to-changes.js index 61f32902..409adb0e 100644 --- a/src/d2m/converters/edit-to-changes.js +++ b/src/d2m/converters/edit-to-changes.js @@ -69,8 +69,8 @@ async function editToChanges(message, guild, api) { // Figure out what we will be replacing them with - const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api}) - const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api}) + const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api, fetch}) + const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api, fetch}) assert.ok(newFallbackContent.length === newInnerContent.length) // Match the new events to the old events diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 619588ec..49e3b649 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -109,10 +109,11 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ /** * @param {{room?: boolean, user_ids?: string[]}} mentions - * @param {Omit} attachment + * @param {Omit & {proxy_url?: string}} attachment * @param {boolean} [alwaysLink] + * @param {{fetch?: typeof fetch}} [di] */ -async function attachmentToEvent(mentions, attachment, alwaysLink) { +async function attachmentToEvent(mentions, attachment, alwaysLink, di) { const external_url = dUtils.getPublicUrlForCdn(attachment.url) const emoji = attachment.content_type?.startsWith("image/jp") ? "📸" @@ -132,8 +133,37 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { formatted_body: `
    ${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})
    ` } } + // attempt to convert large PNG image files to JPEG, since it's almost certainly a photo that was forced into PNG by the user copy-pasting. + if ( + attachment.content_type === "image/png" && attachment.size > reg.ooye.max_file_size + && !alwaysLink && attachment.proxy_url && attachment.width && attachment.height && di?.fetch + ) { + const proxyUrl = new URL(attachment.proxy_url) + proxyUrl.searchParams.set("format", "jpeg") + const jpegUrl = proxyUrl.toString() + const res = await di.fetch(jpegUrl, {method: "HEAD"}) + const newFilename = attachment.filename.replace(/\.png$/, ".jpg") + const newSize = Number(res.headers.get("content-length")) + if (res.ok && res.headers.get("content-type") === "image/jpeg" && !res.headers.has("content-encoding") && newSize <= reg.ooye.max_file_size) { + return { + $type: "m.room.message", + "m.mentions": mentions, + msgtype: "m.image", + url: await file.uploadDiscordFileToMxc(jpegUrl), + external_url, + body: attachment.description || newFilename, + filename: newFilename, + info: { + mimetype: "image/jpeg", + w: attachment.width, + h: attachment.height, + size: newSize + } + } + } + } // for large files, always link them instead of uploading so I don't use up all the space in the content repo - else if (alwaysLink || attachment.size > reg.ooye.max_file_size) { + if (alwaysLink || attachment.size > reg.ooye.max_file_size) { return { $type: "m.room.message", "m.mentions": mentions, @@ -142,7 +172,8 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { format: "org.matrix.custom.html", formatted_body: `${emoji} Uploaded file: ${attachment.filename} (${pb(attachment.size)})` } - } else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) { + } + if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) { return { $type: "m.room.message", "m.mentions": mentions, @@ -158,7 +189,8 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { size: attachment.size } } - } else if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) { + } + if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) { return { $type: "m.room.message", "m.mentions": mentions, @@ -174,7 +206,8 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { size: attachment.size } } - } else if (attachment.content_type?.startsWith("audio/")) { + } + if (attachment.content_type?.startsWith("audio/")) { return { $type: "m.room.message", "m.mentions": mentions, @@ -189,19 +222,19 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { duration: attachment.duration_secs && Math.round(attachment.duration_secs * 1000) } } - } else { - return { - $type: "m.room.message", - "m.mentions": mentions, - msgtype: "m.file", - url: await file.uploadDiscordFileToMxc(attachment.url), - external_url, - body: attachment.description || attachment.filename, - filename: attachment.filename, - info: { - mimetype: attachment.content_type, - size: attachment.size - } + } + // else + return { + $type: "m.room.message", + "m.mentions": mentions, + msgtype: "m.file", + url: await file.uploadDiscordFileToMxc(attachment.url), + external_url, + body: attachment.description || attachment.filename, + filename: attachment.filename, + info: { + mimetype: attachment.content_type, + size: attachment.size } } } @@ -295,7 +328,7 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false * - includeEditFallbackStar: false * - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true. * - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned. - * @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API + * @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer, fetch?: typeof fetch}} di simple-as-nails dependency injection for the matrix API * @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>} */ async function messageToEvent(message, guild, options = {}, di) { @@ -882,7 +915,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Then attachments if (message.attachments) { - const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment))) + const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment, false, {fetch: di?.fetch}))) // Try to merge attachment events with the previous event // This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together. @@ -913,7 +946,7 @@ async function messageToEvent(message, guild, options = {}, di) { url: file.url, height: file.height, width: file.width, - }, true) + }, true, {fetch: di?.fetch}) stack.msb.addLine(ev.body, ev.formatted_body) } else if (component.type === DiscordTypes.ComponentType.MediaGallery) { diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 0742c7b1..aea24a83 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1103,6 +1103,45 @@ test("message2event: very large attachment is linked instead of being uploaded", }]) }) +test("message2event: very large png is converted to jpeg for upload", async t => { + const events = await messageToEvent({ + content: "", + attachments: [{ + filename: "855064e9-bdae-487a-a497-c662bd510487.png", + url: "https://cdn.discordapp.com/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png", + proxy_url: "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png", + content_type: "image/png", + width: 5472, + height: 3648, + size: 14967919 + }] + }, data.guild.general, {}, { + async fetch(url, init) { + t.equal(url, "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg") + t.equal(init.method, "HEAD") + return new Response("wa", {headers: { + "Content-Type": "image/jpeg", + "Content-Length": 483085 + }}) + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": {}, + msgtype: "m.image", + body: "855064e9-bdae-487a-a497-c662bd510487.jpg", + info: { + w: 5472, + h: 3648, + mimetype: "image/jpeg", + size: 483085 + }, + external_url: "https://bridge.example.org/download/discordcdn/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png", + filename: "855064e9-bdae-487a-a497-c662bd510487.jpg", + url: "mxc://cadence.moe/zXNXRvJwRDMIzZZVqHUXtGeD" + }]) +}) + test("message2event: multiple attachments are combined into the same event where possible", async t => { const events = await messageToEvent({ content: "hey", diff --git a/src/matrix/file.js b/src/matrix/file.js index c469aeab..b338c594 100644 --- a/src/matrix/file.js +++ b/src/matrix/file.js @@ -15,10 +15,15 @@ const IMAGE_SIZE = 1024 const inflight = new Map() /** - * @param {string} url + * @param {string} urlString */ -function _removeExpiryParams(url) { - return url.replace(/\?(?:(?:ex|is|sg|hm)=[a-f0-9]+&?)*$/, "") +function _removeExpiryParams(urlString) { + const url = new URL(urlString) + url.searchParams.delete("ex") + url.searchParams.delete("is") + url.searchParams.delete("sg") + url.searchParams.delete("hm") + return url.toString() } /** diff --git a/src/matrix/file.test.js b/src/matrix/file.test.js index 2d59dbc6..3a5c73be 100644 --- a/src/matrix/file.test.js +++ b/src/matrix/file.test.js @@ -20,3 +20,9 @@ test("removeExpiryParams: rearranged params are removed", t => { const result = file._removeExpiryParams(url) t.equal(result, "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png") }) + +test("removeExpiryParams: works on media proxy and keeps quality", t => { + const url = "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?ex=6aa7e234&is=6aa690b4&hm=7c153efcdd1feb1d36d0bb99955a2bfe12f26abea377fed02980521766f51620&format=jpeg" + const result = file._removeExpiryParams(url) + t.equal(result, "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg") +}) diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 524e1fe7..072397fa 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -168,7 +168,8 @@ INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP'), ('https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif', 'mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh'), ('https://cdn.discordapp.com/attachments/123/456/my_enemies.txt', 'mxc://cadence.moe/y89EOTRp2lbeOkgdsEleGOge'), -('https://cdn.discordapp.com/emojis/1254940125948022915.webp', 'mxc://cadence.moe/bvVJFgOIyNcAknKCbmaHDktG'); +('https://cdn.discordapp.com/emojis/1254940125948022915.webp', 'mxc://cadence.moe/bvVJFgOIyNcAknKCbmaHDktG'), +('https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg', 'mxc://cadence.moe/zXNXRvJwRDMIzZZVqHUXtGeD'); INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES ('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'), From 3d648e23c82422930f9cf54d389cd15d416c330e Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 14 Sep 2026 16:20:56 +1200 Subject: [PATCH 130/184] Check for homeserver liveliness more --- src/matrix/homeserver-status.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/matrix/homeserver-status.js b/src/matrix/homeserver-status.js index 3ceac84e..da38ac2f 100644 --- a/src/matrix/homeserver-status.js +++ b/src/matrix/homeserver-status.js @@ -27,13 +27,21 @@ const homeserverStatus = new class HomeserverStatus { /** @private */ this.pingInterval = undefined + /** @private */ + this.checkingWhileOnline = true + + /** @private */ + this.lastCheck = 0 + /** @private */ this.sm = new StateMachine("online") .defineState("online") .defineState("checking", { - onEnter: [async () => { + onEnter: [async (event) => { + this.checkingWhileOnline = event === "check while online" const pingResult = await api.ping().catch(e => ({ok: false})) + this.lastCheck = Date.now() if (pingResult.ok) { this.sm.doTransition("check ok") } else { @@ -48,6 +56,7 @@ const homeserverStatus = new class HomeserverStatus { onEnter: [() => { this.pingInterval = setInterval(async () => { const pingResult = await api.ping().catch(e => ({ok: false, status: "net", root: e.message})) + this.lastCheck = Date.now() if (pingResult.ok) { this.sm.doTransition("ping ok") } @@ -77,7 +86,8 @@ const homeserverStatus = new class HomeserverStatus { .defineUniversalTransition("error", "offline") .defineTransition("offline", "ping ok", "recovering") .defineTransition("recovering", "recovered", "online") - .defineTransition("online", "check", "checking") + .defineTransition("online", "check assume online", "checking") + .defineTransition("online", "check assume offline", "checking") .defineTransition("checking", "check ok", "recovering") .defineTransition("checking", "check fail", "offline") @@ -87,7 +97,7 @@ const homeserverStatus = new class HomeserverStatus { } isRealTime() { - return this.sm.currentStateName === "online" + return this.sm.currentStateName === "online" || (this.sm.currentStateName === "checking" && this.checkingWhileOnline) } /** @param {boolean} forceCheck */ @@ -96,7 +106,9 @@ const homeserverStatus = new class HomeserverStatus { // Already online? Start check or just done if (this.sm.currentStateName === "online") { if (forceCheck) { - this.sm.doTransition("check") + this.sm.doTransition("check assume offline") + } else if (this.lastCheck < Date.now() - 15e3) { + this.sm.doTransition("check assume online") } else { return resolve(null) } From 2430d60809f6fb914258a64ba07c735831c729b0 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 14 Sep 2026 16:22:00 +1200 Subject: [PATCH 131/184] v3.7 --- package-lock.json | 242 +++++++++++++++++++++++----------------------- package.json | 2 +- 2 files changed, 122 insertions(+), 122 deletions(-) diff --git a/package-lock.json b/package-lock.json index 986e6cae..ee244106 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "out-of-your-element", - "version": "3.6.0", + "version": "3.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "out-of-your-element", - "version": "3.6.0", + "version": "3.7.0", "license": "AGPL-3.0-or-later", "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", @@ -302,9 +302,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -320,13 +320,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -342,20 +342,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -365,9 +365,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -381,9 +381,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -397,9 +397,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -413,9 +413,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -429,9 +429,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -445,9 +445,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -461,9 +461,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -477,9 +477,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -493,9 +493,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -509,9 +509,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -525,9 +525,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -543,13 +543,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -565,13 +565,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -587,13 +587,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -609,13 +609,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -631,13 +631,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -653,13 +653,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -675,13 +675,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -697,17 +697,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -717,16 +717,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -736,9 +736,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -755,9 +755,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -774,9 +774,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -2490,9 +2490,9 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", @@ -2506,31 +2506,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" }, "peerDependenciesMeta": { "@types/node": { diff --git a/package.json b/package.json index 06b2a495..00f11b75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "out-of-your-element", - "version": "3.6.0", + "version": "3.7.0", "description": "A bridge between Matrix and Discord", "main": "index.js", "repository": { From 9058b60efed49cf071b5d89e3e19fa090e2d3538 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 15 Sep 2026 01:38:47 +1200 Subject: [PATCH 132/184] Update dependencies --- docs/developer-orientation.md | 20 +- package-lock.json | 755 +++++++------------- package.json | 8 +- scripts/start-server.js | 3 - src/d2m/actions/register-webhook-user.js | 2 +- src/d2m/converters/find-mentions.js | 2 +- src/d2m/converters/lottie.js | 2 +- src/d2m/converters/user-to-mxid.js | 6 +- src/d2m/discord-packets.js | 6 +- src/m2d/converters/event-to-message.js | 2 +- src/m2d/converters/event-to-message.test.js | 33 + src/matrix/homeserver-status.js | 2 +- src/stdin.js | 1 + src/web/server.js | 2 +- 14 files changed, 333 insertions(+), 511 deletions(-) diff --git a/docs/developer-orientation.md b/docs/developer-orientation.md index 0f5e0c01..839b4af4 100644 --- a/docs/developer-orientation.md +++ b/docs/developer-orientation.md @@ -89,14 +89,14 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin # Dependency justification -Total transitive production dependencies: 144 +Total transitive production dependencies: 112 ### 🦕 -* (35) better-sqlite3: SQLite is the best database, and this is the best library for it. * (29) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. It has libvips prebuilts for each platform. * (26) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.) * (9) h3: Web server. OOYE needs this for the web UI, appservice listener, authmedia proxy, and more. +* (1) better-sqlite3: SQLite is the best database, and this is the best library for it. ### 🪱 @@ -120,6 +120,7 @@ Total transitive production dependencies: 144 * (0) entities: Looks fine. No dependencies. * (0) get-relative-path: Looks fine. No dependencies. * (1) heatsync: Module hot-reloader that I trust. +* (0) htmx.org: Declarative partial page reloads that do just enough. I'm not totally happy with this one, but not sure what I'd do instead. * (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used. * (1) mime-types: List of mime type mappings. Needed to serve static files. * (0) prettier-bytes: It does what I want and has no dependencies. @@ -128,3 +129,18 @@ Total transitive production dependencies: 144 * (0) uqr: QR code SVG generator. Used on the website to scan in an invite link. * (0) xxhash-wasm: Used where cryptographically secure hashing is not required. * (0) zod: Input validation for the web server. It's popular and easy to use. + +### Outdated versions + +* @chriscdn/promise-semaphore + * Highest usable version is 3.1.3 because the author **dropped CJS** in 4.0.0. +* @stackoverflow/stacks + * Highest usable version is 2.9.0 (and 6.9.0 for stacks-icons) because the company **ruined their design** in 3.0.0. +* cross-env + * Highest usable version is 7.0.3 because the author **dropped CJS** in 10.0.0. +* entities + * Using version 5.0.0. The author messed things up in 6.0.0, started using LLMs in 7.0.0, and **dropped CJS** in 8.0.0. +* h3 + * Using major version 1. Version 2 has been stuck as **release candidates for months**, and all the code comments are Claude. +* htmx.org + * Using major version 2. Version 4 exists, and **I'd be okay updating to it**, but the author changed a lot and the entire site would need to be re-tested. diff --git a/package-lock.json b/package-lock.json index ee244106..9e35392e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks-icons": "^6.0.2", "ansi-colors": "^4.1.3", - "better-sqlite3": "^12.2.0", + "better-sqlite3": "^13.0.3", "chunk-text": "^2.0.1", "cloudstorm": "^0.19.0", "denque": "^2.1.0", @@ -32,10 +32,10 @@ "entities": "^5.0.0", "get-relative-path": "^1.0.2", "h3": "^1.15.10", - "heatsync": "^2.7.2", + "heatsync": "^3.3.0", "htmx.org": "^2.0.4", "lru-cache": "^11.0.2", - "mime-types": "^2.1.35", + "mime-types": "^3.0.2", "prettier-bytes": "^1.0.4", "sharp": "^0.35.3", "snowtransfer": "^0.19.0", @@ -47,7 +47,7 @@ "devDependencies": { "@cloudrac3r/tap-dot": "^2.0.3", "@types/node": "^22.17.1", - "c8": "^11.0.0", + "c8": "^12.0.0", "cross-env": "^7.0.3", "supertape": "^13.2.0" }, @@ -909,9 +909,9 @@ "license": "MIT" }, "node_modules/@stackoverflow/stacks": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.8.6.tgz", - "integrity": "sha512-pR0vMDBA5rNV5Cb/McG+2F1nG68fZk3UiNwuOvQrP82WoQy41k9PsBjRjD7wHDVVZyDrWHLb/WnosXX7uK3IdA==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.9.0.tgz", + "integrity": "sha512-6VX9GTYFqsBFaelZ0sJ9YGZDa+1eLjr6Dh8r6cIh02SA/BcocWe0lxNZTcLpBQEKtX/9A0jTRsY3Au0BXcFJWQ==", "dependencies": { "@hotwired/stimulus": "^3.2.2", "@popperjs/core": "^2.11.8" @@ -1039,9 +1039,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", "dev": true, "license": "MIT", "dependencies": { @@ -1129,9 +1129,9 @@ } }, "node_modules/backtracker": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/backtracker/-/backtracker-5.0.0.tgz", - "integrity": "sha512-2rY1s1iMlF1FVb4jpIMxTeGE+KRppuVvPyU61q7gvap1MWVahToUI8WUqy+v3L37iip5a4mJOTRBZxNRbTv4bg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/backtracker/-/backtracker-5.1.1.tgz", + "integrity": "sha512-+/rMk2Eyzc/k6AqguRxdlUVOzMmyjUqavmICnwI5XGNBpbKLxAu5Bps3G7yRvxCg0b5zc5crZoSgpPTo9i5lbQ==", "license": "MIT" }, "node_modules/balanced-match": { @@ -1144,58 +1144,16 @@ "node": "18 || 20 || >=22" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/better-sqlite3": { - "version": "12.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", - "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", - "hasInstallScript": true, + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", "license": "MIT", "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" + "node-addon-api": "^8.0.0" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" + "node": ">=22" } }, "node_modules/brace-expansion": { @@ -1211,34 +1169,10 @@ "node": "20 || >=22" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/c8": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", - "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", "dev": true, "license": "ISC", "dependencies": { @@ -1251,14 +1185,14 @@ "istanbul-reports": "^3.1.6", "test-exclude": "^8.0.0", "v8-to-istanbul": "^9.0.0", - "yargs": "^17.7.2", + "yargs": "^18.0.0", "yargs-parser": "^21.1.1" }, "bin": { "c8": "bin/c8.js" }, "engines": { - "node": "20 || >=22" + "node": "^20.19.0 || ^22.12.0 || >=23" }, "peerDependencies": { "monocart-coverage-reports": "^2" @@ -1288,12 +1222,6 @@ "integrity": "sha512-jWburCrDpd+aPopB7esjh/gLyZoHZS4C2xwwJlkTPyhhJdXG+FCG0P4qCOInvOd9yhiuAEJYlZsUMQ0JSK4ykw==", "license": "MIT" }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/chunk-text": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/chunk-text/-/chunk-text-2.0.1.tgz", @@ -1337,18 +1265,72 @@ } }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/cloudstorm": { @@ -1463,30 +1445,6 @@ "dev": true, "license": "MIT" }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/defu": { "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", @@ -1518,13 +1476,10 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.53", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.53.tgz", - "integrity": "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A==", - "license": "MIT", - "workspaces": [ - "scripts/actions/documentation" - ] + "version": "0.38.55", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.55.tgz", + "integrity": "sha512-ytuaRTzdnHUCXJ6KjL9MrItQX0xKncBKEeYI1Bst4+ud47eejH3cG6gaesYakjpPcUhh68XYS2YwGq3mmujFsA==", + "license": "MIT" }, "node_modules/doctypes": { "version": "1.1.0", @@ -1533,9 +1488,9 @@ "license": "MIT" }, "node_modules/domino": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.7.tgz", - "integrity": "sha512-3rcXhx0ixJV2nj8J0tljzejTF73A35LVVdnTQu79UAqTBFEgYPMgGtykMuu/BDqaOZphATku1ddRUn/RtqUHYQ==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.8.tgz", + "integrity": "sha512-qLkTcPRkqvifAB0bweVznkWgAu2hwin8An+HbLy0bH1ZtMyJzwXiHKMhynwR1Wq4PgBvEyg2Y749j4JJnlfDKg==", "license": "BSD-2-Clause" }, "node_modules/emoji-regex": { @@ -1545,15 +1500,6 @@ "dev": true, "license": "MIT" }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -1589,15 +1535,6 @@ "node": ">=6" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -1608,12 +1545,6 @@ "node": ">= 4.9.1" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1655,12 +1586,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fullstore": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/fullstore/-/fullstore-4.0.2.tgz", @@ -1691,6 +1616,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-relative-path": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-relative-path/-/get-relative-path-1.0.2.tgz", @@ -1708,12 +1646,6 @@ "source-map": "^0.6.1" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -1773,15 +1705,15 @@ } }, "node_modules/heatsync": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.8.3.tgz", - "integrity": "sha512-9pVRC3BZD1NZ0EYnU5akjoO10+s/aJc04QqUxgtBqAYUeberV8st0ctWH7selEnyU8OEAUKZhBCFxmH7MvCQQQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/heatsync/-/heatsync-3.3.0.tgz", + "integrity": "sha512-9qkijny0a9lo0He103na+sGVx8YRcDKizfGNBahkx5OX6LmeR8njNOftRFSCuSRkNqrNWuxRFOOVKIiY0FsEVQ==", "license": "MIT", "dependencies": { - "backtracker": "^5.0.0" + "backtracker": "^5.1.1" }, "engines": { - "node": ">=14.6.0" + "node": ">=20.6.0" } }, "node_modules/html-es6cape": { @@ -1803,38 +1735,6 @@ "integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==", "license": "0BSD" }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -2019,9 +1919,9 @@ } }, "node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -2044,36 +1944,28 @@ } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/minimatch": { @@ -2092,15 +1984,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -2111,28 +1994,13 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/node-abi": { - "version": "3.89.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", - "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, "engines": { - "node": ">=10" + "node": "^18 || ^20 || >= 21" } }, "node_modules/node-mock-http": { @@ -2154,6 +2022,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -2235,33 +2104,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prettier-bytes": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/prettier-bytes/-/prettier-bytes-1.0.4.tgz", @@ -2358,37 +2200,12 @@ "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==", "license": "MIT" }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/radix3": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", "license": "MIT" }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, "node_modules/re-emitter": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/re-emitter/-/re-emitter-1.1.4.tgz", @@ -2403,30 +2220,6 @@ "dev": true, "license": "MIT" }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -2457,26 +2250,6 @@ "node": ">=4.0.0" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -2574,51 +2347,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/simple-markdown": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/simple-markdown/-/simple-markdown-0.7.3.tgz", @@ -2671,15 +2399,6 @@ "get-source": "^2.0.12" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2707,19 +2426,10 @@ "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/supertape": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/supertape/-/supertape-13.2.0.tgz", - "integrity": "sha512-UoxZnyoMOdSJHvbcmD8i28MaGXsA7I0cJ0jr8anT4CkmfaE9M1y5mt9EoXyzfC8UdnQZwXOnJLUwqyKLAeUOug==", + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/supertape/-/supertape-13.6.0.tgz", + "integrity": "sha512-W/lm1wk370QF0u+LrxS+BMUFNYMfRgjFC9dTKyFB522fa77pKJHwOBDiTl3uhNbsw0Sb1NQPRYk+eFICcqxedA==", "dev": true, "license": "MIT", "dependencies": { @@ -2792,34 +2502,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/test-exclude": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", @@ -2874,18 +2556,6 @@ "license": "0BSD", "optional": true }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/ufo": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", @@ -2911,12 +2581,6 @@ "integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==", "license": "MIT" }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -2973,43 +2637,95 @@ } }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/wraptile": { @@ -3036,22 +2752,21 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^8.2.1", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { @@ -3064,6 +2779,62 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3078,9 +2849,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 00f11b75..5fbf9287 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks-icons": "^6.0.2", "ansi-colors": "^4.1.3", - "better-sqlite3": "^12.2.0", + "better-sqlite3": "^13.0.3", "chunk-text": "^2.0.1", "cloudstorm": "^0.19.0", "denque": "^2.1.0", @@ -41,10 +41,10 @@ "entities": "^5.0.0", "get-relative-path": "^1.0.2", "h3": "^1.15.10", - "heatsync": "^2.7.2", + "heatsync": "^3.3.0", "htmx.org": "^2.0.4", "lru-cache": "^11.0.2", - "mime-types": "^2.1.35", + "mime-types": "^3.0.2", "prettier-bytes": "^1.0.4", "sharp": "^0.35.3", "snowtransfer": "^0.19.0", @@ -59,7 +59,7 @@ "devDependencies": { "@cloudrac3r/tap-dot": "^2.0.3", "@types/node": "^22.17.1", - "c8": "^11.0.0", + "c8": "^12.0.0", "cross-env": "^7.0.3", "supertape": "^13.2.0" }, diff --git a/scripts/start-server.js b/scripts/start-server.js index 44edbcb9..2cfee682 100755 --- a/scripts/start-server.js +++ b/scripts/start-server.js @@ -1,9 +1,6 @@ #!/usr/bin/env node // @ts-check -const {createServer} = require("http") -const EventEmitter = require("events") -const {createApp, createRouter, toNodeListener} = require("h3") const sqlite = require("better-sqlite3") const migrate = require("../src/db/migrate") const HeatSync = require("heatsync") diff --git a/src/d2m/actions/register-webhook-user.js b/src/d2m/actions/register-webhook-user.js index 145eeb8f..f0168431 100644 --- a/src/d2m/actions/register-webhook-user.js +++ b/src/d2m/actions/register-webhook-user.js @@ -1,6 +1,6 @@ // @ts-check -const assert = require("assert") +const assert = require("assert").strict const {reg} = require("../../matrix/read-registration") const Ty = require("../../types") diff --git a/src/d2m/converters/find-mentions.js b/src/d2m/converters/find-mentions.js index 5135d172..e9c51c54 100644 --- a/src/d2m/converters/find-mentions.js +++ b/src/d2m/converters/find-mentions.js @@ -1,6 +1,6 @@ // @ts-check -const assert = require("assert") +const assert = require("assert").strict const {reg} = require("../../matrix/read-registration") const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) diff --git a/src/d2m/converters/lottie.js b/src/d2m/converters/lottie.js index 969d3455..66419586 100644 --- a/src/d2m/converters/lottie.js +++ b/src/d2m/converters/lottie.js @@ -1,6 +1,6 @@ // @ts-check -const assert = require("assert") +const assert = require("assert").strict const stream = require("stream") const {PNG} = require("@cloudrac3r/pngjs") diff --git a/src/d2m/converters/user-to-mxid.js b/src/d2m/converters/user-to-mxid.js index 7705aff1..fb37aa1f 100644 --- a/src/d2m/converters/user-to-mxid.js +++ b/src/d2m/converters/user-to-mxid.js @@ -1,6 +1,6 @@ // @ts-check -const assert = require("assert") +const assert = require("assert").strict const {reg} = require("../../matrix/read-registration") const Ty = require("../../types") @@ -66,7 +66,7 @@ function userToSimName(user) { // 1. Is sim user already registered? const existing = select("sim", "user_id", {user_id: user.id}).pluck().get() - assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim") + assert.equal(existing, undefined, "Shouldn't try to create a new name for an existing sim") // 2. Register based on username (could be new or old format) // (Unless it's a special user, in which case copy their provided mappings.) @@ -113,7 +113,7 @@ function webhookAuthorToSimName(author) { // 1. Is sim user already registered? const fakeUserID = webhookAuthorToFakeUserID(author) const existing = select("sim", "user_id", {user_id: fakeUserID}).pluck().get() - assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim") + assert.equal(existing, undefined, "Shouldn't try to create a new name for an existing sim") // 2. Register based on username (could be new or old format) const downcased = "webhook_" + downcaseUsername(author) diff --git a/src/d2m/discord-packets.js b/src/d2m/discord-packets.js index 992689ff..9f3b291b 100644 --- a/src/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -74,6 +74,10 @@ async function onPacket(client, message, listen) { const firstReady = guildReadyStatus.allReady() const lastGuildReady = guildReadyStatus.makeReady(message.d.id) + if (lastGuildReady) { + console.log(`ok, ${client.guilds.size} available.`) + } + const arr = [] client.guildChannelMap.set(message.d.id, arr) for (const channel of message.d.channels || []) { @@ -93,7 +97,7 @@ async function onPacket(client, message, listen) { try { // Wait for guilds to be connected and homeserver to be online. If this is the last guild, a different code path is used to trigger the homeserver check. if (lastGuildReady) { - process.stdout.write(`ok, ${client.guilds.size} available.\nConnecting to homeserver... `) + process.stdout.write(`Connecting to homeserver... `) // await guildReadyStatus.waitForAllReady() - no need, we already checked this is the last guild await homeserverStatus.homeserverStatus.waitForOnline(true) console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.") diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index a017e99c..5fe1109d 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -620,7 +620,7 @@ async function eventToMessage(event, guild, channel, di) { let filename = event.content.filename || event.content.body if (!filename) { const extension = mimeTypes.extension(event.content.info?.mimetype) - if (!extension) throw new Error("Filename and mimetype missing. Please report this bug to your client.") + assert(extension, "Filename and mimetype missing. Please report this bug to your client.") filename = `file.${extension}` } diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 6b227731..b262a379 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -4816,6 +4816,39 @@ test("event2message: image attachments can have a formatted caption", async t => ) }) +test("event2message: attachments have a generated filename if it was missing", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + body: "", + filename: "", + info: { + mimetype: "image/png", + size: 50832 + }, + msgtype: "m.image", + url: "mxc://agiadn.org/3SvzETkab3XBDM6HCMJxpOryIDxMO31j" + }, + event_id: "$CXQy3Wmg1A-gL_xAesC1HQcQTEXwICLdSwwUx55FBTI", + room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", + attachments: [{id: "0", filename: "file.png"}], + pendingFiles: [{name: "file.png", mxc: "mxc://agiadn.org/3SvzETkab3XBDM6HCMJxpOryIDxMO31j"}] + }] + } + ) +}) + test("event2message: encrypted image attachments work", async t => { t.deepEqual( await eventToMessage({ diff --git a/src/matrix/homeserver-status.js b/src/matrix/homeserver-status.js index da38ac2f..6d0ea76f 100644 --- a/src/matrix/homeserver-status.js +++ b/src/matrix/homeserver-status.js @@ -1,6 +1,6 @@ // @ts-check -const assert = require("assert") +const assert = require("assert").strict const Denque = require("denque") const StateMachine = require("snowtransfer").StateMachine diff --git a/src/stdin.js b/src/stdin.js index 43f9607e..6e35e789 100644 --- a/src/stdin.js +++ b/src/stdin.js @@ -7,6 +7,7 @@ const {addbot} = require("../addbot") const passthrough = require("./passthrough") const {discord, sync, db, select, from, as} = passthrough +const reg = require("./matrix/read-registration") const data = sync.require("../test/data") const createSpace = sync.require("./d2m/actions/create-space") const createRoom = sync.require("./d2m/actions/create-room") diff --git a/src/web/server.js b/src/web/server.js index c8d363dc..e2ce1b5c 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -1,6 +1,6 @@ // @ts-check -const assert = require("assert") +const assert = require("assert").strict const fs = require("fs") const {join} = require("path") const h3 = require("h3") From 2959fd8ae3823b08d80b09869295a1e7e9f891de Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 15 Sep 2026 23:37:45 +1200 Subject: [PATCH 133/184] Render pug blocks without a separate template --- .vscode/settings.json | 8 +- package-lock.json | 1 + scripts/start-server.js | 8 +- src/js/errors.js | 53 +++++++++++ src/js/errors.test.js | 23 +++++ src/m2d/event-dispatcher.js | 57 +----------- src/m2d/event-dispatcher.test.js | 21 +---- src/web/pug-sync.js | 96 +++++++++++++++++--- src/web/pug/fragments/default-roles-list.pug | 5 - src/web/pug/guild.pug | 25 ++++- src/web/pug/includes/default-roles-list.pug | 19 ---- src/web/pug/invite.pug | 4 +- src/web/routes/guild-settings.js | 2 +- src/web/server.js | 2 +- test/test.js | 1 + 15 files changed, 199 insertions(+), 126 deletions(-) create mode 100644 src/js/errors.js create mode 100644 src/js/errors.test.js delete mode 100644 src/web/pug/fragments/default-roles-list.pug delete mode 100644 src/web/pug/includes/default-roles-list.pug diff --git a/.vscode/settings.json b/.vscode/settings.json index 9f1e1831..61015ef3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,11 @@ { "editor.insertSpaces": false, "editor.detectIndentation": false, - "editor.tabSize": 3 + "editor.tabSize": 3, + "[jade]": { + "editor.stickyScroll.enabled": true, + "editor.stickyScroll.maxLineCount": 6, + "editor.tabSize": 2, + "editor.insertSpaces": true + } } diff --git a/package-lock.json b/package-lock.json index 9e35392e..d9ff5cb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1148,6 +1148,7 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" diff --git a/scripts/start-server.js b/scripts/start-server.js index 2cfee682..883333d1 100755 --- a/scripts/start-server.js +++ b/scripts/start-server.js @@ -13,6 +13,10 @@ const sync = new HeatSync() Object.assign(passthrough, {sync, db}) +const orm = sync.require("../src/db/orm") +passthrough.from = orm.from +passthrough.select = orm.select + const DiscordClient = require("../src/d2m/discord-client") const discord = new DiscordClient(reg.ooye.discord_token, "half") @@ -21,10 +25,6 @@ passthrough.discord = discord const {as} = require("../src/matrix/appservice") passthrough.as = as -const orm = sync.require("../src/db/orm") -passthrough.from = orm.from -passthrough.select = orm.select - ;(async () => { await migrate.migrate(db) await discord.cloud.connect() diff --git a/src/js/errors.js b/src/js/errors.js new file mode 100644 index 00000000..77707724 --- /dev/null +++ b/src/js/errors.js @@ -0,0 +1,53 @@ +/** + * This function is adapted from Evan Kaufman's fantastic work. + * The original function and my adapted function are both MIT licensed. + * @url https://github.com/EvanK/npm-loggable-error/ + * @param {number} [depth] + * @returns {string} +*/ +function stringifyErrorStack(err, depth = 0) { + let collapsed = " ".repeat(depth); + if (!(err instanceof Error)) { + return collapsed + err + } + + // add full stack trace if one exists, otherwise convert to string + let stackLines = String(err?.stack ?? err).replace(/^/gm, " ".repeat(depth)).trim().split("\n") + let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/")) + if (cloudstormLine !== -1) { + stackLines = stackLines.slice(0, cloudstormLine - 2) + } + collapsed += stackLines.join("\n") + + const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p)) + + // only break into object notation if we have additional props to dump + if (props.length) { + const dedent = " ".repeat(depth); + const indent = " ".repeat(depth + 2); + + collapsed += " {\n"; + + // loop and print each (indented) prop name + for (let property of props) { + collapsed += `${indent}[${property}]: `; + + // if another error object, stringify it too + if (err[property] instanceof Error) { + collapsed += stringifyErrorStack(err[property], depth + 2).trimStart(); + } + // otherwise stringify as JSON + else { + collapsed += JSON.stringify(err[property]); + } + + collapsed += "\n"; + } + + collapsed += `${dedent}}\n`; + } + + return collapsed; +} + +module.exports.stringifyErrorStack = stringifyErrorStack diff --git a/src/js/errors.test.js b/src/js/errors.test.js new file mode 100644 index 00000000..ddff7d36 --- /dev/null +++ b/src/js/errors.test.js @@ -0,0 +1,23 @@ +// @ts-check + +const {test} = require("supertape") +const {stringifyErrorStack} = require("./errors") + +test("stringify error stack: works", t => { + function a() { + const e = new Error("message", {cause: new Error("inner")}) + // @ts-ignore + e.prop = 2.1 + throw e + } + try { + a() + t.fail("shouldn't get here") + } catch (e) { + const str = stringifyErrorStack(e) + t.match(str, /^Error: message$/m) + t.match(str, /^ at a \(.*errors\.test\.js/m) + t.match(str, /^ \[cause\]: Error: inner$/m) + t.match(str, /^ \[prop\]: 2.1$/m) + } +}) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 0560f461..f6f414a4 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -38,62 +38,12 @@ const retrigger = sync.require("../d2m/actions/retrigger") const homeserverStatus = sync.require("../matrix/homeserver-status") /** @type {import("./actions/typing")} */ const typing = sync.require("./actions/typing") +/** @type {import("../js/errors")} */ +const errors = sync.require("../js/errors") const {reg} = require("../matrix/read-registration") let lastReportedEvent = 0 -/** - * This function is adapted from Evan Kaufman's fantastic work. - * The original function and my adapted function are both MIT licensed. - * @url https://github.com/EvanK/npm-loggable-error/ - * @param {number} [depth] - * @returns {string} -*/ -function stringifyErrorStack(err, depth = 0) { - let collapsed = " ".repeat(depth); - if (!(err instanceof Error)) { - return collapsed + err - } - - // add full stack trace if one exists, otherwise convert to string - let stackLines = String(err?.stack ?? err).replace(/^/gm, " ".repeat(depth)).trim().split("\n") - let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/")) - if (cloudstormLine !== -1) { - stackLines = stackLines.slice(0, cloudstormLine - 2) - } - collapsed += stackLines.join("\n") - - const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p)) - - // only break into object notation if we have additional props to dump - if (props.length) { - const dedent = " ".repeat(depth); - const indent = " ".repeat(depth + 2); - - collapsed += " {\n"; - - // loop and print each (indented) prop name - for (let property of props) { - collapsed += `${indent}[${property}]: `; - - // if another error object, stringify it too - if (err[property] instanceof Error) { - collapsed += stringifyErrorStack(err[property], depth + 2).trimStart(); - } - // otherwise stringify as JSON - else { - collapsed += JSON.stringify(err[property]); - } - - collapsed += "\n"; - } - - collapsed += `${dedent}}\n`; - } - - return collapsed; -} - function printError(type, source, e, payload) { console.error(`Error while processing a ${type} ${source} event:`) console.error(e) @@ -145,7 +95,7 @@ async function sendError(roomID, source, type, e, payload) { builder.addLine(errorIntroLine) // Where - const stack = cleanErrorStack(stringifyErrorStack(e)) + const stack = cleanErrorStack(errors.stringifyErrorStack(e)) builder.addLine(`Error trace:\n${stack}`, tag`
    Error trace
    ${stack}
    `) // How @@ -543,7 +493,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 2de6381a..302b33b1 100644 --- a/src/m2d/event-dispatcher.test.js +++ b/src/m2d/event-dispatcher.test.js @@ -1,26 +1,7 @@ // @ts-check const {test} = require("supertape") -const {stringifyErrorStack, cleanErrorStack} = require("./event-dispatcher") - -test("stringify error stack: works", t => { - function a() { - const e = new Error("message", {cause: new Error("inner")}) - // @ts-ignore - e.prop = 2.1 - throw e - } - try { - a() - t.fail("shouldn't get here") - } catch (e) { - const str = stringifyErrorStack(e) - t.match(str, /^Error: message$/m) - t.match(str, /^ at a \(.*event-dispatcher\.test\.js/m) - t.match(str, /^ \[cause\]: Error: inner$/m) - t.match(str, /^ \[prop\]: 2.1$/m) - } -}) +const {cleanErrorStack} = require("./event-dispatcher") test("clean error stack: removes webhook token", t => { t.notMatch( diff --git a/src/web/pug-sync.js b/src/web/pug-sync.js index e61c53bd..cca90c2e 100644 --- a/src/web/pug-sync.js +++ b/src/web/pug-sync.js @@ -2,20 +2,24 @@ const assert = require("assert/strict") const fs = require("fs") +const util = require("util") const {join} = require("path") const getRelativePath = require("get-relative-path") const h3 = require("h3") -const {defineEventHandler, defaultContentType, setResponseStatus, getQuery} = h3 +const {defineEventHandler, defaultContentType, setResponseStatus, getQuery, getRequestURL} = h3 const {compileFile} = require("@cloudrac3r/pug") +const walkAST = require("pug-walk") const pretty = process.argv.join(" ").includes("test") const {sync} = require("../passthrough") /** @type {import("./auth")} */ const auth = sync.require("./auth") +/** @type {import("../js/errors")} */ +const errors = sync.require("../js/errors") // Pug -let globals = {} +let globals = sync.remember(() => ({})) /** @type {Map) => Promise>} */ const pugCache = new Map() @@ -30,21 +34,60 @@ function addGlobals(obj) { * @param {Record} locals */ function render(event, filename, locals) { + return renderFragments(event, filename, null, locals) +} + +/** + * @param {import("h3").H3Event} event + * @param {string} filename + * @param {string[] | null} fragments + * @param {Record} locals + */ +function renderFragments(event, filename, fragments, locals) { const path = join(__dirname, "pug", filename) - return renderPath(event, path, locals) + return renderPath(event, path, fragments, locals) +} + +/** + * @param {string[] | null} fragments + * @param {string[]} requiresOut + */ +function fragmentSelectorPlugins(fragments, requiresOut) { + if (!fragments) return [] + return [{ + parse: true, + postParse(ast, options) { + const found = [] + walkAST(ast, (node, replace) => { + if (node.type === "NamedBlock") { + const [name, requires] = node.name.split("requires").map(x => x.trim()) + // console.log(name, "|", requires) + if (fragments?.includes(name)) { + if (requires) requiresOut.push(...requires.split(",").map(x => x.trim())) + found.push(node) + } + } + }) + // console.log(util.inspect(found, false, 9, true)) + return {type: "Block", nodes: found, line: 0, filename: found[0].filename} + } + }] } /** * @param {import("h3").H3Event} event * @param {string} path + * @param {string[] | null} fragments * @param {Record} locals */ -function renderPath(event, path, locals) { +function renderPath(event, path, fragments, locals) { + const cacheKey = path + (fragments ? fragments.map(f => `#${f}`).join("") : "") + function compile() { try { - const template = compileFile(path, {pretty}) - pugCache.set(path, async (event, locals) => { - defaultContentType(event, "text/html; charset=utf-8") + const requiredVars = [] + const template = compileFile(path, {pretty, plugins: fragmentSelectorPlugins(fragments, requiredVars)}) + pugCache.set(cacheKey, async (event, locals) => { const session = await auth.useSession(event) const managed = await auth.getManagedGuilds(event) const rel = (to, paramsObject) => { @@ -55,16 +98,36 @@ function renderPath(event, path, locals) { } return result } - return template(Object.assign({}, + const templateVars = Object.assign({}, getQuery(event), // Query parameters can be easily accessed on the top level but don't allow them to overwrite anything globals, // Globals locals, // Explicit locals overwrite globals in case we need to DI something {session, event, rel, managed} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted. - )) + ) + // Verify all fragment block required variables are present + for (const key of requiredVars) { + if (templateVars[key] === undefined) { + setResponseStatus(event, 500, "Internal Template Variables Error") + defaultContentType(event, "text/plain") + return `requested fragments ${JSON.stringify(fragments)}\nrequire variables ${JSON.stringify(requiredVars)}\nbut "${key}" is ${templateVars[key]} (present: ${key in templateVars})` + } + } + try { + const content = template(templateVars) + defaultContentType(event, "text/html; charset=utf-8") + return content + } catch (e) { + console.error(e) + setResponseStatus(event, 500, "Internal Template Rendering Error") + defaultContentType(event, "text/plain") + e.url = getRequestURL(event).toString() + return errors.stringifyErrorStack(e) + } }) /* c8 ignore start */ } catch (e) { - pugCache.set(path, async (event) => { + pugCache.set(cacheKey, async (event) => { + console.error(e) setResponseStatus(event, 500, "Internal Template Error") defaultContentType(event, "text/plain") return e.toString() @@ -73,14 +136,16 @@ function renderPath(event, path, locals) { /* c8 ignore stop */ } - if (!pugCache.has(path)) { + if (!pugCache.has(cacheKey)) { compile() - fs.watch(path, {persistent: false}, compile) - fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile) - fs.watch(join(__dirname, "pug", "fragments"), {persistent: false}, compile) + const ac = new AbortController() + const {signal} = ac + fs.watch(path, {persistent: false, signal}, compile) + fs.watch(join(__dirname, "pug", "includes"), {persistent: false, signal}, compile) + sync.events.once(__filename, () => ac.abort()) } - const cb = pugCache.get(path) + const cb = pugCache.get(cacheKey) assert(cb) return cb(event, locals) } @@ -98,5 +163,6 @@ function createRoute(router, url, filename) { module.exports.addGlobals = addGlobals module.exports.render = render +module.exports.renderFragments = renderFragments module.exports.renderPath = renderPath module.exports.createRoute = createRoute diff --git a/src/web/pug/fragments/default-roles-list.pug b/src/web/pug/fragments/default-roles-list.pug deleted file mode 100644 index 3b36549c..00000000 --- a/src/web/pug/fragments/default-roles-list.pug +++ /dev/null @@ -1,5 +0,0 @@ -//- locals: guild, guild_id - -include ../includes/default-roles-list.pug -+default-roles-list(guild, guild_id) -+add-roles-menu(guild, guild_id) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 7411a1e8..c464954d 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -1,5 +1,4 @@ extends includes/template.pug -include includes/default-roles-list.pug mixin badge-readonly .s-badge.s-badge__xs.s-badge__icon.s-badge__muted @@ -113,15 +112,33 @@ block body .d-flex.fw-wrap.g4 .s-tag.s-tag__md.fs-body1.s-tag__required @everyone - +default-roles-list(guild, guild_id) + block default-roles-list requires guild, guild_id + #default-roles-list(style="display: contents") + each roleID in select("role_default", "role_id", {guild_id}).pluck().all() + - let r = guild.roles.find(r => r.id === roleID) + if r + .s-tag.s-tag__md.fs-body1= r.name + span(id=`role-loading-${roleID}`) + button(name="remove_role" value=roleID hx-post="api/default-roles" hx-trigger="click consume" hx-indicator=`#role-loading-${roleID}`).s-tag--dismiss + != icons.Icons.IconClearSm 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 + #role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible.box-border.lh-sm .s-popover--arrow.s-popover--arrow__tc - +add-roles-menu(guild, guild_id) + + block add-roles-menu requires guild, guild_id + ul.s-menu(role="menu" hx-swap-oob="true").overflow-y-auto.overflow-x-hidden#add-roles-menu + li.s-menu--title.d-flex(role="separator") Select role + span#add-role-loading + each r in guild.roles.sort((a, b) => b.position - a.position) + if r.id !== guild_id && !r.managed + - let selected = !!select("role_default", "role_id", {guild_id, role_id: r.id}).get() + li(role="menuitem") + button(name="toggle_role" value=r.id class={"is-selected": selected}).s-block-link.s-block-link__left= r.name + 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 diff --git a/src/web/pug/includes/default-roles-list.pug b/src/web/pug/includes/default-roles-list.pug deleted file mode 100644 index 8c0a4e0d..00000000 --- a/src/web/pug/includes/default-roles-list.pug +++ /dev/null @@ -1,19 +0,0 @@ -mixin default-roles-list(guild, guild_id) - #default-roles-list(style="display: contents") - each roleID in select("role_default", "role_id", {guild_id}).pluck().all() - - let r = guild.roles.find(r => r.id === roleID) - if r - .s-tag.s-tag__md.fs-body1= r.name - span(id=`role-loading-${roleID}`) - button(name="remove_role" value=roleID hx-post="api/default-roles" hx-trigger="click consume" hx-indicator=`#role-loading-${roleID}`).s-tag--dismiss - != icons.Icons.IconClearSm - -mixin add-roles-menu(guild, guild_id) - ul.s-menu(role="menu" hx-swap-oob="true").overflow-y-auto.overflow-x-hidden#add-roles-menu - li.s-menu--title.d-flex(role="separator") Select role - span#add-role-loading - each r in guild.roles.sort((a, b) => b.position - a.position) - if r.id !== guild_id && !r.managed - - let selected = !!select("role_default", "role_id", {guild_id, role_id: r.id}).get() - li(role="menuitem") - button(name="toggle_role" value=r.id class={"is-selected": selected}).s-block-link.s-block-link__left= r.name diff --git a/src/web/pug/invite.pug b/src/web/pug/invite.pug index 81a428dd..0596d589 100644 --- a/src/web/pug/invite.pug +++ b/src/web/pug/invite.pug @@ -17,10 +17,10 @@ block body .fl-grow1 h2.fs-headline1 Invite a Matrix user - form.d-flex.gy16.fd-column(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container") + form.d-flex.gy16.wmx3.fd-column(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container") .d-flex.gy4.fd-column label.s-label(for="mxid") Matrix ID - input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org") + input.fl-grow1.s-input#mxid(name="mxid" required placeholder="@user:example.org") .d-flex.gy4.fd-column label.s-label(for="permissions") Permissions .s-select diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js index 8119f933..9bfff3de 100644 --- a/src/web/routes/guild-settings.js +++ b/src/web/routes/guild-settings.js @@ -135,7 +135,7 @@ as.router.post("/api/default-roles", defineEventHandler(async event => { await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request if (getRequestHeader(event, "HX-Request")) { - return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID}) + return pugSync.renderFragments(event, "guild.pug", ["default-roles-list", "add-roles-menu"], {guild, guild_id: guildID}) } else { return sendRedirect(event, `/guild?guild_id=${guildID}`, 302) } diff --git a/src/web/server.js b/src/web/server.js index e2ce1b5c..3c06ede9 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -98,7 +98,7 @@ function tryStatic(event, fallthrough) { getContents: id => { if (id.match(/\.pug$/)) { const path = join(publicDir, id) - return pugSync.renderPath(event, path, {}) + return pugSync.renderPath(event, path, null, {}) } else { return fs.createReadStream(join(publicDir, id)) } diff --git a/test/test.js b/test/test.js index 4cd96277..5079ed35 100644 --- a/test/test.js +++ b/test/test.js @@ -130,6 +130,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("./addbot.test") require("../src/db/orm.test") + require("../src/js/errors.test") require("../src/web/server.test") require("../src/discord/utils.test") require("../src/matrix/kstate.test") From 59b5e05b339b4ebdefbb6e4b6b88547aebe4b864 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 24 Sep 2026 13:18:52 +1200 Subject: [PATCH 134/184] Make m->d Klipy links not produce error --- src/d2m/converters/message-to-event.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 49e3b649..e00e5c33 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -785,7 +785,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Send Klipy GIFs in customised form let isKlipyGIF = false let isOnlyKlipyGIF = false - if (message.embeds?.length === 1 && message.embeds[0].provider?.name === "Klipy" && message.embeds[0].video?.url) { + if (message.content && message.embeds?.length === 1 && message.embeds[0].provider?.name === "Klipy" && message.embeds[0].video?.url) { isKlipyGIF = true if (message.content.match(/^https?:\/\/klipy\.com[^ \n]+$/)) { isOnlyKlipyGIF = true From 77cc1e6eae7fc0ba32645732d05251d35c5b484e Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 25 Sep 2026 14:05:01 +1200 Subject: [PATCH 135/184] Better solution for previous commit --- src/d2m/converters/message-to-event.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index e00e5c33..86b310c6 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -785,9 +785,9 @@ async function messageToEvent(message, guild, options = {}, di) { // Send Klipy GIFs in customised form let isKlipyGIF = false let isOnlyKlipyGIF = false - if (message.content && message.embeds?.length === 1 && message.embeds[0].provider?.name === "Klipy" && message.embeds[0].video?.url) { + if (message.embeds?.length === 1 && message.embeds[0].provider?.name === "Klipy" && message.embeds[0].video?.url) { isKlipyGIF = true - if (message.content.match(/^https?:\/\/klipy\.com[^ \n]+$/)) { + if (message.content?.match(/^https?:\/\/klipy\.com[^ \n]+$/)) { isOnlyKlipyGIF = true } } From fab512a9bae51b59f50b575f0f9a8c41396c0ddb Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 4 Oct 2026 22:01:24 +1300 Subject: [PATCH 136/184] Fix Klipy gifs when url previews disabled --- src/d2m/converters/message-to-event.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 86b310c6..c6d5e2c6 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -783,9 +783,10 @@ async function messageToEvent(message, guild, options = {}, di) { } // Send Klipy GIFs in customised form + const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1 let isKlipyGIF = false let isOnlyKlipyGIF = false - if (message.embeds?.length === 1 && message.embeds[0].provider?.name === "Klipy" && message.embeds[0].video?.url) { + if (message.embeds?.length === 1 && message.embeds[0].provider?.name === "Klipy" && message.embeds[0].video?.url && urlPreviewEnabled) { isKlipyGIF = true if (message.content?.match(/^https?:\/\/klipy\.com[^ \n]+$/)) { isOnlyKlipyGIF = true @@ -1046,7 +1047,6 @@ async function messageToEvent(message, guild, options = {}, di) { } // Then embeds - const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1 for (const embed of message.embeds || []) { if (!urlPreviewEnabled && !message.author?.bot) { continue // show embeds for everyone if enabled, or bot users only if disabled (bots often send content in embeds) From 5794bee128c45af69a98741fe7a174a0c6e62ccb Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 4 Oct 2026 22:19:35 +1300 Subject: [PATCH 137/184] Support Discord game mentions --- package-lock.json | 274 ++++++++++---------- package.json | 2 +- src/d2m/converters/message-to-event.js | 8 + src/d2m/converters/message-to-event.test.js | 10 + test/data.js | 52 ++++ 5 files changed, 208 insertions(+), 138 deletions(-) diff --git a/package-lock.json b/package-lock.json index d9ff5cb3..6ec8ce6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0-or-later", "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.7.0", + "@cloudrac3r/discord-markdown": "^2.8.0", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/in-your-element": "^1.1.1", @@ -131,9 +131,9 @@ } }, "node_modules/@cloudrac3r/discord-markdown": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.7.0.tgz", - "integrity": "sha512-1iR9tKI2WJe8UNB+4VSh7D8m6RP7ugByuf8RNWyJwyhIrSlqQ8ljY1BKXodSvDg7seZkf7B7V2t5FfK7UpTw/A==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.8.0.tgz", + "integrity": "sha512-ciLxS6iaMxuEkEln7Y9rTpUBFf+gij8djNKS1QGrTfKoHcKABmDvcXQlPntdTx7odHjtMqV2RfvR/rnmQ25YPQ==", "license": "MIT", "dependencies": { "simple-markdown": "^0.7.3" @@ -302,9 +302,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -320,13 +320,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -342,20 +342,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -365,9 +365,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -381,9 +381,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -397,9 +397,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -413,9 +413,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -429,9 +429,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -445,9 +445,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -461,9 +461,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -477,9 +477,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -493,9 +493,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -509,9 +509,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -525,9 +525,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -543,13 +543,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -565,13 +565,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -587,13 +587,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -609,13 +609,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -631,13 +631,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -653,13 +653,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -675,13 +675,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -697,13 +697,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -717,16 +717,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -736,9 +736,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -755,9 +755,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -774,9 +774,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -1039,9 +1039,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.20.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", - "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", "dev": true, "license": "MIT", "dependencies": { @@ -1158,9 +1158,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1477,9 +1477,9 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.55", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.55.tgz", - "integrity": "sha512-ytuaRTzdnHUCXJ6KjL9MrItQX0xKncBKEeYI1Bst4+ud47eejH3cG6gaesYakjpPcUhh68XYS2YwGq3mmujFsA==", + "version": "0.38.56", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.56.tgz", + "integrity": "sha512-Hti4RZsGvF5C7SM8rF2MDHtIm0Hyfrlk84GpEA29nj91tvVVMvX+oQSCodu10t2ZcjFGofq8TX0YyC04B2E69Q==", "license": "MIT" }, "node_modules/doctypes": { @@ -1920,9 +1920,9 @@ } }, "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -2264,9 +2264,9 @@ } }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", @@ -2280,31 +2280,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -2428,9 +2428,9 @@ } }, "node_modules/supertape": { - "version": "13.6.0", - "resolved": "https://registry.npmjs.org/supertape/-/supertape-13.6.0.tgz", - "integrity": "sha512-W/lm1wk370QF0u+LrxS+BMUFNYMfRgjFC9dTKyFB522fa77pKJHwOBDiTl3uhNbsw0Sb1NQPRYk+eFICcqxedA==", + "version": "13.6.2", + "resolved": "https://registry.npmjs.org/supertape/-/supertape-13.6.2.tgz", + "integrity": "sha512-VPNYybfbgrTMhhOl4amTfEQ1NpyOPH+gq29rlHeBFyKmKMAcUa1ZEaUWbexY4YLUAzNvaFKOGVzogvZFKq4T9A==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 5fbf9287..7c9ac57f 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.7.0", + "@cloudrac3r/discord-markdown": "^2.8.0", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/in-your-element": "^1.1.1", diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index c6d5e2c6..5aa65297 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -86,6 +86,14 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) { return `@${role.name}:` } }, + game: node => { + const game = message.mention_games?.find(g => g.id === node.id) + if (!game?.name) { + return "@$" + node.id + } else { + return game.name + } + }, everyone: () => { if (message.mention_everyone) return "@room" return "@everyone" diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index aea24a83..41f40bab 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -192,6 +192,16 @@ test("message2event: manually constructed unknown roles should use fallback", as }]) }) +test("message2event: simple game mentions", async t => { + const events = await messageToEvent(data.message.simple_game_mention, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": {}, + msgtype: "m.text", + body: "do you play Minecraft" + }]) +}) + test("message2event: simple message link", async t => { let called = 0 const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, { diff --git a/test/data.js b/test/data.js index 60e9bd88..f4417781 100644 --- a/test/data.js +++ b/test/data.js @@ -1680,6 +1680,58 @@ module.exports = { flags: 0, components: [] }, + simple_game_mention: { + type: 0, + content: "do you play <@$1402418491272986635>", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2026-09-28T21:01:56.749000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1554236746756268077", + channel_id: "1160894080998461480", + author: { + id: "177438227563675648", + username: "owlbot", + avatar: "bafbcafcc0a6c2698d59a2cb567a9ecf", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: null, + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + vad_colors: null, + banner_color: null, + clan: { + identity_guild_id: "773319828295385108", + identity_enabled: true, + tag: "puzl", + badge: "1935451f265a42d57241c7bcc9555abe" + }, + primary_guild: { + identity_guild_id: "773319828295385108", + identity_enabled: true, + tag: "puzl", + badge: "1935451f265a42d57241c7bcc9555abe" + } + }, + pinned: false, + mention_everyone: false, + tts: false, + mention_games: [ + { + id: "1402418491272986635", + name: "Minecraft", + aliases: [ "Minecraft Windows 10 Edition", "Minecraft Launcher" ], + } + ] + }, unknown_role: { id: "1162374402785153106", type: 0, From 302427037b18a6a522207592d2788a0bfab9dd38 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 4 Oct 2026 22:24:13 +1300 Subject: [PATCH 138/184] Don't copy URL previews for GitHub --- src/d2m/converters/message-to-event.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 5aa65297..b3e31164 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -1076,6 +1076,10 @@ async function messageToEvent(message, guild, options = {}, di) { continue // If discord creates an embed preview for a discord channel link, don't copy that embed } + if (embed.provider?.name === "GitHub") { + continue // GitHub url previews are very large and fairly useless + } + if (embed.url && spoilers.some(sp => sp.match(/\bhttps?:\/\/[a-z]/))) { // If the original message had spoilered URLs, don't generate any embeds for links. // This logic is the same as the Discord desktop client. It doesn't match specific embeds to specific spoilered text, it's all or nothing. From c25b5a19c62d3c7e13d9d8c5ccdaafe55f9ba7c7 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 7 Oct 2026 22:52:42 +1300 Subject: [PATCH 139/184] Support Discord @time --- package-lock.json | 27 +++- package.json | 2 +- src/d2m/converters/message-to-event.js | 13 ++ .../message-to-event.test.components.js | 4 +- src/d2m/converters/message-to-event.test.js | 22 +++ src/discord/time.js | 51 +++++++ src/discord/time.test.js | 48 ++++++ src/web/pug/time.pug | 143 ++++++++++++++++++ src/web/routes/time.js | 31 ++++ src/web/server.js | 1 + test/data.js | 50 ++++++ test/test.js | 1 + 12 files changed, 383 insertions(+), 10 deletions(-) create mode 100644 src/discord/time.js create mode 100644 src/discord/time.test.js create mode 100644 src/web/pug/time.pug create mode 100644 src/web/routes/time.js diff --git a/package-lock.json b/package-lock.json index 6ec8ce6f..32e7e880 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0-or-later", "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.8.0", + "@cloudrac3r/discord-markdown": "^2.9.0", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/in-your-element": "^1.1.1", @@ -55,6 +55,19 @@ "node": ">=22" } }, + "../discord-markdown": { + "name": "@cloudrac3r/discord-markdown", + "version": "2.9.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "simple-markdown": "^0.7.3" + }, + "devDependencies": { + "@cloudrac3r/tap-dot": "^2.0.2", + "supertape": "^13.6.2" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -131,9 +144,9 @@ } }, "node_modules/@cloudrac3r/discord-markdown": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.8.0.tgz", - "integrity": "sha512-ciLxS6iaMxuEkEln7Y9rTpUBFf+gij8djNKS1QGrTfKoHcKABmDvcXQlPntdTx7odHjtMqV2RfvR/rnmQ25YPQ==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.9.0.tgz", + "integrity": "sha512-0S22mNlT9rPhYnxP+qzKWayVe+hFWcEqxVCviF/pyzWAb9nLaBZkIVk0jHTImoA1MY0mqx1uMKjIcxVHkEZe3A==", "license": "MIT", "dependencies": { "simple-markdown": "^0.7.3" @@ -1049,9 +1062,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" diff --git a/package.json b/package.json index 7c9ac57f..72004cfb 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.8.0", + "@cloudrac3r/discord-markdown": "^2.9.0", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/in-your-element": "^1.1.1", diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index b3e31164..f192aff5 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -18,6 +18,8 @@ const lottie = sync.require("../actions/lottie") const mxUtils = sync.require("../../matrix/utils") /** @type {import("../../discord/utils")} */ const dUtils = sync.require("../../discord/utils") +/** @type {import("../../discord/time")} */ +const discordTime = sync.require("../../discord/time") /** @type {import("./find-mentions")} */ const findMentions = sync.require("./find-mentions") /** @type {import("../../discord/interactions/poll-responses")} */ @@ -72,6 +74,17 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) { return `:${node.name}:` } }, + /** @param {{timestamp: number, style: string | null}} node */ + timestamp: node => { + const text = discordTime.convertDiscordTime(node.timestamp, node.style) + if (useHTML) { + let url = `${reg.ooye.bridge_origin}/time/${node.timestamp}` + if (node.style) url += `/${node.style}` + return tag`${text}` + } else { + return text + } + }, role: node => { const role = guild.roles.find(r => r.id === node.id) if (!role) { diff --git a/src/d2m/converters/message-to-event.test.components.js b/src/d2m/converters/message-to-event.test.components.js index 1ef83c36..47e27b5c 100644 --- a/src/d2m/converters/message-to-event.test.components.js +++ b/src/d2m/converters/message-to-event.test.components.js @@ -44,7 +44,7 @@ test("message2event components: pk question mark output", async t => { + "\n| Same hat" + "\n| 🖼️ Image: https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png" + "\n" - + "\n-# Original Message ID: 1466556003645657118 · ", + + "\n-# Original Message ID: 1466556003645657118 · 29 January 2026 at 10:09 pm (UTC)", format: "org.matrix.custom.html", formatted_body: "
    " + "

    Lillith (INX)

    " @@ -73,7 +73,7 @@ test("message2event components: pk question mark output", async t => { + "
    " + "

    Same hat

    " + `🖼️ Image: image.png
    ` - + "

    Original Message ID: 1466556003645657118 · <t:1769724599:f>

    ", + + "

    Original Message ID: 1466556003645657118 · 29 January 2026 at 10:09 pm (UTC)

    ", "m.mentions": {}, msgtype: "m.text", }]) diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 41f40bab..397be392 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -202,6 +202,28 @@ test("message2event: simple game mentions", async t => { }]) }) +test("message2event: timestamps", async t => { + const events = await messageToEvent(data.message.timestamps, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "4 Oct 2026, 9:33:33 am (UTC)" + + "\n4 October 2026 at 9:33 am (UTC)" + + "\nSunday, 4 October 2026 at 9:34 am (UTC)" + + "\n9:34:08 am (UTC)" + + "\n3 October 2026 at 9:30 am (UTC)" + + "\n24 December 2026 at 11:00 pm (UTC)", + format: "org.matrix.custom.html", + formatted_body: `4 Oct 2026, 9:33:33 am (UTC)` + + `
    4 October 2026 at 9:33 am (UTC)` + + `
    Sunday, 4 October 2026 at 9:34 am (UTC)` + + `
    9:34:08 am (UTC)` + + `
    3 October 2026 at 9:30 am (UTC)` + + `
    24 December 2026 at 11:00 pm (UTC)`, + "m.mentions": {} + }]) +}) + test("message2event: simple message link", async t => { let called = 0 const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, { diff --git a/src/discord/time.js b/src/discord/time.js new file mode 100644 index 00000000..d0f7a044 --- /dev/null +++ b/src/discord/time.js @@ -0,0 +1,51 @@ +// @ts-check + +const assert = require("assert").strict + +/* + https://docs.discord.com/developers/reference#message-formatting + + Trying to make these formats fairly culture independent since the Matrix text will show up the same for everyone. + That means no ambiguous short dates like 03/04/26. The shortest allowable date is "medium", which spells the month. + It also means no 24-hour time, since this is ambiguous to 12-hour time users. Times must have am/pm. + Relative time is not supported for text conversion and will fall back to the default, "f". + + See the test file for sample outputs. +*/ + +/** @type {Map} discord style letter abbreviation -> Intl.DateTimeFormat constructor options */ +const styles = new Map([ + ["t", {timeStyle: "short", timeZone: "UTC"}], + ["T", {timeStyle: "medium", timeZone: "UTC"}], + ["d", {dateStyle: "medium", timeZone: "UTC"}], + ["D", {dateStyle: "long", timeZone: "UTC"}], + ["f", {dateStyle: "long", timeStyle: "short", timeZone: "UTC"}], + ["F", {dateStyle: "full", timeStyle: "short", timeZone: "UTC"}], + ["s", {dateStyle: "medium", timeStyle: "short", timeZone: "UTC"}], + ["S", {dateStyle: "medium", timeStyle: "medium", timeZone: "UTC"}] +]) +const defaultStyle = "f" + +/** + * @param {string | null | undefined} [style] + * @returns {Intl.DateTimeFormatOptions} */ +function getFormatOptions(style) { + if (!style || !styles.has(style)) style = defaultStyle + const formatOptions = styles.get(style) + assert(formatOptions) + return formatOptions +} + +/** + * @param {number} timestamp unix time in seconds + * @param {string | null | undefined} [style] discord style letter abbreviation, if present + * @returns {string} + */ +function convertDiscordTime(timestamp, style) { + const formatter = new Intl.DateTimeFormat("en-NZ", getFormatOptions(style)) + const d = new Date(timestamp * 1000) + return formatter.format(d) + " (UTC)" +} + +module.exports.getFormatOptions = getFormatOptions +module.exports.convertDiscordTime = convertDiscordTime diff --git a/src/discord/time.test.js b/src/discord/time.test.js new file mode 100644 index 00000000..b889682e --- /dev/null +++ b/src/discord/time.test.js @@ -0,0 +1,48 @@ +// @ts-check + +const time = require("./time") +const {test} = require("supertape") + +const d = new Date("2026-02-14T14:23:06.000Z") +const seconds = d.getTime() / 1000 + +test("time: default style", t => { + t.equal(time.convertDiscordTime(seconds), "14 February 2026 at 2:23 pm (UTC)") +}) + +test("time: t style", t => { + t.equal(time.convertDiscordTime(seconds, "t"), "2:23 pm (UTC)") +}) + +test("time: T style", t => { + t.equal(time.convertDiscordTime(seconds, "T"), "2:23:06 pm (UTC)") +}) + +test("time: d style", t => { + t.equal(time.convertDiscordTime(seconds, "d"), "14 Feb 2026 (UTC)") +}) + +test("time: D style", t => { + t.equal(time.convertDiscordTime(seconds, "D"), "14 February 2026 (UTC)") +}) + +test("time: f style", t => { + t.equal(time.convertDiscordTime(seconds, "f"), "14 February 2026 at 2:23 pm (UTC)") +}) + +test("time: F style", t => { + t.equal(time.convertDiscordTime(seconds, "F"), "Saturday, 14 February 2026 at 2:23 pm (UTC)") +}) + +test("time: s style", t => { + t.equal(time.convertDiscordTime(seconds, "s"), "14 Feb 2026, 2:23 pm (UTC)") +}) + +test("time: S style", t => { + t.equal(time.convertDiscordTime(seconds, "S"), "14 Feb 2026, 2:23:06 pm (UTC)") +}) + +test("time: R style", t => { + // Discord would display this as relative time, but I don't want to do that in a static message. + t.equal(time.convertDiscordTime(seconds, "R"), "14 February 2026 at 2:23 pm (UTC)") +}) diff --git a/src/web/pug/time.pug b/src/web/pug/time.pug new file mode 100644 index 00000000..1d71c228 --- /dev/null +++ b/src/web/pug/time.pug @@ -0,0 +1,143 @@ +//- requires timestamp, text, style + +- const WIDTH = 600 + +doctype html +html(lang="en") + head + + + title= `Converting time ${text}...` + style. + body { + font-family: "Segoe UI", sans-serif; + margin: 0 auto; + padding: 4px; + font-size: 18px; + background-color: #f0f2f5; /* element compound tertiary off-white */ + max-width: !{WIDTH}px; + } + .only-script { + display: none; + } + .yes-script .only-script { + display: revert; + } + .local { + line-height: 1; + width: auto; + height: auto; + max-width: 100%; + } + .original { + color: #004c84; + } + .remaining { + font-weight: bold; + } + .primary-remaining { + font-size: 25px; + } + @media (prefers-color-scheme: dark) { + body { + color: #e3e3e3; + background-color: #131416; /* element compound bg but desaturated */ + } + .original { + color: #99c9ec; + } + .local { + fill: #fff; + } + .remaining { + color: #fff; + } + } + body + noscript + | You must enable JavaScript to convert to your local time. + .only-script + p In your local time, #[span.original= text] is... + if style === "R" + p.primary-remaining #[span#remaining.remaining]#[span#direction] #[span#tick.tick] + svg.local(viewBox=`0 -130 ${WIDTH} 253`) + text(style="font-size: 160px; font-weight: bold;")#localTime + text(y=70 style="font-size: 45px; font-weight: bold;")#localDate + text(y=115 style="font-size: 22px;")#localTz + if style !== "R" + p #[span#remaining.remaining]#[span#direction] #[span#tick.tick] + script. + document.body.classList.add("yes-script") + const WIDTH = !{WIDTH} + const timestamp = !{timestamp} + const style = "!{style}" + const formatOptions = !{JSON.stringify(formatOptions)} + const d = new Date(timestamp * 1000) + const UNITS = [ + ["week", 7*24*60*60*1000], + ["day", 24*60*60*1000], + ["hour", 60*60*1000], + ["minute", 60*1000], + ] + const showSeconds = formatOptions.timeStyle === "medium" || style === "R" + if (showSeconds) UNITS.push(["second", 1000]) + + if (formatOptions.timeStyle) { + localTime.textContent = new Intl.DateTimeFormat(undefined, {timeStyle: formatOptions.timeStyle}).format(d) + } else { + localTime.textContent = "Any time on" + localTime.style.fill = "#888" + } + if (formatOptions.dateStyle) { + localDate.textContent = new Intl.DateTimeFormat(undefined, {dateStyle: formatOptions.dateStyle}).format(d) + } else { + localDate.textContent = "On any date" + localDate.style.fill = "#888" + } + const localTzText = new Intl.DateTimeFormat(undefined, {timeZoneName: "long"}).formatToParts(d).find(p => p.type === "timeZoneName").value + const localTitleText = new Intl.DateTimeFormat(undefined, {...formatOptions, timeZone: undefined}).format(d) + document.title = localTitleText + localTz.textContent = localTzText + for (const st of [localTime, localDate, localTz]) { + const l = st.getComputedTextLength() + if (l > WIDTH) { + st.style.fontSize = Math.floor(parseInt(st.style.fontSize) * WIDTH / l) + "px"; + } + } + + let frame = 0 + function updateTimeRemaining() { + const now = new Date() + const timeForRemaining = new Date(d) + if (!formatOptions.dateStyle) { + timeForRemaining.setFullYear(now.getFullYear()) + timeForRemaining.setMonth(now.getMonth()) + timeForRemaining.setDate(now.getDate()) + } + let difference = Math.abs(timeForRemaining.getTime() - now.getTime()) + + let foundSignificantField = false + let remainingText = UNITS.map(([unit, duration], index) => { + const multiplier = Math.floor(difference / duration) + difference -= multiplier * duration + if (multiplier > 0 || foundSignificantField) { + foundSignificantField = true + return multiplier + "\u00a0" + unit + (multiplier !== 1 ? "s" : "") + } + }).filter(s => s).join(", ") + if (remainingText) { + remaining.textContent = remainingText + direction.textContent = + ( timeForRemaining.getTime() > now.getTime() + ? "\u00a0from\u00a0now." + : "\u00a0ago.") + } else { + remaining.textContent = "Right now!" + direction.textContent = "" + } + if (!showSeconds) { + tick.textContent = String.fromCodePoint(11028 + ++frame % 2) + } + } + updateTimeRemaining() + setInterval(updateTimeRemaining, 1000) diff --git a/src/web/routes/time.js b/src/web/routes/time.js new file mode 100644 index 00000000..ad89b584 --- /dev/null +++ b/src/web/routes/time.js @@ -0,0 +1,31 @@ +// @ts-check + +const {defineEventHandler, getValidatedRouterParams, H3Event, setResponseHeader} = require("h3") +const {as, db, sync} = require("../../passthrough") +const {z} = require("zod") + +/** @type {import("../pug-sync")} */ +const pugSync = sync.require("../pug-sync") +/** @type {import("../../discord/time")} */ +const discordTime = sync.require("../../discord/time") + +const schema = { + time: z.object({ + timestamp: z.coerce.number(), + style: z.string().optional() + }) +} + +as.router.get("/time/:timestamp", defineEventHandler(async event => { + const {timestamp} = await getValidatedRouterParams(event, schema.time.parse) + const formatOptions = discordTime.getFormatOptions() + const text = discordTime.convertDiscordTime(timestamp) + return pugSync.render(event, "time.pug", {text, timestamp, formatOptions}) +})) + +as.router.get("/time/:timestamp/:style", defineEventHandler(async event => { + const {timestamp, style} = await getValidatedRouterParams(event, schema.time.parse) + const formatOptions = discordTime.getFormatOptions(style) + const text = discordTime.convertDiscordTime(timestamp, style) + return pugSync.render(event, "time.pug", {text, timestamp, style, formatOptions}) +})) diff --git a/src/web/server.js b/src/web/server.js index 3c06ede9..83aa5884 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -141,3 +141,4 @@ sync.require("./routes/oauth") sync.require("./routes/opt-out") sync.require("./routes/password") sync.require("./routes/stats") +sync.require("./routes/time") diff --git a/test/data.js b/test/data.js index f4417781..bcc9fdb3 100644 --- a/test/data.js +++ b/test/data.js @@ -1732,6 +1732,56 @@ module.exports = { } ] }, + timestamps: { + type: 0, + content: "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2026-10-04T09:34:27.909000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1556238063774990499", + channel_id: "1160894080998461480", + author: { + 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, + vad_colors: 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" + } + }, + pinned: false, + mention_everyone: false, + tts: false + }, unknown_role: { id: "1162374402785153106", type: 0, diff --git a/test/test.js b/test/test.js index 5079ed35..74d99b1f 100644 --- a/test/test.js +++ b/test/test.js @@ -132,6 +132,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("../src/db/orm.test") require("../src/js/errors.test") require("../src/web/server.test") + require("../src/discord/time.test") require("../src/discord/utils.test") require("../src/matrix/kstate.test") require("../src/matrix/api.test") From b49c6e8fd4a51900fc70da9dce30cadf6971e448 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 8 Oct 2026 02:19:26 +1300 Subject: [PATCH 140/184] Add //time command for m->d scheduling --- docs/developer-orientation.md | 4 +- package-lock.json | 20 ++++ package.json | 2 + src/matrix/matrix-command-handler.js | 137 +++++++++++++++++++++++++-- start.js | 5 + 5 files changed, 159 insertions(+), 9 deletions(-) diff --git a/docs/developer-orientation.md b/docs/developer-orientation.md index 839b4af4..27ddb7de 100644 --- a/docs/developer-orientation.md +++ b/docs/developer-orientation.md @@ -89,7 +89,7 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin # Dependency justification -Total transitive production dependencies: 112 +Total transitive production dependencies: 114 ### 🦕 @@ -108,6 +108,7 @@ Total transitive production dependencies: 112 * (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.) * (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs. * (0) @cloudrac3r/stream-type: Determine type of Matrix files that don't specify it in info. Switched from stream-mime-type to this. +* (0) @cloudrac3r/tell-me-when: Parse English natural language dates and times for the m->d //time command. * (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works. * (3) @stackoverflow/stacks: Stack Overflow design language and icons. * (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer. @@ -125,6 +126,7 @@ Total transitive production dependencies: 112 * (1) mime-types: List of mime type mappings. Needed to serve static files. * (0) prettier-bytes: It does what I want and has no dependencies. * (0) snowtransfer: Discord API library with bring-your-own-caching that I trust. +* (0) temporal-polyfill-lite: Needed for the //time command. The Temporal API has only been stabilised as of Node.js v26. * (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well. * (0) uqr: QR code SVG generator. Used on the website to scan in an invite link. * (0) xxhash-wasm: Used where cryptographically secure hashing is not required. diff --git a/package-lock.json b/package-lock.json index 32e7e880..5a60f1c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/stream-type": "^1.0.0", + "@cloudrac3r/tell-me-when": "^2.0.0", "@cloudrac3r/turndown": "^7.1.5", "@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks-icons": "^6.0.2", @@ -39,6 +40,7 @@ "prettier-bytes": "^1.0.4", "sharp": "^0.35.3", "snowtransfer": "^0.19.0", + "temporal-polyfill-lite": "^0.4.3", "try-to-catch": "^4.0.5", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", @@ -280,6 +282,18 @@ "node": ">=8.0.0" } }, + "node_modules/@cloudrac3r/tell-me-when": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@cloudrac3r/tell-me-when/-/tell-me-when-2.0.0.tgz", + "integrity": "sha512-U+TEOTe6f/vSIeD/QP0VPrkLrhnFtQs2euxBOAaqC8rzQKu8nNoKSFuhipT6DyuCZH48B58+qrPMmH2w2IgMhA==", + "license": "MIT", + "bin": { + "tell-me-when": "dist/src/cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@cloudrac3r/turndown": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.5.tgz", @@ -2516,6 +2530,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/temporal-polyfill-lite": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/temporal-polyfill-lite/-/temporal-polyfill-lite-0.4.3.tgz", + "integrity": "sha512-Lw/de9tydKmrU0icQy6Z+abX5mzDk4iqyt6Xp2iDDxY6SGISiuhuP3TcfWlYveM1FN1I7FjT5nG+Kh9eCiSnVg==", + "license": "MIT" + }, "node_modules/test-exclude": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", diff --git a/package.json b/package.json index 72004cfb..4c9de0ed 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/stream-type": "^1.0.0", + "@cloudrac3r/tell-me-when": "^2.0.0", "@cloudrac3r/turndown": "^7.1.5", "@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks-icons": "^6.0.2", @@ -48,6 +49,7 @@ "prettier-bytes": "^1.0.4", "sharp": "^0.35.3", "snowtransfer": "^0.19.0", + "temporal-polyfill-lite": "^0.4.3", "try-to-catch": "^4.0.5", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index aa80b9c3..de81899a 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -3,8 +3,9 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") const Ty = require("../types") -const {pipeline} = require("stream").promises const sharp = require("sharp") +const tw = require("@cloudrac3r/tell-me-when") +const {tag} = require("@cloudrac3r/html-template-tag") const {discord, sync, db, select} = require("../passthrough") /** @type {import("./api")}) */ @@ -13,8 +14,9 @@ const api = sync.require("./api") const mxUtils = sync.require("./utils") /** @type {import("../discord/utils")} */ const dUtils = sync.require("../discord/utils") -/** @type {import("./kstate")} */ -const ks = sync.require("./kstate") +/** @type {import("../d2m/actions/retrigger")} */ +const retrigger = sync.require("../d2m/actions/retrigger") + const {reg} = require("./read-registration") const PREFIXES = ["//", "/"] @@ -33,7 +35,17 @@ function getSlotCount(tier) { return TIER_EMOJI_SLOTS.get(tier) || 50 } -let buttons = [] +/** + * @typedef Button + * @property {string} roomID + * @property {string} eventID + * @property {string} mxid + * @property {string} key + * @property {(any) => any} resolve + * @property {number} created + */ + +const buttonStore = sync.remember(() => /** @type {{buttons: Button[]}} */ ({buttons: []})) /** * @param {string} roomID where to add the button @@ -51,22 +63,23 @@ async function addButton(roomID, eventID, key, mxid) { } }) return new Promise(resolve => { - buttons.push({roomID, eventID, mxid, key, resolve, created: Date.now()}) + buttonStore.buttons.push({roomID, eventID, mxid, key, resolve, created: Date.now()}) }) } // Clear out old buttons every so often to free memory setInterval(() => { const now = Date.now() - buttons = buttons.filter(b => now - b.created < 2*60*60*1000) + buttonStore.buttons = buttonStore.buttons.filter(b => now - b.created < 2*60*60*1000) }, 10*60*1000).unref() /** @param {Ty.Event.Outer} event */ function onReactionAdd(event) { - const button = buttons.find(b => b.roomID === event.room_id && b.mxid === event.sender && b.eventID === event.content["m.relates_to"]?.event_id && b.key === event.content["m.relates_to"]?.key) + const button = buttonStore.buttons.find(b => b.roomID === event.room_id && b.mxid === event.sender && b.eventID === event.content["m.relates_to"]?.event_id && b.key === event.content["m.relates_to"]?.key) if (button) { - buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again + buttonStore.buttons = buttonStore.buttons.filter(b => b !== button) // remove button data so it can't be clicked again button.resolve(event) + return true } } @@ -318,6 +331,114 @@ const commands = [{ }) } ) +}, { + aliases: ["time"], + execute: replyctx( + async (event, realBody, words, ctx) => { + // Guard + /** @type {string} */ // @ts-ignore + const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() + const channel = discord.channels.get(channelID) + const guildID = channel?.["guild_id"] + if (!guildID) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "This room isn't bridged to the other side." + }) + } + + // Get user timezone + const profile = await api.getProfile(event.sender) + const senderTimeZone = profile?.["m.tz"] + if (!senderTimeZone) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "This command converts a time in your local time zone for everybody else. But I don't know your time zone. Please set your time zone (hopefully possible in your client's settings/account menu) and try again." + }) + } + + try { + var now = Temporal.Now.zonedDateTimeISO(senderTimeZone) + } catch (e) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + ...new mxUtils.MatrixStringBuilder().add( + `The time zone on your account, "${senderTimeZone}", is not valid. It needs to be an IANA time zone value, with a slash. See yours here: https://time.is/your_time_zone`, + tag`The time zone on your account, ${senderTimeZone}, is not valid. It needs to be an IANA time zone value with a slash. See yours here.` + ).get() + }) + } + + const input = words.slice(1).join(" ").trim() + if (!input) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "This command converts a time in your local time zone for everybody else. Use this command with a time or date (English natural language)." + }) + } + + try { + var parsed = tw.parse(input) + var when = tw.tellMeWhen(input, {now}) + } catch (e) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "I didn't understand your input. If you think your input was reasonable, please report this as a bug." + }) + } + + const messageID = select("event_message", "message_id", {event_id: event.event_id}).pluck().get() + + const hasDate = parsed.some(x => Array.isArray(x) && x[0].match(/year|month|week|date|day/i)) + const hasTime = parsed.some(x => Array.isArray(x) && x[0].match(/hour|minute|second/i)) + const colonStyle = + ( hasDate && hasTime ? ":F" + : hasDate ? ":D" + : ":t") + + /** @param {Temporal.ZonedDateTime} z */ + function convertOne(z) { + return "" + } + + if (Array.isArray(when)) { // convert range + var content = `From ${convertOne(when[0])} to ${convertOne(when[1])}` + } else { // convert single time + var content = `${convertOne(when)}` + } + + /** @type {DiscordTypes.RESTPostAPIChannelMessageJSONBody} */ + const message = { + flags: DiscordTypes.MessageFlags.IsComponentsV2, + components: [{ + type: DiscordTypes.ComponentType.Container, + components: [{ + type: DiscordTypes.ComponentType.TextDisplay, + content: `## ${content}\n-# Wrong? 🗑️ to delete` + }] + }] + } + if (messageID) { + Object.assign(message, { + message_reference: { + type: DiscordTypes.MessageReferenceType.Default, + message_id: messageID, + } + }) + } + const sent = await discord.snow.channel.createMessage(channelID, message) + if (!await retrigger.waitForMessage(sent.id)) return + const sentEventID = select("event_message", "event_id", {message_id: sent.id}, "ORDER BY reaction_part").pluck().get() + assert(sentEventID) + addButton(event.room_id, sentEventID, "🗑️", event.sender).then(() => { + discord.snow.channel.deleteMessage(channelID, sent.id).catch(() => {}) + }) + } + ) }] diff --git a/start.js b/start.js index 2ab79cbe..728c8aff 100755 --- a/start.js +++ b/start.js @@ -1,6 +1,11 @@ #!/usr/bin/env node // @ts-check +// Temporal API stabilised in Node.js 26. Previous versions can use `--harmony-temporal`, but that's jank, so have a polyfill. +if (!("Temporal" in globalThis)) { + require("temporal-polyfill-lite/global") +} + const fs = require("fs") const sqlite = require("better-sqlite3") const migrate = require("./src/db/migrate") From a5032776731a458f4678ea5f0ab6514374fde121 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 8 Oct 2026 02:54:46 +1300 Subject: [PATCH 141/184] Use hyperlinked times for scheduled events --- src/d2m/converters/message-to-event.js | 43 +++++++++++++++++--------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index f192aff5..4314a0e9 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -883,32 +883,47 @@ async function messageToEvent(message, guild, options = {}, di) { // Then scheduled events if (message.content && di?.snow) { - for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old + for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})|\/events\/([0-9]+)\/([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old + /** @type {DiscordTypes.APIGuildScheduledEvent | undefined} */ + let event let invite - try { - invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) - } catch (e) { - // Skip expired/invalid invites and events - if (e.message === "Unknown Invite") { - break - } else { + + if (match[1]) { + try { + invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) + event = invite.guild_scheduled_event + } catch (e) { + // Skip expired/invalid invites and events + if (e.message === "Unknown Invite") { + break + } else { + throw e + } + } + } else if (match[3]) { + try { + event = await di.snow.guildScheduledEvent.getGuildScheduledEvent(match[3], match[4]) + } catch (e) { throw e } } - const event = invite.guild_scheduled_event if (!event) continue // the event ID provided was not valid - const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric", timeZone: reg.ooye.time_zone}) // 9 June at 3:00 pm NZT const rep = new mxUtils.MatrixStringBuilder() + const unixSeconds = Math.floor(new Date(event.scheduled_start_time).getTime() / 1000) // Add time if (event.scheduled_end_time) { - // @ts-ignore - no definition available for formatRange - rep.addParagraph(`Scheduled Event - ${formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time))}`) + const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric", timeZone: reg.ooye.time_zone}) // 9 June at 3:00 pm NZT + var displayTime = formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time)) } else { - rep.addParagraph(`Scheduled Event - ${formatter.format(new Date(event.scheduled_start_time))}`) + var displayTime = discordTime.convertDiscordTime(unixSeconds, "F") } + rep.addParagraph( + `Scheduled Event - ${displayTime}`, + tag`Scheduled Event - ${displayTime}` + ) // Add details rep.addLine(`## ${event.name}`, tag`${event.name}`) @@ -917,7 +932,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Add location if (event.entity_metadata?.location) { rep.addParagraph(`📍 ${event.entity_metadata.location}`) - } else if (invite.channel?.name) { + } else if (invite?.channel?.name) { const roomID = select("channel_room", "room_id", {channel_id: invite.channel.id}).pluck().get() if (roomID) { const via = await getViaServersMemo(roomID) From 9d7f8888a7253a01997ae3ceef2875556c0b760e Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 13 Oct 2026 21:59:49 +1300 Subject: [PATCH 142/184] Fix scheduled event tests --- src/d2m/converters/message-to-event.js | 4 ++-- src/d2m/converters/message-to-event.test.js | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 4314a0e9..45d97468 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -915,8 +915,8 @@ async function messageToEvent(message, guild, options = {}, di) { // Add time if (event.scheduled_end_time) { - const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric", timeZone: reg.ooye.time_zone}) // 9 June at 3:00 pm NZT - var displayTime = formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time)) + const formatter = new Intl.DateTimeFormat("en-NZ", {weekday: "long", month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZone: "UTC"}) // 9 June at 3:00 pm NZT + var displayTime = formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time)) + " (UTC)" } else { var displayTime = discordTime.convertDiscordTime(unixSeconds, "F") } diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 397be392..a7b17335 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1589,12 +1589,12 @@ test("message2event: irl invite event renders embed", async t => { { $type: "m.room.message", msgtype: "m.notice", - body: `| Scheduled Event - 8 June at 10:00 pm NZT – 9 June at 12:00 am NZT` + body: `| Scheduled Event - Sunday, 8 June, 10:00 am – 12:00 pm (UTC)` + `\n| ## forest exploration` + `\n| ` + `\n| 📍 the dark forest`, format: "org.matrix.custom.html", - formatted_body: `

    Scheduled Event - 8 June at 10:00 pm NZT – 9 June at 12:00 am NZT

    ` + formatted_body: `

    Scheduled Event - Sunday, 8 June, 10:00 am – 12:00 pm (UTC)

    ` + `forest exploration` + `

    📍 the dark forest

    `, "m.mentions": {} @@ -1622,13 +1622,13 @@ test("message2event: vc invite event renders embed", async t => { { $type: "m.room.message", msgtype: "m.notice", - body: `| Scheduled Event - 9 June at 3:00 pm NZT` + body: `| Scheduled Event - Monday, 9 June 2025 at 3:00 am (UTC)` + `\n| ## Cooking (Netrunners)` + `\n| Short circuited brain interfaces actually just means your brain is medium rare, yum.` + `\n| ` + `\n| 🔊 Cooking`, format: "org.matrix.custom.html", - formatted_body: `

    Scheduled Event - 9 June at 3:00 pm NZT

    ` + formatted_body: `

    Scheduled Event - Monday, 9 June 2025 at 3:00 am (UTC)

    ` + `Cooking (Netrunners)
    Short circuited brain interfaces actually just means your brain is medium rare, yum.` + `

    🔊 Cooking

    `, "m.mentions": {} @@ -1664,13 +1664,13 @@ test("message2event: vc invite event renders embed with room link", async t => { { $type: "m.room.message", msgtype: "m.notice", - body: `| Scheduled Event - 9 June at 3:00 pm NZT` + body: `| Scheduled Event - Monday, 9 June 2025 at 3:00 am (UTC)` + `\n| ## Cooking (Netrunners)` + `\n| Short circuited brain interfaces actually just means your brain is medium rare, yum.` + `\n| ` + `\n| 🔊 Hey. - https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe?via=cadence.moe`, format: "org.matrix.custom.html", - formatted_body: `

    Scheduled Event - 9 June at 3:00 pm NZT

    ` + formatted_body: `

    Scheduled Event - Monday, 9 June 2025 at 3:00 am (UTC)

    ` + `Cooking (Netrunners)
    Short circuited brain interfaces actually just means your brain is medium rare, yum.` + `

    🔊 Hey. - Hey.

    `, "m.mentions": {} From a4f8f93ad9cdb27ca77dd390d482ea5f0dbcd3e7 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 13 Oct 2026 22:06:27 +1300 Subject: [PATCH 143/184] Put "and" between duration units if there's only 2 --- src/web/pug/time.pug | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/web/pug/time.pug b/src/web/pug/time.pug index 1d71c228..ed37bec4 100644 --- a/src/web/pug/time.pug +++ b/src/web/pug/time.pug @@ -67,7 +67,6 @@ html(lang="en") if style !== "R" p #[span#remaining.remaining]#[span#direction] #[span#tick.tick] script. - document.body.classList.add("yes-script") const WIDTH = !{WIDTH} const timestamp = !{timestamp} const style = "!{style}" @@ -104,6 +103,7 @@ html(lang="en") st.style.fontSize = Math.floor(parseInt(st.style.fontSize) * WIDTH / l) + "px"; } } + document.body.classList.add("yes-script") let frame = 0 function updateTimeRemaining() { @@ -116,15 +116,17 @@ html(lang="en") } let difference = Math.abs(timeForRemaining.getTime() - now.getTime()) + // Temporal.Duration isn't Baseline https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration let foundSignificantField = false - let remainingText = UNITS.map(([unit, duration], index) => { + const remainingTextParts = UNITS.map(([unit, duration], index) => { const multiplier = Math.floor(difference / duration) difference -= multiplier * duration if (multiplier > 0 || foundSignificantField) { foundSignificantField = true return multiplier + "\u00a0" + unit + (multiplier !== 1 ? "s" : "") } - }).filter(s => s).join(", ") + }).filter(s => s) + const remainingText = remainingTextParts.length === 2 ? remainingTextParts.join(" and ") : remainingTextParts.join(", ") if (remainingText) { remaining.textContent = remainingText direction.textContent = From 08dea68e46089fc0eb799ad3dd3204cc977d6463 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 13 Oct 2026 22:16:43 +1300 Subject: [PATCH 144/184] Support npm v12 --- package-lock.json | 26 ++++++-------------------- package.json | 3 +++ 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5a60f1c9..dac3626e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,19 +57,6 @@ "node": ">=22" } }, - "../discord-markdown": { - "name": "@cloudrac3r/discord-markdown", - "version": "2.9.0", - "extraneous": true, - "license": "MIT", - "dependencies": { - "simple-markdown": "^0.7.3" - }, - "devDependencies": { - "@cloudrac3r/tap-dot": "^2.0.2", - "supertape": "^13.6.2" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -146,9 +133,9 @@ } }, "node_modules/@cloudrac3r/discord-markdown": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.9.0.tgz", - "integrity": "sha512-0S22mNlT9rPhYnxP+qzKWayVe+hFWcEqxVCviF/pyzWAb9nLaBZkIVk0jHTImoA1MY0mqx1uMKjIcxVHkEZe3A==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.9.1.tgz", + "integrity": "sha512-sYaDfRBaS7uMVQx4wJwT68F6OzJZX84DF2XvHkQr8vdwTCvnpeaT26obqvzNawVwVoQ4xjIMZxafgMrAEK4Oxw==", "license": "MIT", "dependencies": { "simple-markdown": "^0.7.3" @@ -283,9 +270,9 @@ } }, "node_modules/@cloudrac3r/tell-me-when": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@cloudrac3r/tell-me-when/-/tell-me-when-2.0.0.tgz", - "integrity": "sha512-U+TEOTe6f/vSIeD/QP0VPrkLrhnFtQs2euxBOAaqC8rzQKu8nNoKSFuhipT6DyuCZH48B58+qrPMmH2w2IgMhA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@cloudrac3r/tell-me-when/-/tell-me-when-2.0.1.tgz", + "integrity": "sha512-nm1Ao/vpHDn2YMivnc9EVr/QrcT5IG6G7o1Ffh31YafIo9Cc3bSN1jPLL9eOuKKLCYT1/DZP7Nh8PXAN371bAQ==", "license": "MIT", "bin": { "tell-me-when": "dist/src/cli.js" @@ -1175,7 +1162,6 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", - "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" diff --git a/package.json b/package.json index 4c9de0ed..e807089a 100644 --- a/package.json +++ b/package.json @@ -71,5 +71,8 @@ "addbot": "node addbot.js", "test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js | tap-dot", "cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/m2d/event-dispatcher.js -x src/matrix/file.js -x src/matrix/api.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow" + }, + "allowScripts": { + "better-sqlite3": true } } From f19f24314a392c5986ac5726b9d3f2fe7ec34e5d Mon Sep 17 00:00:00 2001 From: "N0\\A" Date: Tue, 13 Oct 2026 11:51:27 +0200 Subject: [PATCH 145/184] Add customisable @room mention roles --- src/db/migrations/0041-room-mention-role.sql | 5 ++ src/db/orm-defs.d.ts | 1 + src/m2d/converters/event-to-message.js | 9 ++- src/m2d/converters/event-to-message.test.js | 2 +- src/web/pug/guild.pug | 66 ++++++++++++++++++++ src/web/routes/guild-settings.js | 19 ++++++ src/web/routes/guild-settings.test.js | 2 +- src/web/routes/guild.js | 2 +- 8 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 src/db/migrations/0041-room-mention-role.sql diff --git a/src/db/migrations/0041-room-mention-role.sql b/src/db/migrations/0041-room-mention-role.sql new file mode 100644 index 00000000..d8a62806 --- /dev/null +++ b/src/db/migrations/0041-room-mention-role.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +ALTER TABLE channel_room ADD COLUMN room_mention_role TEXT; + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index ff63d324..23766276 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -21,6 +21,7 @@ export type Models = { speedbump_checked: number | null guild_id: string | null custom_topic: number + room_mention_role: string | null } channel_speedbump: { diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 5fe1109d..77e984c8 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -460,14 +460,15 @@ async function handleRoomOrMessageLinks(input, di) { async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+ if (writtenMentionMatch) { - if (writtenMentionMatch[1] === "room") { // convert @room to @everyone + if (writtenMentionMatch[1] === "room") { // convert @room to discord const {powers: {[senderMxid]: userPower}, powerLevels} = await mxUtils.getEffectivePower(roomID, [senderMxid], di.api) if (userPower >= (powerLevels.notifications?.room ?? 50)) { + const roleID = select("channel_room", "room_mention_role", {room_id: roomID}).pluck().get() return { // @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]), + content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + (roleID ? `<@&${roleID}>` : "@everyone") + content.slice(writtenMentionMatch.indices[1][1]), ensureJoined: [], - allowedMentionsParse: ["everyone"], + allowedMentionsParse: roleID ? [] : ["everyone"], allowedMentionsUsers: [] } } @@ -860,6 +861,8 @@ async function eventToMessage(event, guild, channel, di) { } }) + input = input.replace(/]*href="https:\/\/matrix\.to\/#\/(?:!|%21|#|%23)[^"\/]*"[^>]*>@room<\/a>/g, "@room") + // Handling mentions of rooms and room-messages input = await handleRoomOrMessageLinks(input, di) diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index b262a379..b8d88f94 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -4,7 +4,7 @@ const DiscordTypes = require("discord-api-types/v10") const {eventToMessage} = require("./event-to-message") const data = require("../../../test/data") const {MatrixServerError} = require("../../matrix/mreq") -const {select, discord} = require("../../passthrough") +const {select, discord, db} = require("../../passthrough") /** * @param {string} roomID diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index c464954d..1b28b46f 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -188,6 +188,72 @@ block body td(colspan="3") .s-empty-state No channels linked between Discord and Matrix yet... + if space_id && linkedChannelsWithDetails.length + - const customMentionRooms = linkedChannelsWithDetails.filter(row => row.room_mention_role) + - const defaultMentionRooms = linkedChannelsWithDetails.filter(row => !row.room_mention_role) + - const mentionRoles = [...guild.roles].filter(role => role.id !== guild_id).sort((a, b) => b.position - a.position) + h3.mt32.fs-category#room-notifications Room notifications + .s-card.p0 + p.s-description.m12 Choose which Discord role receives Matrix @room notifications + if customMentionRooms.length + .s-table-container(style="container-type: inline-size") + table.s-table.s-table__bx-simple(style="table-layout: fixed") + thead + tr + th(scope="col" style="width: calc(50cqw - 40px)") Channel + th.pr12(scope="col") Notify role + tbody + each row in customMentionRooms + tr + td.va-middle + +discord(row.channel) + td.pr12 + .d-flex.ai-start.g8 + form.fl-grow1.wmn0.d-flex.ai-stretch.g8(method="post" action=rel("/api/room-mention-role")) + input(type="hidden" name="guild_id" value=guild_id) + input(type="hidden" name="channel_id" value=row.channel.id) + .s-select.fl-grow1.wmn0 + select.w100(name="room_mention_role" id="mention-role-" + row.channel.id aria-label="Notify role for #" + row.channel.name onchange="this.form.requestSubmit()" style="height: 32px") + if !mentionRoles.some(role => role.id === row.room_mention_role) + option(value=row.room_mention_role selected disabled) Deleted role (choose a replacement) + each role in mentionRoles + option(value=role.id selected=row.room_mention_role === role.id)= "@" + role.name + button.s-btn.s-btn__outlined.py0.box-border(type="submit" data-room-mention-save style="height: 32px; min-height: 0") Save + form.d-flex.ai-stretch(method="post" action=rel("/api/room-mention-role")) + input(type="hidden" name="guild_id" value=guild_id) + input(type="hidden" name="channel_id" value=row.channel.id) + input(type="hidden" name="room_mention_role" value="") + button.s-btn.s-btn__danger.s-btn__outlined.py0(type="submit" aria-label="Remove custom role for #" + row.channel.name style="height: 32px") Remove + + script. + document.querySelectorAll('[data-room-mention-save]').forEach(button => button.remove()) + + if defaultMentionRooms.length && mentionRoles.length + details.mt16.mx12.mb12 + summary.s-label.cursor-pointer Add custom role + form.mt16(method="post" action=rel("/api/room-mention-role")) + input(type="hidden" name="guild_id" value=guild_id) + .d-flex.fw-wrap.ai-end.g16 + .fl-grow1.wmn0(style="flex-basis: 0; min-width: 180px") + label.s-label(for="mention-role-channel") Channel + .s-select + select.w100.box-border#mention-role-channel(name="channel_id" required style="height: 32px") + option(value="" selected disabled) Choose a channel + each row in defaultMentionRooms + - const parent = discord.channels.get(row.channel.parent_id) + option(value=row.channel.id)= "#" + row.channel.name + (parent ? " - " + parent.name : "") + .fl-grow1.wmn0(style="flex-basis: 0; min-width: 180px") + label.s-label(for="mention-role-new") Notify role + .s-select + select.w100.box-border#mention-role-new(name="room_mention_role" required style="height: 32px") + option(value="" selected disabled) Choose a role + each role in mentionRoles + option(value=role.id)= "@" + role.name + .d-flex.ai-stretch(style="height: 32px") + button.s-btn.s-btn__icon.s-btn__filled.d-inline-flex.ai-center.g4.py0.box-border(type="submit" style="min-height: 0") + != icons.Icons.IconPlusSm + span Add + h3.fs-category.mt32 Auto-create .s-card.d-grid.px0 form.d-flex.ai-center.g16 diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js index 9bfff3de..9f626d70 100644 --- a/src/web/routes/guild-settings.js +++ b/src/web/routes/guild-settings.js @@ -140,3 +140,22 @@ as.router.post("/api/default-roles", defineEventHandler(async event => { return sendRedirect(event, `/guild?guild_id=${guildID}`, 302) } })) + +// An empty role restores @everyone +as.router.post("/api/room-mention-role", defineEventHandler(async event => { + const body = await readValidatedBody(event, z.object({ + guild_id: z.string(), + channel_id: z.string(), + room_mention_role: z.string().regex(/^(?:[0-9]+)?$/) + }).parse) + const managed = await auth.getManagedGuilds(event) + if (!managed.has(body.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"}) + const room = select("channel_room", "room_id", {channel_id: body.channel_id, guild_id: body.guild_id}).get() + if (!room) throw createError({status: 404, message: "Linked channel not found"}) + const guild = discord.guilds.get(body.guild_id) + if (body.room_mention_role && (!guild?.roles.some(role => role.id === body.room_mention_role) || body.room_mention_role === body.guild_id)) { + throw createError({status: 400, message: "Choose a role from this server"}) + } + db.prepare("UPDATE channel_room SET room_mention_role = ? WHERE channel_id = ? AND guild_id = ?").run(body.room_mention_role || null, body.channel_id, body.guild_id) + return sendRedirect(event, `/guild?guild_id=${body.guild_id}#room-notifications`, 302) +})) diff --git a/src/web/routes/guild-settings.test.js b/src/web/routes/guild-settings.test.js index 00acb897..d1a978bd 100644 --- a/src/web/routes/guild-settings.test.js +++ b/src/web/routes/guild-settings.test.js @@ -2,7 +2,7 @@ const {tryToCatch} = require("try-to-catch") const {router, test} = require("../../../test/web") -const {select} = require("../../passthrough") +const {select, discord} = require("../../passthrough") const {MatrixServerError} = require("../../matrix/mreq") test("web autocreate: checks permissions", async t => { diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index c9561e58..ef2065e3 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -99,7 +99,7 @@ function getChannelRoomsLinks(guild, rooms, roles) { let channelIDs = discord.guildChannelMap.get(guild.id) assert(channelIDs) - let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {channel_id: channelIDs}).all() + let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick", "room_mention_role"], {channel_id: channelIDs}).all() let linkedChannelsWithDetails = linkedChannels.map(c => ({ // @ts-ignore /** @type {DiscordTypes.APIGuildChannel} */ channel: discord.channels.get(c.channel_id), From 682d32bf4bcbd1d8b5e13d674d06353125859d3c Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 12:21:03 +0200 Subject: [PATCH 146/184] Btw, I think you forgot to update this, when you bumped the limit. xD --- src/d2m/event-dispatcher.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index d407991b..eeb8a72a 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -69,7 +69,7 @@ module.exports = { }, /** - * When logging back in, check if we missed any conversations in any channels. Bridge up to 49 missed messages per channel. + * When logging back in, check if we missed any conversations in any channels. Bridge up to 99 missed messages per channel. * If more messages were missed, only the latest missed message will be posted. TODO: Consider bridging more, or post a warning when skipping history? * This can ONLY detect new messages, not any other kind of event. Any missed edits, deletes, reactions, etc will not be bridged. * @param {import("./discord-client")} client From f39a7f42518224429fbb84d85eab0e8b25768722 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 13 Oct 2026 23:32:43 +1300 Subject: [PATCH 147/184] Check m.mentions before converting @room text --- src/m2d/converters/event-to-message.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 77e984c8..1f8c4018 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -452,18 +452,18 @@ async function handleRoomOrMessageLinks(input, di) { /** * @param {string} content - * @param {string} senderMxid - * @param {string} roomID + * @param {{content: any, room_id: string, sender: string}} event * @param {DiscordTypes.APIGuild} guild * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer}} di */ -async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { +async function checkWrittenMentions(content, event, guild, di) { let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+ if (writtenMentionMatch) { if (writtenMentionMatch[1] === "room") { // convert @room to discord - const {powers: {[senderMxid]: userPower}, powerLevels} = await mxUtils.getEffectivePower(roomID, [senderMxid], di.api) + if (("m.mentions" in event.content) && !event.content["m.mentions"].room) return // check it's allowed by m.mentions if present + const {powers: {[event.sender]: userPower}, powerLevels} = await mxUtils.getEffectivePower(event.room_id, [event.sender], di.api) if (userPower >= (powerLevels.notifications?.room ?? 50)) { - const roleID = select("channel_room", "room_mention_role", {room_id: roomID}).pluck().get() + const roleID = select("channel_room", "room_mention_role", {room_id: event.room_id}).pluck().get() return { // @ts-ignore - typescript doesn't know about indices yet content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + (roleID ? `<@&${roleID}>` : "@everyone") + content.slice(writtenMentionMatch.indices[1][1]), @@ -914,7 +914,7 @@ async function eventToMessage(event, guild, channel, di) { // Check written mentions if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) { - const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di) + const result = await checkWrittenMentions(node.nodeValue, event, guild, di) if (result) { node.nodeValue = result.content ensureJoined.push(...result.ensureJoined) @@ -1032,7 +1032,7 @@ async function eventToMessage(event, guild, channel, di) { } } - const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di) + const result = await checkWrittenMentions(content, event, guild, di) if (result) { content = result.content ensureJoined.push(...result.ensureJoined) From c196bef0bab1af96e82e288af879894973692603 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 13 Oct 2026 23:33:14 +1300 Subject: [PATCH 148/184] Add tests for @room mention roles conversion --- src/m2d/converters/event-to-message.test.js | 122 +++++++++++++++++++- test/ooye-test-data.sql | 1 + 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index b8d88f94..6b3048ee 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -5521,6 +5521,9 @@ test("event2message: @room converts to @everyone and is allowed when the room do type: "m.room.message", sender: "@cadence:cadence.moe", content: { + "m.mentions": { + room: true + }, msgtype: "m.text", body: "@room dinner's ready", }, @@ -5563,7 +5566,124 @@ test("event2message: @room converts to @everyone and is allowed when the room do content: "@everyone dinner's ready", avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { - parse: ["users", "roles", "everyone"] + users: [], + parse: ["roles", "everyone"] + } + }], + ensureJoined: [] + } + ) +}) + +test("event2message: @room is converted to a role in configured rooms", async t => { + let called = 0 + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "@room dinner's ready", + }, + room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe", + event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A" + }, data.guild.general, data.channel.general, { + api: { + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!fGgIymcYWOqjbSRUdV:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: {}, + notifications: { + room: 0 + } + } + }, + async getStateEventOuter(roomID, type, key) { + t.equal(roomID, "!fGgIymcYWOqjbSRUdV:cadence.moe") + t.equal(type, "m.room.create") + t.equal(key, "") + return { + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + content: { + room_version: "11" + } + } + } + } + }), + { + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "<@&123456> dinner's ready", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", + allowed_mentions: { + parse: ["users", "roles"] + } + }], + ensureJoined: [] + } + ) +}) + +test("event2message: @room is not allowed with mismatched m.mentions", async t => { + let called = 0 + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + "m.mentions": {}, + msgtype: "m.text", + body: "@room dinner's ready", + }, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A" + }, data.guild.general, data.channel.general, { + api: { + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: {}, + notifications: { + room: 0 + } + } + }, + async getStateEventOuter(roomID, type, key) { + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(type, "m.room.create") + t.equal(key, "") + return { + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + content: { + room_version: "11" + } + } + } + } + }), + { + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "@room dinner's ready", + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", + allowed_mentions: { + users: [], + parse: ["roles"] } }], ensureJoined: [] diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 072397fa..a1640661 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -25,6 +25,7 @@ INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom ('1161864271370666075', '!mHmhQQPwXNananMUqq:cadence.moe', 'updates', NULL, NULL, NULL, '112760669178241024'), ('1438284564815548418', '!MHxNpwtgVqWOrmyoTn:cadence.moe', 'sin-cave', NULL, NULL, NULL, '665289423482519565'), ('598707048112193536', '!JBxeGYnzQwLnaooOLD:cadence.moe', 'winners', NULL, NULL, NULL, '1345641201902288987'); +UPDATE channel_room SET room_mention_role = '123456' WHERE room_id = '!fGgIymcYWOqjbSRUdV:cadence.moe'; INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) SELECT channel_id, room_id, 0 FROM channel_room; From 7902b91c38d59cc89da2d3b85c570e927ad5ab28 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 13 Oct 2026 23:46:04 +1300 Subject: [PATCH 149/184] Fix time formatting in different node versions --- src/d2m/converters/message-to-event.js | 2 +- src/d2m/converters/message-to-event.test.js | 4 ++-- src/discord/time.js | 2 +- start.js | 6 ++---- test/test.js | 3 +++ 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 45d97468..1ed97547 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -916,7 +916,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Add time if (event.scheduled_end_time) { const formatter = new Intl.DateTimeFormat("en-NZ", {weekday: "long", month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZone: "UTC"}) // 9 June at 3:00 pm NZT - var displayTime = formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time)) + " (UTC)" + var displayTime = (formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time)) + " (UTC)").replaceAll(/\p{Zs}/gv, " ") } else { var displayTime = discordTime.convertDiscordTime(unixSeconds, "F") } diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index a7b17335..44b9fe65 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1589,12 +1589,12 @@ test("message2event: irl invite event renders embed", async t => { { $type: "m.room.message", msgtype: "m.notice", - body: `| Scheduled Event - Sunday, 8 June, 10:00 am – 12:00 pm (UTC)` + body: `| Scheduled Event - Sunday, 8 June, 10:00 am – 12:00 pm (UTC)` + `\n| ## forest exploration` + `\n| ` + `\n| 📍 the dark forest`, format: "org.matrix.custom.html", - formatted_body: `

    Scheduled Event - Sunday, 8 June, 10:00 am – 12:00 pm (UTC)

    ` + formatted_body: `

    Scheduled Event - Sunday, 8 June, 10:00 am – 12:00 pm (UTC)

    ` + `forest exploration` + `

    📍 the dark forest

    `, "m.mentions": {} diff --git a/src/discord/time.js b/src/discord/time.js index d0f7a044..e9d8f8c2 100644 --- a/src/discord/time.js +++ b/src/discord/time.js @@ -44,7 +44,7 @@ function getFormatOptions(style) { function convertDiscordTime(timestamp, style) { const formatter = new Intl.DateTimeFormat("en-NZ", getFormatOptions(style)) const d = new Date(timestamp * 1000) - return formatter.format(d) + " (UTC)" + return (formatter.format(d) + " (UTC)").replaceAll(/\p{Zs}/gv, " ") } module.exports.getFormatOptions = getFormatOptions diff --git a/start.js b/start.js index 728c8aff..eb846276 100755 --- a/start.js +++ b/start.js @@ -1,10 +1,8 @@ #!/usr/bin/env node // @ts-check -// Temporal API stabilised in Node.js 26. Previous versions can use `--harmony-temporal`, but that's jank, so have a polyfill. -if (!("Temporal" in globalThis)) { - require("temporal-polyfill-lite/global") -} +// Ensure consistent output from the Intl and Temporal API across Node.js versions +require("temporal-polyfill-lite/global") const fs = require("fs") const sqlite = require("better-sqlite3") diff --git a/test/test.js b/test/test.js index 74d99b1f..96326f54 100644 --- a/test/test.js +++ b/test/test.js @@ -1,5 +1,8 @@ // @ts-check +// Ensure consistent output from the Intl and Temporal API across Node.js versions +require("temporal-polyfill-lite/global") + const fs = require("fs") const {join} = require("path") const sqlite = require("better-sqlite3") From 2c1c95241307cc2bfb2c188b052b955faacd37b7 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 19:36:15 +0200 Subject: [PATCH 150/184] Possibly fixed the Git-thinks-I-redone-the-whole-Pug-file-from-scratch bug FUCK Windows; Screw it's stupid line endings --- src/web/pug/guild.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 109ac9e0..db32e4c0 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -350,4 +350,4 @@ block body .s-card.p0 ul.my8.ml24 each row in removedArchivedThreadRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name \ No newline at end of file From b6017780cb88bf5fcf20bf187b16fc9d53acaf14 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 19:47:01 +0200 Subject: [PATCH 151/184] I am about to go insane. --- src/web/pug/guild.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index db32e4c0..109ac9e0 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -350,4 +350,4 @@ block body .s-card.p0 ul.my8.ml24 each row in removedArchivedThreadRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name \ No newline at end of file + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name From d982a7d7d39d6fbc4f33a9e0c45a35659a6836ba Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 20:14:34 +0200 Subject: [PATCH 152/184] Let's just remove the check and see if it fixes stuff --- src/web/pug/guild.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 109ac9e0..8563f72d 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -188,7 +188,7 @@ block body td(colspan="3") .s-empty-state No channels linked between Discord and Matrix yet... - if space_id && linkedChannelsWithDetails.length + if space_id //&& linkedChannelsWithDetails.length - const customMentionRooms = linkedChannelsWithDetails.filter(row => row.room_mention_role) - const defaultMentionRooms = linkedChannelsWithDetails.filter(row => !row.room_mention_role) - const mentionRoles = [...guild.roles].filter(role => role.id !== guild_id).sort((a, b) => b.position - a.position) From d5dd1d874e717a639d0e4cae1cb37cb4db7a85c9 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 20:21:04 +0200 Subject: [PATCH 153/184] =?UTF-8?q?It=20did=20not,=20in=20fact,=20"fix=20s?= =?UTF-8?q?tuff"=20=F0=9F=92=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/web/pug/guild.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 8563f72d..109ac9e0 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -188,7 +188,7 @@ block body td(colspan="3") .s-empty-state No channels linked between Discord and Matrix yet... - if space_id //&& linkedChannelsWithDetails.length + if space_id && linkedChannelsWithDetails.length - const customMentionRooms = linkedChannelsWithDetails.filter(row => row.room_mention_role) - const defaultMentionRooms = linkedChannelsWithDetails.filter(row => !row.room_mention_role) - const mentionRoles = [...guild.roles].filter(role => role.id !== guild_id).sort((a, b) => b.position - a.position) From c09c746628b53a6d593d23543ee77502c01c9d12 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 20:30:52 +0200 Subject: [PATCH 154/184] cries --- src/web/pug/guild.pug | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 109ac9e0..8653f93b 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -188,7 +188,9 @@ block body td(colspan="3") .s-empty-state No channels linked between Discord and Matrix yet... + p Debug1 if space_id && linkedChannelsWithDetails.length + p Debug2 - const customMentionRooms = linkedChannelsWithDetails.filter(row => row.room_mention_role) - const defaultMentionRooms = linkedChannelsWithDetails.filter(row => !row.room_mention_role) - const mentionRoles = [...guild.roles].filter(role => role.id !== guild_id).sort((a, b) => b.position - a.position) @@ -253,6 +255,7 @@ block body button.s-btn.s-btn__icon.s-btn__filled.d-inline-flex.ai-center.g4.py0.box-border(type="submit" style="min-height: 0") != icons.Icons.IconPlusSm span Add + p Debug3 h3.fs-category.mt32 Auto-create .s-card.d-grid.px0 From db874a1aa9e334834664477e879375c6c75d7b2f Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 20:34:06 +0200 Subject: [PATCH 155/184] WHY CAN'T A BASIC P-TAG RENDER, HUH???? --- src/web/pug/guild.pug | 3 +- src/web/routes/guild-nova.js | 269 +++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 src/web/routes/guild-nova.js diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 8653f93b..16a7a76a 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -257,7 +257,8 @@ block body span Add p Debug3 - h3.fs-category.mt32 Auto-create + h3.fs-category.mt32 Auto-create REAL QUESTION, WTF???? + p Debug4 .s-card.d-grid.px0 form.d-flex.ai-center.g16 #autocreate-loading.p8 diff --git a/src/web/routes/guild-nova.js b/src/web/routes/guild-nova.js new file mode 100644 index 00000000..ef2065e3 --- /dev/null +++ b/src/web/routes/guild-nova.js @@ -0,0 +1,269 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const assert = require("assert/strict") +const {z} = require("zod") +const {H3Event, defineEventHandler, sendRedirect, createError, getValidatedQuery, readValidatedBody, setResponseHeader} = require("h3") +const {randomUUID} = require("crypto") +const {LRUCache} = require("lru-cache") +const Ty = require("../../types") +const uqr = require("uqr") + +const {id: botID} = require("../../../addbot") +const {discord, as, sync, select, from, db} = require("../../passthrough") +/** @type {import("../pug-sync")} */ +const pugSync = sync.require("../pug-sync") +/** @type {import("../../d2m/actions/create-space")} */ +const createSpace = sync.require("../../d2m/actions/create-space") +/** @type {import("../auth")} */ +const auth = require("../auth") +/** @type {import("../../discord/utils")} */ +const dUtils = sync.require("../../discord/utils") +/** @type {import("../../matrix/utils")} */ +const mxUtils = sync.require("../../matrix/utils") +const {reg} = require("../../matrix/read-registration") + +const schema = { + guild: z.object({ + guild_id: z.string().optional() + }), + qr: z.object({ + guild_id: z.string().optional() + }), + invite: z.object({ + mxid: z.string().regex(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/), + permissions: z.enum(["default", "moderator", "admin"]), + guild_id: z.string().optional(), + nonce: z.string().optional() + }), + inviteNonce: z.object({ + nonce: z.string() + }) +} + +/** + * @param {H3Event} event + * @returns {import("../../matrix/api")} + */ +function getAPI(event) { + /* c8 ignore next */ + return event.context.api || sync.require("../../matrix/api") +} + +/** @type {LRUCache} nonce to guild id */ +const validNonce = new LRUCache({max: 200}) + +/** + * @param {{type: number, parent_id?: string | null, position?: number}} channel + * @param {Map} channels + */ +function getPosition(channel, channels) { + let position = 0 + + // Categories always appear below un-categorised channels. Their contents can be ordered. + // So categories, and things in them, will have their position multiplied by a big number. The category's big number. The regular position small number sorts within the category. + // Categories are size 2000. + let foundCategory = channel + while (foundCategory.parent_id) { + const f = channels.get(foundCategory.parent_id) + assert(f) + foundCategory = f + } + if (foundCategory.type === DiscordTypes.ChannelType.GuildCategory) position = ((foundCategory.position || 0) + 1) * 2000 + + // Categories always appear above what they contain. + if (channel.type === DiscordTypes.ChannelType.GuildCategory) position -= 0.5 + + // Within a category, voice channels are always sorted to the bottom. The text/voice split is size 1000 each. + if ([DiscordTypes.ChannelType.GuildVoice, DiscordTypes.ChannelType.GuildStageVoice].includes(channel.type)) position += 1000 + + // Channels are manually ordered within the text/voice split. + if (typeof channel.position === "number") position += channel.position + + // Threads appear below their channel. + if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { + position += 0.5 + let parent = channels.get(channel.parent_id || "") + if (parent && parent["position"]) position += parent["position"] + } + + return position +} + +/** + * @param {DiscordTypes.APIGuild} guild + * @param {Ty.R.Hierarchy[]} rooms + * @param {string[]} roles + */ +function getChannelRoomsLinks(guild, rooms, roles) { + let channelIDs = discord.guildChannelMap.get(guild.id) + assert(channelIDs) + + let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick", "room_mention_role"], {channel_id: channelIDs}).all() + let linkedChannelsWithDetails = linkedChannels.map(c => ({ + // @ts-ignore + /** @type {DiscordTypes.APIGuildChannel} */ channel: discord.channels.get(c.channel_id), + ...c + })) + let removedUncachedChannels = dUtils.filterTo(linkedChannelsWithDetails, c => c.channel) + let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id) + linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel, discord.channels) - getPosition(b.channel, discord.channels)) + + let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c)) + /** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore + let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)) + let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type)) + let removedPrivateChannels = dUtils.filterTo(unlinkedChannels, c => { + const permissions = dUtils.getPermissions(guild.id, roles, guild.roles, botID, c["permission_overwrites"]) + return dUtils.hasSomePermissions(permissions, ["Administrator", "ViewChannel"]) + }) + unlinkedChannels.sort((a, b) => getPosition(a, discord.channels) - getPosition(b, discord.channels)) + + let linkedRoomIDs = linkedChannels.map(c => c.room_id) + let unlinkedRooms = [...rooms] + let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) + let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type) + let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"]) + // https://discord.com/developers/docs/topics/threads#active-archived-threads + // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name + let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) + + return { + linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, + removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms, removedEncryptedRooms + } +} + +/** + * @param {string} mxid + */ +function getInviteTargetSpaces(mxid) { + /** @type {{room_id: string, mxid: string, type: string, name: string, topic: string?, avatar: string?}[]} */ + const spaces = + // invited spaces + db.prepare("SELECT room_id, invite.mxid, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL AND type = 'm.space'").all(mxid) + // moderated spaces + .concat(db.prepare("SELECT room_id, invite.mxid, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id INNER JOIN member_cache USING (room_id) WHERE member_cache.mxid = ? AND power_level >= 50 AND space_id IS NULL AND type = 'm.space'").all(mxid)) + const seen = new Set(spaces.map(s => s.room_id)) + return spaces.filter(s => seen.delete(s.room_id)) +} + +as.router.get("/guild", defineEventHandler(async event => { + const {guild_id} = await getValidatedQuery(event, schema.guild.parse) + const session = await auth.useSession(event) + const managed = await auth.getManagedGuilds(event) + const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() + // @ts-ignore + const guild = discord.guilds.get(guild_id) + + // Permission problems + if (!guild_id || !guild || !managed.has(guild_id) || !row) { + return pugSync.render(event, "guild-access-denied.pug", {guild_id, row}) + } + + // Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow + if (!row.space_id && row.autocreate === 0) { + const spaces = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : [] + return pugSync.render(event, "guild-not-linked.pug", {guild, guild_id, spaces}) + } + + const roles = guild.members?.find(m => m.user.id === botID)?.roles || [] + + // Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space + if (!row.space_id) { + const links = getChannelRoomsLinks(guild, [], roles) + return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) + } + + // Linked guild + const api = getAPI(event) + const rooms = await api.getFullHierarchy(row.space_id) + const links = getChannelRoomsLinks(guild, rooms, roles) + return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) +})) + +as.router.get("/qr", defineEventHandler(async event => { + const {guild_id} = await getValidatedQuery(event, schema.qr.parse) + const managed = await auth.getManagedGuilds(event) + const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() + // @ts-ignore + const guild = discord.guilds.get(guild_id) + + // Permission problems + if (!guild_id || !guild || !managed.has(guild_id) || !row) { + return pugSync.render(event, "guild-access-denied.pug", {guild_id, row}) + } + + const nonce = randomUUID() + validNonce.set(nonce, guild_id) + + const data = `${reg.ooye.bridge_origin}/invite?nonce=${nonce}` + // necessary to scale the svg pixel-perfectly on the page + // https://github.com/unjs/uqr/blob/244952a8ba2d417f938071b61e11fb1ff95d6e75/src/svg.ts#L24 + const generatedSvg = uqr.renderSVG(data, {pixelSize: 3}) + const svg = generatedSvg.replace(/viewBox="0 0 ([0-9]+) ([0-9]+)"/, `data-nonce="${nonce}" width="$1" height="$2" $&`) + assert.notEqual(svg, generatedSvg) + + return svg +})) + +as.router.get("/invite", defineEventHandler(async event => { + const {nonce} = await getValidatedQuery(event, schema.inviteNonce.parse) + const isValid = validNonce.has(nonce) + const guild_id = validNonce.get(nonce) + const guild = discord.guilds.get(guild_id || "") + return pugSync.render(event, "invite.pug", {isValid, nonce, guild_id, guild}) +})) + +as.router.post("/api/invite", defineEventHandler(async event => { + const parsedBody = await readValidatedBody(event, schema.invite.parse) + const managed = await auth.getManagedGuilds(event) + const api = getAPI(event) + + // Check guild ID or nonce + if (parsedBody.guild_id) { + var guild_id = parsedBody.guild_id + if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't invite users to a guild you don't have Manage Server permissions in"}) + } else if (parsedBody.nonce) { + if (!validNonce.has(parsedBody.nonce)) throw createError({status: 403, message: "Nonce expired", data: "Nonce means number-used-once, and, well, you tried to use it twice..."}) + let ok = validNonce.get(parsedBody.nonce) + assert(ok) + var guild_id = ok + validNonce.delete(parsedBody.nonce) + } else { + throw createError({status: 400, message: "Missing guild ID", data: "Passing a guild ID or a nonce is required."}) + } + + // Check guild is bridged + const guild = discord.guilds.get(guild_id) + assert(guild) + const spaceID = await createSpace.ensureSpace(guild) + + // Check for existing invite to the space + let spaceMember + try { + spaceMember = await api.getStateEvent(spaceID, "m.room.member", parsedBody.mxid) + } catch (e) {} + + if (!spaceMember || !["invite", "join"].includes(spaceMember.membership)) { + // Invite + await api.inviteToRoom(spaceID, parsedBody.mxid) + } + + // Permissions + const powerLevel = + ( parsedBody.permissions === "admin" ? 100 + : parsedBody.permissions === "moderator" ? 50 + : 0) + if (powerLevel) await mxUtils.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel, api) + + if (parsedBody.guild_id) { + setResponseHeader(event, "HX-Refresh", true) + return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302) + } else { + return sendRedirect(event, "/ok?msg=User has been invited.", 302) + } +})) + +module.exports._getPosition = getPosition +module.exports.getInviteTargetSpaces = getInviteTargetSpaces From 0425c18cf3b971b48a25cc66e40b15d788e47fc2 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 20:35:17 +0200 Subject: [PATCH 156/184] sh!t, that wasn't supposed to make it there... --- src/web/routes/guild-nova.js | 269 ----------------------------------- 1 file changed, 269 deletions(-) delete mode 100644 src/web/routes/guild-nova.js diff --git a/src/web/routes/guild-nova.js b/src/web/routes/guild-nova.js deleted file mode 100644 index ef2065e3..00000000 --- a/src/web/routes/guild-nova.js +++ /dev/null @@ -1,269 +0,0 @@ -// @ts-check - -const DiscordTypes = require("discord-api-types/v10") -const assert = require("assert/strict") -const {z} = require("zod") -const {H3Event, defineEventHandler, sendRedirect, createError, getValidatedQuery, readValidatedBody, setResponseHeader} = require("h3") -const {randomUUID} = require("crypto") -const {LRUCache} = require("lru-cache") -const Ty = require("../../types") -const uqr = require("uqr") - -const {id: botID} = require("../../../addbot") -const {discord, as, sync, select, from, db} = require("../../passthrough") -/** @type {import("../pug-sync")} */ -const pugSync = sync.require("../pug-sync") -/** @type {import("../../d2m/actions/create-space")} */ -const createSpace = sync.require("../../d2m/actions/create-space") -/** @type {import("../auth")} */ -const auth = require("../auth") -/** @type {import("../../discord/utils")} */ -const dUtils = sync.require("../../discord/utils") -/** @type {import("../../matrix/utils")} */ -const mxUtils = sync.require("../../matrix/utils") -const {reg} = require("../../matrix/read-registration") - -const schema = { - guild: z.object({ - guild_id: z.string().optional() - }), - qr: z.object({ - guild_id: z.string().optional() - }), - invite: z.object({ - mxid: z.string().regex(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/), - permissions: z.enum(["default", "moderator", "admin"]), - guild_id: z.string().optional(), - nonce: z.string().optional() - }), - inviteNonce: z.object({ - nonce: z.string() - }) -} - -/** - * @param {H3Event} event - * @returns {import("../../matrix/api")} - */ -function getAPI(event) { - /* c8 ignore next */ - return event.context.api || sync.require("../../matrix/api") -} - -/** @type {LRUCache} nonce to guild id */ -const validNonce = new LRUCache({max: 200}) - -/** - * @param {{type: number, parent_id?: string | null, position?: number}} channel - * @param {Map} channels - */ -function getPosition(channel, channels) { - let position = 0 - - // Categories always appear below un-categorised channels. Their contents can be ordered. - // So categories, and things in them, will have their position multiplied by a big number. The category's big number. The regular position small number sorts within the category. - // Categories are size 2000. - let foundCategory = channel - while (foundCategory.parent_id) { - const f = channels.get(foundCategory.parent_id) - assert(f) - foundCategory = f - } - if (foundCategory.type === DiscordTypes.ChannelType.GuildCategory) position = ((foundCategory.position || 0) + 1) * 2000 - - // Categories always appear above what they contain. - if (channel.type === DiscordTypes.ChannelType.GuildCategory) position -= 0.5 - - // Within a category, voice channels are always sorted to the bottom. The text/voice split is size 1000 each. - if ([DiscordTypes.ChannelType.GuildVoice, DiscordTypes.ChannelType.GuildStageVoice].includes(channel.type)) position += 1000 - - // Channels are manually ordered within the text/voice split. - if (typeof channel.position === "number") position += channel.position - - // Threads appear below their channel. - if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { - position += 0.5 - let parent = channels.get(channel.parent_id || "") - if (parent && parent["position"]) position += parent["position"] - } - - return position -} - -/** - * @param {DiscordTypes.APIGuild} guild - * @param {Ty.R.Hierarchy[]} rooms - * @param {string[]} roles - */ -function getChannelRoomsLinks(guild, rooms, roles) { - let channelIDs = discord.guildChannelMap.get(guild.id) - assert(channelIDs) - - let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick", "room_mention_role"], {channel_id: channelIDs}).all() - let linkedChannelsWithDetails = linkedChannels.map(c => ({ - // @ts-ignore - /** @type {DiscordTypes.APIGuildChannel} */ channel: discord.channels.get(c.channel_id), - ...c - })) - let removedUncachedChannels = dUtils.filterTo(linkedChannelsWithDetails, c => c.channel) - let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id) - linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel, discord.channels) - getPosition(b.channel, discord.channels)) - - let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c)) - /** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore - let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)) - let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type)) - let removedPrivateChannels = dUtils.filterTo(unlinkedChannels, c => { - const permissions = dUtils.getPermissions(guild.id, roles, guild.roles, botID, c["permission_overwrites"]) - return dUtils.hasSomePermissions(permissions, ["Administrator", "ViewChannel"]) - }) - unlinkedChannels.sort((a, b) => getPosition(a, discord.channels) - getPosition(b, discord.channels)) - - let linkedRoomIDs = linkedChannels.map(c => c.room_id) - let unlinkedRooms = [...rooms] - let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) - let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type) - let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"]) - // https://discord.com/developers/docs/topics/threads#active-archived-threads - // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name - let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) - - return { - linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, - removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms, removedEncryptedRooms - } -} - -/** - * @param {string} mxid - */ -function getInviteTargetSpaces(mxid) { - /** @type {{room_id: string, mxid: string, type: string, name: string, topic: string?, avatar: string?}[]} */ - const spaces = - // invited spaces - db.prepare("SELECT room_id, invite.mxid, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL AND type = 'm.space'").all(mxid) - // moderated spaces - .concat(db.prepare("SELECT room_id, invite.mxid, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id INNER JOIN member_cache USING (room_id) WHERE member_cache.mxid = ? AND power_level >= 50 AND space_id IS NULL AND type = 'm.space'").all(mxid)) - const seen = new Set(spaces.map(s => s.room_id)) - return spaces.filter(s => seen.delete(s.room_id)) -} - -as.router.get("/guild", defineEventHandler(async event => { - const {guild_id} = await getValidatedQuery(event, schema.guild.parse) - const session = await auth.useSession(event) - const managed = await auth.getManagedGuilds(event) - const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() - // @ts-ignore - const guild = discord.guilds.get(guild_id) - - // Permission problems - if (!guild_id || !guild || !managed.has(guild_id) || !row) { - return pugSync.render(event, "guild-access-denied.pug", {guild_id, row}) - } - - // Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow - if (!row.space_id && row.autocreate === 0) { - const spaces = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : [] - return pugSync.render(event, "guild-not-linked.pug", {guild, guild_id, spaces}) - } - - const roles = guild.members?.find(m => m.user.id === botID)?.roles || [] - - // Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space - if (!row.space_id) { - const links = getChannelRoomsLinks(guild, [], roles) - return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) - } - - // Linked guild - const api = getAPI(event) - const rooms = await api.getFullHierarchy(row.space_id) - const links = getChannelRoomsLinks(guild, rooms, roles) - return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) -})) - -as.router.get("/qr", defineEventHandler(async event => { - const {guild_id} = await getValidatedQuery(event, schema.qr.parse) - const managed = await auth.getManagedGuilds(event) - const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() - // @ts-ignore - const guild = discord.guilds.get(guild_id) - - // Permission problems - if (!guild_id || !guild || !managed.has(guild_id) || !row) { - return pugSync.render(event, "guild-access-denied.pug", {guild_id, row}) - } - - const nonce = randomUUID() - validNonce.set(nonce, guild_id) - - const data = `${reg.ooye.bridge_origin}/invite?nonce=${nonce}` - // necessary to scale the svg pixel-perfectly on the page - // https://github.com/unjs/uqr/blob/244952a8ba2d417f938071b61e11fb1ff95d6e75/src/svg.ts#L24 - const generatedSvg = uqr.renderSVG(data, {pixelSize: 3}) - const svg = generatedSvg.replace(/viewBox="0 0 ([0-9]+) ([0-9]+)"/, `data-nonce="${nonce}" width="$1" height="$2" $&`) - assert.notEqual(svg, generatedSvg) - - return svg -})) - -as.router.get("/invite", defineEventHandler(async event => { - const {nonce} = await getValidatedQuery(event, schema.inviteNonce.parse) - const isValid = validNonce.has(nonce) - const guild_id = validNonce.get(nonce) - const guild = discord.guilds.get(guild_id || "") - return pugSync.render(event, "invite.pug", {isValid, nonce, guild_id, guild}) -})) - -as.router.post("/api/invite", defineEventHandler(async event => { - const parsedBody = await readValidatedBody(event, schema.invite.parse) - const managed = await auth.getManagedGuilds(event) - const api = getAPI(event) - - // Check guild ID or nonce - if (parsedBody.guild_id) { - var guild_id = parsedBody.guild_id - if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't invite users to a guild you don't have Manage Server permissions in"}) - } else if (parsedBody.nonce) { - if (!validNonce.has(parsedBody.nonce)) throw createError({status: 403, message: "Nonce expired", data: "Nonce means number-used-once, and, well, you tried to use it twice..."}) - let ok = validNonce.get(parsedBody.nonce) - assert(ok) - var guild_id = ok - validNonce.delete(parsedBody.nonce) - } else { - throw createError({status: 400, message: "Missing guild ID", data: "Passing a guild ID or a nonce is required."}) - } - - // Check guild is bridged - const guild = discord.guilds.get(guild_id) - assert(guild) - const spaceID = await createSpace.ensureSpace(guild) - - // Check for existing invite to the space - let spaceMember - try { - spaceMember = await api.getStateEvent(spaceID, "m.room.member", parsedBody.mxid) - } catch (e) {} - - if (!spaceMember || !["invite", "join"].includes(spaceMember.membership)) { - // Invite - await api.inviteToRoom(spaceID, parsedBody.mxid) - } - - // Permissions - const powerLevel = - ( parsedBody.permissions === "admin" ? 100 - : parsedBody.permissions === "moderator" ? 50 - : 0) - if (powerLevel) await mxUtils.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel, api) - - if (parsedBody.guild_id) { - setResponseHeader(event, "HX-Refresh", true) - return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302) - } else { - return sendRedirect(event, "/ok?msg=User has been invited.", 302) - } -})) - -module.exports._getPosition = getPosition -module.exports.getInviteTargetSpaces = getInviteTargetSpaces From bf9b6b198af4b44b8be00d92d020baa8c97f5591 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 21:02:42 +0200 Subject: [PATCH 157/184] FUCK YOU, PODMAN :"< it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache it was the goddamnit build cache --- src/web/pug/guild.pug | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 16a7a76a..109ac9e0 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -188,9 +188,7 @@ block body td(colspan="3") .s-empty-state No channels linked between Discord and Matrix yet... - p Debug1 if space_id && linkedChannelsWithDetails.length - p Debug2 - const customMentionRooms = linkedChannelsWithDetails.filter(row => row.room_mention_role) - const defaultMentionRooms = linkedChannelsWithDetails.filter(row => !row.room_mention_role) - const mentionRoles = [...guild.roles].filter(role => role.id !== guild_id).sort((a, b) => b.position - a.position) @@ -255,10 +253,8 @@ block body button.s-btn.s-btn__icon.s-btn__filled.d-inline-flex.ai-center.g4.py0.box-border(type="submit" style="min-height: 0") != icons.Icons.IconPlusSm span Add - p Debug3 - h3.fs-category.mt32 Auto-create REAL QUESTION, WTF???? - p Debug4 + h3.fs-category.mt32 Auto-create .s-card.d-grid.px0 form.d-flex.ai-center.g16 #autocreate-loading.p8 From e1b512bf090f1e16697199f1fa625b15484e0149 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 22:32:12 +0200 Subject: [PATCH 158/184] RIP Ellie Mode; fck u Commet The whole reason this woke branch up again, is that Cadence said she wants to use Matrix threads for discoverability of Discord threads. So RIP Ellie Mode (the one that eradicates threads fully). --- src/d2m/converters/thread-to-announcement.js | 15 ++++++--------- src/web/routes/guild.js | 6 +++--- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 6571619c..5ce84439 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -17,7 +17,6 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) */ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get() - const ellieMode = false //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */ const context = {} @@ -25,18 +24,16 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr if (branchedFromEventID) { // Need to figure out who sent that event... const event = await di.api.getEvent(parentRoomID, branchedFromEventID) - suffix = "\n[Note: You really should move the conversation to that room, rather than continuing to reply via a Matrix thread. Any messages sent in threads will be DELETED and instead moved to that room by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]" + suffix = "\n[ YOU MUST continue the conversation in that thread-room, rather than in this Matrix thread, even if your client supports MX threads. Any messages sent in MX threads ARE !DELETED! and copied to thread-rooms, which makes OOYE the author, and strips you from edit/delete rights! ]" context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}} if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} - if (!ellieMode) { - //...And actually branch from that event (if configured to do so) - suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]" - context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} - } + //...And actually branch from that event + context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} } - const msgtype = creatorMxid ? "m.emote" : "m.text" - const template = creatorMxid ? "started a thread called" : "New thread started:" + //TODO: Wait until certain clients (*cough* Commet *cough*) figure out how to render an m.emote in a thread, and then simplify this to creatorMxid ? "m.emote" : "m.text" and creatorMxid ? "started a thread called" : "New thread started:" + const msgtype = creatorMxid && !branchedFromEventID ? "m.emote" : "m.text" + const template = creatorMxid ? `${branchedFromEventID ? `${creatorMxid} ` : ""}started a thread called` : "New thread started:" const via = await mxUtils.getViaServersQuery(threadRoomID, di.api) let body = `${template} \"${thread.name}\" in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}` diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 33182523..6151dfbb 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -58,7 +58,7 @@ const validNonce = new LRUCache({max: 200}) /** * TYPING = Channels on which Discord messages can be sent. They should be bridgeable to anything other than an m.space (because if it did end up as a space, no one would be able to actually see the text messages sent there). - * SPACE = Channels on which Discord messages cannot be received. They should be bridgeable to m.space only (because not only does m.space make sending messages impossible on any sane client (thus preventing Discord-caused errors), but it also just-so-happens that both currently-existing message-unsupporting channel types (Categories and School hubs) are sort of "indexes", which fits nicely to m.space). + * SPACE = Channels on which Discord messages cannot be sent. They should be bridgeable to m.space only (because not only does m.space make sending messages impossible on any sane client (thus preventing Discord-caused errors), but it also just-so-happens that both currently-existing message-unsupporting channel types (Categories and School hubs) are sort of "indexes", which fits nicely to m.space). * MIXED = Forum-like channels. They can be bridged to both m.space and anything other than an m.space - hence the name. * @type {Map}*/ const linkRules = new Map([ @@ -66,13 +66,13 @@ const linkRules = new Map([ [1, {type: "TYPING", humanName:"Normal DMs", unsupported: "OOYE won't support DMs until a good way of doing it can be figured out. Please see https://gitdab.com/cadence/out-of-your-element#caveats for more."}], [2, {type: "TYPING", humanName:"Normal VCs"}], [3, {type: "TYPING", humanName:"Group DMs", unsupported: "OOYE won't support DMs until a good way of doing it can be figured out. Please see https://gitdab.com/cadence/out-of-your-element#caveats for more."}], - [4, {type: "SPACE", humanName:"Categories", unsupported: "There is no concept of categories on Matrix."}], //...at least officially. In practice, some clients will render sub-spaces as categories. TODO: Bridge categories to sub-spaces. + [4, {type: "SPACE", humanName:"Categories", unsupported: "There is no concept of categories on Matrix."}], //...at least officially. In practice, some clients will render sub-spaces as categories. TODO: Research Cadence's stance on bridging categories to sub-spaces. [5, {type: "TYPING", humanName:"Announcement text channels"}], [10, {type: "TYPING", humanName:"Announcement threads"}], [11, {type: "TYPING", humanName:"Normal threads"}], [12, {type: "TYPING", humanName:"Private threads"}], [13, {type: "TYPING", humanName:"Stage VCs"}], - [14, {type: "SPACE", humanName:"School hubs", unsupported: "Bots cannot be members of school hubs. How in the sweet hell did you manage to put OOYE on one, anyway??? ~~Emma, please stop breaking Discord API in cursed ways again.~~"}], + [14, {type: "SPACE", humanName:"School hubs", unsupported: "Bots cannot be members of school hubs. With that said, how in the sweet hell did you manage to put OOYE on one???"}], [15, {type: "MIXED", humanName:"Normal forums"}], [16, {type: "MIXED", humanName:"Media forums"}], ]) From 17cc56299dfe880a5b2cff17ba4a60dbb091e78f Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 23:29:54 +0200 Subject: [PATCH 159/184] Nevermind, Commet is *just broken* when it comes to threads. It refuses to render the 1st message of a thread, whether it's an emote, or anything else. COMMET, *WHY?* Anyway, I don't think it makes sense to add extra accommodation to what's clearly a client-level bug. So emotes are back, yay! --- src/d2m/converters/thread-to-announcement.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 5ce84439..38634e10 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -31,9 +31,8 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} } - //TODO: Wait until certain clients (*cough* Commet *cough*) figure out how to render an m.emote in a thread, and then simplify this to creatorMxid ? "m.emote" : "m.text" and creatorMxid ? "started a thread called" : "New thread started:" - const msgtype = creatorMxid && !branchedFromEventID ? "m.emote" : "m.text" - const template = creatorMxid ? `${branchedFromEventID ? `${creatorMxid} ` : ""}started a thread called` : "New thread started:" + const msgtype = creatorMxid ? "m.emote" : "m.text" + const template = creatorMxid ? "started a thread called" : "New thread started:" const via = await mxUtils.getViaServersQuery(threadRoomID, di.api) let body = `${template} \"${thread.name}\" in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}` From 1382ce6791933d25e0ce02d51884e6d5f6b7dfad Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 13 Oct 2026 23:32:57 +0200 Subject: [PATCH 160/184] update tests --- .../converters/thread-to-announcement.test.js | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.test.js b/src/d2m/converters/thread-to-announcement.test.js index a4dfd9a3..3d732153 100644 --- a/src/d2m/converters/thread-to-announcement.test.js +++ b/src/d2m/converters/thread-to-announcement.test.js @@ -1,8 +1,8 @@ -const {test} = require("supertape") -const {threadToAnnouncement} = require("./thread-to-announcement") +const { test } = require("supertape") +const { threadToAnnouncement } = require("./thread-to-announcement") const data = require("../../../test/data") const Ty = require("../../types") -const {mockGetEffectivePower} = require("../../matrix/utils.test") +const { mockGetEffectivePower } = require("../../matrix/utils.test") /** * @param {string} roomID @@ -10,7 +10,7 @@ const {mockGetEffectivePower} = require("../../matrix/utils.test") * @returns {(roomID: string, eventID: string) => Promise>} */ function mockGetEvent(t, roomID_in, eventID_in, outer) { - return async function(roomID, eventID) { + return async function (roomID, eventID) { t.equal(roomID, roomID_in) t.equal(eventID, eventID_in) return new Promise(resolve => { @@ -35,8 +35,8 @@ const viaApi = { async getJoinedMembers(roomID) { return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -46,7 +46,7 @@ test("thread2announcement: no known creator, no branched from event", async t => const content = await threadToAnnouncement("!parent", "!thread", null, { name: "test thread", id: "-1" - }, {api: viaApi}) + }, { api: viaApi }) t.deepEqual(content, { msgtype: "m.text", body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", @@ -57,7 +57,7 @@ test("thread2announcement: known creator, no branched from event", async t => { const content = await threadToAnnouncement("!parent", "!thread", "@_ooye_crunch_god:cadence.moe", { name: "test thread", id: "-1" - }, {api: viaApi}) + }, { api: viaApi }) t.deepEqual(content, { msgtype: "m.emote", body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", @@ -83,7 +83,7 @@ test("thread2announcement: no known creator, branched from discord event", async }) t.deepEqual(content, { msgtype: "m.text", - body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", + body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[ YOU MUST continue the conversation in that thread-room, rather than in this Matrix thread, even if your client supports MX threads. Any messages sent in MX threads ARE !DELETED! and copied to thread-rooms, which makes OOYE the author, and strips you from edit/delete rights! ]", "m.relates_to": { "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", "is_falling_back": false, @@ -114,7 +114,7 @@ test("thread2announcement: known creator, branched from discord event", async t }) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", + body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[ YOU MUST continue the conversation in that thread-room, rather than in this Matrix thread, even if your client supports MX threads. Any messages sent in MX threads ARE !DELETED! and copied to thread-rooms, which makes OOYE the author, and strips you from edit/delete rights! ]", "m.relates_to": { "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", "is_falling_back": false, @@ -145,7 +145,7 @@ test("thread2announcement: no known creator, branched from matrix event", async }) t.deepEqual(content, { msgtype: "m.text", - body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", + body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[ YOU MUST continue the conversation in that thread-room, rather than in this Matrix thread, even if your client supports MX threads. Any messages sent in MX threads ARE !DELETED! and copied to thread-rooms, which makes OOYE the author, and strips you from edit/delete rights! ]", "m.mentions": { user_ids: ["@cadence:cadence.moe"] }, @@ -179,7 +179,7 @@ test("thread2announcement: known creator, branched from matrix event", async t = }) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", + body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[ YOU MUST continue the conversation in that thread-room, rather than in this Matrix thread, even if your client supports MX threads. Any messages sent in MX threads ARE !DELETED! and copied to thread-rooms, which makes OOYE the author, and strips you from edit/delete rights! ]", "m.mentions": { user_ids: ["@cadence:cadence.moe"] }, From 866a49658274457fa92a97a255b7f08b261a9681 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 14 Oct 2026 16:13:45 +1300 Subject: [PATCH 161/184] Support component URL previews --- src/d2m/converters/message-to-event.js | 14 +++++ .../message-to-event.test.components.js | 23 ++++++++ test/data.js | 52 +++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 1ed97547..a291a5e0 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -960,6 +960,10 @@ async function messageToEvent(message, guild, options = {}, di) { } // Then components + // Shift component-type URL embeds here too + const componentEmbeds = dUtils.filterTo(message.embeds || [], e => e.type !== "components") + message.components ??= [] + message.components.push(...componentEmbeds.map(e => e.components).flat(1)) if (message.components?.length) { const stack = new mxUtils.MatrixStringBuilderStack() /** @param {DiscordTypes.APIMessageComponent} component */ @@ -1064,6 +1068,16 @@ async function messageToEvent(message, guild, options = {}, di) { if ((message.flags ?? 0) & DiscordTypes.MessageFlags.IsComponentsV2) { await addTextEvent(body, formatted_body, "m.text") } + // Add as m.notice if component URL preview + else if (componentEmbeds.length) { + mergeTextEvents([{ + msgtype: "m.notice", + body, + format: "org.matrix.custom.html", + formatted_body, + "m.mentions": mentions + }], events, true, false) + } // Add to existing message if legacy components https://docs.discord.com/developers/components/reference#legacy-message-component-behavior else { mergeTextEvents([{ diff --git a/src/d2m/converters/message-to-event.test.components.js b/src/d2m/converters/message-to-event.test.components.js index 47e27b5c..3f2f4d0f 100644 --- a/src/d2m/converters/message-to-event.test.components.js +++ b/src/d2m/converters/message-to-event.test.components.js @@ -3,6 +3,29 @@ const {messageToEvent} = require("./message-to-event") const data = require("../../../test/data") const {mockGetEffectivePower} = require("../../matrix/utils.test") +test("message2event url preview components: wikipedia", async t => { + const events = await messageToEvent(data.message_with_components.wikipedia_component_url_preview, data.guild.general, {}) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "https://en.wikipedia.org/wiki/HTTP/2", + format: "org.matrix.custom.html", + formatted_body: "https://en.wikipedia.org/wiki/HTTP/2", + "m.mentions": {}, + msgtype: "m.text" + }, + { + body: "| ### [HTTP/2](https://en.wikipedia.org/wiki/HTTP/2?wprov=diim1)" + + "\n| " + + "\n| HTTP/2 (originally named HTTP/2.0) is a major revision of the HTTP network protocol used by the World Wide Web. It was derived from the earlier experimental SPDY protocol, originally developed by Google. HTTP/2 was developed by the HTTP Working Group (also called httpbis, where \"bis\" means \"twice\" in Latin) of the Internet Engineering Task Force (…", + format: "org.matrix.custom.html", + formatted_body: "

    HTTP/2

    HTTP/2 (originally named HTTP/2.0) is a major revision of the HTTP network protocol used by the World Wide Web. It was derived from the earlier experimental SPDY protocol, originally developed by Google. HTTP/2 was developed by the HTTP Working Group (also called httpbis, where \"bis\" means \"twice\" in Latin) of the Internet Engineering Task Force (…
    ", + msgtype: "m.notice", + "m.mentions": {} + } + ]) +}) + test("message2event components: pk question mark output", async t => { const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {}) t.deepEqual(events, [{ diff --git a/test/data.js b/test/data.js index bcc9fdb3..89f74e99 100644 --- a/test/data.js +++ b/test/data.js @@ -5456,6 +5456,58 @@ module.exports = { } }, message_with_components: { + wikipedia_component_url_preview: { + type: 0, + content: "https://en.wikipedia.org/wiki/HTTP/2", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [ + { + type: "components", + url: "https://en.wikipedia.org/wiki/HTTP/2", + content_scan_version: 4, + components: [ + { + type: 17, + id: 1, + accent_color: null, + components: [ + { + type: 10, + id: 2, + content: "### [HTTP/2](https://en.wikipedia.org/wiki/HTTP/2?wprov=diim1)\n\nHTTP/2 (originally named HTTP/2.0) is a major revision of the HTTP network protocol used by the World Wide Web. It was derived from the earlier experimental SPDY protocol, originally developed by Google. HTTP/2 was developed by the HTTP Working Group (also called httpbis, where \"bis\" means \"twice\" in Latin) of the Internet Engineering Task Force (…" + } + ], + spoiler: false + } + ] + } + ], + timestamp: "2026-10-14T02:52:14.399000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1559760719153602600", + channel_id: "1160893337029586956", + author: { + id: "1506959407827779736", + username: "dollmoder", + avatar: "37f1b0457fc28bccad57bf5baefa0b1b", + 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: "684280192553844747", + webhook_id: "1506959407827779736" + }, pk_question_mark_response: { type: 0, content: '', From 3cb12606717627007dc5424b2b6418e387853f09 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 18 Oct 2026 18:14:07 +1300 Subject: [PATCH 162/184] Rename auth file to web-utils I'm going to put more stuff in it. --- src/web/pug-sync.js | 8 ++++---- src/web/routes/guild-settings.js | 10 +++++----- src/web/routes/guild.js | 12 ++++++------ src/web/routes/link.js | 14 +++++++------- src/web/routes/log-in-with-matrix.js | 8 ++++---- src/web/routes/oauth.js | 6 +++--- src/web/routes/opt-out.js | 8 +++----- src/web/routes/password.js | 6 +++--- src/web/{auth.js => web-utils.js} | 0 9 files changed, 35 insertions(+), 37 deletions(-) rename src/web/{auth.js => web-utils.js} (100%) diff --git a/src/web/pug-sync.js b/src/web/pug-sync.js index cca90c2e..8be17c5d 100644 --- a/src/web/pug-sync.js +++ b/src/web/pug-sync.js @@ -12,8 +12,8 @@ const walkAST = require("pug-walk") const pretty = process.argv.join(" ").includes("test") const {sync} = require("../passthrough") -/** @type {import("./auth")} */ -const auth = sync.require("./auth") +/** @type {import("./web-utils")} */ +const webUtils = sync.require("./web-utils") /** @type {import("../js/errors")} */ const errors = sync.require("../js/errors") @@ -88,8 +88,8 @@ function renderPath(event, path, fragments, locals) { const requiredVars = [] const template = compileFile(path, {pretty, plugins: fragmentSelectorPlugins(fragments, requiredVars)}) pugCache.set(cacheKey, async (event, locals) => { - const session = await auth.useSession(event) - const managed = await auth.getManagedGuilds(event) + const session = await webUtils.useSession(event) + const managed = await webUtils.getManagedGuilds(event) const rel = (to, paramsObject) => { let result = getRelativePath(event.path, to) if (paramsObject) { diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js index 9f626d70..42bdd40e 100644 --- a/src/web/routes/guild-settings.js +++ b/src/web/routes/guild-settings.js @@ -6,8 +6,8 @@ const {defineEventHandler, createError, readValidatedBody, getRequestHeader, set const {as, db, sync, select, discord} = require("../../passthrough") -/** @type {import("../auth")} */ -const auth = sync.require("../auth") +/** @type {import("../web-utils")} */ +const webUtils = sync.require("../web-utils") /** @type {import("../pug-sync")} */ const pugSync = sync.require("../pug-sync") /** @type {import("../../d2m/actions/set-presence")} */ @@ -56,7 +56,7 @@ function defineToggle(key, inputOptions) { }) /** @type {Record & Record<"guild_id", string> & Record} */ // @ts-ignore const parsedBody = await readValidatedBody(event, bodySchema.parse) - const managed = await auth.getManagedGuilds(event) + const managed = await webUtils.getManagedGuilds(event) if (!managed.has(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"}) const value = options.transform(parsedBody[key]) @@ -108,7 +108,7 @@ as.router.post("/api/privacy-level", defineToggle("privacy_level", { as.router.post("/api/default-roles", defineEventHandler(async event => { const parsedBody = await readValidatedBody(event, schema.defaultRoles.parse) - const managed = await auth.getManagedGuilds(event) + const managed = await webUtils.getManagedGuilds(event) const guildID = parsedBody.guild_id if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"}) @@ -148,7 +148,7 @@ as.router.post("/api/room-mention-role", defineEventHandler(async event => { channel_id: z.string(), room_mention_role: z.string().regex(/^(?:[0-9]+)?$/) }).parse) - const managed = await auth.getManagedGuilds(event) + const managed = await webUtils.getManagedGuilds(event) if (!managed.has(body.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"}) const room = select("channel_room", "room_id", {channel_id: body.channel_id, guild_id: body.guild_id}).get() if (!room) throw createError({status: 404, message: "Linked channel not found"}) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index ef2065e3..5837b3d7 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -15,8 +15,8 @@ const {discord, as, sync, select, from, db} = require("../../passthrough") const pugSync = sync.require("../pug-sync") /** @type {import("../../d2m/actions/create-space")} */ const createSpace = sync.require("../../d2m/actions/create-space") -/** @type {import("../auth")} */ -const auth = require("../auth") +/** @type {import("../web-utils")} */ +const webUtils = require("../web-utils") /** @type {import("../../discord/utils")} */ const dUtils = sync.require("../../discord/utils") /** @type {import("../../matrix/utils")} */ @@ -150,8 +150,8 @@ function getInviteTargetSpaces(mxid) { as.router.get("/guild", defineEventHandler(async event => { const {guild_id} = await getValidatedQuery(event, schema.guild.parse) - const session = await auth.useSession(event) - const managed = await auth.getManagedGuilds(event) + const session = await webUtils.useSession(event) + const managed = await webUtils.getManagedGuilds(event) const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() // @ts-ignore const guild = discord.guilds.get(guild_id) @@ -184,7 +184,7 @@ as.router.get("/guild", defineEventHandler(async event => { as.router.get("/qr", defineEventHandler(async event => { const {guild_id} = await getValidatedQuery(event, schema.qr.parse) - const managed = await auth.getManagedGuilds(event) + const managed = await webUtils.getManagedGuilds(event) const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() // @ts-ignore const guild = discord.guilds.get(guild_id) @@ -217,7 +217,7 @@ as.router.get("/invite", defineEventHandler(async event => { as.router.post("/api/invite", defineEventHandler(async event => { const parsedBody = await readValidatedBody(event, schema.invite.parse) - const managed = await auth.getManagedGuilds(event) + const managed = await webUtils.getManagedGuilds(event) const api = getAPI(event) // Check guild ID or nonce diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 772a19c8..2206cf1c 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -7,8 +7,8 @@ const Ty = require("../../types") const DiscordTypes = require("discord-api-types/v10") const {discord, db, as, sync, select, from} = require("../../passthrough") -/** @type {import("../auth")} */ -const auth = sync.require("../auth") +/** @type {import("../web-utils")} */ +const webUtils = sync.require("../web-utils") /** @type {import("../../matrix/utils")}*/ const utils = sync.require("../../matrix/utils") /** @type {import("./guild")}*/ @@ -98,8 +98,8 @@ async function validateAndUnbridgeChannel(event, channel_id, guild_id) { as.router.post("/api/link-space", defineEventHandler(async event => { const parsedBody = await readValidatedBody(event, schema.linkSpace.parse) - const session = await auth.useSession(event) - const managed = await auth.getManagedGuilds(event) + const session = await webUtils.useSession(event) + const managed = await webUtils.getManagedGuilds(event) const api = getAPI(event) // Check guild ID @@ -148,7 +148,7 @@ as.router.post("/api/link-space", defineEventHandler(async event => { as.router.post("/api/link", defineEventHandler(async event => { const parsedBody = await readValidatedBody(event, schema.link.parse) - const managed = await auth.getManagedGuilds(event) + const managed = await webUtils.getManagedGuilds(event) const api = getAPI(event) const createRoom = getCreateRoom(event) const createSpace = getCreateSpace(event) @@ -240,7 +240,7 @@ as.router.post("/api/link", defineEventHandler(async event => { as.router.post("/api/unlink", defineEventHandler(async event => { const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse) - const managed = await auth.getManagedGuilds(event) + const managed = await webUtils.getManagedGuilds(event) // Check guild ID or nonce if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) @@ -257,7 +257,7 @@ as.router.post("/api/unlink", defineEventHandler(async event => { as.router.post("/api/unlink-space", defineEventHandler(async event => { const {guild_id} = await readValidatedBody(event, schema.unlinkSpace.parse) - const managed = await auth.getManagedGuilds(event) + const managed = await webUtils.getManagedGuilds(event) const api = getAPI(event) const snow = getSnow(event) diff --git a/src/web/routes/log-in-with-matrix.js b/src/web/routes/log-in-with-matrix.js index d36d8fa4..02865ea2 100644 --- a/src/web/routes/log-in-with-matrix.js +++ b/src/web/routes/log-in-with-matrix.js @@ -12,8 +12,8 @@ const {sync} = require("../../passthrough") const assert = require("assert").strict /** @type {import("../pug-sync")} */ const pugSync = sync.require("../pug-sync") -/** @type {import("../auth")} */ -const auth = sync.require("../auth") +/** @type {import("../web-utils")} */ +const webUtils = sync.require("../web-utils") const schema = { form: z.object({ @@ -53,11 +53,11 @@ as.router.get("/log-in-with-matrix", defineEventHandler(async event => { } const userAgent = getRequestHeader(event, "User-Agent") - if (userAgent?.match(/bot|matrix/)) throw createError({status: 400, data: "Sorry URL previewer, you can't have this URL."}) + if (userAgent?.match(/bot|matrix|tuwunel/i)) throw createError({status: 400, data: "Sorry URL previewer, you can't have this URL."}) if (!validToken.has(token)) return sendRedirect(event, `${reg.ooye.bridge_origin}/log-in-with-matrix`, 302) - const session = await auth.useSession(event) + const session = await webUtils.useSession(event) const mxid = validToken.get(token) assert(mxid) validToken.delete(token) diff --git a/src/web/routes/oauth.js b/src/web/routes/oauth.js index fc31f971..94333e4f 100644 --- a/src/web/routes/oauth.js +++ b/src/web/routes/oauth.js @@ -9,8 +9,8 @@ const getRelativePath = require("get-relative-path") const {as, db, sync} = require("../../passthrough") const {id, permissions} = require("../../../addbot") -/** @type {import("../auth")} */ -const auth = sync.require("../auth") +/** @type {import("../web-utils")} */ +const webUtils = sync.require("../web-utils") const {reg} = require("../../matrix/read-registration") const redirect_uri = `${reg.ooye.bridge_origin}/oauth` @@ -53,7 +53,7 @@ function getOauth2Token(event) { } as.router.get("/oauth", defineEventHandler(async event => { - const session = await auth.useSession(event) + const session = await webUtils.useSession(event) const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse) let scope = "guilds" diff --git a/src/web/routes/opt-out.js b/src/web/routes/opt-out.js index 412e25a5..36ff60fc 100644 --- a/src/web/routes/opt-out.js +++ b/src/web/routes/opt-out.js @@ -6,10 +6,8 @@ const {defineEventHandler, createError, readValidatedBody, getRequestHeader, set const {as, db, sync, select, discord} = require("../../passthrough") -/** @type {import("../auth")} */ -const auth = sync.require("../auth") -/** @type {import("../pug-sync")} */ -const pugSync = sync.require("../pug-sync") +/** @type {import("../web-utils")} */ +const webUtils = sync.require("../web-utils") const schema = { optOut: z.object({ @@ -23,7 +21,7 @@ as.router.post("/api/opt-out", defineEventHandler(async event => { throw createError({status: 403, message: "Forbidden", data: "JavaScript is required for the opt-out form."}) } - const session = await auth.useSession(event) + const session = await webUtils.useSession(event) if (!session.data.userID) { throw createError({status: 401, message: "Unauthorised", data: "Log in first."}) } diff --git a/src/web/routes/password.js b/src/web/routes/password.js index e1dd2990..08aebd8a 100644 --- a/src/web/routes/password.js +++ b/src/web/routes/password.js @@ -4,8 +4,8 @@ const {z} = require("zod") const {defineEventHandler, readValidatedBody, sendRedirect} = require("h3") const {as, sync} = require("../../passthrough") -/** @type {import("../auth")} */ -const auth = sync.require("../auth") +/** @type {import("../web-utils")} */ +const webUtils = sync.require("../web-utils") const schema = { password: z.object({ @@ -15,7 +15,7 @@ const schema = { as.router.post("/api/password", defineEventHandler(async event => { const {password} = await readValidatedBody(event, schema.password.parse) - const session = await auth.useSession(event) + const session = await webUtils.useSession(event) await session.update({password}) return sendRedirect(event, "../") })) diff --git a/src/web/auth.js b/src/web/web-utils.js similarity index 100% rename from src/web/auth.js rename to src/web/web-utils.js From b3d42e036c5c25768f835516effab9fbcb48f70f Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 19 Oct 2026 22:25:21 +1300 Subject: [PATCH 163/184] No URL preview for times --- src/web/routes/log-in-with-matrix.js | 3 +-- src/web/routes/time.js | 4 ++++ src/web/web-utils.js | 11 +++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/web/routes/log-in-with-matrix.js b/src/web/routes/log-in-with-matrix.js index 02865ea2..2895be09 100644 --- a/src/web/routes/log-in-with-matrix.js +++ b/src/web/routes/log-in-with-matrix.js @@ -52,8 +52,7 @@ as.router.get("/log-in-with-matrix", defineEventHandler(async event => { return pugSync.render(event, "log-in-with-matrix.pug", {next}) } - const userAgent = getRequestHeader(event, "User-Agent") - if (userAgent?.match(/bot|matrix|tuwunel/i)) throw createError({status: 400, data: "Sorry URL previewer, you can't have this URL."}) + webUtils.rejectUrlPreviewer(event) if (!validToken.has(token)) return sendRedirect(event, `${reg.ooye.bridge_origin}/log-in-with-matrix`, 302) diff --git a/src/web/routes/time.js b/src/web/routes/time.js index ad89b584..5560e135 100644 --- a/src/web/routes/time.js +++ b/src/web/routes/time.js @@ -8,6 +8,8 @@ const {z} = require("zod") const pugSync = sync.require("../pug-sync") /** @type {import("../../discord/time")} */ const discordTime = sync.require("../../discord/time") +/** @type {import("../web-utils")} */ +const webUtils = sync.require("../web-utils") const schema = { time: z.object({ @@ -17,6 +19,7 @@ const schema = { } as.router.get("/time/:timestamp", defineEventHandler(async event => { + webUtils.rejectUrlPreviewer(event) // url previewers can't show the local time, making it useless. hiding for everyone's sake const {timestamp} = await getValidatedRouterParams(event, schema.time.parse) const formatOptions = discordTime.getFormatOptions() const text = discordTime.convertDiscordTime(timestamp) @@ -24,6 +27,7 @@ as.router.get("/time/:timestamp", defineEventHandler(async event => { })) as.router.get("/time/:timestamp/:style", defineEventHandler(async event => { + webUtils.rejectUrlPreviewer(event) const {timestamp, style} = await getValidatedRouterParams(event, schema.time.parse) const formatOptions = discordTime.getFormatOptions(style) const text = discordTime.convertDiscordTime(timestamp, style) diff --git a/src/web/web-utils.js b/src/web/web-utils.js index 45d308d1..0a2c0e4c 100644 --- a/src/web/web-utils.js +++ b/src/web/web-utils.js @@ -1,6 +1,7 @@ // @ts-check const h3 = require("h3") +const {getRequestHeader, createError} = require("h3") const {db} = require("../passthrough") const {reg} = require("../matrix/read-registration") @@ -29,5 +30,15 @@ function useSession(event) { return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60}) } +/** + * Rejects by throwing createError. + * @param {h3.H3Event} event + */ +function rejectUrlPreviewer(event) { + const userAgent = getRequestHeader(event, "User-Agent") + if (userAgent?.match(/bot|matrix|tuwunel/i)) throw createError({status: 400, data: "Sorry URL previewer, you can't have this URL."}) +} + module.exports.getManagedGuilds = getManagedGuilds module.exports.useSession = useSession +module.exports.rejectUrlPreviewer = rejectUrlPreviewer From ce4b911b6fcfb62a2d6ff807d62c86c0c14b6053 Mon Sep 17 00:00:00 2001 From: Kryn Exa Skye Date: Sun, 18 Oct 2026 20:53:22 -0400 Subject: [PATCH 164/184] Show account and pronouns for PMP messages --- src/m2d/converters/event-to-message.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 1f8c4018..1540cad2 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -559,7 +559,16 @@ async function eventToMessage(event, guild, channel, di) { // MSC4144: Override display name and avatar from per-message profile if present const perMessageProfile = event.content["com.beeper.per_message_profile"] - if (perMessageProfile?.displayname) displayName = perMessageProfile.displayname + if (perMessageProfile?.displayname) { + // TODO: Ideally this should filter on the "language" key, rather than just picking the first, once we're aware of the guild's preferred locale. + // Regardless, these are intended to be in preference order, and the PMP user could always choose to reorder them if the wrong choice is made here. + const pronouns = perMessageProfile["io.fsky.nyx.pronouns"]?.[0]?.summary + let pronounsSuffix = "" + if (pronouns) { + pronounsSuffix = ` (${pronouns})` + } + displayName = `${perMessageProfile.displayname}${pronounsSuffix} | ${displayName}` + } if (perMessageProfile && "avatar_url" in perMessageProfile) { if (perMessageProfile.avatar_url) { // use provided avatar_url From 4d96963ecd2ea1a547c0533dbf44b32443ef1442 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 19 Oct 2026 22:33:42 +1300 Subject: [PATCH 165/184] Add test for PMP pronouns --- src/m2d/converters/event-to-message.test.js | 59 +++++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 6b3048ee..f876f3c2 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -5985,7 +5985,7 @@ test("event2message: com.beeper.per_message_profile overrides displayname and av messagesToDelete: [], messagesToEdit: [], messagesToSend: [{ - username: "Unstable Name", + username: "Unstable Name | cadence [they]", content: "hello from unstable profile", avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo?preset=avatar", allowed_mentions: { @@ -6018,7 +6018,7 @@ test("event2message: com.beeper.per_message_profile empty avatar_url clears avat messagesToDelete: [], messagesToEdit: [], messagesToSend: [{ - username: "No Avatar User", + username: "No Avatar User | cadence [they]", content: "hello with cleared avatar", avatar_url: "https://bridge.example.org/download/letter-avatar?letter=N&hue=90", allowed_mentions: { @@ -6054,7 +6054,7 @@ test("event2message: data-mx-profile-fallback element is stripped from formatted messagesToDelete: [], messagesToEdit: [], messagesToSend: [{ - username: "Tidus Herboren", + username: "Tidus Herboren | cadence [they]", content: "one more test", avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo?preset=avatar", allowed_mentions: { @@ -6087,7 +6087,7 @@ test("event2message: displayname prefix is stripped from plain body when per-mes messagesToDelete: [], messagesToEdit: [], messagesToSend: [{ - username: "Tidus Herboren", + username: "Tidus Herboren | cadence [they]", content: "one more test", avatar_url: "https://bridge.example.org/download/letter-avatar?letter=T&hue=90", allowed_mentions: { @@ -6098,6 +6098,57 @@ test("event2message: displayname prefix is stripped from plain body when per-mes ) }) +test("event2message: pronouns can be displayed with per-message profile", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@exa:sleeping.town", + content: { + msgtype: "m.text", + body: "Well, I can't really do it now...", + "com.beeper.per_message_profile": { + id: "Kryn", + displayname: "Kryn", + "eu.she-a.color": { + on_dark: "#FFCC00", + on_light: "#976800" + }, + "io.fsky.nyx.pronouns": [{ + language: "en", + summary: "fae/she" + }] + } + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }, {}, {}, { + api: { + async getStateEvent(roomID, type, key) { + t.equal(type, "m.room.member") + t.equal(key, "@exa:sleeping.town") + return { + displayname: "Exa", + membership: "join" + } + } + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "Kryn (fae/she) | Exa", + content: "Well, I can't really do it now...", + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=K&hue=150", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: all unknown chess emojis are used for sprite sheet", async t => { t.deepEqual( await eventToMessage({ From 860506c037b2bed11a7cc813e1b6ce1e08d6b9ff Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 19 Oct 2026 23:05:56 +1300 Subject: [PATCH 166/184] Show displayname runoff once per message group --- src/m2d/actions/send-event.js | 16 +++++++++++++--- src/m2d/converters/event-to-message.js | 4 ++-- src/m2d/event-dispatcher.js | 20 ++++++++++++-------- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index 2e6bf5cd..f7e2b1ce 100644 --- a/src/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -14,8 +14,6 @@ const channelWebhook = sync.require("./channel-webhook") const eventToMessage = sync.require("../converters/event-to-message") /** @type {import("../../matrix/api")}) */ const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/utils")}) */ -const utils = sync.require("../../matrix/utils") /** @type {import("../../d2m/actions/register-user")} */ const registerUser = sync.require("../../d2m/actions/register-user") /** @type {import("../../d2m/actions/edit-message")} */ @@ -23,6 +21,12 @@ const editMessage = sync.require("../../d2m/actions/edit-message") /** @type {import("../actions/emoji-sheet")} */ const emojiSheet = sync.require("../actions/emoji-sheet") +/** + * @type {Map} room id -> mxid + * Keep track of the sender of the latest event per room, this is needed to trim display name runoff for subsequent messages + */ +const latestSenderPerRoom = sync.remember(() => new Map()) + /** * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | stream.Readable})[]}} message * @returns {Promise} @@ -81,7 +85,12 @@ async function sendEvent(event) { // no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it - const di = {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji} + const di = { + api, + snow: discord.snow, + mxcDownloader: emojiSheet.getAndConvertEmoji, + noRunoff: latestSenderPerRoom.get(event.room_id) === event.sender + } if (event.type === "org.matrix.msc3381.poll.end") { // Validity already checked by dispatcher. Poll is definitely closed. Update it and DI necessary data. @@ -187,3 +196,4 @@ async function sendEvent(event) { } module.exports.sendEvent = sendEvent +module.exports.latestSenderPerRoom = latestSenderPerRoom diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 1540cad2..d62398bf 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -537,7 +537,7 @@ async function getL1L2ReplyLine(called = false) { * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event * @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuildTextChannel} channel - * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, pollEnd?: {messageID: string}}} di simple-as-nails dependency injection for the matrix API + * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, pollEnd?: {messageID: string}, noRunoff: boolean}} di simple-as-nails dependency injection for the matrix API */ async function eventToMessage(event, guild, channel, di) { let displayName = event.sender @@ -584,7 +584,7 @@ async function eventToMessage(event, guild, channel, di) { // put the excess characters into displayNameRunoff, later to be put at the top of the message let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName) // If the message type is m.emote, the full name is already included at the start of the message, so remove any runoff - if (event.type === "m.room.message" && event.content.msgtype === "m.emote") { + if (di?.noRunoff || (event.type === "m.room.message" && event.content.msgtype === "m.emote")) { displayNameRunoff = "" } diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index f6f414a4..daa8c043 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -182,15 +182,19 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event it is a m.room.message because that's what this listener is filtering for */ async event => { - if (utils.eventSenderIsFromDiscord(event.sender)) return - const messageResponses = await sendEvent.sendEvent(event) - if (!messageResponses.length) return - if (event.type === "m.room.message" && event.content.msgtype === "m.text") { - // @ts-ignore - await matrixCommandHandler.execute(event) + if (!utils.eventSenderIsFromDiscord(event.sender)) { + const messageResponses = await sendEvent.sendEvent(event) + if (!messageResponses.length) return + if (event.type === "m.room.message" && event.content.msgtype === "m.text") { + // @ts-ignore + await matrixCommandHandler.execute(event) + } + retrigger.finishedBridging(event.event_id) + await api.ackEvent(event) + } + if (!event.content["m.relates_to"]) { // Ignore edits since they don't update the timeline on Discord + sendEvent.latestSenderPerRoom.set(event.room_id, event.sender) } - retrigger.finishedBridging(event.event_id) - await api.ackEvent(event) })) sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", From 4852ef3b86551caeef250a3c4803b4a13e3ec705 Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 19 Oct 2026 13:12:52 +0200 Subject: [PATCH 167/184] improve consistency of my code with Cadence's --- src/m2d/event-dispatcher.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 89c04067..8ecfb09b 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -9,7 +9,7 @@ const Ty = require("../types") const {discord, db, sync, as, select} = require("../passthrough") const {tag} = require("@cloudrac3r/html-template-tag") const {Semaphore} = require("@chriscdn/promise-semaphore") -const { bridgeThread, handleForums } = require("./converters/threads-and-forums") +const {bridgeThread, handleForums} = require("./converters/threads-and-forums") /** @type {import("./actions/send-event")} */ const sendEvent = sync.require("./actions/send-event") From 1a1173413b27c9182914341d404e6c4daf7cde3f Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 19 Oct 2026 13:43:39 +0200 Subject: [PATCH 168/184] spoiler fix --- src/d2m/converters/message-to-event.js | 9 +++++---- src/discord/utils.js | 9 ++++++--- src/web/routes/download-discord.js | 19 +++++++++++++++---- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index a291a5e0..62eed46c 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -135,7 +135,8 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ * @param {{fetch?: typeof fetch}} [di] */ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { - const external_url = dUtils.getPublicUrlForCdn(attachment.url) + const spoiler = attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler); + const external_url = dUtils.getPublicUrlForCdn(attachment.url, spoiler ? "spoiler/" : "") const emoji = attachment.content_type?.startsWith("image/jp") ? "📸" : attachment.content_type?.startsWith("image/") ? "🖼️" @@ -143,15 +144,15 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { : attachment.content_type?.startsWith("text/") ? "📝" : attachment.content_type?.startsWith("audio/") ? "🎶" : "📄" - // no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote - if (attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler)) { + // no native media spoilers in Element, so we'll post a spoiler (unpreviewable) link instead + if (spoiler) { return { $type: "m.room.message", "m.mentions": mentions, msgtype: "m.text", body: `${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})`, format: "org.matrix.custom.html", - formatted_body: `
    ${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})
    ` + formatted_body: `${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})` } } // attempt to convert large PNG image files to JPEG, since it's almost certainly a photo that was forced into PNG by the user copy-pasting. diff --git a/src/discord/utils.js b/src/discord/utils.js index 6fb0b433..2f7a7e0e 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -134,14 +134,17 @@ function timestampToSnowflakeInexact(timestamp) { return String((timestamp - EPOCH) * 2**22) } -/** @param {string} url */ -function getPublicUrlForCdn(url) { +/** + * @param {string} url + * @param {""|"spoiler/"} spoiler + */ +function getPublicUrlForCdn(url, spoiler="") { const match = url.match(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/) if (!match) return url const unsignedHash = hasher.h64(match[3]) // attachment ID const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) - return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}` + return `${reg.ooye.bridge_origin}/download/discord${match[1]}${spoiler}/${match[2]}/${match[3]}/${match[4]}` } /** diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index 769fc9c0..f802d178 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -9,7 +9,10 @@ let hasher = null // @ts-ignore require("xxhash-wasm")().then(h => hasher = h) -const {discord, as, select} = require("../../passthrough") +const {discord, as, select, sync} = require("../../passthrough") + +/** @type {import("../web-utils")} */ +const webUtils = sync.require("../web-utils") const schema = { params: z.object({ @@ -40,10 +43,16 @@ function timeUntilExpiry(url) { if (time > 0) return time } -function defineMediaProxyHandler(domain) { +/** + * @param {string} domain + * @param {boolean} spoiler + */ +function defineMediaProxyHandler(domain, spoiler) { return defineEventHandler(async event => { const params = await getValidatedRouterParams(event, schema.params.parse) + if (spoiler) webUtils.rejectUrlPreviewer(event) + const unsignedHash = hasher.h64(params.attachment_id) const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range @@ -81,7 +90,9 @@ function defineMediaProxyHandler(domain) { }) } -as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com")) -as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net")) +as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com", false)) +as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net", false)) +as.router.get(`/download/discordcdn/:channel_id/spoiler/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com", true)) +as.router.get(`/download/discordmedia/:channel_id/spoiler/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net", true)) module.exports._cache = cache From 966095c3610b5565c2dd902805ebfb3b00dd2187 Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 19 Oct 2026 13:57:48 +0200 Subject: [PATCH 169/184] of course, I fucked up where the / goes... --- src/discord/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/discord/utils.js b/src/discord/utils.js index 2f7a7e0e..9d1775fe 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -144,7 +144,7 @@ function getPublicUrlForCdn(url, spoiler="") { const unsignedHash = hasher.h64(match[3]) // attachment ID const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) - return `${reg.ooye.bridge_origin}/download/discord${match[1]}${spoiler}/${match[2]}/${match[3]}/${match[4]}` + return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${spoiler}${match[2]}/${match[3]}/${match[4]}` } /** From 4350bff202e83151b25c35ba8068537d75c06bf4 Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 19 Oct 2026 14:06:33 +0200 Subject: [PATCH 170/184] kill me --- src/discord/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/discord/utils.js b/src/discord/utils.js index 9d1775fe..8effd884 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -144,7 +144,7 @@ function getPublicUrlForCdn(url, spoiler="") { const unsignedHash = hasher.h64(match[3]) // attachment ID const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) - return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${spoiler}${match[2]}/${match[3]}/${match[4]}` + return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${spoiler}${match[3]}/${match[4]}` } /** From 727b5a3e4f97c59de6d0cd69d6adc00c6eed6d81 Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 19 Oct 2026 15:33:54 +0200 Subject: [PATCH 171/184] update tests --- src/d2m/converters/message-to-event.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 44b9fe65..233a4ba5 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -366,9 +366,9 @@ test("message2event: spoiler attachment", async t => { $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)", + body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/spoiler/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)", format: "org.matrix.custom.html", - formatted_body: "
    📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)
    " + formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/spoiler/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)" }]) }) @@ -1205,13 +1205,13 @@ test("message2event: multiple attachments are combined into the same event where msgtype: "m.text", body: "hey" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)" - + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg (38 KB)" + + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/spoiler/456/SPOILER_secret.jpg (38 KB)" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", format: "org.matrix.custom.html", formatted_body: "hey" + `
    📄 Uploaded file: hey.jpg (100 MB)` - + `
    📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg (38 KB)
    ` - + `📄 Uploaded file: hey.jpg (100 MB)` + + `
    📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/spoiler/456/SPOILER_secret.jpg (38 KB)` + + `
    📄 Uploaded file: hey.jpg (100 MB)` }, { $type: "m.room.message", "m.mentions": {}, From 12d448d7fb114e89dcd31cbdb21430dd25186796 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 20 Oct 2026 17:33:09 +1300 Subject: [PATCH 172/184] Avoid media stream errors crashing process --- src/m2d/actions/channel-webhook.js | 4 ++-- src/m2d/actions/send-event.js | 16 ++++++++++------ src/m2d/actions/sticker.js | 23 ++++++++--------------- src/m2d/converters/event-to-message.js | 2 +- src/web/routes/download-matrix.js | 6 +++--- 5 files changed, 24 insertions(+), 27 deletions(-) diff --git a/src/m2d/actions/channel-webhook.js b/src/m2d/actions/channel-webhook.js index 01b3dd4a..5c0cc2b5 100644 --- a/src/m2d/actions/channel-webhook.js +++ b/src/m2d/actions/channel-webhook.js @@ -57,7 +57,7 @@ async function withWebhook(channelID, callback) { /** * @param {string} channelID - * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}} data + * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Blob}[]}} data * @param {string} [threadID] */ async function sendMessageWithWebhook(channelID, data, threadID) { @@ -70,7 +70,7 @@ async function sendMessageWithWebhook(channelID, data, threadID) { /** * @param {string} channelID * @param {string} messageID - * @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}} data + * @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | Blob}[]}} data * @param {string} [threadID] */ async function editMessageWithWebhook(channelID, messageID, data, threadID) { diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index f7e2b1ce..a6bf4561 100644 --- a/src/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -28,8 +28,8 @@ const emojiSheet = sync.require("../actions/emoji-sheet") const latestSenderPerRoom = sync.remember(() => new Map()) /** - * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | stream.Readable})[]}} message - * @returns {Promise} + * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Blob}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | Blob})[]}} message + * @returns {Promise} */ async function resolvePendingFiles(message) { if (!message.pendingFiles) return message @@ -42,18 +42,22 @@ async function resolvePendingFiles(message) { } if ("key" in p) { // Encrypted file + const res = await api.getMedia(p.mxc) 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)) + /** @type {ReadableStream} */ // @ts-ignore - close enough + const piped = res.body.pipeThrough(stream.Transform.toWeb(d)) + const blob = await new Response(piped).blob() return { name: p.name, - file: d + file: blob } } else { // Unencrypted file - const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body)) + const res = await api.getMedia(p.mxc) + const blob = await res.blob() return { name: p.name, - file: body + file: blob } } })) diff --git a/src/m2d/actions/sticker.js b/src/m2d/actions/sticker.js index f5a1e160..b484c7d2 100644 --- a/src/m2d/actions/sticker.js +++ b/src/m2d/actions/sticker.js @@ -1,6 +1,6 @@ // @ts-check -const {Readable} = require("stream") +const {Transform} = require("stream") const {ReadableStream} = require("stream/web") const {sync} = require("../../passthrough") @@ -9,7 +9,6 @@ const sharp = require("sharp") const api = sync.require("../../matrix/api") /** @type {import("../../matrix/mreq")} */ const mreq = sync.require("../../matrix/mreq") -const {streamType} = require("@cloudrac3r/stream-type") const WIDTH = 160 const HEIGHT = 160 @@ -20,20 +19,14 @@ const HEIGHT = 160 */ async function getAndResizeSticker(mxc) { const res = await api.getMedia(mxc) - if (res.status !== 200) { - const root = await res.json() - throw new mreq.MatrixServerError(root, res.status, {mxc}) - } - const streamIn = Readable.fromWeb(res.body) - const {streamThrough, type} = await streamType(streamIn) - const animated = ["image/gif", "image/webp"].includes(type) - - const transformer = sharp({animated: animated}) - .resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}}) - .webp() - streamThrough.pipe(transformer) - return Readable.toWeb(transformer) + return res.body.pipeThrough( + Transform.toWeb( + sharp({animated: true}) + .resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}}) + .webp() + ) + ) } diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index d62398bf..bab7a51e 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -1079,7 +1079,7 @@ async function eventToMessage(event, guild, channel, di) { } // Assemble chunks into Discord messages content - /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */ + /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Blob}[]})[]} */ const messages = chunks.map(content => ({ content, allowed_mentions, diff --git a/src/web/routes/download-matrix.js b/src/web/routes/download-matrix.js index 26645207..f7a8db09 100644 --- a/src/web/routes/download-matrix.js +++ b/src/web/routes/download-matrix.js @@ -4,7 +4,7 @@ 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 {Transform} = require("stream") const sharp = require("sharp") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore @@ -26,8 +26,8 @@ const sticker = sync.require("../../m2d/actions/sticker") /** @type {{[presetKey: string]: (body: ReadableStream) => ReadableStream}} */ const MEDIA_THUMBNAIL_PRESETS = { avatar: body => - Readable.toWeb( - Readable.fromWeb(body).pipe( + body.pipeThrough( + Transform.toWeb( 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 From 4ac4aecebf5d72ec0ceb4a5c21e691578cf00c63 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 20 Oct 2026 17:33:59 +1300 Subject: [PATCH 173/184] Reliability fixes --- src/d2m/actions/create-room.js | 13 +++++++++-- src/d2m/discord-packets.js | 2 +- src/m2d/event-dispatcher.js | 4 ++-- src/matrix/homeserver-status.js | 34 ++++++++++++++++++---------- src/matrix/matrix-command-handler.js | 4 ++-- src/matrix/mreq.js | 4 ++++ 6 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index a6230b2e..aaddd4f8 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -497,8 +497,17 @@ async function unbridgeChannel(channel, guildID, messageBeforeLeave = "This room const powerLevelsUsers = (powerLevels.users ||= {}) for (const mxid of Object.keys(powerLevelsUsers)) { if (powerLevelsUsers[mxid] >= (powerLevels.state_default ?? 50) && !allCreators.includes(mxid) && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== mUtils.bot) { - delete powerLevelsUsers[mxid] - await api.sendState(roomID, "m.room.power_levels", "", powerLevels, mxid) // done individually because each user must demote themselves + // can OOYE bot demote them? + if (allCreators.includes(mUtils.bot) || powerLevelsUsers[mxid] < powerLevelsUsers[mUtils.bot]) { + delete powerLevelsUsers[mxid] + await api.sendState(roomID, "m.room.power_levels", "", powerLevels) // bot can demote them + } else { + // check user is still in room and can send events + if (select("sim_member", "mxid", {mxid, room_id: roomID}).get()) { + delete powerLevelsUsers[mxid] + await api.sendState(roomID, "m.room.power_levels", "", powerLevels, mxid) // done individually because each user must demote themselves + } + } } } diff --git a/src/d2m/discord-packets.js b/src/d2m/discord-packets.js index 9f3b291b..bbfa1d0c 100644 --- a/src/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -221,7 +221,7 @@ async function onPacket(client, message, listen) { // when not real time, skip optional events } else { // queue required events - homeserverStatus.homeserverStatus.queuePacket(message) + homeserverStatus.homeserverStatus.queuePacket(["Discord", message]) } } } diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index daa8c043..a02763f7 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -128,7 +128,7 @@ async function sendError(roomID, source, type, e, payload) { || (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus) && e.errcode === "CX_SERVER_ERROR") ) { // Matrix homeserver is down (reverse proxy indicated failure) - homeserverStatus.homeserverStatus.setErrorWithPacket(payload) + homeserverStatus.homeserverStatus.setErrorWithPacket([source, payload]) } } } @@ -266,7 +266,7 @@ async event => { // Try to bridge a failed event again? await onRetryReactionAdd(event) } else { - matrixCommandHandler.onReactionAdd(event) + if (matrixCommandHandler.onReactionAdd(event)) return await addReaction.addReaction(event) } })) diff --git a/src/matrix/homeserver-status.js b/src/matrix/homeserver-status.js index 6d0ea76f..59cbfb03 100644 --- a/src/matrix/homeserver-status.js +++ b/src/matrix/homeserver-status.js @@ -54,6 +54,7 @@ const homeserverStatus = new class HomeserverStatus { .defineState("offline", { onEnter: [() => { + console.log("[homeserver status] Offline. The failed event and further events will be queued.") this.pingInterval = setInterval(async () => { const pingResult = await api.ping().catch(e => ({ok: false, status: "net", root: e.message})) this.lastCheck = Date.now() @@ -70,13 +71,22 @@ const homeserverStatus = new class HomeserverStatus { .defineState("recovering", { onEnter: [async () => { // Drain queue. - while (!this.queue.isEmpty()) { - const packet = this.queue.peekFront() // same position as .shift() - debugHomeserverStatus(`homeserver status: ${new Date().toISOString()} dq packet ${packet.t} ${packet.d?.content}`) - await discordPackets.dispatchPacketToBridge(passthrough.discord, packet) - if (this.sm.currentStateName !== "recovering") return // got kicked out due to another error - this.queue.shift() + if (!this.queue.isEmpty()) { + console.log(`[homeserver status] Online, playing ${this.queue.length} events from queue...`) + while (!this.queue.isEmpty()) { + const [source, packet] = this.queue.peekFront() // same position as .shift() + debugHomeserverStatus(`homeserver status: ${new Date().toISOString()} dq sourceAndPacket ${packet.t} ${packet.d?.content}`) + if (source === "Discord") { + await discordPackets.dispatchPacketToBridge(passthrough.discord, packet) + } else { + // Unfortunately cannot await this, just have to trust it + passthrough.as.emit(`type:${packet.type}`, packet) + } + if (this.sm.currentStateName !== "recovering") return // got kicked out due to another error + this.queue.shift() + } } + console.log("[homeserver status] Back to real-time operation.") this.sm.doTransition("recovered") }], onLeave: [], @@ -88,7 +98,7 @@ const homeserverStatus = new class HomeserverStatus { .defineTransition("recovering", "recovered", "online") .defineTransition("online", "check assume online", "checking") .defineTransition("online", "check assume offline", "checking") - .defineTransition("checking", "check ok", "recovering") + .defineTransition("checking", "check ok", "online") .defineTransition("checking", "check fail", "offline") this.sm.on("enter", st => debugHomeserverStatus(`homeserver status: ${st}`)) @@ -129,16 +139,16 @@ const homeserverStatus = new class HomeserverStatus { /** * When offline or recovering, call this for incoming packets to queue them to be sent in order later. */ - queuePacket(packet) { + queuePacket(sourceAndPacket) { assert(["offline", "recovering"].includes(this.sm.currentStateName)) - this.queue.push(packet) + this.queue.push(sourceAndPacket) } - setErrorWithPacket(packet) { + setErrorWithPacket(sourceAndPacket) { const wasRecovering = this.sm.currentStateName === "recovering" this.sm.doTransition("error") - if (!wasRecovering) { // if was recovering then packet is already in the right place in queue - this.queuePacket(packet) + if (!wasRecovering) { // if was recovering then sourceAndPacket is already in the right place in queue + this.queuePacket(sourceAndPacket) } } } diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index de81899a..23cb6a70 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -222,7 +222,7 @@ const commands = [{ b.add("", `:${e.name}:`) } await api.sendState(event.room_id, type, key, pack) - api.sendEvent(event.room_id, "m.room.message", { + await api.sendEvent(event.room_id, "m.room.message", { ...ctx, ...b.get() }) @@ -238,7 +238,7 @@ const commands = [{ console.log(`uploading emoji ${resizeOutput.data.length} bytes to :${e.name}:`) await discord.snow.assets.createGuildEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")}) } - api.sendEvent(event.room_id, "m.room.message", { + await api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", body: `Created ${toUpload.length} emojis` diff --git a/src/matrix/mreq.js b/src/matrix/mreq.js index b6bc5b9d..fe989dbe 100644 --- a/src/matrix/mreq.js +++ b/src/matrix/mreq.js @@ -77,6 +77,10 @@ async function mreq(method, url, bodyIn, extra = {}) { }, extra) const res = await fetch(baseUrl + url, opts) + if (!res.ok) { + throw await makeMatrixServerError(res, {baseUrl, url, ...opts}) + } + const text = await res.text() try { /** @type {any} */ From 1434cb567cd0d2cf0838d0f4117487c086eeb5e0 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 20 Oct 2026 22:25:20 +1300 Subject: [PATCH 174/184] Fix time display skipping size calculations --- src/web/pug/time.pug | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/pug/time.pug b/src/web/pug/time.pug index ed37bec4..71cb6882 100644 --- a/src/web/pug/time.pug +++ b/src/web/pug/time.pug @@ -18,10 +18,10 @@ html(lang="en") max-width: !{WIDTH}px; } .only-script { - display: none; + visibility: hidden; } .yes-script .only-script { - display: revert; + visibility: visible; } .local { line-height: 1; From be403698f9049e24e2561bf6eced91c3e114335d Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 20 Oct 2026 22:35:52 +1300 Subject: [PATCH 175/184] Use time markup per MSC3160 Supported by Sable, don't know of any other supporting clients Always includes full timestamp (rather than partial date/time) because Sable doesn't render partials. Has custom data-style extension if anyone cares https://github.com/matrix-org/matrix-spec-proposals/pull/3160 --- src/d2m/converters/message-to-event.js | 9 +++++++-- .../converters/message-to-event.test.components.js | 2 +- src/d2m/converters/message-to-event.test.js | 12 ++++++------ 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index a291a5e0..40ba4b21 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -79,8 +79,13 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) { const text = discordTime.convertDiscordTime(node.timestamp, node.style) if (useHTML) { let url = `${reg.ooye.bridge_origin}/time/${node.timestamp}` - if (node.style) url += `/${node.style}` - return tag`${text}` + let dataStyle = "" + if (node.style) { + url += `/${node.style}` + dataStyle = tag`data-style="${node.style}" ` + } + const iso = new Date(node.timestamp * 1000).toISOString() + return tag`` } else { return text } diff --git a/src/d2m/converters/message-to-event.test.components.js b/src/d2m/converters/message-to-event.test.components.js index 3f2f4d0f..4cb5d96b 100644 --- a/src/d2m/converters/message-to-event.test.components.js +++ b/src/d2m/converters/message-to-event.test.components.js @@ -96,7 +96,7 @@ test("message2event components: pk question mark output", async t => { + "
    " + "

    Same hat

    " + `🖼️ Image: image.png
    ` - + "

    Original Message ID: 1466556003645657118 · 29 January 2026 at 10:09 pm (UTC)

    ", + + "

    Original Message ID: 1466556003645657118 ·

    ", "m.mentions": {}, msgtype: "m.text", }]) diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 44b9fe65..2c9c7f91 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -214,12 +214,12 @@ test("message2event: timestamps", async t => { + "\n3 October 2026 at 9:30 am (UTC)" + "\n24 December 2026 at 11:00 pm (UTC)", format: "org.matrix.custom.html", - formatted_body: `4 Oct 2026, 9:33:33 am (UTC)` - + `
    4 October 2026 at 9:33 am (UTC)` - + `
    Sunday, 4 October 2026 at 9:34 am (UTC)` - + `
    9:34:08 am (UTC)` - + `
    3 October 2026 at 9:30 am (UTC)` - + `
    24 December 2026 at 11:00 pm (UTC)`, + formatted_body: `` + + `
    ` + + `
    ` + + `
    ` + + `
    ` + + `
    `, "m.mentions": {} }]) }) From 3dd78307268da44542aed5abe1197ebfadce623b Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 20 Oct 2026 22:40:35 +1300 Subject: [PATCH 176/184] Tests to see when uncaught errors are emitted --- scripts/http-interrupt-test-client.js | 31 +++++++++++++ scripts/http-interrupt-test-server.js | 63 +++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 scripts/http-interrupt-test-client.js create mode 100644 scripts/http-interrupt-test-server.js diff --git a/scripts/http-interrupt-test-client.js b/scripts/http-interrupt-test-client.js new file mode 100644 index 00000000..cd764993 --- /dev/null +++ b/scripts/http-interrupt-test-client.js @@ -0,0 +1,31 @@ +// @ts-check + +const crypto = require("crypto") +const fs = require("fs") +const assert = require("assert") +const stream = require("stream") +const {scheduler} = require("timers/promises") + +;(async () => { + try { + const res = await fetch("http://localhost:6694", { + keepalive: true + }) + assert(res.body) + + // const data = await res.blob().then(blob => blob.bytes()) + // console.log(data) + + const c = crypto.createCipheriv("aes-256-ctr", Buffer.from("oKInhenybApUF-X4ahfQi0dIql8fyTXDVjfiEZvJquE", "base64url"), Buffer.from("Qt4ZM5g1zDQAAAAAAAAAAA", "base64url")) + // @ts-ignore - close enough + // const nodeBody = stream.Readable.fromWeb(res.body) + console.log(await new Response(res.body.pipeThrough(stream.Transform.toWeb(c))).bytes()) + + + console.log("all done") + } catch (e) { + console.error(e) + } + + console.log("didn't crash") +})() diff --git a/scripts/http-interrupt-test-server.js b/scripts/http-interrupt-test-server.js new file mode 100644 index 00000000..0b2e30d6 --- /dev/null +++ b/scripts/http-interrupt-test-server.js @@ -0,0 +1,63 @@ +// @ts-check + +const net = require("net") +const stream = require("stream") +const {scheduler} = require("timers/promises") + +const CHUNKED = true +const REPEAT_COUNT = 10 + +const b = new Uint8Array(1024).fill(0x65) + +async function* createData() { + for (let i = 0; i < REPEAT_COUNT; i++) { + console.log(`generated chunk ${i}`) + if (CHUNKED) { + yield b.length.toString(16) + yield "\r\n" + } + yield b + if (CHUNKED) { + yield "\r\n" + } + await scheduler.wait(800) + } + if (CHUNKED) { + yield "0\r\n\r\n" + } +} + +const server = net.createServer(socket => { + socket.write(`HTTP/1.1 200 OK\r\n`) + socket.write("Connection: keep-alive\r\n") + if (CHUNKED) { + socket.write("Transfer-Encoding: chunked\r\n") + } else { + socket.write(`Content-Length: ${b.length*REPEAT_COUNT}\r\n`) + } + socket.write(`\r\n`) + const dataStream = stream.Readable.from(createData()) + dataStream.pipe(socket) + + // In case of problem with dataStream + dataStream.on("error", () => { + console.error("aw dang it") + socket.end() + }) + + // In case of problem with recipient + socket.on("close", () => { + console.log("socket close") + dataStream.destroy() + }) + + socket.on("error", () => { + console.log("socket error") + }) + + socket.on("end", () => { + console.log("socket end") + }) +}) + +server.listen(6694) From 5da1a090c6b1393be53154b77e4984b9705516fe Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 20 Oct 2026 12:35:03 +0200 Subject: [PATCH 177/184] rewrite in QP --- src/d2m/converters/message-to-event.js | 2 +- src/d2m/converters/message-to-event.test.js | 8 +++--- src/discord/utils.js | 4 +-- src/web/routes/download-discord.js | 25 ++++++++++--------- src/web/routes/download-discord.test.js | 27 +++++++++++++++++++++ 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 62eed46c..334d55eb 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -136,7 +136,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ */ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { const spoiler = attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler); - const external_url = dUtils.getPublicUrlForCdn(attachment.url, spoiler ? "spoiler/" : "") + const external_url = dUtils.getPublicUrlForCdn(attachment.url, spoiler ? "?spoiler" : "") const emoji = attachment.content_type?.startsWith("image/jp") ? "📸" : attachment.content_type?.startsWith("image/") ? "🖼️" diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 233a4ba5..59abb1ef 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -366,9 +366,9 @@ test("message2event: spoiler attachment", async t => { $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/spoiler/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)", + body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler (74 KB)", format: "org.matrix.custom.html", - formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/spoiler/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)" + formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler (74 KB)" }]) }) @@ -1205,12 +1205,12 @@ test("message2event: multiple attachments are combined into the same event where msgtype: "m.text", body: "hey" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)" - + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/spoiler/456/SPOILER_secret.jpg (38 KB)" + + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler (38 KB)" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", format: "org.matrix.custom.html", formatted_body: "hey" + `
    📄 Uploaded file: hey.jpg (100 MB)` - + `
    📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/spoiler/456/SPOILER_secret.jpg (38 KB)` + + `
    📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler (38 KB)` + `
    📄 Uploaded file: hey.jpg (100 MB)` }, { $type: "m.room.message", diff --git a/src/discord/utils.js b/src/discord/utils.js index 8effd884..19bf743c 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -136,7 +136,7 @@ function timestampToSnowflakeInexact(timestamp) { /** * @param {string} url - * @param {""|"spoiler/"} spoiler + * @param {""|"?spoiler"} spoiler */ function getPublicUrlForCdn(url, spoiler="") { const match = url.match(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/) @@ -144,7 +144,7 @@ function getPublicUrlForCdn(url, spoiler="") { const unsignedHash = hasher.h64(match[3]) // attachment ID const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) - return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${spoiler}${match[3]}/${match[4]}` + return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}${spoiler}` } /** diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index f802d178..550b669b 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -1,7 +1,7 @@ // @ts-check const assert = require("assert/strict") -const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError, H3Event} = require("h3") +const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError, H3Event, getValidatedQuery} = require("h3") const {z} = require("zod") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore @@ -15,10 +15,13 @@ const {discord, as, select, sync} = require("../../passthrough") const webUtils = sync.require("../web-utils") const schema = { - params: z.object({ + router: z.object({ channel_id: z.string().regex(/^[0-9]+$/), attachment_id: z.string().regex(/^[0-9]+$/), file_name: z.string().regex(/^[-A-Za-z0-9_.,]+$/) + }), + query: z.object({ + spoiler: z.optional(z.string().max(0)) // Lets you choose between no query param, and a ?spoiler query param (H3 considers it an empty string). Booleans do NOT work here, unfortunately. }) } @@ -45,15 +48,15 @@ function timeUntilExpiry(url) { /** * @param {string} domain - * @param {boolean} spoiler */ -function defineMediaProxyHandler(domain, spoiler) { +function defineMediaProxyHandler(domain) { return defineEventHandler(async event => { - const params = await getValidatedRouterParams(event, schema.params.parse) + const routerParams = await getValidatedRouterParams(event, schema.router.parse) + const queryParams = await getValidatedQuery(event, schema.query.parse) - if (spoiler) webUtils.rejectUrlPreviewer(event) + if (queryParams.spoiler === "") webUtils.rejectUrlPreviewer(event) - const unsignedHash = hasher.h64(params.attachment_id) + const unsignedHash = hasher.h64(routerParams.attachment_id) const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range const row = select("media_proxy", "permitted_hash", {permitted_hash: signedHash}).get() @@ -64,7 +67,7 @@ function defineMediaProxyHandler(domain, spoiler) { }) } - const url = `https://${domain}/attachments/${params.channel_id}/${params.attachment_id}/${params.file_name}` + const url = `https://${domain}/attachments/${routerParams.channel_id}/${routerParams.attachment_id}/${routerParams.file_name}` let promise = cache.get(url) /** @type {string | undefined} */ let refreshed @@ -90,9 +93,7 @@ function defineMediaProxyHandler(domain, spoiler) { }) } -as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com", false)) -as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net", false)) -as.router.get(`/download/discordcdn/:channel_id/spoiler/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com", true)) -as.router.get(`/download/discordmedia/:channel_id/spoiler/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net", true)) +as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com")) +as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net")) module.exports._cache = cache diff --git a/src/web/routes/download-discord.test.js b/src/web/routes/download-discord.test.js index 0d4b8844..e08832cf 100644 --- a/src/web/routes/download-discord.test.js +++ b/src/web/routes/download-discord.test.js @@ -46,6 +46,33 @@ test("web download discord: works if a known attachment", async t => { t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) }) +test("web download discord: works with spoilers", async t => { + const event = {} + await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name?spoiler", { + params: { + channel_id: "655216173696286746", + attachment_id: "1314358913482621010", + file_name: "image.png" + }, + event, + snow: { + channel: { + async refreshAttachmentURLs(attachments) { + assert(Array.isArray(attachments)) + return { + refreshed_urls: attachments.map(a => ({ + original: a, + refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` + })) + } + } + } + } + }) + t.equal(event.node.res.statusCode, 302) + t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) +}) + test("web download discord: uses cache", async t => { let notCalled = true const event = {} From 15055b8ee09a98aaead8c48e462abb65c7d05c33 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 20 Oct 2026 13:22:18 +0200 Subject: [PATCH 178/184] Use less hacky way of setting the params --- src/d2m/converters/message-to-event.js | 2 +- src/d2m/converters/message-to-event.test.js | 226 ++++++++++---------- src/discord/utils.js | 8 +- src/web/routes/download-discord.js | 2 +- 4 files changed, 122 insertions(+), 116 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 334d55eb..65fe3e45 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -136,7 +136,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ */ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { const spoiler = attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler); - const external_url = dUtils.getPublicUrlForCdn(attachment.url, spoiler ? "?spoiler" : "") + const external_url = dUtils.getPublicUrlForCdn(attachment.url, spoiler) const emoji = attachment.content_type?.startsWith("image/jp") ? "📸" : attachment.content_type?.startsWith("image/") ? "🖼️" diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 59abb1ef..dd2449f6 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1,10 +1,10 @@ -const {test} = require("supertape") -const {messageToEvent} = require("./message-to-event") -const {MatrixServerError} = require("../../matrix/mreq") +const { test } = require("supertape") +const { messageToEvent } = require("./message-to-event") +const { MatrixServerError } = require("../../matrix/mreq") const data = require("../../../test/data") -const {mockGetEffectivePower} = require("../../matrix/utils.test") +const { mockGetEffectivePower } = require("../../matrix/utils.test") const Ty = require("../../types") -const {db} = require("../../passthrough") +const { db } = require("../../passthrough") /** * @param {string} roomID @@ -12,7 +12,7 @@ const {db} = require("../../passthrough") * @returns {(roomID: string, eventID: string) => Promise>} */ function mockGetEvent(t, roomID_in, eventID_in, outer) { - return async function(roomID, eventID) { + return async function (roomID, eventID) { t.equal(roomID, roomID_in) t.equal(eventID, eventID_in) return new Promise(resolve => { @@ -74,8 +74,8 @@ test("message2event: simple room mention", async t => { t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -102,8 +102,8 @@ test("message2event: simple room link", async t => { t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -130,8 +130,8 @@ test("message2event: nicked room mention", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -234,8 +234,8 @@ test("message2event: simple message link", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:super.invalid": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:super.invalid": { display_name: null, avatar_url: null } } } } @@ -270,8 +270,8 @@ test("message2event: message link that OOYE doesn't know about", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -308,8 +308,8 @@ test("message2event: message timestamp failed to fetch", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -366,9 +366,9 @@ test("message2event: spoiler attachment", async t => { $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler (74 KB)", + body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler= (74 KB)", format: "org.matrix.custom.html", - formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler (74 KB)" + formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler= (74 KB)" }]) }) @@ -505,7 +505,7 @@ test("message2event: simple reply to matrix user", async t => { }) test("message2event: simple reply to matrix user, reply fallbacks disabled", async t => { - const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {includeReplyFallback: false}, { + const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, { includeReplyFallback: false }, { api: { getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", { type: "m.room.message", @@ -605,17 +605,17 @@ test("message2event: voice message", async t => { const events = await messageToEvent(data.message.voice_message) t.deepEqual(events, [{ $type: "m.room.message", - body: "voice-message.ogg", - external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg", - filename: "voice-message.ogg", - info: { - duration: 3960, - mimetype: "audio/ogg", - size: 10584, + body: "voice-message.ogg", + external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg", + filename: "voice-message.ogg", + info: { + duration: 3960, + mimetype: "audio/ogg", + size: 10584, }, - "m.mentions": {}, - msgtype: "m.audio", - url: "mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB" + "m.mentions": {}, + msgtype: "m.audio", + url: "mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB" }]) }) @@ -628,16 +628,16 @@ test("message2event: misc file", async t => { "m.mentions": {} }, { $type: "m.room.message", - body: "the.yml", - external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml", - filename: "the.yml", + body: "the.yml", + external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml", + filename: "the.yml", info: { mimetype: "text/plain; charset=utf-8", size: 2274 }, - "m.mentions": {}, - msgtype: "m.file", - url: "mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP" + "m.mentions": {}, + msgtype: "m.file", + url: "mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP" }]) }) @@ -674,7 +674,7 @@ test("message2event: simple reply in thread to a matrix user's reply", async t = user_ids: ["@cadence:cadence.moe"] }, msgtype: "m.text", - body: "Well, they don't seem to..." + body: "Well, they don't seem to..." }]) }) @@ -711,7 +711,7 @@ test("message2event: infinidoge's reply to ami's matrix smalltext reply to infin user_ids: ["@ami:the-apothecary.club"] }, msgtype: "m.text", - body: `Most likely` + body: `Most likely` }]) }) @@ -748,7 +748,7 @@ test("message2event: infinidoge's reply to ami's matrix smalltext singleline rep user_ids: ["@ami:the-apothecary.club"] }, msgtype: "m.text", - body: `Most likely` + body: `Most likely` }]) }) @@ -757,11 +757,11 @@ test("message2event: reply to a Discord message that wasn't bridged", async t => t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.text", - body: `> In reply to a 1-day-old unbridged message:` + body: `> In reply to a 1-day-old unbridged message:` + `\n> Occimyy: BILLY BOB THE GREAT` + `\n\nenigmatic`, - format: "org.matrix.custom.html", - formatted_body: `
    In reply to a 1-day-old unbridged message from Occimyy:
    BILLY BOB THE GREAT
    enigmatic`, + format: "org.matrix.custom.html", + formatted_body: `
    In reply to a 1-day-old unbridged message from Occimyy:
    BILLY BOB THE GREAT
    enigmatic`, "m.mentions": {} }]) }) @@ -1151,10 +1151,12 @@ test("message2event: very large png is converted to jpeg for upload", async t => async fetch(url, init) { t.equal(url, "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg") t.equal(init.method, "HEAD") - return new Response("wa", {headers: { - "Content-Type": "image/jpeg", - "Content-Length": 483085 - }}) + return new Response("wa", { + headers: { + "Content-Type": "image/jpeg", + "Content-Length": 483085 + } + }) } }) t.deepEqual(events, [{ @@ -1205,12 +1207,12 @@ test("message2event: multiple attachments are combined into the same event where msgtype: "m.text", body: "hey" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)" - + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler (38 KB)" + + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler= (38 KB)" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", format: "org.matrix.custom.html", formatted_body: "hey" + `
    📄 Uploaded file: hey.jpg (100 MB)` - + `
    📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler (38 KB)` + + `
    📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler= (38 KB)` + `
    📄 Uploaded file: hey.jpg (100 MB)` }, { $type: "m.room.message", @@ -1332,7 +1334,7 @@ test("message2event: emoji triple long name", async t => { body: ":brillillillilliant_move::brillillillilliant_move::brillillillilliant_move:", format: "org.matrix.custom.html", formatted_body: - ':brillillillilliant_move:' + ':brillillillilliant_move:' + ':brillillillilliant_move:' + ':brillillillilliant_move:' }]) @@ -1446,8 +1448,8 @@ test("message2event: constructed forwarded message", async t => { async getJoinedMembers() { return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -1505,8 +1507,8 @@ test("message2event: constructed forwarded text", async t => { async getJoinedMembers() { return { joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@user:matrix.org": { display_name: null, avatar_url: null } } } } @@ -1550,10 +1552,10 @@ test("message2event: don't scan forwarded messages for mentions", async t => { }) test("message2event: invite no details embed if no event", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { + const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381190945646710824" }, {}, {}, { snow: { invite: { - getInvite: async () => ({...data.invite.irl, guild_scheduled_event: null}) + getInvite: async () => ({ ...data.invite.irl, guild_scheduled_event: null }) } } }) @@ -1570,7 +1572,7 @@ test("message2event: invite no details embed if no event", async t => { }) test("message2event: irl invite event renders embed", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { + const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381190945646710824" }, {}, {}, { snow: { invite: { getInvite: async () => data.invite.irl @@ -1603,7 +1605,7 @@ test("message2event: irl invite event renders embed", async t => { }) test("message2event: vc invite event renders embed", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { + const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381174024801095751" }, {}, {}, { snow: { invite: { getInvite: async () => data.invite.vc @@ -1637,12 +1639,12 @@ test("message2event: vc invite event renders embed", async t => { }) test("message2event: vc invite event renders embed with room link", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { + const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381174024801095751" }, {}, {}, { api: { getEffectivePower: mockGetEffectivePower(), getJoinedMembers: async () => ({ joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, } }) }, @@ -1679,7 +1681,7 @@ test("message2event: vc invite event renders embed with room link", async t => { }) test("message2event: expired/invalid invites are sent as-is", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { + const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381190945646710824" }, {}, {}, { snow: { invite: { async getInvite() { @@ -1704,11 +1706,11 @@ test("message2event: channel links are converted even inside lists (parser post- let called = 0 const events = await messageToEvent({ content: "1. Don't be a dick" - + "\n2. Follow rule number 1" - + "\n3. Follow Discord TOS" - + "\n4. Do **not** post NSFW content, shock content, suggestive content" - + "\n5. Please keep <#176333891320283136> professional and helpful, no random off-topic joking" - + "\nThis list will probably change in the future" + + "\n2. Follow rule number 1" + + "\n3. Follow Discord TOS" + + "\n4. Do **not** post NSFW content, shock content, suggestive content" + + "\n5. Please keep <#176333891320283136> professional and helpful, no random off-topic joking" + + "\nThis list will probably change in the future" }, data.guild.general, {}, { api: { getEffectivePower: mockGetEffectivePower(), @@ -1730,11 +1732,11 @@ test("message2event: channel links are converted even inside lists (parser post- { $type: "m.room.message", body: "1. Don't be a dick" - + "\n2. Follow rule number 1" - + "\n3. Follow Discord TOS" - + "\n4. Do **not** post NSFW content, shock content, suggestive content" - + "\n5. Please keep #wonderland professional and helpful, no random off-topic joking" - + "\nThis list will probably change in the future", + + "\n2. Follow rule number 1" + + "\n3. Follow Discord TOS" + + "\n4. Do **not** post NSFW content, shock content, suggestive content" + + "\n5. Please keep #wonderland professional and helpful, no random off-topic joking" + + "\nThis list will probably change in the future", format: "org.matrix.custom.html", formatted_body: "
    1. Don't be a dick
    2. Follow rule number 1
    3. Follow Discord TOS
    4. Do not post NSFW content, shock content, suggestive content
    5. Please keep #wonderland professional and helpful, no random off-topic joking
    This list will probably change in the future", "m.mentions": {}, @@ -1783,31 +1785,33 @@ test("message2event: cross-room reply", async t => { } }, content: "cross-room reply" - }, {}, {}, {api: { - async getEvent(roomID, eventID) { - called++ - t.equal(roomID, "!mHmhQQPwXNananaOLD:cadence.moe") - t.equal(eventID, "$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY") - return { - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - "body": "image.png", - "info": { - "h": 738, - "mimetype": "image/png", - "org.matrix.msc4230.is_animated": false, - "size": 111189, - "w": 772, - "xyz.amorgan.blurhash": "L255Oa~qRPD$-pxuoJoLIUM{xuxu" - }, - "m.mentions": {}, - "msgtype": "m.image", - "url": "mxc://matrix.org/QbSujQjRLekzPknKlPsXbGDS" + }, {}, {}, { + api: { + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!mHmhQQPwXNananaOLD:cadence.moe") + t.equal(eventID, "$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY") + return { + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + "body": "image.png", + "info": { + "h": 738, + "mimetype": "image/png", + "org.matrix.msc4230.is_animated": false, + "size": 111189, + "w": 772, + "xyz.amorgan.blurhash": "L255Oa~qRPD$-pxuoJoLIUM{xuxu" + }, + "m.mentions": {}, + "msgtype": "m.image", + "url": "mxc://matrix.org/QbSujQjRLekzPknKlPsXbGDS" + } } } } - }}) + }) t.deepEqual(events, [ { $type: "m.room.message", @@ -1911,15 +1915,15 @@ test("message2event: single-choice poll", async t => { kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. max_selections: 1, answers: [{ - id: "1", - "org.matrix.msc1767.text": "[\ud83d\udc4d] answer one" - }, { - id: "2", - "org.matrix.msc1767.text": "[\ud83d\udc4e] answer two" - }, { - id: "3", - "org.matrix.msc1767.text": "answer three" - }] + id: "1", + "org.matrix.msc1767.text": "[\ud83d\udc4d] answer one" + }, { + id: "2", + "org.matrix.msc1767.text": "[\ud83d\udc4e] answer two" + }, { + id: "3", + "org.matrix.msc1767.text": "answer three" + }] }, "org.matrix.msc1767.text": "only one answer allowed!\n1. [\ud83d\udc4d] answer one\n2. [\ud83d\udc4e] answer two\n3. answer three" }]) @@ -1938,15 +1942,15 @@ test("message2event: multiple-choice poll", async t => { kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. max_selections: 3, answers: [{ - id: "1", - "org.matrix.msc1767.text": "[😭] no" - }, { - id: "2", - "org.matrix.msc1767.text": "oh no" - }, { - id: "3", - "org.matrix.msc1767.text": "oh noooooo" - }] + id: "1", + "org.matrix.msc1767.text": "[😭] no" + }, { + id: "2", + "org.matrix.msc1767.text": "oh no" + }, { + id: "3", + "org.matrix.msc1767.text": "oh noooooo" + }] }, "org.matrix.msc1767.text": "more than one answer allowed\n1. [😭] no\n2. oh no\n3. oh noooooo" }]) diff --git a/src/discord/utils.js b/src/discord/utils.js index 19bf743c..40231d16 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -136,15 +136,17 @@ function timestampToSnowflakeInexact(timestamp) { /** * @param {string} url - * @param {""|"?spoiler"} spoiler + * @param {boolean} spoiler */ -function getPublicUrlForCdn(url, spoiler="") { +function getPublicUrlForCdn(url, spoiler=false) { const match = url.match(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/) if (!match) return url const unsignedHash = hasher.h64(match[3]) // attachment ID const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) - return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}${spoiler}` + const transformedUrl = new URL(`${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}`) + if (spoiler) transformedUrl.searchParams.set("spoiler", "") + return transformedUrl.toString() } /** diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index 550b669b..8b9c57da 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -21,7 +21,7 @@ const schema = { file_name: z.string().regex(/^[-A-Za-z0-9_.,]+$/) }), query: z.object({ - spoiler: z.optional(z.string().max(0)) // Lets you choose between no query param, and a ?spoiler query param (H3 considers it an empty string). Booleans do NOT work here, unfortunately. + spoiler: z.string().optional() }) } From 2849c7a051708e15ad605a1b2f7cb67cd9cb0bd4 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 20 Oct 2026 13:31:03 +0200 Subject: [PATCH 179/184] yesn't --- src/d2m/converters/message-to-event.test.js | 8 ++++---- src/discord/utils.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index dd2449f6..63c46115 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -366,9 +366,9 @@ test("message2event: spoiler attachment", async t => { $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler= (74 KB)", + body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler=yes (74 KB)", format: "org.matrix.custom.html", - formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler= (74 KB)" + formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler=yes (74 KB)" }]) }) @@ -1207,12 +1207,12 @@ test("message2event: multiple attachments are combined into the same event where msgtype: "m.text", body: "hey" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)" - + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler= (38 KB)" + + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler=yes (38 KB)" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", format: "org.matrix.custom.html", formatted_body: "hey" + `
    📄 Uploaded file: hey.jpg (100 MB)` - + `
    📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler= (38 KB)` + + `
    📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler=yes (38 KB)` + `
    📄 Uploaded file: hey.jpg (100 MB)` }, { $type: "m.room.message", diff --git a/src/discord/utils.js b/src/discord/utils.js index 40231d16..05ec3d11 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -145,7 +145,7 @@ function getPublicUrlForCdn(url, spoiler=false) { const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) const transformedUrl = new URL(`${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}`) - if (spoiler) transformedUrl.searchParams.set("spoiler", "") + if (spoiler) transformedUrl.searchParams.set("spoiler", "yes") return transformedUrl.toString() } From 74cf6a1d8956588184ba618cbfcc6ab83179064a Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 20 Oct 2026 13:33:47 +0200 Subject: [PATCH 180/184] shit --- src/web/routes/download-discord.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index 8b9c57da..aa03871a 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -54,7 +54,7 @@ function defineMediaProxyHandler(domain) { const routerParams = await getValidatedRouterParams(event, schema.router.parse) const queryParams = await getValidatedQuery(event, schema.query.parse) - if (queryParams.spoiler === "") webUtils.rejectUrlPreviewer(event) + if (queryParams.spoiler) webUtils.rejectUrlPreviewer(event) const unsignedHash = hasher.h64(routerParams.attachment_id) const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range From cfafac2480f2a8a8a3a64ad1a20ba6710d82211a Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 20 Oct 2026 14:12:51 +0200 Subject: [PATCH 181/184] fix format --- src/d2m/converters/message-to-event.test.js | 230 ++++++++++---------- 1 file changed, 113 insertions(+), 117 deletions(-) diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 63c46115..fe4801cc 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1,10 +1,10 @@ -const { test } = require("supertape") -const { messageToEvent } = require("./message-to-event") -const { MatrixServerError } = require("../../matrix/mreq") +const {test} = require("supertape") +const {messageToEvent} = require("./message-to-event") +const {MatrixServerError} = require("../../matrix/mreq") const data = require("../../../test/data") -const { mockGetEffectivePower } = require("../../matrix/utils.test") +const {mockGetEffectivePower} = require("../../matrix/utils.test") const Ty = require("../../types") -const { db } = require("../../passthrough") +const {db} = require("../../passthrough") /** * @param {string} roomID @@ -12,7 +12,7 @@ const { db } = require("../../passthrough") * @returns {(roomID: string, eventID: string) => Promise>} */ function mockGetEvent(t, roomID_in, eventID_in, outer) { - return async function (roomID, eventID) { + return async function(roomID, eventID) { t.equal(roomID, roomID_in) t.equal(eventID, eventID_in) return new Promise(resolve => { @@ -74,8 +74,8 @@ test("message2event: simple room mention", async t => { t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:matrix.org": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} } } } @@ -102,8 +102,8 @@ test("message2event: simple room link", async t => { t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:matrix.org": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} } } } @@ -130,8 +130,8 @@ test("message2event: nicked room mention", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:matrix.org": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} } } } @@ -214,12 +214,12 @@ test("message2event: timestamps", async t => { + "\n3 October 2026 at 9:30 am (UTC)" + "\n24 December 2026 at 11:00 pm (UTC)", format: "org.matrix.custom.html", - formatted_body: `4 Oct 2026, 9:33:33 am (UTC)` - + `
    4 October 2026 at 9:33 am (UTC)` - + `
    Sunday, 4 October 2026 at 9:34 am (UTC)` - + `
    9:34:08 am (UTC)` - + `
    3 October 2026 at 9:30 am (UTC)` - + `
    24 December 2026 at 11:00 pm (UTC)`, + formatted_body: `` + + `
    ` + + `
    ` + + `
    ` + + `
    ` + + `
    `, "m.mentions": {} }]) }) @@ -234,8 +234,8 @@ test("message2event: simple message link", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:super.invalid": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:super.invalid": {display_name: null, avatar_url: null} } } } @@ -270,8 +270,8 @@ test("message2event: message link that OOYE doesn't know about", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:matrix.org": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} } } } @@ -308,8 +308,8 @@ test("message2event: message timestamp failed to fetch", async t => { t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:matrix.org": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} } } } @@ -505,7 +505,7 @@ test("message2event: simple reply to matrix user", async t => { }) test("message2event: simple reply to matrix user, reply fallbacks disabled", async t => { - const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, { includeReplyFallback: false }, { + const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {includeReplyFallback: false}, { api: { getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", { type: "m.room.message", @@ -605,17 +605,17 @@ test("message2event: voice message", async t => { const events = await messageToEvent(data.message.voice_message) t.deepEqual(events, [{ $type: "m.room.message", - body: "voice-message.ogg", - external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg", - filename: "voice-message.ogg", - info: { - duration: 3960, - mimetype: "audio/ogg", - size: 10584, + body: "voice-message.ogg", + external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg", + filename: "voice-message.ogg", + info: { + duration: 3960, + mimetype: "audio/ogg", + size: 10584, }, - "m.mentions": {}, - msgtype: "m.audio", - url: "mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB" + "m.mentions": {}, + msgtype: "m.audio", + url: "mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB" }]) }) @@ -628,16 +628,16 @@ test("message2event: misc file", async t => { "m.mentions": {} }, { $type: "m.room.message", - body: "the.yml", - external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml", - filename: "the.yml", + body: "the.yml", + external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml", + filename: "the.yml", info: { mimetype: "text/plain; charset=utf-8", size: 2274 }, - "m.mentions": {}, - msgtype: "m.file", - url: "mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP" + "m.mentions": {}, + msgtype: "m.file", + url: "mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP" }]) }) @@ -674,7 +674,7 @@ test("message2event: simple reply in thread to a matrix user's reply", async t = user_ids: ["@cadence:cadence.moe"] }, msgtype: "m.text", - body: "Well, they don't seem to..." + body: "Well, they don't seem to..." }]) }) @@ -711,7 +711,7 @@ test("message2event: infinidoge's reply to ami's matrix smalltext reply to infin user_ids: ["@ami:the-apothecary.club"] }, msgtype: "m.text", - body: `Most likely` + body: `Most likely` }]) }) @@ -748,7 +748,7 @@ test("message2event: infinidoge's reply to ami's matrix smalltext singleline rep user_ids: ["@ami:the-apothecary.club"] }, msgtype: "m.text", - body: `Most likely` + body: `Most likely` }]) }) @@ -757,11 +757,11 @@ test("message2event: reply to a Discord message that wasn't bridged", async t => t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.text", - body: `> In reply to a 1-day-old unbridged message:` + body: `> In reply to a 1-day-old unbridged message:` + `\n> Occimyy: BILLY BOB THE GREAT` + `\n\nenigmatic`, - format: "org.matrix.custom.html", - formatted_body: `
    In reply to a 1-day-old unbridged message from Occimyy:
    BILLY BOB THE GREAT
    enigmatic`, + format: "org.matrix.custom.html", + formatted_body: `
    In reply to a 1-day-old unbridged message from Occimyy:
    BILLY BOB THE GREAT
    enigmatic`, "m.mentions": {} }]) }) @@ -1151,12 +1151,10 @@ test("message2event: very large png is converted to jpeg for upload", async t => async fetch(url, init) { t.equal(url, "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg") t.equal(init.method, "HEAD") - return new Response("wa", { - headers: { - "Content-Type": "image/jpeg", - "Content-Length": 483085 - } - }) + return new Response("wa", {headers: { + "Content-Type": "image/jpeg", + "Content-Length": 483085 + }}) } }) t.deepEqual(events, [{ @@ -1334,7 +1332,7 @@ test("message2event: emoji triple long name", async t => { body: ":brillillillilliant_move::brillillillilliant_move::brillillillilliant_move:", format: "org.matrix.custom.html", formatted_body: - ':brillillillilliant_move:' + ':brillillillilliant_move:' + ':brillillillilliant_move:' + ':brillillillilliant_move:' }]) @@ -1448,8 +1446,8 @@ test("message2event: constructed forwarded message", async t => { async getJoinedMembers() { return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:matrix.org": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} } } } @@ -1507,8 +1505,8 @@ test("message2event: constructed forwarded text", async t => { async getJoinedMembers() { return { joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, - "@user:matrix.org": { display_name: null, avatar_url: null } + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} } } } @@ -1552,10 +1550,10 @@ test("message2event: don't scan forwarded messages for mentions", async t => { }) test("message2event: invite no details embed if no event", async t => { - const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381190945646710824" }, {}, {}, { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { snow: { invite: { - getInvite: async () => ({ ...data.invite.irl, guild_scheduled_event: null }) + getInvite: async () => ({...data.invite.irl, guild_scheduled_event: null}) } } }) @@ -1572,7 +1570,7 @@ test("message2event: invite no details embed if no event", async t => { }) test("message2event: irl invite event renders embed", async t => { - const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381190945646710824" }, {}, {}, { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { snow: { invite: { getInvite: async () => data.invite.irl @@ -1605,7 +1603,7 @@ test("message2event: irl invite event renders embed", async t => { }) test("message2event: vc invite event renders embed", async t => { - const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381174024801095751" }, {}, {}, { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { snow: { invite: { getInvite: async () => data.invite.vc @@ -1639,12 +1637,12 @@ test("message2event: vc invite event renders embed", async t => { }) test("message2event: vc invite event renders embed with room link", async t => { - const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381174024801095751" }, {}, {}, { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { api: { getEffectivePower: mockGetEffectivePower(), getJoinedMembers: async () => ({ joined: { - "@_ooye_bot:cadence.moe": { display_name: null, avatar_url: null }, + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, } }) }, @@ -1681,7 +1679,7 @@ test("message2event: vc invite event renders embed with room link", async t => { }) test("message2event: expired/invalid invites are sent as-is", async t => { - const events = await messageToEvent({ content: "https://discord.gg/placeholder?event=1381190945646710824" }, {}, {}, { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { snow: { invite: { async getInvite() { @@ -1706,11 +1704,11 @@ test("message2event: channel links are converted even inside lists (parser post- let called = 0 const events = await messageToEvent({ content: "1. Don't be a dick" - + "\n2. Follow rule number 1" - + "\n3. Follow Discord TOS" - + "\n4. Do **not** post NSFW content, shock content, suggestive content" - + "\n5. Please keep <#176333891320283136> professional and helpful, no random off-topic joking" - + "\nThis list will probably change in the future" + + "\n2. Follow rule number 1" + + "\n3. Follow Discord TOS" + + "\n4. Do **not** post NSFW content, shock content, suggestive content" + + "\n5. Please keep <#176333891320283136> professional and helpful, no random off-topic joking" + + "\nThis list will probably change in the future" }, data.guild.general, {}, { api: { getEffectivePower: mockGetEffectivePower(), @@ -1732,11 +1730,11 @@ test("message2event: channel links are converted even inside lists (parser post- { $type: "m.room.message", body: "1. Don't be a dick" - + "\n2. Follow rule number 1" - + "\n3. Follow Discord TOS" - + "\n4. Do **not** post NSFW content, shock content, suggestive content" - + "\n5. Please keep #wonderland professional and helpful, no random off-topic joking" - + "\nThis list will probably change in the future", + + "\n2. Follow rule number 1" + + "\n3. Follow Discord TOS" + + "\n4. Do **not** post NSFW content, shock content, suggestive content" + + "\n5. Please keep #wonderland professional and helpful, no random off-topic joking" + + "\nThis list will probably change in the future", format: "org.matrix.custom.html", formatted_body: "
    1. Don't be a dick
    2. Follow rule number 1
    3. Follow Discord TOS
    4. Do not post NSFW content, shock content, suggestive content
    5. Please keep #wonderland professional and helpful, no random off-topic joking
    This list will probably change in the future", "m.mentions": {}, @@ -1785,33 +1783,31 @@ test("message2event: cross-room reply", async t => { } }, content: "cross-room reply" - }, {}, {}, { - api: { - async getEvent(roomID, eventID) { - called++ - t.equal(roomID, "!mHmhQQPwXNananaOLD:cadence.moe") - t.equal(eventID, "$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY") - return { - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - "body": "image.png", - "info": { - "h": 738, - "mimetype": "image/png", - "org.matrix.msc4230.is_animated": false, - "size": 111189, - "w": 772, - "xyz.amorgan.blurhash": "L255Oa~qRPD$-pxuoJoLIUM{xuxu" - }, - "m.mentions": {}, - "msgtype": "m.image", - "url": "mxc://matrix.org/QbSujQjRLekzPknKlPsXbGDS" - } + }, {}, {}, {api: { + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!mHmhQQPwXNananaOLD:cadence.moe") + t.equal(eventID, "$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY") + return { + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + "body": "image.png", + "info": { + "h": 738, + "mimetype": "image/png", + "org.matrix.msc4230.is_animated": false, + "size": 111189, + "w": 772, + "xyz.amorgan.blurhash": "L255Oa~qRPD$-pxuoJoLIUM{xuxu" + }, + "m.mentions": {}, + "msgtype": "m.image", + "url": "mxc://matrix.org/QbSujQjRLekzPknKlPsXbGDS" } } } - }) + }}) t.deepEqual(events, [ { $type: "m.room.message", @@ -1915,15 +1911,15 @@ test("message2event: single-choice poll", async t => { kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. max_selections: 1, answers: [{ - id: "1", - "org.matrix.msc1767.text": "[\ud83d\udc4d] answer one" - }, { - id: "2", - "org.matrix.msc1767.text": "[\ud83d\udc4e] answer two" - }, { - id: "3", - "org.matrix.msc1767.text": "answer three" - }] + id: "1", + "org.matrix.msc1767.text": "[\ud83d\udc4d] answer one" + }, { + id: "2", + "org.matrix.msc1767.text": "[\ud83d\udc4e] answer two" + }, { + id: "3", + "org.matrix.msc1767.text": "answer three" + }] }, "org.matrix.msc1767.text": "only one answer allowed!\n1. [\ud83d\udc4d] answer one\n2. [\ud83d\udc4e] answer two\n3. answer three" }]) @@ -1942,15 +1938,15 @@ test("message2event: multiple-choice poll", async t => { kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. max_selections: 3, answers: [{ - id: "1", - "org.matrix.msc1767.text": "[😭] no" - }, { - id: "2", - "org.matrix.msc1767.text": "oh no" - }, { - id: "3", - "org.matrix.msc1767.text": "oh noooooo" - }] + id: "1", + "org.matrix.msc1767.text": "[😭] no" + }, { + id: "2", + "org.matrix.msc1767.text": "oh no" + }, { + id: "3", + "org.matrix.msc1767.text": "oh noooooo" + }] }, "org.matrix.msc1767.text": "more than one answer allowed\n1. [😭] no\n2. oh no\n3. oh noooooo" }]) From 45a105a3042757bb9a8c8ca488703be8f61712f4 Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 20 Oct 2026 14:32:23 +0200 Subject: [PATCH 182/184] some renames --- src/web/routes/download-discord.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index aa03871a..6b9b4582 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -15,7 +15,7 @@ const {discord, as, select, sync} = require("../../passthrough") const webUtils = sync.require("../web-utils") const schema = { - router: z.object({ + params: z.object({ channel_id: z.string().regex(/^[0-9]+$/), attachment_id: z.string().regex(/^[0-9]+$/), file_name: z.string().regex(/^[-A-Za-z0-9_.,]+$/) @@ -51,12 +51,12 @@ function timeUntilExpiry(url) { */ function defineMediaProxyHandler(domain) { return defineEventHandler(async event => { - const routerParams = await getValidatedRouterParams(event, schema.router.parse) - const queryParams = await getValidatedQuery(event, schema.query.parse) + const params = await getValidatedRouterParams(event, schema.params.parse) + const query = await getValidatedQuery(event, schema.query.parse) - if (queryParams.spoiler) webUtils.rejectUrlPreviewer(event) + if (query.spoiler) webUtils.rejectUrlPreviewer(event) - const unsignedHash = hasher.h64(routerParams.attachment_id) + const unsignedHash = hasher.h64(params.attachment_id) const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range const row = select("media_proxy", "permitted_hash", {permitted_hash: signedHash}).get() @@ -67,7 +67,7 @@ function defineMediaProxyHandler(domain) { }) } - const url = `https://${domain}/attachments/${routerParams.channel_id}/${routerParams.attachment_id}/${routerParams.file_name}` + const url = `https://${domain}/attachments/${params.channel_id}/${params.attachment_id}/${params.file_name}` let promise = cache.get(url) /** @type {string | undefined} */ let refreshed From 11acba45e655d4209ed8035e866035299c31a64f Mon Sep 17 00:00:00 2001 From: Guzio Date: Tue, 20 Oct 2026 14:32:34 +0200 Subject: [PATCH 183/184] stronger tests --- src/web/routes/download-discord.test.js | 33 +++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/web/routes/download-discord.test.js b/src/web/routes/download-discord.test.js index e08832cf..a1d65402 100644 --- a/src/web/routes/download-discord.test.js +++ b/src/web/routes/download-discord.test.js @@ -46,9 +46,9 @@ test("web download discord: works if a known attachment", async t => { t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) }) -test("web download discord: works with spoilers", async t => { +test("web download discord: works with spoilers for humans", async t => { const event = {} - await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name?spoiler", { + await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name?spoiler=yes", { params: { channel_id: "655216173696286746", attachment_id: "1314358913482621010", @@ -73,6 +73,35 @@ test("web download discord: works with spoilers", async t => { t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) }) +test("web download discord: rejects with spoilers for bots", async t => { + const event = {} + const [error] = await tryToCatch(() => router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name?spoiler=yes", { + params: { + channel_id: "655216173696286746", + attachment_id: "1314358913482621010", + file_name: "image.png" + }, + headers: { + "user-agent": "Synapse (bot; +https://github.com/matrix-org/synapse)" + }, + event, + snow: { + channel: { + async refreshAttachmentURLs(attachments) { + assert(Array.isArray(attachments)) + return { + refreshed_urls: attachments.map(a => ({ + original: a, + refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` + })) + } + } + } + } + })) + t.equal(error.data, "Sorry URL previewer, you can't have this URL.") +}) + test("web download discord: uses cache", async t => { let notCalled = true const event = {} From bdcdb4fb9a609b0f92fed251f62efed0df49655e Mon Sep 17 00:00:00 2001 From: Guzio Date: Wed, 21 Oct 2026 01:42:28 +1300 Subject: [PATCH 184/184] Make spoiler file URLs unpreviewable Squashed commit of the following: commit afd1aa56b587ddee82eb6f402f996c452400efdb Author: Cadence Ember Date: Wed Oct 21 01:40:34 2026 +1300 Formatting commit 11acba45e655d4209ed8035e866035299c31a64f Author: Guzio Date: Tue Oct 20 14:32:34 2026 +0200 stronger tests commit 45a105a3042757bb9a8c8ca488703be8f61712f4 Author: Guzio Date: Tue Oct 20 14:32:23 2026 +0200 some renames commit cfafac2480f2a8a8a3a64ad1a20ba6710d82211a Author: Guzio Date: Tue Oct 20 14:12:51 2026 +0200 fix format commit 74cf6a1d8956588184ba618cbfcc6ab83179064a Author: Guzio Date: Tue Oct 20 13:33:47 2026 +0200 shit commit 2849c7a051708e15ad605a1b2f7cb67cd9cb0bd4 Author: Guzio Date: Tue Oct 20 13:31:03 2026 +0200 yesn't commit 15055b8ee09a98aaead8c48e462abb65c7d05c33 Author: Guzio Date: Tue Oct 20 13:22:18 2026 +0200 Use less hacky way of setting the params commit 5da1a090c6b1393be53154b77e4984b9705516fe Author: Guzio Date: Tue Oct 20 12:35:03 2026 +0200 rewrite in QP commit 727b5a3e4f97c59de6d0cd69d6adc00c6eed6d81 Author: Guzio Date: Mon Oct 19 15:33:54 2026 +0200 update tests commit 4350bff202e83151b25c35ba8068537d75c06bf4 Author: Guzio Date: Mon Oct 19 14:06:33 2026 +0200 kill me commit 966095c3610b5565c2dd902805ebfb3b00dd2187 Author: Guzio Date: Mon Oct 19 13:57:48 2026 +0200 of course, I fucked up where the / goes... commit 1a1173413b27c9182914341d404e6c4daf7cde3f Author: Guzio Date: Mon Oct 19 13:43:39 2026 +0200 spoiler fix --- src/d2m/converters/message-to-event.js | 25 ++++----- src/d2m/converters/message-to-event.test.js | 10 ++-- src/discord/utils.js | 11 ++-- src/web/routes/download-discord.js | 16 +++++- src/web/routes/download-discord.test.js | 56 +++++++++++++++++++++ 5 files changed, 96 insertions(+), 22 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 40ba4b21..97ca1a4b 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -140,7 +140,6 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ * @param {{fetch?: typeof fetch}} [di] */ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { - const external_url = dUtils.getPublicUrlForCdn(attachment.url) const emoji = attachment.content_type?.startsWith("image/jp") ? "📸" : attachment.content_type?.startsWith("image/") ? "🖼️" @@ -148,15 +147,17 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { : attachment.content_type?.startsWith("text/") ? "📝" : attachment.content_type?.startsWith("audio/") ? "🎶" : "📄" - // no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote - if (attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler)) { + // no native media spoilers in Element, so we'll post an unpreviewable link instead + const spoiler = attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler); + const externalURL = dUtils.getPublicUrlForCdn(attachment.url, spoiler) + if (spoiler) { return { $type: "m.room.message", "m.mentions": mentions, msgtype: "m.text", - body: `${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})`, + body: `${emoji} Uploaded SPOILER file: ${externalURL} (${pb(attachment.size)})`, format: "org.matrix.custom.html", - formatted_body: `
    ${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})
    ` + formatted_body: `${emoji} Uploaded SPOILER file: ${externalURL} (${pb(attachment.size)})` } } // attempt to convert large PNG image files to JPEG, since it's almost certainly a photo that was forced into PNG by the user copy-pasting. @@ -176,7 +177,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { "m.mentions": mentions, msgtype: "m.image", url: await file.uploadDiscordFileToMxc(jpegUrl), - external_url, + external_url: externalURL, body: attachment.description || newFilename, filename: newFilename, info: { @@ -194,9 +195,9 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { $type: "m.room.message", "m.mentions": mentions, msgtype: "m.text", - body: `${emoji} Uploaded file: ${external_url} (${pb(attachment.size)})`, + body: `${emoji} Uploaded file: ${externalURL} (${pb(attachment.size)})`, format: "org.matrix.custom.html", - formatted_body: `${emoji} Uploaded file: ${attachment.filename} (${pb(attachment.size)})` + formatted_body: `${emoji} Uploaded file: ${attachment.filename} (${pb(attachment.size)})` } } if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) { @@ -205,7 +206,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { "m.mentions": mentions, msgtype: "m.image", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url, + external_url: externalURL, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -222,7 +223,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { "m.mentions": mentions, msgtype: "m.video", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url, + external_url: externalURL, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -239,7 +240,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { "m.mentions": mentions, msgtype: "m.audio", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url, + external_url: externalURL, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -255,7 +256,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { "m.mentions": mentions, msgtype: "m.file", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url, + external_url: externalURL, body: attachment.description || attachment.filename, filename: attachment.filename, info: { diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 2c9c7f91..fe4801cc 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -366,9 +366,9 @@ test("message2event: spoiler attachment", async t => { $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)", + body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler=yes (74 KB)", format: "org.matrix.custom.html", - formatted_body: "
    📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)
    " + formatted_body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler=yes (74 KB)" }]) }) @@ -1205,13 +1205,13 @@ test("message2event: multiple attachments are combined into the same event where msgtype: "m.text", body: "hey" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)" - + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg (38 KB)" + + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler=yes (38 KB)" + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", format: "org.matrix.custom.html", formatted_body: "hey" + `
    📄 Uploaded file: hey.jpg (100 MB)` - + `
    📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg (38 KB)
    ` - + `📄 Uploaded file: hey.jpg (100 MB)` + + `
    📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler=yes (38 KB)` + + `
    📄 Uploaded file: hey.jpg (100 MB)` }, { $type: "m.room.message", "m.mentions": {}, diff --git a/src/discord/utils.js b/src/discord/utils.js index 6fb0b433..7feb006f 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -134,14 +134,19 @@ function timestampToSnowflakeInexact(timestamp) { return String((timestamp - EPOCH) * 2**22) } -/** @param {string} url */ -function getPublicUrlForCdn(url) { +/** + * @param {string} url + * @param {boolean} [spoiler] + */ +function getPublicUrlForCdn(url, spoiler = false) { const match = url.match(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/) if (!match) return url const unsignedHash = hasher.h64(match[3]) // attachment ID const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) - return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}` + const transformedUrl = new URL(`${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}`) + if (spoiler) transformedUrl.searchParams.set("spoiler", "yes") + return transformedUrl.toString() } /** diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index 769fc9c0..6b9b4582 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -1,7 +1,7 @@ // @ts-check const assert = require("assert/strict") -const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError, H3Event} = require("h3") +const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError, H3Event, getValidatedQuery} = require("h3") const {z} = require("zod") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore @@ -9,13 +9,19 @@ let hasher = null // @ts-ignore require("xxhash-wasm")().then(h => hasher = h) -const {discord, as, select} = require("../../passthrough") +const {discord, as, select, sync} = require("../../passthrough") + +/** @type {import("../web-utils")} */ +const webUtils = sync.require("../web-utils") const schema = { params: z.object({ channel_id: z.string().regex(/^[0-9]+$/), attachment_id: z.string().regex(/^[0-9]+$/), file_name: z.string().regex(/^[-A-Za-z0-9_.,]+$/) + }), + query: z.object({ + spoiler: z.string().optional() }) } @@ -40,9 +46,15 @@ function timeUntilExpiry(url) { if (time > 0) return time } +/** + * @param {string} domain + */ function defineMediaProxyHandler(domain) { return defineEventHandler(async event => { const params = await getValidatedRouterParams(event, schema.params.parse) + const query = await getValidatedQuery(event, schema.query.parse) + + if (query.spoiler) webUtils.rejectUrlPreviewer(event) const unsignedHash = hasher.h64(params.attachment_id) const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range diff --git a/src/web/routes/download-discord.test.js b/src/web/routes/download-discord.test.js index 0d4b8844..a1d65402 100644 --- a/src/web/routes/download-discord.test.js +++ b/src/web/routes/download-discord.test.js @@ -46,6 +46,62 @@ test("web download discord: works if a known attachment", async t => { t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) }) +test("web download discord: works with spoilers for humans", async t => { + const event = {} + await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name?spoiler=yes", { + params: { + channel_id: "655216173696286746", + attachment_id: "1314358913482621010", + file_name: "image.png" + }, + event, + snow: { + channel: { + async refreshAttachmentURLs(attachments) { + assert(Array.isArray(attachments)) + return { + refreshed_urls: attachments.map(a => ({ + original: a, + refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` + })) + } + } + } + } + }) + t.equal(event.node.res.statusCode, 302) + t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) +}) + +test("web download discord: rejects with spoilers for bots", async t => { + const event = {} + const [error] = await tryToCatch(() => router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name?spoiler=yes", { + params: { + channel_id: "655216173696286746", + attachment_id: "1314358913482621010", + file_name: "image.png" + }, + headers: { + "user-agent": "Synapse (bot; +https://github.com/matrix-org/synapse)" + }, + event, + snow: { + channel: { + async refreshAttachmentURLs(attachments) { + assert(Array.isArray(attachments)) + return { + refreshed_urls: attachments.map(a => ({ + original: a, + refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` + })) + } + } + } + } + })) + t.equal(error.data, "Sorry URL previewer, you can't have this URL.") +}) + test("web download discord: uses cache", async t => { let notCalled = true const event = {}