Adapt createRoom/space/invite to self-service

This commit is contained in:
Cadence Ember 2024-09-25 01:58:26 +12:00
parent 312ea69d73
commit b0a0e62a86
6 changed files with 96 additions and 46 deletions

View file

@ -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.

12
package-lock.json generated
View file

@ -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": {

View file

@ -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

View file

@ -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)

View file

@ -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<DiscordTypes.APIInteractionResponse>}
*/
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))
}

View file

@ -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",