From bb711c26ac6ecfa621a58c9ff712fe1db39143d8 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 12 May 2025 14:30:49 +1200 Subject: [PATCH 01/40] API endpoint for message info --- src/matrix/api.js | 9 ++++++ src/web/routes/info.js | 60 +++++++++++++++++++++++++++++++++++++ src/web/routes/info.test.js | 19 ++++++++++++ src/web/server.js | 1 + test/test.js | 1 + 5 files changed, 90 insertions(+) create mode 100644 src/web/routes/info.js create mode 100644 src/web/routes/info.test.js diff --git a/src/matrix/api.js b/src/matrix/api.js index 170802d..eb534c0 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -419,6 +419,14 @@ async function setPresence(data, mxid) { await mreq.mreq("PUT", path(`/client/v3/presence/${mxid}/status`, mxid), data) } +/** + * @param {string} mxid + * @returns {Promise<{displayname?: string, avatar_url?: string}>} + */ +function getProfile(mxid) { + return mreq.mreq("GET", `/client/v3/profile/${mxid}`) +} + module.exports.path = path module.exports.register = register module.exports.createRoom = createRoom @@ -452,3 +460,4 @@ module.exports.getAlias = getAlias module.exports.getAccountData = getAccountData module.exports.setAccountData = setAccountData module.exports.setPresence = setPresence +module.exports.getProfile = getProfile diff --git a/src/web/routes/info.js b/src/web/routes/info.js new file mode 100644 index 0000000..1ed615b --- /dev/null +++ b/src/web/routes/info.js @@ -0,0 +1,60 @@ +// @ts-check + +const {z} = require("zod") +const {defineEventHandler, getValidatedQuery, H3Event} = require("h3") +const {as, from, sync, select} = require("../../passthrough") + +/** + * @param {H3Event} event + * @returns {import("../../matrix/api")} + */ +function getAPI(event) { + /* c8 ignore next */ + return event.context.api || sync.require("../../matrix/api") +} + +const schema = { + message: z.object({ + message_id: z.string().regex(/^[0-9]+$/) + }) +} + +as.router.get("/api/message", defineEventHandler(async event => { + const api = getAPI(event) + + const {message_id} = await getValidatedQuery(event, schema.message.parse) + const metadatas = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").where({message_id}) + .select("event_id", "event_type", "event_subtype", "part", "reaction_part", "room_id", "source").and("ORDER BY part ASC, reaction_part DESC").all() + + if (metadatas.length === 0) { + return new Response("Message not found", {status: 404, statusText: "Not Found"}) + } + + const events = await Promise.all(metadatas.map(metadata => + api.getEvent(metadata.room_id, metadata.event_id).then(raw => ({ + metadata: Object.assign({sender: raw.sender}, metadata), + raw + })) + )) + + const primary = events.find(e => e.metadata.part === 0) || events[0] + const mxid = primary.metadata.sender + const source = primary.metadata.source === 0 ? "matrix" : "discord" + + let matrix_author = undefined + if (source === "matrix") { + matrix_author = select("member_cache", ["displayname", "avatar_url", "mxid"], {room_id: primary.metadata.room_id, mxid}).get() + if (!matrix_author) { + try { + matrix_author = await api.getProfile(mxid) + } catch (e) { + matrix_author = {} + } + } + if (!matrix_author.displayname) matrix_author.displayname = mxid + if (!matrix_author.avatar_url) matrix_author.avatar_url = null + matrix_author["mxid"] = mxid + } + + return {source, matrix_author, events} +})) diff --git a/src/web/routes/info.test.js b/src/web/routes/info.test.js new file mode 100644 index 0000000..5188198 --- /dev/null +++ b/src/web/routes/info.test.js @@ -0,0 +1,19 @@ +// @ts-check + +const assert = require("assert/strict") +const tryToCatch = require("try-to-catch") +const {router, test} = require("../../../test/web") + +test("web info: 404 when message does not exist", async t => { + const res = await router.test("get", "/api/message?message_id=1", { + api: { + async getEvent(roomID, eventID) { + } + } + }) + t.fail("test not written") +}) + +test("web info: returns data when message exists", async t => { + t.fail("test not written") +}) diff --git a/src/web/server.js b/src/web/server.js index 565f29a..7c8ed3e 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -29,6 +29,7 @@ sync.require("./routes/download-matrix") sync.require("./routes/download-discord") sync.require("./routes/guild-settings") sync.require("./routes/guild") +sync.require("./routes/info") sync.require("./routes/link") sync.require("./routes/log-in-with-matrix") sync.require("./routes/oauth") diff --git a/test/test.js b/test/test.js index 2d02cbb..3695a84 100644 --- a/test/test.js +++ b/test/test.js @@ -133,6 +133,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("../src/web/routes/download-matrix.test") require("../src/web/routes/guild.test") require("../src/web/routes/guild-settings.test") + require("../src/web/routes/info.test") require("../src/web/routes/link.test") require("../src/web/routes/log-in-with-matrix.test") require("../src/discord/utils.test") From 2a6284968f01607b01dafcc1d66aa0c3a6fcc692 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 26 May 2025 00:18:56 +1200 Subject: [PATCH 02/40] Fix replying to a message that had a new emoji Without this, the emoji consistency assertion would fail because we must call transformContent to upload the emoji to Matrix. --- src/d2m/converters/message-to-event.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index ae7ea1e..88448b1 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -477,14 +477,7 @@ async function messageToEvent(message, guild, options = {}, di) { } if (repliedToContent == "") repliedToContent = "[Media]" else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]" - const repliedToHtml = markdown.toHTML(repliedToContent, { - discordCallback: getDiscordParseCallbacks(message, guild, true) - }) - const repliedToBody = markdown.toHTML(repliedToContent, { - discordCallback: getDiscordParseCallbacks(message, guild, false), - discordOnly: true, - escapeHTML: false, - }) + const {body: repliedToBody, html: repliedToHtml} = await transformContent(repliedToContent) if (repliedToEventRow) { // Generate a reply pointing to the Matrix event we found html = `
In reply to ${repliedToUserHtml}` From 8d4d505ab9e02d87b9ad9cfd4b385ca0deb5736d Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 29 May 2025 11:57:34 +1200 Subject: [PATCH 03/40] d->m: preserve unknown messages when syncing pins --- src/d2m/actions/update-pins.js | 3 +- src/d2m/converters/pins-to-list.js | 18 +++++++-- src/d2m/converters/pins-to-list.test.js | 51 ++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/d2m/actions/update-pins.js b/src/d2m/actions/update-pins.js index 6ef477f..15febaa 100644 --- a/src/d2m/actions/update-pins.js +++ b/src/d2m/actions/update-pins.js @@ -33,9 +33,10 @@ async function updatePins(channelID, roomID, convertedTimestamp) { } throw e } - const pinned = pinsToList.pinsToList(discordPins) const kstate = await ks.roomToKState(roomID) + const pinned = pinsToList.pinsToList(discordPins, kstate) + const diff = ks.diffKState(kstate, {"m.room.pinned_events/": {pinned}}) await ks.applyKStateDiffToRoom(roomID, diff) diff --git a/src/d2m/converters/pins-to-list.js b/src/d2m/converters/pins-to-list.js index 047bb9f..3e890ea 100644 --- a/src/d2m/converters/pins-to-list.js +++ b/src/d2m/converters/pins-to-list.js @@ -4,16 +4,28 @@ const {select} = require("../../passthrough") /** * @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins + * @param {{"m.room.pinned_events/"?: {pinned?: string[]}}} kstate */ -function pinsToList(pins) { +function pinsToList(pins, kstate) { + let alreadyPinned = kstate["m.room.pinned_events/"]?.pinned || [] + + // If any of the already pinned messages are bridged messages then remove them from the already pinned list. + // * If a bridged message is still pinned then it'll be added back in the next step. + // * If a bridged message was unpinned from Discord-side then it'll be unpinned from our side due to this step. + // * Matrix-only unbridged messages that are pinned will remain pinned. + alreadyPinned = alreadyPinned.filter(event_id => { + const messageID = select("event_message", "message_id", {event_id}).pluck().get() + return !messageID || pins.find(m => m.id === messageID) // if it is bridged then remove it from the filter + }) + /** @type {string[]} */ const result = [] for (const message of pins) { const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get() - if (eventID) result.push(eventID) + if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID) } result.reverse() - return result + return alreadyPinned.concat(result) } module.exports.pinsToList = pinsToList diff --git a/src/d2m/converters/pins-to-list.test.js b/src/d2m/converters/pins-to-list.test.js index 7ee89b6..d0657cb 100644 --- a/src/d2m/converters/pins-to-list.test.js +++ b/src/d2m/converters/pins-to-list.test.js @@ -3,10 +3,59 @@ const data = require("../../../test/data") const {pinsToList} = require("./pins-to-list") test("pins2list: converts known IDs, ignores unknown IDs", t => { - const result = pinsToList(data.pins.faked) + const result = pinsToList(data.pins.faked, {}) t.deepEqual(result, [ "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" ]) }) + +test("pins2list: already pinned duplicate items are not moved", t => { + const result = pinsToList(data.pins.faked, { + "m.room.pinned_events/": { + pinned: [ + "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA" + ] + } + }) + t.deepEqual(result, [ + "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", + "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", + "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" + ]) +}) + +test("pins2list: already pinned unknown items are not moved", t => { + const result = pinsToList(data.pins.faked, { + "m.room.pinned_events/": { + pinned: [ + "$unknown1", + "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", + "$unknown2" + ] + } + }) + t.deepEqual(result, [ + "$unknown1", + "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", + "$unknown2", + "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", + "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" + ]) +}) + +test("pins2list: bridged messages can be unpinned", t => { + const result = pinsToList(data.pins.faked.slice(0, -2), { + "m.room.pinned_events/": { + pinned: [ + "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", + "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4" + ] + } + }) + t.deepEqual(result, [ + "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", + "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", + ]) +}) From c50d23855283a045755d03fb48d75c4024faed5b Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 4 Jun 2025 11:31:22 +1200 Subject: [PATCH 04/40] Suppress error when adding to a super reaction --- src/m2d/actions/add-reaction.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/m2d/actions/add-reaction.js b/src/m2d/actions/add-reaction.js index 6eb12e0..1a9f05c 100644 --- a/src/m2d/actions/add-reaction.js +++ b/src/m2d/actions/add-reaction.js @@ -31,6 +31,10 @@ async function addReaction(event) { // not adding it to the database otherwise a m->d removal would try calling the API return } + if (e.message?.includes("Unknown Emoji")) { + // happens if a matrix user tries to add on to a super reaction + return + } throw e } From ab396bd581c2b6e2f372561df3e5a9bc047af33d Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 8 Jun 2025 21:52:28 +1200 Subject: [PATCH 05/40] Generate embeds for invites with events --- package-lock.json | 6 +- src/d2m/actions/send-message.js | 2 +- src/d2m/converters/message-to-event.js | 45 +++- src/d2m/converters/message-to-event.test.js | 128 ++++++++++ test/data.js | 245 ++++++++++++++++++++ 5 files changed, 421 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index fbb9d93..d6b690b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2950,9 +2950,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", - "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", "license": "MIT", "dependencies": { "chownr": "^1.1.1", diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index 15d749c..b01235a 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -42,7 +42,7 @@ async function sendMessage(message, channel, guild, row) { } } - const events = await messageToEvent.messageToEvent(message, guild, {}, {api}) + const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow}) const eventIDs = [] if (events.length) { db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 88448b1..cdceeca 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -204,7 +204,7 @@ async function attachmentToEvent(mentions, attachment) { * - includeEditFallbackStar: false * - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true. * - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned. - * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API + * @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API */ async function messageToEvent(message, guild, options = {}, di) { const events = [] @@ -608,6 +608,49 @@ async function messageToEvent(message, guild, options = {}, di) { await addTextEvent(body, html, msgtype) } + // 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]}) + const event = invite.guild_scheduled_event + if (!event) continue // the event ID provided was not valid + + const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric"}) // 9 June at 3:00 pm NZT + const rep = new mxUtils.MatrixStringBuilder() + + // Add time + if (event.scheduled_end_time) { + // @ts-ignore - no definition available for formatRange + rep.addParagraph(`Scheduled Event - ${formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time))}`) + } else { + rep.addParagraph(`Scheduled Event - ${formatter.format(new Date(event.scheduled_start_time))}`) + } + + // Add details + rep.addLine(`## ${event.name}`, tag`${event.name}`) + if (event.description) rep.addLine(event.description) + + // Add location + if (event.entity_metadata?.location) { + rep.addParagraph(`📍 ${event.entity_metadata.location}`) + } else if (invite.channel?.name) { + const roomID = select("channel_room", "room_id", {channel_id: invite.channel.id}).pluck().get() + if (roomID) { + const via = await getViaServersMemo(roomID) + rep.addParagraph(`🔊 ${invite.channel.name} - https://matrix.to/#/${roomID}?${via}`, tag`🔊 ${invite.channel.name} - ${invite.channel.name}`) + } else { + rep.addParagraph(`🔊 ${invite.channel.name}`) + } + } + + // Send like an embed + let {body, formatted_body: html} = rep.get() + body = body.split("\n").map(l => "| " + l).join("\n") + html = `
${html}
` + await addTextEvent(body, html, "m.notice") + } + } + // Then attachments if (message.attachments) { const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions))) diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 3e7bcd4..cd7c3fe 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1165,3 +1165,131 @@ test("message2event: don't scan forwarded messages for mentions", async t => { } ]) }) + +test("message2event: invite no details embed if no event", async t => { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { + snow: { + invite: { + getInvite: async () => ({...data.invite.irl, guild_scheduled_event: null}) + } + } + }) + 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: irl invite event renders embed", async t => { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { + snow: { + invite: { + getInvite: async () => data.invite.irl + } + } + }) + 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", + }, + { + $type: "m.room.message", + msgtype: "m.notice", + body: `| Scheduled Event - 8 June at 10:00 pm NZT – 9 June at 12:00 am NZT` + + `\n| ## forest exploration` + + `\n| ` + + `\n| 📍 the dark forest`, + format: "org.matrix.custom.html", + formatted_body: `

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

` + + `forest exploration` + + `

📍 the dark forest

`, + "m.mentions": {} + } + ]) +}) + +test("message2event: vc invite event renders embed", async t => { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { + snow: { + invite: { + getInvite: async () => data.invite.vc + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "https://discord.gg/placeholder?event=1381174024801095751", + format: "org.matrix.custom.html", + formatted_body: "https://discord.gg/placeholder?event=1381174024801095751", + "m.mentions": {}, + msgtype: "m.text", + }, + { + $type: "m.room.message", + msgtype: "m.notice", + body: `| Scheduled Event - 9 June at 3:00 pm NZT` + + `\n| ## Cooking (Netrunners)` + + `\n| Short circuited brain interfaces actually just means your brain is medium rare, yum.` + + `\n| ` + + `\n| 🔊 Cooking`, + format: "org.matrix.custom.html", + formatted_body: `

Scheduled Event - 9 June at 3:00 pm NZT

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

🔊 Cooking

`, + "m.mentions": {} + } + ]) +}) + +test("message2event: vc invite event renders embed with room link", async t => { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { + api: { + getJoinedMembers: async () => ({ + joined: { + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + } + }) + }, + snow: { + invite: { + getInvite: async () => data.invite.known_vc + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "https://discord.gg/placeholder?event=1381174024801095751", + format: "org.matrix.custom.html", + formatted_body: "https://discord.gg/placeholder?event=1381174024801095751", + "m.mentions": {}, + msgtype: "m.text", + }, + { + $type: "m.room.message", + msgtype: "m.notice", + body: `| Scheduled Event - 9 June at 3:00 pm NZT` + + `\n| ## Cooking (Netrunners)` + + `\n| Short circuited brain interfaces actually just means your brain is medium rare, yum.` + + `\n| ` + + `\n| 🔊 Hey. - https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe?via=cadence.moe`, + format: "org.matrix.custom.html", + formatted_body: `

Scheduled Event - 9 June at 3:00 pm NZT

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

🔊 Hey. - Hey.

