diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 154c461..5191e6e 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -366,7 +366,7 @@ async function messageToEvent(message, guild, options = {}, di) { * Translate Discord attachment links into links that go via the bridge, so they last forever. */ function transformAttachmentLinks(content) { - return content.replace(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/g, url => dUtils.getPublicUrlForCdn(url)) + return content.replace(/https:\/\/cdn\.discord(?:app)?\.com\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/g, url => dUtils.getPublicUrlForCdn(url)) } /** diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 1806ee6..235248c 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -27,6 +27,8 @@ 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")} */ @@ -264,6 +266,7 @@ module.exports = { // @ts-ignore await sendMessage.sendMessage(message, channel, guild, row), + await discordCommandHandler.execute(message, channel, guild) retrigger.messageFinishedBridging(message.id) }, @@ -310,6 +313,7 @@ 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 new file mode 100644 index 0000000..f0a4b49 --- /dev/null +++ b/src/discord/discord-command-handler.js @@ -0,0 +1,274 @@ +// @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 e010b1b..82c3d3c 100644 --- a/src/discord/interactions/permissions.js +++ b/src/discord/interactions/permissions.js @@ -5,6 +5,7 @@ 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 deleted file mode 100644 index bb8c6c9..0000000 --- a/src/discord/interactions/privacy.js +++ /dev/null @@ -1,59 +0,0 @@ -// @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 cd9203f..eeeab62 100644 --- a/src/discord/register-interactions.js +++ b/src/discord/register-interactions.js @@ -9,9 +9,6 @@ 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", @@ -43,42 +40,16 @@ 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) { @@ -98,8 +69,6 @@ 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}`) } diff --git a/src/discord/utils.js b/src/discord/utils.js index 8cb241b..24a3c85 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -121,9 +121,9 @@ function timestampToSnowflakeInexact(timestamp) { /** @param {string} url */ function getPublicUrlForCdn(url) { - const match = url.match(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/) + const match = url.match(/https:\/\/cdn.discordapp.com\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/) if (!match) return url - return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}` + return `${reg.ooye.bridge_origin}/download/discordcdn/${match[1]}/${match[2]}/${match[3]}` } module.exports.getPermissions = getPermissions diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index 047dd94..9bac9f3 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -27,41 +27,36 @@ function timeUntilExpiry(url) { return false } -function defineMediaProxyHandler(domain) { - return defineEventHandler(async event => { - const params = await getValidatedRouterParams(event, schema.params.parse) +as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineEventHandler(async event => { + const params = await getValidatedRouterParams(event, schema.params.parse) - const row = select("channel_room", "channel_id", {channel_id: params.channel_id}).get() - if (row == null) { - throw createError({ - status: 403, - data: `The file you requested isn't permitted by this media proxy.` - }) - } + const row = select("channel_room", "channel_id", {channel_id: params.channel_id}).get() + if (row == null) { + throw createError({ + status: 403, + data: `The file you requested isn't permitted by this media proxy.` + }) + } - const url = `https://${domain}/attachments/${params.channel_id}/${params.attachment_id}/${params.file_name}` - let promise = cache.get(url) - /** @type {string | undefined} */ - let refreshed - if (promise) { - refreshed = await promise - if (!timeUntilExpiry(refreshed)) promise = undefined - } - if (!promise) { - promise = discord.snow.channel.refreshAttachmentURLs([url]).then(x => x.refreshed_urls[0].refreshed) - cache.set(url, promise) - refreshed = await promise - const time = timeUntilExpiry(refreshed) - assert(time) // the just-refreshed URL will always be in the future - setTimeout(() => { - cache.delete(url) - }, time).unref() - } - assert(refreshed) // will have been assigned by one of the above branches + const url = `https://cdn.discordapp.com/attachments/${params.channel_id}/${params.attachment_id}/${params.file_name}` + let promise = cache.get(url) + /** @type {string | undefined} */ + let refreshed + if (promise) { + refreshed = await promise + if (!timeUntilExpiry(refreshed)) promise = undefined + } + if (!promise) { + promise = discord.snow.channel.refreshAttachmentURLs([url]).then(x => x.refreshed_urls[0].refreshed) + cache.set(url, promise) + refreshed = await promise + const time = timeUntilExpiry(refreshed) + assert(time) // the just-refreshed URL will always be in the future + setTimeout(() => { + cache.delete(url) + }, time).unref() + } + assert(refreshed) // will have been assigned by one of the above branches - return sendRedirect(event, refreshed) - }) -} - -as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com")) -as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net")) + return sendRedirect(event, refreshed) +}))