diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 235248c8..1806ee67 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -27,8 +27,6 @@ const updatePins = sync.require("./actions/update-pins") const api = sync.require("../matrix/api") /** @type {import("../discord/utils")} */ const dUtils = sync.require("../discord/utils") -/** @type {import("../discord/discord-command-handler")}) */ -const discordCommandHandler = sync.require("../discord/discord-command-handler") /** @type {import("../m2d/converters/utils")} */ const mxUtils = require("../m2d/converters/utils") /** @type {import("./actions/speedbump")} */ @@ -266,7 +264,6 @@ module.exports = { // @ts-ignore await sendMessage.sendMessage(message, channel, guild, row), - await discordCommandHandler.execute(message, channel, guild) retrigger.messageFinishedBridging(message.id) }, @@ -313,7 +310,6 @@ module.exports = { */ async onReactionAdd(client, data) { if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix. - discordCommandHandler.onReactionAdd(data) await addReaction.addReaction(data) }, diff --git a/src/discord/discord-command-handler.js b/src/discord/discord-command-handler.js deleted file mode 100644 index f0a4b49c..00000000 --- a/src/discord/discord-command-handler.js +++ /dev/null @@ -1,274 +0,0 @@ -// @ts-check - -const assert = require("assert").strict -const util = require("util") -const DiscordTypes = require("discord-api-types/v10") -const {reg} = require("../matrix/read-registration") -const {addbot} = require("../../addbot") - -const {discord, sync, db, select} = require("../passthrough") -/** @type {import("../matrix/api")}) */ -const api = sync.require("../matrix/api") -/** @type {import("../matrix/file")} */ -const file = sync.require("../matrix/file") -/** @type {import("../m2d/converters/utils")} */ -const mxUtils = sync.require("../m2d/converters/utils") -/** @type {import("../d2m/actions/create-space")} */ -const createSpace = sync.require("../d2m/actions/create-space") -/** @type {import("./utils")} */ -const utils = sync.require("./utils") - -const PREFIX = "//" - -let buttons = [] - -/** - * @param {string} channelID where to add the button - * @param {string} messageID where to add the button - * @param {string} emoji emoji to add as a button - * @param {string} userID only listen for responses from this user - * @returns {Promise} - */ -async function addButton(channelID, messageID, emoji, userID) { - await discord.snow.channel.createReaction(channelID, messageID, emoji) - return new Promise(resolve => { - buttons.push({channelID, messageID, userID, resolve, created: Date.now()}) - }) -} - -// Clear out old buttons every so often to free memory -setInterval(() => { - const now = Date.now() - buttons = buttons.filter(b => now - b.created < 2*60*60*1000) -}, 10*60*1000) - -/** @param {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data */ -function onReactionAdd(data) { - const button = buttons.find(b => b.channelID === data.channel_id && b.messageID === data.message_id && b.userID === data.user_id) - if (button) { - buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again - button.resolve(data) - } -} - -/** - * @callback CommandExecute - * @param {DiscordTypes.GatewayMessageCreateDispatchData} message - * @param {DiscordTypes.APIGuildTextChannel} channel - * @param {DiscordTypes.APIGuild} guild - * @param {Partial} [ctx] - */ - -/** - * @typedef Command - * @property {string[]} aliases - * @property {(message: DiscordTypes.GatewayMessageCreateDispatchData, channel: DiscordTypes.APIGuildTextChannel, guild: DiscordTypes.APIGuild) => Promise} execute - */ - -/** @param {CommandExecute} execute */ -function replyctx(execute) { - /** @type {CommandExecute} */ - return function(message, channel, guild, ctx = {}) { - ctx.message_reference = { - message_id: message.id, - channel_id: channel.id, - guild_id: guild.id, - fail_if_not_exists: false - } - return execute(message, channel, guild, ctx) - } -} - -/** @type {Command[]} */ -const commands = [{ - aliases: ["icon", "avatar", "roomicon", "roomavatar", "channelicon", "channelavatar"], - execute: replyctx( - async (message, channel, guild, ctx) => { - // Guard - const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() - if (!roomID) return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "This channel isn't bridged to the other side." - }) - - // Current avatar - const avatarEvent = await api.getStateEvent(roomID, "m.room.avatar", "") - let currentAvatarMessage = - ( avatarEvent.url ? `Current room-specific avatar: ${mxUtils.getPublicUrlForMxc(avatarEvent.url)}` - : "No avatar. Now's your time to strike. Use `//icon` again with a link or upload to set the room-specific avatar.") - - // Next potential avatar - const nextAvatarURL = message.attachments.find(a => a.content_type?.startsWith("image/"))?.url || message.content.match(/https?:\/\/[^ ]+\.[^ ]+\.(?:png|jpg|jpeg|webp)\b/)?.[0] - let nextAvatarMessage = - ( nextAvatarURL ? `\nYou want to set it to: ${nextAvatarURL}\nHit ✅ to make it happen.` - : "") - - const sent = await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: currentAvatarMessage + nextAvatarMessage - }) - - if (nextAvatarURL) { - addButton(channel.id, sent.id, "✅", message.author.id).then(async data => { - const mxcUrl = await file.uploadDiscordFileToMxc(nextAvatarURL) - await api.sendState(roomID, "m.room.avatar", "", { - url: mxcUrl - }) - db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE channel_id = ?").run(mxcUrl, channel.id) - await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "Your creation is unleashed. Any complaints will be redirected to Grelbo." - }) - }) - } - } - ) -}, { - aliases: ["invite"], - execute: replyctx( - async (message, channel, guild, ctx) => { - // Check guild is bridged - const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get() - const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() - if (!spaceID || !roomID) return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "This server isn't bridged to Matrix, so you can't invite Matrix users." - }) - - // Check CREATE_INSTANT_INVITE permission - assert(message.member) - const guildPermissions = utils.getPermissions(message.member.roles, guild.roles) - if (!(guildPermissions & DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "You don't have permission to invite people to this Discord server." - }) - } - - // Guard against accidental mentions instead of the MXID - if (message.content.match(/<[@#:].*>/)) return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "You have to say the Matrix ID of the person you want to invite, but you mentioned a Discord user in your message.\nOne way to fix this is by writing `` ` `` backticks `` ` `` around the Matrix ID." - }) - - // Get named MXID - const mxid = message.content.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0] - if (!mxid) return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`" - }) - - // Check for existing invite to the space - let spaceMember - try { - spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid) - } catch (e) {} - if (spaceMember && spaceMember.membership === "invite") { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `\`${mxid}\` already has an invite, which they haven't accepted yet.` - }) - } - - // Invite Matrix user if not in space - if (!spaceMember || spaceMember.membership !== "join") { - await api.inviteToRoom(spaceID, mxid) - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `You invited \`${mxid}\` to the server.` - }) - } - - // The Matrix user *is* in the space, maybe we want to invite them to this channel? - let roomMember - try { - roomMember = await api.getStateEvent(roomID, "m.room.member", mxid) - } catch (e) {} - if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) { - const sent = await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?\nHit ✅ to make it happen.` - }) - return addButton(channel.id, sent.id, "✅", message.author.id).then(async data => { - await api.inviteToRoom(roomID, mxid) - await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `You invited \`${mxid}\` to the channel.` - }) - }) - } - - // The Matrix user *is* in the space and in the channel. - await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `\`${mxid}\` is already in this server and this channel.` - }) - } - ) -}, { - aliases: ["addbot"], - execute: replyctx( - async (message, channel, guild, ctx) => { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: addbot() - }) - } - ) -}, { - aliases: ["privacy", "discoverable", "publish", "published"], - execute: replyctx( - async (message, channel, guild, ctx) => { - const current = select("guild_space", "privacy_level", {guild_id: guild.id}).pluck().get() - if (current == null) { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "This server isn't bridged to the other side." - }) - } - - const levels = ["invite", "link", "directory"] - const level = levels.findIndex(x => message.content.includes(x)) - if (level === -1) { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "**Usage: `//privacy `**. This will set who can join the space on Matrix-side. There are three levels:" - + "\n`invite`: Can only join with a direct in-app invite from another Matrix user, or the //invite command." - + "\n`link`: Matrix links can be created and shared like Discord's invite links. `invite` features also work." - + "\n`directory`: Publishes to the Matrix in-app directory, like Server Discovery. Preview enabled. `invite` and `link` also work." - + `\n**Current privacy level: \`${levels[current]}\`**` - }) - } - - assert(message.member) - const guildPermissions = utils.getPermissions(message.member.roles, guild.roles) - if (guild.owner_id !== message.author.id && !(guildPermissions & BigInt(0x28))) { // MANAGE_GUILD | ADMINISTRATOR - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "You don't have permission to change the privacy level. You need Manage Server or Administrator." - }) - } - - db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild.id) - discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `Privacy level updated to \`${levels[level]}\`. Changes will apply shortly.` - }) - await createSpace.syncSpaceFully(guild.id) - } - ) -}] - -/** @type {CommandExecute} */ -async function execute(message, channel, guild) { - if (!message.content.startsWith(PREFIX)) return - const words = message.content.slice(PREFIX.length).split(" ") - const commandName = words[0] - const command = commands.find(c => c.aliases.includes(commandName)) - if (!command) return - - await command.execute(message, channel, guild) -} - -module.exports.execute = execute -module.exports.onReactionAdd = onReactionAdd diff --git a/src/discord/interactions/permissions.js b/src/discord/interactions/permissions.js index 82c3d3c2..e010b1b8 100644 --- a/src/discord/interactions/permissions.js +++ b/src/discord/interactions/permissions.js @@ -5,7 +5,6 @@ const Ty = require("../../types") const {discord, sync, db, select, from} = require("../../passthrough") const assert = require("assert/strict") - /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") diff --git a/src/discord/interactions/privacy.js b/src/discord/interactions/privacy.js new file mode 100644 index 00000000..bb8c6c9c --- /dev/null +++ b/src/discord/interactions/privacy.js @@ -0,0 +1,59 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const {discord, sync, db, select} = require("../../passthrough") +const {id: botID} = require("../../../addbot") + +/** @type {import("../../d2m/actions/create-space")} */ +const createSpace = sync.require("../../d2m/actions/create-space") + +/** + * @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction + */ +async function interact({id, token, data, guild_id}) { + // Check guild is bridged + const current = select("guild_space", "privacy_level", {guild_id}).pluck().get() + if (current == null) return { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.", + flags: DiscordTypes.MessageFlags.Ephemeral + } + } + + // Get input level + /** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore + const options = data.options + const input = options?.[0].value || "" + const levels = ["invite", "link", "directory"] + const level = levels.findIndex(x => input === x) + if (level === -1) { + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "**Usage: `/privacy `**. This will set who can join the space on Matrix-side. There are three levels:" + + "\n`invite`: Can only join with a direct in-app invite from another user. No shareable invite links." + + "\n`link`: Matrix links can be created and shared like Discord's invite links. In-app invites still work." + + "\n`directory`: Publicly visible in the Matrix space directory, like Server Discovery. Invites and links still work." + + `\n**Current privacy level: \`${levels[current]}\`**`, + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) + } + + await discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource, + data: { + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) + + db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild_id) + await createSpace.syncSpaceFully(guild_id) // this is inefficient but OK to call infrequently on user request + + await discord.snow.interaction.editOriginalInteractionResponse(botID, token, { + content: `Privacy level updated to \`${levels[level]}\`.` + }) +} + +module.exports.interact = interact diff --git a/src/discord/register-interactions.js b/src/discord/register-interactions.js index eeeab625..cd9203fa 100644 --- a/src/discord/register-interactions.js +++ b/src/discord/register-interactions.js @@ -9,6 +9,9 @@ const invite = sync.require("./interactions/invite.js") const permissions = sync.require("./interactions/permissions.js") const bridge = sync.require("./interactions/bridge.js") const reactions = sync.require("./interactions/reactions.js") +const privacy = sync.require("./interactions/privacy.js") + +// User must have EVERY permission in default_member_permissions to be able to use the command discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ name: "Matrix info", @@ -40,16 +43,42 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ name: "bridge", contexts: [DiscordTypes.InteractionContextType.Guild], type: DiscordTypes.ApplicationCommandType.ChatInput, - description: "Start bridging this channel to a Matrix room.", + description: "Start bridging this channel to a Matrix room", default_member_permissions: String(DiscordTypes.PermissionFlagsBits.ManageChannels), options: [ { type: DiscordTypes.ApplicationCommandOptionType.String, - description: "Destination room to bridge to.", + description: "Destination room to bridge to", name: "room", autocomplete: true } ] +}, { + name: "privacy", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.ChatInput, + description: "Change whether Matrix users can join through direct invites, links, or the public directory.", + default_member_permissions: String(DiscordTypes.PermissionFlagsBits.ManageGuild), + options: [ + { + type: DiscordTypes.ApplicationCommandOptionType.String, + description: "Check or set the new privacy level", + name: "level", + choices: [{ + name: "❓️ Check the current privacy level and get more information.", + value: "info" + }, { + name: "🤝 Only allow joining with a direct in-app invite from another user. No shareable invite links.", + value: "invite" + }, { + name: "🔗 Matrix links can be created and shared like Discord's invite links. In-app invites still work.", + value: "link", + }, { + name: "🌏️ Publicly visible in the Matrix directory, like Server Discovery. Invites and links still work.", + value: "directory" + }] + } + ] }]) async function dispatchInteraction(interaction) { @@ -69,6 +98,8 @@ async function dispatchInteraction(interaction) { await bridge.interact(interaction) } else if (interactionId === "Reactions") { await reactions.interact(interaction) + } else if (interactionId === "privacy") { + await privacy.interact(interaction) } else { throw new Error(`Unknown interaction ${interactionId}`) }