// @ts-check const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") const Ty = require("../../types") const {reg} = require("../../matrix/read-registration") const passthrough = require("../../passthrough") const {discord, sync, db, select, from} = passthrough /** @type {import("../../matrix/file")} */ const file = sync.require("../../matrix/file") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") /** @type {import("../../matrix/mreq")} */ const mreq = sync.require("../../matrix/mreq") /** @type {import("../../matrix/kstate")} */ const ks = sync.require("../../matrix/kstate") /** @type {import("../../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") /** * There are 3 levels of room privacy: * 0: Room is invite-only. * 1: Anybody can use a link to join. * 2: Room is published in room directory. */ const PRIVACY_ENUMS = { PRESET: ["private_chat", "public_chat", "public_chat"], VISIBILITY: ["private", "private", "private"], SPACE_HISTORY_VISIBILITY: ["invited", "world_readable", "world_readable"], // copying from element client ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after are visible, but for world_readable anybody can read without even joining GUEST_ACCESS: ["can_join", "forbidden", "forbidden"], // whether guests can join space if other conditions are met SPACE_JOIN_RULES: ["invite", "public", "public"], ROOM_JOIN_RULES: ["restricted", "public", "public"] } const DEFAULT_PRIVACY_LEVEL = 0 const READ_ONLY_ROOM_EVENTS_DEFAULT_POWER = 50 /** @type {Map>} channel ID -> Promise */ const inflightRoomCreate = new Map() /** * @param {{id: string, name: string, topic?: string?, type: number, parent_id?: string?}} channel * @param {{id: string}} guild * @param {string | null | undefined} customName */ function convertNameAndTopic(channel, guild, customName) { // @ts-ignore const parentChannel = discord.channels.get(channel.parent_id) let channelPrefix = ( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "" : channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.AnnouncementThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] " : channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] " : "") const chosenName = customName || (channelPrefix + channel.name); const maybeTopicWithPipe = channel.topic ? ` | ${channel.topic}` : ''; const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : ''; const channelIDPart = `Channel ID: ${channel.id}`; const guildIDPart = `Guild ID: ${guild.id}`; const convertedTopic = customName ? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${guildIDPart}` : `${maybeTopicWithNewlines}${channelIDPart}\n${guildIDPart}`; return [chosenName, convertedTopic]; } /** * Async because it may create the guild and/or upload the guild icon to mxc. * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel * @param {DiscordTypes.APIGuild} guild * @param {{api: {getStateEvent: typeof api.getStateEvent}}} di simple-as-nails dependency injection for the matrix API */ async function channelToKState(channel, guild, di) { // @ts-ignore const parentChannel = discord.channels.get(channel.parent_id) /** Used for membership/permission checks. */ const guildSpaceID = await createSpace.ensureSpace(guild) /** 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", "custom_topic"], {channel_id: channel.id}).get() const customName = channelRow?.nick const customAvatar = channelRow?.custom_avatar const hasCustomTopic = channelRow?.custom_topic const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName) const avatarEventContent = {} if (customAvatar) { avatarEventContent.url = customAvatar } else if (guild.icon) { avatarEventContent.url = {$url: file.guildIcon(guild)} } const privacyLevel = select("guild_space", "privacy_level", {guild_id: guild.id}).pluck().get() assert(privacyLevel != null) // already ensured the space exists let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel] if (channel["thread_metadata"]) history_visibility = "world_readable" /** @type {{join_rule: string, allow?: any}} */ let join_rules = { join_rule: "restricted", allow: [{ type: "m.room_membership", room_id: guildSpaceID }] } if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") { join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} } const everyonePermissions = dUtils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages) 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), {}) /** @type {Ty.Event.M_Power_Levels} */ const spacePowerEvent = await di.api.getStateEvent(guildSpaceID, "m.room.power_levels", "") const spacePower = spacePowerEvent.users /** @type {any} */ const channelKState = { "m.room.name/": {name: convertedName}, "m.room.topic/": {topic: convertedTopic}, "m.room.avatar/": avatarEventContent, "m.room.guest_access/": {guest_access: PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]}, "m.room.history_visibility/": {history_visibility}, [`m.space.parent/${parentSpaceID}`]: { via: [reg.ooye.server_name], canonical: true }, /** @type {{join_rule: string, [x: string]: any}} */ "m.room.join_rules/": join_rules, /** @type {Ty.Event.M_Power_Levels} */ "m.room.power_levels/": { events_default: everyoneCanSend ? 0 : READ_ONLY_ROOM_EVENTS_DEFAULT_POWER, events: { "m.reaction": 0, "m.room.redaction": 0 // only affects redactions of own events, required to be able to un-react }, notifications: { room: everyoneCanMentionEveryone ? 0 : 20 }, users: {...spacePower, ...globalAdminPower} }, "chat.schildi.hide_ui/read_receipts": { }, [`uk.half-shot.bridge/moe.cadence.ooye://discord/${guild.id}/${channel.id}`]: { bridgebot: `@${reg.sender_localpart}:${reg.ooye.server_name}`, protocol: { id: "discord", displayname: "Discord" }, network: { id: guild.id, displayname: guild.name, avatar_url: {$url: file.guildIcon(guild)} }, channel: { id: channel.id, displayname: channel.name, external_url: `https://discord.com/channels/${guild.id}/${channel.id}` } } } // Don't overwrite room topic if the topic has been customised if (hasCustomTopic) delete channelKState["m.room.topic/"] // Don't add a space parent if it's self service // (The person setting up self-service has already put it in their preferred space to be able to get this far.) const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get() if (autocreate === 0 && ![DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { delete channelKState[`m.space.parent/${parentSpaceID}`] } return {spaceID: parentSpaceID, privacyLevel, channelKState} } /** * Create a bridge room, store the relationship in the database, and add it to the guild's space. * @param {DiscordTypes.APIGuildTextChannel} channel * @param guild * @param {string} spaceID * @param {any} kstate * @param {number} privacyLevel * @returns {Promise} room ID */ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { let threadParent = null if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id let spaceCreationContent = {} if (channel.type === DiscordTypes.ChannelType.GuildForum) spaceCreationContent = {creation_content: {type: "m.space"}} // Name and topic can be done earlier in room creation rather than in initial_state // https://spec.matrix.org/latest/client-server-api/#creation const name = kstate["m.room.name/"].name delete kstate["m.room.name/"] assert(name) const topic = kstate["m.room.topic/"].topic delete kstate["m.room.topic/"] assert(topic) const roomID = await postApplyPowerLevels(kstate, async kstate => { const roomID = await api.createRoom({ name, topic, preset: PRIVACY_ENUMS.PRESET[privacyLevel], // This is closest to what we want, but properties from kstate override it anyway visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel], invite: [], initial_state: await ks.kstateToState(kstate), ...spaceCreationContent }) db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent) return roomID }) // Put the newly created child into the space await _syncSpaceMember(channel, spaceID, roomID, guild.id) return roomID } /** * Handling power levels separately. The spec doesn't specify what happens, Dendrite differs, * and Synapse does an absolutely insane *shallow merge* of what I provide on top of what it creates. * We don't want the `events` key to be overridden completely. * https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210 * https://github.com/matrix-org/matrix-spec/issues/492 * @param {any} kstate * @param {(_: any) => Promise} callback must return room ID * @returns {Promise} room ID */ async function postApplyPowerLevels(kstate, callback) { const powerLevelContent = kstate["m.room.power_levels/"] const kstateWithoutPowerLevels = {...kstate} delete kstateWithoutPowerLevels["m.room.power_levels/"] delete kstateWithoutPowerLevels["chat.schildi.hide_ui/read_receipts"] /** @type {string} */ const roomID = await callback(kstateWithoutPowerLevels) // Now *really* apply the power level overrides on top of what Synapse *really* set if (powerLevelContent) { const newRoomKState = await ks.roomToKState(roomID) const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent}) await ks.applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff) } return roomID } /** * @param {DiscordTypes.APIGuildChannel} channel */ function channelToGuild(channel) { const guildID = channel.guild_id assert(guildID) const guild = discord.guilds.get(guildID) assert(guild) return guild } /** * 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(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 2. Does room exist? If so great! (it doesn't, so it needs to be created) 3. Get kstate for channel 4. Create room, return new ID Ensure + sync flow: 1. Get IDs 2. Does room exist? 2.5: If room does exist AND wasn't asked to sync: return here 3. Get kstate for channel 4. Create room with kstate if room doesn't exist 5. Get and update room state with kstate if room does exist */ /** * Create room and/or sync room data. Please check that a channel_room entry exists or autocreate = 1 before calling this. * @param {string} channelID * @param {boolean} shouldActuallySync false if just need to ensure room exists (which is a quick database check), true if also want to sync room data when it does exist (slow) * @returns {Promise} room ID */ async function _syncRoom(channelID, shouldActuallySync) { /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ const channel = discord.channels.get(channelID) assert.ok(channel) const guild = channelToGuild(channel) if (inflightRoomCreate.has(channelID)) { await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it } const existing = assertExistsOrAutocreatable(channel, guild.id) if (existing === 1) { const creation = (async () => { const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild, {api}) const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel) inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row return roomID })() inflightRoomCreate.set(channelID, creation) return creation // Naturally, the newly created room is already up to date, so we can always skip syncing here. } const roomID = existing.room_id if (!shouldActuallySync) { return existing.room_id // only need to ensure room exists, and it does. return the room ID } console.log(`[room sync] to matrix: ${channel.name}`) const {spaceID, channelKState} = await channelToKState(channel, guild, {api}) // calling this in both branches because we don't want to calculate this if not syncing // sync channel state to room const roomKState = await ks.roomToKState(roomID) if (+roomKState["m.room.create/"].room_version <= 8) { // join_rule `restricted` is not available in room version < 8 and not working properly in version == 8 // read more: https://spec.matrix.org/v1.8/rooms/v9/ // we have to use `public` instead, otherwise the room will be unjoinable. channelKState["m.room.join_rules/"] = {join_rule: "public"} } const roomDiff = ks.diffKState(roomKState, channelKState) const roomApply = ks.applyKStateDiffToRoom(roomID, roomDiff) db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID) // sync room as space member const spaceApply = _syncSpaceMember(channel, spaceID, roomID, guild.id) await Promise.all([roomApply, spaceApply]) return roomID } /** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ function ensureRoom(channelID) { return _syncRoom(channelID, false) } /** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ 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) { 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").get() assert.ok(row) let botInRoom = true // remove declaration that the room is bridged try { await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {}) } catch (e) { if (String(e).includes("not in room")) { botInRoom = false } else { throw e } } if (botInRoom && "topic" in channel) { // previously the Matrix topic would say the channel ID. we should remove that await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""}) } // delete webhook on discord const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get() if (webhook) { await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token) db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id) } // 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) // cascades to most other tables, like messages 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 ??= {} const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}` for (const mxid of Object.keys(powerLevelContent.users)) { if (powerLevelContent.users[mxid] >= 100 && 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, {}) } // 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 = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, bot) 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) } } // leave room await api.leaveRoom(roomID) } /** * Async because it gets all space state from the homeserver, then if necessary sends one state event back. * @param {DiscordTypes.APIGuildTextChannel} channel * @param {string} spaceID * @param {string} roomID * @param {string} guild_id * @returns {Promise} */ async function _syncSpaceMember(channel, spaceID, roomID, guild_id) { // If space is self-service then only permit changes to space parenting for threads // (The person setting up self-service has already put it in their preferred space to be able to get this far.) const autocreate = select("guild_active", "autocreate", {guild_id}).pluck().get() if (autocreate === 0 && ![DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { return [] } const spaceKState = await ks.roomToKState(spaceID) let spaceEventContent = {} if ( channel.type !== DiscordTypes.ChannelType.PrivateThread // private threads do not belong in the space (don't offer people something they can't join) && ( !channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant) || discord.channels.get(channel.parent_id || "")?.type === DiscordTypes.ChannelType.GuildForum ) ) { spaceEventContent = { via: [reg.ooye.server_name] } } const spaceDiff = ks.diffKState(spaceKState, { [`m.space.child/${roomID}`]: spaceEventContent }) return ks.applyKStateDiffToRoom(spaceID, spaceDiff) } async function createAllForGuild(guildID) { const channelIDs = discord.guildChannelMap.get(guildID) assert.ok(channelIDs) for (const channelID of channelIDs) { const allowedTypes = [DiscordTypes.ChannelType.GuildText, DiscordTypes.ChannelType.PublicThread] // @ts-ignore if (allowedTypes.includes(discord.channels.get(channelID)?.type)) { const roomID = await syncRoom(channelID) console.log(`synced ${channelID} <-> ${roomID}`) } } } module.exports.DEFAULT_PRIVACY_LEVEL = DEFAULT_PRIVACY_LEVEL module.exports.READ_ONLY_ROOM_EVENTS_DEFAULT_POWER = READ_ONLY_ROOM_EVENTS_DEFAULT_POWER module.exports.PRIVACY_ENUMS = PRIVACY_ENUMS module.exports.createRoom = createRoom module.exports.ensureRoom = ensureRoom module.exports.syncRoom = syncRoom module.exports.createAllForGuild = createAllForGuild module.exports.channelToKState = channelToKState module.exports.postApplyPowerLevels = postApplyPowerLevels module.exports._convertNameAndTopic = convertNameAndTopic module.exports.unbridgeChannel = unbridgeChannel module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel module.exports.existsOrAutocreatable = existsOrAutocreatable module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable