From b0a0e62a8626782a38dbc29cde3b130f9264a7a2 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 25 Sep 2024 01:58:26 +1200 Subject: [PATCH] Adapt createRoom/space/invite to self-service --- docs/self-service-room-creation-rules.md | 4 + package-lock.json | 12 +-- src/d2m/actions/create-room.js | 99 ++++++++++++++++-------- src/d2m/actions/create-space.js | 3 + src/discord/interactions/invite.js | 22 ++++-- test/data.js | 2 +- 6 files changed, 96 insertions(+), 46 deletions(-) diff --git a/docs/self-service-room-creation-rules.md b/docs/self-service-room-creation-rules.md index 1638f09..c47ca14 100644 --- a/docs/self-service-room-creation-rules.md +++ b/docs/self-service-room-creation-rules.md @@ -61,6 +61,8 @@ So there will be 3 states of whether a guild is self-service or not. At first, i Pressing buttons on web or using the /invite command on a guild will insert a row into guild_active, allowing it to be bridged. +One more thing. Before v3, when a Matrix room was autocreated it would autocreate the space as well, if it needed to. But now, since nothing will be created until the user takes an action, the guild will always be created directly in response to a request. So room creation can now trust that the guild exists already. + So here's all the technical changes needed to support self-service in v3: - New guild_active table showing whether, and how, a guild is bridged. @@ -68,3 +70,5 @@ So here's all the technical changes needed to support self-service in v3: - When bot is added through "easy mode" web button, REPLACE INTO state 1 and ensureSpace. - When bot is added through "self-service" web button, REPLACE INTO state 0. - Event dispatcher will only ensureRoom if the guild_active state is 1. +- createRoom can trust that the space exists because we check that in a calling function. +- createRoom will only create other dependencies if the guild is autocreate. diff --git a/package-lock.json b/package-lock.json index 178690a..45de4cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1217,9 +1217,9 @@ ] }, "node_modules/better-sqlite3": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.2.1.tgz", - "integrity": "sha512-Xbt1d68wQnUuFIEVsbt6V+RG30zwgbtCGQ4QOcXVrOH0FE4eHk64FWZ9NUfRHS4/x1PXqwz/+KOrnXD7f0WieA==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.3.0.tgz", + "integrity": "sha512-iHt9j8NPYF3oKCNOO5ZI4JwThjt3Z6J6XrcwG85VNMVzv1ByqrHWv5VILEbCMFWDsoHhXvQ7oC8vgRXFAKgl9w==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1633,9 +1633,9 @@ } }, "node_modules/discord-api-types": { - "version": "0.37.98", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.98.tgz", - "integrity": "sha512-xsH4UwmnCQl4KjAf01/p9ck9s+/vDqzHbUxPOBzo8fcVUa/hQG6qInD7Cr44KAuCM+xCxGJFSAUx450pBrX0+g==", + "version": "0.37.101", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.101.tgz", + "integrity": "sha512-2wizd94t7G3A8U5Phr3AiuL4gSvhqistDwWnlk1VLTit8BI1jWUncFqFQNdPbHqS3661+Nx/iEyIwtVjPuBP3w==", "license": "MIT" }, "node_modules/doctypes": { diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 2ea9c91..75696f2 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -15,8 +15,6 @@ const api = sync.require("../../matrix/api") const ks = sync.require("../../matrix/kstate") /** @type {import("../../discord/utils")} */ const utils = sync.require("../../discord/utils") -/** @type {import("./create-space")}) */ -const createSpace = sync.require("./create-space") // watch out for the require loop /** * There are 3 levels of room privacy: @@ -95,27 +93,21 @@ function convertNameAndTopic(channel, guild, customName) { async function channelToKState(channel, guild, di) { // @ts-ignore const parentChannel = discord.channels.get(channel.parent_id) - /** Used for membership/permission checks. */ - let guildSpaceID - /** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */ - let parentSpaceID - let privacyLevel - if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { // it's a forum channel's thread, so use a different space to group those threads - guildSpaceID = await createSpace.ensureSpace(guild) - parentSpaceID = await ensureRoom(channel.parent_id) - privacyLevel = select("guild_space", "privacy_level", {space_id: guildSpaceID}).pluck().get() - } else { // otherwise use the guild's space like usual - parentSpaceID = await createSpace.ensureSpace(guild) - guildSpaceID = parentSpaceID - privacyLevel = select("guild_space", "privacy_level", {space_id: parentSpaceID}).pluck().get() - } - assert(typeof parentSpaceID === "string") - assert(typeof guildSpaceID === "string") - assert(typeof privacyLevel === "number") + const guildRow = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get() + assert(guildRow) - const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get() - const customName = row?.nick - const customAvatar = row?.custom_avatar + /** Used for membership/permission checks. */ + let guildSpaceID = guildRow.space_id + /** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */ + let parentSpaceID = guildSpaceID + if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { + parentSpaceID = await ensureRoom(channel.parent_id) + assert(typeof parentSpaceID === "string") + } + + const channelRow = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get() + const customName = channelRow?.nick + const customAvatar = channelRow?.custom_avatar const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName) const avatarEventContent = {} @@ -125,6 +117,7 @@ async function channelToKState(channel, guild, di) { avatarEventContent.url = {$url: file.guildIcon(guild)} } + const privacyLevel = guildRow.privacy_level let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel] if (channel["thread_metadata"]) history_visibility = "world_readable" @@ -280,16 +273,60 @@ function channelToGuild(channel) { } /** - * @param {string} channelID + * This function handles whether it's allowed to bridge messages in this channel, and if so, where to. + * This has to account for whether self-service is enabled for the guild or not. + * This also has to account for different channel types, like forum channels (which need the + * parent forum to already exist, and ignore the self-service setting), or thread channels (which + * need the parent channel to already exist, and ignore the self-service setting). + * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread * @param {string} guildID + * @returns obj if bridged; 1 if autocreatable; null/undefined if guild is not bridged; 0 if self-service and not autocreatable thread */ -function existsOrAutocreatable(channelID, guildID) { - const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channelID}).get() +function existsOrAutocreatable(channel, guildID) { + // 1. If the channel is already linked somewhere, it's always okay to bridge to that destination, no matter what. Yippee! + const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channel.id}).get() if (existing) return existing + + // 2. If the guild is an autocreate guild, it's always okay to bridge to that destination, and + // we'll need to create any dependent resources recursively. const autocreate = select("guild_active", "autocreate", {guild_id: guildID}).pluck().get() + if (autocreate === 1) return autocreate + + // 3. If the guild is not approved for bridging yet, we can't bridge there. + // They need to decide one way or another whether it's self-service before we can continue. + if (autocreate == null) return autocreate + + // 4. If we got here, the guild is in self-service mode. + // New channels won't be able to create new rooms. But forum threads or channel threads could be fine. + if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { + // In self-service mode, threads rely on the parent resource already existing. + /** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore + const parent = discord.channels.get(channel.parent_id) + assert(parent) + const parentExisting = existsOrAutocreatable(parent, guildID) + if (parentExisting) return 1 // Autocreatable + } + + // 5. If we got here, the guild is in self-service mode and the channel is truly not bridged. return autocreate } +/** + * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread + * @param {string} guildID + * @returns obj if bridged; 1 if autocreatable. (throws if not autocreatable) + */ +function assertExistsOrAutocreatable(channel, guildID) { + const existing = existsOrAutocreatable(channel, guildID) + if (existing === 0) { + throw new Error(`Guild ${guildID} is self-service, so won't create a Matrix room for channel ${channel.id}`) + } + if (!existing) { + throw new Error(`Guild ${guildID} is not bridged, so won't create a Matrix room for channel ${channel.id}`) + } + return existing +} + /* Ensure flow: 1. Get IDs @@ -323,15 +360,7 @@ async function _syncRoom(channelID, shouldActuallySync) { await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it } - const existing = existsOrAutocreatable(channelID, guild.id) - - if (existing === 0) { - throw new Error("refusing to create a new matrix room when autocreate is deactivated") - } - - if (!existing) { - throw new Error("refusing to craete a new matrix room when there is no guild_active entry") - } + const existing = assertExistsOrAutocreatable(channel, guild.id) if (existing === 1) { const creation = (async () => { @@ -475,3 +504,5 @@ module.exports.postApplyPowerLevels = postApplyPowerLevels module.exports._convertNameAndTopic = convertNameAndTopic module.exports._unbridgeRoom = _unbridgeRoom module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel +module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable +module.exports.existsOrAutocreatable = existsOrAutocreatable diff --git a/src/d2m/actions/create-space.js b/src/d2m/actions/create-space.js index ec1677e..0c3d5e3 100644 --- a/src/d2m/actions/create-space.js +++ b/src/d2m/actions/create-space.js @@ -92,6 +92,9 @@ async function _syncSpace(guild, shouldActuallySync) { const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get() if (!row) { + const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get() + assert.equal(autocreate, 1, `refusing to implicitly create guild ${guild.id}. set the guild_active data first before calling ensureSpace/syncSpace.`) + const creation = (async () => { const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) // New spaces will have to use the default privacy level; we obviously can't look up the existing entry const spaceID = await createSpace(guild, guildKState) diff --git a/src/discord/interactions/invite.js b/src/discord/interactions/invite.js index ce80e99..3b39d9d 100644 --- a/src/discord/interactions/invite.js +++ b/src/discord/interactions/invite.js @@ -1,6 +1,7 @@ // @ts-check const DiscordTypes = require("discord-api-types/v10") +const Ty = require("../../types") const assert = require("assert/strict") const {discord, sync, db, select, from} = require("../../passthrough") @@ -12,7 +13,7 @@ const createSpace = sync.require("../../d2m/actions/create-space") const api = sync.require("../../matrix/api") /** - * @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction + * @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction * @returns {Promise} */ async function _interact({data, channel, guild_id}) { @@ -29,12 +30,23 @@ async function _interact({data, channel, guild_id}) { } } + const guild = discord.guilds.get(guild_id) + assert(guild) + // Ensure guild and room are bridged db.prepare("INSERT OR IGNORE INTO guild_active (guild_id, autocreate) VALUES (?, 1)").run(guild_id) + const existing = createRoom.existsOrAutocreatable(channel, guild_id) + if (existing === 0) return { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "This channel isn't bridged, so you can't invite Matrix users yet. Try turning on automatic room-creation or link a Matrix room in the website.", + flags: DiscordTypes.MessageFlags.Ephemeral + } + } + assert(existing) // can't be null or undefined as we just inserted the guild_active row + + const spaceID = await createSpace.ensureSpace(guild) const roomID = await createRoom.ensureRoom(channel.id) - assert(roomID) - const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() - assert(spaceID) // Check for existing invite to the space let spaceMember @@ -115,7 +127,7 @@ async function _interactButton({channel, message}) { } } -/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */ +/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction */ async function interact(interaction) { await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction)) } diff --git a/test/data.js b/test/data.js index c8217c2..eb2c42a 100644 --- a/test/data.js +++ b/test/data.js @@ -58,7 +58,7 @@ module.exports = { network: { id: "112760669178241024", displayname: "Psychonauts 3", - avatar_url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF" + avatar_url: {$url: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024"} }, channel: { id: "112760669178241024",