diff --git a/application/build.gradle b/application/build.gradle index 2ce85aab62..c448842157 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -39,6 +39,8 @@ shadowJar { } dependencies { + implementation 'org.jetbrains:annotations:23.0.0' + implementation project(':database') implementation 'net.dv8tion:JDA:5.0.0-alpha.9' diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommandAdapter.java b/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommandAdapter.java index a8bb33b9ab..023c808a1d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommandAdapter.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommandAdapter.java @@ -3,10 +3,15 @@ import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; import net.dv8tion.jda.api.events.interaction.component.SelectMenuInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.CommandInteractionPayload; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; import net.dv8tion.jda.api.interactions.commands.build.CommandData; import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Range; +import org.jetbrains.annotations.Unmodifiable; import org.togetherjava.tjbot.commands.componentids.ComponentId; import org.togetherjava.tjbot.commands.componentids.ComponentIdGenerator; import org.togetherjava.tjbot.commands.componentids.Lifespan; @@ -14,6 +19,8 @@ import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.function.Function; +import java.util.stream.IntStream; /** * Adapter implementation of a {@link SlashCommand}. The minimal setup only requires implementation @@ -68,9 +75,9 @@ public abstract class SlashCommandAdapter implements SlashCommand { * Creates a new adapter with the given data. * * @param name the name of this command, requirements for this are documented in - * {@link CommandData#CommandData(String, String)} + * {@link SlashCommandData#setName(String)} * @param description the description of this command, requirements for this are documented in - * {@link CommandData#CommandData(String, String)} + * {@link SlashCommandData#setDescription(String)} * @param visibility the visibility of the command */ protected SlashCommandAdapter(@NotNull String name, @NotNull String description, @@ -157,4 +164,58 @@ public void onSelectionMenu(@NotNull SelectMenuInteractionEvent event, return Objects.requireNonNull(componentIdGenerator) .generate(new ComponentId(getName(), Arrays.asList(args)), lifespan); } + + /** + * Copies the given option multiple times. + *

+ * The generated options are all not required (optional) and have ascending number suffixes on + * their name. For example, if the name of the given option is {@code "foo"}, calling this with + * an amount of {@code 5} would result in a list of options like: + *

+ *

+ * This can be useful to offer a variable amount of input options for a user, similar to + * varargs. + *

+ * After generation, the user input can conveniently be parsed back using + * {@link #getMultipleOptionsByNamePrefix(CommandInteractionPayload, String)}. + * + * @param optionData the original option to copy + * @param amount how often to copy the option + * @return the generated list of options + */ + @Unmodifiable + protected static @NotNull List generateMultipleOptions( + @NotNull OptionData optionData, @Range(from = 1, to = 25) int amount) { + String baseName = optionData.getName(); + + Function nameToOption = + name -> new OptionData(optionData.getType(), name, optionData.getDescription()); + + return IntStream.rangeClosed(1, amount) + .mapToObj(i -> baseName + i) + .map(nameToOption) + .toList(); + } + + /** + * Gets all options from the given event whose name start with the given prefix. + * + * @param event the event to extract options from + * @param namePrefix the name prefix to search for + * @return all options with the given prefix + */ + @Unmodifiable + protected static @NotNull List getMultipleOptionsByNamePrefix( + @NotNull CommandInteractionPayload event, @NotNull String namePrefix) { + return event.getOptions() + .stream() + .filter(option -> option.getName().startsWith(namePrefix)) + .toList(); + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/basic/RoleSelectCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/basic/RoleSelectCommand.java index 594489b223..0bf97b93f8 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/basic/RoleSelectCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/basic/RoleSelectCommand.java @@ -1,44 +1,49 @@ package org.togetherjava.tjbot.commands.basic; import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.MessageBuilder; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.events.interaction.component.SelectMenuInteractionEvent; -import net.dv8tion.jda.api.interactions.Interaction; import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.commands.CommandInteraction; import net.dv8tion.jda.api.interactions.commands.OptionMapping; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; -import net.dv8tion.jda.api.interactions.components.ActionRow; -import net.dv8tion.jda.api.interactions.components.ComponentInteraction; import net.dv8tion.jda.api.interactions.components.selections.SelectMenu; import net.dv8tion.jda.api.interactions.components.selections.SelectOption; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.commands.SlashCommandAdapter; import org.togetherjava.tjbot.commands.SlashCommandVisibility; import org.togetherjava.tjbot.commands.componentids.Lifespan; -import java.awt.*; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Objects; +import java.awt.Color; +import java.util.*; import java.util.function.Function; +import java.util.stream.Collectors; /** - * Implements the {@code roleSelect} command. - * + * Implements the {@code /role-select} command. *

- * Allows users to select their roles without using reactions, instead it uses selection menus where - * you can select multiple roles.
- * Note: the bot can only use roles with a position below its highest one + * The command works in two stages. First, a user sets up a role selection dialog by using the + * command: + * + *

+ * {@code
+ * /role-select
+ *   title: Star Wars
+ *   description: Pick your preference
+ *   selectable-role: @Jedi
+ *   selectable-role1: @Sith
+ *   selectable-role2: @Droid
+ * }
+ * 
+ * + * Afterwards, users can pick their roles in a menu, upon which the command adjusts their roles. */ public final class RoleSelectCommand extends SlashCommandAdapter { @@ -46,259 +51,227 @@ public final class RoleSelectCommand extends SlashCommandAdapter { private static final String TITLE_OPTION = "title"; private static final String DESCRIPTION_OPTION = "description"; + private static final String ROLE_OPTION = "selectable-role"; private static final Color AMBIENT_COLOR = new Color(24, 221, 136, 255); - private static final List messageOptions = List.of( - new OptionData(OptionType.STRING, TITLE_OPTION, "The title for the message", false), - new OptionData(OptionType.STRING, DESCRIPTION_OPTION, "A description for the message", - false)); + private static final int OPTIONAL_ROLES_AMOUNT = 22; /** * Construct an instance. */ public RoleSelectCommand() { - super("role-select", "Sends a message where users can select their roles", + super("role-select", + "Creates a dialog that lets users pick roles, system roles are ignored when selected.", SlashCommandVisibility.GUILD); - getData().addOptions(messageOptions); + OptionData roleOption = new OptionData(OptionType.ROLE, ROLE_OPTION, + "pick roles that users will then be able to select", true); + + getData() + .addOptions( + new OptionData(OptionType.STRING, TITLE_OPTION, + "title for the role selection message", true), + new OptionData(OptionType.STRING, DESCRIPTION_OPTION, + "description for the role selection message", true), + roleOption) + .addOptions(generateMultipleOptions(roleOption, OPTIONAL_ROLES_AMOUNT)); } - @NotNull - private static SelectOption mapToSelectOption(@NotNull Role role) { - RoleIcon roleIcon = role.getIcon(); + @Override + public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { + if (!handleHasPermissions(event)) { + return; + } + + List selectedRoles = getMultipleOptionsByNamePrefix(event, ROLE_OPTION).stream() + .map(OptionMapping::getAsRole) + .filter(RoleSelectCommand::handleIsBotAccessibleRole) + .toList(); - if (null == roleIcon || !roleIcon.isEmoji()) { - return SelectOption.of(role.getName(), role.getId()); - } else { - return SelectOption.of(role.getName(), role.getId()) - .withEmoji((Emoji.fromUnicode(roleIcon.getEmoji()))); + if (!handleAccessibleRolesSelected(event, selectedRoles)) { + return; } + + if (!handleInteractableRolesSelected(event, selectedRoles)) { + return; + } + + sendRoleSelectionMenu(event, selectedRoles); } - @Override - public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { - Member member = Objects.requireNonNull(event.getMember(), "Member is null"); - if (!member.hasPermission(Permission.MANAGE_ROLES)) { - event.reply("You dont have the right permissions to use this command") + private boolean handleHasPermissions(@NotNull SlashCommandInteractionEvent event) { + if (!event.getMember().hasPermission(Permission.MANAGE_ROLES)) { + event.reply("You do not have the required manage role permission to use this command") .setEphemeral(true) .queue(); - return; + return false; } - Member selfMember = Objects.requireNonNull(event.getGuild()).getSelfMember(); + Member selfMember = event.getGuild().getSelfMember(); if (!selfMember.hasPermission(Permission.MANAGE_ROLES)) { - event.reply("The bot needs the manage role permissions").setEphemeral(true).queue(); - logger.error("The bot needs the manage role permissions"); - return; + event.reply( + "Sorry, but I was not set up correctly. I need the manage role permissions for this.") + .setEphemeral(true) + .queue(); + logger.error("The bot requires the manage role permissions for /role-select."); + return false; } - SelectMenu.Builder menu = - SelectMenu.create(generateComponentId(Lifespan.PERMANENT, member.getId())); - - addMenuOptions(event, menu, "Select the roles to display", 1); - - // Handle Optional arguments - OptionMapping titleOption = event.getOption(TITLE_OPTION); - OptionMapping descriptionOption = event.getOption(DESCRIPTION_OPTION); + return true; + } - String title = handleOption(titleOption); - String description = handleOption(descriptionOption); + @Contract(pure = true) + private static boolean handleIsBotAccessibleRole(@NotNull Role role) { + boolean isSystemRole = role.isPublicRole() || role.getTags().isBot() + || role.getTags().isBoost() || role.getTags().isIntegration(); - MessageBuilder messageBuilder = new MessageBuilder(makeEmbed(title, description)) - .setActionRows(ActionRow.of(menu.build())); + if (isSystemRole) { + logger.debug("The {} ({}) role is a system role, and is ignored for /role-select.", + role.getName(), role.getId()); + } - event.reply(messageBuilder.build()).setEphemeral(true).queue(); + return !isSystemRole; } - /** - * Adds role options to a selection menu. - *

- * - * @param event the {@link SlashCommandInteractionEvent} - * @param menu the menu to add options to {@link SelectMenu.Builder} - * @param placeHolder the placeholder for the menu {@link String} - * @param minValues the minimum number of selections. nullable {@link Integer} - */ - private static void addMenuOptions(@NotNull final Interaction event, - @NotNull final SelectMenu.Builder menu, @NotNull final String placeHolder, - @Nullable final Integer minValues) { + private static boolean handleAccessibleRolesSelected(@NotNull IReplyCallback event, + @NotNull Collection selectedRoles) { + if (!selectedRoles.isEmpty()) { + return true; + } - Guild guild = Objects.requireNonNull(event.getGuild(), "The given guild cannot be null"); + MessageEmbed embed = createEmbed("Only system roles selected", + """ + I can not interact with system roles, these roles are special roles created by Discord, such as + `@everyone`, or the role given automatically to boosters. + Please pick non-system roles."""); - Role highestBotRole = guild.getSelfMember().getRoles().get(0); - List guildRoles = guild.getRoles(); + event.replyEmbeds(embed).setEphemeral(true).queue(); + return false; + } - Collection roles = new ArrayList<>( - guildRoles.subList(guildRoles.indexOf(highestBotRole) + 1, guildRoles.size())); + private static boolean handleInteractableRolesSelected(@NotNull IReplyCallback event, + @NotNull Collection selectedRoles) { + List nonInteractableRoles = selectedRoles.stream() + .filter(role -> !event.getGuild().getSelfMember().canInteract(role)) + .toList(); - if (null != minValues) { - menu.setMinValues(minValues); + if (nonInteractableRoles.isEmpty()) { + return true; } - menu.setPlaceholder(placeHolder) - .setMaxValues(roles.size()) - .addOptions(roles.stream() - .filter(role -> !role.isPublicRole()) - .filter(role -> !role.getTags().isBot()) - .map(RoleSelectCommand::mapToSelectOption) - .toList()); + String nonInteractableRolesText = nonInteractableRoles.stream() + .map(IMentionable::getAsMention) + .collect(Collectors.joining(", ", "(", ")")); + + MessageEmbed embed = createEmbed("Lacking permission", + "I can not interact with %s, please contact someone to give me appropriate permissions or select other roles." + .formatted(nonInteractableRolesText)); + + event.replyEmbeds(embed).setEphemeral(true).queue(); + return false; } - /** - * Creates an embedded message to send with the selection menu. - * - * @param title for the embedded message. nullable {@link String} - * @param description for the embedded message. nullable {@link String} - * @return the formatted embed {@link MessageEmbed} - */ - private static @NotNull MessageEmbed makeEmbed(@Nullable final String title, - @Nullable final CharSequence description) { + private void sendRoleSelectionMenu(@NotNull final CommandInteraction event, + @NotNull final Collection selectableRoles) { + SelectMenu.Builder menu = + SelectMenu.create(generateComponentId(Lifespan.PERMANENT, event.getUser().getId())) + .setPlaceholder("Select your roles") + .setMinValues(0) + .setMaxValues(selectableRoles.size()); - String effectiveTitle = (null == title) ? "Select your roles:" : title; + selectableRoles.stream() + .map(RoleSelectCommand::mapToSelectOption) + .forEach(menu::addOptions); - return new EmbedBuilder().setTitle(effectiveTitle) - .setDescription(description) - .setColor(AMBIENT_COLOR) - .build(); + OptionMapping titleOption = event.getOption(TITLE_OPTION); + String title = titleOption == null ? "Select your roles:" : titleOption.getAsString(); + + MessageEmbed embed = createEmbed(title, event.getOption(DESCRIPTION_OPTION).getAsString()); + + event.replyEmbeds(embed).addActionRow(menu.build()).queue(); } - @Override - public void onSelectionMenu(@NotNull final SelectMenuInteractionEvent event, - @NotNull final List args) { + @NotNull + private static SelectOption mapToSelectOption(@NotNull Role role) { + RoleIcon roleIcon = role.getIcon(); - Guild guild = Objects.requireNonNull(event.getGuild(), "The given guild cannot be null"); - List selectedOptions = Objects.requireNonNull(event.getSelectedOptions(), - "The given selectedOptions cannot be null"); + SelectOption option = SelectOption.of(role.getName(), role.getId()); + if (null != roleIcon && roleIcon.isEmoji()) { + option = option.withEmoji((Emoji.fromUnicode(roleIcon.getEmoji()))); + } - List selectedRoles = selectedOptions.stream() + return option; + } + + @Override + public void onSelectionMenu(@NotNull SelectMenuInteractionEvent event, + @NotNull List args) { + Guild guild = event.getGuild(); + List selectedRoles = event.getSelectedOptions() + .stream() .map(SelectOption::getValue) .map(guild::getRoleById) .filter(Objects::nonNull) - .filter(role -> guild.getSelfMember().canInteract(role)) .toList(); - - if (event.getMessage().isEphemeral()) { - handleNewRoleBuilderSelection(event, selectedRoles); - } else { - handleRoleSelection(event, selectedRoles, guild); + if (!handleInteractableRolesSelected(event, selectedRoles)) { + return; } + + handleRoleSelection(event, guild, selectedRoles); } - /** - * Handles selection of a {@link SelectMenuInteractionEvent}. - * - * @param event the unacknowledged {@link SelectMenuInteractionEvent} - * @param selectedRoles the {@link Role roles} selected - * @param guild the {@link Guild} - */ - private static void handleRoleSelection(final @NotNull SelectMenuInteractionEvent event, - final @NotNull Collection selectedRoles, final Guild guild) { + private static void handleRoleSelection(@NotNull SelectMenuInteractionEvent event, + @NotNull Guild guild, @NotNull Collection selectedRoles) { Collection rolesToAdd = new ArrayList<>(selectedRoles.size()); Collection rolesToRemove = new ArrayList<>(selectedRoles.size()); + // Diff the selected roles from all selectable roles event.getInteraction() .getComponent() .getOptions() .stream() - .map(roleFromSelectOptionFunction(guild)) - .filter(Objects::nonNull) + .map(optionToRole(guild)) + .filter(Optional::isPresent) + .map(Optional::orElseThrow) .forEach(role -> { - if (selectedRoles.contains(role)) { - rolesToAdd.add(role); - } else { - rolesToRemove.add(role); - } + Collection target = selectedRoles.contains(role) ? rolesToAdd : rolesToRemove; + target.add(role); }); - handleRoleModifications(event, event.getMember(), guild, rolesToAdd, rolesToRemove); + modifyRoles(event, event.getMember(), guild, rolesToAdd, rolesToRemove); } @NotNull - private static Function roleFromSelectOptionFunction(Guild guild) { - return selectedOption -> { - Role role = guild.getRoleById(selectedOption.getValue()); + private static Function> optionToRole(@NotNull Guild guild) { + return option -> { + Role role = guild.getRoleById(option.getValue()); if (null == role) { - handleNullRole(selectedOption); + logger.info( + "The {} ({}) role has been removed but is still an option in a selection menu.", + option.getLabel(), option.getValue()); } - return role; + return Optional.ofNullable(role); }; } - /** - * Handles the selection of the {@link SelectionMenu} if it came from a builder. - * - * @param event the unacknowledged {@link ComponentInteraction} - * @param selectedRoles the {@link Role roles} selected by the {@link User} from the - * {@link ComponentInteraction} event - */ - private void handleNewRoleBuilderSelection(@NotNull final ComponentInteraction event, - final @NotNull Collection selectedRoles) { - SelectMenu.Builder menu = SelectMenu.create(generateComponentId(event.getUser().getId())) - .setPlaceholder("Select your roles") - .setMaxValues(selectedRoles.size()) - .setMinValues(0); - - selectedRoles.forEach(role -> menu.addOption(role.getName(), role.getId())); - - event.getChannel() - .sendMessageEmbeds(event.getMessage().getEmbeds().get(0)) - .setActionRow(menu.build()) - .queue(); - - event.reply("Message sent successfully!").setEphemeral(true).queue(); - } - - /** - * Logs that the role of the given {@link SelectOption} doesn't exist anymore. - * - * @param selectedOption the {@link SelectOption} - */ - private static void handleNullRole(final @NotNull SelectOption selectedOption) { - logger.info( - "The {} ({}) role has been removed but is still an option in the selection menu", - selectedOption.getLabel(), selectedOption.getValue()); - } - - /** - * Updates the roles of the given member. - * - * @param event an unacknowledged {@link Interaction} event - * @param member the member to update the roles of - * @param guild what guild to update the roles in - * @param additionRoles the roles to add - * @param removalRoles the roles to remove - */ - private static void handleRoleModifications(@NotNull final IReplyCallback event, - final Member member, final @NotNull Guild guild, final Collection additionRoles, - final Collection removalRoles) { - guild.modifyMemberRoles(member, additionRoles, removalRoles) - .flatMap(empty -> event.reply("Your roles have been updated!").setEphemeral(true)) + private static void modifyRoles(@NotNull IReplyCallback event, @NotNull Member target, + @NotNull Guild guild, @NotNull Collection rolesToAdd, + @NotNull Collection rolesToRemove) { + guild.modifyMemberRoles(target, rolesToAdd, rolesToRemove) + .flatMap(empty -> event.reply("Your roles have been updated.").setEphemeral(true)) .queue(); } - /** - * This gets the OptionMapping and returns the value as a string if there is one. - * - * @param option the {@link OptionMapping} - * @return the value. nullable {@link String} - */ - @Contract("null -> null") - private static @Nullable String handleOption(@Nullable final OptionMapping option) { - if (null == option) { - return null; - } - - if (OptionType.STRING == option.getType()) { - return option.getAsString(); - } else if (OptionType.BOOLEAN == option.getType()) { - return option.getAsBoolean() ? "true" : "false"; - } else { - return null; - } + private static @NotNull MessageEmbed createEmbed(@NotNull String title, + @NotNull CharSequence description) { + return new EmbedBuilder().setTitle(title) + .setDescription(description) + .setColor(AMBIENT_COLOR) + .build(); } }