From 35e9c9e1ea2d35301ea102496bb28499cf75e1d2 Mon Sep 17 00:00:00 2001 From: Elliu Date: Fri, 13 Feb 2026 19:12:01 +1300 Subject: [PATCH] Add unlink space feature Squashed commit of the following: commit bd9fd5cd3cf3f1301df18074c997ec537a81b4f5 Author: Elliu Date: Sat Nov 15 15:32:18 2025 +0900 Revert "fix matrix / db resource cleanup on space unlink" This reverts commit ccc10564f1e33ab277bc15f360b8c65f2d0ea867. commit eec559293861305394770343d501389905fe1650 Author: Cadence Ember Date: Sat Nov 8 13:01:59 2025 +1300 Dependency inject snow for testing commit b45eeb150e0702c201b8f710a3bdaa8e9f7d90be Author: Elliu Date: Wed Nov 5 00:20:20 2025 +0900 manually revert 3597a3b: "Factorize some of the space link/unlink sanity checks" commit 0f2e575df21bf940e4780c30d2701da989f62471 Author: Elliu Date: Wed Nov 5 00:04:38 2025 +0900 on unbriding room, also demote powel level of bridge user in matrix room commit ccc10564f1e33ab277bc15f360b8c65f2d0ea867 Author: Elliu Date: Wed Nov 5 00:04:13 2025 +0900 fix matrix / db resource cleanup on space unlink commit f4c1ea7c7f7d5a265b84ce464cd8e9e26d934a32 Author: Elliu Date: Tue Nov 4 23:54:41 2025 +0900 /unlink-space: properly leave guild and clean DB commit 5f0ec3b2c861cc8b9edc51389d6176c7a22a1135 Author: Cadence Ember Date: Sun Nov 2 22:31:14 2025 +1300 Improve HTML to a state I'm happy with commit 16309f26b3dd72927e05454cee8c63504b447b7f Author: Elliu Date: Sat Nov 1 22:24:51 2025 +0900 add tests from /unlink-space endpoint commit 5aff6f9048330a86eda3b2d1862f42df8d2bad84 Author: Elliu Date: Sat Sep 6 20:05:18 2025 +0900 Add /api/unlink-space implementation commit dfc61594f68db4b52b3553ac7d3561ae9ce13b49 Author: Elliu Date: Sat Sep 6 19:59:44 2025 +0900 Extract /api/unlink code to its own function commit 3597a3b5ce9dde3a9ddfe0853253bfda91a38335 Author: Elliu Date: Sat Sep 6 19:28:42 2025 +0900 Factorize some of the space link/unlink sanity checks commit 05d788e26394106d9be24cef8b38f6c6f1e4c984 Author: Elliu Date: Sat Sep 6 18:23:01 2025 +0900 Add button to unlink a space Co-authored-by: Cadence Ember --- src/d2m/actions/create-room.js | 27 ++--- src/d2m/actions/create-space.js | 2 +- src/d2m/event-dispatcher.js | 2 +- src/web/pug/guild.pug | 22 +++- src/web/pug/guild_not_linked.pug | 19 ++- src/web/pug/includes/template.pug | 7 ++ src/web/routes/link.js | 111 ++++++++++++++---- src/web/routes/link.test.js | 185 +++++++++++++++++++++++++++++- test/ooye-test-data.sql | 2 +- 9 files changed, 322 insertions(+), 55 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 735b84c..651eaf4 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -439,19 +439,11 @@ function syncRoom(channelID) { return _syncRoom(channelID, true) } -async function unbridgeChannel(channelID) { - /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ - const channel = discord.channels.get(channelID) - assert.ok(channel) - assert.ok(channel.guild_id) - return unbridgeDeletedChannel(channel, channel.guild_id) -} - /** * @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional) * @param {string} guildID */ -async function unbridgeDeletedChannel(channel, guildID) { +async function unbridgeChannel(channel, guildID) { const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() assert.ok(roomID) const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get() @@ -488,14 +480,13 @@ async function unbridgeDeletedChannel(channel, guildID) { if (!botInRoom) return - // demote admins in room - /** @type {Ty.Event.M_Power_Levels} */ - const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "") - powerLevelContent.users ??= {} - for (const mxid of Object.keys(powerLevelContent.users)) { - if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== mUtils.bot) { - delete powerLevelContent.users[mxid] - await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid) + // demote discord sim admins in room + const {powerLevels, allCreators} = await mUtils.getEffectivePower(roomID, [], api) + const powerLevelsUsers = (powerLevels.users ||= {}) + for (const mxid of Object.keys(powerLevelsUsers)) { + if (powerLevelsUsers[mxid] >= (powerLevels.state_default ?? 50) && !allCreators.includes(mxid) && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== mUtils.bot) { + delete powerLevelsUsers[mxid] + await api.sendState(roomID, "m.room.power_levels", "", powerLevels, mxid) // done individually because each user must demote themselves } } @@ -526,6 +517,7 @@ async function unbridgeDeletedChannel(channel, guildID) { } // leave room + await mUtils.setUserPower(roomID, mUtils.bot, 0, api) await api.leaveRoom(roomID) } @@ -589,6 +581,5 @@ module.exports.postApplyPowerLevels = postApplyPowerLevels module.exports._convertNameAndTopic = convertNameAndTopic module.exports._syncSpaceMember = _syncSpaceMember module.exports.unbridgeChannel = unbridgeChannel -module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel module.exports.existsOrAutocreatable = existsOrAutocreatable module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable diff --git a/src/d2m/actions/create-space.js b/src/d2m/actions/create-space.js index 1417b2d..7a751e2 100644 --- a/src/d2m/actions/create-space.js +++ b/src/d2m/actions/create-space.js @@ -203,7 +203,7 @@ async function syncSpaceFully(guildID) { if (discord.channels.has(channelID)) { await createRoom.syncRoom(channelID) } else { - await createRoom.unbridgeDeletedChannel({id: channelID}, guildID) + await createRoom.unbridgeChannel({id: channelID}, guildID) } } diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index c3bba33..01bbc67 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -250,7 +250,7 @@ module.exports = { const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() if (!roomID) return // channel wasn't being bridged in the first place // @ts-ignore - await createRoom.unbridgeDeletedChannel(channel, guildID) + await createRoom.unbridgeChannel(channel, guildID) }, /** diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index c219e29..a9e770b 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -75,6 +75,7 @@ block body button.s-btn(class=space_id ? "s-btn__muted" : "s-btn__filled" hx-get=rel(`/qr?guild_id=${guild_id}`) hx-indicator="closest button" hx-swap="outerHTML" hx-disabled-elt="this") Show QR if space_id + h2.mt48.fs-headline1 Server settings h3.mt32.fs-category Privacy level span#privacy-level-loading .s-card @@ -104,7 +105,7 @@ block body p.s-description.m0 Shareable invite links, like Discord p.s-description.m0 Publicly listed in directory, like Discord server discovery - h2.mt48.fs-headline1 Features + h3.mt32.fs-category Features .s-card.d-grid.px0.g16 form.d-flex.ai-center.g16 #url-preview-loading.p8 @@ -138,13 +139,13 @@ block body h3.mt32.fs-category Linked channels .s-card.bs-sm.p0 - form.s-table-container(method="post" action=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources.") + form.s-table-container(method="post" action=rel("/api/unlink")) input(type="hidden" name="guild_id" value=guild_id) table.s-table.s-table__bx-simple each row in linkedChannelsWithDetails tr td.w40: +discord(row.channel) - td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" value=row.channel.id hx-post=rel("/api/unlink") hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm + td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" cx-prevent-default hx-post=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources." value=row.channel.id hx-indicator="this" hx-disabled-elt="this")!= icons.Icons.IconLinkSm td: +matrix(row) else tr @@ -185,6 +186,19 @@ block body != icons.Icons.IconMerge = ` Link` + h3.mt32.fs-category Unlink server + form.s-card.d-flex.gx16.ai-center(method="post" action=rel("/api/unlink-space")) + input(type="hidden" name="guild_id" value=guild.id) + .fl-grow1.s-prose.s-prose__sm.lh-lg + p.fc-medium. + Not using this bridge, or just made a mistake? You can unlink the whole server and all its channels.#[br] + This may take a minute to process. Please be patient and wait until the page refreshes. + div + button.s-btn.s-btn__icon.s-btn__danger.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server and all its channels?\nIt may take a minute to clean up Matrix resources." hx-indicator="this" hx-disabled-elt="this") + != icons.Icons.IconUnsync + span.ml4= ` Unlink` + + if space_id details.mt48 summary Debug room list .d-grid.grid__2.gx24 @@ -205,7 +219,7 @@ block body ul.my8.ml24 each row in removedWrongTypeChannels li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} - h3.mt24 Unavailable channels: Bridge can't access + h3.mt24 Unavailable channels: Discord bot can't access .s-card.p0 ul.my8.ml24 each row in removedPrivateChannels diff --git a/src/web/pug/guild_not_linked.pug b/src/web/pug/guild_not_linked.pug index 61c57e9..04d2dae 100644 --- a/src/web/pug/guild_not_linked.pug +++ b/src/web/pug/guild_not_linked.pug @@ -42,12 +42,23 @@ block body | You need to log in with Matrix first. a.s-btn.s-btn__matrix.s-btn__outlined(href=rel(`/log-in-with-matrix`, {next: `./guild?guild_id=${guild_id}`})) Log in with Matrix - h3.mt48.fs-category Auto-create - .s-card + h3.mt48.fs-category Other choices + .s-card.d-grid.g16 form.d-flex.ai-center.g8(method="post" action=rel("/api/autocreate") hx-post=rel("/api/autocreate") hx-indicator="#easy-mode-button") input(type="hidden" name="guild_id" value=guild_id) input(type="hidden" name="autocreate" value="true") label.s-label.fl-grow1 - | Changed your mind? + | Do it automatically p.s-description If you want, OOYE can create and manage the Matrix space so you don't have to. - button.s-btn.s-btn__outlined#easy-mode-button Use easy mode + button.s-btn.s-btn__icon.s-btn__outlined#easy-mode-button + != icons.Icons.IconWand + span.ml4= ` Use easy mode` + + form.d-flex.gx16.ai-center(method="post" action=rel("/api/unlink-space")) + input(type="hidden" name="guild_id" value=guild.id) + label.s-label.fl-grow1 + | Cancel + p.s-description Don't want to link this server after all? Here's the button for you. + button.s-btn.s-btn__icon.s-btn__muted.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-indicator="this" hx-disabled-elt="this") + != icons.Icons.IconUnsync + span.ml4= ` Unlink` diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index 93aaefc..452f8d5 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -129,6 +129,13 @@ html(lang="en") document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length) }) }) + //- Prevent default + script. + document.querySelectorAll("[cx-prevent-default]").forEach(e => { + e.addEventListener("click", event => { + event.preventDefault() + }) + }) script(src=rel("/static/htmx.js")) //- Error dialog aside.s-modal#server-error(aria-hidden="true") diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 10596f2..3ae6de5 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -9,11 +9,8 @@ const DiscordTypes = require("discord-api-types/v10") const {discord, db, as, sync, select, from} = require("../../passthrough") /** @type {import("../auth")} */ const auth = sync.require("../auth") -/** @type {import("../../matrix/mreq")} */ -const mreq = sync.require("../../matrix/mreq") /** @type {import("../../matrix/utils")}*/ const utils = sync.require("../../matrix/utils") -const {reg} = require("../../matrix/read-registration") /** * @param {H3Event} event @@ -42,6 +39,15 @@ function getCreateSpace(event) { return event.context.createSpace || sync.require("../../d2m/actions/create-space") } +/** + * @param {H3Event} event + * @returns {import("snowtransfer").SnowTransfer} + */ +function getSnow(event) { + /* c8 ignore next */ + return event.context.snow || discord.snow +} + const schema = { linkSpace: z.object({ guild_id: z.string(), @@ -55,7 +61,37 @@ const schema = { unlink: z.object({ guild_id: z.string(), channel_id: z.string() - }) + }), + unlinkSpace: z.object({ + guild_id: z.string(), + }), +} + +/** + * @param {H3Event} event + * @param {string} channel_id + * @param {string} guild_id + */ +async function validateAndUnbridgeChannel(event, channel_id, guild_id) { + const createRoom = getCreateRoom(event) + + // 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`}) + + // 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} + } + + // Do it + await createRoom.unbridgeChannel(channel, guild_id) } as.router.post("/api/link-space", defineEventHandler(async event => { @@ -195,7 +231,6 @@ 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"}) @@ -204,24 +239,56 @@ as.router.post("/api/unlink", defineEventHandler(async event => { 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"}) - // 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 validateAndUnbridgeChannel(event, channel_id, guild_id) 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 managed = await auth.getManagedGuilds(event) + const api = getAPI(event) + const snow = getSnow(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"}) + + const active = select("guild_active", "guild_id", {guild_id: guild_id}).get() + if (!active) { + throw createError({status: 400, message: "Bad Request", data: "Discord guild has not been considered for bridging"}) + } + + // Check if there are Matrix resources + const spaceID = select("guild_space", "space_id", {guild_id: guild_id}).pluck().get() + if (spaceID) { + // Unlink all rooms + const linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all() + for (const channel of linkedChannels) { + await validateAndUnbridgeChannel(event, channel.channel_id, guild_id) + } + + // Verify all rooms were unlinked + const remainingLinkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all() + if (remainingLinkedChannels.length) { + throw createError({status: 500, message: "Internal Server Error", data: "Failed to unlink some rooms. Please try doing it manually, or report a bug. The space will not be unlinked until all rooms are."}) + } + + // Unlink space + await utils.setUserPower(spaceID, utils.bot, 0, api) + await api.leaveRoom(spaceID) + db.prepare("DELETE FROM guild_space WHERE guild_id = ? AND space_id = ?").run(guild_id, spaceID) + } + + // Mark as not considered for bridging + db.prepare("DELETE FROM guild_active WHERE guild_id = ?").run(guild_id) + db.prepare("DELETE FROM invite WHERE room_id = ?").run(spaceID) + await snow.user.leaveGuild(guild_id) + + setResponseHeader(event, "HX-Redirect", "/") + return null +})) diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index 440bdfc..e8473f8 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -613,7 +613,7 @@ test("web unlink room: checks that the channel is part of the guild", async t => t.equal(error.data, "Channel ID 112760669178241024 is not part of guild 665289423482519565") }) -test("web unlink room: successfully calls unbridgeDeletedChannel when the channel does exist", async t => { +test("web unlink room: successfully calls unbridgeChannel when the channel does exist", async t => { let called = 0 await router.test("post", "/api/unlink", { sessionData: { @@ -624,7 +624,7 @@ test("web unlink room: successfully calls unbridgeDeletedChannel when the channe guild_id: "665289423482519565" }, createRoom: { - async unbridgeDeletedChannel(channel) { + async unbridgeChannel(channel) { called++ t.equal(channel.id, "665310973967597573") } @@ -633,7 +633,7 @@ test("web unlink room: successfully calls unbridgeDeletedChannel when the channe t.equal(called, 1) }) -test("web unlink room: successfully calls unbridgeDeletedChannel when the channel does not exist", async t => { +test("web unlink room: successfully calls unbridgeChannel when the channel does not exist", async t => { let called = 0 await router.test("post", "/api/unlink", { sessionData: { @@ -644,7 +644,7 @@ test("web unlink room: successfully calls unbridgeDeletedChannel when the channe guild_id: "112760669178241024" }, createRoom: { - async unbridgeDeletedChannel(channel) { + async unbridgeChannel(channel) { called++ t.equal(channel.id, "489237891895768942") } @@ -654,7 +654,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"] @@ -665,4 +667,179 @@ 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 => { + db.exec("BEGIN TRANSACTION") + db.prepare("DELETE FROM guild_active 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, "Discord guild has not been considered for bridging") + + db.exec("ROLLBACK") // ぬ +}) + +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 unbridgeChannel(channel, guildID) { + unbridgedChannel = true + t.ok(["1438284564815548418", "665310973967597573"].includes(channel.id)) + 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, "Failed to unlink some rooms. Please try doing it manually, or report a bug. The space will not be unlinked until all rooms are.") + t.equal(unbridgedChannel, true) +}) + +test("web unlink space: successfully calls unbridgeChannel 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 unbridgeChannel(channel, guildID) { + unbridgedChannel = true + t.ok(["1438284564815548418", "665310973967597573"].includes(channel.id)) + 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 channel_id = ?").run(channel.id) + } + }, + snow: { + user: { + // @ts-ignore - snowtransfer or discord-api-types broken, 204 No Content should be mapped to void but is actually mapped to never + async leaveGuild(guildID) { + t.equal(guildID, "665289423482519565") + } + } + }, + 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 getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@_ooye_bot:cadence.moe": 100, "@example:matrix.org": 50}, events: {"m.room.tombstone": 100}} + }, + + async getStateEventOuter(roomID, type, key) { + t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + t.equal(type, "m.room.create") + t.equal(key, "") + return { + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", + event_id: "$create", + origin_server_ts: 0, + content: { + room_version: "11" + } + } + }, + + async sendState(roomID, type, key, content) { + downgradedPowerLevel = true + t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + t.equal(type, "m.room.power_levels") + t.notOk(me in content.users, `got ${JSON.stringify(content)} but expected bot user to not be present`) + return "" + }, + + 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) }) diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 216581c..3df5901 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -22,7 +22,7 @@ INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom ('176333891320283136', '!qzDBLKlildpzrrOnFZ:cadence.moe', '🌈丨davids-horse_she-took-the-kids', 'wonderland', NULL, 'mxc://cadence.moe/EVvrSkKIRONHjtRJsMLmHWLS', '112760669178241024'), ('489237891895768942', '!tnedrGVYKFNUdnegvf:tchncs.de', 'ex-room-doesnt-exist-any-more', NULL, NULL, NULL, '66192955777486848'), ('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL, '66192955777486848'), -('1161864271370666075', '!mHmhQQPwXNananMUqq:cadence.moe', 'updates', NULL, NULL, NULL, '665289423482519565'), +('1161864271370666075', '!mHmhQQPwXNananMUqq:cadence.moe', 'updates', NULL, NULL, NULL, '112760669178241024'), ('1438284564815548418', '!MHxNpwtgVqWOrmyoTn:cadence.moe', 'sin-cave', NULL, NULL, NULL, '665289423482519565'), ('598707048112193536', '!JBxeGYnzQwLnaooOLD:cadence.moe', 'winners', NULL, NULL, NULL, '1345641201902288987');