diff --git a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts index d0452703..47aa4c64 100644 --- a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts +++ b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts @@ -1,5 +1,8 @@ import { PluginOptions } from "knub"; -import { GuildButtonRoles } from "src/data/GuildButtonRoles"; +import { ConfigPreprocessorFn } from "knub/dist/config/configTypes"; +import { GuildButtonRoles } from "../../data/GuildButtonRoles"; +import { isValidSnowflake } from "../../utils"; +import { StrictValidationError } from "../../validatorUtils"; import { GuildReactionRoles } from "../../data/GuildReactionRoles"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { Queue } from "../../Queue"; @@ -14,6 +17,7 @@ import { ButtonInteractionEvt } from "./events/ButtonInteractionEvt"; import { MessageDeletedEvt } from "./events/MessageDeletedEvt"; import { ConfigSchema, ReactionRolesPluginType } from "./types"; import { autoRefreshLoop } from "./util/autoRefreshLoop"; +import { getRowCount } from "./util/splitButtonsIntoRows"; const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API @@ -36,6 +40,57 @@ const defaultOptions: PluginOptions = { ], }; +const MAXIMUM_COMPONENT_ROWS = 5; + +const configPreprocessor: ConfigPreprocessorFn = options => { + if (options.config.button_groups) { + for (const [groupName, group] of Object.entries(options.config.button_groups)) { + const defaultButtonNames = Object.keys(group.default_buttons); + const defaultButtons = Object.values(group.default_buttons); + const menuNames = Object.keys(group.button_menus ?? []); + + const defaultBtnRowCount = getRowCount(defaultButtons); + if (defaultBtnRowCount > MAXIMUM_COMPONENT_ROWS || defaultBtnRowCount === 0) { + throw new StrictValidationError([ + `Invalid row count for default_buttons: You currently have ${defaultBtnRowCount}, the maximum is 5. A new row is started automatically each 5 consecutive buttons.`, + ]); + } + + for (let i = 0; i < defaultButtons.length; i++) { + const defBtn = defaultButtons[i]; + if (!menuNames.includes(defBtn.role_or_menu) && !isValidSnowflake(defBtn.role_or_menu)) { + throw new StrictValidationError([ + `Invalid value for default_buttons/${defaultButtonNames[i]}/role_or_menu: ${defBtn.role_or_menu} is neither an existing menu nor a valid snowflake.`, + ]); + } + } + + for (const [menuName, menuButtonEntries] of Object.entries(group.button_menus ?? [])) { + const menuButtonNames = Object.keys(menuButtonEntries); + const menuButtons = Object.values(menuButtonEntries); + + const menuButtonRowCount = getRowCount(menuButtons); + if (menuButtonRowCount > MAXIMUM_COMPONENT_ROWS || menuButtonRowCount === 0) { + throw new StrictValidationError([ + `Invalid row count for button_menus/${menuName}: You currently have ${menuButtonRowCount}, the maximum is 5. A new row is started automatically each 5 consecutive buttons.`, + ]); + } + + for (let i = 0; i < menuButtons.length; i++) { + const menuBtn = menuButtons[i]; + if (!menuNames.includes(menuBtn.role_or_menu) && !isValidSnowflake(menuBtn.role_or_menu)) { + throw new StrictValidationError([ + `Invalid value for button_menus/${menuButtonNames[i]}/role_or_menu: ${menuBtn.role_or_menu} is neither an existing menu nor a valid snowflake.`, + ]); + } + } + } + } + } + + return options; +}; + export const ReactionRolesPlugin = zeppelinGuildPlugin()({ name: "reaction_roles", showInDocs: true, @@ -61,6 +116,7 @@ export const ReactionRolesPlugin = zeppelinGuildPlugin( ButtonInteractionEvt, MessageDeletedEvt, ], + configPreprocessor, beforeLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts index 3b162fa3..5f602921 100644 --- a/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts +++ b/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts @@ -4,6 +4,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes"; import { reactionRolesCmd } from "../types"; import { createHash } from "crypto"; import moment from "moment"; +import { splitButtonsIntoRows } from "../util/splitButtonsIntoRows"; export const PostButtonRolesCmd = reactionRolesCmd({ trigger: "reaction_roles post", @@ -16,6 +17,10 @@ export const PostButtonRolesCmd = reactionRolesCmd({ async run({ message: msg, args, pluginData }) { const cfg = pluginData.config.get(); + if (!cfg.button_groups) { + sendErrorMessage(pluginData, msg.channel, "No button groups defined in config"); + return; + } const group = cfg.button_groups[args.buttonGroup]; if (!group) { @@ -31,10 +36,11 @@ export const PostButtonRolesCmd = reactionRolesCmd({ .digest("hex"); const btn = new MessageButton() - .setLabel(button.label) - .setStyle("PRIMARY") + .setLabel(button.label ?? "") + .setStyle(button.style ?? "PRIMARY") .setType("BUTTON") - .setCustomID(customId); + .setCustomID(customId) + .setDisabled(button.disabled ?? false); if (button.emoji) { const emo = pluginData.client.emojis.resolve(button.emoji) ?? button.emoji; @@ -44,10 +50,10 @@ export const PostButtonRolesCmd = reactionRolesCmd({ buttons.push(btn); toInsert.push({ customId, buttonGroup: args.buttonGroup, buttonName }); } - const row = new MessageActionRow().addComponents(buttons); + const rows = splitButtonsIntoRows(buttons, Object.values(group.default_buttons)); // new MessageActionRow().addComponents(buttons); try { - const newMsg = await args.channel.send({ content: group.message, components: [row], split: false }); + const newMsg = await args.channel.send({ content: group.message, components: rows, split: false }); for (const btn of toInsert) { await pluginData.state.buttonRoles.add( diff --git a/backend/src/plugins/ReactionRoles/types.ts b/backend/src/plugins/ReactionRoles/types.ts index 940af482..203a4327 100644 --- a/backend/src/plugins/ReactionRoles/types.ts +++ b/backend/src/plugins/ReactionRoles/types.ts @@ -1,21 +1,35 @@ import * as t from "io-ts"; import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub"; import { GuildButtonRoles } from "src/data/GuildButtonRoles"; +import { tNullable } from "../../utils"; import { GuildReactionRoles } from "../../data/GuildReactionRoles"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { Queue } from "../../Queue"; +// These need to be updated every time discord adds/removes a style, +// but i cant figure out how to import MessageButtonStyles at runtime +enum ButtonStyles { + PRIMARY = 1, + SECONDARY = 2, + SUCCESS = 3, + DANGER = 4, + // LINK = 5, We do not want users to create link buttons, but it would be style 5 +} + const ButtonOpts = t.type({ - label: t.string, - emoji: t.string, + label: tNullable(t.string), + emoji: tNullable(t.string), role_or_menu: t.string, + style: tNullable(t.keyof(ButtonStyles)), // https://discord.js.org/#/docs/main/master/typedef/MessageButtonStyle + disabled: tNullable(t.boolean), + end_row: tNullable(t.boolean), }); export type TButtonOpts = t.TypeOf; const ButtonPairOpts = t.type({ message: t.string, default_buttons: t.record(t.string, ButtonOpts), - button_menus: t.record(t.string, t.record(t.string, ButtonOpts)), + button_menus: tNullable(t.record(t.string, t.record(t.string, ButtonOpts))), }); export type TButtonPairOpts = t.TypeOf; diff --git a/backend/src/plugins/ReactionRoles/util/buttonActionHandlers.ts b/backend/src/plugins/ReactionRoles/util/buttonActionHandlers.ts index 8a098476..c59ae400 100644 --- a/backend/src/plugins/ReactionRoles/util/buttonActionHandlers.ts +++ b/backend/src/plugins/ReactionRoles/util/buttonActionHandlers.ts @@ -4,6 +4,7 @@ import { LogType } from "../../../data/LogType"; import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin"; import { ReactionRolesPluginType, TButtonPairOpts } from "../types"; import { generateStatelessCustomId } from "./buttonCustomIdFunctions"; +import { splitButtonsIntoRows } from "./splitButtonsIntoRows"; export async function handleOpenMenu( pluginData: GuildPluginData, @@ -12,14 +13,29 @@ export async function handleOpenMenu( context, ) { const menuButtons: MessageButton[] = []; + if (group.button_menus == null) { + await int.reply({ + content: `A configuration error was encountered, please contact the Administrators!`, + ephemeral: true, + }); + pluginData + .getPlugin(LogsPlugin) + .log( + LogType.BOT_ALERT, + `**A configuration error occured** on buttons for message ${int.message.id}, no menus found in config`, + ); + return; + } + for (const menuButton of Object.values(group.button_menus[context.roleOrMenu])) { const customId = await generateStatelessCustomId(pluginData, context.groupName, menuButton.role_or_menu); const btn = new MessageButton() - .setLabel(menuButton.label) + .setLabel(menuButton.label ?? "") .setStyle("PRIMARY") .setType("BUTTON") - .setCustomID(customId); + .setCustomID(customId) + .setDisabled(menuButton.disabled ?? false); if (menuButton.emoji) { const emo = pluginData.client.emojis.resolve(menuButton.emoji) ?? menuButton.emoji; @@ -41,9 +57,9 @@ export async function handleOpenMenu( ); return; } - const row = new MessageActionRow().addComponents(menuButtons); + const rows = splitButtonsIntoRows(menuButtons, Object.values(group.button_menus[context.roleOrMenu])); // new MessageActionRow().addComponents(menuButtons); - int.reply({ content: `Click to add/remove a role`, components: [row], ephemeral: true }); + int.reply({ content: `Click to add/remove a role`, components: rows, ephemeral: true }); return; } @@ -69,12 +85,26 @@ export async function handleModifyRole( } const member = await pluginData.guild.members.fetch(int.user.id); - if (member.roles.cache.has(role.id)) { - await member.roles.remove(role, `Button Roles on message ${int.message.id}`); - await int.reply({ content: `Role **${role.name}** removed`, ephemeral: true }); - } else { - await member.roles.add(role, `Button Roles on message ${int.message.id}`); - await int.reply({ content: `Role **${role.name}** added`, ephemeral: true }); + try { + if (member.roles.cache.has(role.id)) { + await member.roles.remove(role, `Button Roles on message ${int.message.id}`); + await int.reply({ content: `Role **${role.name}** removed`, ephemeral: true }); + } else { + await member.roles.add(role, `Button Roles on message ${int.message.id}`); + await int.reply({ content: `Role **${role.name}** added`, ephemeral: true }); + } + } catch (e) { + await int.reply({ + content: "A configuration error was encountered, please contact the Administrators!", + ephemeral: true, + }); + pluginData + .getPlugin(LogsPlugin) + .log( + LogType.BOT_ALERT, + `**A configuration error occured** on buttons for message ${int.message.id}, error: ${e}. We might be missing permissions!`, + ); + return; } return; diff --git a/backend/src/plugins/ReactionRoles/util/splitButtonsIntoRows.ts b/backend/src/plugins/ReactionRoles/util/splitButtonsIntoRows.ts new file mode 100644 index 00000000..d357b7e1 --- /dev/null +++ b/backend/src/plugins/ReactionRoles/util/splitButtonsIntoRows.ts @@ -0,0 +1,41 @@ +import { MessageActionRow, MessageButton } from "discord.js"; +import { TButtonOpts } from "../types"; + +export function splitButtonsIntoRows(actualButtons: MessageButton[], configButtons: TButtonOpts[]): MessageActionRow[] { + const rows: MessageActionRow[] = []; + let curRow = new MessageActionRow(); + let consecutive = 0; + + for (let i = 0; i < actualButtons.length; i++) { + const aBtn = actualButtons[i]; + const cBtn = configButtons[i]; + + curRow.addComponents(aBtn); + if (((consecutive + 1) % 5 === 0 || cBtn.end_row) && i + 1 < actualButtons.length) { + rows.push(curRow); + curRow = new MessageActionRow(); + consecutive = 0; + } else { + consecutive++; + } + } + + return rows; +} + +export function getRowCount(configButtons: TButtonOpts[]): number { + let count = 1; + let consecutive = 0; + for (let i = 0; i < configButtons.length; i++) { + const cBtn = configButtons[i]; + + if (((consecutive + 1) % 5 === 0 || cBtn.end_row) && i + 1 < configButtons.length) { + count++; + consecutive = 0; + } else { + consecutive++; + } + } + + return count; +}