From ae6b730c2655c78c3a44a31be97a37638a5bf2ec Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 13:03:16 +0000 Subject: [PATCH 01/51] Update .gitignore --- .gitignore | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index c38dd88..7b0c60d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,18 @@ -# Secrets +# Personal config.js registration.yaml ooye.db* events.db* backfill.db* custom-webroot +icon.svg +.devcontainer # Automatically generated node_modules coverage test/res/* !test/res/lottie* -icon.svg *~ .#* \#*# From 2c7831c587d0f797e34d50fd44d373d67a2feaf9 Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 16:19:39 +0000 Subject: [PATCH 02/51] Small TypeScript coverage expansion * The guard() function in m2d/event-dispatcher.js no longer takes (any, any), but a string and a function. * m2d/send-event.js no longer complains that res.body has some missing fields. It would appear as though those missing fields weren't revelant to the fromWeb() function (into which res.body is passed), given that this code worked before and still contunes to work, so I just @ts-ignore'd res.body This commit's developer's off-topic personal comment, related to this commit: This has nothing to do with improving thread UX, even tho this is what I was supposed to work on. However, in my attempts to discover in what file should I start, I stumbled upon those errors from m2d/send-event.js, so I fixed them. And after establishing that m2d/event-dispatcher.js is the file that I'm looking for, I also noticed that guard()'s @parm definitions could be improved, so I did that. Now - back to thread work... --- src/m2d/actions/send-event.js | 10 ++++++++-- src/m2d/event-dispatcher.js | 6 +++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index 00557a1..bce45c6 100644 --- a/src/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -39,14 +39,20 @@ async function resolvePendingFiles(message) { if ("key" in p) { // Encrypted file const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url")) - await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body).pipe(d)) + await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb( + // @ts-ignore + res.body + ).pipe(d)) return { name: p.name, file: d } } else { // Unencrypted file - const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body)) + const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb( + // @ts-ignore + res.body + )) return { name: p.name, file: body diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 70e293b..2091f7d 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -156,8 +156,12 @@ async function sendError(roomID, source, type, e, payload) { } catch (e) {} } +/** + * @param {string} type + * @param {(event: any, ...args: any)=>any} fn + */ function guard(type, fn) { - return async function(event, ...args) { + return async function(/** @type {Ty.Event.Outer} */ event, /** @type {any} */ ...args) { try { return await fn(event, ...args) } catch (e) { From aedd30ab4a0080083475d436af9a9ec813661b4c Mon Sep 17 00:00:00 2001 From: Guzio Date: Thu, 19 Feb 2026 17:48:32 +0000 Subject: [PATCH 03/51] Update "m.relates_to" type definition @ types.d.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * To better reflect reality ("m.in_reply_to" will not always be present - it's not (always?) found on "rel_type":"m.replace" relation-events) * To support "rel_type":"m.replace" relation-events (added "m.replace" option to existing key "rel_type" and a new "is_falling_back" key) AFFECTED TYPES: M_Room_Message, M_Room_Message_File, M_Room_Message_Encrypted_File BREAKS: Nothing, as .d.ts files don't affect buisness logic. In terms of lint errors: Marking "m.in_reply_to" as optional is indeed technically a "breaking change" (TypeScript may complain about „is probably undefined” in places where it didn't before), but from early "testing" (ie. looking at VSCode's errors tab), it doesn't seem like anything broke, as no file that imports any of those 3 types (Or their Outer_ counterparts) has „lit up” with errors (unless I missed something). There was one type error found in m2d/converters/event-to-message.js, at line 1009, but that seemed unrelated to types.d.ts - nevertheless, that error was also corrected in this commit, by adding proper type annotations somewhere else in the affected file. --- src/m2d/converters/event-to-message.js | 2 ++ src/types.d.ts | 21 ++++++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 2add279..7eee659 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -471,6 +471,7 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { // @ts-ignore - typescript doesn't know about indices yet content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]), ensureJoined: [], + /**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that "everyone" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that "everyone" is a valid enum value. allowedMentionsParse: ["everyone"] } } @@ -543,6 +544,7 @@ async function getL1L2ReplyLine(called = false) { async function eventToMessage(event, guild, channel, di) { let displayName = event.sender let avatarURL = undefined + /**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that neither "users" no "roles" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that both are valid enum values. const allowedMentionsParse = ["users", "roles"] /** @type {string[]} */ let messageIDsToEdit = [] diff --git a/src/types.d.ts b/src/types.d.ts index 6ee2eb1..e7ef318 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -190,11 +190,12 @@ export namespace Event { format?: "org.matrix.custom.html" formatted_body?: string, "m.relates_to"?: { - "m.in_reply_to": { + event_id?: string + is_falling_back?: bool + "m.in_reply_to"?: { event_id: string } - rel_type?: "m.replace" - event_id?: string + rel_type?: "m.replace"|"m.thread" } } @@ -210,11 +211,12 @@ export namespace Event { info?: any "page.codeberg.everypizza.msc4193.spoiler"?: boolean "m.relates_to"?: { - "m.in_reply_to": { + event_id?: string + is_falling_back?: bool + "m.in_reply_to"?: { event_id: string } - rel_type?: "m.replace" - event_id?: string + rel_type?: "m.replace"|"m.thread" } } @@ -246,11 +248,12 @@ export namespace Event { }, info?: any "m.relates_to"?: { - "m.in_reply_to": { + event_id?: string + is_falling_back?: bool + "m.in_reply_to"?: { event_id: string } - rel_type?: "m.replace" - event_id?: string + rel_type?: "m.replace"|"m.thread" } } From 748e851b3996fc63d79c96aa1ebf22272b33df5d Mon Sep 17 00:00:00 2001 From: Guzio Date: Mon, 2 Mar 2026 15:17:08 +0000 Subject: [PATCH 04/51] Improve threads UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /This is a squash-commit - the following is a rough summary of all sub-commits, written in style of commit messages (not necessarily those commits themselves), ie. short and in present tense./ * Document design choice to not bridge Discord threads as Matrix threads [by directly quoting Cadence] * Alter thread-to-announcement, so that it replies in-thread [with this, Matrix users will get a list of almost all (exl. those that don't branch from anything) open threads on a given channel, whereas before it wasn't possible. Also features slight alterations to the text] * Notify the user, whenever an in-thread message on Matrix is sent, that this isn't how they're supposed to do threads on OOYE * Detect /thread being ran as a reply or in-thread to branch the thread from the relevant message * Handle various /thread errors [notably being ran without args (infer the title if ran in the context above, simply show help if not)] * Whenever possible, direct the user to an already-existing thread-room [if /thread was ran as a reply or in-thread, or as part of the notification mentioned in point 3 (feat. a new utility method)] AUXILIARY TYPE CHANGES (not always relevant to UX-improvement-related changes): * Fix „boolean” being referred to as „bool” in types.d.ts * Rename execute(event) in matrix-command-handler is now parseAndExecute(event) [and it is no longer of type CommandExecute, but has its own custom definition, because a) it has a different return now (returns what command was ran (needed for point 3 in section above) instead of always undefined and b) other params from CommandExecute (like ctx or words) weren't being used - quite the contrary, their values were only being created at that stage (as part of command parsing - hence the rename, too), so telling that they're values you pass into execute() was at least somewhat confusing] * Further narrow-down the type of guard() in m2d event-dispatcher TEST CHANGES: * Create 7 new tests, all pass * Update 4 existing threads, all pass * Pass all other relevant tests [and almost all other tests, too - there are some issues with event2message for stickers, but given the fact that this commit does not touch the stickers subsystem in any way at all, it does not seem like they are any way related to my changes and they must've been failing before] * Do extensive manual testing and debugging Co-authored-by: Guzio Co-committed-by: Guzio --- docs/threads-as-rooms.md | 9 ++ src/d2m/converters/thread-to-announcement.js | 10 +- .../converters/thread-to-announcement.test.js | 65 ++++++++-- src/m2d/event-dispatcher.js | 29 ++++- src/matrix/matrix-command-handler.js | 113 +++++++++++++++++- src/matrix/utils.js | 13 +- src/matrix/utils.test.js | 36 +++++- src/types.d.ts | 6 +- test/ooye-test-data.sql | 4 +- 9 files changed, 256 insertions(+), 29 deletions(-) create mode 100644 docs/threads-as-rooms.md diff --git a/docs/threads-as-rooms.md b/docs/threads-as-rooms.md new file mode 100644 index 0000000..012c7af --- /dev/null +++ b/docs/threads-as-rooms.md @@ -0,0 +1,9 @@ +I thought pretty hard about it and I opted to make threads separate rooms because + +1. parity: discord has separate things like permissions and pins for threads, matrix cannot do this at all unless the thread is a separate room +2. usage styles: most discord threads I've seen tend to be long-lived, spanning months or years, which isn't suited to matrix because of the timeline + - I'm in a discord thread for posting photos of food that gets a couple posts a week and has a timeline going back to 2023 +3. the timeline: if a matrix room has threads, and you want to scroll back through the timeline of a room OR of one of its threads, the timeline is merged, so you have to download every message linearised and throw them away if they aren't part of the thread you're looking through. it's bad for threads and it's bad for the main room +4. it is also very very complex for clients to implement read receipts and typing indicators correctly for the merged timeline. if your client doesn't implement this, or doesn't do it correctly, you have a bad experience. many clients don't. element seems to have done it well enough, but is an exception + +overall in my view, threads-as-rooms has better parity and fewer downsides over native threads. but if there are things you don't like about this approach, I'm happy to discuss and see if we can improve them. \ No newline at end of file diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 575b3c5..179a559 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -19,19 +19,21 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) */ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get() - /** @type {{"m.mentions"?: any, "m.in_reply_to"?: any}} */ + /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */ const context = {} + let suffix = ""; if (branchedFromEventID) { // Need to figure out who sent that event... const event = await di.api.getEvent(parentRoomID, branchedFromEventID) - context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}} + suffix = "\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]"; + context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} } const msgtype = creatorMxid ? "m.emote" : "m.text" - const template = creatorMxid ? "started a thread:" : "Thread started:" + const template = creatorMxid ? "started a thread" : "New thread started:" const via = await mxUtils.getViaServersQuery(threadRoomID, di.api) - let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}` + let body = `${template} „${thread.name}” in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}` return { msgtype, diff --git a/src/d2m/converters/thread-to-announcement.test.js b/src/d2m/converters/thread-to-announcement.test.js index 3286f62..8af4d79 100644 --- a/src/d2m/converters/thread-to-announcement.test.js +++ b/src/d2m/converters/thread-to-announcement.test.js @@ -49,7 +49,7 @@ test("thread2announcement: no known creator, no branched from event", async t => }, {api: viaApi}) t.deepEqual(content, { msgtype: "m.text", - body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", "m.mentions": {} }) }) @@ -61,7 +61,7 @@ test("thread2announcement: known creator, no branched from event", async t => { }, {api: viaApi}) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", "m.mentions": {} }) }) @@ -85,12 +85,15 @@ test("thread2announcement: no known creator, branched from discord event", async }) t.deepEqual(content, { msgtype: "m.text", - body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", "m.mentions": {}, "m.relates_to": { + "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", + "is_falling_back": false, "m.in_reply_to": { - event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" - } + "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", + }, + "rel_type": "m.thread", } }) }) @@ -114,12 +117,15 @@ test("thread2announcement: known creator, branched from discord event", async t }) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", "m.mentions": {}, "m.relates_to": { + "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", + "is_falling_back": false, "m.in_reply_to": { - event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" - } + "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", + }, + "rel_type": "m.thread", } }) }) @@ -143,14 +149,51 @@ test("thread2announcement: no known creator, branched from matrix event", async }) t.deepEqual(content, { msgtype: "m.text", - body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "New thread started: „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", "m.mentions": { user_ids: ["@cadence:cadence.moe"] }, "m.relates_to": { + "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", + "is_falling_back": false, "m.in_reply_to": { - event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4" - } + "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", + }, + "rel_type": "m.thread", + } + }) +}) + +test("thread2announcement: known creator, branched from matrix event", async t => { + const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", "@_ooye_crunch_god:cadence.moe", { + name: "test thread", + id: "1128118177155526666" + }, { + api: { + getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", { + type: "m.room.message", + content: { + msgtype: "m.text", + body: "so can you reply to my webhook uwu" + }, + sender: "@cadence:cadence.moe" + }), + ...viaApi + } + }) + t.deepEqual(content, { + msgtype: "m.emote", + body: "started a thread „test thread” in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You should continue the conversation in that room, rather than in this thread. Any messages sent in Matrix threads will be bridged to Discord as replies, not in-thread messages, which is probably not what you want.]", + "m.mentions": { + user_ids: ["@cadence:cadence.moe"] + }, + "m.relates_to": { + "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", + "is_falling_back": false, + "m.in_reply_to": { + "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", + }, + "rel_type": "m.thread", } }) }) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 2091f7d..5000a8c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -158,11 +158,12 @@ async function sendError(roomID, source, type, e, payload) { /** * @param {string} type - * @param {(event: any, ...args: any)=>any} fn + * @param {(event: Ty.Event.Outer & {type: any, redacts:any, state_key:any}, ...args: any)=>any} fn */ function guard(type, fn) { return async function(/** @type {Ty.Event.Outer} */ event, /** @type {any} */ ...args) { try { + // @ts-ignore return await fn(event, ...args) } catch (e) { await sendError(event.room_id, "Matrix", type, e, event) @@ -211,10 +212,32 @@ async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return const messageResponses = await sendEvent.sendEvent(event) if (!messageResponses.length) return + + /** @type {string|undefined} */ + let executedCommand if (event.type === "m.room.message" && event.content.msgtype === "m.text") { - // @ts-ignore - await matrixCommandHandler.execute(event) + executedCommand = await matrixCommandHandler.parseAndExecute( + // @ts-ignore - TypeScript doesn't know that the event.content.msgtype === "m.text" check ensures that event isn't of type Ty.Event.Outer_M_Room_Message_File (which, indeed, wouldn't fit here) + event + ) } + if (event.content["m.relates_to"]?.rel_type === "m.thread" && executedCommand !== "thread"){ + const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id) + api.sendEvent(event.room_id, "m.room.message", { + body: "⚠️ **This message may not have been bridged to Discord in the way you thought it was gonna be!**\n\nIt seems like you sent this message inside a Matrix thread. Matrix threads don't work like Discord threads - they are effectively just „fancy replies”, not independent rooms/channels (any „thread-like appearance” is handled purely client-side - and even then, most Matrix clients don't handle it particularly well, with Element being the only one known to actually render threads as threads), and as such, they are bridged as replies to Discord. *In other words: __Discord users will not be aware that you sent this message inside a thread - the reply will go directly onto the main channel.__ If the thread you sent this message in is old, such a random reply **may be distracting** to Discord users!*\n\nFor the sake of Discord parity (and for better support in numerous Matrix clients - as stated above, most Matrix clients don't handle threads particularly well, and they just render in-thread messages as fancy replies), it is recommended to send threaded messages inside a separate Matrix room that gets bridged to Discord. "+ (bridgedTo ? "Luckily for you, this thread already has one! You can access it on https://matrix.to/#/"+bridgedTo+"?"+(await utils.getViaServersQuery(bridgedTo, api)).toString() : "Please run `/thread [Optional: Thread Name]` to create such a room for this thread, or get a link to it if someone else has already done so. If you run `/thread` (without any arguments) outside any threads and not as a reply, you'll get more info about this command")+".\n\n*You can read more about the rationale behind this design choice [here](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/threads-as-rooms.md).*", + format: "org.matrix.custom.html", + formatted_body: "⚠️ This message may not have been bridged to Discord in the way you thought it was gonna be!

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

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

You can read more about the rationale behind this design choice here.", + "m.mentions": { "user_ids": [event.sender]}, + "m.relates_to": { + event_id: event.content["m.relates_to"].event_id, + is_falling_back: false, + "m.in_reply_to": { event_id: event.event_id }, + rel_type: "m.thread" + }, + msgtype: "m.text" + }) + } + retrigger.messageFinishedBridging(event.event_id) await api.ackEvent(event) })) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index e382a32..6758f78 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -96,6 +96,33 @@ function replyctx(execute) { } } +/** + * @param {Error & {code?: string|number}} e + * @returns {e} +*/ +function unmarshallDiscordError(e) { + if (e.name === "DiscordAPIError"){ + try{ + const unmarshaled = JSON.parse(e.message) + return { + ...e, + ...unmarshaled + } + } catch (err) { + return { + ...err, + code: "JSON_PARSE_FAILED", + message: JSON.stringify({ + original_error_where_message_failed_to_parse: e, + json_parser_error_message: err.message, + json_parser_error_code: err.code, + }) + } + } + } + return e; +} + /** @type {Command[]} */ const commands = [{ aliases: ["emoji"], @@ -255,18 +282,93 @@ const commands = [{ return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", - body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission." + body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission." // NOTE: Currently, this assumes that all Matrix users have no Discord roles (see: empty [] in the getPermissions call above), and makes that „If you were a Discord user...” claim based on that assumption. If Discord permission emulation is gonna happen in the future, this is certainly one among many places that will need to be changed. }) } + + const relation = event.content["m.relates_to"] + let isFallingBack = false; + let branchedFromMxEvent = relation?.["m.in_reply_to"]?.event_id // By default, attempt to branch the thread from the message to which /thread was replying. + if (relation?.rel_type === "m.thread") branchedFromMxEvent = relation?.event_id // If /thread was sent inside a Matrix thread, attempt to branch the Discord thread from the message, which that Matrix thread already is branching from. + if (!branchedFromMxEvent){ + branchedFromMxEvent = event.event_id // If /thread wasn't replying to anything (ie. branchedFromMxEvent was undefined at initial assignment), or if the event was somehow malformed (in such a way that that - one way or another - branchedFromMxEvent ended up being undefined, even if according to the spec it shouldn't), branch the thread from the /thread command-message that created it. + isFallingBack = true; + } + const branchedFromDiscordMessage = select("event_message", "message_id", {event_id: branchedFromMxEvent}).pluck().get() - await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")}) + if (words.length < 2){ + if (isFallingBack) return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "**`/thread` usage:**\nRun this command as `/thread [Thread Name]` to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:\n* If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The `Thread Name` argument must be provided in this case, otherwise you get this help message.\n* If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.\n* If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.", + format: "org.matrix.custom.html", + formatted_body: "/thread usage:
Run this command as /thread [Thread Name] to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:
  • If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The Thread Name argument must be provided in this case, otherwise you get this help message.
  • If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.
  • If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.
