From 5a152b87b846cfc95a9987033c49a14f0564f108 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 8 Sep 2025 12:37:19 +1200 Subject: [PATCH 01/16] I guess mentions is an optional property too --- src/d2m/converters/message-to-event.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index a8e5a6b7..1d6288a7 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -33,9 +33,10 @@ function getDiscordParseCallbacks(message, guild, useHTML) { user: node => { 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 + 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) + || (message.author.id === node.id ? message.author.username : null) || node.id if (mxid && useHTML) { return `@${username}` From d8e6de62e50b75e02fdfc9d57fc613d561faa033 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 8 Sep 2025 16:26:16 +1200 Subject: [PATCH 02/16] Keep sim_proxy profile data up to date --- 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 b5e44e56..27e949ce 100644 --- a/src/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -146,7 +146,7 @@ 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) + db.prepare("REPLACE 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() From 389150616395f0fd98b45ad0e13ad531e2ae7ff4 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 7 Oct 2025 00:46:44 -0500 Subject: [PATCH 03/16] Roll back snowtransfer to avoid issue with pins --- package-lock.json | 28 ++++++++++++++++++++-------- package.json | 2 +- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index aa7822f7..d82b914c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "lru-cache": "^11.0.2", "prettier-bytes": "^1.0.4", "sharp": "^0.33.4", - "snowtransfer": "^0.15.0", + "snowtransfer": "^0.14.2", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", "uqr": "^0.1.2", @@ -1464,6 +1464,18 @@ "node": ">=22.0.0" } }, + "node_modules/cloudstorm/node_modules/snowtransfer": { + "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.21" + }, + "engines": { + "node": ">=16.15.0" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -2719,12 +2731,12 @@ } }, "node_modules/snowtransfer": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.15.0.tgz", - "integrity": "sha512-kEDGKtFiH5nSkHsDZonEUuDx99lUasJoZ7AGrgvE8HzVG59vjvqc//C+pjWj4DuJqTj4Q+Z1L/M/MYNim8F2VA==", + "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.21" + "discord-api-types": "^0.38.8" }, "engines": { "node": ">=16.15.0" @@ -3076,9 +3088,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "license": "MIT", "dependencies": { "chownr": "^1.1.1", diff --git a/package.json b/package.json index 2fb21f24..f7220667 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "lru-cache": "^11.0.2", "prettier-bytes": "^1.0.4", "sharp": "^0.33.4", - "snowtransfer": "^0.15.0", + "snowtransfer": "^0.14.2", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", "uqr": "^0.1.2", From 7905802825c6dd03b26770fd32f03d1b77b6bf82 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 7 Oct 2025 00:48:06 -0500 Subject: [PATCH 04/16] Allow customising port in setup --- scripts/setup.js | 23 ++++++++++++++++++----- src/m2d/actions/setup-emojis.js | 3 +-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/scripts/setup.js b/scripts/setup.js index 6bff2938..ecef03d8 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -120,16 +120,28 @@ function defineEchoHandler() { /** @type {string} */ // @ts-ignore const serverOrigin = await serverOriginPrompt.run() + console.log("OOYE has its own web server. It needs to be accessible on the public internet.") + console.log("What port would you like OOYE to use? You can connect your reverse proxy to this port later.") + /** @type {{socket: string | number}} */ + const portResponse = await prompt({ + type: "input", + name: "socket", + message: "Web server port", + initial: "6693" + }) + portResponse.socket = +portResponse.socket || portResponse.socket // convert to number if numeric + const app = createApp() app.use(defineEchoHandler()) const server = createServer(toNodeListener(app)) - await server.listen(6693) + await server.listen(portResponse.socket) - console.log("OOYE has its own web server. It needs to be accessible on the public internet.") - console.log("You need to enter a public URL where you will be able to host this web server.") - console.log("OOYE listens on localhost:6693, so you will probably have to set up a reverse proxy.") + console.log("Now you need to enter a public URL that OOYE's web server will live on.") + console.log("Set up your reverse proxy so that this URL accesses OOYE.") console.log("Examples: https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md#appendix") - console.log("Now listening on port 6693. Feel free to send some test requests.") + if (typeof portResponse.socket === "number") { + console.log(`Now listening on http://localhost:${portResponse.socket}. Feel free to send some test requests.`) + } /** @type {{bridge_origin: string}} */ const bridgeOriginResponse = await prompt({ type: "input", @@ -255,6 +267,7 @@ function defineEchoHandler() { reg = { ...template, url: bridgeOriginResponse.bridge_origin, + ...portResponse, ooye: { ...template.ooye, ...bridgeOriginResponse, diff --git a/src/m2d/actions/setup-emojis.js b/src/m2d/actions/setup-emojis.js index ba2c0455..1be1d2d2 100644 --- a/src/m2d/actions/setup-emojis.js +++ b/src/m2d/actions/setup-emojis.js @@ -5,9 +5,8 @@ const {join} = require("path") const passthrough = require("../../passthrough") -const {id} = require("../../../addbot") - async function setupEmojis() { + const {id} = require("../../../addbot") const {discord, db} = passthrough const emojis = await discord.snow.assets.getAppEmojis(id) for (const name of ["L1", "L2"]) { From 7916f82b55fd81d95abfdea328b5decc75588492 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 7 Oct 2025 14:09:42 -0500 Subject: [PATCH 05/16] Change thread started message (closes #61) --- src/d2m/converters/thread-to-announcement.js | 3 --- src/d2m/converters/thread-to-announcement.test.js | 10 ---------- 2 files changed, 13 deletions(-) diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 11a067fd..98b8f124 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -32,13 +32,10 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr const template = creatorMxid ? "started a thread:" : "Thread started:" const via = await mxUtils.getViaServersQuery(threadRoomID, di.api) let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}` - let html = `${template} ${thread.name}` return { msgtype, body, - format: "org.matrix.custom.html", - formatted_body: html, "m.mentions": {}, ...context } diff --git a/src/d2m/converters/thread-to-announcement.test.js b/src/d2m/converters/thread-to-announcement.test.js index 471cd943..3d5d1ebc 100644 --- a/src/d2m/converters/thread-to-announcement.test.js +++ b/src/d2m/converters/thread-to-announcement.test.js @@ -55,8 +55,6 @@ test("thread2announcement: no known creator, no branched from event", async t => t.deepEqual(content, { msgtype: "m.text", body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", - format: "org.matrix.custom.html", - formatted_body: `Thread started: test thread`, "m.mentions": {} }) }) @@ -69,8 +67,6 @@ test("thread2announcement: known creator, no branched from event", async t => { t.deepEqual(content, { msgtype: "m.emote", body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", - format: "org.matrix.custom.html", - formatted_body: `started a thread: test thread`, "m.mentions": {} }) }) @@ -95,8 +91,6 @@ test("thread2announcement: no known creator, branched from discord event", async t.deepEqual(content, { msgtype: "m.text", body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", - format: "org.matrix.custom.html", - formatted_body: `Thread started: test thread`, "m.mentions": {}, "m.relates_to": { "m.in_reply_to": { @@ -126,8 +120,6 @@ test("thread2announcement: known creator, branched from discord event", async t t.deepEqual(content, { msgtype: "m.emote", body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", - format: "org.matrix.custom.html", - formatted_body: `started a thread: test thread`, "m.mentions": {}, "m.relates_to": { "m.in_reply_to": { @@ -157,8 +149,6 @@ test("thread2announcement: no known creator, branched from matrix event", async t.deepEqual(content, { msgtype: "m.text", body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", - format: "org.matrix.custom.html", - formatted_body: `Thread started: test thread`, "m.mentions": { user_ids: ["@cadence:cadence.moe"] }, From 5b7433de3260a0504df94f7dcda6a0f0a8ed2884 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 7 Oct 2025 14:09:50 -0500 Subject: [PATCH 06/16] Make tests time zone independent --- src/d2m/converters/message-to-event.js | 2 +- src/types.d.ts | 1 + test/test.js | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 1d6288a7..f4941233 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -611,7 +611,7 @@ async function messageToEvent(message, guild, options = {}, di) { 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 formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric", timeZone: reg.ooye.time_zone}) // 9 June at 3:00 pm NZT const rep = new mxUtils.MatrixStringBuilder() // Add time diff --git a/src/types.d.ts b/src/types.d.ts index 37da6332..27dfddfb 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -31,6 +31,7 @@ export type AppServiceRegistrationConfig = { discord_origin?: string discord_cdn_origin?: string, web_password: string + time_zone?: string } old_bridge?: { as_token: string diff --git a/test/test.js b/test/test.js index 3695a848..233fd940 100644 --- a/test/test.js +++ b/test/test.js @@ -25,6 +25,7 @@ reg.namespaces = { aliases: [{regex: "#_ooye_.*:cadence.moe", exclusive: true}] } reg.ooye.bridge_origin = "https://bridge.example.org" +reg.ooye.time_zone = "Pacific/Auckland" const sync = new HeatSync({watchFS: false}) From cffd3c9f2ea0dc1c6be04f28501f4b59ee88caa2 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 10 Oct 2025 12:26:01 -0600 Subject: [PATCH 07/16] Fix converting discord channel links --- package-lock.json | 8 +-- package.json | 2 +- src/d2m/converters/message-to-event.js | 4 +- src/d2m/converters/message-to-event.test.js | 38 ++++++++++++++ test/data.js | 57 +++++++++++++++++++++ 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index d82b914c..fda73e3f 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.6", + "@cloudrac3r/discord-markdown": "^2.6.7", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/in-your-element": "^1.1.1", @@ -225,9 +225,9 @@ } }, "node_modules/@cloudrac3r/discord-markdown": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.6.tgz", - "integrity": "sha512-4FNO7WmACPvcTrQjeLQLr9WRuP7JDUVUGFrRJvmAjiMs2UlUAsShfSRuU2SCqz3QqmX8vyJ06wy2hkjTTyRtbw==", + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.7.tgz", + "integrity": "sha512-bWLmBYWaNEDcQfZHDz4jaAxLKA9161ruEnHo3ms6kfRw8uYku/Uz7U1xTmQ2dQF/q1PiuBvM9I37pLiotlQj8A==", "license": "MIT", "dependencies": { "simple-markdown": "^0.7.3" diff --git a/package.json b/package.json index f7220667..85530c10 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.6.6", + "@cloudrac3r/discord-markdown": "^2.6.7", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/in-your-element": "^1.1.1", diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index f4941233..30a20fe2 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -408,13 +408,13 @@ async function messageToEvent(message, guild, options = {}, di) { async function transformParsedVia(parsed) { for (const node of parsed) { - if (node.type === "discordChannel") { + if (node.type === "discordChannel" || node.type === "discordChannelLink") { node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get() if (node.row?.room_id) { node.via = await getViaServersMemo(node.row.room_id) } } - ;for (const maybeChildNodesArray of [node, node.content, node.items]) { + for (const maybeChildNodesArray of [node, node.content, node.items]) { if (Array.isArray(maybeChildNodesArray)) { await transformParsedVia(maybeChildNodesArray) } diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index fc933e35..ee4ec037 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -100,6 +100,44 @@ test("message2event: simple room mention", async t => { t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") }) +test("message2event: simple room link", async t => { + let called = 0 + const events = await messageToEvent(data.message.simple_room_link, data.guild.general, {}, { + api: { + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, + async getJoinedMembers(roomID) { + called++ + t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") + return { + joined: { + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} + } + } + } + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": {}, + msgtype: "m.text", + body: "#worm-farm", + format: "org.matrix.custom.html", + formatted_body: '#worm-farm' + }]) + t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") +}) + test("message2event: nicked room mention", async t => { let called = 0 const events = await messageToEvent(data.message.nicked_room_mention, data.guild.general, {}, { diff --git a/test/data.js b/test/data.js index a8ff8a88..e64b9c2f 100644 --- a/test/data.js +++ b/test/data.js @@ -1398,6 +1398,63 @@ module.exports = { attachments: [], guild_id: "112760669178241024" }, + simple_room_link: { + type: 0, + tts: false, + timestamp: "2023-07-10T20:04:25.939000+00:00", + referenced_message: null, + pinned: false, + nonce: "1128054139385806848", + mentions: [], + mention_roles: [], + mention_everyone: false, + member: { + roles: [ + "112767366235959296", "118924814567211009", + "204427286542417920", "199995902742626304", + "222168467627835392", "238028326281805825", + "259806643414499328", "265239342648131584", + "271173313575780353", "287733611912757249", + "225744901915148298", "305775031223320577", + "318243902521868288", "348651574924541953", + "349185088157777920", "378402925128712193", + "392141548932038658", "393912152173576203", + "482860581670486028", "495384759074160642", + "638988388740890635", "373336013109461013", + "530220455085473813", "454567553738473472", + "790724320824655873", "1123518980456452097", + "1040735082610167858", "695946570482450442", + "1123460940935991296", "849737964090556488" + ], + premium_since: null, + pending: false, + nick: null, + mute: false, + joined_at: "2015-11-11T09:55:40.321000+00:00", + flags: 0, + deaf: false, + communication_disabled_until: null, + avatar: null + }, + id: "1128054143064494233", + flags: 0, + embeds: [], + edited_timestamp: null, + content: "https://discord.com/channels/112760669178241024/1100319550446252084", + components: [], + channel_id: "266767590641238027", + author: { + username: "kumaccino", + public_flags: 128, + id: "113340068197859328", + global_name: "kumaccino", + discriminator: "0", + avatar_decoration: null, + avatar: "b48302623a12bc7c59a71328f72ccb39" + }, + attachments: [], + guild_id: "112760669178241024" + }, nicked_room_mention: { type: 0, tts: false, From 3de762d42852174c2876845dd4eacdb1698a3647 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 12 Oct 2025 12:17:20 -0600 Subject: [PATCH 08/16] Fix stickers that don't provide content type --- src/matrix/api.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/matrix/api.js b/src/matrix/api.js index 41af63f7..709d70c5 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -384,7 +384,9 @@ async function getMedia(mxc, init = {}) { }, ...init }) - assert(res.body) + if (init.method !== "HEAD") { + assert(res.body) + } // @ts-ignore return res } From d4f4664c25454ffe50672264737be83afde824ba Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 23 Oct 2025 23:05:23 +1100 Subject: [PATCH 09/16] Fix retrying m->d message deletions --- src/m2d/actions/redact.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/m2d/actions/redact.js b/src/m2d/actions/redact.js index 1f6cef89..1d3aa67f 100644 --- a/src/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -13,10 +13,12 @@ const utils = sync.require("../converters/utils") */ async function deleteMessage(event) { const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all() + if (!rows.length) return for (const row of rows) { - db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(row.message_id) await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason) + db.prepare("DELETE FROM event_message WHERE message_id = ?").run(row.message_id) } + db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(rows[0].message_id) } /** From 255e166e8cd7ff95f2576ab7b55c46bc93b6c290 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 31 Oct 2025 16:22:32 +1300 Subject: [PATCH 10/16] Better message when remote emojis unavailable --- src/m2d/actions/emoji-sheet.js | 6 ++++++ src/m2d/converters/event-to-message.js | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/m2d/actions/emoji-sheet.js b/src/m2d/actions/emoji-sheet.js index a63f0b0c..ed5ab883 100644 --- a/src/m2d/actions/emoji-sheet.js +++ b/src/m2d/actions/emoji-sheet.js @@ -7,6 +7,8 @@ const {sync} = require("../../passthrough") const emojiSheetConverter = sync.require("../converters/emoji-sheet") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/mreq")} */ +const mreq = sync.require("../../matrix/mreq") /** * Downloads the emoji from the web and converts to uncompressed PNG data. @@ -19,6 +21,10 @@ async function getAndConvertEmoji(mxc) { // If we were using connection pooling, we would be forced to download the entire GIF. // So we set no agent to ensure we are not connection pooling. const res = await api.getMedia(mxc, {signal: abortController.signal}) + if (res.status !== 200) { + const root = await res.json() + throw new mreq.MatrixServerError(root, {mxc}) + } const readable = stream.Readable.fromWeb(res.body) return emojiSheetConverter.convertImageStream(readable, () => { abortController.abort() diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 3cf08cf3..61525e2b 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -11,6 +11,7 @@ const entities = require("entities") const passthrough = require("../../passthrough") const {sync, db, discord, select, from} = passthrough +const {reg} = require("../../matrix/read-registration") /** @type {import("../converters/utils")} */ const mxUtils = sync.require("../converters/utils") /** @type {import("../../discord/utils")} */ @@ -238,7 +239,8 @@ function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink if (!found) row = null } // Or, if we don't have an emoji right now, we search for the name instead. - if (!row && nameForGuess) { + const isLocalMxc = mxcUrl?.match(/^mxc:\/\/([^/]+)/)?.[1] === reg.ooye.server_name + if (!row && nameForGuess && isLocalMxc) { const nameForGuessLower = nameForGuess.toLowerCase() for (const guild of discord.guilds.values()) { /** @type {{name: string, id: string, animated: number}[]} */ From f1313db0289dd62ecbe821db37934034558fd492 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 18:23:01 +0900 Subject: [PATCH 11/16] Add button to unlink a space --- src/web/pug/guild.pug | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 92ffa1b6..cedc32aa 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -54,6 +54,10 @@ block body .s-page-title.mb24 h1.s-page-title--header= guild.name + form(method="post" action=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server?\nThis will unlink every channels listed below.\nIt may take a moment to clean up Matrix resources.") + input(type="hidden" name="guild_id" value=guild.id) + button.s-btn.s-btn__muted.s-btn__xs(hx-post=rel("/api/unlink-space") hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm + .d-flex.g16(class="sm:fw-wrap") .fl-grow1 h2.fs-headline1 Invite a Matrix user From e0c449dc1f9268ffe0591d1548940cd38960e56c Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 23 Aug 2025 23:46:51 +1200 Subject: [PATCH 12/16] Fill in more of reg for other people to test with --- src/matrix/api.test.js | 4 ++++ test/test.js | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/matrix/api.test.js b/src/matrix/api.test.js index 82565ebe..da923858 100644 --- a/src/matrix/api.test.js +++ b/src/matrix/api.test.js @@ -24,3 +24,7 @@ test("api path: real world mxid", t => { test("api path: extras number works", t => { t.equal(path(`/client/v3/rooms/!example/timestamp_to_event`, null, {ts: 1687324651120}), "/client/v3/rooms/!example/timestamp_to_event?ts=1687324651120") }) + +test("api path: multiple via params", t => { + t.equal(path(`/client/v3/rooms/!example/join`, null, {via: ["cadence.moe", "matrix.org"], ts: 1687324651120}), "/client/v3/rooms/!example/join?via=cadence.moe&via=matrix.org&ts=1687324651120") +}) diff --git a/test/test.js b/test/test.js index 233fd940..b01f0ce2 100644 --- a/test/test.js +++ b/test/test.js @@ -17,6 +17,8 @@ const {reg} = require("../src/matrix/read-registration") reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby" reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded reg.ooye.server_name = "cadence.moe" +reg.ooye.namespace_prefix = "_ooye_" +reg.sender_localpart = "_ooye_bot" reg.id = "baby" reg.as_token = "don't actually take authenticated actions on the server" reg.hs_token = "don't actually take authenticated actions on the server" From 673871ce67927d50275b4cf34f927b1287bfeb10 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 19:28:42 +0900 Subject: [PATCH 13/16] Factorize some of the space link/unlink sanity checks --- src/web/routes/link.js | 46 +++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index c5f404eb..54ff133d 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -39,6 +39,33 @@ function getCreateSpace(event) { return event.context.createSpace || sync.require("../../d2m/actions/create-space") } +/** + * @param {H3Event} event + * @param {string} guild_id + */ +async function validateUserHaveRightsOnGuild(event, guild_id) { + const managed = await auth.getManagedGuilds(event) + if (!managed.has(guild_id)) + throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) +} + +/** + * @param {H3Event} event + * @param {string} guild_id + * @returns {Promise} + */ +async function validateGuildAccess(event, guild_id) { + // Check guild ID or nonce + await validateUserHaveRightsOnGuild(event, guild_id) + + // Check guild exists + const guild = discord.guilds.get(guild_id) + if (!guild) + throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) + + return guild +} + const schema = { linkSpace: z.object({ guild_id: z.string(), @@ -58,12 +85,11 @@ const schema = { as.router.post("/api/link-space", defineEventHandler(async event => { const parsedBody = await readValidatedBody(event, schema.linkSpace.parse) const session = await auth.useSession(event) - const managed = await auth.getManagedGuilds(event) const api = getAPI(event) // Check guild ID const guildID = parsedBody.guild_id - if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) + await validateUserHaveRightsOnGuild(event, guildID) // Check space ID if (!session.data.mxid) throw createError({status: 403, message: "Forbidden", data: "Can't link with your Matrix space if you aren't logged in to Matrix"}) @@ -108,18 +134,12 @@ as.router.post("/api/link-space", defineEventHandler(async event => { as.router.post("/api/link", defineEventHandler(async event => { const parsedBody = await readValidatedBody(event, schema.link.parse) - const managed = await auth.getManagedGuilds(event) const api = getAPI(event) const createRoom = getCreateRoom(event) const createSpace = getCreateSpace(event) - // Check guild ID or nonce const guildID = parsedBody.guild_id - if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) - - // Check guild is bridged - const guild = discord.guilds.get(guildID) - if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) + const guild = await validateGuildAccess(event, guildID) const spaceID = await createSpace.ensureSpace(guild) // Check channel exists @@ -183,15 +203,9 @@ as.router.post("/api/link", defineEventHandler(async event => { as.router.post("/api/unlink", defineEventHandler(async event => { const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse) - const managed = await auth.getManagedGuilds(event) const createRoom = getCreateRoom(event) - // Check guild ID or nonce - if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) - - // Check guild exists - const guild = discord.guilds.get(guild_id) - if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) + await validateGuildAccess(event, guild_id) // Check that the channel (if it exists) is part of this guild /** @type {any} */ From cd20495b116c59fa12ddf5d8a4c8ea8d8a2aedfd Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 19:59:44 +0900 Subject: [PATCH 14/16] Extract /api/unlink code to its own function --- .editorconfig | 6 ++++++ src/web/routes/link.js | 47 +++++++++++++++++++++++++----------------- 2 files changed, 34 insertions(+), 19 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..089c28f8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +[*] +indent_style = tab + +[*.pug] +indent_style = space +indent_size = 2 diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 54ff133d..c7def59b 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -66,6 +66,33 @@ async function validateGuildAccess(event, guild_id) { return guild } +/** + * @param {H3Event} event + * @param {string} channel_id + * @param {string} guild_id + */ +async function doRoomUnlink(event, channel_id, guild_id) { + const createRoom = getCreateRoom(event) + + // Check that the channel (if it exists) is part of this guild + /** @type {any} */ + let channel = discord.channels.get(channel_id) + if (channel) { + if (!("guild_id" in channel) || channel.guild_id !== guild_id) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not part of guild ${guild_id}`}) + } else { + // Otherwise, if the channel isn't cached, it must have been deleted. + // There's no other authentication here - it's okay for anyone to unlink a deleted channel just by knowing its ID. + channel = {id: channel_id} + } + + // Check channel is currently bridged + const row = select("channel_room", "channel_id", {channel_id: channel_id}).get() + if (!row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not currently bridged`}) + + // Do it + await createRoom.unbridgeDeletedChannel(channel, guild_id) +} + const schema = { linkSpace: z.object({ guild_id: z.string(), @@ -203,27 +230,9 @@ as.router.post("/api/link", defineEventHandler(async event => { as.router.post("/api/unlink", defineEventHandler(async event => { const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse) - const createRoom = getCreateRoom(event) - await validateGuildAccess(event, guild_id) - // Check that the channel (if it exists) is part of this guild - /** @type {any} */ - let channel = discord.channels.get(channel_id) - if (channel) { - if (!("guild_id" in channel) || channel.guild_id !== guild_id) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not part of guild ${guild_id}`}) - } else { - // Otherwise, if the channel isn't cached, it must have been deleted. - // There's no other authentication here - it's okay for anyone to unlink a deleted channel just by knowing its ID. - channel = {id: channel_id} - } - - // Check channel is currently bridged - const row = select("channel_room", "channel_id", {channel_id: channel_id}).get() - if (!row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not currently bridged`}) - - // Do it - await createRoom.unbridgeDeletedChannel(channel, guild_id) + await doRoomUnlink(event, channel_id, guild_id) setResponseHeader(event, "HX-Refresh", "true") return null // 204 From b1d271eb21dddd3f1f197adc99a2dbc51af7a3bb Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 20:05:18 +0900 Subject: [PATCH 15/16] Add /api/unlink-space implementation --- src/web/routes/link.js | 43 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index c7def59b..a6a581fa 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -12,6 +12,8 @@ const auth = sync.require("../auth") const mreq = sync.require("../../matrix/mreq") const {reg} = require("../../matrix/read-registration") +const me = `@${reg.sender_localpart}:${reg.ooye.server_name}` + /** * @param {H3Event} event * @returns {import("../../matrix/api")} @@ -106,7 +108,10 @@ const schema = { unlink: z.object({ guild_id: z.string(), channel_id: z.string() - }) + }), + unlinkSpace: z.object({ + guild_id: z.string(), + }), } as.router.post("/api/link-space", defineEventHandler(async event => { @@ -136,7 +141,6 @@ as.router.post("/api/link-space", defineEventHandler(async event => { } // Check bridge has PL 100 - const me = `@${reg.sender_localpart}:${reg.ooye.server_name}` /** @type {Ty.Event.M_Power_Levels?} */ let powerLevelsStateContent = null try { @@ -237,3 +241,38 @@ as.router.post("/api/unlink", defineEventHandler(async event => { setResponseHeader(event, "HX-Refresh", "true") return null // 204 })) + +as.router.post("/api/unlink-space", defineEventHandler(async event => { + const {guild_id} = await readValidatedBody(event, schema.unlinkSpace.parse) + const api = getAPI(event) + await validateGuildAccess(event, guild_id) + + const spaceID = select("guild_space", "space_id", {guild_id: guild_id}).pluck().get() + if (!spaceID) + throw createError({status: 400, message: "Bad Request", data: "Matrix space does not exist or bot has not linked it"}) + + const linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all() + + for (const channel of linkedChannels) { + await doRoomUnlink(event, channel.channel_id, guild_id) + } + + const remainingLinkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all() + if (remainingLinkedChannels.length !== 0) + throw createError({status: 400, message: "Bad Request", data: "Some linked room still exists after trying to unlink all of them. Aborting the space unlinking..."}) + + await api.setUserPower(spaceID, me, 0) + await api.leaveRoom(spaceID) + + db.prepare("DELETE FROM guild_space WHERE guild_id=? AND space_id=?").run(guild_id, spaceID) + + // NOTE: not deleting from guild_active as this can lead to inconsistent state: + // if we only delete from DB, the guild is still displayed on the top-right dropdown, + // but when selected we get the "Please add the bot to your server using the buttons on the home page." page + // + // So either keep as-is, or delete from guild_active, but also leave the discord guild? Not sure if we want that or not + // db.prepare("DELETE FROM guild_active WHERE guild_id=?").run(guild_id) + + setResponseHeader(event, "HX-Refresh", "true") + return null // 204 +})) From 255d96304939a96131511abfed150a0681357db2 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sun, 7 Sep 2025 15:34:23 +0900 Subject: [PATCH 16/16] WIP: add tests for /api/unlink-space --- src/web/routes/link.test.js | 73 +++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index 0d8d366d..cc39354b 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -630,3 +630,76 @@ test("web unlink room: checks that the channel is bridged", async t => { })) t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged") }) + +// ***** + +test("web unlink space: access denied if not logged in to Discord", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", { + body: { + guild_id: "665289423482519565" + } + })) + t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in") +}) + +test("web unlink space: checks that guild exists", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", { + sessionData: { + managedGuilds: ["2"] + }, + body: { + guild_id: "2" + } + })) + t.equal(error.data, "Discord guild does not exist or bot has not joined it") +}) + +test("web unlink space: checks that a space is linked to the guild", async t => { + const row = db.prepare("SELECT * FROM guild_space WHERE guild_id = '665289423482519565'").get() + db.prepare("DELETE FROM guild_space WHERE guild_id = '665289423482519565'").run() + + const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + guild_id: "665289423482519565" + } + })) + t.equal(error.data, "Matrix space does not exist or bot has not linked it") + + db.prepare("INSERT INTO guild_space (guild_id, space_id, privacy_level, presence, url_preview) VALUES (?, ?, ?, ?, ?)").run(row.guild_id, row.space_id, row.privacy_level, row.presence, row.url_preview) + const new_row = db.prepare("SELECT * FROM guild_space WHERE guild_id = '665289423482519565'").get() + t.deepEqual(row, new_row) +}) + +test("web unlink space: successfully calls unbridgeDeletedChannel on linked channels in space", async t => { + // Need to re-link the room to check it is properly unlinked by the unlink-space + await router.test("post", "/api/link", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + discord: "665310973967597573", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "665289423482519565" + }, + }) + + let called = 0 + await router.test("post", "/api/unlink-space", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + guild_id: "665289423482519565", + }, + createRoom: { + async unbridgeDeletedChannel(channel) { + called++ + t.equal(channel.id, "665310973967597573") + } + } + }) + t.equal(called, 1) +})