From f1313db0289dd62ecbe821db37934034558fd492 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 18:23:01 +0900 Subject: [PATCH 01/12] 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 92ffa1b..cedc32a 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 fc8d400b7d35a4afa4d797a233b8323c05f0f32b Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 19:28:42 +0900 Subject: [PATCH 02/12] 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 c5f404e..54ff133 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 6aa2bfad13432682e18d0cc1e4b14f154df80d08 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 19:59:44 +0900 Subject: [PATCH 03/12] Extract /api/unlink code to its own function --- src/web/routes/link.js | 47 +++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 54ff133..c7def59 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 9492137ca6f44e2eb83eb2d050012d36efedc65f Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 20:05:18 +0900 Subject: [PATCH 04/12] 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 c7def59..9ca62b7 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: 500, message: "Internal Server Error", 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 677d899a92412cae4c6f0657d564ecf8ebf8ba66 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 23 Aug 2025 23:46:51 +1200 Subject: [PATCH 05/12] Fill in more of reg for other people to test with (cherry pick to run tests, to delete after rebase main when fix-remote-join is merged) --- 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 82565eb..da92385 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 233fd94..b01f0ce 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 10d14bbdaae26ecad2de1c6b43a9e7c0b3d464c5 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 1 Nov 2025 22:24:51 +0900 Subject: [PATCH 06/12] add tests from /unlink-space endpoint --- src/web/routes/link.test.js | 147 ++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index 0d8d366..65f8b11 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -618,7 +618,9 @@ test("web unlink room: successfully calls unbridgeDeletedChannel when the channe }) test("web unlink room: checks that the channel is bridged", async t => { + const row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get() db.prepare("DELETE FROM channel_room WHERE channel_id = '665310973967597573'").run() + const [error] = await tryToCatch(() => router.test("post", "/api/unlink", { sessionData: { managedGuilds: ["665289423482519565"] @@ -629,4 +631,149 @@ test("web unlink room: checks that the channel is bridged", async t => { } })) t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged") + + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_id, row.speedbump_checked, row.speedbump_webhook_id, row.guild_id, row.custom_topic) + const new_row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get() + t.deepEqual(row, new_row) +}) + +// ***** + +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 before trying to unlink the space", 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: correctly abort unlinking if some linked channels remain after trying to unlink them all", async t => { + let unbridgedChannel = false + + const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + guild_id: "665289423482519565", + }, + createRoom: { + async unbridgeDeletedChannel(channel, guildID) { + unbridgedChannel = true + t.equal(channel.id, "665310973967597573") + t.equal(guildID, "665289423482519565") + // Do not actually delete the link from DB, should trigger error later in check + } + }, + api: { + async *generateFullHierarchy(spaceID) { + 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 */ + }, + } + })) + + t.equal(error.data, "Some linked room still exists after trying to unlink all of them. Aborting the space unlinking...") + t.equal(unbridgedChannel, true) +}) + +test("web unlink space: successfully calls unbridgeDeletedChannel on linked channels in space, self-downgrade power level, leave space, and delete link from DB", async t => { + const {reg} = require("../../matrix/read-registration") + const me = `@${reg.sender_localpart}:${reg.ooye.server_name}` + + const getLinkRowQuery = "SELECT * FROM guild_space WHERE guild_id = '665289423482519565'" + + const row = db.prepare(getLinkRowQuery).get() + t.equal(row.space_id, "!zTMspHVUBhFLLSdmnS:cadence.moe") + + let unbridgedChannel = false + let downgradedPowerLevel = false + let leftRoom = false + await router.test("post", "/api/unlink-space", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + guild_id: "665289423482519565", + }, + createRoom: { + async unbridgeDeletedChannel(channel, guildID) { + unbridgedChannel = true + t.equal(channel.id, "665310973967597573") + t.equal(guildID, "665289423482519565") + + // In order to not simulate channel deletion and not trigger the post unlink channels, pre-unlink space check + db.prepare("DELETE FROM channel_room WHERE guild_id = '665289423482519565' AND channel_id = '665310973967597573'").run() + } + }, + api: { + async *generateFullHierarchy(spaceID) { + 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 setUserPower(spaceID, targetUser, powerLevel) { + downgradedPowerLevel = true + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + t.equal(targetUser, me) + t.equal(powerLevel, 0) + }, + + async leaveRoom(spaceID) { + leftRoom = true + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + }, + } + }) + + t.equal(unbridgedChannel, true) + t.equal(downgradedPowerLevel, true) + t.equal(leftRoom, true) + + const missed_row = db.prepare(getLinkRowQuery).get() + t.equal(missed_row, undefined) }) From d95a1143774e69b8619d2e00e2d70c5186b99c8e Mon Sep 17 00:00:00 2001 From: Elliu Date: Sun, 2 Nov 2025 07:50:16 +0000 Subject: [PATCH 07/12] Fix matrix api joinRoom() for remote rooms (#60) When using self-service mode and trying to link with a remote matrix room (room not in the same HS as the bridge user), then we need to add the "via" HSs to join the room with, or else it fails. We get it from the "m.space.child" in the "children_state" of the space hierarchy. Co-authored-by: Cadence Ember Reviewed-on: https://gitdab.com/cadence/out-of-your-element/pulls/60 Co-authored-by: Elliu Co-committed-by: Elliu --- jsconfig.json | 9 ++++ src/d2m/converters/message-to-event.js | 1 + src/matrix/api.js | 13 ++++-- src/matrix/api.test.js | 4 ++ src/types.d.ts | 10 ++++- src/web/routes/link.js | 34 +++++++++++---- src/web/routes/link.test.js | 60 +++++++++++++++++++++++--- test/test.js | 2 + 8 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 jsconfig.json diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..4106061 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "es2024", + "module": "nodenext", + "strict": true, + "noImplicitAny": false, + "useUnknownInCatchVariables": false + } +} diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 30a20fe..93e120e 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -207,6 +207,7 @@ async function attachmentToEvent(mentions, attachment) { * - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true. * - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned. * @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API + * @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>} */ async function messageToEvent(message, guild, options = {}, di) { const events = [] diff --git a/src/matrix/api.js b/src/matrix/api.js index 709d70c..edffc45 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -22,7 +22,11 @@ function path(p, mxid, otherParams = {}) { const u = new URL(p, "http://localhost") if (mxid) u.searchParams.set("user_id", mxid) for (const entry of Object.entries(otherParams)) { - if (entry[1] != undefined) { + if (Array.isArray(entry[1])) { + for (const element of entry[1]) { + u.searchParams.append(entry[0], element) + } + } else if (entry[1] != undefined) { u.searchParams.set(entry[0], entry[1]) } } @@ -62,11 +66,14 @@ async function createRoom(content) { } /** + * @param {string} roomIDOrAlias + * @param {string?} [mxid] + * @param {string[]?} [via] * @returns {Promise} room ID */ -async function joinRoom(roomIDOrAlias, mxid) { +async function joinRoom(roomIDOrAlias, mxid, via) { /** @type {Ty.R.RoomJoined} */ - const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid), {}) + const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid, {via}), {}) return root.room_id } diff --git a/src/matrix/api.test.js b/src/matrix/api.test.js index 82565eb..da92385 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/src/types.d.ts b/src/types.d.ts index 27dfddf..c7cb006 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -149,6 +149,14 @@ export namespace Event { prev_content?: any } + export type StrippedChildStateEvent = { + type: string + state_key: string + sender: string + origin_server_ts: number + content: any + } + export type M_Room_Message = { msgtype: "m.text" | "m.emote" body: string @@ -345,7 +353,7 @@ export namespace R { export type Hierarchy = { avatar_url?: string canonical_alias?: string - children_state: {} + children_state: Event.StrippedChildStateEvent[] guest_can_join: boolean join_rule?: string name?: string diff --git a/src/web/routes/link.js b/src/web/routes/link.js index c5f404e..0afbc49 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -75,11 +75,15 @@ as.router.post("/api/link-space", defineEventHandler(async event => { const existing = select("guild_space", "guild_id", {}, "WHERE guild_id = ? OR space_id = ?").get(guildID, spaceID) if (existing) throw createError({status: 400, message: "Bad Request", data: `Guild ID ${guildID} or space ID ${spaceID} are already bridged and cannot be reused`}) + const inviteSender = select("invite", "mxid", {mxid: session.data.mxid, room_id: spaceID}).pluck().get() + const inviteSenderServer = inviteSender?.match(/:(.*)/)?.[1] + const via = [inviteSenderServer || ""] + // Check space exists and bridge is joined try { - await api.joinRoom(parsedBody.space_id) + await api.joinRoom(parsedBody.space_id, null, via) } catch (e) { - throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) + throw createError({status: 400, message: "Unable To Join", data: `Unable to join the requested Matrix space. Please invite the bridge to the space and try again. (Server said: ${e.errcode} - ${e.message})`}) } // Check bridge has PL 100 @@ -134,19 +138,33 @@ 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 - let found = false + let foundRoom = false + /** @type {string[]?} */ + let foundVia = null for await (const room of api.generateFullHierarchy(spaceID)) { - if (room.room_id === parsedBody.matrix && !room.room_type) { - found = true - break + // When finding a space during iteration, look at space's children state, because we need a `via` to join the room (when we find it later) + for (const state of room.children_state) { + if (state.type === "m.space.child" && state.state_key === parsedBody.matrix) { + foundVia = state.content.via + } } + + // When finding a room during iteration, see if it was the requested room (to confirm that the room is in the space) + if (room.room_id === parsedBody.matrix && !room.room_type) { + foundRoom = true + } + + if (foundRoom && foundVia) break } - if (!found) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) + if (!foundRoom) 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 { - await api.joinRoom(parsedBody.matrix) + await api.joinRoom(parsedBody.matrix, null, foundVia) } catch (e) { + if (!foundVia) { + throw createError({status: 400, message: "Unable To Join", data: `Unable to join the requested Matrix room. Please invite the bridge to the room and try again. (Server said: ${e.errcode} - ${e.message})`}) + } throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) } diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index 0d8d366..ffe4e5e 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -77,7 +77,7 @@ test("web link space: check that OOYE is joined", async t => { } } })) - t.equal(error.data, "M_FORBIDDEN - not allowed to join I guess") + t.equal(error.data, "Unable to join the requested Matrix space. Please invite the bridge to the space and try again. (Server said: M_FORBIDDEN - not allowed to join I guess)") t.equal(called, 1) }) @@ -360,7 +360,7 @@ test("web link room: check that room is part of space (not in hierarchy)", async t.equal(called, 1) }) -test("web link room: check that bridge can join room", async t => { +test("web link room: check that bridge can join room (notices lack of via and asks for invite instead)", async t => { let called = 0 const [error] = await tryToCatch(() => router.test("post", "/api/link", { sessionData: { @@ -381,7 +381,55 @@ test("web link room: check that bridge can join room", async t => { t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") yield { room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: {}, + children_state: [], + guest_can_join: false, + num_joined_members: 2 + } + /* c8 ignore next */ + } + } + })) + t.equal(error.data, "Unable to join the requested Matrix room. Please invite the bridge to the room and try again. (Server said: M_FORBIDDEN - not allowed to join I guess)") + t.equal(called, 2) +}) + +test("web link room: check that bridge can join room (uses via for join attempt)", async t => { + let called = 0 + const [error] = await tryToCatch(() => router.test("post", "/api/link", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + discord: "665310973967597573", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "665289423482519565" + }, + api: { + async joinRoom(roomID, _, via) { + called++ + t.deepEqual(via, ["cadence.moe", "hashi.re"]) + throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) + }, + 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 + } + yield { + room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", + children_state: [{ + type: "m.space.child", + state_key: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + sender: "@elliu:hashi.re", + content: { + via: ["cadence.moe", "hashi.re"] + }, + origin_server_ts: 0 + }], guest_can_join: false, num_joined_members: 2 } @@ -414,7 +462,7 @@ test("web link room: check that bridge has PL 100 in target room (event missing) t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") yield { room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: {}, + children_state: [], guest_can_join: false, num_joined_members: 2 } @@ -454,7 +502,7 @@ test("web link room: check that bridge has PL 100 in target room (users default) t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") yield { room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: {}, + children_state: [], guest_can_join: false, num_joined_members: 2 } @@ -494,7 +542,7 @@ test("web link room: successfully calls createRoom", async t => { t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") yield { room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: {}, + children_state: [], guest_can_join: false, num_joined_members: 2 } diff --git a/test/test.js b/test/test.js index 233fd94..b01f0ce 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 05d788e26394106d9be24cef8b38f6c6f1e4c984 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 18:23:01 +0900 Subject: [PATCH 08/12] 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 92ffa1b..cedc32a 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 3597a3b5ce9dde3a9ddfe0853253bfda91a38335 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 19:28:42 +0900 Subject: [PATCH 09/12] 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 0afbc49..2a970bb 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"}) @@ -112,18 +138,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 @@ -201,15 +221,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 dfc61594f68db4b52b3553ac7d3561ae9ce13b49 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 19:59:44 +0900 Subject: [PATCH 10/12] Extract /api/unlink code to its own function --- src/web/routes/link.js | 47 +++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 2a970bb..7e7df5a 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(), @@ -221,27 +248,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 5aff6f9048330a86eda3b2d1862f42df8d2bad84 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 20:05:18 +0900 Subject: [PATCH 11/12] 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 7e7df5a..4698dfe 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 => { @@ -140,7 +145,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 { @@ -255,3 +259,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: 500, message: "Internal Server Error", 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 16309f26b3dd72927e05454cee8c63504b447b7f Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 1 Nov 2025 22:24:51 +0900 Subject: [PATCH 12/12] add tests from /unlink-space endpoint --- src/web/routes/link.test.js | 147 ++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index ffe4e5e..4b29891 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -666,7 +666,9 @@ test("web unlink room: successfully calls unbridgeDeletedChannel when the channe }) test("web unlink room: checks that the channel is bridged", async t => { + const row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get() db.prepare("DELETE FROM channel_room WHERE channel_id = '665310973967597573'").run() + const [error] = await tryToCatch(() => router.test("post", "/api/unlink", { sessionData: { managedGuilds: ["665289423482519565"] @@ -677,4 +679,149 @@ test("web unlink room: checks that the channel is bridged", async t => { } })) t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged") + + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_id, row.speedbump_checked, row.speedbump_webhook_id, row.guild_id, row.custom_topic) + const new_row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get() + t.deepEqual(row, new_row) +}) + +// ***** + +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 before trying to unlink the space", 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: correctly abort unlinking if some linked channels remain after trying to unlink them all", async t => { + let unbridgedChannel = false + + const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + guild_id: "665289423482519565", + }, + createRoom: { + async unbridgeDeletedChannel(channel, guildID) { + unbridgedChannel = true + t.equal(channel.id, "665310973967597573") + t.equal(guildID, "665289423482519565") + // Do not actually delete the link from DB, should trigger error later in check + } + }, + api: { + async *generateFullHierarchy(spaceID) { + 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 */ + }, + } + })) + + t.equal(error.data, "Some linked room still exists after trying to unlink all of them. Aborting the space unlinking...") + t.equal(unbridgedChannel, true) +}) + +test("web unlink space: successfully calls unbridgeDeletedChannel on linked channels in space, self-downgrade power level, leave space, and delete link from DB", async t => { + const {reg} = require("../../matrix/read-registration") + const me = `@${reg.sender_localpart}:${reg.ooye.server_name}` + + const getLinkRowQuery = "SELECT * FROM guild_space WHERE guild_id = '665289423482519565'" + + const row = db.prepare(getLinkRowQuery).get() + t.equal(row.space_id, "!zTMspHVUBhFLLSdmnS:cadence.moe") + + let unbridgedChannel = false + let downgradedPowerLevel = false + let leftRoom = false + await router.test("post", "/api/unlink-space", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + guild_id: "665289423482519565", + }, + createRoom: { + async unbridgeDeletedChannel(channel, guildID) { + unbridgedChannel = true + t.equal(channel.id, "665310973967597573") + t.equal(guildID, "665289423482519565") + + // In order to not simulate channel deletion and not trigger the post unlink channels, pre-unlink space check + db.prepare("DELETE FROM channel_room WHERE guild_id = '665289423482519565' AND channel_id = '665310973967597573'").run() + } + }, + api: { + async *generateFullHierarchy(spaceID) { + 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 setUserPower(spaceID, targetUser, powerLevel) { + downgradedPowerLevel = true + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + t.equal(targetUser, me) + t.equal(powerLevel, 0) + }, + + async leaveRoom(spaceID) { + leftRoom = true + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + }, + } + }) + + t.equal(unbridgedChannel, true) + t.equal(downgradedPowerLevel, true) + t.equal(leftRoom, true) + + const missed_row = db.prepare(getLinkRowQuery).get() + t.equal(missed_row, undefined) })