" + }) + words[1] = (await api.getEvent(event.room_id, branchedFromMxEvent)).content.body.replaceAll("\n", " ") + words[1] = words[1].length < 100 ? words[1] : words[1].slice(0, 96) + "..." + } + + try { + if (branchedFromDiscordMessage) await discord.snow.channel.createThreadWithMessage(channelID, branchedFromDiscordMessage, {name: words.slice(1).join(" ")}) + else throw {code: "NO_BRANCH_SOURCE", was_supposed_to_be: branchedFromMxEvent}; + } + catch (e){ + switch (unmarshallDiscordError(e).code) { + case "NO_BRANCH_SOURCE": return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID `"+e.was_supposed_to_be+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", + format: "org.matrix.custom.html", + formatted_body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID "+e.was_supposed_to_be+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." + }) + + case (160004): // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes + if (isFallingBack){ + await api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "⚠️ Discord claims that there already exists a thread for the message you ran this command on, but that doesn't make logical sense, as it doesn't seem like you ran this command on any message. Either your Matrix client did something funny with reply/thread tags, or this is a logic error on OOYE's side. At any rate, this should be reported for further investigation. you should also attach the error message that's about to be sent below (or on the main room timeline, if the command was ran inside a thread).", + }) + throw e; + } + const thread = mxUtils.getThreadRoomFromThreadEvent(branchedFromMxEvent) + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "There already exists a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue by creating the bridge. (If you're said admin and you can see that said bridge already exists, but this error message is still showing up, please report that as a bug.)") + }) + + case (50024): return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. Did you try to create a thread inside a thread?" + }) + + case (50035): return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)" + }) + + default: + await api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "⚠️ Unknown error occurred during thread creation. See error message below (or on the main room timeline, if the command was ran inside a thread) for details." + }) + throw e + } + } } ) }] -/** @type {CommandExecute} */ -async function execute(event) { +/** + * @param {Ty.Event.Outer_M_Room_Message} event + * @returns {Promise} the executed command's name or undefined if no command execution was performed +*/ +async function parseAndExecute(event) { let realBody = event.content.body while (realBody.startsWith("> ")) { const i = realBody.indexOf("\n") @@ -287,7 +389,8 @@ async function execute(event) { if (!command) return await command.execute(event, realBody, words) + return words[0] } -module.exports.execute = execute +module.exports.parseAndExecute = parseAndExecute module.exports.onReactionAdd = onReactionAdd diff --git a/src/matrix/utils.js b/src/matrix/utils.js index 9f5cb0f..6383c4d 100644 --- a/src/matrix/utils.js +++ b/src/matrix/utils.js @@ -4,7 +4,7 @@ const assert = require("assert").strict const Ty = require("../types") const {tag} = require("@cloudrac3r/html-template-tag") const passthrough = require("../passthrough") -const {db} = passthrough +const {db, select} = passthrough const {reg} = require("./read-registration") const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) @@ -398,6 +398,16 @@ async function setUserPowerCascade(spaceID, mxid, power, api) { } } +/** + * @param {undefined|string?} eventID + */ //^For some reason, „?” doesn't include Undefined and it needs to be explicitly specified +function getThreadRoomFromThreadEvent(eventID){ + if (!eventID) return eventID; + const threadID = select("event_message", "message_id", {event_id: eventID}).pluck().get() //Discord thread ID === its message ID + if (!threadID) return threadID; + return select("channel_room", "room_id", {channel_id: threadID}).pluck().get() +} + module.exports.bot = bot module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord @@ -413,3 +423,4 @@ module.exports.removeCreatorsFromPowerLevels = removeCreatorsFromPowerLevels module.exports.getEffectivePower = getEffectivePower module.exports.setUserPower = setUserPower module.exports.setUserPowerCascade = setUserPowerCascade +module.exports.getThreadRoomFromThreadEvent = getThreadRoomFromThreadEvent diff --git a/src/matrix/utils.test.js b/src/matrix/utils.test.js index 842c513..8db998d 100644 --- a/src/matrix/utils.test.js +++ b/src/matrix/utils.test.js @@ -2,7 +2,7 @@ const {select} = require("../passthrough") const {test} = require("supertape") -const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower} = require("./utils") +const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower, getThreadRoomFromThreadEvent} = require("./utils") const util = require("util") /** @param {string[]} mxids */ @@ -417,4 +417,38 @@ test("set user power: privileged users must demote themselves", async t => { t.equal(called, 3) }) +test("getThreadRoomFromThreadEvent: real message, but without a thread", t => { + const room = getThreadRoomFromThreadEvent("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") + const msg = "Expected null/undefined, got: "+room + if(room) t.fail(msg); + else t.pass(msg) +}) + +test("getThreadRoomFromThreadEvent: real message with a thread", t => { + const room = getThreadRoomFromThreadEvent("$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg") + t.equal(room, "!FuDZhlOAtqswlyxzeR:cadence.moe") +}) + +test("getThreadRoomFromThreadEvent: fake message", t => { + const room = getThreadRoomFromThreadEvent("$ThisEvent-IdDoesNotExistInTheDatabase4Sure") + const msg = "Expected null/undefined, got: "+room + if(room) t.fail(msg); + else t.pass(msg) +}) + +test("getThreadRoomFromThreadEvent: null", t => { + const room = getThreadRoomFromThreadEvent(null) + t.equal(room, null) +}) + +test("getThreadRoomFromThreadEvent: undefined", t => { + const room = getThreadRoomFromThreadEvent(undefined) + t.equal(room, undefined) +}) + +test("getThreadRoomFromThreadEvent: no value at all", t => { + const room = getThreadRoomFromThreadEvent() //This line should be giving a type-error, so it's not @ts-ignored on purpose. This is to test the desired behavior of that function, ie. „it CAN TAKE an undefined VALUE (as tested above), but you can just LEAVE the value completely undefined” (well, you can leave it like that from JS syntax perspective (which is why this test passes), but it makes no sense from usage standpoint, as it just gives back undefined). So this isn't a logic test (that's handled above), as much as it is a TypeScript test. + t.equal(room, undefined) +}) + module.exports.mockGetEffectivePower = mockGetEffectivePower diff --git a/src/types.d.ts b/src/types.d.ts index 81eec7b..7dc92d6 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -191,7 +191,7 @@ export namespace Event { formatted_body?: string, "m.relates_to"?: { event_id?: string - is_falling_back?: bool + is_falling_back?: boolean "m.in_reply_to"?: { event_id: string } @@ -212,7 +212,7 @@ export namespace Event { "page.codeberg.everypizza.msc4193.spoiler"?: boolean "m.relates_to"?: { event_id?: string - is_falling_back?: bool + is_falling_back?: boolean "m.in_reply_to"?: { event_id: string } @@ -249,7 +249,7 @@ export namespace Event { info?: any "m.relates_to"?: { event_id?: string - is_falling_back?: bool + is_falling_back?: boolean "m.in_reply_to"?: { event_id: string } diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 1dd9dfe..a321d48 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -82,12 +82,14 @@ WITH a (message_id, channel_id) AS (VALUES ('1381212840957972480', '112760669178241024'), ('1401760355339862066', '112760669178241024'), ('1439351590262800565', '1438284564815548418'), -('1404133238414376971', '112760669178241024')) +('1404133238414376971', '112760669178241024'), +('1162005314908999790', '1100319550446252084')) SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id; INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES ('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1), ('$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4', 'm.room.message', 'm.text', '1128118177155526666', 0, 0, 0), +('$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg', 'm.room.message', 'm.text', '1162005314908999790', 0, 0, 1), ('$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10', 'm.room.message', 'm.text', '1141619794500649020', 0, 0, 1), ('$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY', 'm.room.message', 'm.text', '1141206225632112650', 0, 0, 1), ('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', 0, 1, 1), From 74c0c28cf4c1683e38a2dfa32b0b93f1fe7ab3b9 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 8 Mar 2026 22:34:04 +1300 Subject: [PATCH 05/51] Update dependencies --- package-lock.json | 252 ++++++++++++++++++---------------------------- package.json | 6 +- 2 files changed, 99 insertions(+), 159 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9847400..4e6ecd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "ansi-colors": "^4.1.3", "better-sqlite3": "^12.2.0", "chunk-text": "^2.0.1", - "cloudstorm": "^0.15.2", + "cloudstorm": "^0.17.0", "discord-api-types": "^0.38.38", "domino": "^2.1.6", "enquirer": "^2.4.1", @@ -36,7 +36,7 @@ "mime-types": "^2.1.35", "prettier-bytes": "^1.0.4", "sharp": "^0.34.5", - "snowtransfer": "^0.17.1", + "snowtransfer": "^0.17.5", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", "uqr": "^0.1.2", @@ -46,7 +46,7 @@ "devDependencies": { "@cloudrac3r/tap-dot": "^2.0.3", "@types/node": "^22.17.1", - "c8": "^10.1.2", + "c8": "^11.0.0", "cross-env": "^7.0.3", "supertape": "^12.0.12" }, @@ -137,9 +137,9 @@ } }, "node_modules/@chriscdn/promise-semaphore": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.1.2.tgz", - "integrity": "sha512-rELbH6FSr9wr5J249Ax8dpzQdTaqEgcW+lilDKZxB13Hz0Bz3Iyx4q/7qZxPMnra9FUW4ZOkVf+bx5tbi6Goog==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.1.3.tgz", + "integrity": "sha512-EAmwIbH1L2CNsJWloXBG4Kv89H7IUsjYFQnGnmus3OX70LcD5Uu5A7sohPx3O0Ks9UQWEgcr5n2IfxBSuYvOeg==", "license": "MIT" }, "node_modules/@cloudcmd/stub": { @@ -178,9 +178,9 @@ } }, "node_modules/@cloudrac3r/in-your-element": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@cloudrac3r/in-your-element/-/in-your-element-1.1.1.tgz", - "integrity": "sha512-AKp9vnSDA9wzJl4O3C/LA8jgI5m1r0M3MRBQGHcVVL22SrrZMdcy+kWjlZWK343KVLOkuTAISA2D+Jb/zyZS6A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@cloudrac3r/in-your-element/-/in-your-element-1.1.2.tgz", + "integrity": "sha512-adFZel24sGHpTI1vgJdBN5twcdu6QmPFlO8qAJt49KO6N8mwDcbUC2GPqH5pGerXNv1Lpq0eXsNLm+ytKrOTaQ==", "license": "AGPL-3.0-or-later", "dependencies": { "h3": "^1.12.0", @@ -765,21 +765,12 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -1048,9 +1039,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", - "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", "dependencies": { @@ -1132,10 +1123,14 @@ "license": "MIT" }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -1225,20 +1220,24 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/c8": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", - "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", + "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==", "dev": true, + "license": "ISC", "dependencies": { "@bcoe/v8-coverage": "^1.0.1", "@istanbuljs/schema": "^0.1.3", @@ -1247,7 +1246,7 @@ "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.6", - "test-exclude": "^7.0.1", + "test-exclude": "^8.0.0", "v8-to-istanbul": "^9.0.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" @@ -1256,7 +1255,7 @@ "c8": "bin/c8.js" }, "engines": { - "node": ">=18" + "node": "20 || >=22" }, "peerDependencies": { "monocart-coverage-reports": "^2" @@ -1370,13 +1369,13 @@ } }, "node_modules/cloudstorm": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.15.2.tgz", - "integrity": "sha512-5y7E0uI39R3d7c+AWksqAQAlZlpx+qNjxjQfNIem2hh68s6QRmOFHTKu34I7pBE6JonpZf8AmoMYArY/4lLVmg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.17.0.tgz", + "integrity": "sha512-zsd9y5ljNnbxdvDid9TgWePDqo7il4so5spzx6NDwZ67qWQjR96UUhLxJ+BAOdBBSPF9UXFM61dAzC2g918q+A==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.37", - "snowtransfer": "^0.17.0" + "discord-api-types": "^0.38.40", + "snowtransfer": "^0.17.5" }, "engines": { "node": ">=22.0.0" @@ -1517,9 +1516,9 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.38", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.38.tgz", - "integrity": "sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q==", + "version": "0.38.41", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.41.tgz", + "integrity": "sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" @@ -1731,66 +1730,18 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" }, "node_modules/glob": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz", - "integrity": "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", - "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "jackspeak": "^4.2.3" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", - "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -1973,22 +1924,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jest-diff": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", @@ -2023,6 +1958,13 @@ "integrity": "sha512-p2BdO7o4BI+pMun3J+dhaOfYan5JsZrw9wjshRjkWY9+p+u+kKSMhNWYnot2yHDR9CSahZ9iT3dcqJ+V72qHMw==", "dev": true }, + "node_modules/just-snake-case": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/just-snake-case/-/just-snake-case-3.2.0.tgz", + "integrity": "sha512-iugHP9bSE0jOq3BzN0W0rdu/OOkFirPe8FtUw6v9y37UlbUDgJ1x4xiGNfUhI6mV9dc/paaifyiyn+F+mrm8gw==", + "dev": true, + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2039,9 +1981,9 @@ } }, "node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -2094,16 +2036,16 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2118,10 +2060,11 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -2199,12 +2142,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", - "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", - "dev": true - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2230,9 +2167,9 @@ "dev": true }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -2240,7 +2177,7 @@ "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2648,12 +2585,12 @@ } }, "node_modules/snowtransfer": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.1.tgz", - "integrity": "sha512-WSXj055EJhzzfD7B3oHVyRTxkqFCaxcVhwKY6B3NkBSHRyM6wHxZLq6VbFYhopUg+lMtd7S1ZO8JM+Ut+js2iA==", + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.5.tgz", + "integrity": "sha512-nVI1UJNFoX1ndGFZxB3zb3X5SWtD9hIAcw7wCgVKWvCf42Wg2B4UFIrZWI83HxaSBY0CGbPZmZzZb3RSt/v2wQ==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.37" + "discord-api-types": "^0.38.40" }, "engines": { "node": ">=22.0.0" @@ -2780,9 +2717,9 @@ } }, "node_modules/supertape": { - "version": "12.0.12", - "resolved": "https://registry.npmjs.org/supertape/-/supertape-12.0.12.tgz", - "integrity": "sha512-ugmCQsB7s22fCTJKiMb6+Fd8kP7Hsvlo6/aly0qLGgOepu1PVBydhrBPMWaoY3wf+VqLtMkkvwGxUTCFde5z/g==", + "version": "12.7.0", + "resolved": "https://registry.npmjs.org/supertape/-/supertape-12.7.0.tgz", + "integrity": "sha512-5PXh6HsfEJKkC0SMhPNkH35o8Okj8xlVvoju9R0aCohzsK+GEufeYZ1IPhRBRQ2DBLXdMZHVF6N/4pAefxNuAA==", "dev": true, "license": "MIT", "dependencies": { @@ -2800,9 +2737,10 @@ "cli-progress": "^3.8.2", "flatted": "^3.3.1", "fullstore": "^4.0.0", - "glob": "^11.0.1", + "glob": "^13.0.0", "jest-diff": "^30.0.3", "json-with-bigint": "^3.4.4", + "just-snake-case": "^3.2.0", "once": "^1.4.0", "resolve": "^1.17.0", "stacktracey": "^2.1.7", @@ -2819,9 +2757,9 @@ } }, "node_modules/supertape/node_modules/try-to-catch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.0.3.tgz", - "integrity": "sha512-mUz1zpe6nkRQW0XZ/Ojfe/Eg7e5h3s+r+h7ONfP3Oo27/Jm8mkNDAnLzZ/A3sEMApROolzuJGBiQhGmmVDAFLw==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.0.5.tgz", + "integrity": "sha512-VKBslDQsy4pGj2TMNgDdskWb7AWSi/9dPEmcNv3sdL0+aRMQTPJo6aEqlcuN0vbOwFfsE1oAXmx3bFdf6vrJFg==", "dev": true, "license": "MIT", "engines": { @@ -2903,17 +2841,18 @@ } }, "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", "dev": true, + "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" + "glob": "^13.0.6", + "minimatch": "^10.2.2" }, "engines": { - "node": ">=18" + "node": "20 || >=22" } }, "node_modules/through2": { @@ -2979,6 +2918,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-3.0.1.tgz", "integrity": "sha512-hOY83V84Hx/1sCzDSaJA+Xz2IIQOHRvjxzt+F0OjbQGPZ6yLPLArMA0gw/484MlfUkQbCpKYMLX3VDCAjWKfzQ==", + "license": "MIT", "engines": { "node": ">=6" } @@ -3185,9 +3125,9 @@ } }, "node_modules/zod": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", - "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index afbb90a..71639b0 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "ansi-colors": "^4.1.3", "better-sqlite3": "^12.2.0", "chunk-text": "^2.0.1", - "cloudstorm": "^0.15.2", + "cloudstorm": "^0.17.0", "discord-api-types": "^0.38.38", "domino": "^2.1.6", "enquirer": "^2.4.1", @@ -45,7 +45,7 @@ "mime-types": "^2.1.35", "prettier-bytes": "^1.0.4", "sharp": "^0.34.5", - "snowtransfer": "^0.17.1", + "snowtransfer": "^0.17.5", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", "uqr": "^0.1.2", @@ -58,7 +58,7 @@ "devDependencies": { "@cloudrac3r/tap-dot": "^2.0.3", "@types/node": "^22.17.1", - "c8": "^10.1.2", + "c8": "^11.0.0", "cross-env": "^7.0.3", "supertape": "^12.0.12" }, From 9eaa85c072fefd1978247cbfe4dcc4d71af8fe97 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 8 Mar 2026 22:34:51 +1300 Subject: [PATCH 06/51] Add /invite Matrix command to get Discord invite --- src/matrix/matrix-command-handler.js | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index e382a32..d568f7b 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -1,6 +1,7 @@ // @ts-check const assert = require("assert").strict +const DiscordTypes = require("discord-api-types/v10") const Ty = require("../types") const {pipeline} = require("stream").promises const sharp = require("sharp") @@ -262,6 +263,46 @@ const commands = [{ await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")}) } ) +}, { + aliases: ["invite"], + execute: replyctx( + async (event, realBody, words, ctx) => { + // Guard + /** @type {string} */ // @ts-ignore + const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() + const guildID = discord.channels.get(channelID)?.["guild_id"] + if (!guildID) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "This room isn't bridged to the other side." + }) + } + + const guild = discord.guilds.get(guildID) + assert(guild) + const permissions = dUtils.getPermissions(guild.id, [], guild.roles) + if (!dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "This command creates an invite link to the Discord side. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Invite permission." + }) + } + + const invite = await discord.snow.channel.createChannelInvite(channelID) + const validHours = Math.ceil(invite.max_age / (60 * 60)) + const validUses = + ( invite.max_uses === 0 ? "unlimited uses" + : invite.max_uses === 1 ? "single-use" + : `${invite.max_uses} uses`) + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: `https://discord.gg/${invite.code}\nValid for next ${validHours} hours, ${validUses}.` + }) + } + ) }] From 6a2606cbdb41f8ee5f7aa8c8da2d0fa90e93bf60 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 8 Mar 2026 22:35:10 +1300 Subject: [PATCH 07/51] Add UI for defining default roles --- src/db/migrations/0035-role-default.sql | 9 ++++ src/db/orm-defs.d.ts | 5 +++ src/web/pug-sync.js | 1 + src/web/pug/fragments/default-roles-list.pug | 5 +++ src/web/pug/guild.pug | 21 ++++++++- src/web/pug/includes/default-roles-list.pug | 19 +++++++++ src/web/pug/includes/template.pug | 25 +++++++++-- src/web/routes/guild-settings.js | 45 +++++++++++++++++++- 8 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 src/db/migrations/0035-role-default.sql create mode 100644 src/web/pug/fragments/default-roles-list.pug create mode 100644 src/web/pug/includes/default-roles-list.pug diff --git a/src/db/migrations/0035-role-default.sql b/src/db/migrations/0035-role-default.sql new file mode 100644 index 0000000..6c44e7e --- /dev/null +++ b/src/db/migrations/0035-role-default.sql @@ -0,0 +1,9 @@ +BEGIN TRANSACTION; + +CREATE TABLE "role_default" ( + "guild_id" TEXT NOT NULL, + "role_id" TEXT NOT NULL, + PRIMARY KEY ("guild_id", "role_id") +); + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index 79f02ad..f6628f2 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -104,6 +104,11 @@ export type Models = { historical_room_index: number } + role_default: { + guild_id: string + role_id: string + } + room_upgrade_pending: { new_room_id: string old_room_id: string diff --git a/src/web/pug-sync.js b/src/web/pug-sync.js index f87550d..e61c53b 100644 --- a/src/web/pug-sync.js +++ b/src/web/pug-sync.js @@ -77,6 +77,7 @@ function renderPath(event, path, locals) { compile() fs.watch(path, {persistent: false}, compile) fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile) + fs.watch(join(__dirname, "pug", "fragments"), {persistent: false}, compile) } const cb = pugCache.get(path) diff --git a/src/web/pug/fragments/default-roles-list.pug b/src/web/pug/fragments/default-roles-list.pug new file mode 100644 index 0000000..3b36549 --- /dev/null +++ b/src/web/pug/fragments/default-roles-list.pug @@ -0,0 +1,5 @@ +//- locals: guild, guild_id + +include ../includes/default-roles-list.pug ++default-roles-list(guild, guild_id) ++add-roles-menu(guild, guild_id) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index a9e770b..74b476a 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -1,4 +1,5 @@ extends includes/template.pug +include includes/default-roles-list.pug mixin badge-readonly .s-badge.s-badge__xs.s-badge__icon.s-badge__muted @@ -76,7 +77,7 @@ block body if space_id h2.mt48.fs-headline1 Server settings - h3.mt32.fs-category Privacy level + h3.mt32.fs-category How Matrix users join span#privacy-level-loading .s-card form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") @@ -105,6 +106,24 @@ block body p.s-description.m0 Shareable invite links, like Discord p.s-description.m0 Publicly listed in directory, like Discord server discovery + h3.mt32.fs-category Default roles + .s-card + form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles + input(type="hidden" name="guild_id" value=guild_id) + .d-flex.fw-wrap.g4 + .s-tag.s-tag__md.fs-body1.s-tag__required @everyone + + +default-roles-list(guild, guild_id) + + button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0 + .s-tag--dismiss.m1 + != icons.Icons.IconPlusSm + + #role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible + .s-popover--arrow.s-popover--arrow__tc + +add-roles-menu(guild, guild_id) + p.fc-medium.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate. + h3.mt32.fs-category Features .s-card.d-grid.px0.g16 form.d-flex.ai-center.g16 diff --git a/src/web/pug/includes/default-roles-list.pug b/src/web/pug/includes/default-roles-list.pug new file mode 100644 index 0000000..8c0a4e0 --- /dev/null +++ b/src/web/pug/includes/default-roles-list.pug @@ -0,0 +1,19 @@ +mixin default-roles-list(guild, guild_id) + #default-roles-list(style="display: contents") + each roleID in select("role_default", "role_id", {guild_id}).pluck().all() + - let r = guild.roles.find(r => r.id === roleID) + if r + .s-tag.s-tag__md.fs-body1= r.name + span(id=`role-loading-${roleID}`) + button(name="remove_role" value=roleID hx-post="api/default-roles" hx-trigger="click consume" hx-indicator=`#role-loading-${roleID}`).s-tag--dismiss + != icons.Icons.IconClearSm + +mixin add-roles-menu(guild, guild_id) + ul.s-menu(role="menu" hx-swap-oob="true").overflow-y-auto.overflow-x-hidden#add-roles-menu + li.s-menu--title.d-flex(role="separator") Select role + span#add-role-loading + each r in guild.roles.sort((a, b) => b.position - a.position) + if r.id !== guild_id && !r.managed + - let selected = !!select("role_default", "role_id", {guild_id, role_id: r.id}).get() + li(role="menuitem") + button(name="toggle_role" value=r.id class={"is-selected": selected}).s-block-link.s-block-link__left= r.name diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index 9fe80aa..278a16a 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -91,6 +91,19 @@ html(lang="en") .s-btn__dropdown:has(+ :popover-open) { background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important; } + .s-btn__dropdown.s-tag:has(+ :popover-open) .s-tag--dismiss { + background-color: var(--black-500) !important; + color: var(--black-150) !important; + } + .s-tag .is-loading { + margin-right: -4px; + } + .s-tag .is-loading + .s-tag--dismiss { + display: none !important; + } + a.s-block-link, .s-block-link { + --_bl-bs-color: var(--green-400); + } @media (prefers-color-scheme: dark) { body.theme-system .s-popover { --_po-bg: var(--black-100); @@ -141,11 +154,15 @@ html(lang="en") //- Guild list popover script. document.querySelectorAll("[popovertarget]").forEach(e => { - e.addEventListener("click", () => { - const rect = e.getBoundingClientRect() - const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }` + const target = document.getElementById(e.getAttribute("popovertarget")) + e.addEventListener("click", calculate) + target.addEventListener("toggle", calculate) + function calculate() { + const buttonRect = e.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const t = `:popover-open { position: absolute; top: ${Math.floor(buttonRect.bottom + window.scrollY)}px; left: ${Math.floor(Math.max(targetRect.width / 2, buttonRect.left + buttonRect.width / 2))}px; width: ${Math.floor(buttonRect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }` document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length) - }) + } }) //- Prevent default script. diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js index 63dd3ec..62a28a1 100644 --- a/src/web/routes/guild-settings.js +++ b/src/web/routes/guild-settings.js @@ -4,10 +4,12 @@ const assert = require("assert/strict") const {z} = require("zod") const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3") -const {as, db, sync, select} = require("../../passthrough") +const {as, db, sync, select, discord} = require("../../passthrough") /** @type {import("../auth")} */ const auth = sync.require("../auth") +/** @type {import("../pug-sync")} */ +const pugSync = sync.require("../pug-sync") /** @type {import("../../d2m/actions/set-presence")} */ const setPresence = sync.require("../../d2m/actions/set-presence") @@ -20,6 +22,14 @@ function getCreateSpace(event) { return event.context.createSpace || sync.require("../../d2m/actions/create-space") } +const schema = { + defaultRoles: z.object({ + guild_id: z.string(), + toggle_role: z.string().optional(), + remove_role: z.string().optional() + }) +} + /** * @typedef Options * @prop {(value: string?) => number} transform @@ -94,3 +104,36 @@ as.router.post("/api/privacy-level", defineToggle("privacy_level", { await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request } })) + +as.router.post("/api/default-roles", defineEventHandler(async event => { + const parsedBody = await readValidatedBody(event, schema.defaultRoles.parse) + + const managed = await auth.getManagedGuilds(event) + const guildID = parsedBody.guild_id + if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"}) + + const roleID = parsedBody.toggle_role || parsedBody.remove_role + assert(roleID) + assert.notEqual(guildID, roleID) // the @everyone role is always default + + const guild = discord.guilds.get(guildID) + assert(guild) + + let shouldRemove = !!parsedBody.remove_role + if (!shouldRemove) { + shouldRemove = !!select("role_default", "role_id", {guild_id: guildID, role_id: roleID}).get() + } + + if (shouldRemove) { + db.prepare("DELETE FROM role_default WHERE guild_id = ? AND role_id = ?").run(guildID, roleID) + } else { + assert(guild.roles.find(r => r.id === roleID)) + db.prepare("INSERT OR IGNORE INTO role_default (guild_id, role_id) VALUES (?, ?)").run(guildID, roleID) + } + + if (getRequestHeader(event, "HX-Request")) { + return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID}) + } else { + return sendRedirect(event, "", 302) + } +})) From f7a5b2d74cedd132d6ae12b8e0e00d8f0c2b72ee Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 8 Mar 2026 22:36:05 +1300 Subject: [PATCH 08/51] Update tryToCatch dependency and usages --- package-lock.json | 20 +++++--------------- package.json | 2 +- src/d2m/converters/user-to-mxid.test.js | 2 +- src/matrix/read-registration.test.js | 2 +- src/web/routes/download-discord.test.js | 2 +- src/web/routes/download-matrix.test.js | 2 +- src/web/routes/guild-settings.test.js | 2 +- src/web/routes/guild.test.js | 2 +- src/web/routes/link.test.js | 2 +- src/web/routes/log-in-with-matrix.test.js | 2 +- src/web/routes/oauth.test.js | 2 +- src/web/routes/password.test.js | 2 +- 12 files changed, 16 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4e6ecd4..e154da1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "sharp": "^0.34.5", "snowtransfer": "^0.17.5", "stream-mime-type": "^1.0.2", - "try-to-catch": "^3.0.1", + "try-to-catch": "^4.0.5", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", "zod": "^4.0.17" @@ -2756,16 +2756,6 @@ "node": ">=22" } }, - "node_modules/supertape/node_modules/try-to-catch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.0.5.tgz", - "integrity": "sha512-VKBslDQsy4pGj2TMNgDdskWb7AWSi/9dPEmcNv3sdL0+aRMQTPJo6aEqlcuN0vbOwFfsE1oAXmx3bFdf6vrJFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22" - } - }, "node_modules/supertape/node_modules/yargs-parser": { "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", @@ -2915,12 +2905,12 @@ } }, "node_modules/try-to-catch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-3.0.1.tgz", - "integrity": "sha512-hOY83V84Hx/1sCzDSaJA+Xz2IIQOHRvjxzt+F0OjbQGPZ6yLPLArMA0gw/484MlfUkQbCpKYMLX3VDCAjWKfzQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.0.5.tgz", + "integrity": "sha512-VKBslDQsy4pGj2TMNgDdskWb7AWSi/9dPEmcNv3sdL0+aRMQTPJo6aEqlcuN0vbOwFfsE1oAXmx3bFdf6vrJFg==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=22" } }, "node_modules/tslib": { diff --git a/package.json b/package.json index 71639b0..0d0c2b6 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "sharp": "^0.34.5", "snowtransfer": "^0.17.5", "stream-mime-type": "^1.0.2", - "try-to-catch": "^3.0.1", + "try-to-catch": "^4.0.5", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", "zod": "^4.0.17" diff --git a/src/d2m/converters/user-to-mxid.test.js b/src/d2m/converters/user-to-mxid.test.js index f8cf16a..b020e15 100644 --- a/src/d2m/converters/user-to-mxid.test.js +++ b/src/d2m/converters/user-to-mxid.test.js @@ -1,5 +1,5 @@ const {test} = require("supertape") -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const assert = require("assert") const data = require("../../../test/data") const {userToSimName, webhookAuthorToSimName} = require("./user-to-mxid") diff --git a/src/matrix/read-registration.test.js b/src/matrix/read-registration.test.js index 5fb3b55..a8dcc25 100644 --- a/src/matrix/read-registration.test.js +++ b/src/matrix/read-registration.test.js @@ -1,6 +1,6 @@ // @ts-check -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {test} = require("supertape") const {reg, checkRegistration, getTemplateRegistration} = require("./read-registration") diff --git a/src/web/routes/download-discord.test.js b/src/web/routes/download-discord.test.js index e4f4ab4..0d4b884 100644 --- a/src/web/routes/download-discord.test.js +++ b/src/web/routes/download-discord.test.js @@ -1,7 +1,7 @@ // @ts-check const assert = require("assert").strict -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {test} = require("supertape") const {router} = require("../../../test/web") const {_cache} = require("./download-discord") diff --git a/src/web/routes/download-matrix.test.js b/src/web/routes/download-matrix.test.js index ccbcfdd..610a62d 100644 --- a/src/web/routes/download-matrix.test.js +++ b/src/web/routes/download-matrix.test.js @@ -2,7 +2,7 @@ const fs = require("fs") const {convertImageStream} = require("../../m2d/converters/emoji-sheet") -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {test} = require("supertape") const {router} = require("../../../test/web") const streamWeb = require("stream/web") diff --git a/src/web/routes/guild-settings.test.js b/src/web/routes/guild-settings.test.js index fccc266..00acb89 100644 --- a/src/web/routes/guild-settings.test.js +++ b/src/web/routes/guild-settings.test.js @@ -1,6 +1,6 @@ // @ts-check -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {router, test} = require("../../../test/web") const {select} = require("../../passthrough") const {MatrixServerError} = require("../../matrix/mreq") diff --git a/src/web/routes/guild.test.js b/src/web/routes/guild.test.js index aa17548..06b604b 100644 --- a/src/web/routes/guild.test.js +++ b/src/web/routes/guild.test.js @@ -1,7 +1,7 @@ // @ts-check const DiscordTypes = require("discord-api-types/v10") -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {router, test} = require("../../../test/web") const {MatrixServerError} = require("../../matrix/mreq") const {_getPosition} = require("./guild") diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index e8473f8..70299d5 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -1,6 +1,6 @@ // @ts-check -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {router, test} = require("../../../test/web") const {MatrixServerError} = require("../../matrix/mreq") const {select, db} = require("../../passthrough") diff --git a/src/web/routes/log-in-with-matrix.test.js b/src/web/routes/log-in-with-matrix.test.js index 830556e..2f9afcc 100644 --- a/src/web/routes/log-in-with-matrix.test.js +++ b/src/web/routes/log-in-with-matrix.test.js @@ -1,6 +1,6 @@ // @ts-check -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {router, test} = require("../../../test/web") const {MatrixServerError} = require("../../matrix/mreq") diff --git a/src/web/routes/oauth.test.js b/src/web/routes/oauth.test.js index 2f3a791..1a06e39 100644 --- a/src/web/routes/oauth.test.js +++ b/src/web/routes/oauth.test.js @@ -1,7 +1,7 @@ // @ts-check const DiscordTypes = require("discord-api-types/v10") -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const assert = require("assert/strict") const {router, test} = require("../../../test/web") diff --git a/src/web/routes/password.test.js b/src/web/routes/password.test.js index aa60bd3..fca4e70 100644 --- a/src/web/routes/password.test.js +++ b/src/web/routes/password.test.js @@ -1,6 +1,6 @@ // @ts-check -const tryToCatch = require("try-to-catch") +const {tryToCatch} = require("try-to-catch") const {test} = require("supertape") const {router} = require("../../../test/web") From cd8549da387cf365e2b29c13601f1671a6825e40 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 8 Mar 2026 23:32:36 +1300 Subject: [PATCH 09/51] Fix sticker tests and coverage --- src/d2m/converters/message-to-event.js | 10 ++-- src/m2d/converters/event-to-message.test.js | 64 +++------------------ src/matrix/utils.js | 13 ----- src/web/routes/guild-settings.js | 2 +- 4 files changed, 13 insertions(+), 76 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 7f77b81..b1af2e3 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -582,7 +582,8 @@ async function messageToEvent(message, guild, options = {}, di) { // check that condition 1 or 2 is met if (repliedToEventInDifferentRoom || repliedToUnknownEvent) { let referenced = message.referenced_message - if (!referenced) { // backend couldn't be bothered to dereference the message, have to do it ourselves + /* c8 ignore next 4 - backend couldn't be bothered to dereference the message, have to do it ourselves */ + if (!referenced) { assert(message.message_reference?.message_id) referenced = await discord.snow.channel.getChannelMessage(message.message_reference.channel_id, message.message_reference.message_id) } @@ -905,11 +906,8 @@ async function messageToEvent(message, guild, options = {}, di) { else if (component.type === DiscordTypes.ComponentType.Button) { // May only be a section accessory or in an action row (up to 5) if (component.style === DiscordTypes.ButtonStyle.Link) { - if (component.label) { - stack.msb.add(`[${component.label} ${component.url}] `, tag`${component.label} `) - } else { - stack.msb.add(component.url) - } + assert(component.label) // required for Discord to validate link buttons + stack.msb.add(`[${component.label} ${component.url}] `, tag`${component.label} `) } } diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 629f2b8..aa426cd 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -4747,17 +4747,17 @@ test("event2message: stickers work", async t => { messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "", + content: "[get_real2](https://bridge.example.org/download/sticker/cadence.moe/NyMXQFAAdniImbHzsygScbmN/_.webp)", avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", filename: "get_real2.gif"}], - pendingFiles: [{name: "get_real2.gif", mxc: "mxc://cadence.moe/NyMXQFAAdniImbHzsygScbmN"}] + allowed_mentions: { + parse: ["users", "roles"] + } }] } ) }) test("event2message: stickers fetch mimetype from server when mimetype not provided", async t => { - let called = 0 t.deepEqual( await eventToMessage({ type: "m.sticker", @@ -4768,20 +4768,6 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi }, event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0", room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }, {}, {}, { - api: { - async getMedia(mxc, options) { - called++ - t.equal(mxc, "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf") - t.equal(options.method, "HEAD") - return { - status: 200, - headers: new Map([ - ["content-type", "image/gif"] - ]) - } - } - } }), { ensureJoined: [], @@ -4789,48 +4775,14 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "", + content: "[YESYESYES](https://bridge.example.org/download/sticker/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf/_.webp)", avatar_url: undefined, - attachments: [{id: "0", filename: "YESYESYES.gif"}], - pendingFiles: [{name: "YESYESYES.gif", mxc: "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf"}] + allowed_mentions: { + parse: ["users", "roles"] + } }] } ) - t.equal(called, 1, "sticker headers should be fetched") -}) - -test("event2message: stickers with unknown mimetype are not allowed", async t => { - let called = 0 - try { - await eventToMessage({ - type: "m.sticker", - sender: "@cadence:cadence.moe", - content: { - body: "something", - url: "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJe" - }, - event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }, {}, {}, { - api: { - async getMedia(mxc, options) { - called++ - t.equal(mxc, "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJe") - t.equal(options.method, "HEAD") - return { - status: 404, - headers: new Map([ - ["content-type", "application/json"] - ]) - } - } - } - }) - /* c8 ignore next */ - t.fail("should throw an error") - } catch (e) { - t.match(e.toString(), "mimetype") - } }) test("event2message: static emojis work", async t => { diff --git a/src/matrix/utils.js b/src/matrix/utils.js index 9f5cb0f..eee635b 100644 --- a/src/matrix/utils.js +++ b/src/matrix/utils.js @@ -225,19 +225,6 @@ async function getViaServersQuery(roomID, api) { return qs } -function generatePermittedMediaHash(mxc) { - assert(hasher, "xxhash is not ready yet") - const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) - if (!mediaParts) return undefined - - const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}` - const unsignedHash = hasher.h64(serverAndMediaID) - const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range - db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) - - return serverAndMediaID -} - /** * Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL * because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge. diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js index 62a28a1..ae52825 100644 --- a/src/web/routes/guild-settings.js +++ b/src/web/routes/guild-settings.js @@ -134,6 +134,6 @@ as.router.post("/api/default-roles", defineEventHandler(async event => { if (getRequestHeader(event, "HX-Request")) { return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID}) } else { - return sendRedirect(event, "", 302) + return sendRedirect(event, `/guild?guild_id=${guildID}`, 302) } })) From f5ee130463e9a2157a58c840794d669d536d595f Mon Sep 17 00:00:00 2001 From: Bea Date: Sun, 8 Mar 2026 22:11:28 +0000 Subject: [PATCH 10/51] Handle expired invites & fix test registration (#73) This PR addresses a bridge crash discovered while backfilling old channels, alongside a wee QoL fix for the test suite. * **Expired Events (`d2m`):** Wraps Discord scheduled event/invite link lookups in a try-catch block. If a link is expired (404 or Discord error 10006), the bridge now posts a fallback `m.notice` rather than throwing an error and halting message conversion. * **Test Suite Setup:** Updates `test.js` to initialize the mock registration object using `getTemplateRegistration()` preventing test runner crashes when running without a local `registration.yaml` file. Co-authored-by: Cadence Ember Reviewed-on: https://gitdab.com/cadence/out-of-your-element/pulls/73 Co-authored-by: Bea Co-committed-by: Bea --- src/d2m/converters/message-to-event.js | 13 +++++++- src/d2m/converters/message-to-event.test.js | 22 +++++++++++++ test/test.js | 36 ++++++++++----------- 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index b1af2e3..81c821f 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -770,7 +770,18 @@ async function messageToEvent(message, guild, options = {}, di) { // Then scheduled events if (message.content && di?.snow) { for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old - const invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) + let invite + try { + invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) + } catch (e) { + // Skip expired/invalid invites and events + if (e.message === `{"message": "Unknown Invite", "code": 10006}`) { + break + } else { + throw e + } + } + const event = invite.guild_scheduled_event if (!event) continue // the event ID provided was not valid diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 1a73aea..f071417 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1538,6 +1538,28 @@ test("message2event: vc invite event renders embed with room link", async t => { ]) }) +test("message2event: expired/invalid invites are sent as-is", async t => { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { + snow: { + invite: { + async getInvite() { + throw new Error(`{"message": "Unknown Invite", "code": 10006}`) + } + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "https://discord.gg/placeholder?event=1381190945646710824", + format: "org.matrix.custom.html", + formatted_body: "https://discord.gg/placeholder?event=1381190945646710824", + "m.mentions": {}, + msgtype: "m.text", + } + ]) +}) + test("message2event: channel links are converted even inside lists (parser post-processer descends into list items)", async t => { let called = 0 const events = await messageToEvent({ diff --git a/test/test.js b/test/test.js index e05b687..da6bcba 100644 --- a/test/test.js +++ b/test/test.js @@ -6,31 +6,29 @@ const sqlite = require("better-sqlite3") const {Writable} = require("stream") const migrate = require("../src/db/migrate") const HeatSync = require("heatsync") -const {test, extend} = require("supertape") +const {test} = require("supertape") const data = require("./data") const {green} = require("ansi-colors") +const mixin = require("@cloudrac3r/mixin-deep") const passthrough = require("../src/passthrough") const db = new sqlite(":memory:") -const {reg} = require("../src/matrix/read-registration") -reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby" -reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded -reg.ooye.server_name = "cadence.moe" -reg.ooye.namespace_prefix = "_ooye_" -reg.sender_localpart = "_ooye_bot" -reg.id = "baby" -reg.as_token = "don't actually take authenticated actions on the server" -reg.hs_token = "don't actually take authenticated actions on the server" -reg.namespaces = { - users: [{regex: "@_ooye_.*:cadence.moe", exclusive: true}], - aliases: [{regex: "#_ooye_.*:cadence.moe", exclusive: true}] -} -reg.ooye.bridge_origin = "https://bridge.example.org" -reg.ooye.time_zone = "Pacific/Auckland" -reg.ooye.max_file_size = 5000000 -reg.ooye.web_password = "password123" -reg.ooye.include_user_id_in_mxid = false +const registration = require("../src/matrix/read-registration") +registration.reg = mixin(registration.getTemplateRegistration("cadence.moe"), { + id: "baby", + url: "http://localhost:6693", + as_token: "don't actually take authenticated actions on the server", + hs_token: "don't actually take authenticated actions on the server", + ooye: { + server_origin: "https://matrix.cadence.moe", + bridge_origin: "https://bridge.example.org", + discord_token: "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby", + discord_client_secret: "baby", + web_password: "password123", + time_zone: "Pacific/Auckland", + } +}) const sync = new HeatSync({watchFS: false}) From ada3933d9cb3357d2f9cb0b46e2aa00bd3450613 Mon Sep 17 00:00:00 2001 From: Bea Date: Mon, 9 Mar 2026 00:22:41 +0000 Subject: [PATCH 11/51] Backfill: Create new rooms when needed This updates the backfill script to attempt to create rooms for unbridged rooms, rather than bombing out that the room isn't already bridged. Co-authored-by: Cadence Ember Reviewed-on: https://gitdab.com/cadence/out-of-your-element/pulls/75 Co-authored-by: Bea Co-committed-by: Bea --- scripts/backfill.js | 45 +++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/scripts/backfill.js b/scripts/backfill.js index 27600f0..941e803 100644 --- a/scripts/backfill.js +++ b/scripts/backfill.js @@ -10,7 +10,6 @@ if (!channelID) { process.exit(1) } -const assert = require("assert/strict") const sqlite = require("better-sqlite3") const backfill = new sqlite("scripts/backfill.db") backfill.prepare("CREATE TABLE IF NOT EXISTS backfill (channel_id TEXT NOT NULL, message_id INTEGER NOT NULL, PRIMARY KEY (channel_id, message_id))").run() @@ -38,12 +37,8 @@ passthrough.select = orm.select /** @type {import("../src/d2m/event-dispatcher")}*/ const eventDispatcher = sync.require("../src/d2m/event-dispatcher") - -const roomID = passthrough.select("channel_room", "room_id", {channel_id: channelID}).pluck().get() -if (!roomID) { - console.error("Please choose a channel that's already bridged.") - process.exit(1) -} +/** @type {import("../src/d2m/actions/create-room")} */ +const createRoom = sync.require("../src/d2m/actions/create-room") ;(async () => { await discord.cloud.connect() @@ -60,23 +55,29 @@ async function event(event) { if (!channel) return const guild_id = event.d.id - let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0" - console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`) + try { + await createRoom.syncRoom(channelID) + let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0" + console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`) - while (last) { - const messages = await discord.snow.channel.getChannelMessages(channelID, {limit: 50, after: String(last)}) - messages.reverse() // More recent messages come first -> More recent messages come last - for (const message of messages) { - const simulatedGatewayDispatchData = { - guild_id, - backfill: true, - ...message + while (last) { + const messages = await discord.snow.channel.getChannelMessages(channelID, {limit: 50, after: String(last)}) + messages.reverse() // More recent messages come first -> More recent messages come last + for (const message of messages) { + const simulatedGatewayDispatchData = { + guild_id, + backfill: true, + ...message + } + await eventDispatcher.MESSAGE_CREATE(discord, simulatedGatewayDispatchData) + preparedInsert.run(channelID, message.id) } - await eventDispatcher.MESSAGE_CREATE(discord, simulatedGatewayDispatchData) - preparedInsert.run(channelID, message.id) + last = messages.at(-1)?.id } - last = messages.at(-1)?.id - } - process.exit() + process.exit() + } catch (e) { + console.error(e) + process.exit(1) // won't exit automatically on thrown error due to living discord connection, so manual exit is necessary + } } From 6ca1b836e1e6bf21e09d4217f2373e7d82e4b081 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 11 Mar 2026 12:38:05 +1300 Subject: [PATCH 12/51] Add more debugging information --- src/d2m/discord-client.js | 6 +++++- src/matrix/file.js | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index 7b0fcf8..c2a0549 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -52,7 +52,11 @@ class DiscordClient { /** @type {Map>} */ this.guildChannelMap = new Map() if (listen !== "no") { - this.cloud.on("event", message => discordPackets.onPacket(this, message, listen)) + this.cloud.on("event", message => { + process.nextTick(() => { + discordPackets.onPacket(this, message, listen) + }) + }) } const addEventLogger = (eventName, logName) => { diff --git a/src/matrix/file.js b/src/matrix/file.js index 7bc1fec..c469aea 100644 --- a/src/matrix/file.js +++ b/src/matrix/file.js @@ -85,6 +85,7 @@ async function _actuallyUploadDiscordFileToMxc(url) { writeRegistration(reg) return root } + e.uploadURL = url throw e } } From 5f768fee01ee2ffcbd879f635fb6bc7c410dd169 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 12 Mar 2026 16:23:22 +1300 Subject: [PATCH 13/51] d->m: Don't guess mentions in code blocks --- src/d2m/converters/edit-to-changes.test.js | 4 +- src/d2m/converters/find-mentions.js | 12 +++- src/d2m/converters/message-to-event.js | 68 ++++++++++++--------- src/d2m/converters/message-to-event.test.js | 25 ++++++-- 4 files changed, 70 insertions(+), 39 deletions(-) diff --git a/src/d2m/converters/edit-to-changes.test.js b/src/d2m/converters/edit-to-changes.test.js index cb1fb5a..842c24e 100644 --- a/src/d2m/converters/edit-to-changes.test.js +++ b/src/d2m/converters/edit-to-changes.test.js @@ -78,7 +78,7 @@ test("edit2changes: bot response", async t => { newContent: { $type: "m.room.message", msgtype: "m.text", - body: "* :ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", + body: "* :ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", format: "org.matrix.custom.html", formatted_body: '* :ae_botrac4r: @cadence asked ­, I respond: Stop drinking paint. (No)

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

Hit :bn_re: to reroll.', "m.mentions": { diff --git a/src/d2m/converters/find-mentions.js b/src/d2m/converters/find-mentions.js index 8726830..8107459 100644 --- a/src/d2m/converters/find-mentions.js +++ b/src/d2m/converters/find-mentions.js @@ -146,10 +146,18 @@ function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) { // Highlight the relevant part of the message const start = baseOffset + best.scored.matchedInputTokens[0].index const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end - const newContent = content.slice(0, start) + "[" + content.slice(start, end) + "](https://matrix.to/#/" + best.mxid + ")" + content.slice(end) + const newNodes = [{ + type: "text", content: content.slice(0, start) + }, { + type: "link", target: `https://matrix.to/#/${best.mxid}`, content: [ + {type: "text", content: content.slice(start, end)} + ] + }, { + type: "text", content: content.slice(end) + }] return { mxid: best.mxid, - newContent + newNodes } } } diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 81c821f..05a376a 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -519,29 +519,60 @@ async function messageToEvent(message, guild, options = {}, di) { return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed })) - async function transformParsedVia(parsed) { - for (const node of parsed) { + async function transformParsedVia(parsed, scanTextForMentions) { + for (let n = 0; n < parsed.length; n++) { + const node = parsed[n] if (node.type === "discordChannel" || node.type === "discordChannelLink") { node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get() if (node.row?.room_id) { node.via = await getViaServersMemo(node.row.room_id) } } + else if (node.type === "text" && typeof node.content === "string") { + // Merge adjacent text nodes into this one + while (parsed[n+1]?.type === "text" && typeof parsed[n+1].content === "string") { + node.content += parsed[n+1].content + parsed.splice(n+1, 1) + } + // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention. + if (scanTextForMentions) { + let content = node.content + const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)] + for (let i = matches.length; i--;) { + const m = matches[i] + const prefix = m[1] + const maximumWrittenSection = m[2].toLowerCase() + if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it + if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here + + var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() + assert(roomID) + var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name}))) + + const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content) + if (found) { + addMention(found.mxid) + parsed.splice(n, 1, ...found.newNodes) + content = found.newNodes[0].content + } + } + } + } for (const maybeChildNodesArray of [node, node.content, node.items]) { if (Array.isArray(maybeChildNodesArray)) { - await transformParsedVia(maybeChildNodesArray) + await transformParsedVia(maybeChildNodesArray, scanTextForMentions && ["blockQuote", "list", "paragraph", "em", "strong", "u", "del", "text"].includes(node.type)) } } } return parsed } - let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, { + let html = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, customOptions.isTheMessageContent && options.scanTextForMentions !== false), { discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers), ...customOptions }, customParser, customHtmlOutput) - let body = await markdown.toHtmlWithPostParser(content, transformParsedVia, { + let body = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, false), { // not scanning plaintext body for mentions as we don't parse whether they're in code discordCallback: getDiscordParseCallbacks(message, guild, false), discordOnly: true, escapeHTML: false, @@ -735,35 +766,12 @@ async function messageToEvent(message, guild, options = {}, di) { // Then text content if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) { - // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention. - let content = message.content - if (options.scanTextForMentions !== false) { - const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)] - for (let i = matches.length; i--;) { - const m = matches[i] - const prefix = m[1] - const maximumWrittenSection = m[2].toLowerCase() - if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it - if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here - - var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() - assert(roomID) - var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name}))) - - const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content) - if (found) { - addMention(found.mxid) - content = found.newContent - } - } - } - // Scan the content for emojihax and replace them with real emojis - content = content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => { + let content = message.content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => { return `<:${name}:${id}>` }) - const {body, html} = await transformContent(content) + const {body, html} = await transformContent(content, {isTheMessageContent: true}) await addTextEvent(body, html, msgtype) } diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index f071417..4728c2b 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -789,7 +789,7 @@ test("message2event: simple written @mention for matrix user", async t => { ] }, msgtype: "m.text", - body: "[@ash](https://matrix.to/#/@she_who_brings_destruction:cadence.moe) do you need anything from the store btw as I'm heading there after gym", + body: "@ash do you need anything from the store btw as I'm heading there after gym", format: "org.matrix.custom.html", formatted_body: `@ash do you need anything from the store btw as I'm heading there after gym` }]) @@ -838,7 +838,7 @@ test("message2event: many written @mentions for matrix users", async t => { ] }, msgtype: "m.text", - body: "[@Cadence](https://matrix.to/#/@cadence:cadence.moe), tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and [@huck](https://matrix.to/#/@huckleton:cadence.moe)", + body: "@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck", format: "org.matrix.custom.html", formatted_body: `@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck` }]) @@ -890,7 +890,7 @@ test("message2event: written @mentions may match part of the name", async t => { ] }, msgtype: "m.text", - body: "I wonder if [@cadence](https://matrix.to/#/@secret:cadence.moe) saw this?", + body: "I wonder if @cadence saw this?", format: "org.matrix.custom.html", formatted_body: `I wonder if @cadence saw this?` }]) @@ -941,7 +941,7 @@ test("message2event: written @mentions may match part of the mxid", async t => { ] }, msgtype: "m.text", - body: "I wonder if [@huck](https://matrix.to/#/@huckleton:cadence.moe) saw this?", + body: "I wonder if @huck saw this?", format: "org.matrix.custom.html", formatted_body: `I wonder if @huck saw this?` }]) @@ -962,6 +962,21 @@ test("message2event: written @mentions do not match in URLs", async t => { }]) }) +test("message2event: written @mentions do not match in inline code", async t => { + const events = await messageToEvent({ + ...data.message.advanced_written_at_mention_for_matrix, + content: "`public @Nullable EntityType`" + }, data.guild.general, {}, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": {}, + msgtype: "m.text", + body: "`public @Nullable EntityType`", + format: "org.matrix.custom.html", + formatted_body: `public @Nullable EntityType<?>` + }]) +}) + test("message2event: entire message may match elaborate display name", async t => { let called = 0 const events = await messageToEvent({ @@ -1007,7 +1022,7 @@ test("message2event: entire message may match elaborate display name", async t = ] }, msgtype: "m.text", - body: "[@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆](https://matrix.to/#/@wa:cadence.moe)", + body: "@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆", format: "org.matrix.custom.html", formatted_body: `@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆` }]) From 99f4c52bebeb514c45a529e06abafefb44790f78 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 13 Mar 2026 10:17:04 +1300 Subject: [PATCH 14/51] Fix attempting to follow an upgrade path twice --- src/matrix/room-upgrade.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/matrix/room-upgrade.js b/src/matrix/room-upgrade.js index 5a2606e..e7de906 100644 --- a/src/matrix/room-upgrade.js +++ b/src/matrix/room-upgrade.js @@ -54,17 +54,17 @@ async function onBotMembership(event, api, createRoom) { assert.equal(event.type, "m.room.member") assert.equal(event.state_key, utils.bot) - // Check if an upgrade is pending for this room - const newRoomID = event.room_id - const oldRoomID = select("room_upgrade_pending", "old_room_id", {new_room_id: newRoomID}).pluck().get() - if (!oldRoomID) return false - const channelRow = from("channel_room").join("guild_space", "guild_id").where({room_id: oldRoomID}).select("space_id", "guild_id", "channel_id").get() - assert(channelRow) // this could only fail if the channel was unbridged or something between upgrade and joining - - // Check if is join/invite - if (event.content.membership !== "invite" && event.content.membership !== "join") return false - return await roomUpgradeSema.request(async () => { + // Check if an upgrade is pending for this room + const newRoomID = event.room_id + const oldRoomID = select("room_upgrade_pending", "old_room_id", {new_room_id: newRoomID}).pluck().get() + if (!oldRoomID) return false + const channelRow = from("channel_room").join("guild_space", "guild_id").where({room_id: oldRoomID}).select("space_id", "guild_id", "channel_id").get() + assert(channelRow) // this could only fail if the channel was unbridged or something between upgrade and joining + + // Check if is join/invite + if (event.content.membership !== "invite" && event.content.membership !== "join") return false + // If invited, join if (event.content.membership === "invite") { await api.joinRoom(newRoomID) From ff022e8793dc32420fda889035d9188112947233 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 13 Mar 2026 11:12:44 +1300 Subject: [PATCH 15/51] Combine additional embed images into same event --- src/d2m/converters/message-to-event.js | 45 ++++-- .../message-to-event.test.embeds.js | 38 +++++ src/d2m/converters/message-to-event.test.js | 2 +- test/data.js | 135 ++++++++++++++++++ 4 files changed, 209 insertions(+), 11 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 05a376a..adc56e6 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -261,6 +261,29 @@ function getFormattedInteraction(interaction, isThinkingInteraction) { } } +/** + * @param {any} newEvents merge into events + * @param {any} events will be modified + * @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc + */ +function mergeTextEvents(newEvents, events, forceSameMsgtype) { + let prev = events.at(-1) + for (const ne of newEvents) { + const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype) + const typesPermitted = !forceSameMsgtype || ne?.msgtype === prev?.msgtype + if (isAllText && typesPermitted) { + const rep = new mxUtils.MatrixStringBuilder() + rep.body = prev.body + rep.formattedBody = prev.formatted_body + rep.addLine(ne.body, ne.formatted_body) + prev.body = rep.body + prev.formatted_body = rep.formattedBody + } else { + events.push(ne) + } + } +} + /** * @param {DiscordTypes.APIMessage} message * @param {DiscordTypes.APIGuild} guild @@ -835,15 +858,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Try to merge attachment events with the previous event // This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together. - let prev = events.at(-1) - for (const atch of attachmentEvents) { - if (atch.msgtype === "m.text" && prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(prev?.msgtype)) { - prev.body = prev.body + "\n" + atch.body - prev.formatted_body = prev.formatted_body + "
" + atch.formatted_body - } else { - events.push(atch) - } - } + mergeTextEvents(attachmentEvents, events, false) } // Then components @@ -981,6 +996,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once const rep = new mxUtils.MatrixStringBuilder() + let isAdditionalImage = false if (isKlipyGIF) { assert(embed.video?.url) @@ -1047,7 +1063,11 @@ async function messageToEvent(message, guild, options = {}, di) { let chosenImage = embed.image?.url // the thumbnail seems to be used for "article" type but displayed big at the bottom by discord if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url - if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`) + + if (chosenImage) { + isAdditionalImage = !rep.body && !!events.length + rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`) + } if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`) @@ -1056,6 +1076,11 @@ async function messageToEvent(message, guild, options = {}, di) { body = body.split("\n").map(l => "| " + l).join("\n") html = `
${html}
` + if (isAdditionalImage) { + mergeTextEvents([{...rep.get(), body, html, msgtype: "m.notice"}], events, true) + continue + } + // Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person await addTextEvent(body, html, "m.notice") } diff --git a/src/d2m/converters/message-to-event.test.embeds.js b/src/d2m/converters/message-to-event.test.embeds.js index 259aa66..73244d3 100644 --- a/src/d2m/converters/message-to-event.test.embeds.js +++ b/src/d2m/converters/message-to-event.test.embeds.js @@ -204,6 +204,44 @@ test("message2event embeds: author url without name", async t => { }]) }) +test("message2event embeds: 4 images", async t => { + const events = await messageToEvent(data.message_with_embeds.four_images, data.guild.general) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "[🔀 Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046", + format: "org.matrix.custom.html", + formatted_body: "🔀 Forwarded message
https://fixupx.com/i/status/2032003668787020046
", + "m.mentions": {} + }, { + $type: "m.room.message", + msgtype: "m.notice", + body: "» | ## ⏺️ AUTOMATON WEST (@AUTOMATON_ENG) https://x.com/AUTOMATON_ENG/status/2032003668787020046" + + "\n» | " + + "\n» | 4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanity’s last non\\-AI made social network”" + + "\n» | ︀︀" + + "\n» | ︀︀[automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/](https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/)" + + "\n» | " + + "\n» | **[💬](https://x.com/intent/tweet?in_reply_to=2032003668787020046) 36 [🔁](https://x.com/intent/retweet?tweet_id=2032003668787020046) 212 [❤](https://x.com/intent/like?tweet_id=2032003668787020046) 3\\.0K 👁 131\\.7K **" + + "\n» | " + + "\n» | 📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig" + + "\n» | — FixupX" + + "\n» | 📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig" + + "\n» | 📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig" + + "\n» | 📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig", + format: "org.matrix.custom.html", + formatted_body: "

⏺️ AUTOMATON WEST (@AUTOMATON_ENG)

" + + "

4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanity’s last non-AI made social network”" + + "
︀︀
︀︀automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/" + + "

💬 36 🔁 212  3.0K 👁 131.7K 

" + + "

📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig

— FixupX
" + + "

📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig

" + + "

📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig

" + + "

📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig

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

System: INX (xffgnx)" + "
Member: Lillith (pphhoh)" - + "
Sent by: infinidoge1337 (@unknown-user:)" + + "
Sent by: infinidoge1337 (@unknown-user)" + "

Account Roles (7)" + "
§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping

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