diff --git a/backend/src/data/GuildButtonRoles.ts b/backend/src/data/GuildButtonRoles.ts index 56bf7bb1..24ee2e59 100644 --- a/backend/src/data/GuildButtonRoles.ts +++ b/backend/src/data/GuildButtonRoles.ts @@ -38,9 +38,17 @@ export class GuildButtonRoles extends BaseGuildRepository { }); } - async add(messageId: string, buttonId: string, buttonGroup: string, buttonName: string) { + async getForButtonGroup(buttonGroup: string) { + return this.buttonRoles.find({ + guild_id: this.guildId, + button_group: buttonGroup, + }); + } + + async add(channelId: string, messageId: string, buttonId: string, buttonGroup: string, buttonName: string) { await this.buttonRoles.insert({ guild_id: this.guildId, + channel_id: channelId, message_id: messageId, button_id: buttonId, button_group: buttonGroup, diff --git a/backend/src/data/entities/ButtonRole.ts b/backend/src/data/entities/ButtonRole.ts index 9dc52b2c..affcce07 100644 --- a/backend/src/data/entities/ButtonRole.ts +++ b/backend/src/data/entities/ButtonRole.ts @@ -6,6 +6,10 @@ export class ButtonRole { @PrimaryColumn() guild_id: string; + @Column() + @PrimaryColumn() + channel_id: string; + @Column() @PrimaryColumn() message_id: string; diff --git a/backend/src/migrations/1623018101018-CreateButtonRolesTable.ts b/backend/src/migrations/1623018101018-CreateButtonRolesTable.ts index ef5c6e8a..4d6b47e1 100644 --- a/backend/src/migrations/1623018101018-CreateButtonRolesTable.ts +++ b/backend/src/migrations/1623018101018-CreateButtonRolesTable.ts @@ -11,6 +11,11 @@ export class CreateButtonRolesTable1623018101018 implements MigrationInterface { type: "bigint", isPrimary: true, }, + { + name: "channel_id", + type: "bigint", + isPrimary: true, + }, { name: "message_id", type: "bigint", diff --git a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts index 51817f58..d0452703 100644 --- a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts +++ b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts @@ -1,4 +1,5 @@ import { PluginOptions } from "knub"; +import { GuildButtonRoles } from "src/data/GuildButtonRoles"; import { GuildReactionRoles } from "../../data/GuildReactionRoles"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { Queue } from "../../Queue"; @@ -10,6 +11,7 @@ import { PostButtonRolesCmd } from "./commands/PostButtonRolesCmd"; import { RefreshReactionRolesCmd } from "./commands/RefreshReactionRolesCmd"; import { AddReactionRoleEvt } from "./events/AddReactionRoleEvt"; import { ButtonInteractionEvt } from "./events/ButtonInteractionEvt"; +import { MessageDeletedEvt } from "./events/MessageDeletedEvt"; import { ConfigSchema, ReactionRolesPluginType } from "./types"; import { autoRefreshLoop } from "./util/autoRefreshLoop"; @@ -57,6 +59,7 @@ export const ReactionRolesPlugin = zeppelinGuildPlugin( events: [ AddReactionRoleEvt, ButtonInteractionEvt, + MessageDeletedEvt, ], beforeLoad(pluginData) { @@ -64,6 +67,7 @@ export const ReactionRolesPlugin = zeppelinGuildPlugin( state.reactionRoles = GuildReactionRoles.getGuildInstance(guild.id); state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); + state.buttonRoles = GuildButtonRoles.getGuildInstance(guild.id); state.reactionRemoveQueue = new Queue(); state.roleChangeQueue = new Queue(); state.pendingRoleChanges = new Map(); diff --git a/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts index e5ccf683..3b162fa3 100644 --- a/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts +++ b/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts @@ -2,42 +2,33 @@ import { MessageActionRow, MessageButton, TextChannel } from "discord.js"; import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { reactionRolesCmd } from "../types"; -import { ButtonMenuActions } from "../util/buttonMenuActions"; +import { createHash } from "crypto"; +import moment from "moment"; export const PostButtonRolesCmd = reactionRolesCmd({ trigger: "reaction_roles post", permission: "can_manage", signature: { - button_group: ct.string(), + channel: ct.textChannel(), + buttonGroup: ct.string(), }, async run({ message: msg, args, pluginData }) { const cfg = pluginData.config.get(); - const group = cfg.button_groups[args.button_group]; + const group = cfg.button_groups[args.buttonGroup]; if (!group) { - sendErrorMessage(pluginData, msg.channel, `No button group matches the name **${args.button_group}**`); - } - - const channel = pluginData.guild.channels.resolve(group.channel_id); - if (!channel) { - await sendErrorMessage( - pluginData, - msg.channel, - `The ID ${group.channel_id} does not match a channel on the server`, - ); + sendErrorMessage(pluginData, msg.channel, `No button group matches the name **${args.buttonGroup}**`); return; } const buttons: MessageButton[] = []; - for (const button of Object.values(group.default_buttons)) { - let customId = ""; - if ((await pluginData.guild.roles.fetch(button.role_or_menu)) != null) { - customId = `${args.button_group}::${ButtonMenuActions.GRANT_ROLE}::${button.role_or_menu}`; - } else { - customId = `${args.button_group}::${ButtonMenuActions.OPEN_MENU}::${button.role_or_menu}`; - } + const toInsert: Array<{ customId; buttonGroup; buttonName }> = []; + for (const [buttonName, button] of Object.entries(group.default_buttons)) { + const customId = createHash("md5") + .update(`${buttonName}${moment.utc().valueOf()}`) + .digest("hex"); const btn = new MessageButton() .setLabel(button.label) @@ -51,15 +42,27 @@ export const PostButtonRolesCmd = reactionRolesCmd({ } buttons.push(btn); + toInsert.push({ customId, buttonGroup: args.buttonGroup, buttonName }); } const row = new MessageActionRow().addComponents(buttons); try { - await (channel as TextChannel).send({ content: group.message, components: [row], split: false }); + const newMsg = await args.channel.send({ content: group.message, components: [row], split: false }); + + for (const btn of toInsert) { + await pluginData.state.buttonRoles.add( + args.channel.id, + newMsg.id, + btn.customId, + btn.buttonGroup, + btn.buttonName, + ); + } } catch (e) { sendErrorMessage(pluginData, msg.channel, `Error trying to post message: ${e}`); return; } - await sendSuccessMessage(pluginData, msg.channel, `Successfully posted message in <#${channel.id}>`); + + await sendSuccessMessage(pluginData, msg.channel, `Successfully posted message in <#${args.channel.id}>`); }, }); diff --git a/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts b/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts index 05638f2f..29cef85b 100644 --- a/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts +++ b/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts @@ -1,9 +1,20 @@ import { MessageActionRow, MessageButton, MessageComponentInteraction } from "discord.js"; +import moment from "moment"; import { LogType } from "src/data/LogType"; import { logger } from "src/logger"; import { LogsPlugin } from "src/plugins/Logs/LogsPlugin"; +import { MINUTES } from "src/utils"; +import { idToTimestamp } from "src/utils/idToTimestamp"; import { reactionRolesEvt } from "../types"; +import { + generateStatelessCustomId, + resolveStatefulCustomId, + BUTTON_CONTEXT_SEPARATOR, +} from "../util/buttonCustomIdFunctions"; import { ButtonMenuActions } from "../util/buttonMenuActions"; +import humanizeDuration from "humanize-duration"; + +const BUTTON_INVALIDATION_TIME = 15 * MINUTES; export const ButtonInteractionEvt = reactionRolesEvt({ event: "interaction", @@ -14,42 +25,60 @@ export const ButtonInteractionEvt = reactionRolesEvt({ : null; if (!int) return; const cfg = meta.pluginData.config.get(); - const split = int.customID.split("::"); - const [groupName, action, roleOrMenu] = [split[0], split[1], split[2]]; + const split = int.customID.split(BUTTON_CONTEXT_SEPARATOR); + const context = (await resolveStatefulCustomId(meta.pluginData, int.customID)) ?? { + groupName: split[0], + action: split[1], + roleOrMenu: split[2], + stateless: true, + }; - const group = cfg.button_groups[groupName]; + if (context.stateless) { + const timeSinceCreation = moment.utc().valueOf() - idToTimestamp(int.message.id)!; + if (timeSinceCreation >= BUTTON_INVALIDATION_TIME) { + sendEphemeralReply( + int, + `Sorry, but these buttons are invalid because they are older than ${humanizeDuration( + BUTTON_INVALIDATION_TIME, + )}.\nIf the menu is still available, open it again to assign yourself roles!`, + ); + return; + } + } + + const group = cfg.button_groups[context.groupName]; if (!group) { await sendEphemeralReply(int, `A configuration error was encountered, please contact the Administrators!`); meta.pluginData .getPlugin(LogsPlugin) .log( LogType.BOT_ALERT, - `**A configuration error occured** on buttons for message ${int.message.id}, group **${groupName}** not found in config`, + `**A configuration error occured** on buttons for message ${int.message.id}, group **${context.groupName}** not found in config`, ); return; } // Verify that detected action is known by us - if (!(Object).values(ButtonMenuActions).includes(action)) { + if (!(Object).values(ButtonMenuActions).includes(context.action)) { await sendEphemeralReply(int, `A internal error was encountered, please contact the Administrators!`); meta.pluginData .getPlugin(LogsPlugin) .log( LogType.BOT_ALERT, - `**A internal error occured** on buttons for message ${int.message.id}, action **${action}** is not known`, + `**A internal error occured** on buttons for message ${int.message.id}, action **${context.action}** is not known`, ); return; } - if (action === ButtonMenuActions.GRANT_ROLE) { - const role = await meta.pluginData.guild.roles.fetch(roleOrMenu); + if (context.action === ButtonMenuActions.GRANT_ROLE) { + const role = await meta.pluginData.guild.roles.fetch(context.roleOrMenu); if (!role) { await sendEphemeralReply(int, `A configuration error was encountered, please contact the Administrators!`); meta.pluginData .getPlugin(LogsPlugin) .log( LogType.BOT_ALERT, - `**A configuration error occured** on buttons for message ${int.message.id}, group **${groupName}** not found in config`, + `**A configuration error occured** on buttons for message ${int.message.id}, group **${context.groupName}** not found in config`, ); return; } @@ -66,11 +95,10 @@ export const ButtonInteractionEvt = reactionRolesEvt({ return; } - if (action === ButtonMenuActions.OPEN_MENU) { + if (context.action === ButtonMenuActions.OPEN_MENU) { const menuButtons: MessageButton[] = []; - for (const menuButton of Object.values(group.button_menus[roleOrMenu])) { - let customId = ""; - customId = `${groupName}::${ButtonMenuActions.GRANT_ROLE}::${menuButton.role}`; + for (const menuButton of Object.values(group.button_menus[context.roleOrMenu])) { + const customId = await generateStatelessCustomId(meta.pluginData, context.groupName, menuButton.role_or_menu); const btn = new MessageButton() .setLabel(menuButton.label) @@ -91,7 +119,7 @@ export const ButtonInteractionEvt = reactionRolesEvt({ .getPlugin(LogsPlugin) .log( LogType.BOT_ALERT, - `**A configuration error occured** on buttons for message ${int.message.id}, menu **${roleOrMenu}** not found in config`, + `**A configuration error occured** on buttons for message ${int.message.id}, menu **${context.roleOrMenu}** not found in config`, ); return; } @@ -102,7 +130,7 @@ export const ButtonInteractionEvt = reactionRolesEvt({ } logger.warn( - `Action ${action} on button ${int.customID} (Guild: ${int.guildID}, Channel: ${int.channelID}) is unknown!`, + `Action ${context.action} on button ${int.customID} (Guild: ${int.guildID}, Channel: ${int.channelID}) is unknown!`, ); await sendEphemeralReply(int, `A internal error was encountered, please contact the Administrators!`); }, diff --git a/backend/src/plugins/ReactionRoles/events/MessageDeletedEvt.ts b/backend/src/plugins/ReactionRoles/events/MessageDeletedEvt.ts new file mode 100644 index 00000000..323da037 --- /dev/null +++ b/backend/src/plugins/ReactionRoles/events/MessageDeletedEvt.ts @@ -0,0 +1,14 @@ +import { reactionRolesEvt } from "../types"; + +export const MessageDeletedEvt = reactionRolesEvt({ + event: "messageDelete", + allowBots: true, + allowSelf: true, + + async listener(meta) { + const pluginData = meta.pluginData; + + await pluginData.state.buttonRoles.removeAllForMessageId(meta.args.message.id); + await pluginData.state.reactionRoles.removeFromMessage(meta.args.message.id); + }, +}); diff --git a/backend/src/plugins/ReactionRoles/types.ts b/backend/src/plugins/ReactionRoles/types.ts index 9d80c81e..940af482 100644 --- a/backend/src/plugins/ReactionRoles/types.ts +++ b/backend/src/plugins/ReactionRoles/types.ts @@ -1,5 +1,6 @@ import * as t from "io-ts"; import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub"; +import { GuildButtonRoles } from "src/data/GuildButtonRoles"; import { GuildReactionRoles } from "../../data/GuildReactionRoles"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { Queue } from "../../Queue"; @@ -11,18 +12,10 @@ const ButtonOpts = t.type({ }); export type TButtonOpts = t.TypeOf; -const MenuButtonOpts = t.type({ - label: t.string, - emoji: t.string, - role: t.string, -}); -export type TMenuButtonOpts = t.TypeOf; - const ButtonPairOpts = t.type({ - channel_id: t.string, message: t.string, default_buttons: t.record(t.string, ButtonOpts), - button_menus: t.record(t.string, t.record(t.string, MenuButtonOpts)), + button_menus: t.record(t.string, t.record(t.string, ButtonOpts)), }); export type TButtonPairOpts = t.TypeOf; @@ -54,6 +47,7 @@ export interface ReactionRolesPluginType extends BasePluginType { state: { reactionRoles: GuildReactionRoles; savedMessages: GuildSavedMessages; + buttonRoles: GuildButtonRoles; reactionRemoveQueue: Queue; roleChangeQueue: Queue; diff --git a/backend/src/plugins/ReactionRoles/util/buttonCustomIdFunctions.ts b/backend/src/plugins/ReactionRoles/util/buttonCustomIdFunctions.ts new file mode 100644 index 00000000..cde61646 --- /dev/null +++ b/backend/src/plugins/ReactionRoles/util/buttonCustomIdFunctions.ts @@ -0,0 +1,41 @@ +import { GuildPluginData } from "knub"; +import { ReactionRolesPluginType } from "../types"; +import { ButtonMenuActions } from "./buttonMenuActions"; + +export const BUTTON_CONTEXT_SEPARATOR = "::"; + +export async function getButtonAction(pluginData: GuildPluginData, roleOrMenu: string) { + if (await pluginData.guild.roles.fetch(roleOrMenu)) { + return ButtonMenuActions.GRANT_ROLE; + } else { + return ButtonMenuActions.OPEN_MENU; + } +} + +export async function generateStatelessCustomId( + pluginData: GuildPluginData, + groupName: string, + roleOrMenu: string, +) { + let id = groupName + BUTTON_CONTEXT_SEPARATOR; + + id += `${await getButtonAction(pluginData, roleOrMenu)}${BUTTON_CONTEXT_SEPARATOR}${roleOrMenu}`; + + return id; +} + +export async function resolveStatefulCustomId(pluginData: GuildPluginData, id: string) { + const button = await pluginData.state.buttonRoles.getForButtonId(id); + + if (button) { + const group = pluginData.config.get().button_groups[button.button_group]; + const cfgButton = group.default_buttons[button.button_name]; + + return { + groupName: button.button_group, + action: await getButtonAction(pluginData, cfgButton.role_or_menu), + roleOrMenu: cfgButton.role_or_menu, + stateless: false, + }; + } +} diff --git a/backend/src/utils/idToTimestamp.ts b/backend/src/utils/idToTimestamp.ts new file mode 100644 index 00000000..62dd4743 --- /dev/null +++ b/backend/src/utils/idToTimestamp.ts @@ -0,0 +1,10 @@ +import moment from "moment"; + +const EPOCH = 1420070400000; + +export function idToTimestamp(id: string) { + if (typeof id === "number") return null; + return moment(+id / 4194304 + EPOCH) + .utc() + .valueOf(); +}