diff --git a/addbot.js b/addbot.js index 667fbabc..106ebc1f 100644 --- a/addbot.js +++ b/addbot.js @@ -1,10 +1,10 @@ // @ts-check const config = require("./config") +const token = config.discordToken +const id = Buffer.from(token.split(".")[0], "base64").toString() function addbot() { - const token = config.discordToken - const id = Buffer.from(token.split(".")[0], "base64") return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 ` } @@ -12,4 +12,5 @@ if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) { console.log(addbot()) } +module.exports.id = id module.exports.addbot = addbot diff --git a/d2m/discord-packets.js b/d2m/discord-packets.js index 6ba839ac..09818276 100644 --- a/d2m/discord-packets.js +++ b/d2m/discord-packets.js @@ -16,6 +16,8 @@ const utils = { // requiring this later so that the client is already constructed by the time event-dispatcher is loaded /** @type {typeof import("./event-dispatcher")} */ const eventDispatcher = sync.require("./event-dispatcher") + /** @type {import("../discord/register-interactions")} */ + const interactions = sync.require("../discord/register-interactions") // Client internals, keep track of the state we need if (message.t === "READY") { @@ -172,7 +174,11 @@ const utils = { } else if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") { await eventDispatcher.onSomeReactionsRemoved(client, message.d) + + } else if (message.t === "INTERACTION_CREATE") { + await interactions.dispatchInteraction(message.d) } + } catch (e) { // Let OOYE try to handle errors too eventDispatcher.onError(client, e, message) diff --git a/discord/interactions/bridge.js b/discord/interactions/bridge.js new file mode 100644 index 00000000..53eee7dc --- /dev/null +++ b/discord/interactions/bridge.js @@ -0,0 +1,121 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const Ty = require("../../types") +const {discord, sync, db, select, from, as} = require("../../passthrough") +const assert = require("assert/strict") + +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") + +/** @type {Map>} spaceID -> list of rooms */ +const cache = new Map() +/** @type {Map} roomID -> spaceID */ +const reverseCache = new Map() + +// Manage clearing the cache +sync.addTemporaryListener(as, "type:m.room.name", /** @param {Ty.Event.StateOuter} event */ async event => { + if (event.state_key !== "") return + const roomID = event.room_id + const spaceID = reverseCache.get(roomID) + if (!spaceID) return + const childRooms = await cache.get(spaceID) + if (!childRooms) return + if (event.content.name) { + const found = childRooms.find(r => r.value === roomID) + if (!found) return + found.name = event.content.name + } else { + cache.set(spaceID, Promise.resolve(childRooms.filter(r => r.value !== roomID))) + reverseCache.delete(roomID) + } +}) + +// Manage adding to the cache +async function getHierarchy(spaceID) { + return cache.get(spaceID) || (() => { + const entry = (async () => { + /** @type {{name: string, value: string}[]} */ + let childRooms = [] + /** @type {string | undefined} */ + let nextBatch = undefined + do { + /** @type {Ty.HierarchyPagination} */ + const res = await api.getHierarchy(spaceID, {from: nextBatch}) + for (const room of res.rooms) { + if (room.name) { + childRooms.push({name: room.name, value: room.room_id}) + reverseCache.set(room.room_id, spaceID) + } + } + nextBatch = res.next_batch + } while (nextBatch) + return childRooms + })() + cache.set(spaceID, entry) + return entry + })() +} + +/** @param {DiscordTypes.APIApplicationCommandAutocompleteGuildInteraction} interaction */ +async function interactAutocomplete({id, token, data, guild_id}) { + const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() + if (!spaceID) { + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult, + data: { + choices: [ + { + name: `Error: This server needs to be bridged somewhere first...`, + value: "baby" + } + ] + } + }) + } + + let rooms = await getHierarchy(spaceID) + // @ts-ignore + rooms = rooms.filter(r => r.name.startsWith(data.options[0].value)) + + await discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult, + data: { + choices: rooms + } + }) +} + +/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */ +async function interactSubmit({id, token, data, guild_id}) { + const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() + if (!spaceID) { + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "Error: This server needs to be bridged somewhere first...", + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) + } + + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "Valid input. This would do something but it isn't implemented yet.", + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) +} + +/** @param {DiscordTypes.APIGuildInteraction} interaction */ +async function interact(interaction) { + if (interaction.type === DiscordTypes.InteractionType.ApplicationCommandAutocomplete) { + return interactAutocomplete(interaction) + } else if (interaction.type === DiscordTypes.InteractionType.ApplicationCommand) { + // @ts-ignore + return interactSubmit(interaction) + } +} + +module.exports.interact = interact diff --git a/discord/interactions/invite.js b/discord/interactions/invite.js new file mode 100644 index 00000000..0590be8d --- /dev/null +++ b/discord/interactions/invite.js @@ -0,0 +1,113 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const assert = require("assert/strict") +const {discord, sync, db, select, from} = require("../../passthrough") + +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") + +/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */ +async function interact({id, token, data, channel, member, guild_id}) { + // Check guild is bridged + const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() + const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() + if (!spaceID || !roomID) return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "This server isn't bridged to Matrix, so you can't invite Matrix users.", + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) + + // Get named MXID + /** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore + const options = data.options + const input = options?.[0].value || "" + const mxid = input.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0] + if (!mxid) return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`", + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) + + // 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.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`, + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) + } + + // Invite Matrix user if not in space + if (!spaceMember || spaceMember.membership !== "join") { + await api.inviteToRoom(spaceID, mxid) + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + 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")) { + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?`, + flags: DiscordTypes.MessageFlags.Ephemeral, + components: [{ + type: DiscordTypes.ComponentType.ActionRow, + components: [{ + type: DiscordTypes.ComponentType.Button, + custom_id: "invite_channel", + style: DiscordTypes.ButtonStyle.Primary, + label: "Sure", + }] + }] + } + }) + } + + // The Matrix user *is* in the space and in the channel. + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `\`${mxid}\` is already in this server and this channel.`, + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) +} + +/** @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction */ +async function interactButton({id, token, data, channel, member, guild_id, message}) { + const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1] + assert(mxid) + const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() + await api.inviteToRoom(roomID, mxid) + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.UpdateMessage, + data: { + content: `You invited \`${mxid}\` to the channel.`, + flags: DiscordTypes.MessageFlags.Ephemeral, + components: [] + } + }) +} + +module.exports.interact = interact +module.exports.interactButton = interactButton diff --git a/discord/interactions/matrix-info.js b/discord/interactions/matrix-info.js new file mode 100644 index 00000000..9f0e9e15 --- /dev/null +++ b/discord/interactions/matrix-info.js @@ -0,0 +1,48 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const {discord, sync, db, select, from} = require("../../passthrough") + +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") + +/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */ +async function interact({id, token, data}) { + const message = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id") + .select("name", "nick", "source", "room_id", "event_id").where({message_id: data.target_id}).get() + + if (!message) { + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "This message hasn't been bridged to Matrix.", + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) + } + + if (message.source === 1) { // from Discord + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `This message was bridged to [${message.nick || message.name}]() on Matrix.` + + `\n-# Room ID: \`${message.room_id}\`\n-# Event ID: \`${message.event_id}\``, + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) + } + + // from Matrix + const event = await api.getEvent(message.room_id, message.event_id) + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `This message was bridged from [${message.nick || message.name}]() on Matrix.` + + `\nIt was originally sent by [${event.sender}]().` + + `\n-# Room ID: \`${message.room_id}\`\n-# Event ID: \`${message.event_id}\``, + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) +} + +module.exports.interact = interact diff --git a/discord/interactions/permissions.js b/discord/interactions/permissions.js new file mode 100644 index 00000000..d30f6326 --- /dev/null +++ b/discord/interactions/permissions.js @@ -0,0 +1,108 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +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") + +/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */ +async function interact({data, channel, id, token, guild_id}) { + const row = select("event_message", ["event_id", "source"], {message_id: data.target_id}).get() + assert(row) + + // Can't operate on Discord users + if (row.source === 1) { // discord + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `This command is only meaningful for Matrix users.`, + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) + } + + // Get the message sender, the person that will be inspected/edited + const eventID = row.event_id + const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() + assert(roomID) + const event = await api.getEvent(roomID, eventID) + const sender = event.sender + + // Get the space, where the power levels will be inspected/edited + const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() + assert(spaceID) + + // Get the power level + /** @type {Ty.Event.M_Power_Levels} */ + const powerLevelsContent = await api.getStateEvent(spaceID, "m.room.power_levels", "") + const userPower = powerLevelsContent.users?.[event.sender] || 0 + + // Administrators equal to the bot cannot be demoted + if (userPower >= 100) { + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `\`${sender}\` has administrator permissions. This cannot be edited.`, + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) + } + + await discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `Showing permissions for \`${sender}\`. Click to edit.`, + flags: DiscordTypes.MessageFlags.Ephemeral, + components: [ + { + type: DiscordTypes.ComponentType.ActionRow, + components: [ + { + type: DiscordTypes.ComponentType.StringSelect, + custom_id: "permissions_edit", + options: [ + { + label: "Default", + value: "default", + default: userPower < 50 + }, { + label: "Moderator", + value: "moderator", + default: userPower >= 50 && userPower < 100 + } + ] + } + ] + } + ] + } + }) +} + +/** @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction */ +async function interactEdit({data, channel, id, token, guild_id, message}) { + // Get the person that will be inspected/edited + const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1] + assert(mxid) + + // Get the space, where the power levels will be inspected/edited + const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() + assert(spaceID) + + // Do it + const permission = data.values[0] + const power = permission === "moderator" ? 50 : 0 + await api.setUserPower(spaceID, mxid, power) + // TODO: Cascade permissions through room hierarchy (make a helper for this already, geez...) + + // ACK + await discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.DeferredMessageUpdate + }) +} + +module.exports.interact = interact +module.exports.interactEdit = interactEdit diff --git a/discord/register-interactions.js b/discord/register-interactions.js new file mode 100644 index 00000000..79bcb149 --- /dev/null +++ b/discord/register-interactions.js @@ -0,0 +1,90 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const {discord, sync, db, select} = require("../passthrough") +const {id} = require("../addbot") + +const matrixInfo = sync.require("./interactions/matrix-info.js") +const invite = sync.require("./interactions/invite.js") +const permissions = sync.require("./interactions/permissions.js") +const bridge = sync.require("./interactions/bridge.js") + +discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ + name: "Matrix info", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.Message, +}, { + name: "Permissions", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.Message, + default_member_permissions: String(DiscordTypes.PermissionFlagsBits.KickMembers) +}, { + name: "invite", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.ChatInput, + description: "Invite a Matrix user to this Discord server", + default_member_permissions: String(DiscordTypes.PermissionFlagsBits.CreateInstantInvite), + options: [ + { + type: DiscordTypes.ApplicationCommandOptionType.String, + description: "The Matrix user to invite, e.g. @username:example.org", + name: "user" + } + ] +}, { + name: "bridge", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.ChatInput, + 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.", + name: "room", + autocomplete: true + } + ] +}]) + +async function dispatchInteraction(interaction) { + const id = interaction.data.custom_id || interaction.data.name + try { + console.log(interaction) + if (id === "Matrix info") { + await matrixInfo.interact(interaction) + } else if (id === "invite") { + await invite.interact(interaction) + } else if (id === "invite_channel") { + await invite.interactButton(interaction) + } else if (id === "Permissions") { + await permissions.interact(interaction) + } else if (id === "permissions_edit") { + await permissions.interactEdit(interaction) + } else if (id === "bridge") { + await bridge.interact(interaction) + } else { + throw new Error(`Unknown interaction ${id}`) + } + } catch (e) { + let stackLines = null + if (e.stack) { + stackLines = e.stack.split("\n") + let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/")) + if (cloudstormLine !== -1) { + stackLines = stackLines.slice(0, cloudstormLine - 2) + } + } + discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `Interaction failed: **${id}**` + + `\nError trace:\n\`\`\`\n${stackLines.join("\n")}\`\`\`` + + `Interaction data:\n\`\`\`\n${JSON.stringify(interaction.data, null, 2)}\`\`\``, + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) + } +} + +module.exports.dispatchInteraction = dispatchInteraction diff --git a/discord/utils.js b/discord/utils.js index 6e95d175..57e563f8 100644 --- a/discord/utils.js +++ b/discord/utils.js @@ -102,11 +102,10 @@ function isWebhookMessage(message) { } /** - * Ephemeral messages can be generated if a slash command is attached to the same bot that OOYE is running on * @param {Pick} message */ function isEphemeralMessage(message) { - return message.flags && (message.flags & (1 << 6)); + return message.flags && (message.flags & DiscordTypes.MessageFlags.Ephemeral) } /** @param {string} snowflake */ diff --git a/start.js b/start.js index c7ae0eb6..63f5c570 100644 --- a/start.js +++ b/start.js @@ -8,6 +8,7 @@ const config = require("./config") const passthrough = require("./passthrough") const db = new sqlite("db/ooye.db") +/** @type {import("heatsync").default} */ // @ts-ignore const sync = new HeatSync() Object.assign(passthrough, {config, sync, db})