Fully support unlinking channels

This commit is contained in:
Cadence Ember 2025-01-31 15:09:01 +13:00
parent 5c0e830658
commit b1b9124052
4 changed files with 84 additions and 26 deletions
docs
src
d2m/actions
web

View file

@ -45,7 +45,7 @@ Here are some tables that could potentially have foreign keys added between them
* The storage cost of the additional index on `sim` would not be worth the benefits.
* `channel_room` <--(**C** room_id PK)-- `sim_member`
* If a room is being permanently unlinked, it may be useful to see a populated member list. If it's about to be relinked to another channel, we want to keep the sims in the room for more speed and to avoid spamming state events into the timeline.
* Either way, the sims should remain in the room even after it's been unlinked. So no referential integrity is desirable here.
* Either way, the sims could remain in the room even after it's been unlinked. So no referential integrity is desirable here.
* `sim` <--(PK user_id PK)-- `sim_proxy`
* OOYE left joins on this. In normal operation, this relationship might not exist.
* `channel_room` <--(PK channel_id PK)-- `webhook`

View file

@ -6,7 +6,7 @@ const Ty = require("../../types")
const {reg} = require("../../matrix/read-registration")
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
const {discord, sync, db, select, from} = passthrough
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("../../matrix/api")} */
@ -14,7 +14,9 @@ const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/kstate")} */
const ks = sync.require("../../matrix/kstate")
/** @type {import("../../discord/utils")} */
const utils = sync.require("../../discord/utils")
const dUtils = sync.require("../../discord/utils")
/** @type {import("../../m2d/converters/utils")} */
const mUtils = sync.require("../../m2d/converters/utils")
/** @type {import("./create-space")} */
const createSpace = sync.require("./create-space")
@ -114,8 +116,8 @@ async function channelToKState(channel, guild, di) {
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
}
const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
const everyoneCanMentionEveryone = utils.hasAllPermissions(everyonePermissions, ["MentionEveryone"])
const everyonePermissions = dUtils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
const everyoneCanMentionEveryone = dUtils.hasAllPermissions(everyonePermissions, ["MentionEveryone"])
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})
@ -392,7 +394,7 @@ function syncRoom(channelID) {
return _syncRoom(channelID, true)
}
async function _unbridgeRoom(channelID) {
async function unbridgeChannel(channelID) {
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
const channel = discord.channels.get(channelID)
assert.ok(channel)
@ -407,12 +409,8 @@ async function _unbridgeRoom(channelID) {
async function unbridgeDeletedChannel(channel, guildID) {
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
assert.ok(roomID)
const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get()
assert.ok(spaceID)
// remove room from being a space member
await api.sendState(roomID, "m.space.parent", spaceID, {})
await api.sendState(spaceID, "m.space.child", roomID, {})
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get()
assert.ok(row)
// remove declaration that the room is bridged
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {})
@ -421,15 +419,6 @@ async function unbridgeDeletedChannel(channel, guildID) {
await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""})
}
// send a notification in the room
await api.sendEvent(roomID, "m.room.message", {
msgtype: "m.notice",
body: "⚠️ This room was removed from the bridge."
})
// leave room
await api.leaveRoom(roomID)
// delete webhook on discord
const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get()
if (webhook) {
@ -439,7 +428,48 @@ async function unbridgeDeletedChannel(channel, guildID) {
// delete room from database
db.prepare("DELETE FROM member_cache WHERE room_id = ?").run(roomID)
db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id)
db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages
// demote admins in room
/** @type {Ty.Event.M_Power_Levels} */
const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
powerLevelContent.users ??= {}
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
for (const mxid of Object.keys(powerLevelContent.users)) {
if (mUtils.eventSenderIsFromDiscord(mxid) && mxid !== bot) {
delete powerLevelContent.users[mxid]
await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid)
}
}
// send a notification in the room
await api.sendEvent(roomID, "m.room.message", {
msgtype: "m.notice",
body: "⚠️ This room was removed from the bridge."
})
// if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged
// (don't do this for self-service rooms, because they might continue to be used on Matrix or linked somewhere else later)
if (row.autocreate === 1) {
// remove room from being a space member
await api.sendState(roomID, "m.space.parent", row.space_id, {})
await api.sendState(row.space_id, "m.space.child", roomID, {})
// leave room
await api.leaveRoom(roomID)
}
// if it is a self-service room, remove sim members
// (the room can be used with less clutter and the member list makes sense if it's bridged somewhere else)
if (row.autocreate === 0) {
// remove sim members
const members = select("sim_member", "mxid", {room_id: roomID}).pluck().all()
const preparedDelete = db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?")
for (const mxid of members) {
await api.leaveRoom(roomID, mxid)
preparedDelete.run(roomID, mxid)
}
}
}
/**
@ -488,7 +518,7 @@ module.exports.createAllForGuild = createAllForGuild
module.exports.channelToKState = channelToKState
module.exports.postApplyPowerLevels = postApplyPowerLevels
module.exports._convertNameAndTopic = convertNameAndTopic
module.exports._unbridgeRoom = _unbridgeRoom
module.exports.unbridgeChannel = unbridgeChannel
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
module.exports.existsOrAutocreatable = existsOrAutocreatable
module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable

View file

@ -78,12 +78,13 @@ block body
h3.mt32.fs-category Linked channels
.s-card.bs-sm.p0
.s-table-container
form.s-table-container(method="post" action="/api/unlink" hx-post="/api/unlink" hx-trigger="submit" hx-disabled-elt="this" hx-confirm="Unlink these channels?\nIt may take a moment to clean up Matrix resources.")
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!= icons.Icons.IconLinkSm
td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" value=row.channel.id)!= icons.Icons.IconLinkSm
td: +matrix(row)
else
tr

View file

@ -1,7 +1,7 @@
// @ts-check
const {z} = require("zod")
const {defineEventHandler, useSession, createError, readValidatedBody} = require("h3")
const {defineEventHandler, useSession, createError, readValidatedBody, setResponseHeader} = require("h3")
const Ty = require("../../types")
const {discord, db, as, sync, select, from} = require("../../passthrough")
@ -19,6 +19,10 @@ const schema = {
guild_id: z.string(),
matrix: z.string(),
discord: z.string()
}),
unlink: z.object({
guild_id: z.string(),
channel_id: z.string()
})
}
@ -61,3 +65,26 @@ as.router.post("/api/link", defineEventHandler(async event => {
return null // 204
}))
as.router.post("/api/unlink", defineEventHandler(async event => {
const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse)
const session = await useSession(event, {password: reg.as_token})
// Check guild ID or nonce
if (!(session.data.managedGuilds || []).includes(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
// Check channel is part of this guild
const channel = discord.channels.get(channel_id)
if (!channel) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} does not exist`})
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}`})
// 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)
setResponseHeader(event, "HX-Refresh", "true")
return null // 204
}))