`, + "m.mentions": {} + } + ]) +}) diff --git a/test/data.js b/test/data.js index 01e49a4..78fcb75 100644 --- a/test/data.js +++ b/test/data.js @@ -4858,5 +4858,250 @@ module.exports = { application_id: "1109360903096369153", guild_id: "497159726455455754" } + }, + invite: { + irl: { + type: 0, + code: 'placeholder', + inviter: { + id: '772659086046658620', + username: 'cadence.worm', + avatar: '466df0c98b1af1e1388f595b4c1ad1b9', + discriminator: '0', + public_flags: 0, + flags: 0, + banner: null, + accent_color: 4534897, + global_name: 'cadence', + avatar_decoration_data: null, + collectibles: null, + banner_color: '#453271', + clan: null, + primary_guild: null + }, + expires_at: '2025-06-15T08:39:43+00:00', + guild: { + id: '1338114140941586518', + name: 'self service', + splash: null, + banner: null, + description: null, + icon: null, + features: [], + verification_level: 0, + vanity_url_code: null, + nsfw_level: 0, + nsfw: false, + premium_subscription_count: 0, + premium_tier: 0 + }, + guild_id: '1338114140941586518', + channel: { id: '1338114141658939517', type: 0, name: 'general' }, + guild_scheduled_event: { + id: '1381190945646710824', + guild_id: '1338114140941586518', + name: 'forest exploration', + description: '', + channel_id: null, + creator_id: '772659086046658620', + image: null, + scheduled_start_time: '2025-06-08T10:00:00.161000+00:00', + scheduled_end_time: '2025-06-08T12:00:00.161000+00:00', + status: 1, + entity_type: 3, + entity_id: null, + recurrence_rule: null, + user_count: 1, + privacy_level: 2, + sku_ids: [], + user_rsvp: null, + guild_scheduled_event_exceptions: [], + entity_metadata: { location: 'the dark forest' } + }, + profile: { + id: '1338114140941586518', + name: 'self service', + icon_hash: null, + member_count: 2, + online_count: 1, + description: null, + banner_hash: null, + game_application_ids: [], + game_activity: {}, + tag: null, + badge: 0, + badge_color_primary: '#ff0000', + badge_color_secondary: '#800000', + badge_hash: null, + traits: [], + features: [], + visibility: 2, + custom_banner_hash: null, + premium_subscription_count: 0, + premium_tier: 0 + } + }, + vc: { + type: 0, + code: 'placeholder', + inviter: { + id: '1024720274928697384', + username: '1024720274928697384', + avatar: '040a0652f1c76af3b71bb2c58ee0057b', + discriminator: '0', + public_flags: 0, + flags: 0, + banner: null, + accent_color: 4259841, + global_name: 'Regalia, Goddess of OH GOD OH FU', + avatar_decoration_data: null, + collectibles: null, + banner_color: '#410001', + clan: null, + primary_guild: null + }, + expires_at: '2025-06-15T07:32:30+00:00', + guild: { + id: '1340545485542391879', + name: 'VRCooking', + splash: null, + banner: null, + description: null, + icon: '8e1948b83d79c11ccb32b9e54a5d85fd', + features: [ 'SOUNDBOARD', 'ACTIVITY_FEED_DISABLED_BY_USER' ], + verification_level: 0, + vanity_url_code: null, + nsfw_level: 0, + nsfw: false, + premium_subscription_count: 0, + premium_tier: 0 + }, + guild_id: '1340545485542391879', + channel: { id: '1368144987707019306', type: 2, name: 'Cooking' }, + guild_scheduled_event: { + id: '1381174024801095751', + guild_id: '1340545485542391879', + name: 'Cooking (Netrunners)', + description: 'Short circuited brain interfaces actually just means your brain is medium rare, yum.', + channel_id: '1368144987707019306', + creator_id: '1024720274928697384', + image: null, + scheduled_start_time: '2025-06-09T03:00:00+00:00', + scheduled_end_time: null, + status: 1, + entity_type: 2, + entity_id: null, + recurrence_rule: null, + user_count: 2, + privacy_level: 2, + sku_ids: [], + user_rsvp: null, + guild_scheduled_event_exceptions: [], + entity_metadata: {} + }, + profile: { + id: '1340545485542391879', + name: 'VRCooking', + icon_hash: '8e1948b83d79c11ccb32b9e54a5d85fd', + member_count: 18, + online_count: 13, + description: null, + banner_hash: null, + game_application_ids: [], + game_activity: {}, + tag: null, + badge: 0, + badge_color_primary: '#ff0000', + badge_color_secondary: '#800000', + badge_hash: null, + traits: [], + features: [], + visibility: 2, + custom_banner_hash: null, + premium_subscription_count: 0, + premium_tier: 0 + } + }, + known_vc: { + type: 0, + code: 'placeholder', + inviter: { + id: '1024720274928697384', + username: '1024720274928697384', + avatar: '040a0652f1c76af3b71bb2c58ee0057b', + discriminator: '0', + public_flags: 0, + flags: 0, + banner: null, + accent_color: 4259841, + global_name: 'Regalia, Goddess of OH GOD OH FU', + avatar_decoration_data: null, + collectibles: null, + banner_color: '#410001', + clan: null, + primary_guild: null + }, + expires_at: '2025-06-15T07:32:30+00:00', + guild: { + id: '112760669178241024', + name: 'Psychonauts 3', + splash: null, + banner: null, + description: null, + icon: '8e1948b83d79c11ccb32b9e54a5d85fd', + features: [ 'SOUNDBOARD', 'ACTIVITY_FEED_DISABLED_BY_USER' ], + verification_level: 0, + vanity_url_code: null, + nsfw_level: 0, + nsfw: false, + premium_subscription_count: 0, + premium_tier: 0 + }, + guild_id: '112760669178241024', + channel: { id: '1162005314908999790', type: 0, name: 'Hey.' }, + guild_scheduled_event: { + id: '1381174024801095751', + guild_id: '112760669178241024', + name: 'Cooking (Netrunners)', + description: 'Short circuited brain interfaces actually just means your brain is medium rare, yum.', + channel_id: '1162005314908999790', + creator_id: '1024720274928697384', + image: null, + scheduled_start_time: '2025-06-09T03:00:00+00:00', + scheduled_end_time: null, + status: 1, + entity_type: 2, + entity_id: null, + recurrence_rule: null, + user_count: 2, + privacy_level: 2, + sku_ids: [], + user_rsvp: null, + guild_scheduled_event_exceptions: [], + entity_metadata: {} + }, + profile: { + id: '112760669178241024', + name: 'Psychonauts 3', + icon_hash: '8e1948b83d79c11ccb32b9e54a5d85fd', + member_count: 18, + online_count: 13, + description: null, + banner_hash: null, + game_application_ids: [], + game_activity: {}, + tag: null, + badge: 0, + badge_color_primary: '#ff0000', + badge_color_secondary: '#800000', + badge_hash: null, + traits: [], + features: [], + visibility: 2, + custom_banner_hash: null, + premium_subscription_count: 0, + premium_tier: 0 + } + } } } From 557b7653e280b67a4825a8785af39887f1ffb7f5 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 8 Jun 2025 22:29:10 +1200 Subject: [PATCH 06/40] Test coverage for message info API --- src/web/routes/info.js | 1 + src/web/routes/info.test.js | 212 +++++++++++++++++++++++++++++++++++- test/ooye-test-data.sql | 9 +- 3 files changed, 214 insertions(+), 8 deletions(-) diff --git a/src/web/routes/info.js b/src/web/routes/info.js index 1ed615b..b70ef84 100644 --- a/src/web/routes/info.js +++ b/src/web/routes/info.js @@ -37,6 +37,7 @@ as.router.get("/api/message", defineEventHandler(async event => { })) )) + /* c8 ignore next */ const primary = events.find(e => e.metadata.part === 0) || events[0] const mxid = primary.metadata.sender const source = primary.metadata.source === 0 ? "matrix" : "discord" diff --git a/src/web/routes/info.test.js b/src/web/routes/info.test.js index 5188198..28dac3b 100644 --- a/src/web/routes/info.test.js +++ b/src/web/routes/info.test.js @@ -1,19 +1,219 @@ // @ts-check const assert = require("assert/strict") -const tryToCatch = require("try-to-catch") const {router, test} = require("../../../test/web") -test("web info: 404 when message does not exist", async t => { - const res = await router.test("get", "/api/message?message_id=1", { +test("web info: returns 404 when message doesn't exist", async t => { + const res = await router.test("get", "/api/message?message_id=1") + assert(res instanceof Response) + t.equal(res.status, 404) +}) + +test("web info: returns data for a matrix message and profile", async t => { + let called = 0 + const raw = { + type: "m.room.message", + room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "testing :heart_pink: :heart_pink: ", + format: "org.matrix.custom.html", + formatted_body: "testing \":heart_pink:\" \":heart_pink:\"" + }, + origin_server_ts: 1739312945302, + unsigned: { + membership: "join", + age: 10063702303 + }, + event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk", + user_id: "@cadence:cadence.moe", + age: 10063702303 + } + const res = await router.test("get", "/api/message?message_id=1339000288144658482", { api: { + // @ts-ignore - returning static data when method could be called with a different typescript generic async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + t.equal(eventID, "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk") + return raw + }, + async getProfile(mxid) { + called++ + t.equal(mxid, "@cadence:cadence.moe") + return { + displayname: "okay 🤍 yay 🤍" + } } } }) - t.fail("test not written") + t.deepEqual(res, { + source: "matrix", + matrix_author: { + displayname: "okay 🤍 yay 🤍", + avatar_url: null, + mxid: "@cadence:cadence.moe" + }, + events: [{ + metadata: { + event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk", + event_subtype: "m.text", + event_type: "m.room.message", + part: 0, + reaction_part: 0, + room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", + sender: "@cadence:cadence.moe", + source: 0 + }, + raw + }] + }) + t.equal(called, 2) }) -test("web info: returns data when message exists", async t => { - t.fail("test not written") +test("web info: returns data for a matrix message without profile", async t => { + let called = 0 + const raw = { + type: "m.room.message", + room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "testing :heart_pink: :heart_pink: ", + format: "org.matrix.custom.html", + formatted_body: "testing \":heart_pink:\" \":heart_pink:\"" + }, + origin_server_ts: 1739312945302, + unsigned: { + membership: "join", + age: 10063702303 + }, + event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk", + user_id: "@cadence:cadence.moe", + age: 10063702303 + } + const res = await router.test("get", "/api/message?message_id=1339000288144658482", { + api: { + // @ts-ignore - returning static data when method could be called with a different typescript generic + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + t.equal(eventID, "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk") + return raw + } + } + }) + t.deepEqual(res, { + source: "matrix", + matrix_author: { + displayname: "@cadence:cadence.moe", + avatar_url: null, + mxid: "@cadence:cadence.moe" + }, + events: [{ + metadata: { + event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk", + event_subtype: "m.text", + event_type: "m.room.message", + part: 0, + reaction_part: 0, + room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", + sender: "@cadence:cadence.moe", + source: 0 + }, + raw + }] + }) + t.equal(called, 1) +}) + +test("web info: returns data for a discord message", async t => { + let called = 0 + const raw1 = { + type: "m.room.message", + sender: "@_ooye_accavish:cadence.moe", + content: { + "m.mentions": {}, + msgtype: "m.text", + body: "brony music mentioned on wikipedia's did you know and also unrelated cat pic" + }, + origin_server_ts: 1749377203735, + unsigned: { + membership: "join", + age: 119 + }, + event_id: "$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + } + const raw2 = { + type: "m.room.message", + sender: "@_ooye_accavish:cadence.moe", + content: { + "m.mentions": {}, + msgtype: "m.image", + url: "mxc://cadence.moe/ABOMymxHcpVeecHvmSIYmYXx", + external_url: "https://bridge.cadence.moe/download/discordcdn/112760669178241024/1381212840710504448/image.png", + body: "image.png", + filename: "image.png", + info: { + mimetype: "image/png", + w: 966, + h: 368, + size: 166060 + } + }, + origin_server_ts: 1749377203789, + unsigned: { + membership: "join", + age: 65 + }, + event_id: "$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + } + const res = await router.test("get", "/api/message?message_id=1381212840957972480", { + api: { + // @ts-ignore - returning static data when method could be called with a different typescript generic + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + if (eventID === raw1.event_id) { + return raw1 + } else { + assert(eventID === raw2.event_id) + return raw2 + } + } + } + }) + t.deepEqual(res, { + source: "discord", + matrix_author: undefined, + events: [{ + metadata: { + event_id: "$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI", + event_subtype: "m.text", + event_type: "m.room.message", + part: 0, + reaction_part: 1, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + sender: "@_ooye_accavish:cadence.moe", + source: 1 + }, + raw: raw1 + }, { + metadata: { + event_id: "$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM", + event_subtype: "m.image", + event_type: "m.room.message", + part: 1, + reaction_part: 0, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + sender: "@_ooye_accavish:cadence.moe", + source: 1 + }, + raw: raw2 + }] + }) + t.equal(called, 2) }) diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index caac692..2b66486 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -70,7 +70,9 @@ INSERT INTO message_channel (message_id, channel_id) VALUES ('1278002262400176128', '1100319550446252084'), ('1278001833876525057', '1100319550446252084'), ('1191567971970191490', '176333891320283136'), -('1144874214311067708', '687028734322147344'); +('1144874214311067708', '687028734322147344'), +('1339000288144658482', '176333891320283136'), +('1381212840957972480', '112760669178241024'); 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), @@ -110,7 +112,10 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part ('$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4', 'm.room.message', 'm.text', '1273743950028607530', 0, 0, 0), ('$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF', 'm.room.message', 'm.text', '1278002262400176128', 0, 0, 1), ('$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM', 'm.room.message', 'm.text', '1278001833876525057', 0, 0, 1), -('$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE', 'm.room.message', 'm.text', '1191567971970191490', 0, 0, 1); +('$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE', 'm.room.message', 'm.text', '1191567971970191490', 0, 0, 1), +('$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk', 'm.room.message', 'm.text', '1339000288144658482', 0, 0, 0), +('$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI', 'm.room.message', 'm.text', '1381212840957972480', 0, 1, 1), +('$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM', 'm.room.message', 'm.image', '1381212840957972480', 1, 0, 1); INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'), From 45de3f8be44e8d67d02c2ae931daa2882abafdf9 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 8 Jun 2025 22:52:07 +1200 Subject: [PATCH 07/40] Info API: Use HTTPS for avatar URLs --- src/web/routes/info.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/web/routes/info.js b/src/web/routes/info.js index b70ef84..0ccdeca 100644 --- a/src/web/routes/info.js +++ b/src/web/routes/info.js @@ -4,6 +4,9 @@ const {z} = require("zod") const {defineEventHandler, getValidatedQuery, H3Event} = require("h3") const {as, from, sync, select} = require("../../passthrough") +/** @type {import("../../m2d/converters/utils")} */ +const mUtils = sync.require("../../m2d/converters/utils") + /** * @param {H3Event} event * @returns {import("../../matrix/api")} @@ -53,7 +56,8 @@ as.router.get("/api/message", defineEventHandler(async event => { } } if (!matrix_author.displayname) matrix_author.displayname = mxid - if (!matrix_author.avatar_url) matrix_author.avatar_url = null + if (matrix_author.avatar_url) matrix_author.avatar_url = mUtils.getPublicUrlForMxc(matrix_author.avatar_url) + else matrix_author.avatar_url = null matrix_author["mxid"] = mxid } From 65a591e92426c96a50d74f145c31b1347d311696 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 8 Jun 2025 22:52:43 +1200 Subject: [PATCH 08/40] Add documentation for info API --- docs/api.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/api.md diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..4689db1 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,52 @@ +# API + +There is a web API for getting information about things that are bridged with Out Of Your Element. + +The base URL is the URL of the particular OOYE instance, for example, https://bridge.cadence.moe. + +No authentication is required. + +I'm happy to add more endpoints, just ask for them. + +## Endpoint: GET /api/message + +|Query parameter|Type|Description| +|---------------|----|-----------| +|`message_id`|regexp `/^[0-9]+$/`|Discord message ID to look up information for| + +Response: + +```typescript +{ + source: "matrix" | "discord" // Which platform the message originated on + matrix_author?: { // Only for Matrix messages; should be up-to-date rather than historical data + displayname: string, // Matrix user's current display name + avatar_url: string | null, // Absolute HTTP(S) URL to download the Matrix user's current avatar + mxid: string // Matrix user ID, can never change + }, + events: [ // Data about each individual event + { + metadata: { // Data from OOYE's database about how bridging was performed + sender: string, // Same as matrix user ID + event_id: string, // Unique ID of the event on Matrix, can never change + event_type: "m.room.message" | string, // Event type + event_subtype: "m.text" | string | null, // For m.room.message events, this is the msgtype property + part: 0 | 1, // For multi-event messages, 0 if this is the first part + reaction_part: 0 | 1, // For multi-event messages, 0 if this is the last part + room_id: string, // Room ID that the event was sent in, linked to the Discord channel + source: number + }, + raw: { // Raw historical event data from the Matrix API. Contains at least these properties: + content: any, // The only non-metadata property, entirely client-generated + type: string, + room_id: string, + sender: string, + origin_server_ts: number, + unsigned?: any, + event_id: string, + user_id: string + } + } + ] +} +``` From 890e80854f97881b1d690991c7dccb6d67fb887a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 9 Jun 2025 12:07:11 +1200 Subject: [PATCH 09/40] m->d: render tables --- src/m2d/converters/event-to-message.js | 21 ++++++++++++ src/m2d/converters/event-to-message.test.js | 36 +++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 7044859..4f834b7 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -154,6 +154,27 @@ turndownService.addRule("listItem", { } }) +turndownService.addRule("table", { + filter: "table", + replacement: function (content, node, options) { + const trs = node.querySelectorAll("tr").cache + /** @type {{text: string, tag: string}[][]} */ + const tableText = trs.map(tr => [...tr.querySelectorAll("th, td")].map(cell => ({text: cell.textContent, tag: cell.tagName}))) + const tableTextByColumn = tableText[0].map((col, i) => tableText.map(row => row[i])) + const columnWidths = tableTextByColumn.map(col => Math.max(...col.map(cell => cell.text.length))) + const resultRows = tableText.map((row, rowIndex) => + row.map((cell, colIndex) => + cell.text.padEnd(columnWidths[colIndex]) + ).join(" ") + ) + const tableHasHeader = tableText[0].slice(1).some(cell => cell.tag === "TH") + if (tableHasHeader) { + resultRows.splice(1, 0, "-".repeat(columnWidths.reduce((a, c) => a + c + 2))) + } + return "```\n" + resultRows.join("\n") + "```" + } +}) + /** @type {string[]} SPRITE SHEET EMOJIS FEATURE: mxc urls for the currently processing message */ let endOfMessageEmojis = [] turndownService.addRule("emoji", { diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 4c1ff6c..70853aa 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -4537,6 +4537,42 @@ test("event2message: @room in the middle of a link is not converted", async t => ) }) +test("event2message: table", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: "content
Col 1Col 2Col 3
AppleBananaCherry
AardvarkBeeCrocodile
ArgonBoronCarbon
more content" + }, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A" + }), + { + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "content```" + + "\nCol 1 Col 2 Col 3 " + + "\n---------------------------" + + "\nApple Banana Cherry " + + "\nAardvark Bee Crocodile" + + "\nArgon Boron Carbon ```" + + "more content", + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }], + ensureJoined: [] + } + ) +}) + slow()("event2message: unknown emoji at the end is reuploaded as a sprite sheet", async t => { const messages = await eventToMessage({ type: "m.room.message", From edf60bcd2d4c820c2e8eeac87da6771239c40551 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 15 Jun 2025 21:18:33 +1200 Subject: [PATCH 10/40] Remove provider line from Tenor gifs --- .../message-to-event.embeds.test.js | 19 +++++ src/d2m/converters/message-to-event.js | 2 +- test/data.js | 77 +++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/d2m/converters/message-to-event.embeds.test.js b/src/d2m/converters/message-to-event.embeds.test.js index 9c18386..ed165c6 100644 --- a/src/d2m/converters/message-to-event.embeds.test.js +++ b/src/d2m/converters/message-to-event.embeds.test.js @@ -321,6 +321,25 @@ test("message2event embeds: youtube video", async t => { }]) }) +test("message2event embeds: tenor gif should show a video link without a provider", async t => { + const events = await messageToEvent(data.message_with_embeds.tenor_gif, data.guild.general, {}, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "@Realdditors: get real https://tenor.com/view/get-real-gif-26176788", + format: "org.matrix.custom.html", + formatted_body: "@Realdditors get real https://tenor.com/view/get-real-gif-26176788", + "m.mentions": {} + }, { + $type: "m.room.message", + msgtype: "m.notice", + body: "| 🎞️ https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4", + format: "org.matrix.custom.html", + formatted_body: "

🎞️ https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4

", + "m.mentions": {} + }]) +}) + test("message2event embeds: if discord creates an embed preview for a discord channel link, don't copy that embed", async t => { const events = await messageToEvent(data.message_with_embeds.discord_server_included_punctuation_bad_discord, data.guild.general, {}, { api: { diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index cdceeca..104a08c 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -676,7 +676,7 @@ async function messageToEvent(message, guild, options = {}, di) { const rep = new mxUtils.MatrixStringBuilder() // Provider - if (embed.provider?.name) { + if (embed.provider?.name && embed.provider.name !== "Tenor") { if (embed.provider.url) { rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`${embed.provider.name}`) } else { diff --git a/test/data.js b/test/data.js index 78fcb75..fba0587 100644 --- a/test/data.js +++ b/test/data.js @@ -208,6 +208,25 @@ module.exports = { hoist: true, flags: 0, color: 16745267 + }, { + version: 1743122443142, + unicode_emoji: null, + tags: {}, + position: 3, + permissions: "0", + name: "Realdditors", + mentionable: true, + managed: false, + id: "1182745800661540927", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 16729344 + }, + color: 16729344 } ], discovery_splash: null, @@ -3778,6 +3797,64 @@ module.exports = { edited_timestamp: null, flags: 0, components: [] + }, + tenor_gif: { + type: 0, + content: "<@&1182745800661540927> get real https://tenor.com/view/get-real-gif-26176788", + mentions: [], + mention_roles: [ "1182745800661540927" ], + attachments: [], + embeds: [ + { + type: "gifv", + url: "https://tenor.com/view/get-real-gif-26176788", + provider: { name: "Tenor", url: "https://tenor.co" }, + thumbnail: { + url: "https://media.tenor.com/Bz5pfRIu81oAAAAe/get-real.png", + proxy_url: "https://images-ext-1.discordapp.net/external/I71Ngw9drAKZhL_lhQRnAD_A-DkRNgN3EeZ2njv3Vi4/https/media.tenor.com/Bz5pfRIu81oAAAAe/get-real.png", + width: 632, + height: 640, + placeholder: "IBgSHwSYaIePiHh/d7h3d4eEJvkchZsA", + placeholder_version: 1, + flags: 0 + }, + video: { + url: "https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4", + proxy_url: "https://images-ext-1.discordapp.net/external/vNEtsZd1p_mWQh-nEIa0ZBndMEo2_oa1sAOMyXsgoWI/https/media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4", + width: 632, + height: 640, + placeholder: "IBgSHwSYaIePiHh/d7h3d4eEJvkchZsA", + placeholder_version: 1, + flags: 0 + } + } + ], + timestamp: "2025-06-08T03:49:08.500000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1381117821190279271", + channel_id: "1099031887500034088", + author: { + id: "771520384671416320", + username: "Bojack Horseman", + avatar: "d14f47194b6ebe4da2e18a56fc6dacfd", + discriminator: "9703", + public_flags: 0, + flags: 0, + bot: true, + banner: null, + accent_color: null, + global_name: null, + avatar_decoration_data: null, + collectibles: null, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false } }, message_update: { From 2c15468c228d2a88b69287e09586e459f8cc5a2c Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 16 Jun 2025 22:50:34 +1200 Subject: [PATCH 11/40] Fix m->d then d->m reactions not merging --- src/d2m/actions/add-reaction.js | 2 +- src/d2m/actions/remove-reaction.js | 4 ++-- src/d2m/converters/emoji-to-key.js | 8 ++++++-- src/d2m/converters/message-to-event.js | 2 +- .../migrations/0023-add-original-encoding-to-reaction.sql | 5 +++++ src/db/orm-defs.d.ts | 1 + src/m2d/actions/add-reaction.js | 2 +- 7 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 src/db/migrations/0023-add-original-encoding-to-reaction.sql diff --git a/src/d2m/actions/add-reaction.js b/src/d2m/actions/add-reaction.js index b131f13..8d86e5f 100644 --- a/src/d2m/actions/add-reaction.js +++ b/src/d2m/actions/add-reaction.js @@ -25,7 +25,7 @@ async function addReaction(data) { if (!parentID) return // Nothing can be done if the parent message was never bridged. assert.equal(typeof parentID, "string") - const key = await emojiToKey.emojiToKey(data.emoji) + const key = await emojiToKey.emojiToKey(data.emoji, data.message_id) const shortcode = key.startsWith("mxc://") ? `:${data.emoji.name}:` : undefined const roomID = await createRoom.ensureRoom(data.channel_id) diff --git a/src/d2m/actions/remove-reaction.js b/src/d2m/actions/remove-reaction.js index 6f922cb..06c4b59 100644 --- a/src/d2m/actions/remove-reaction.js +++ b/src/d2m/actions/remove-reaction.js @@ -43,7 +43,7 @@ async function removeSomeReactions(data) { * @param {Ty.Event.Outer[]} reactions */ async function removeReaction(data, reactions) { - const key = await emojiToKey.emojiToKey(data.emoji) + const key = await emojiToKey.emojiToKey(data.emoji, data.message_id) return converter.removeReaction(data, reactions, key) } @@ -52,7 +52,7 @@ async function removeReaction(data, reactions) { * @param {Ty.Event.Outer[]} reactions */ async function removeEmojiReaction(data, reactions) { - const key = await emojiToKey.emojiToKey(data.emoji) + const key = await emojiToKey.emojiToKey(data.emoji, data.message_id) const discordPreferredEncoding = await emoji.encodeEmoji(key, undefined) db.prepare("DELETE FROM reaction WHERE message_id = ? AND encoded_emoji = ?").run(data.message_id, discordPreferredEncoding) diff --git a/src/d2m/converters/emoji-to-key.js b/src/d2m/converters/emoji-to-key.js index 267664c..54bda18 100644 --- a/src/d2m/converters/emoji-to-key.js +++ b/src/d2m/converters/emoji-to-key.js @@ -8,9 +8,10 @@ const file = sync.require("../../matrix/file") /** * @param {import("discord-api-types/v10").APIEmoji} emoji + * @param {string} message_id * @returns {Promise} */ -async function emojiToKey(emoji) { +async function emojiToKey(emoji, message_id) { let key if (emoji.id) { // Custom emoji @@ -30,7 +31,10 @@ async function emojiToKey(emoji) { // Default emoji const name = emoji.name assert(name) - key = name + // If the reaction was used on Matrix already, it might be using a different arrangement of Variation Selector 16 characters. + // We'll use the same arrangement that was originally used, otherwise a duplicate of the emoji will appear as a separate reaction. + const originalEncoding = select("reaction", "original_encoding", {message_id, encoded_emoji: encodeURIComponent(name)}).pluck().get() + key = originalEncoding || name } return key } diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 104a08c..6928685 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -401,7 +401,7 @@ async function messageToEvent(message, guild, options = {}, di) { const id = match[3] const name = match[2] const animated = !!match[1] - return emojiToKey.emojiToKey({id, name, animated}) // Register the custom emoji if needed + return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed })) async function transformParsedVia(parsed) { diff --git a/src/db/migrations/0023-add-original-encoding-to-reaction.sql b/src/db/migrations/0023-add-original-encoding-to-reaction.sql new file mode 100644 index 0000000..e42e4e1 --- /dev/null +++ b/src/db/migrations/0023-add-original-encoding-to-reaction.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +ALTER TABLE reaction ADD COLUMN original_encoding TEXT; + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index c38dc35..d96ccf2 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -110,6 +110,7 @@ export type Models = { hashed_event_id: number message_id: string encoded_emoji: string + original_encoding: string | null } auto_emoji: { diff --git a/src/m2d/actions/add-reaction.js b/src/m2d/actions/add-reaction.js index 1a9f05c..277c475 100644 --- a/src/m2d/actions/add-reaction.js +++ b/src/m2d/actions/add-reaction.js @@ -38,7 +38,7 @@ async function addReaction(event) { throw e } - db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES (?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding) + db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji, original_encoding) VALUES (?, ?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding, key) } module.exports.addReaction = addReaction From e0c0b7c9c20545d4689114d43388822d3e9f4c75 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 16 Jun 2025 23:10:55 +1200 Subject: [PATCH 12/40] Set up emojis in-process if needed --- scripts/setup.js | 15 +++------------ src/m2d/actions/setup-emojis.js | 26 ++++++++++++++++++++++++++ src/m2d/converters/event-to-message.js | 23 ++++++++++++++++++++--- 3 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 src/m2d/actions/setup-emojis.js diff --git a/scripts/setup.js b/scripts/setup.js index d907c66..6bff293 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -48,6 +48,8 @@ passthrough.select = orm.select let registration = require("../src/matrix/read-registration") let {reg, getTemplateRegistration, writeRegistration, readRegistration, checkRegistration, registrationFilePath} = registration +const {setupEmojis} = require("../src/m2d/actions/setup-emojis") + function die(message) { console.error(message) process.exit(1) @@ -347,18 +349,7 @@ function defineEchoHandler() { console.log("✅ Matrix appservice login works...") // upload the L1 L2 emojis to user emojis - const emojis = await discord.snow.assets.getAppEmojis(client.id) - for (const name of ["L1", "L2"]) { - const existing = emojis.items.find(e => e.name === name) - if (existing) { - db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(existing.name, existing.id) - } else { - const filename = join(__dirname, "../docs/img", `${name}.png`) - const data = fs.readFileSync(filename, null) - const uploaded = await discord.snow.assets.createAppEmoji(client.id, {name, image: "data:image/png;base64," + data.toString("base64")}) - db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(uploaded.name, uploaded.id) - } - } + await setupEmojis() console.log("✅ Emojis are ready...") // set profile data on discord... diff --git a/src/m2d/actions/setup-emojis.js b/src/m2d/actions/setup-emojis.js new file mode 100644 index 0000000..5a55461 --- /dev/null +++ b/src/m2d/actions/setup-emojis.js @@ -0,0 +1,26 @@ +// @ts-check + +const fs = require("fs") +const {join} = require("path") + +const passthrough = require("../../passthrough") + +const {id} = require("../../../addbot") + +async function setupEmojis() { + const {discord, db} = passthrough + const emojis = await discord.snow.assets.getAppEmojis(id) + for (const name of ["L1", "L2"]) { + const existing = emojis.items.find(e => e.name === name) + if (existing) { + db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(existing.name, existing.id) + } else { + const filename = join(__dirname, "../docs/img", `${name}.png`) + const data = fs.readFileSync(filename, null) + const uploaded = await discord.snow.assets.createAppEmoji(id, {name, image: "data:image/png;base64," + data.toString("base64")}) + db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(uploaded.name, uploaded.id) + } + } +} + +module.exports.setupEmojis = setupEmojis diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 4f834b7..1cd4c97 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -19,6 +19,8 @@ const dUtils = sync.require("../../discord/utils") const file = sync.require("../../matrix/file") /** @type {import("./emoji-sheet")} */ const emojiSheet = sync.require("./emoji-sheet") +/** @type {import("../actions/setup-emojis")} */ +const setupEmojis = sync.require("../actions/setup-emojis") /** @type {[RegExp, string][]} */ const markdownEscapes = [ @@ -479,6 +481,23 @@ const attachmentEmojis = new Map([ ["m.file", "📄"] ]) +async function getL1L2ReplyLine(called = false) { + // @ts-ignore + const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all()) + if (autoEmoji.size === 2) { + return `<:L1:${autoEmoji.get("L1")}><:L2:${autoEmoji.get("L2")}>` + } + /* c8 ignore start */ + if (called) { + // Don't know how this could happen, but just making sure we don't enter an infinite loop. + console.warn("Warning: OOYE is missing data to format replies. To fix this: `npm run setup`") + return "" + } + await setupEmojis.setupEmojis() + return getL1L2ReplyLine(true) + /* c8 ignore stop */ +} + /** * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event * @param {import("discord-api-types/v10").APIGuild} guild @@ -628,9 +647,7 @@ async function eventToMessage(event, guild, di) { return } - // @ts-ignore - const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all()) - replyLine = `<:L1:${autoEmoji.get("L1")}><:L2:${autoEmoji.get("L2")}>` + replyLine = await getL1L2ReplyLine() const row = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get() if (row) { replyLine += `https://discord.com/channels/${guild.id}/${row.channel_id}/${row.message_id} ` From d5d51b4e7e959c7972afa4b3d9f9e6b45d8d85fb Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 17 Jun 2025 14:54:34 +1200 Subject: [PATCH 13/40] Don't search for excessively long text --- 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 1cd4c97..e889fc7 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -449,7 +449,7 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { allowedMentionsParse: ["everyone"] } } - } else { + } 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 const results = await di.snow.guild.searchGuildMembers(guild.id, {query: writtenMentionMatch[1]}) if (results[0]) { assert(results[0].user) From 408475dabb83bb367e0590a01a280817a8a73fc6 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 17 Jun 2025 17:18:44 +1200 Subject: [PATCH 14/40] Fix guild emoji upload command --- src/matrix/matrix-command-handler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 7a35e12..93bc312 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -224,7 +224,7 @@ const commands = [{ .png() .toBuffer({resolveWithObject: true}) console.log(`uploading emoji ${resizeOutput.data.length} bytes to :${e.name}:`) - const emoji = await discord.snow.guildAssets.createEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")}) + await discord.snow.assets.createGuildEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")}) } api.sendEvent(event.room_id, "m.room.message", { ...ctx, From 7d83f114bae92d4a992fdb0c5a44936c4e47994e Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 21 Jun 2025 14:45:49 +1200 Subject: [PATCH 15/40] Fix channel links inside lists --- src/d2m/converters/message-to-event.js | 6 ++- src/d2m/converters/message-to-event.test.js | 43 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 6928685..5af7487 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -412,8 +412,10 @@ async function messageToEvent(message, guild, options = {}, di) { node.via = await getViaServersMemo(node.row.room_id) } } - if (Array.isArray(node.content)) { - await transformParsedVia(node.content) + ;for (const maybeChildNodesArray of [node, node.content, node.items]) { + if (Array.isArray(maybeChildNodesArray)) { + await transformParsedVia(maybeChildNodesArray) + } } } return parsed diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index cd7c3fe..89c881e 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1293,3 +1293,46 @@ test("message2event: vc invite event renders embed with room link", async t => { } ]) }) + +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({ + content: "1. Don't be a dick" + + "\n2. Follow rule number 1" + + "\n3. Follow Discord TOS" + + "\n4. Do **not** post NSFW content, shock content, suggestive content" + + "\n5. Please keep <#176333891320283136> professional and helpful, no random off-topic joking" + + "\nThis list will probably change in the future" + }, data.guild.general, {}, { + api: { + getJoinedMembers(roomID) { + called++ + t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + return { + joined: { + "@quadradical:federated.nexus": { + membership: "join", + display_name: "quadradical" + } + } + } + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "1. Don't be a dick" + + "\n2. Follow rule number 1" + + "\n3. Follow Discord TOS" + + "\n4. Do **not** post NSFW content, shock content, suggestive content" + + "\n5. Please keep #wonderland professional and helpful, no random off-topic joking" + + "\nThis list will probably change in the future", + format: "org.matrix.custom.html", + formatted_body: "
  1. Don't be a dick
  2. Follow rule number 1
  3. Follow Discord TOS
  4. Do not post NSFW content, shock content, suggestive content
  5. Please keep #wonderland professional and helpful, no random off-topic joking
This list will probably change in the future", + "m.mentions": {}, + msgtype: "m.text" + } + ]) + t.equal(called, 1) +}) From 4b5fb59d962e508a1cb0da993d1f3159597543bc Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 21 Jun 2025 17:02:57 +1200 Subject: [PATCH 16/40] Fix directory with emoji files --- src/m2d/actions/setup-emojis.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/m2d/actions/setup-emojis.js b/src/m2d/actions/setup-emojis.js index 5a55461..ba2c045 100644 --- a/src/m2d/actions/setup-emojis.js +++ b/src/m2d/actions/setup-emojis.js @@ -15,7 +15,7 @@ async function setupEmojis() { if (existing) { db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(existing.name, existing.id) } else { - const filename = join(__dirname, "../docs/img", `${name}.png`) + const filename = join(__dirname, "../../../docs/img", `${name}.png`) const data = fs.readFileSync(filename, null) const uploaded = await discord.snow.assets.createAppEmoji(id, {name, image: "data:image/png;base64," + data.toString("base64")}) db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(uploaded.name, uploaded.id) From efaa59ca9293a56b57d997d3dc7c5bd7564d07d4 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 21 Jun 2025 22:57:31 +1200 Subject: [PATCH 17/40] Update CloudStorm (requires node 22+!) --- docs/get-started.md | 2 +- package-lock.json | 34 ++++++++++++++++++++++++++++------ package.json | 2 +- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/get-started.md b/docs/get-started.md index e987d9f..ae31f5f 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -11,7 +11,7 @@ You'll need: Follow these steps: -1. [Get Node.js version 20 or later](https://nodejs.org/en/download/prebuilt-installer). If you're on Linux, you may prefer to install through system's package manager, though Debian and Ubuntu have hopelessly out of date packages. +1. [Get Node.js version 22 or later](https://nodejs.org/en/download/prebuilt-installer). If you're on Linux, you may prefer to install through system's package manager, though Debian and Ubuntu have hopelessly out of date packages. 1. Switch to a normal user account. (i.e. do not run any of the following commands as root or sudo.) diff --git a/package-lock.json b/package-lock.json index d6b690b..39d6f42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "ansi-colors": "^4.1.3", "better-sqlite3": "^11.1.2", "chunk-text": "^2.0.1", - "cloudstorm": "^0.12.0", + "cloudstorm": "^0.14.0", "discord-api-types": "^0.37.119", "domino": "^2.1.6", "enquirer": "^2.4.1", @@ -1414,12 +1414,34 @@ } }, "node_modules/cloudstorm": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.12.0.tgz", - "integrity": "sha512-2rxx1hzlSzYc2cssPak6+PCsuHVT3eTcbFr4+Lp30k+YTukGOw8kzdzHU6O7kWDBgs3UiGfbdAaUgZmRytRYgQ==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.14.0.tgz", + "integrity": "sha512-EgjMGxb2Z+L6Acti6DzL/bEbR495AIqPThyW4DaG6Jpvd0ZuM5eC13EiyxV8wlqAME612QO2LjqbhkdXn/327Q==", + "license": "MIT", "dependencies": { - "discord-api-types": "^0.37.119", - "snowtransfer": "^0.13.1" + "discord-api-types": "^0.38.12", + "snowtransfer": "^0.14.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/cloudstorm/node_modules/discord-api-types": { + "version": "0.38.12", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.12.tgz", + "integrity": "sha512-vqkRM50N5Zc6OVckAqtSslbUEoXmpN4bd9xq2jkoK9fgO3KNRIOyMMQ7ipqjwjKuAgzWvU6G8bRIcYWaUe1sCA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/cloudstorm/node_modules/snowtransfer": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.14.2.tgz", + "integrity": "sha512-Fi8OdRmaIgeCj58oVej+tQAoY2I+Xp/6PAYV8X93jE/2E6Anc87SbTbDV6WZXCnuzTQz3gty8JOGz02qI7Qs9A==", + "license": "MIT", + "dependencies": { + "discord-api-types": "^0.38.8" }, "engines": { "node": ">=16.15.0" diff --git a/package.json b/package.json index 3799b87..af0ca1c 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "ansi-colors": "^4.1.3", "better-sqlite3": "^11.1.2", "chunk-text": "^2.0.1", - "cloudstorm": "^0.12.0", + "cloudstorm": "^0.14.0", "discord-api-types": "^0.37.119", "domino": "^2.1.6", "enquirer": "^2.4.1", From 50a047249ba45f0d68caaec8ed1b28ba65078615 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 22 Jun 2025 18:38:20 +1200 Subject: [PATCH 18/40] Check hierarchy instead of m.space.child --- src/d2m/actions/create-space.js | 14 ++-- src/matrix/api.js | 18 +++++ src/web/routes/link.js | 14 ++-- src/web/routes/link.test.js | 120 ++++++++++++++------------------ 4 files changed, 84 insertions(+), 82 deletions(-) diff --git a/src/d2m/actions/create-space.js b/src/d2m/actions/create-space.js index 4ab831a..8bce3ad 100644 --- a/src/d2m/actions/create-space.js +++ b/src/d2m/actions/create-space.js @@ -129,16 +129,10 @@ async function _syncSpace(guild, shouldActuallySync) { // don't try to update rooms with custom avatars though const roomsWithCustomAvatars = select("channel_room", "room_id", {}, "WHERE custom_avatar IS NOT NULL").pluck().all() - const state = await ks.kstateToState(spaceKState) - const childRooms = state.filter(({type, state_key, content}) => { - return type === "m.space.child" && "via" in content && !roomsWithCustomAvatars.includes(state_key) - }).map(({state_key}) => state_key) - - for (const roomID of childRooms) { - const avatarEventContent = await api.getStateEvent(roomID, "m.room.avatar", "") - if (avatarEventContent.url !== newAvatarState.url) { - await api.sendState(roomID, "m.room.avatar", "", newAvatarState) - } + for await (const room of api.generateFullHierarchy(spaceID)) { + if (room.avatar_url === newAvatarState.url) continue + if (roomsWithCustomAvatars.includes(room.room_id)) continue + await api.sendState(room.room_id, "m.room.avatar", "", newAvatarState) } } diff --git a/src/matrix/api.js b/src/matrix/api.js index eb534c0..1e3f607 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -181,6 +181,23 @@ async function getFullHierarchy(roomID) { return rooms } +/** + * Like `getFullHierarchy` but reveals a page at a time through an async iterator. + * @param {string} roomID + */ +async function* generateFullHierarchy(roomID) { + /** @type {string | undefined} */ + let nextBatch = undefined + do { + /** @type {Ty.HierarchyPagination} */ + const res = await getHierarchy(roomID, {from: nextBatch}) + for (const room of res.rooms) { + yield room + } + nextBatch = res.next_batch + } while (nextBatch) +} + /** * @param {string} roomID * @param {string} eventID @@ -442,6 +459,7 @@ module.exports.getJoinedMembers = getJoinedMembers module.exports.getMembers = getMembers module.exports.getHierarchy = getHierarchy module.exports.getFullHierarchy = getFullHierarchy +module.exports.generateFullHierarchy = generateFullHierarchy module.exports.getRelations = getRelations module.exports.getFullRelations = getFullRelations module.exports.sendState = sendState diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 080ffc5..2d0277c 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -134,12 +134,14 @@ as.router.post("/api/link", defineEventHandler(async event => { if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`}) // Check room is part of the guild's space - /** @type {Ty.Event.M_Space_Child?} */ - let spaceChildEvent = null - try { - spaceChildEvent = await api.getStateEvent(spaceID, "m.space.child", parsedBody.matrix) - } catch (e) {} - if (!Array.isArray(spaceChildEvent?.via)) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) + let found = false + for await (const room of api.generateFullHierarchy(spaceID)) { + if (room.room_id === parsedBody.matrix && !room.room_type) { + found = true + break + } + } + if (!found) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) // Check room exists and bridge is joined try { diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index 3c503cf..0d8d366 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -233,13 +233,7 @@ test("web link space: successfully adds entry to database and loads page", async mxid: "@cadence:cadence.moe" }, api: { - async getStateEvent(roomID, type, key) { - return {} - }, - async getMembers(roomID, membership) { - return {chunk: []} - }, - async getFullHierarchy(roomID) { + async getFullHierarchy(spaceID) { return [] } } @@ -344,7 +338,7 @@ test("web link room: checks the autocreate setting if the space doesn't exist ye t.equal(called, 1) }) -test("web link room: check that room is part of space (event missing)", async t => { +test("web link room: check that room is part of space (not in hierarchy)", async t => { let called = 0 const [error] = await tryToCatch(() => router.test("post", "/api/link", { sessionData: { @@ -356,37 +350,9 @@ test("web link room: check that room is part of space (event missing)", async t guild_id: "665289423482519565" }, api: { - async getStateEvent(roomID, type, key) { + async *generateFullHierarchy(spaceID) { called++ - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(type, "m.space.child") - t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "what if I told you there was no such thing as a space"}) - } - } - })) - t.equal(error.data, "Matrix room needs to be part of the bridged space") - t.equal(called, 1) -}) - -test("web link room: check that room is part of space (event empty)", 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 getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(type, "m.space.child") - t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - return {} + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") } } })) @@ -410,12 +376,16 @@ test("web link room: check that bridge can join room", async t => { called++ throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) }, - async getStateEvent(roomID, type, key) { + async *generateFullHierarchy(spaceID) { called++ - t.equal(type, "m.space.child") - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - return {via: ["cadence.moe"]} + 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 */ } } })) @@ -439,17 +409,23 @@ test("web link room: check that bridge has PL 100 in target room (event missing) 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++ - if (type === "m.space.child") { - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - return {via: ["cadence.moe"]} - } else if (type === "m.room.power_levels") { - t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - t.equal(key, "") - throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "what if I told you there's no such thing as power levels"}) - } + t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "what if I told you there's no such thing as power levels"}) } } })) @@ -473,17 +449,23 @@ test("web link room: check that bridge has PL 100 in target room (users default) 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++ - if (type === "m.space.child") { - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - return {via: ["cadence.moe"]} - } else if (type === "m.room.power_levels") { - t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - t.equal(key, "") - return {users_default: 50} - } + t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users_default: 50} } } })) @@ -507,17 +489,23 @@ test("web link room: successfully calls createRoom", async t => { 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) { if (type === "m.room.power_levels") { called++ t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") t.equal(key, "") return {users: {"@_ooye_bot:cadence.moe": 100}} - } else if (type === "m.space.child") { - called++ - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - return {via: ["cadence.moe"]} } else if (type === "m.room.name") { called++ t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") From 639912fee30af595e68dfe2aeb207166a3ff60b4 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 22 Jun 2025 18:51:24 +1200 Subject: [PATCH 19/40] Don't overwrite space parent of self-service rooms --- src/d2m/actions/create-room.js | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 19085c2..605b6d3 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -54,6 +54,7 @@ function convertNameAndTopic(channel, guild, customName) { let channelPrefix = ( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "" : channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] " + : channel.type === DiscordTypes.ChannelType.AnnouncementThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] " : channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] " : "") @@ -176,8 +177,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/"] + // 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() + if (autocreate === 0 && ![DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { + delete channelKState[`m.space.parent/${parentSpaceID}`] + } + return {spaceID: parentSpaceID, privacyLevel, channelKState} } @@ -222,8 +231,8 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { return roomID }) - // Put the newly created child into the space, no need to await this - _syncSpaceMember(channel, spaceID, roomID) + // Put the newly created child into the space + await _syncSpaceMember(channel, spaceID, roomID, guild.id) return roomID } @@ -392,7 +401,7 @@ async function _syncRoom(channelID, shouldActuallySync) { db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID) // sync room as space member - const spaceApply = _syncSpaceMember(channel, spaceID, roomID) + const spaceApply = _syncSpaceMember(channel, spaceID, roomID, guild.id) await Promise.all([roomApply, spaceApply]) return roomID @@ -504,9 +513,17 @@ async function unbridgeDeletedChannel(channel, guildID) { * @param {DiscordTypes.APIGuildTextChannel} channel * @param {string} spaceID * @param {string} roomID + * @param {string} guild_id * @returns {Promise} */ -async function _syncSpaceMember(channel, spaceID, roomID) { +async function _syncSpaceMember(channel, spaceID, roomID, guild_id) { + // If space is self-service then only permit changes to space parenting for threads + // (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}).pluck().get() + if (autocreate === 0 && ![DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { + return [] + } + const spaceKState = await ks.roomToKState(spaceID) let spaceEventContent = {} if ( From 65498e6cd1f35aa11a437028763f3b589c78e082 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 22 Jun 2025 19:04:25 +1200 Subject: [PATCH 20/40] Don't archive threads that are part of a forum --- src/d2m/actions/create-room.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 605b6d3..4c02fd2 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -528,7 +528,10 @@ async function _syncSpaceMember(channel, spaceID, roomID, guild_id) { let spaceEventContent = {} if ( channel.type !== DiscordTypes.ChannelType.PrivateThread // private threads do not belong in the space (don't offer people something they can't join) - && !channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant) + && ( + !channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant) + || discord.channels.get(channel.parent_id || "")?.type === DiscordTypes.ChannelType.GuildForum + ) ) { spaceEventContent = { via: [reg.ooye.server_name] From 10a3185823e2c320b864decffdb7634b5de8769e Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 22 Jun 2025 22:35:33 +1200 Subject: [PATCH 21/40] Give sims enough power to send to read-only rooms --- src/d2m/actions/create-room.js | 5 +- src/d2m/actions/register-user.js | 12 ++++- src/d2m/actions/register-user.test.js | 66 +++++++++++++++++++++++++-- test/data.js | 34 ++++++++++++-- 4 files changed, 107 insertions(+), 10 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 4c02fd2..ff5782d 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -40,6 +40,8 @@ const PRIVACY_ENUMS = { const DEFAULT_PRIVACY_LEVEL = 0 +const READ_ONLY_ROOM_EVENTS_DEFAULT_POWER = 50 + /** @type {Map>} channel ID -> Promise */ const inflightRoomCreate = new Map() @@ -146,7 +148,7 @@ async function channelToKState(channel, guild, di) { "m.room.join_rules/": join_rules, /** @type {Ty.Event.M_Power_Levels} */ "m.room.power_levels/": { - events_default: everyoneCanSend ? 0 : 50, + events_default: everyoneCanSend ? 0 : READ_ONLY_ROOM_EVENTS_DEFAULT_POWER, events: { "m.reaction": 0, "m.room.redaction": 0 // only affects redactions of own events, required to be able to un-react @@ -557,6 +559,7 @@ async function createAllForGuild(guildID) { } module.exports.DEFAULT_PRIVACY_LEVEL = DEFAULT_PRIVACY_LEVEL +module.exports.READ_ONLY_ROOM_EVENTS_DEFAULT_POWER = READ_ONLY_ROOM_EVENTS_DEFAULT_POWER module.exports.PRIVACY_ENUMS = PRIVACY_ENUMS module.exports.createRoom = createRoom module.exports.ensureRoom = ensureRoom diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js index d231e0f..90528ac 100644 --- a/src/d2m/actions/register-user.js +++ b/src/d2m/actions/register-user.js @@ -15,6 +15,8 @@ const file = sync.require("../../matrix/file") const utils = sync.require("../../discord/utils") /** @type {import("../converters/user-to-mxid")} */ const userToMxid = sync.require("../converters/user-to-mxid") +/** @type {import("./create-room")} */ +const createRoom = sync.require("./create-room") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore let hasher = null // @ts-ignore @@ -139,6 +141,7 @@ function memberToPowerLevel(user, member, guild, channel) { if (!member) return 0 const permissions = utils.getPermissions(member.roles, guild.roles, user.id, channel.permission_overwrites) + const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) /* * PL 100 = Administrator = People who can brick the room. RATIONALE: * - Administrator. @@ -158,8 +161,14 @@ function memberToPowerLevel(user, member, guild, channel) { * - Moderate Members. */ if (utils.hasSomePermissions(permissions, ["ManageMessages", "ManageNicknames", "ManageThreads", "KickMembers", "BanMembers", "MuteMembers", "DeafenMembers", "ModerateMembers"])) return 50 + /* PL 50 = if room is read-only but the user has been specially allowed to send messages */ + const everyoneCanSend = utils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages) + const userCanSend = utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages) + if (!everyoneCanSend && userCanSend) return createRoom.READ_ONLY_ROOM_EVENTS_DEFAULT_POWER /* PL 20 = Mention Everyone for technical reasons. */ - if (utils.hasSomePermissions(permissions, ["MentionEveryone"])) return 20 + const everyoneCanMentionEveryone = utils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone) + const userCanMentionEveryone = utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.MentionEveryone) + if (!everyoneCanMentionEveryone && userCanMentionEveryone) return 20 return 0 } @@ -250,3 +259,4 @@ module.exports.ensureSim = ensureSim module.exports.ensureSimJoined = ensureSimJoined module.exports.syncUser = syncUser module.exports.syncAllUsersInRoom = syncAllUsersInRoom +module.exports._memberToPowerLevel = memberToPowerLevel diff --git a/src/d2m/actions/register-user.test.js b/src/d2m/actions/register-user.test.js index 353c89f..f1934cf 100644 --- a/src/d2m/actions/register-user.test.js +++ b/src/d2m/actions/register-user.test.js @@ -1,10 +1,12 @@ -const {_memberToStateContent} = require("./register-user") +const {_memberToStateContent, _memberToPowerLevel} = require("./register-user") const {test} = require("supertape") -const testData = require("../../../test/data") +const data = require("../../../test/data") +const mixin = require("@cloudrac3r/mixin-deep") +const DiscordTypes = require("discord-api-types/v10") test("member2state: without member nick or avatar", async t => { t.deepEqual( - await _memberToStateContent(testData.member.kumaccino.user, testData.member.kumaccino, testData.guild.general.id), + await _memberToStateContent(data.member.kumaccino.user, data.member.kumaccino, data.guild.general.id), { avatar_url: "mxc://cadence.moe/UpAeIqeclhKfeiZNdIWNcXXL", displayname: "kumaccino", @@ -24,7 +26,7 @@ test("member2state: without member nick or avatar", async t => { test("member2state: with global name, without member nick or avatar", async t => { t.deepEqual( - await _memberToStateContent(testData.member.papiophidian.user, testData.member.papiophidian, testData.guild.general.id), + await _memberToStateContent(data.member.papiophidian.user, data.member.papiophidian, data.guild.general.id), { avatar_url: "mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX", displayname: "PapiOphidian", @@ -44,7 +46,7 @@ test("member2state: with global name, without member nick or avatar", async t => test("member2state: with member nick and avatar", async t => { t.deepEqual( - await _memberToStateContent(testData.member.sheep.user, testData.member.sheep, testData.guild.general.id), + await _memberToStateContent(data.member.sheep.user, data.member.sheep, data.guild.general.id), { avatar_url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl", displayname: "The Expert's Submarine", @@ -61,3 +63,57 @@ test("member2state: with member nick and avatar", async t => { } ) }) + +test("member2power: default to zero if member roles unknown", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, null, data.guild.data_horde, data.channel.saving_the_world) + t.equal(power, 0) +}) + +test("member2power: unremarkable = 0", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: [] + }, data.guild.data_horde, data.channel.general) + t.equal(power, 0) +}) + +test("member2power: can mention everyone = 20", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: ["684524730274807911"] + }, data.guild.data_horde, data.channel.general) + t.equal(power, 20) +}) + +test("member2power: can send messages in protected channel due to role = 50", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: ["684524730274807911"] + }, data.guild.data_horde, data.channel.saving_the_world) + t.equal(power, 50) +}) + +test("member2power: can send messages in protected channel due to user override = 50", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: [] + }, data.guild.data_horde, mixin({}, data.channel.saving_the_world, { + permission_overwrites: data.channel.saving_the_world.permission_overwrites.concat({ + type: DiscordTypes.OverwriteType.member, + id: data.user.clyde_ai.id, + allow: String(DiscordTypes.PermissionFlagsBits.SendMessages), + deny: "0" + }) + })) + t.equal(power, 50) +}) + +test("member2power: can kick users = 50", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: ["682789592390281245"] + }, data.guild.data_horde, data.channel.general) + t.equal(power, 50) +}) + +test("member2power: can manage channels = 100", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: ["665290147377578005"] + }, data.guild.data_horde, data.channel.saving_the_world) + t.equal(power, 100) +}) diff --git a/test/data.js b/test/data.js index fba0587..aba31d3 100644 --- a/test/data.js +++ b/test/data.js @@ -37,18 +37,31 @@ module.exports = { id: "1161864271370666075", guild_id: "112760669178241024" }, + /** @type {DiscordTypes.APITextChannel} */ saving_the_world: { type: 0, topic: "Anything and everything archiving/preservation related", rate_limit_per_user: 0, position: 0, - permission_overwrites: [], + permission_overwrites: [ + { + id: "665289423482519565", + type: DiscordTypes.OverwriteType.Role, + allow: "0", + deny: String(DiscordTypes.PermissionFlagsBits.SendMessages) + }, + { + id: "684524730274807911", + type: DiscordTypes.OverwriteType.Role, + allow: String(DiscordTypes.PermissionFlagsBits.SendMessages), + deny: "0" + } + ], parent_id: null, name: "saving-the-world", last_pin_timestamp: "2021-04-14T18:39:41+00:00", last_message_id: "1335828749479837750", id: "665310973967597573", - flags: 0, guild_id: "665289423482519565" } }, @@ -349,7 +362,7 @@ module.exports = { unicode_emoji: null, tags: {}, position: 0, - permissions: "2221982107557441", + permissions: "968619318849", name: "@everyone", mentionable: false, managed: false, @@ -374,6 +387,21 @@ module.exports = { flags: 0, color: 1752220 }, + { + version: 1683791258594, + unicode_emoji: null, + tags: {}, + position: 22, + permissions: "8194", + name: "Moderator", + mentionable: true, + managed: false, + id: "682789592390281245", + icon: null, + hoist: false, + flags: 0, + color: 1752220 + }, { version: 1683791258580, unicode_emoji: null, From baf024af84513abaee4e04b49ed6b3d9c150740d Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 23 Jun 2025 10:09:34 +1200 Subject: [PATCH 22/40] Fix invalid power level state changes --- src/d2m/actions/register-user.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js index 90528ac..a545b6d 100644 --- a/src/d2m/actions/register-user.js +++ b/src/d2m/actions/register-user.js @@ -3,6 +3,7 @@ const assert = require("assert").strict const {reg} = require("../../matrix/read-registration") const DiscordTypes = require("discord-api-types/v10") +const Ty = require("../../types") const mixin = require("@cloudrac3r/mixin-deep") const passthrough = require("../../passthrough") @@ -210,11 +211,13 @@ async function syncUser(user, member, channel, guild, roomID) { // Update room member state await api.sendState(roomID, "m.room.member", mxid, content, mxid) // Update power levels (only if we can actually access the member roles) + /** @type {Ty.Event.M_Power_Levels} */ const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "") - const oldPowerLevel = powerLevelsStateContent.users?.[mxid] || 0 + const oldPowerLevel = powerLevelsStateContent.users?.[mxid] || powerLevelsStateContent.events_default || 0 mixin(powerLevelsStateContent, {users: {[mxid]: powerLevel}}) - if (powerLevel === 0) delete powerLevelsStateContent.users[mxid] // keep the event compact - const sendPowerLevelAs = powerLevel < oldPowerLevel ? mxid : undefined // bridge bot won't not have permission to demote equal power users, so do this action as themselves + if (powerLevel === powerLevelsStateContent.events_default || 0) delete powerLevelsStateContent.users?.[mxid] // keep the event compact + const botPowerLevel = powerLevelsStateContent.users?.[`@${reg.sender_localpart}:${reg.ooye.server_name}`] || 100 + const sendPowerLevelAs = oldPowerLevel === botPowerLevel ? mxid : undefined // bridge bot can't demote equal power users, so do this action as themselves await api.sendState(roomID, "m.room.power_levels", "", powerLevelsStateContent, sendPowerLevelAs) // Update cached hash db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) From 9a33ba3ed2d4bdfc88d31a9199f03bc2405bd0d1 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 21 Jul 2025 12:46:51 +1200 Subject: [PATCH 23/40] Fix evil encrypted file event with null url --- src/m2d/converters/event-to-message.js | 10 +-- src/m2d/converters/event-to-message.test.js | 85 +++++++++++++++++++++ test/ooye-test-data.sql | 3 +- 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index e889fc7..3cf08cf 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -539,15 +539,15 @@ async function eventToMessage(event, guild, di) { if (event.type === "m.room.message" && (event.content.msgtype === "m.file" || event.content.msgtype === "m.video" || event.content.msgtype === "m.audio" || event.content.msgtype === "m.image")) { content = "" const filename = event.content.filename || event.content.body - if ("url" in event.content) { - // Unencrypted - attachments.push({id: "0", filename}) - pendingFiles.push({name: filename, mxc: event.content.url}) - } else { + if ("file" in event.content) { // Encrypted assert.equal(event.content.file.key.alg, "A256CTR") attachments.push({id: "0", filename}) pendingFiles.push({name: filename, mxc: event.content.file.url, key: event.content.file.key.k, iv: event.content.file.iv}) + } else { + // Unencrypted + attachments.push({id: "0", filename}) + pendingFiles.push({name: filename, mxc: event.content.url}) } // Check if we also need to process a text event for this image - if it has a caption that's different from its filename if ((event.content.body && event.content.filename && event.content.body !== event.content.filename) || event.content.formatted_body) { diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 70853aa..3d1c918 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -3956,6 +3956,91 @@ test("event2message: encrypted image attachments work", async t => { ) }) +test("event2message: evil encrypted image attachment works", async t => { + t.deepEqual( + await eventToMessage({ + sender: "@austin:tchncs.de", + type: "m.room.message", + content: { + body: "Screenshot 2025-06-29 at 13.36.46.png", + file: { + hashes: { + sha256: "Vh1apd8wSFu/BpUdQbIrKUzFB0Uu+l1octgZL+aVGTQ" + }, + iv: "sd33K7pSZNMAAAAAAAAAAA", + key: { + alg: "A256CTR", + ext: true, + k: "-nyqk1eqI-g-ND59P9qHp310-Qyc2A5gSAYm1BxopSg", + key_ops: [ + "encrypt", + "decrypt" + ], + kty: "oct" + }, + url: "mxc://tchncs.de/eac5f83fa97cd74062daf75dfa04d6e5356897281939377544214085632", + v: "v2" + }, + info: { + h: 682, + mimetype: "image/png", + "org.matrix.msc4230.is_animated": false, + size: 1813154, + thumbnail_file: { + hashes: { + sha256: "o3xykQwfsTUf5Y8qP5fjT7qBv5lAT3rtkmPpise5eQw" + }, + iv: "SNxIZsJkju4AAAAAAAAAAA", + key: { + alg: "A256CTR", + ext: true, + k: "CcibYjzzSDexOWBbcBh_kCDiLibg8vUZthz5CnxV0es", + key_ops: [ + "encrypt", + "decrypt" + ], + kty: "oct" + }, + url: "mxc://tchncs.de/ecd811d913ed1b240ebfc81517a5de2c3a1e9d401939377537079574528", + v: "v2" + }, + thumbnail_info: { + h: 600, + mimetype: "image/png", + size: 451773, + w: 507 + }, + thumbnail_url: null, + w: 577, + "xyz.amorgan.blurhash": "TqN1Ais=t1~qRjWFxURiWCM{ofof" + }, + "m.mentions": {}, + msgtype: "m.image", + url: null + }, + event_id: "$UKMbzTlqlyLYN78utVEtiivABFvOe39nx5trHwqNmeQ", + room_id: "!iSyXgNxQcEuXoXpsSn:pussthecat.org" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "Austin Huang", + content: "", + avatar_url: "https://bridge.example.org/download/matrix/tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e", + attachments: [{id: "0", filename: "Screenshot 2025-06-29 at 13.36.46.png"}], + pendingFiles: [{ + name: "Screenshot 2025-06-29 at 13.36.46.png", + mxc: "mxc://tchncs.de/eac5f83fa97cd74062daf75dfa04d6e5356897281939377544214085632", + key: "-nyqk1eqI-g-ND59P9qHp310-Qyc2A5gSAYm1BxopSg", + iv: "sd33K7pSZNMAAAAAAAAAAA" + }] + }] + } + ) +}) + test("event2message: stickers work", async t => { t.deepEqual( await eventToMessage({ diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 2b66486..4acff5e 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -160,7 +160,8 @@ INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) V ('!TqlyQmifxGUggEmdBN:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL, 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@ampflower:matrix.org', 'Ampflower 🌺', 'mxc://cadence.moe/PRfhXYBTOalvgQYtmCLeUXko', 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@aflower:syndicated.gay', 'Rose', 'mxc://syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP', 0), -('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0); +('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0), +('!iSyXgNxQcEuXoXpsSn:pussthecat.org', '@austin:tchncs.de', 'Austin Huang', 'mxc://tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e', 0); INSERT INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES (5162930312280790092, '1141501302736695317', '%F0%9F%90%88'); From cf39737b5a7de3df774f66b29b69c09e64e87ded Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 4 Aug 2025 18:09:39 +1200 Subject: [PATCH 24/40] Move to util --- src/d2m/converters/message-to-event.js | 14 +++----------- src/discord/utils.js | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 5af7487..527ba1d 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -491,19 +491,11 @@ async function messageToEvent(message, guild, options = {}, di) { } else { // repliedToUnknownEvent // This reply can't point to the Matrix event because it isn't bridged, we need to indicate this. assert(message.referenced_message) - const dateDifference = new Date(message.timestamp).getTime() - new Date(message.referenced_message.timestamp).getTime() - const oneHour = 60 * 60 * 1000 - if (dateDifference < oneHour) { - var dateDisplay = "n" - } else if (dateDifference < 25 * oneHour) { - var dateDisplay = ` ${Math.floor(dateDifference / oneHour)}-hour-old` - } else { - var dateDisplay = ` ${Math.round(dateDifference / (24 * oneHour))}-day-old` - } - html = `
In reply to a${dateDisplay} unbridged message from ${repliedToDisplayName}:` + const dateDisplay = dUtils.howOldUnbridgedMessage(message.referenced_message.timestamp, message.timestamp) + html = `
In reply to ${dateDisplay} from ${repliedToDisplayName}:` + `
${repliedToHtml}
` + html - body = (`In reply to a${dateDisplay} unbridged message:\n${repliedToDisplayName}: ` + body = (`In reply to ${dateDisplay}:\n${repliedToDisplayName}: ` + repliedToBody).split("\n").map(line => "> " + line).join("\n") + "\n\n" + body } diff --git a/src/discord/utils.js b/src/discord/utils.js index dea05ae..963f0b8 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -136,6 +136,24 @@ function getPublicUrlForCdn(url) { return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}` } +/** + * @param {string} oldTimestamp + * @param {string} newTimestamp + * @returns {string} "a x-day-old unbridged message" + */ +function howOldUnbridgedMessage(oldTimestamp, newTimestamp) { + const dateDifference = new Date(newTimestamp).getTime() - new Date(oldTimestamp).getTime() + const oneHour = 60 * 60 * 1000 + if (dateDifference < oneHour) { + return "an unbridged message" + } else if (dateDifference < 25 * oneHour) { + var dateDisplay = `a ${Math.floor(dateDifference / oneHour)}-hour-old unbridged message` + } else { + var dateDisplay = `a ${Math.round(dateDifference / (24 * oneHour))}-day-old unbridged message` + } + return dateDisplay +} + module.exports.getPermissions = getPermissions module.exports.hasPermission = hasPermission module.exports.hasSomePermissions = hasSomePermissions @@ -145,3 +163,4 @@ module.exports.isEphemeralMessage = isEphemeralMessage module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact module.exports.getPublicUrlForCdn = getPublicUrlForCdn +module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage From 2614493646a846de72de92a7455df87330dac6ea Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 4 Aug 2025 18:10:08 +1200 Subject: [PATCH 25/40] Look harder for username data --- src/d2m/converters/message-to-event.js | 1 + src/d2m/converters/message-to-event.test.js | 37 +++++++++ test/data.js | 89 +++++++++++++++++++++ test/ooye-test-data.sql | 6 +- 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 527ba1d..a8e5a6b 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -34,6 +34,7 @@ function getDiscordParseCallbacks(message, guild, useHTML) { const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get() const interaction = message.interaction_metadata || message.interaction 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) || node.id if (mxid && useHTML) { diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 89c881e..fc933e3 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -532,6 +532,43 @@ test("message2event: simple reply to matrix user, reply fallbacks disabled", asy }]) }) +test("message2event: reply to matrix user with mention", async t => { + const events = await messageToEvent(data.message.reply_to_matrix_user_mention, data.guild.general, {}, { + api: { + getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk", { + type: "m.room.message", + content: { + msgtype: "m.text", + body: "@_ooye_extremity:cadence.moe you owe me $30", + format: "org.matrix.custom.html", + formatted_body: "@_ooye_extremity:cadence.moe you owe me $30" + }, + sender: "@cadence:cadence.moe" + }) + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.relates_to": { + "m.in_reply_to": { + event_id: "$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk" + } + }, + "m.mentions": { + user_ids: [ + "@cadence:cadence.moe" + ] + }, + msgtype: "m.text", + body: "> okay 🤍 yay 🤍: @extremity: you owe me $30\n\nkys", + format: "org.matrix.custom.html", + formatted_body: + '
In reply to okay 🤍 yay 🤍' + + '
@extremity you owe me $30
' + + 'kys' + }]) +}) + test("message2event: reply with a video", async t => { const events = await messageToEvent(data.message.reply_with_video, data.guild.general, { api: { diff --git a/test/data.js b/test/data.js index aba31d3..f460f88 100644 --- a/test/data.js +++ b/test/data.js @@ -1691,6 +1691,95 @@ module.exports = { attachments: [], guild_id: "112760669178241024" }, + reply_to_matrix_user_mention: { + type: 19, + content: "kys", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2025-08-04T05:31:26.506000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1401799674192723998", + channel_id: "112760669178241024", + author: { + id: "114147806469554185", + username: "extremity", + avatar: "e0394d500407a8fa93774e1835b8b03a", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "Extremity", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + message_reference: { + type: 0, + channel_id: "112760669178241024", + message_id: "1401760355339862066", + guild_id: "112760669178241024" + }, + referenced_message: { + type: 0, + content: "<@114147806469554185> you owe me $30", + mentions: [ + { + id: "114147806469554185", + username: "extremity", + avatar: "e0394d500407a8fa93774e1835b8b03a", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "Extremity", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: null, + primary_guild: null + } + ], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2025-08-04T02:55:12.161000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1401760355339862066", + channel_id: "112760669178241024", + author: { + id: "1152700216189911081", + username: "okay 🤍 yay 🤍", + avatar: "90bc1d6912252d4fa9f92a2f5f6d347b", + discriminator: "0000", + public_flags: 0, + flags: 0, + bot: true, + global_name: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + application_id: "684280192553844747", + webhook_id: "1152700216189911081" + } + }, reply_with_video: { id: "1197621094983676007", type: 19, diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 4acff5e..faca448 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -72,7 +72,8 @@ INSERT INTO message_channel (message_id, channel_id) VALUES ('1191567971970191490', '176333891320283136'), ('1144874214311067708', '687028734322147344'), ('1339000288144658482', '176333891320283136'), -('1381212840957972480', '112760669178241024'); +('1381212840957972480', '112760669178241024'), +('1401760355339862066', '112760669178241024'); 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), @@ -115,7 +116,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part ('$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE', 'm.room.message', 'm.text', '1191567971970191490', 0, 0, 1), ('$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk', 'm.room.message', 'm.text', '1339000288144658482', 0, 0, 0), ('$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI', 'm.room.message', 'm.text', '1381212840957972480', 0, 1, 1), -('$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM', 'm.room.message', 'm.image', '1381212840957972480', 1, 0, 1); +('$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM', 'm.room.message', 'm.image', '1381212840957972480', 1, 0, 1), +('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0); INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'), From e306b957641252717f60365f5b84d965c214044a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 4 Aug 2025 23:27:56 +1200 Subject: [PATCH 26/40] Add test case for something that was irking me --- src/d2m/actions/register-user.js | 9 +- src/d2m/actions/register-user.test.js | 7 + src/matrix/api.js | 26 +- test/data.js | 642 ++++++++++++++++++++++++++ 4 files changed, 667 insertions(+), 17 deletions(-) diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js index a545b6d..530ce6d 100644 --- a/src/d2m/actions/register-user.js +++ b/src/d2m/actions/register-user.js @@ -211,14 +211,7 @@ async function syncUser(user, member, channel, guild, roomID) { // Update room member state await api.sendState(roomID, "m.room.member", mxid, content, mxid) // Update power levels (only if we can actually access the member roles) - /** @type {Ty.Event.M_Power_Levels} */ - const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "") - const oldPowerLevel = powerLevelsStateContent.users?.[mxid] || powerLevelsStateContent.events_default || 0 - mixin(powerLevelsStateContent, {users: {[mxid]: powerLevel}}) - if (powerLevel === powerLevelsStateContent.events_default || 0) delete powerLevelsStateContent.users?.[mxid] // keep the event compact - const botPowerLevel = powerLevelsStateContent.users?.[`@${reg.sender_localpart}:${reg.ooye.server_name}`] || 100 - const sendPowerLevelAs = oldPowerLevel === botPowerLevel ? mxid : undefined // bridge bot can't demote equal power users, so do this action as themselves - await api.sendState(roomID, "m.room.power_levels", "", powerLevelsStateContent, sendPowerLevelAs) + await api.setUserPower(roomID, mxid, powerLevel) // Update cached hash db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) } diff --git a/src/d2m/actions/register-user.test.js b/src/d2m/actions/register-user.test.js index f1934cf..13971b3 100644 --- a/src/d2m/actions/register-user.test.js +++ b/src/d2m/actions/register-user.test.js @@ -117,3 +117,10 @@ test("member2power: can manage channels = 100", async t => { }, data.guild.data_horde, data.channel.saving_the_world) t.equal(power, 100) }) + +test("member2power: pathfinder use case", async t => { + const power = _memberToPowerLevel(data.user.jerassicore, { + roles: ["1235396773510647810", "1359752622130593802", "1249165855632265267", "1380768596929806356", "1380756348190462015"] + }, data.guild.pathfinder, data.channel.character_art) + t.equal(power, 50) +}) diff --git a/src/matrix/api.js b/src/matrix/api.js index 1e3f607..e913d0d 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -308,21 +308,29 @@ async function profileSetAvatarUrl(mxid, avatar_url) { * Set a user's power level within a room. * @param {string} roomID * @param {string} mxid - * @param {number} power + * @param {number} newPower */ -async function setUserPower(roomID, mxid, power) { +async function setUserPower(roomID, mxid, newPower) { assert(roomID[0] === "!") assert(mxid[0] === "@") // Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352 - const powerLevels = await getStateEvent(roomID, "m.room.power_levels", "") - powerLevels.users = powerLevels.users || {} - if (power != null) { - powerLevels.users[mxid] = power + const power = await getStateEvent(roomID, "m.room.power_levels", "") + power.users = power.users || {} + + // Bridge bot can't demote equal power users, so need to decide which user will send the event + const oldPowerLevel = power.users?.[mxid] || power.events_default || 0 + const botPowerLevel = power.users?.[`@${reg.sender_localpart}:${reg.ooye.server_name}`] || 100 + const eventSender = oldPowerLevel >= botPowerLevel ? mxid : undefined + + // Update the event content + if (newPower == null || newPower === (power.events_default || 0)) { + delete power.users[mxid] } else { - delete powerLevels.users[mxid] + power.users[mxid] = newPower } - await sendState(roomID, "m.room.power_levels", "", powerLevels) - return powerLevels + + await sendState(roomID, "m.room.power_levels", "", power, eventSender) + return power } /** diff --git a/test/data.js b/test/data.js index f460f88..a8ff8a8 100644 --- a/test/data.js +++ b/test/data.js @@ -63,6 +63,40 @@ module.exports = { last_message_id: "1335828749479837750", id: "665310973967597573", guild_id: "665289423482519565" + }, + character_art: { + version: 1749274266694, + type: 0, + topic: null, + rate_limit_per_user: 0, + position: 22, + permission_overwrites: [ + { + type: 0, + id: "1235396773510647810", + deny: "0", + allow: "3072" + }, + { + type: 0, + id: "1236581109391949875", + deny: "0", + allow: "0" + }, + { + type: 0, + id: "1234728422044074064", + deny: "3072", + allow: "309237645312" + } + ], + parent_id: "1234730744291528714", + nsfw: false, + name: "character-art", + last_message_id: "1384358176106872924", + id: "1235072132095021096", + flags: 0, + guild_id: "1234728422044074064" } }, room: { @@ -442,6 +476,601 @@ module.exports = { version: 1717720047590, emojis: [], presences: [] + }, + pathfinder: { + activity_instances: [], + max_video_channel_users: 25, + mfa_level: 0, + owner_id: "182266888003256320", + stage_instances: [], + profile: null, + rules_channel_id: null, + splash: null, + inventory_settings: null, + max_members: 25000000, + icon: "ec42ae174a7c246568da98983b611f64", + safety_alerts_channel_id: null, + latest_onboarding_question_id: null, + id: "1234728422044074064", + name: "Hub Pathfinder", + embedded_activities: [], + banner: null, + hub_type: null, + threads: [], + lazy: true, + system_channel_id: "1234728422475829318", + member_count: 21, + region: "deprecated", + description: null, + premium_features: null, + verification_level: 0, + unavailable: false, + stickers: [], + application_command_counts: {}, + roles: [ + { + version: 1741255049095, + unicode_emoji: null, + tags: {}, + position: 0, + permissions: "2173706675146305", + name: "@everyone", + mentionable: false, + managed: false, + id: "1234728422044074064", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325117, + unicode_emoji: null, + tags: { bot_id: "684280192553844747" }, + position: 8, + permissions: "1610883072", + name: "Matrix Bridge", + mentionable: false, + managed: true, + id: "1235117664326783049", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325132, + unicode_emoji: null, + tags: {}, + position: 12, + permissions: "0", + name: "Tuesday", + mentionable: false, + managed: false, + id: "1235396773510647810", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325129, + unicode_emoji: null, + tags: {}, + position: 11, + permissions: "0", + name: "Thursday", + mentionable: false, + managed: false, + id: "1235397020919926844", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325174, + unicode_emoji: null, + tags: {}, + position: 20, + permissions: "0", + name: "Fighter", + mentionable: false, + managed: false, + id: "1236579627615518720", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 12657443 + }, + color: 12657443 + }, + { + version: 1749271325189, + unicode_emoji: null, + tags: {}, + position: 24, + permissions: "0", + name: "Bard", + mentionable: false, + managed: false, + id: "1236579780544036904", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 12468701 + }, + color: 12468701 + }, + { + version: 1749271325179, + unicode_emoji: null, + tags: {}, + position: 22, + permissions: "0", + name: "Cleric", + mentionable: false, + managed: false, + id: "1236579861997555763", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 14186005 + }, + color: 14186005 + }, + { + version: 1749271325138, + unicode_emoji: null, + tags: {}, + position: 14, + permissions: "0", + name: "Wizard", + mentionable: false, + managed: false, + id: "1236579900731822110", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 3106806 + }, + color: 3106806 + }, + { + version: 1749271325176, + unicode_emoji: null, + tags: {}, + position: 21, + permissions: "0", + name: "Druid", + mentionable: false, + managed: false, + id: "1236579988254232606", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 8248698 + }, + color: 8248698 + }, + { + version: 1749271325147, + unicode_emoji: null, + tags: {}, + position: 15, + permissions: "0", + name: "Witch", + mentionable: false, + managed: false, + id: "1236580304232255581", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 1737848 + }, + color: 1737848 + }, + { + version: 1749271325206, + unicode_emoji: null, + tags: {}, + position: 28, + permissions: "8", + name: "DM", + mentionable: false, + managed: false, + id: "1236581109391949875", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 6507441 + }, + color: 6507441 + }, + { + version: 1749271325156, + unicode_emoji: null, + tags: {}, + position: 17, + permissions: "0", + name: "Ranger", + mentionable: false, + managed: false, + id: "1240571725914312825", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 2067276 + }, + color: 2067276 + }, + { + version: 1749271325151, + unicode_emoji: null, + tags: {}, + position: 16, + permissions: "0", + name: "Rogue", + mentionable: false, + managed: false, + id: "1249165855632265267", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 9936031 + }, + color: 9936031 + }, + { + version: 1749271325123, + unicode_emoji: null, + tags: {}, + position: 10, + permissions: "0", + name: "Questions Ping!", + mentionable: false, + managed: false, + id: "1249167820571541534", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 13297400 + }, + color: 13297400 + }, + { + version: 1749271325198, + unicode_emoji: null, + tags: {}, + position: 25, + permissions: "0", + name: "Barbarian", + mentionable: false, + managed: false, + id: "1344484288241991730", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 8145454 + }, + color: 8145454 + }, + { + version: 1749271325200, + unicode_emoji: null, + tags: {}, + position: 26, + permissions: "0", + name: "Alchemist", + mentionable: false, + managed: false, + id: "1352190431944900628", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 15844367 + }, + color: 15844367 + }, + { + version: 1749271325168, + unicode_emoji: null, + tags: {}, + position: 19, + permissions: "0", + name: "Investigator", + mentionable: false, + managed: false, + id: "1353890353391866028", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 10068223 + }, + color: 10068223 + }, + { + version: 1749271325134, + unicode_emoji: null, + tags: {}, + position: 13, + permissions: "0", + name: "Monday", + mentionable: false, + managed: false, + id: "1359752622130593802", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325162, + unicode_emoji: null, + tags: {}, + position: 18, + permissions: "0", + name: "Monk", + mentionable: false, + managed: false, + id: "1359753361963880590", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 3447003 + }, + color: 3447003 + }, + { + version: 1749271325183, + unicode_emoji: null, + tags: {}, + position: 23, + permissions: "0", + name: "Champion", + mentionable: false, + managed: false, + id: "1359753472186122320", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 15277667 + }, + color: 15277667 + }, + { + version: 1749271325114, + unicode_emoji: null, + tags: { bot_id: "431544605209788416" }, + position: 7, + permissions: "275415166016", + name: "Tupperbox", + mentionable: false, + managed: true, + id: "1377128320814153862", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325120, + unicode_emoji: null, + tags: {}, + position: 9, + permissions: "0", + name: "PbD ping", + mentionable: false, + managed: false, + id: "1377139953510907995", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325109, + unicode_emoji: null, + tags: { bot_id: "644942473315090434" }, + position: 6, + permissions: "535529122897", + name: "RPG Sage", + mentionable: false, + managed: true, + id: "1377144599310503959", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325106, + unicode_emoji: null, + tags: { bot_id: "572698679618568193" }, + position: 5, + permissions: "278528", + name: "Dicecord", + mentionable: false, + managed: true, + id: "1378726921990307974", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325203, + unicode_emoji: null, + tags: { bot_id: "443545183997657120" }, + position: 27, + permissions: "2097540216", + name: "ChannelBot", + mentionable: false, + managed: true, + id: "1380744875108204658", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325101, + unicode_emoji: null, + tags: {}, + position: 4, + permissions: "0", + name: "Play-by-Discord", + mentionable: false, + managed: false, + id: "1380748596537720872", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 16377559 + }, + color: 16377559 + }, + { + version: 1749271325098, + unicode_emoji: null, + tags: {}, + position: 3, + permissions: "0", + name: "Boredom Busters", + mentionable: false, + managed: false, + id: "1380756348190462015", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 14542591 + }, + color: 14542591 + }, + { + version: 1749271361998, + unicode_emoji: null, + tags: {}, + position: 1, + permissions: "0", + name: "Bots", + mentionable: false, + managed: false, + id: "1380767647578460311", + icon: null, + hoist: true, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271362001, + unicode_emoji: null, + tags: {}, + position: 2, + permissions: "0", + name: "Players", + mentionable: false, + managed: false, + id: "1380768596929806356", + icon: null, + hoist: true, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + } + ], + vanity_url_code: null, + afk_timeout: 300, + premium_tier: 0, + joined_at: "2024-05-01T06:36:38.605000+00:00", + public_updates_channel_id: null, + premium_subscription_count: 0, + soundboard_sounds: [], + home_header: null, + discovery_splash: null, + guild_scheduled_events: [], + system_channel_flags: 0, + preferred_locale: "en-US", + large: false, + explicit_content_filter: 0, + moderator_reporting: null, + features: [ + "TIERLESS_BOOSTING_SYSTEM_MESSAGE", + "ACTIVITY_FEED_DISABLED_BY_USER" + ], + version: 1750145431881, + owner_configured_content_level: null, + voice_states: [], + default_message_notifications: 1, + application_id: null, + incidents_data: null, + nsfw_level: 0, + premium_progress_bar_enabled: false, + afk_channel_id: null, + max_stage_video_channel_users: 50, + nsfw: false } }, user: { @@ -459,6 +1088,19 @@ module.exports = { global_name: "Clyde", avatar_decoration_data: null, banner_color: null + }, + jerassicore: { + username: "ser_jurassicore", + public_flags: 0, + primary_guild: null, + id: "493801948345139202", + global_name: "Jurassicore", + display_name_styles: null, + discriminator: "0", + collectibles: null, + clan: null, + avatar_decoration_data: null, + avatar: "2a4fa0de3aaea30f457ed7bba64176aa" } }, member: { From 50ca219fc1f7cb04969ad66261c27ceef06d8a12 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 5 Aug 2025 00:06:01 +1200 Subject: [PATCH 27/40] Fix retrying d->m errors --- src/d2m/event-dispatcher.js | 2 +- src/m2d/event-dispatcher.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 63ef3e0..e7b1b3a 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -55,7 +55,7 @@ module.exports = { if (gatewayMessage.t === "TYPING_START") return - await matrixEventDispatcher.sendError(roomID, "Discord", gatewayMessage.t, e, gatewayMessage.d) + await matrixEventDispatcher.sendError(roomID, "Discord", gatewayMessage.t, e, gatewayMessage) }, /** diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index a8c0ba1..cfa944c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -177,7 +177,7 @@ async function onRetryReactionAdd(reactionEvent) { } // Redact the error to stop people from executing multiple retries - api.redactEvent(roomID, event.event_id) + await api.redactEvent(roomID, event.event_id) } sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", From 6c23c5725a98dbdf930feff0856db3e6f386bb94 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 5 Aug 2025 00:53:33 +1200 Subject: [PATCH 28/40] Fix default power property usage --- src/matrix/api.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/api.js b/src/matrix/api.js index e913d0d..cf82117 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -318,12 +318,12 @@ async function setUserPower(roomID, mxid, newPower) { power.users = power.users || {} // Bridge bot can't demote equal power users, so need to decide which user will send the event - const oldPowerLevel = power.users?.[mxid] || power.events_default || 0 + const oldPowerLevel = power.users?.[mxid] || power.users_default || 0 const botPowerLevel = power.users?.[`@${reg.sender_localpart}:${reg.ooye.server_name}`] || 100 const eventSender = oldPowerLevel >= botPowerLevel ? mxid : undefined // Update the event content - if (newPower == null || newPower === (power.events_default || 0)) { + if (newPower == null || newPower === (power.users_default || 0)) { delete power.users[mxid] } else { power.users[mxid] = newPower From 67291a37367f5af96629add1360bdf251e1caf84 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 5 Aug 2025 01:25:09 +1200 Subject: [PATCH 29/40] Get member data when running backfill --- src/d2m/actions/register-user.js | 2 +- src/d2m/discord-client.js | 3 --- src/d2m/event-dispatcher.js | 19 +++++++++++++++---- src/m2d/event-dispatcher.js | 2 +- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js index 530ce6d..674853a 100644 --- a/src/d2m/actions/register-user.js +++ b/src/d2m/actions/register-user.js @@ -210,7 +210,7 @@ async function syncUser(user, member, channel, guild, roomID) { if (hashHasChanged && !wouldOverwritePreExisting) { // Update room member state await api.sendState(roomID, "m.room.member", mxid, content, mxid) - // Update power levels (only if we can actually access the member roles) + // Update power levels await api.setUserPower(roomID, mxid, powerLevel) // Update cached hash db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index f65acf8..b05d48f 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -62,9 +62,6 @@ class DiscordClient { addEventLogger("error", "Error") addEventLogger("disconnected", "Disconnected") addEventLogger("ready", "Ready") - this.snow.requestHandler.on("requestError", (requestID, error) => { - console.error("request error:", error) - }) } } diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index e7b1b3a..e72393b 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -109,13 +109,24 @@ module.exports = { }) // console.log(`[check missed messages] got ${messages.length} messages; last message that IS bridged is at position ${latestBridgedMessageIndex} in the channel`) if (latestBridgedMessageIndex === -1) latestBridgedMessageIndex = 1 // rather than crawling the ENTIRE channel history, let's just bridge the most recent 1 message to make it up to date. + + // We get member data so that we can accurately update any changes to nickname or permissions that have occurred in the meantime + // The rate limit is lax enough that the backlog will still be pretty quick (at time of writing, 5 per 1 second per guild) + /** @type {Map} id -> member: cache members for the run because people talk to each other */ + const members = new Map() + + // Send in order for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) { - const simulatedGatewayDispatchData = { + const message = messages[i] + + 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, { guild_id: guild.id, + member: members.get(message.author.id), + // @ts-ignore backfill: true, - ...messages[i] - } - await module.exports.MESSAGE_CREATE(client, simulatedGatewayDispatchData) + ...message + }) } } }, diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index cfa944c..a9bf9c1 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -51,7 +51,7 @@ function stringifyErrorStack(err, depth = 0) { const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p)) - // only break into object notation if we have addtl props to dump + // only break into object notation if we have additional props to dump if (props.length) { const dedent = " ".repeat(depth); const indent = " ".repeat(depth + 2); From 7bfe140d08a0de749e3dea5ad09547ad8ea3a7d7 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 5 Aug 2025 01:40:56 +1200 Subject: [PATCH 30/40] More precise power level checking --- src/matrix/api.js | 10 +++++++--- src/web/routes/link.js | 12 ++++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/matrix/api.js b/src/matrix/api.js index cf82117..41af63f 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -317,13 +317,17 @@ async function setUserPower(roomID, mxid, newPower) { const power = await getStateEvent(roomID, "m.room.power_levels", "") power.users = power.users || {} + // Check if it has really changed to avoid sending a useless state event + // (Can't diff kstate here because of (a) circular imports (b) kstate has special behaviour diffing power levels) + const oldPowerLevel = power.users?.[mxid] ?? power.users_default ?? 0 + if (oldPowerLevel === newPower) return + // Bridge bot can't demote equal power users, so need to decide which user will send the event - const oldPowerLevel = power.users?.[mxid] || power.users_default || 0 - const botPowerLevel = power.users?.[`@${reg.sender_localpart}:${reg.ooye.server_name}`] || 100 + const botPowerLevel = power.users?.[`@${reg.sender_localpart}:${reg.ooye.server_name}`] ?? power.users_default ?? 0 const eventSender = oldPowerLevel >= botPowerLevel ? mxid : undefined // Update the event content - if (newPower == null || newPower === (power.users_default || 0)) { + if (newPower == null || newPower === (power.users_default ?? 0)) { delete power.users[mxid] } else { power.users[mxid] = newPower diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 2d0277c..c5f404e 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -89,12 +89,12 @@ as.router.post("/api/link-space", defineEventHandler(async event => { try { powerLevelsStateContent = await api.getStateEvent(spaceID, "m.room.power_levels", "") } catch (e) {} - const selfPowerLevel = powerLevelsStateContent?.users?.[me] || powerLevelsStateContent?.users_default || 0 - if (selfPowerLevel < (powerLevelsStateContent?.state_default || 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix space"}) + const selfPowerLevel = powerLevelsStateContent?.users?.[me] ?? powerLevelsStateContent?.users_default ?? 0 + if (selfPowerLevel < (powerLevelsStateContent?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix space"}) // Check inviting user is a moderator in the space - const invitingPowerLevel = powerLevelsStateContent?.users?.[session.data.mxid] || powerLevelsStateContent?.users_default || 0 - if (invitingPowerLevel < (powerLevelsStateContent?.state_default || 50)) throw createError({status: 403, message: "Forbidden", data: `You need to be at least power level 50 (moderator) in the target Matrix space to set up OOYE, but you are currently power level ${invitingPowerLevel}.`}) + const invitingPowerLevel = powerLevelsStateContent?.users?.[session.data.mxid] ?? powerLevelsStateContent?.users_default ?? 0 + if (invitingPowerLevel < (powerLevelsStateContent?.state_default ?? 50)) throw createError({status: 403, message: "Forbidden", data: `You need to be at least power level 50 (moderator) in the target Matrix space to set up OOYE, but you are currently power level ${invitingPowerLevel}.`}) // Insert database entry db.transaction(() => { @@ -157,8 +157,8 @@ as.router.post("/api/link", defineEventHandler(async event => { try { powerLevelsStateContent = await api.getStateEvent(parsedBody.matrix, "m.room.power_levels", "") } catch (e) {} - const selfPowerLevel = powerLevelsStateContent?.users?.[me] || powerLevelsStateContent?.users_default || 0 - if (selfPowerLevel < (powerLevelsStateContent?.state_default || 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"}) + const selfPowerLevel = powerLevelsStateContent?.users?.[me] ?? powerLevelsStateContent?.users_default ?? 0 + if (selfPowerLevel < (powerLevelsStateContent?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"}) // Insert database entry, but keep the room's existing properties if they are set const nick = await api.getStateEvent(parsedBody.matrix, "m.room.name", "").then(content => content.name || null).catch(() => null) From ca8bbe076c9e430826c1f62f5f7a96e7cee4d881 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 13 Aug 2025 13:32:26 +1200 Subject: [PATCH 31/40] Replace PK multiple attempts with cache lookup --- src/d2m/actions/edit-message.js | 4 +- src/d2m/actions/register-pk-user.js | 98 ++++++++++++++++------------- src/d2m/actions/send-message.js | 3 +- 3 files changed, 55 insertions(+), 50 deletions(-) diff --git a/src/d2m/actions/edit-message.js b/src/d2m/actions/edit-message.js index 85b1a14..1afcb35 100644 --- a/src/d2m/actions/edit-message.js +++ b/src/d2m/actions/edit-message.js @@ -22,9 +22,7 @@ async function editMessage(message, guild, row) { if (row && row.speedbump_webhook_id === message.webhook_id) { // Handle the PluralKit public instance if (row.speedbump_id === "466378653216014359") { - const root = await registerPkUser.fetchMessage(message.id) - assert(root.member) - senderMxid = await registerPkUser.ensureSimJoined(root, roomID) + senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, false) } } diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js index 477b0d8..20281ae 100644 --- a/src/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -5,7 +5,7 @@ const {reg} = require("../../matrix/read-registration") const Ty = require("../../types") const passthrough = require("../../passthrough") -const {sync, db, select} = passthrough +const {sync, db, select, from} = passthrough /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") /** @type {import("../../matrix/file")} */ @@ -20,6 +20,20 @@ const registerUser = sync.require("./register-user") * @prop {string} id */ +/** @returns {Promise} */ +async function fetchMessage(messageID) { + try { + var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) + } catch (networkError) { + // Network issue, raise a more readable message + throw new Error(`Failed to connect to PK API: ${networkError.toString()}`) + } + if (!res.ok) throw new Error(`PK API returned an error: ${JSON.stringify(await res.text())}`) + const root = await res.json() + if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`) + return root +} + /** * A sim is an account that is being simulated by the bridge to copy events from the other side. * @param {Ty.PkMessage} pkMessage @@ -95,6 +109,7 @@ async function ensureSimJoined(pkMessage, roomID) { } /** + * Generate profile data based on webhook displayname and configured avatar. * @param {Ty.PkMessage} pkMessage * @param {WebhookAuthor} author */ @@ -115,54 +130,47 @@ async function memberToStateContent(pkMessage, author) { /** * Sync profile data for a sim user. This function follows the following process: - * 1. Join the sim to the room if needed - * 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before - * 3. Compare against the previously known state content, which is helpfully stored in the database - * 4. If the state content has changed, send it to Matrix and update it in the database for next time - * @param {WebhookAuthor} author - * @param {Ty.PkMessage} pkMessage - * @param {string} roomID + * 1. Look up data about proxy user from API + * 2. If this fails, try to use previously cached data (won't sync) + * 3. Create and join the sim to the room if needed + * 4. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before + * 5. Compare against the previously known state content, which is helpfully stored in the database + * 6. If the state content has changed, send it to Matrix and update it in the database for next time + * @param {string} messageID to call API with + * @param {WebhookAuthor} author for profile data + * @param {string} roomID room to join member to + * @param {boolean} shouldActuallySync whether to actually sync updated user data or just ensure it's joined * @returns {Promise} mxid of the updated sim */ -async function syncUser(author, pkMessage, roomID) { - const mxid = await ensureSimJoined(pkMessage, roomID) - // Update the sim_proxy table, so mentions can look up the original sender later - db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username) - // Sync the member state - const content = await memberToStateContent(pkMessage, author) - const currentHash = registerUser._hashProfileContent(content, 0) - const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() - // only do the actual sync if the hash has changed since we last looked - if (existingHash !== currentHash) { - await api.sendState(roomID, "m.room.member", mxid, content, mxid) - db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) +async function syncUser(messageID, author, roomID, shouldActuallySync) { + try { + // API lookup + var pkMessage = await fetchMessage(messageID) + db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username) + } catch (e) { + // Fall back to offline cache + const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID}).pluck("mxid").get() + if (!senderMxid) throw e + return senderMxid } + + // Create and join the sim to the room if needed + const mxid = await ensureSimJoined(pkMessage, roomID) + + if (shouldActuallySync) { + // Build current profile data + const content = await memberToStateContent(pkMessage, author) + const currentHash = registerUser._hashProfileContent(content, 0) + const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() + + // Only do the actual sync if the hash has changed since we last looked + if (existingHash !== currentHash) { + await api.sendState(roomID, "m.room.member", mxid, content, mxid) + db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) + } + } + return mxid } -/** @returns {Promise} */ -async function fetchMessage(messageID) { - // Their backend is weird. Sometimes it says "message not found" (code 20006) on the first try, so we make multiple attempts. - let attempts = 0 - do { - try { - var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) - if (res.ok) return res.json() - var errorGetter = () => res.json() - } catch (e) { - // Catch any network issues too. - errorGetter = () => e.toString() - } - - // I think the backend needs some time to update. - await new Promise(resolve => setTimeout(resolve, 1500)) - } while (++attempts < 3) - - throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(await errorGetter())}`) -} - -module.exports._memberToStateContent = memberToStateContent -module.exports.ensureSim = ensureSim -module.exports.ensureSimJoined = ensureSimJoined module.exports.syncUser = syncUser -module.exports.fetchMessage = fetchMessage diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index b01235a..b1cb680 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -37,8 +37,7 @@ async function sendMessage(message, channel, guild, row) { } else if (row && row.speedbump_webhook_id === message.webhook_id) { // Handle the PluralKit public instance if (row.speedbump_id === "466378653216014359") { - const pkMessage = await registerPkUser.fetchMessage(message.id) - senderMxid = await registerPkUser.syncUser(message.author, pkMessage, roomID) + senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true) } } From 106aea4031173b95eee8af3f740b5316f3b9f106 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 13 Aug 2025 13:41:50 +1200 Subject: [PATCH 32/40] Remove silly stringify --- src/d2m/actions/register-pk-user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js index 20281ae..b5e44e5 100644 --- a/src/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -28,7 +28,7 @@ async function fetchMessage(messageID) { // Network issue, raise a more readable message throw new Error(`Failed to connect to PK API: ${networkError.toString()}`) } - if (!res.ok) throw new Error(`PK API returned an error: ${JSON.stringify(await res.text())}`) + if (!res.ok) throw new Error(`PK API returned an error: ${await res.text()}`) const root = await res.json() if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`) return root From 160efc559245488a455de31661fb4aac2f8efbcb Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 13 Aug 2025 20:30:19 +1200 Subject: [PATCH 33/40] Update dependencies --- package-lock.json | 537 +++++++++++++--------- package.json | 16 +- scripts/migrate-from-old-bridge.js | 3 +- scripts/start-server.js | 7 +- src/d2m/event-dispatcher.js | 3 +- src/web/routes/log-in-with-matrix.test.js | 4 +- src/web/routes/oauth.js | 2 +- test/web.js | 60 ++- 8 files changed, 370 insertions(+), 262 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39d6f42..803fe53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,11 @@ "version": "3.1.0", "license": "AGPL-3.0-or-later", "dependencies": { - "@chriscdn/promise-semaphore": "^2.0.1", + "@chriscdn/promise-semaphore": "^3.0.1", "@cloudrac3r/discord-markdown": "^2.6.5", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", - "@cloudrac3r/in-your-element": "^1.1.0", + "@cloudrac3r/in-your-element": "^1.1.1", "@cloudrac3r/mixin-deep": "^3.0.1", "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", @@ -21,10 +21,10 @@ "@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks-icons": "^6.0.2", "ansi-colors": "^4.1.3", - "better-sqlite3": "^11.1.2", + "better-sqlite3": "^12.2.0", "chunk-text": "^2.0.1", "cloudstorm": "^0.14.0", - "discord-api-types": "^0.37.119", + "discord-api-types": "^0.38.19", "domino": "^2.1.6", "enquirer": "^2.4.1", "entities": "^5.0.0", @@ -35,47 +35,24 @@ "lru-cache": "^11.0.2", "prettier-bytes": "^1.0.4", "sharp": "^0.33.4", - "snowtransfer": "^0.13.1", + "snowtransfer": "^0.14.2", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", - "zod": "^3.23.8" + "zod": "^4.0.17" }, "devDependencies": { "@cloudrac3r/tap-dot": "^2.0.3", - "@types/node": "^20.17.19", + "@types/node": "^22.17.1", "c8": "^10.1.2", "cross-env": "^7.0.3", - "supertape": "^10.4.0" + "supertape": "^11.3.0" }, "engines": { "node": ">=20" } }, - "../in-your-element": { - "name": "@cloudrac3r/in-your-element", - "version": "0.0.0", - "extraneous": true, - "license": "AGPL-3.0-or-later", - "dependencies": { - "h3": "^1.12.0", - "zod": "^3.23.8" - }, - "devDependencies": { - "@cloudrac3r/tap-dot": "^2.0.2", - "@types/node": "^18.19.42", - "c8": "^10.1.2", - "cross-env": "^7.0.3", - "mock-req": "^0.2.0", - "readable-mock-req": "^0.2.2", - "supertape": "^10.7.2", - "try-to-catch": "^3.0.1" - }, - "engines": { - "node": ">=18" - } - }, "../tap-dot": { "name": "@cloudrac3r/tap-dot", "version": "2.0.0", @@ -142,9 +119,10 @@ } }, "node_modules/@chriscdn/promise-semaphore": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-2.0.10.tgz", - "integrity": "sha512-NagoHAZEYISDYYprsHe+x2BEcD6GKhTpEreI8BM1qgtHOtCS3lbwRvvTQxzAxU8JVSmw7ep/ROLv3Ng/MPcMHg==" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.0.1.tgz", + "integrity": "sha512-fVlCnoYE4hDzpcYRPtmN7dmcpmd2zxyPWjyfjIKI9Y+gsI7rwZSkjtuwMi8HFtlkSmNh8L7Zr37hdqeL13sYrw==", + "license": "MIT" }, "node_modules/@cloudcmd/stub": { "version": "4.0.1", @@ -269,12 +247,13 @@ } }, "node_modules/@cloudrac3r/in-your-element": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@cloudrac3r/in-your-element/-/in-your-element-1.1.0.tgz", - "integrity": "sha512-3rRoQQ6gKApK7Jk8U8D1g/oYE9f9p1RBLzVUt3OwSjMBGx1czeXjGJcEgHeAtxpmqNQYC6iJ2hfPU6m9BwKwxA==", + "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==", + "license": "AGPL-3.0-or-later", "dependencies": { "h3": "^1.12.0", - "zod": "^3.23.8" + "zod": "^4.0.17" }, "engines": { "node": ">=18" @@ -751,6 +730,29 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -834,16 +836,37 @@ "node": ">=8" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "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==", "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.0.1.tgz", + "integrity": "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -892,16 +915,17 @@ } }, "node_modules/@putout/cli-keypress": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@putout/cli-keypress/-/cli-keypress-2.0.0.tgz", - "integrity": "sha512-EXJv2HaXM+5scjoxE6Tf+o4+pxwL1tYJZJBDMygrF7cocjirGcU05GgNr9WHOaUPaVOpVjVU98ugYD7XJLmMkw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@putout/cli-keypress/-/cli-keypress-3.0.0.tgz", + "integrity": "sha512-RwODGTbcWNaulEPvVPdxH/vnddf5dE627G3s8gyou3kexa6zQerQHvbKFX0wywNdA3HD2O/9STPv/r5mjXFUgw==", "dev": true, + "license": "MIT", "dependencies": { "ci-info": "^4.0.0", "fullstore": "^3.0.0" }, "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/@putout/cli-validate-args": { @@ -918,15 +942,16 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true + "version": "0.34.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", + "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", + "dev": true, + "license": "MIT" }, "node_modules/@stackoverflow/stacks": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.7.4.tgz", - "integrity": "sha512-bUEosPqD7llSwIMujys+beeP3UbUq9b1ac8Z7ahrdK2DmMI1OYJ2M9wQaObp9bDU8LXcYObALDO9U7zpRl48Ew==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.8.3.tgz", + "integrity": "sha512-ZGBeuXJC7moK/f+lgl2dCAW85etD/RO0DNubocdH2qzpJMuuGXX0GMeEAfrTOe+B00I8E1OqTnS1cpkqGdHBdQ==", "license": "MIT", "dependencies": { "@hotwired/stimulus": "^3.2.2", @@ -934,9 +959,9 @@ } }, "node_modules/@stackoverflow/stacks-icons": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/@stackoverflow/stacks-icons/-/stacks-icons-6.2.0.tgz", - "integrity": "sha512-ZJSyMBkZ7xFIf56f6pCC0JliEmxYknYy5r3Pf3wn0aLPO8PYBN1odnBiayrqxvWft+JYe5CPDp8jxzcGoU5YBg==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/@stackoverflow/stacks-icons/-/stacks-icons-6.6.1.tgz", + "integrity": "sha512-upa2jajYTKAHfILFbPWMsml0nlh4fbIEb2V9SS0txjOJEoZE2oBnNJXbg29vShp7Nyn1VwrMjaraX63WkKT07w==", "license": "MIT" }, "node_modules/@supertape/engine-loader": { @@ -952,16 +977,17 @@ } }, "node_modules/@supertape/formatter-fail": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@supertape/formatter-fail/-/formatter-fail-3.0.2.tgz", - "integrity": "sha512-mSBnNprfLFmGvZkP+ODGroPLFCIN5BWE/06XaD5ghiTVWqek7eH8IDqvKyEduvuQu1O5tvQiaTwQsyxvikF+2w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-fail/-/formatter-fail-4.0.0.tgz", + "integrity": "sha512-+isArOXmGkIqH14PQoq2WhJmSwO8rzpQnhurVMuBmC+kYB96R95kRdjo/KO9d9yP1KoSjum0kX94s0SwqlZ8yA==", "dev": true, + "license": "MIT", "dependencies": { - "@supertape/formatter-tap": "^3.0.3", + "@supertape/formatter-tap": "^4.0.0", "fullstore": "^3.0.0" }, "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/@supertape/formatter-json-lines": { @@ -977,10 +1003,11 @@ } }, "node_modules/@supertape/formatter-progress-bar": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@supertape/formatter-progress-bar/-/formatter-progress-bar-6.1.0.tgz", - "integrity": "sha512-BVnLW08BMbF/Xf9DNxTtc5V5Ong4VCj0w46Ts2cc1EboX+RQGuxGO0/wrzTBTt4t30iUzFhG/t2g280MfLHutQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-progress-bar/-/formatter-progress-bar-7.0.0.tgz", + "integrity": "sha512-JDCT86hFJkoaqE/KS8BQsRaYiy3ipMpf0j+o+vwQMcFYm0mgG35JwbotBMUQM7LFifh68bTqU4xuewy7kUS1EA==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^5.3.0", "ci-info": "^4.0.0", @@ -989,14 +1016,15 @@ "once": "^1.4.0" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@supertape/formatter-progress-bar/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", + "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", "dev": true, + "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -1005,28 +1033,31 @@ } }, "node_modules/@supertape/formatter-short": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@supertape/formatter-short/-/formatter-short-2.0.1.tgz", - "integrity": "sha512-zxFrZfCccFV+bf6A7MCEqT/Xsf0Elc3qa0P3jShfdEfrpblEcpSo0T/Wd9jFwc7uHA3ABgxgcHy7LNIpyrFTCg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-short/-/formatter-short-3.0.0.tgz", + "integrity": "sha512-lKiIMekxQgkF4YBj/IiFoRUQrF/Ow7D8zt9ZEBdHTkRys30vhRFn9557okECKGdpnAcSsoTHWwgikS/NPc3g/g==", "dev": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/@supertape/formatter-tap": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@supertape/formatter-tap/-/formatter-tap-3.0.3.tgz", - "integrity": "sha512-U5OuMotfYhGo9cZ8IgdAXRTH5Yy8yfLDZzYo1upTPTwlJJquKwtvuz7ptiB7BN3OFr5YakkDYlFxOYPcLo7urg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-tap/-/formatter-tap-4.0.0.tgz", + "integrity": "sha512-cupeiik+FeTQ24d0fihNdS901Ct720UhUqgtPl2DiLWadEIT/B8+TIB4MG60sTmaE8xclbCieanbS/I94CQTPw==", "dev": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/@supertape/formatter-time": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@supertape/formatter-time/-/formatter-time-1.0.2.tgz", - "integrity": "sha512-QihQWA/3LSNuODHrL8MGNHkdRunaEqNQkuMUDGNgEQO8MYBB0d83WGlNxDFGjn4kRlq47hovw3Skq7Btb2i2JA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-time/-/formatter-time-2.0.0.tgz", + "integrity": "sha512-5UPvVHwpg5ZJmz0nII2f5rBFqNdMxHQnBybetmhgkSDIZHb+3NTPz/VrDggZERWOGxmIf4NKebaA+BWHTBQMeA==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^5.3.0", "ci-info": "^4.0.0", @@ -1036,14 +1067,15 @@ "timer-node": "^5.0.7" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@supertape/formatter-time/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", + "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", "dev": true, + "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -1075,13 +1107,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.17.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", - "integrity": "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==", + "version": "22.17.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.1.tgz", + "integrity": "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~6.21.0" } }, "node_modules/@types/prop-types": { @@ -1207,14 +1239,17 @@ ] }, "node_modules/better-sqlite3": { - "version": "11.9.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.9.1.tgz", - "integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.2.0.tgz", + "integrity": "sha512-eGbYq2CT+tos1fBwLQ/tkBt9J5M3JEHjku4hbvQUePCckkvVf14xWj+1m7dGoK81M/fOjFT7yM9UMeKT/+vFLQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x" } }, "node_modules/bindings": { @@ -1272,10 +1307,11 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -1352,9 +1388,9 @@ } }, "node_modules/ci-info": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", - "integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", "dev": true, "funding": [ { @@ -1362,6 +1398,7 @@ "url": "https://github.com/sponsors/sibiraj-s" } ], + "license": "MIT", "engines": { "node": ">=8" } @@ -1371,6 +1408,7 @@ "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", "dev": true, + "license": "MIT", "dependencies": { "string-width": "^4.2.3" }, @@ -1426,27 +1464,6 @@ "node": ">=22.0.0" } }, - "node_modules/cloudstorm/node_modules/discord-api-types": { - "version": "0.38.12", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.12.tgz", - "integrity": "sha512-vqkRM50N5Zc6OVckAqtSslbUEoXmpN4bd9xq2jkoK9fgO3KNRIOyMMQ7ipqjwjKuAgzWvU6G8bRIcYWaUe1sCA==", - "license": "MIT", - "workspaces": [ - "scripts/actions/documentation" - ] - }, - "node_modules/cloudstorm/node_modules/snowtransfer": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.14.2.tgz", - "integrity": "sha512-Fi8OdRmaIgeCj58oVej+tQAoY2I+Xp/6PAYV8X93jE/2E6Anc87SbTbDV6WZXCnuzTQz3gty8JOGz02qI7Qs9A==", - "license": "MIT", - "dependencies": { - "discord-api-types": "^0.38.8" - }, - "engines": { - "node": ">=16.15.0" - } - }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -1538,9 +1555,10 @@ } }, "node_modules/crossws": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.4.tgz", - "integrity": "sha512-uj0O1ETYX1Bh6uSgktfPvwDiPYGQ3aI4qVsaC/LWpkIzGj1nUYm5FK3K+t11oOlpN01lGbprFCH4wBlKdJjVgw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", "dependencies": { "uncrypto": "^0.1.3" } @@ -1584,9 +1602,9 @@ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" }, "node_modules/destr": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.3.tgz", - "integrity": "sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "license": "MIT" }, "node_modules/detect-libc": { @@ -1597,20 +1615,14 @@ "node": ">=8" } }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/discord-api-types": { - "version": "0.37.120", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.120.tgz", - "integrity": "sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==", - "license": "MIT" + "version": "0.38.19", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.19.tgz", + "integrity": "sha512-NUNMTgjYrgxt7wrTNEqnEez4hIAYbfyBpsjxT5gW7+82GjQCPDZvN+em6t+4/P5kGWnnwDa4ci070BV7eI6GbA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] }, "node_modules/doctypes": { "version": "1.1.0", @@ -1755,12 +1767,13 @@ "dev": true }, "node_modules/foreground-child": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", - "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, + "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -1844,19 +1857,19 @@ } }, "node_modules/h3": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.1.tgz", - "integrity": "sha512-+ORaOBttdUm1E2Uu/obAyCguiI7MbBvsLTndc3gyK3zU+SYLoZXlyCP9Xgy0gikkGufFLTZXCXD6+4BsufnmHA==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.4.tgz", + "integrity": "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==", "license": "MIT", "dependencies": { "cookie-es": "^1.2.2", - "crossws": "^0.3.3", + "crossws": "^0.3.5", "defu": "^6.1.4", - "destr": "^2.0.3", + "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", - "node-mock-http": "^1.0.0", + "node-mock-http": "^1.0.2", "radix3": "^1.1.2", - "ufo": "^1.5.4", + "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, @@ -1882,9 +1895,10 @@ } }, "node_modules/heatsync": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.8.1.tgz", - "integrity": "sha512-BipRCTh6jqndV5FsebdJFQHRKb5J4ecVA7Kqv0gktb/MorrEwgTEoTNSITjEK59heGyP+QnTSj6LyJDFsnVqvQ==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.8.2.tgz", + "integrity": "sha512-zO5ivWP1NYoYmngdqVxzeQGX2Q68rfLkXKbO8Dhcguj5eS2eBDVpcWPh3+KCQagM7xYP5QVzvrUryWDu4mt6Eg==", + "license": "MIT", "dependencies": { "backtracker": "^4.0.0" }, @@ -1904,9 +1918,10 @@ "dev": true }, "node_modules/htmx.org": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz", - "integrity": "sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ==" + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz", + "integrity": "sha512-7ythjYneGSk3yCHgtCnQeaoF+D+o7U2LF37WU3O0JYv3gTZSicdEFiI/Ai/NJyC5ZpYJWMpUb11OC5Lr6AfAqA==", + "license": "0BSD" }, "node_modules/ieee754": { "version": "1.2.1", @@ -2040,27 +2055,19 @@ } }, "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.5.tgz", + "integrity": "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A==", "dev": true, + "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "pretty-format": "30.0.5" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/js-stringify": { @@ -2068,6 +2075,13 @@ "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==" }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/just-kebab-case": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/just-kebab-case/-/just-kebab-case-4.2.0.tgz", @@ -2198,9 +2212,10 @@ } }, "node_modules/node-mock-http": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.0.tgz", - "integrity": "sha512-0uGYQ1WQL1M5kKvGRXWQ3uZCHtLTO8hln3oBjIusM75WoesZ909uQJs/Hb946i2SS+Gsrhkaa6iAO17jRIv6DQ==" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.2.tgz", + "integrity": "sha512-zWaamgDUdo9SSLw47we78+zYw/bDr5gH8pH7oRRs8V3KmBtu8GLgGIbV2p/gRPd3LWpEOpjQj7X1FOU3VFMJ8g==", + "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", @@ -2344,17 +2359,18 @@ "integrity": "sha512-dLbWOa4xBn+qeWeIF60qRoB6Pk2jX5P3DIVgOQyMyvBpu931Q+8dXz8X0snJiFkQdohDDLnZQECjzsAj75hgZQ==" }, "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/pretty-format/node_modules/ansi-styles": { @@ -2362,6 +2378,7 @@ "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" }, @@ -2472,10 +2489,11 @@ "license": "MIT" }, "node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" }, "node_modules/readable-web-to-node-stream": { "version": "3.0.2", @@ -2701,11 +2719,12 @@ } }, "node_modules/snowtransfer": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.13.1.tgz", - "integrity": "sha512-EMrvqCk0JVcpJILTV9JEvUi3VyC5kohcza9d9l034B+cXwLbOWKFhzKULBPe/VqTdx+aqFpdYCdb1/HDrRiZ1Q==", + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.14.2.tgz", + "integrity": "sha512-Fi8OdRmaIgeCj58oVej+tQAoY2I+Xp/6PAYV8X93jE/2E6Anc87SbTbDV6WZXCnuzTQz3gty8JOGz02qI7Qs9A==", + "license": "MIT", "dependencies": { - "discord-api-types": "^0.37.119" + "discord-api-types": "^0.38.8" }, "engines": { "node": ">=16.15.0" @@ -2910,41 +2929,126 @@ } }, "node_modules/supertape": { - "version": "10.10.0", - "resolved": "https://registry.npmjs.org/supertape/-/supertape-10.10.0.tgz", - "integrity": "sha512-Zxww3DePaNlRJgy4XVukEU98254DWwNbV0Ch1jJcCWZxD0AJM9fIJG1bbFmVXXdYe0G0+YnpfrP12nVM2K+cEg==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/supertape/-/supertape-11.3.0.tgz", + "integrity": "sha512-2LP36xLtxsb3bBYrfvWIilhWpA/vs7/vIgElpsqEhZZ0vcOAMlhMIxH6eHAl5u9KcxGD28IrJrw8lREqeMtZeQ==", "dev": true, + "license": "MIT", "dependencies": { "@cloudcmd/stub": "^4.0.0", - "@putout/cli-keypress": "^2.0.0", + "@putout/cli-keypress": "^3.0.0", "@putout/cli-validate-args": "^2.0.0", "@supertape/engine-loader": "^2.0.0", - "@supertape/formatter-fail": "^3.0.0", + "@supertape/formatter-fail": "^4.0.0", "@supertape/formatter-json-lines": "^2.0.0", - "@supertape/formatter-progress-bar": "^6.0.0", - "@supertape/formatter-short": "^2.0.0", - "@supertape/formatter-tap": "^3.0.0", - "@supertape/formatter-time": "^1.0.0", + "@supertape/formatter-progress-bar": "^7.0.0", + "@supertape/formatter-short": "^3.0.0", + "@supertape/formatter-tap": "^4.0.0", + "@supertape/formatter-time": "^2.0.0", "@supertape/operator-stub": "^3.0.0", "cli-progress": "^3.8.2", "flatted": "^3.3.1", "fullstore": "^3.0.0", - "glob": "^10.0.0", - "jest-diff": "^29.0.1", + "glob": "^11.0.1", + "jest-diff": "^30.0.3", + "json-with-bigint": "^3.4.4", "once": "^1.4.0", "resolve": "^1.17.0", "stacktracey": "^2.1.7", "strip-ansi": "^7.0.0", "try-to-catch": "^3.0.0", "wraptile": "^3.0.0", - "yargs-parser": "^21.0.0" + "yargs-parser": "^22.0.0" }, "bin": { "supertape": "bin/tracer.mjs", "tape": "bin/tracer.mjs" }, "engines": { - "node": ">=18" + "node": ">=20" + } + }, + "node_modules/supertape/node_modules/glob": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supertape/node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supertape/node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supertape/node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supertape/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/supports-color": { @@ -3047,10 +3151,11 @@ } }, "node_modules/timer-node": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/timer-node/-/timer-node-5.0.7.tgz", - "integrity": "sha512-M1aP6ASmuVD0PSxl5fqjCAGY9WyND3DHZ8RwT5I8o7469XE53Lb5zbPai20Dhj7TProyaapfVj3TaT0P+LoSEA==", - "dev": true + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/timer-node/-/timer-node-5.0.9.tgz", + "integrity": "sha512-zXxCE/5/YDi0hY9pygqgRqjRbrFRzigYxOudG0I3syaqAAmX9/w9sxex1bNFCN6c1S66RwPtEIJv65dN+1psew==", + "dev": true, + "license": "MIT" }, "node_modules/to-fast-properties": { "version": "2.0.0", @@ -3117,9 +3222,9 @@ } }, "node_modules/ufo": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", - "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", "license": "MIT" }, "node_modules/uncrypto": { @@ -3129,9 +3234,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -3342,9 +3447,9 @@ } }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.17.tgz", + "integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index af0ca1c..a7d3eaa 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,11 @@ "node": ">=20" }, "dependencies": { - "@chriscdn/promise-semaphore": "^2.0.1", + "@chriscdn/promise-semaphore": "^3.0.1", "@cloudrac3r/discord-markdown": "^2.6.5", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", - "@cloudrac3r/in-your-element": "^1.1.0", + "@cloudrac3r/in-your-element": "^1.1.1", "@cloudrac3r/mixin-deep": "^3.0.1", "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", @@ -30,10 +30,10 @@ "@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks-icons": "^6.0.2", "ansi-colors": "^4.1.3", - "better-sqlite3": "^11.1.2", + "better-sqlite3": "^12.2.0", "chunk-text": "^2.0.1", "cloudstorm": "^0.14.0", - "discord-api-types": "^0.37.119", + "discord-api-types": "^0.38.19", "domino": "^2.1.6", "enquirer": "^2.4.1", "entities": "^5.0.0", @@ -44,19 +44,19 @@ "lru-cache": "^11.0.2", "prettier-bytes": "^1.0.4", "sharp": "^0.33.4", - "snowtransfer": "^0.13.1", + "snowtransfer": "^0.14.2", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", - "zod": "^3.23.8" + "zod": "^4.0.17" }, "devDependencies": { "@cloudrac3r/tap-dot": "^2.0.3", - "@types/node": "^20.17.19", + "@types/node": "^22.17.1", "c8": "^10.1.2", "cross-env": "^7.0.3", - "supertape": "^10.4.0" + "supertape": "^11.3.0" }, "scripts": { "start": "node --enable-source-maps start.js", diff --git a/scripts/migrate-from-old-bridge.js b/scripts/migrate-from-old-bridge.js index 88a137c..36cf884 100755 --- a/scripts/migrate-from-old-bridge.js +++ b/scripts/migrate-from-old-bridge.js @@ -2,8 +2,7 @@ // @ts-check const assert = require("assert").strict -/** @type {any} */ // @ts-ignore bad types from semaphore -const Semaphore = require("@chriscdn/promise-semaphore") +const {Semaphore} = require("@chriscdn/promise-semaphore") const sqlite = require("better-sqlite3") const HeatSync = require("heatsync") diff --git a/scripts/start-server.js b/scripts/start-server.js index 6c15037..0d4753a 100755 --- a/scripts/start-server.js +++ b/scripts/start-server.js @@ -21,12 +21,7 @@ const DiscordClient = require("../src/d2m/discord-client") const discord = new DiscordClient(reg.ooye.discord_token, "half") passthrough.discord = discord -const app = createApp() -const router = createRouter() -app.use(router) -const server = createServer(toNodeListener(app)) -server.listen(reg.socket || new URL(reg.url).port) -const as = Object.assign(new EventEmitter(), {app, router, server}) // @ts-ignore +const {as} = require("../src/matrix/appservice") passthrough.as = as const orm = sync.require("../src/db/orm") diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index e72393b..1698317 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -35,8 +35,7 @@ const setPresence = sync.require("./actions/set-presence") /** @type {import("../m2d/event-dispatcher")} */ const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") -/** @type {any} */ // @ts-ignore bad types from semaphore -const Semaphore = require("@chriscdn/promise-semaphore") +const {Semaphore} = require("@chriscdn/promise-semaphore") const checkMissedPinsSema = new Semaphore() // Grab Discord events we care about for the bridge, check them, and pass them on diff --git a/src/web/routes/log-in-with-matrix.test.js b/src/web/routes/log-in-with-matrix.test.js index 0d2d40d..c86b92f 100644 --- a/src/web/routes/log-in-with-matrix.test.js +++ b/src/web/routes/log-in-with-matrix.test.js @@ -22,7 +22,7 @@ test("log in with matrix: checks if mxid format looks valid", async t => { mxid: "x@cadence:cadence.moe" } })) - t.equal(error.data.issues[0].validation, "regex") + t.match(error.data.fieldErrors.mxid, /must match pattern/) }) test("log in with matrix: checks if mxid domain format looks valid", async t => { @@ -31,7 +31,7 @@ test("log in with matrix: checks if mxid domain format looks valid", async t => mxid: "@cadence:cadence." } })) - t.equal(error.data.issues[0].validation, "regex") + t.match(error.data.fieldErrors.mxid, /must match pattern/) }) test("log in with matrix: sends message when there is no m.direct data", async t => { diff --git a/src/web/routes/oauth.js b/src/web/routes/oauth.js index f659115..80765d6 100644 --- a/src/web/routes/oauth.js +++ b/src/web/routes/oauth.js @@ -27,7 +27,7 @@ const schema = { token: z.object({ token_type: z.string(), access_token: z.string(), - expires_in: z.number({coerce: true}), + expires_in: z.coerce.number(), refresh_token: z.string(), scope: z.string() }) diff --git a/test/web.js b/test/web.js index eb2b876..09af95b 100644 --- a/test/web.js +++ b/test/web.js @@ -5,6 +5,10 @@ const {SnowTransfer} = require("snowtransfer") const assert = require("assert").strict const domino = require("domino") const {extend} = require("supertape") +const {reg} = require("../src/matrix/read-registration") + +const {AppService} = require("@cloudrac3r/in-your-element") +const defaultAs = new AppService(reg) /** * @param {string} html @@ -39,7 +43,7 @@ class Router { for (const method of ["get", "post", "put", "patch", "delete"]) { this[method] = function(url, handler) { const key = `${method} ${url}` - this.routes.set(`${key}`, handler) + this.routes.set(key, handler) } } } @@ -49,7 +53,7 @@ class Router { * @param {string} inputUrl * @param {{event?: any, params?: any, body?: any, sessionData?: any, api?: Partial, snow?: {[k in keyof SnowTransfer]?: Partial}, createRoom?: Partial, createSpace?: Partial, headers?: any}} [options] */ - test(method, inputUrl, options = {}) { + async test(method, inputUrl, options = {}) { const url = new URL(inputUrl, "http://a") const key = `${method} ${options.route || url.pathname}` /* c8 ignore next */ @@ -67,36 +71,42 @@ class Router { req.headers["content-type"] = "application/json" } - return this.routes.get(key)(Object.assign(event, { - __is_event__: true, - method: method.toUpperCase(), - path: `${url.pathname}${url.search}`, - _requestBody: options.body, - node: { - req, - res: new http.ServerResponse(req) - }, - context: { - api: options.api, - params: options.params, - snow: options.snow, - createRoom: options.createRoom, - createSpace: options.createSpace, - sessions: { - h3: { - id: "h3", - createdAt: 0, - data: options.sessionData || {} + try { + return await this.routes.get(key)(Object.assign(event, { + __is_event__: true, + method: method.toUpperCase(), + path: `${url.pathname}${url.search}`, + _requestBody: options.body, + node: { + req, + res: new http.ServerResponse(req) + }, + context: { + api: options.api, + params: options.params, + snow: options.snow, + createRoom: options.createRoom, + createSpace: options.createSpace, + sessions: { + h3: { + id: "h3", + createdAt: 0, + data: options.sessionData || {} + } } } - } - })) + })) + } catch (error) { + // Post-process error data + defaultAs.app.options.onError(error) + throw error + } } } const router = new Router() -passthrough.as = {router, on() {}} +passthrough.as = {router, on() {}, options: defaultAs.app.options} module.exports.router = router module.exports.test = test From 2a0e22a1223841482643628a10c8cfdb8eeb5678 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 13 Aug 2025 20:49:02 +1200 Subject: [PATCH 34/40] Don't explode if it can't send follow-up errors This _should_ be awaited all the way up, but it didn't work for me, and better safe than sorry I guess? --- src/m2d/event-dispatcher.js | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index a9bf9c1..ce5f79c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -128,16 +128,18 @@ async function sendError(roomID, source, type, e, payload) { } // Send - await api.sendEvent(roomID, "m.room.message", { - ...builder.get(), - "moe.cadence.ooye.error": { - source: source.toLowerCase(), - payload - }, - "m.mentions": { - user_ids: ["@cadence:cadence.moe"] - } - }) + try { + await api.sendEvent(roomID, "m.room.message", { + ...builder.get(), + "moe.cadence.ooye.error": { + source: source.toLowerCase(), + payload + }, + "m.mentions": { + user_ids: ["@cadence:cadence.moe"] + } + }) + } catch (e) {} } function guard(type, fn) { From a7abdfdc253c88e241fe71de868048658596f8dd Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 17 Aug 2025 18:24:27 +1200 Subject: [PATCH 35/40] Persist cookies longer than session --- src/web/auth.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/auth.js b/src/web/auth.js index 77e60cd..c14dcd8 100644 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -26,7 +26,7 @@ async function getManagedGuilds(event) { * @returns {ReturnType>} */ function useSession(event) { - return h3.useSession(event, {password: reg.as_token}) + return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60}) } module.exports.getManagedGuilds = getManagedGuilds From 344822cec07e793b9631bf425745d7d521ac2133 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 17 Aug 2025 18:25:34 +1200 Subject: [PATCH 36/40] Minor copyedit --- src/web/pug/home.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/pug/home.pug b/src/web/pug/home.pug index 232bd3d..d562250 100644 --- a/src/web/pug/home.pug +++ b/src/web/pug/home.pug @@ -49,7 +49,7 @@ block body if locked h2 This is a private instance - p Anybody can run their own instance of the Out Of Your Element software. The person running this instance has made it private, so you can't add it to your server just yet. If you know the people in charge of #{reg.ooye.server_name}, ask them for the password. + p Anybody can run their own instance of the Out Of Your Element software. The person running this instance has made it private, so you can't add it to your server just yet. If you know who's in charge of #{reg.ooye.server_name}, ask them for the password. h2 Run your own instance p You can still use Out Of Your Element by running your own copy of the software, but this requires some technical skill. From 5e4bea6ce652a011ae1d7609f083563aada6df64 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 21 Aug 2025 00:47:50 +1200 Subject: [PATCH 37/40] Remove useless loop --- src/web/routes/log-in-with-matrix.js | 40 +++++++++++++--------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/src/web/routes/log-in-with-matrix.js b/src/web/routes/log-in-with-matrix.js index 1066f03..9943b82 100644 --- a/src/web/routes/log-in-with-matrix.js +++ b/src/web/routes/log-in-with-matrix.js @@ -71,7 +71,6 @@ as.router.get("/log-in-with-matrix", defineEventHandler(async event => { as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => { const api = getAPI(event) const {mxid, next} = await readValidatedBody(event, schema.form.parse) - let roomID = null // Don't extend a duplicate invite for the same user for (const alreadyInvited of validToken.values()) { @@ -80,31 +79,27 @@ as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => { } } - // See if we can reuse an existing room from account data + // Get list of existing DMs from account data let directData = {} try { directData = await api.getAccountData("m.direct") } catch (e) {} - const rooms = directData[mxid] || [] - for (const candidate of rooms) { - // Check that the person is/still in the room - let member - try { - member = await api.getStateEvent(candidate, "m.room.member", mxid) - } catch (e) {} - if (!member || member.membership === "leave") { - // We can reinvite them back to the same room! - await api.inviteToRoom(candidate, mxid) - roomID = candidate - } else { - // Member is in this room - roomID = candidate - } - if (roomID) break // no need to check other candidates - } + let roomID = directData[mxid]?.at(-1) - // No candidates available, create a new room and invite - if (!roomID) { + // Reuse an existing DM, if able + if (typeof roomID === "string") { + // Check that the person is/still in the room + try { + var member = await api.getStateEvent(roomID, "m.room.member", mxid) + } catch (e) {} + + // Invite them back to the room if needed + if (!member || member.membership === "leave") { + await api.inviteToRoom(roomID, mxid) + } + } + // No existing DMs, create a new room and invite + else { roomID = await api.createRoom({ invite: [mxid], is_direct: true, @@ -116,7 +111,6 @@ as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => { } const token = randomUUID() - validToken.set(token, mxid) console.log(`web log in requested for ${mxid}`) const paramsObject = {token} @@ -129,5 +123,7 @@ as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => { body }) + validToken.set(token, mxid) + return sendRedirect(event, "../ok?msg=Please check your inbox on Matrix!&spot=SpotMailXL", 302) })) From 954d41269c5034316014974484c0327a8558dd5f Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 21 Aug 2025 11:30:23 +1200 Subject: [PATCH 38/40] Store directs in database rather than account data --- src/db/migrations/0024-add-direct.sql | 9 ++++++ src/db/orm-defs.d.ts | 38 +++++++++++++---------- src/m2d/event-dispatcher.js | 8 ++++- src/web/routes/log-in-with-matrix.js | 20 +++++------- src/web/routes/log-in-with-matrix.test.js | 32 ++++--------------- test/ooye-test-data.sql | 4 +++ 6 files changed, 54 insertions(+), 57 deletions(-) create mode 100644 src/db/migrations/0024-add-direct.sql diff --git a/src/db/migrations/0024-add-direct.sql b/src/db/migrations/0024-add-direct.sql new file mode 100644 index 0000000..94dc4ae --- /dev/null +++ b/src/db/migrations/0024-add-direct.sql @@ -0,0 +1,9 @@ +BEGIN TRANSACTION; + +CREATE TABLE direct ( + mxid TEXT NOT NULL, + room_id TEXT NOT NULL, + PRIMARY KEY (mxid) +) WITHOUT ROWID; + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index d96ccf2..79fd501 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -1,4 +1,9 @@ export type Models = { + auto_emoji: { + name: string + emoji_id: string + } + channel_room: { channel_id: string room_id: string @@ -14,6 +19,18 @@ export type Models = { custom_topic: number } + direct: { + mxid: string + room_id: string + } + + emoji: { + emoji_id: string + name: string + animated: number + mxc_url: string + } + event_message: { event_id: string message_id: string @@ -55,6 +72,10 @@ export type Models = { mxc_url: string } + media_proxy: { + permitted_hash: number + } + member_cache: { room_id: string mxid: string @@ -99,29 +120,12 @@ export type Models = { webhook_token: string } - emoji: { - emoji_id: string - name: string - animated: number - mxc_url: string - } - reaction: { hashed_event_id: number message_id: string encoded_emoji: string original_encoding: string | null } - - auto_emoji: { - name: string - emoji_id: string - guild_id: string - } - - media_proxy: { - permitted_hash: number - } } export type Prepared = { diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index ce5f79c..ce3638c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -339,7 +339,13 @@ async event => { if (event.content.membership === "leave" || event.content.membership === "ban") { // Member is gone - return db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) + db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) + + // Unregister room's use as a direct chat if the bot itself left + const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}` + if (event.state_key === bot) { + db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id) + } } const exists = select("channel_room", "room_id", {room_id: event.room_id}) ?? select("guild_space", "space_id", {space_id: event.room_id}) diff --git a/src/web/routes/log-in-with-matrix.js b/src/web/routes/log-in-with-matrix.js index 9943b82..574c312 100644 --- a/src/web/routes/log-in-with-matrix.js +++ b/src/web/routes/log-in-with-matrix.js @@ -5,7 +5,7 @@ const {randomUUID} = require("crypto") const {defineEventHandler, getValidatedQuery, sendRedirect, readValidatedBody, createError, getRequestHeader, H3Event} = require("h3") const {LRUCache} = require("lru-cache") -const {as} = require("../../passthrough") +const {as, db, select} = require("../../passthrough") const {reg} = require("../../matrix/read-registration") const {sync} = require("../../passthrough") @@ -79,15 +79,9 @@ as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => { } } - // Get list of existing DMs from account data - let directData = {} - try { - directData = await api.getAccountData("m.direct") - } catch (e) {} - let roomID = directData[mxid]?.at(-1) - - // Reuse an existing DM, if able - if (typeof roomID === "string") { + // Check if we have an existing DM + let roomID = select("direct", "room_id", {mxid}).pluck().get() + if (roomID) { // Check that the person is/still in the room try { var member = await api.getStateEvent(roomID, "m.room.member", mxid) @@ -98,7 +92,8 @@ as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => { await api.inviteToRoom(roomID, mxid) } } - // No existing DMs, create a new room and invite + + // No existing DM, create a new room and invite else { roomID = await api.createRoom({ invite: [mxid], @@ -106,8 +101,7 @@ as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => { preset: "trusted_private_chat" }) // Store the newly created room in account data (Matrix doesn't do this for us automatically, sigh...) - ;(directData[mxid] ??= []).push(roomID) - await api.setAccountData("m.direct", directData) + db.prepare("REPLACE INTO direct (mxid, room_id) VALUES (?, ?)").run(mxid, roomID) } const token = randomUUID() diff --git a/src/web/routes/log-in-with-matrix.test.js b/src/web/routes/log-in-with-matrix.test.js index c86b92f..bc9c7e0 100644 --- a/src/web/routes/log-in-with-matrix.test.js +++ b/src/web/routes/log-in-with-matrix.test.js @@ -34,7 +34,7 @@ test("log in with matrix: checks if mxid domain format looks valid", async t => t.match(error.data.fieldErrors.mxid, /must match pattern/) }) -test("log in with matrix: sends message when there is no m.direct data", async t => { +test("log in with matrix: sends message when there is no existing dm room", async t => { const event = {} let called = 0 await router.test("post", "/api/log-in-with-matrix", { @@ -42,20 +42,10 @@ test("log in with matrix: sends message when there is no m.direct data", async t mxid: "@cadence:cadence.moe" }, api: { - async getAccountData(type) { - called++ - t.equal(type, "m.direct") - throw new MatrixServerError({errcode: "M_NOT_FOUND"}) - }, async createRoom() { called++ return "!created:cadence.moe" }, - async setAccountData(type, content) { - called++ - t.equal(type, "m.direct") - t.deepEqual(content, {"@cadence:cadence.moe": ["!created:cadence.moe"]}) - }, async sendEvent(roomID, type, content) { called++ t.equal(roomID, "!created:cadence.moe") @@ -68,7 +58,7 @@ test("log in with matrix: sends message when there is no m.direct data", async t event }) t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/) - t.equal(called, 4) + t.equal(called, 2) }) test("log in with matrix: does not send another message when a log in is in progress", async t => { @@ -82,7 +72,7 @@ test("log in with matrix: does not send another message when a log in is in prog t.match(event.node.res.getHeader("location"), /We already sent you a link on Matrix/) }) -test("log in with matrix: reuses room from m.direct", async t => { +test("log in with matrix: reuses room from direct", async t => { const event = {} let called = 0 await router.test("post", "/api/log-in-with-matrix", { @@ -90,11 +80,6 @@ test("log in with matrix: reuses room from m.direct", async t => { mxid: "@user1:example.org" }, api: { - async getAccountData(type) { - called++ - t.equal(type, "m.direct") - return {"@user1:example.org": ["!existing:cadence.moe"]} - }, async getStateEvent(roomID, type, key) { called++ t.equal(roomID, "!existing:cadence.moe") @@ -111,10 +96,10 @@ test("log in with matrix: reuses room from m.direct", async t => { event }) t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/) - t.equal(called, 3) + t.equal(called, 2) }) -test("log in with matrix: reuses room from m.direct, reinviting if user has left", async t => { +test("log in with matrix: reuses room from direct, reinviting if user has left", async t => { const event = {} let called = 0 await router.test("post", "/api/log-in-with-matrix", { @@ -122,11 +107,6 @@ test("log in with matrix: reuses room from m.direct, reinviting if user has left mxid: "@user2:example.org" }, api: { - async getAccountData(type) { - called++ - t.equal(type, "m.direct") - return {"@user2:example.org": ["!existing:cadence.moe"]} - }, async getStateEvent(roomID, type, key) { called++ t.equal(roomID, "!existing:cadence.moe") @@ -148,7 +128,7 @@ test("log in with matrix: reuses room from m.direct, reinviting if user has left event }) t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/) - t.equal(called, 4) + t.equal(called, 3) }) // ***** third request ***** diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index faca448..b31f2c3 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -188,4 +188,8 @@ INSERT INTO invite (mxid, room_id, type, name, avatar, topic) VALUES ('@cadence:cadence.moe', '!room:cadence.moe', NULL, 'some room', NULL, NULL), ('@rnl:cadence.moe', '!space:cadence.moe', NULL, 'somebody else''s space', NULL, NULL); +INSERT INTO direct (mxid, room_id) VALUES +('@user1:example.org', '!existing:cadence.moe'), +('@user2:example.org', '!existing:cadence.moe'); + COMMIT; From c71044fdec882446810e7d4e436d5b3c7d709032 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 29 Aug 2025 00:09:18 +1200 Subject: [PATCH 39/40] Only edit events if the text has changed --- src/d2m/converters/edit-to-changes.js | 19 ++++- src/d2m/converters/edit-to-changes.test.js | 95 +++++++++++++++------- 2 files changed, 83 insertions(+), 31 deletions(-) diff --git a/src/d2m/converters/edit-to-changes.js b/src/d2m/converters/edit-to-changes.js index c38c24e..c615a3f 100644 --- a/src/d2m/converters/edit-to-changes.js +++ b/src/d2m/converters/edit-to-changes.js @@ -22,6 +22,10 @@ function eventCanBeEdited(ev) { return true } +function eventIsText(ev) { + return ev.old.event_type === "m.room.message" && (ev.old.event_subtype === "m.text" || ev.old.event_subtype === "m.notice") +} + /** * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message * @param {import("discord-api-types/v10").APIGuild} guild @@ -121,6 +125,20 @@ async function editToChanges(message, guild, api) { unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents. eventsToReplace = eventsToReplace.filter(eventCanBeEdited) + // Now, everything in eventsToReplace has the potential to have changed, but did it actually? + // (Example: if a URL preview was generated or updated, the message text won't have changed.) + // Only way to detect this is by text content. So we'll remove text events from eventsToReplace that have the same new text as text currently in the event. + for (let i = eventsToReplace.length; i--;) { // move backwards through array + const event = eventsToReplace[i] + if (!eventIsText(event)) continue // not text, can't analyse + const oldEvent = await api.getEvent(roomID, eventsToReplace[i].old.event_id) + const oldEventBodyWithoutQuotedReply = oldEvent.content.body?.replace(/^(>.*\n)*\n*/sm, "") + if (oldEventBodyWithoutQuotedReply !== event.newInnerContent.body) continue // event changed, must replace it + // Move it from eventsToRedact to unchangedEvents. + unchangedEvents.push(...eventsToReplace.filter(ev => ev.old.event_id === event.old.event_id)) + eventsToReplace = eventsToReplace.filter(ev => ev.old.event_id !== event.old.event_id) + } + // We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times. // This would be disrupted if existing events that are (reaction_)part = 0 will be redacted. // If that is the case, pick a different existing or newly sent event to be (reaction_)part = 0. @@ -193,4 +211,3 @@ function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent) } module.exports.editToChanges = editToChanges -module.exports.makeReplacementEventContent = makeReplacementEventContent diff --git a/src/d2m/converters/edit-to-changes.test.js b/src/d2m/converters/edit-to-changes.test.js index 9721a85..30549c7 100644 --- a/src/d2m/converters/edit-to-changes.test.js +++ b/src/d2m/converters/edit-to-changes.test.js @@ -4,7 +4,14 @@ const data = require("../../../test/data") const Ty = require("../../types") test("edit2changes: edit by webhook", async t => { - const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, {}) + let called = 0 + const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, { + getEvent(roomID, eventID) { + called++ + t.equal(eventID, "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -28,10 +35,15 @@ test("edit2changes: edit by webhook", async t => { }]) t.equal(senderMxid, null) t.deepEqual(promotions, []) + t.equal(called, 1) }) test("edit2changes: bot response", async t => { const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY") + return {content: {body: "dummy"}} + }, async getJoinedMembers(roomID) { t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe") return new Promise(resolve => { @@ -123,7 +135,14 @@ test("edit2changes: add caption back to that image (due to it having a reaction, }) test("edit2changes: stickers and attachments are not changed, only the content can be edited", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, {}) + let called = 0 + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, { + getEvent(roomID, eventID) { + called++ + t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -145,10 +164,16 @@ test("edit2changes: stickers and attachments are not changed, only the content c } } }]) + t.equal(called, 1) }) test("edit2changes: edit of reply to skull webp attachment with content", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, {}) + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -177,7 +202,12 @@ test("edit2changes: edit of reply to skull webp attachment with content", async }) test("edit2changes: edits the text event when multiple rows have part = 0 (should never happen in real life, but make sure the safety net works)", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_0, data.guild.general, {}) + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_0, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -202,7 +232,12 @@ test("edit2changes: edits the text event when multiple rows have part = 0 (shoul }) test("edit2changes: promotes the text event when multiple rows have part = 1 (should never happen in real life, but make sure the safety net works)", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_1, data.guild.general, {}) + const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_1, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -279,32 +314,31 @@ test("edit2changes: generated embed", async t => { }) test("edit2changes: generated embed on a reply", async t => { - const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, {}) + let called = 0 + const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, { + getEvent(roomID, eventID) { + called++ + t.equal(eventID, "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF") + return { + type: "m.room.message", + content: { + // Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client. + body: "> a Discord user: [Replied-to message content wasn't provided by Discord]" + + "\n\nhttps://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + format: "org.matrix.custom.html", + formatted_body: "
In reply to a Discord user
[Replied-to message content wasn't provided by Discord]
https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + "m.mentions": {}, + "m.relates_to": { + event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF", + rel_type: "m.replace", + }, + msgtype: "m.text", + } + } + } + }) t.deepEqual(eventsToRedact, []) - t.deepEqual(eventsToReplace, [{ - oldID: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF", - newContent: { - $type: "m.room.message", - // Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client. - body: "> a Discord user: [Replied-to message content wasn't provided by Discord]" - + "\n\n* https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", - format: "org.matrix.custom.html", - formatted_body: "
In reply to a Discord user
[Replied-to message content wasn't provided by Discord]
* https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", - "m.mentions": {}, - "m.new_content": { - body: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", - format: "org.matrix.custom.html", - formatted_body: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", - "m.mentions": {}, - msgtype: "m.text", - }, - "m.relates_to": { - event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF", - rel_type: "m.replace", - }, - msgtype: "m.text", - }, - }]) + t.deepEqual(eventsToReplace, []) t.deepEqual(eventsToSend, [{ $type: "m.room.message", msgtype: "m.notice", @@ -324,4 +358,5 @@ test("edit2changes: generated embed on a reply", async t => { "nextEvent": true, }]) t.equal(senderMxid, "@_ooye_cadence:cadence.moe") + t.equal(called, 1) }) From a968bacffd47f5c85359cd380539f2d7ca17139a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 3 Sep 2025 00:00:02 +1200 Subject: [PATCH 40/40] Update discord-markdown Interpret channel URLs the same as a channel #mention --- package-lock.json | 58 +++++++++++++++++++++++------------------------ package.json | 4 ++-- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 803fe53..aa7822f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0-or-later", "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.6.5", + "@cloudrac3r/discord-markdown": "^2.6.6", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/in-your-element": "^1.1.1", @@ -35,7 +35,7 @@ "lru-cache": "^11.0.2", "prettier-bytes": "^1.0.4", "sharp": "^0.33.4", - "snowtransfer": "^0.14.2", + "snowtransfer": "^0.15.0", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", "uqr": "^0.1.2", @@ -119,9 +119,9 @@ } }, "node_modules/@chriscdn/promise-semaphore": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.0.1.tgz", - "integrity": "sha512-fVlCnoYE4hDzpcYRPtmN7dmcpmd2zxyPWjyfjIKI9Y+gsI7rwZSkjtuwMi8HFtlkSmNh8L7Zr37hdqeL13sYrw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.1.1.tgz", + "integrity": "sha512-ALLLLYlPfd/QZLptcVi6HQRK1zaCDWZoqYYw+axLmCatFs4gVTSZ5nqlyxwFe4qwR/K84HvOMa9hxda881FqMA==", "license": "MIT" }, "node_modules/@cloudcmd/stub": { @@ -225,9 +225,9 @@ } }, "node_modules/@cloudrac3r/discord-markdown": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.5.tgz", - "integrity": "sha512-B4uQNsyva5JNW0CVYkcunMQwWfrok1Hd5FYww/cWcvb98zp/pJdJfE3hoRl9EbnxNK2l62IJQ9j8HmssMFHJ9Q==", + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.6.tgz", + "integrity": "sha512-4FNO7WmACPvcTrQjeLQLr9WRuP7JDUVUGFrRJvmAjiMs2UlUAsShfSRuU2SCqz3QqmX8vyJ06wy2hkjTTyRtbw==", "license": "MIT", "dependencies": { "simple-markdown": "^0.7.3" @@ -949,9 +949,9 @@ "license": "MIT" }, "node_modules/@stackoverflow/stacks": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.8.3.tgz", - "integrity": "sha512-ZGBeuXJC7moK/f+lgl2dCAW85etD/RO0DNubocdH2qzpJMuuGXX0GMeEAfrTOe+B00I8E1OqTnS1cpkqGdHBdQ==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.8.4.tgz", + "integrity": "sha512-FfA7Bw7a0AQrMw3/bG6G4BUrZ698F7Cdk6HkR9T7jdaufORkiX5d16wI4j4b5Sqm1FwkaZAF+ZSKLL1w0tAsew==", "license": "MIT", "dependencies": { "@hotwired/stimulus": "^3.2.2", @@ -1107,9 +1107,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.17.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.1.tgz", - "integrity": "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==", + "version": "22.18.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz", + "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1452,13 +1452,13 @@ } }, "node_modules/cloudstorm": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.14.0.tgz", - "integrity": "sha512-EgjMGxb2Z+L6Acti6DzL/bEbR495AIqPThyW4DaG6Jpvd0ZuM5eC13EiyxV8wlqAME612QO2LjqbhkdXn/327Q==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.14.1.tgz", + "integrity": "sha512-x95WCKg818E1rE1Ru45NPD3RoIq0pg3WxwvF0GE7Eq07pAeLcjSRqM1lUmbmfjdOqZrWdSRYA1NETVZ8QhVrIA==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.12", - "snowtransfer": "^0.14.2" + "discord-api-types": "^0.38.21", + "snowtransfer": "^0.15.0" }, "engines": { "node": ">=22.0.0" @@ -1616,9 +1616,9 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.19", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.19.tgz", - "integrity": "sha512-NUNMTgjYrgxt7wrTNEqnEez4hIAYbfyBpsjxT5gW7+82GjQCPDZvN+em6t+4/P5kGWnnwDa4ci070BV7eI6GbA==", + "version": "0.38.22", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.22.tgz", + "integrity": "sha512-2gnYrgXN3yTlv2cKBISI/A8btZwsSZLwKpIQXeI1cS8a7W7wP3sFVQOm3mPuuinTD8jJCKGPGNH399zE7Un1kA==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" @@ -2719,12 +2719,12 @@ } }, "node_modules/snowtransfer": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.14.2.tgz", - "integrity": "sha512-Fi8OdRmaIgeCj58oVej+tQAoY2I+Xp/6PAYV8X93jE/2E6Anc87SbTbDV6WZXCnuzTQz3gty8JOGz02qI7Qs9A==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.15.0.tgz", + "integrity": "sha512-kEDGKtFiH5nSkHsDZonEUuDx99lUasJoZ7AGrgvE8HzVG59vjvqc//C+pjWj4DuJqTj4Q+Z1L/M/MYNim8F2VA==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.8" + "discord-api-types": "^0.38.21" }, "engines": { "node": ">=16.15.0" @@ -3447,9 +3447,9 @@ } }, "node_modules/zod": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.17.tgz", - "integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.5.tgz", + "integrity": "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index a7d3eaa..2fb21f2 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.6.5", + "@cloudrac3r/discord-markdown": "^2.6.6", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/in-your-element": "^1.1.1", @@ -44,7 +44,7 @@ "lru-cache": "^11.0.2", "prettier-bytes": "^1.0.4", "sharp": "^0.33.4", - "snowtransfer": "^0.14.2", + "snowtransfer": "^0.15.0", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", "uqr": "^0.1.2",