From 48e178858836663c87b3b73647800fecdc86fa89 Mon Sep 17 00:00:00 2001 From: nltbee Date: Tue, 1 Mar 2022 10:41:44 +0100 Subject: [PATCH 1/8] Added Jetbrains Annotations dependency --- application/build.gradle | 2 ++ 1 file changed, 2 insertions(+) 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' From c21229d9f1455f946764d0941153844872d0ff70 Mon Sep 17 00:00:00 2001 From: nltbee Date: Tue, 1 Mar 2022 10:41:07 +0100 Subject: [PATCH 2/8] Added vararg utility methods in SlashCommandAdapter --- .../tjbot/commands/SlashCommandAdapter.java | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) 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..58c41451b6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommandAdapter.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommandAdapter.java @@ -6,14 +6,18 @@ 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.SlashCommandData; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; 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; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; /** * Adapter implementation of a {@link SlashCommand}. The minimal setup only requires implementation @@ -157,4 +161,46 @@ public void onSelectionMenu(@NotNull SelectMenuInteractionEvent event, return Objects.requireNonNull(componentIdGenerator) .generate(new ComponentId(getName(), Arrays.asList(args)), lifespan); } + + /** + * This method copies the given {@link OptionData} for the given amount of times into a + * {@link List}.
+ * This makes all the {@link OptionData OptionData's} optional! Everything else gets exactly + * copied. + * + * @param optionData The {@link OptionData} to copy. + * @param amount The amount of times to copy + * + * @return An unmodifiable {@link List} of the copied {@link OptionData OptionData's} + * + * @see #varArgOptionsToList(Collection, Function) + */ + @Unmodifiable + protected static final @NotNull List generateOptionalVarArgList( + final @NotNull OptionData optionData, @Range(from = 1, to = 25) final int amount) { + + OptionData varArgOption = new OptionData(optionData.getType(), optionData.getName(), + optionData.getDescription()); + + return IntStream.range(0, amount).mapToObj(i -> varArgOption).toList(); + } + + /** + * This method takes a {@link Collection} of {@link OptionMapping OptionMapping's}, these get + * mapped using the given {@link Function} + * + * @param options A {@link Collection} of {@link OptionMapping OptionMapping's}. + * @param mapper The mapper {@link Function} + * @param The type to map it to. + * + * @return A modifiable {@link List} of the given type + * + * @see #generateOptionalVarArgList(OptionData, int) + */ + protected static List varArgOptionsToList( + final @NotNull Collection options, + final @NotNull Function mapper) { + + return options.stream().map(mapper).collect(Collectors.toCollection(ArrayList::new)); + } } From 2e22402c89e972ca878a5efde9ce8c069309d759 Mon Sep 17 00:00:00 2001 From: nltbee Date: Tue, 1 Mar 2022 10:44:48 +0100 Subject: [PATCH 3/8] Full rework of RoleSelectCommand The new RoleSelectCommand takes vararg of Roles, and generates the embed based on that. --- .../commands/basic/RoleSelectCommand.java | 340 +++++++++++------- 1 file changed, 215 insertions(+), 125 deletions(-) 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..33c70002ba 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,18 +1,16 @@ 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; @@ -22,14 +20,15 @@ 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.awt.Color; +import java.time.Instant; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Objects; import java.util.function.Function; +import java.util.stream.Collectors; /** @@ -46,6 +45,7 @@ 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); @@ -54,116 +54,236 @@ public final class RoleSelectCommand extends SlashCommandAdapter { new OptionData(OptionType.STRING, DESCRIPTION_OPTION, "A description for the message", false)); + /** + * Amount of times the role-option will be copied ({@value}) + */ + private static final int ROLE_VAR_ARG_OPTION_AMOUNT = 22; + /** * Construct an instance. */ public RoleSelectCommand() { - super("role-select", "Sends a message where users can select their roles", + super("role-select", + "Sends a message where users can select their roles, system roles are ignored when selected.", SlashCommandVisibility.GUILD); - getData().addOptions(messageOptions); + OptionData roleOption = new OptionData(OptionType.ROLE, ROLE_OPTION, + "The role to add to the selection menu", true); + + getData().addOptions(messageOptions) + .addOptions(roleOption) + .addOptions(generateOptionalVarArgList(roleOption, ROLE_VAR_ARG_OPTION_AMOUNT)); } - @NotNull - private static SelectOption mapToSelectOption(@NotNull Role role) { - RoleIcon roleIcon = role.getIcon(); + /** + * Collects the given {@link Collection} of {@link IMentionable IMentionables} to a comma + * separated String within {@code ()}
+ * It maps the {@link IMentionable IMentionables} to their mention using + * {@link IMentionable#getAsMention()}. + * + * @param mentionables The {@link Collection} of {@link IMentionable IMentionables} to collect + * into a {@link String} + * + * @return The given mentionables their mention collected into a {@link String} + */ + private static String mentionablesToJoinedString( + @NotNull final Collection mentionables) { + return mentionables.stream() + .map(IMentionable::getAsMention) + .collect(Collectors.joining(", ", "(", ")")); + } - 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()))); - } + + /** + * Handles the event when all the given roles are system roles. + * + * @param systemRoles A {@link Collection} of the {@link Role roles} the bot cannot interact + * with. + * + * @return A modified {@link MessageEmbed} for this error + */ + private static @NotNull MessageEmbed generateLackingNonSystemRoles( + @NotNull final Collection systemRoles) { + + return makeEmbed("Error: The given roles are all system roles!", """ + The bot can't/shouldn't interact with %s, these roles are created by Discord. + Examples are @everyone, or the role given automatically to boosters. + Are you sure you picked the correct role? + """.formatted(mentionablesToJoinedString(systemRoles))); } + /** + * Handles the event when the bot cannot interact with certain roles. + * + * @param rolesBotCantInteractWith A {@link Collection} of the {@link Role roles} the bot cannot + * interact with. + * + * @return A modified {@link MessageEmbed} for this error + */ + private static @NotNull MessageEmbed generateCannotInteractWithRolesEmbed( + @NotNull final Collection rolesBotCantInteractWith) { + + return makeEmbed("Error: The role of the bot is too low!", + "The bot can't interact with %s, contact a staff member to move the bot above these roles." + .formatted(mentionablesToJoinedString(rolesBotCantInteractWith))); + } + + /** + * Creates an embed to send with the selection menu.
+ * This embed is specifically designed for this command and might have unwanted side effects. + * + * @param title The title for {@link EmbedBuilder#setTitle(String)}. + * @param description The description for {@link EmbedBuilder#setDescription(CharSequence)} + * + * @return The formatted {@link MessageEmbed}. + */ + private static @NotNull MessageEmbed makeEmbed(@Nullable final String title, + @Nullable final CharSequence description) { + + return new EmbedBuilder().setTitle(title) + .setDescription(description) + .setColor(AMBIENT_COLOR) + .setTimestamp(Instant.now()) + .build(); + } + + @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") + + if (!event.getMember().hasPermission(Permission.MANAGE_ROLES)) { + event.reply("You dont have the required manage role permission to use this command") .setEphemeral(true) .queue(); return; } - 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; } - SelectMenu.Builder menu = - SelectMenu.create(generateComponentId(Lifespan.PERMANENT, member.getId())); - addMenuOptions(event, menu, "Select the roles to display", 1); + List rawRoles = + varArgOptionsToList(event.getOptionsByName(ROLE_OPTION), OptionMapping::getAsRole); + List roles = filterToBotAccessibleRoles(rawRoles); + + if (roles.isEmpty()) { + event.replyEmbeds(generateLackingNonSystemRoles(rawRoles)).queue(); + return; + } - // Handle Optional arguments - OptionMapping titleOption = event.getOption(TITLE_OPTION); - OptionMapping descriptionOption = event.getOption(DESCRIPTION_OPTION); - String title = handleOption(titleOption); - String description = handleOption(descriptionOption); + List rolesBotCantInteractWith = + roles.stream().filter(role -> !selfMember.canInteract(role)).toList(); - MessageBuilder messageBuilder = new MessageBuilder(makeEmbed(title, description)) - .setActionRows(ActionRow.of(menu.build())); + if (!rolesBotCantInteractWith.isEmpty()) { + event.replyEmbeds(generateCannotInteractWithRolesEmbed(rolesBotCantInteractWith)) + .queue(); + return; + } + + + handleCommandSuccess(event, roles); + } + + /** + * Filters the given {@link Collection} of {@link Role roles} to not contain system roles.
+ * + * See {@link #handleIsSystemRole(Role)} for more info on what this exactly filters. + * + * @param roles The {@link Collection} of the {@link Role roles} to filter + * + * @return A modifiable {@link List} of all filtered roles + */ + @NotNull + private static List filterToBotAccessibleRoles( + @NotNull final Collection roles) { - event.reply(messageBuilder.build()).setEphemeral(true).queue(); + return roles.stream() + .filter(RoleSelectCommand::handleIsSystemRole) + .collect(Collectors.toCollection(ArrayList::new)); } /** - * Adds role options to a selection menu. + * Tests the given predicate, if true logs the role as a system role. + * *

+ * returns true if one of the following statements is true: + *

    + *
  • The given role is the {@code @everyone} role
  • + *
  • The given role is the is a bot/integration role
  • + *
  • The given role is the booster role
  • + *
  • The given role is the Twitch Subscriber role
  • + *
+ * + * @param role The {@link Role} to test * - * @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} + * @return Whenever the given {@link Role} is a system-role */ - private static void addMenuOptions(@NotNull final Interaction event, - @NotNull final SelectMenu.Builder menu, @NotNull final String placeHolder, - @Nullable final Integer minValues) { + @Contract(pure = true) + private static boolean handleIsSystemRole(final @NotNull Role role) { + boolean isSystemRole = role.isPublicRole() || role.getTags().isBot() + || role.getTags().isBoost() || role.getTags().isIntegration(); + + if (isSystemRole) { + logger.info("The {} ({}) role is a system role, and is ignored", role.getName(), + role.getId()); + } - Guild guild = Objects.requireNonNull(event.getGuild(), "The given guild cannot be null"); + return !isSystemRole; + } - Role highestBotRole = guild.getSelfMember().getRoles().get(0); - List guildRoles = guild.getRoles(); + /** + * Handles the event when no issues were found and the message can be sent. + * + * @param event The {@link CommandInteraction} to reply to. + * @param roles A {@link List} of the {@link Role roles} that the users should be able to pick. + */ + private void handleCommandSuccess(@NotNull final CommandInteraction event, + @NotNull final Collection roles) { - Collection roles = new ArrayList<>( - guildRoles.subList(guildRoles.indexOf(highestBotRole) + 1, guildRoles.size())); + SelectionMenu.Builder menu = + SelectionMenu.create(generateComponentId(event.getUser().getId())) + .setPlaceholder("Select your roles") + .setMaxValues(roles.size()) + .setMinValues(0); - if (null != minValues) { - menu.setMinValues(minValues); - } + roles.forEach(role -> menu.addOptions(mapToSelectOption(role))); + + String title = null == event.getOption(TITLE_OPTION) ? "Select your roles:" + : event.getOption(TITLE_OPTION).getAsString(); - menu.setPlaceholder(placeHolder) - .setMaxValues(roles.size()) - .addOptions(roles.stream() - .filter(role -> !role.isPublicRole()) - .filter(role -> !role.getTags().isBot()) - .map(RoleSelectCommand::mapToSelectOption) - .toList()); + MessageEmbed generatedEmbed = + makeEmbed(title, event.getOption(DESCRIPTION_OPTION).getAsString()); + + event.replyEmbeds(generatedEmbed).addActionRow(menu.build()).queue(); } /** - * Creates an embedded message to send with the selection menu. + * Maps the given role to a {@link SelectOption} with the {@link SelectOption SelectOption's} + * emoji, if it has one. + * + * @param role The {@link Role} to base the option from. * - * @param title for the embedded message. nullable {@link String} - * @param description for the embedded message. nullable {@link String} - * @return the formatted embed {@link MessageEmbed} + * @return The generated {@link SelectOption}. */ - private static @NotNull MessageEmbed makeEmbed(@Nullable final String title, - @Nullable final CharSequence description) { - - String effectiveTitle = (null == title) ? "Select your roles:" : title; + @NotNull + private static SelectOption mapToSelectOption(@NotNull final Role role) { + RoleIcon roleIcon = role.getIcon(); - return new EmbedBuilder().setTitle(effectiveTitle) - .setDescription(description) - .setColor(AMBIENT_COLOR) - .build(); + 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()))); + } } + @Override public void onSelectionMenu(@NotNull final SelectMenuInteractionEvent event, @NotNull final List args) { @@ -172,27 +292,33 @@ public void onSelectionMenu(@NotNull final SelectMenuInteractionEvent event, List selectedOptions = Objects.requireNonNull(event.getSelectedOptions(), "The given selectedOptions cannot be null"); - List selectedRoles = selectedOptions.stream() + List roles = selectedOptions.stream() .map(SelectOption::getValue) .map(guild::getRoleById) .filter(Objects::nonNull) - .filter(role -> guild.getSelfMember().canInteract(role)) .toList(); + List rolesBotCantInteractWith = + roles.stream().filter(role -> !guild.getSelfMember().canInteract(role)).toList(); - if (event.getMessage().isEphemeral()) { - handleNewRoleBuilderSelection(event, selectedRoles); - } else { - handleRoleSelection(event, selectedRoles, guild); + if (!rolesBotCantInteractWith.isEmpty()) { + event.getChannel() + .sendMessageEmbeds(generateCannotInteractWithRolesEmbed(rolesBotCantInteractWith)) + .queue(); } + + List usableRoles = + roles.stream().filter(role -> guild.getSelfMember().canInteract(role)).toList(); + + handleRoleSelection(event, usableRoles, guild); } /** * Handles selection of a {@link SelectMenuInteractionEvent}. * - * @param event the unacknowledged {@link SelectMenuInteractionEvent} - * @param selectedRoles the {@link Role roles} selected - * @param guild the {@link Guild} + * @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) { @@ -205,7 +331,7 @@ private static void handleRoleSelection(final @NotNull SelectMenuInteractionEven .stream() .map(roleFromSelectOptionFunction(guild)) .filter(Objects::nonNull) - .forEach(role -> { + .forEach((Role role) -> { if (selectedRoles.contains(role)) { rolesToAdd.add(role); } else { @@ -216,49 +342,34 @@ private static void handleRoleSelection(final @NotNull SelectMenuInteractionEven handleRoleModifications(event, event.getMember(), guild, rolesToAdd, rolesToRemove); } + /** + * Creates a function that maps the {@link SelectOption} to a {@link Role} from the given + * {@link Guild}. + * + * @param guild The {@link Guild} to grab the roles from. + * + * @return A {@link Function} which maps {@link SelectOption} to the relating {@link Role}. + */ + @Contract(pure = true) @NotNull - private static Function roleFromSelectOptionFunction(Guild guild) { - return selectedOption -> { + private static Function roleFromSelectOptionFunction(final Guild guild) { + return (SelectOption selectedOption) -> { Role role = guild.getRoleById(selectedOption.getValue()); if (null == role) { - handleNullRole(selectedOption); + logRemovedRole(selectedOption); } return 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) { + private static void logRemovedRole(final @NotNull SelectOption selectedOption) { logger.info( "The {} ({}) role has been removed but is still an option in the selection menu", selectedOption.getLabel(), selectedOption.getValue()); @@ -280,25 +391,4 @@ private static void handleRoleModifications(@NotNull final IReplyCallback event, .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; - } - } } From edf680a16b2bdd70d4908ac1b57d1e19905e2f2e Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Fri, 27 May 2022 15:44:04 +0200 Subject: [PATCH 4/8] Fixed issues after rebase. --- .../tjbot/commands/SlashCommandAdapter.java | 10 +++++----- .../commands/basic/RoleSelectCommand.java | 19 +++++++------------ 2 files changed, 12 insertions(+), 17 deletions(-) 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 58c41451b6..7c494e0ba4 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommandAdapter.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommandAdapter.java @@ -3,6 +3,7 @@ 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.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.SlashCommandData; @@ -72,9 +73,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, @@ -176,9 +177,8 @@ public void onSelectionMenu(@NotNull SelectMenuInteractionEvent event, * @see #varArgOptionsToList(Collection, Function) */ @Unmodifiable - protected static final @NotNull List generateOptionalVarArgList( + protected static @NotNull List generateOptionalVarArgList( final @NotNull OptionData optionData, @Range(from = 1, to = 25) final int amount) { - OptionData varArgOption = new OptionData(optionData.getType(), optionData.getName(), optionData.getDescription()); @@ -187,7 +187,7 @@ public void onSelectionMenu(@NotNull SelectMenuInteractionEvent event, /** * This method takes a {@link Collection} of {@link OptionMapping OptionMapping's}, these get - * mapped using the given {@link Function} + * mapped using the given {@link Function}. * * @param options A {@link Collection} of {@link OptionMapping OptionMapping's}. * @param mapper The mapper {@link Function} 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 33c70002ba..a4223a4cba 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 @@ -45,14 +45,14 @@ 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 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, TITLE_OPTION, "The title for the message", true), new OptionData(OptionType.STRING, DESCRIPTION_OPTION, "A description for the message", - false)); + true)); /** * Amount of times the role-option will be copied ({@value}) @@ -151,7 +151,6 @@ private static String mentionablesToJoinedString( @Override public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { - if (!event.getMember().hasPermission(Permission.MANAGE_ROLES)) { event.reply("You dont have the required manage role permission to use this command") .setEphemeral(true) @@ -166,7 +165,6 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { return; } - List rawRoles = varArgOptionsToList(event.getOptionsByName(ROLE_OPTION), OptionMapping::getAsRole); List roles = filterToBotAccessibleRoles(rawRoles); @@ -176,7 +174,6 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { return; } - List rolesBotCantInteractWith = roles.stream().filter(role -> !selfMember.canInteract(role)).toList(); @@ -186,7 +183,6 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { return; } - handleCommandSuccess(event, roles); } @@ -246,11 +242,10 @@ private static boolean handleIsSystemRole(final @NotNull Role role) { private void handleCommandSuccess(@NotNull final CommandInteraction event, @NotNull final Collection roles) { - SelectionMenu.Builder menu = - SelectionMenu.create(generateComponentId(event.getUser().getId())) - .setPlaceholder("Select your roles") - .setMaxValues(roles.size()) - .setMinValues(0); + SelectMenu.Builder menu = SelectMenu.create(generateComponentId(event.getUser().getId())) + .setPlaceholder("Select your roles") + .setMaxValues(roles.size()) + .setMinValues(0); roles.forEach(role -> menu.addOptions(mapToSelectOption(role))); From 56dc70c39a0a3a20956e019f0539c32f7f5c862a Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Fri, 27 May 2022 15:49:41 +0200 Subject: [PATCH 5/8] Fixed some issues from CR --- .../tjbot/commands/SlashCommandAdapter.java | 7 ++++--- .../tjbot/commands/basic/RoleSelectCommand.java | 14 ++++++-------- 2 files changed, 10 insertions(+), 11 deletions(-) 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 7c494e0ba4..2354916079 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommandAdapter.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommandAdapter.java @@ -6,8 +6,8 @@ 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.SlashCommandData; 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; @@ -18,7 +18,7 @@ import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; -import java.util.stream.IntStream; +import java.util.stream.Stream; /** * Adapter implementation of a {@link SlashCommand}. The minimal setup only requires implementation @@ -179,10 +179,11 @@ public void onSelectionMenu(@NotNull SelectMenuInteractionEvent event, @Unmodifiable protected static @NotNull List generateOptionalVarArgList( final @NotNull OptionData optionData, @Range(from = 1, to = 25) final int amount) { + // Copy is immutable and explicitly optional, even if the parent option is required OptionData varArgOption = new OptionData(optionData.getType(), optionData.getName(), optionData.getDescription()); - return IntStream.range(0, amount).mapToObj(i -> varArgOption).toList(); + return Stream.generate(() -> varArgOption).limit(amount).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 a4223a4cba..b2a3201e50 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 @@ -103,7 +103,7 @@ private static String mentionablesToJoinedString( * * @return A modified {@link MessageEmbed} for this error */ - private static @NotNull MessageEmbed generateLackingNonSystemRoles( + private static @NotNull MessageEmbed generateLackingNonSystemRolesEmbed( @NotNull final Collection systemRoles) { return makeEmbed("Error: The given roles are all system roles!", """ @@ -170,7 +170,7 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { List roles = filterToBotAccessibleRoles(rawRoles); if (roles.isEmpty()) { - event.replyEmbeds(generateLackingNonSystemRoles(rawRoles)).queue(); + event.replyEmbeds(generateLackingNonSystemRolesEmbed(rawRoles)).queue(); return; } @@ -193,15 +193,13 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { * * @param roles The {@link Collection} of the {@link Role roles} to filter * - * @return A modifiable {@link List} of all filtered roles + * @return An unmodifiable {@link List} of all filtered roles */ @NotNull - private static List filterToBotAccessibleRoles( - @NotNull final Collection roles) { + private static List filterToBotAccessibleRoles( + @NotNull final Collection roles) { - return roles.stream() - .filter(RoleSelectCommand::handleIsSystemRole) - .collect(Collectors.toCollection(ArrayList::new)); + return roles.stream().filter(RoleSelectCommand::handleIsSystemRole).toList(); } /** From 6d3aff477369579a9f0e1cead3799e1f22a92b65 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Fri, 27 May 2022 17:02:03 +0200 Subject: [PATCH 6/8] Bugfixes with jda/discord issues on the options --- .../tjbot/commands/SlashCommandAdapter.java | 80 +++++++++++-------- .../commands/basic/RoleSelectCommand.java | 17 ++-- 2 files changed, 52 insertions(+), 45 deletions(-) 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 2354916079..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,6 +3,7 @@ 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; @@ -15,10 +16,11 @@ import org.togetherjava.tjbot.commands.componentids.ComponentIdGenerator; import org.togetherjava.tjbot.commands.componentids.Lifespan; -import java.util.*; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; +import java.util.stream.IntStream; /** * Adapter implementation of a {@link SlashCommand}. The minimal setup only requires implementation @@ -164,44 +166,56 @@ public void onSelectionMenu(@NotNull SelectMenuInteractionEvent event, } /** - * This method copies the given {@link OptionData} for the given amount of times into a - * {@link List}.
- * This makes all the {@link OptionData OptionData's} optional! Everything else gets exactly - * copied. - * - * @param optionData The {@link OptionData} to copy. - * @param amount The amount of times to copy - * - * @return An unmodifiable {@link List} of the copied {@link OptionData OptionData's} + * 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: + *

    + *
  • {@code "foo1"}
  • + *
  • {@code "foo2"}
  • + *
  • {@code "foo3"}
  • + *
  • {@code "foo4"}
  • + *
  • {@code "foo5"}
  • + *
+ *

+ * 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)}. * - * @see #varArgOptionsToList(Collection, Function) + * @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 generateOptionalVarArgList( - final @NotNull OptionData optionData, @Range(from = 1, to = 25) final int amount) { - // Copy is immutable and explicitly optional, even if the parent option is required - OptionData varArgOption = new OptionData(optionData.getType(), optionData.getName(), - optionData.getDescription()); + protected static @NotNull List generateMultipleOptions( + @NotNull OptionData optionData, @Range(from = 1, to = 25) int amount) { + String baseName = optionData.getName(); - return Stream.generate(() -> varArgOption).limit(amount).toList(); + Function nameToOption = + name -> new OptionData(optionData.getType(), name, optionData.getDescription()); + + return IntStream.rangeClosed(1, amount) + .mapToObj(i -> baseName + i) + .map(nameToOption) + .toList(); } /** - * This method takes a {@link Collection} of {@link OptionMapping OptionMapping's}, these get - * mapped using the given {@link Function}. - * - * @param options A {@link Collection} of {@link OptionMapping OptionMapping's}. - * @param mapper The mapper {@link Function} - * @param The type to map it to. - * - * @return A modifiable {@link List} of the given type + * Gets all options from the given event whose name start with the given prefix. * - * @see #generateOptionalVarArgList(OptionData, int) + * @param event the event to extract options from + * @param namePrefix the name prefix to search for + * @return all options with the given prefix */ - protected static List varArgOptionsToList( - final @NotNull Collection options, - final @NotNull Function mapper) { - - return options.stream().map(mapper).collect(Collectors.toCollection(ArrayList::new)); + @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 b2a3201e50..0a7c13b94a 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 @@ -73,7 +73,7 @@ public RoleSelectCommand() { getData().addOptions(messageOptions) .addOptions(roleOption) - .addOptions(generateOptionalVarArgList(roleOption, ROLE_VAR_ARG_OPTION_AMOUNT)); + .addOptions(generateMultipleOptions(roleOption, ROLE_VAR_ARG_OPTION_AMOUNT)); } /** @@ -84,7 +84,6 @@ public RoleSelectCommand() { * * @param mentionables The {@link Collection} of {@link IMentionable IMentionables} to collect * into a {@link String} - * * @return The given mentionables their mention collected into a {@link String} */ private static String mentionablesToJoinedString( @@ -100,7 +99,6 @@ private static String mentionablesToJoinedString( * * @param systemRoles A {@link Collection} of the {@link Role roles} the bot cannot interact * with. - * * @return A modified {@link MessageEmbed} for this error */ private static @NotNull MessageEmbed generateLackingNonSystemRolesEmbed( @@ -118,7 +116,6 @@ private static String mentionablesToJoinedString( * * @param rolesBotCantInteractWith A {@link Collection} of the {@link Role roles} the bot cannot * interact with. - * * @return A modified {@link MessageEmbed} for this error */ private static @NotNull MessageEmbed generateCannotInteractWithRolesEmbed( @@ -135,7 +132,6 @@ private static String mentionablesToJoinedString( * * @param title The title for {@link EmbedBuilder#setTitle(String)}. * @param description The description for {@link EmbedBuilder#setDescription(CharSequence)} - * * @return The formatted {@link MessageEmbed}. */ private static @NotNull MessageEmbed makeEmbed(@Nullable final String title, @@ -165,8 +161,9 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { return; } - List rawRoles = - varArgOptionsToList(event.getOptionsByName(ROLE_OPTION), OptionMapping::getAsRole); + List rawRoles = getMultipleOptionsByNamePrefix(event, ROLE_OPTION).stream() + .map(OptionMapping::getAsRole) + .toList(); List roles = filterToBotAccessibleRoles(rawRoles); if (roles.isEmpty()) { @@ -188,11 +185,10 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { /** * Filters the given {@link Collection} of {@link Role roles} to not contain system roles.
- * + *

* See {@link #handleIsSystemRole(Role)} for more info on what this exactly filters. * * @param roles The {@link Collection} of the {@link Role roles} to filter - * * @return An unmodifiable {@link List} of all filtered roles */ @NotNull @@ -215,7 +211,6 @@ private static List filterToBotAccessibleRoles( * * * @param role The {@link Role} to test - * * @return Whenever the given {@link Role} is a system-role */ @Contract(pure = true) @@ -261,7 +256,6 @@ private void handleCommandSuccess(@NotNull final CommandInteraction event, * emoji, if it has one. * * @param role The {@link Role} to base the option from. - * * @return The generated {@link SelectOption}. */ @NotNull @@ -340,7 +334,6 @@ private static void handleRoleSelection(final @NotNull SelectMenuInteractionEven * {@link Guild}. * * @param guild The {@link Guild} to grab the roles from. - * * @return A {@link Function} which maps {@link SelectOption} to the relating {@link Role}. */ @Contract(pure = true) From 06dc70c29d14f8be5e1548eb0c676ff2a45b068d Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Fri, 27 May 2022 18:51:02 +0200 Subject: [PATCH 7/8] Some polishing --- .../commands/basic/RoleSelectCommand.java | 187 +++++++++--------- 1 file changed, 90 insertions(+), 97 deletions(-) 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 0a7c13b94a..3c9a3941c8 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 @@ -49,15 +49,7 @@ public final class RoleSelectCommand extends SlashCommandAdapter { 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", true), - new OptionData(OptionType.STRING, DESCRIPTION_OPTION, "A description for the message", - true)); - - /** - * Amount of times the role-option will be copied ({@value}) - */ - private static final int ROLE_VAR_ARG_OPTION_AMOUNT = 22; + private static final int OPTIONAL_ROLES_AMOUNT = 22; /** @@ -65,15 +57,99 @@ public final class RoleSelectCommand extends SlashCommandAdapter { */ public RoleSelectCommand() { super("role-select", - "Sends a message where users can select their roles, system roles are ignored when selected.", + "Creates a dialog that lets users pick roles, system roles are ignored when selected.", SlashCommandVisibility.GUILD); OptionData roleOption = new OptionData(OptionType.ROLE, ROLE_OPTION, - "The role to add to the selection menu", true); + "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)); + } + + @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 (selectedRoles.isEmpty()) { + // TODO Used to use the list of roles without the accessible-filter + event.replyEmbeds(generateLackingNonSystemRolesEmbed(rawRoles)).queue(); + return; + } + + List rolesBotCantInteractWith = selectedRoles.stream() + .filter(role -> !event.getGuild().getSelfMember().canInteract(role)) + .toList(); + + if (!rolesBotCantInteractWith.isEmpty()) { + event.replyEmbeds(generateCannotInteractWithRolesEmbed(rolesBotCantInteractWith)) + .queue(); + return; + } - getData().addOptions(messageOptions) - .addOptions(roleOption) - .addOptions(generateMultipleOptions(roleOption, ROLE_VAR_ARG_OPTION_AMOUNT)); + handleCommandSuccess(event, selectedRoles); + } + + 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 false; + } + + Member selfMember = event.getGuild().getSelfMember(); + if (!selfMember.hasPermission(Permission.MANAGE_ROLES)) { + 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; + } + + return true; + } + + /** + * Tests whether the given role is a role accessible to the bot or a system role. + * + *

+ * A system role is a role where one of the following is true: + *

    + *
  • the {@code @everyone} role
  • + *
  • a bot/integration role
  • + *
  • the booster role
  • + *
  • the Twitch Subscriber role
  • + *
+ * + * @param role the role to test + * @return Whenever the given role is accessible to the bot, i.e. not a system role + */ + @Contract(pure = true) + private static boolean handleIsBotAccessibleRole(@NotNull Role role) { + boolean isSystemRole = role.isPublicRole() || role.getTags().isBot() + || role.getTags().isBoost() || role.getTags().isIntegration(); + + if (isSystemRole) { + logger.debug("The {} ({}) role is a system role, and is ignored for /role-select.", + role.getName(), role.getId()); + } + + return !isSystemRole; } /** @@ -93,7 +169,6 @@ private static String mentionablesToJoinedString( .collect(Collectors.joining(", ", "(", ")")); } - /** * Handles the event when all the given roles are system roles. * @@ -144,88 +219,6 @@ private static String mentionablesToJoinedString( .build(); } - - @Override - public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { - if (!event.getMember().hasPermission(Permission.MANAGE_ROLES)) { - event.reply("You dont have the required manage role permission to use this command") - .setEphemeral(true) - .queue(); - return; - } - - 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; - } - - List rawRoles = getMultipleOptionsByNamePrefix(event, ROLE_OPTION).stream() - .map(OptionMapping::getAsRole) - .toList(); - List roles = filterToBotAccessibleRoles(rawRoles); - - if (roles.isEmpty()) { - event.replyEmbeds(generateLackingNonSystemRolesEmbed(rawRoles)).queue(); - return; - } - - List rolesBotCantInteractWith = - roles.stream().filter(role -> !selfMember.canInteract(role)).toList(); - - if (!rolesBotCantInteractWith.isEmpty()) { - event.replyEmbeds(generateCannotInteractWithRolesEmbed(rolesBotCantInteractWith)) - .queue(); - return; - } - - handleCommandSuccess(event, roles); - } - - /** - * Filters the given {@link Collection} of {@link Role roles} to not contain system roles.
- *

- * See {@link #handleIsSystemRole(Role)} for more info on what this exactly filters. - * - * @param roles The {@link Collection} of the {@link Role roles} to filter - * @return An unmodifiable {@link List} of all filtered roles - */ - @NotNull - private static List filterToBotAccessibleRoles( - @NotNull final Collection roles) { - - return roles.stream().filter(RoleSelectCommand::handleIsSystemRole).toList(); - } - - /** - * Tests the given predicate, if true logs the role as a system role. - * - *

- * returns true if one of the following statements is true: - *

    - *
  • The given role is the {@code @everyone} role
  • - *
  • The given role is the is a bot/integration role
  • - *
  • The given role is the booster role
  • - *
  • The given role is the Twitch Subscriber role
  • - *
- * - * @param role The {@link Role} to test - * @return Whenever the given {@link Role} is a system-role - */ - @Contract(pure = true) - private static boolean handleIsSystemRole(final @NotNull Role role) { - boolean isSystemRole = role.isPublicRole() || role.getTags().isBot() - || role.getTags().isBoost() || role.getTags().isIntegration(); - - if (isSystemRole) { - logger.info("The {} ({}) role is a system role, and is ignored", role.getName(), - role.getId()); - } - - return !isSystemRole; - } - /** * Handles the event when no issues were found and the message can be sent. * From 57f4293f9f1dcf4e8cef8e7e20a94e75895434d3 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Sat, 28 May 2022 13:01:23 +0200 Subject: [PATCH 8/8] Polish, improvements, bugfixes --- .../commands/basic/RoleSelectCommand.java | 312 ++++++------------ 1 file changed, 108 insertions(+), 204 deletions(-) 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 3c9a3941c8..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 @@ -5,7 +5,6 @@ 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; @@ -15,29 +14,36 @@ 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.Color; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Objects; +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 { @@ -84,23 +90,15 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { .filter(RoleSelectCommand::handleIsBotAccessibleRole) .toList(); - if (selectedRoles.isEmpty()) { - // TODO Used to use the list of roles without the accessible-filter - event.replyEmbeds(generateLackingNonSystemRolesEmbed(rawRoles)).queue(); + if (!handleAccessibleRolesSelected(event, selectedRoles)) { return; } - List rolesBotCantInteractWith = selectedRoles.stream() - .filter(role -> !event.getGuild().getSelfMember().canInteract(role)) - .toList(); - - if (!rolesBotCantInteractWith.isEmpty()) { - event.replyEmbeds(generateCannotInteractWithRolesEmbed(rolesBotCantInteractWith)) - .queue(); + if (!handleInteractableRolesSelected(event, selectedRoles)) { return; } - handleCommandSuccess(event, selectedRoles); + sendRoleSelectionMenu(event, selectedRoles); } private boolean handleHasPermissions(@NotNull SlashCommandInteractionEvent event) { @@ -124,21 +122,6 @@ private boolean handleHasPermissions(@NotNull SlashCommandInteractionEvent event return true; } - /** - * Tests whether the given role is a role accessible to the bot or a system role. - * - *

- * A system role is a role where one of the following is true: - *

    - *
  • the {@code @everyone} role
  • - *
  • a bot/integration role
  • - *
  • the booster role
  • - *
  • the Twitch Subscriber role
  • - *
- * - * @param role the role to test - * @return Whenever the given role is accessible to the bot, i.e. not a system role - */ @Contract(pure = true) private static boolean handleIsBotAccessibleRole(@NotNull Role role) { boolean isSystemRole = role.isPublicRole() || role.getTags().isBot() @@ -152,222 +135,143 @@ private static boolean handleIsBotAccessibleRole(@NotNull Role role) { return !isSystemRole; } - /** - * Collects the given {@link Collection} of {@link IMentionable IMentionables} to a comma - * separated String within {@code ()}
- * It maps the {@link IMentionable IMentionables} to their mention using - * {@link IMentionable#getAsMention()}. - * - * @param mentionables The {@link Collection} of {@link IMentionable IMentionables} to collect - * into a {@link String} - * @return The given mentionables their mention collected into a {@link String} - */ - private static String mentionablesToJoinedString( - @NotNull final Collection mentionables) { - return mentionables.stream() - .map(IMentionable::getAsMention) - .collect(Collectors.joining(", ", "(", ")")); - } + private static boolean handleAccessibleRolesSelected(@NotNull IReplyCallback event, + @NotNull Collection selectedRoles) { + if (!selectedRoles.isEmpty()) { + return true; + } - /** - * Handles the event when all the given roles are system roles. - * - * @param systemRoles A {@link Collection} of the {@link Role roles} the bot cannot interact - * with. - * @return A modified {@link MessageEmbed} for this error - */ - private static @NotNull MessageEmbed generateLackingNonSystemRolesEmbed( - @NotNull final Collection systemRoles) { - - return makeEmbed("Error: The given roles are all system roles!", """ - The bot can't/shouldn't interact with %s, these roles are created by Discord. - Examples are @everyone, or the role given automatically to boosters. - Are you sure you picked the correct role? - """.formatted(mentionablesToJoinedString(systemRoles))); + 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."""); + + event.replyEmbeds(embed).setEphemeral(true).queue(); + return false; } - /** - * Handles the event when the bot cannot interact with certain roles. - * - * @param rolesBotCantInteractWith A {@link Collection} of the {@link Role roles} the bot cannot - * interact with. - * @return A modified {@link MessageEmbed} for this error - */ - private static @NotNull MessageEmbed generateCannotInteractWithRolesEmbed( - @NotNull final Collection rolesBotCantInteractWith) { + private static boolean handleInteractableRolesSelected(@NotNull IReplyCallback event, + @NotNull Collection selectedRoles) { + List nonInteractableRoles = selectedRoles.stream() + .filter(role -> !event.getGuild().getSelfMember().canInteract(role)) + .toList(); - return makeEmbed("Error: The role of the bot is too low!", - "The bot can't interact with %s, contact a staff member to move the bot above these roles." - .formatted(mentionablesToJoinedString(rolesBotCantInteractWith))); - } + if (nonInteractableRoles.isEmpty()) { + return true; + } - /** - * Creates an embed to send with the selection menu.
- * This embed is specifically designed for this command and might have unwanted side effects. - * - * @param title The title for {@link EmbedBuilder#setTitle(String)}. - * @param description The description for {@link EmbedBuilder#setDescription(CharSequence)} - * @return The formatted {@link MessageEmbed}. - */ - private static @NotNull MessageEmbed makeEmbed(@Nullable final String title, - @Nullable final CharSequence description) { + String nonInteractableRolesText = nonInteractableRoles.stream() + .map(IMentionable::getAsMention) + .collect(Collectors.joining(", ", "(", ")")); - return new EmbedBuilder().setTitle(title) - .setDescription(description) - .setColor(AMBIENT_COLOR) - .setTimestamp(Instant.now()) - .build(); - } + 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)); - /** - * Handles the event when no issues were found and the message can be sent. - * - * @param event The {@link CommandInteraction} to reply to. - * @param roles A {@link List} of the {@link Role roles} that the users should be able to pick. - */ - private void handleCommandSuccess(@NotNull final CommandInteraction event, - @NotNull final Collection roles) { + event.replyEmbeds(embed).setEphemeral(true).queue(); + return false; + } - SelectMenu.Builder menu = SelectMenu.create(generateComponentId(event.getUser().getId())) - .setPlaceholder("Select your roles") - .setMaxValues(roles.size()) - .setMinValues(0); + 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()); - roles.forEach(role -> menu.addOptions(mapToSelectOption(role))); + selectableRoles.stream() + .map(RoleSelectCommand::mapToSelectOption) + .forEach(menu::addOptions); - String title = null == event.getOption(TITLE_OPTION) ? "Select your roles:" - : event.getOption(TITLE_OPTION).getAsString(); + OptionMapping titleOption = event.getOption(TITLE_OPTION); + String title = titleOption == null ? "Select your roles:" : titleOption.getAsString(); - MessageEmbed generatedEmbed = - makeEmbed(title, event.getOption(DESCRIPTION_OPTION).getAsString()); + MessageEmbed embed = createEmbed(title, event.getOption(DESCRIPTION_OPTION).getAsString()); - event.replyEmbeds(generatedEmbed).addActionRow(menu.build()).queue(); + event.replyEmbeds(embed).addActionRow(menu.build()).queue(); } - /** - * Maps the given role to a {@link SelectOption} with the {@link SelectOption SelectOption's} - * emoji, if it has one. - * - * @param role The {@link Role} to base the option from. - * @return The generated {@link SelectOption}. - */ @NotNull - private static SelectOption mapToSelectOption(@NotNull final Role role) { + private static SelectOption mapToSelectOption(@NotNull Role role) { RoleIcon roleIcon = role.getIcon(); - 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()))); + SelectOption option = SelectOption.of(role.getName(), role.getId()); + if (null != roleIcon && roleIcon.isEmoji()) { + option = option.withEmoji((Emoji.fromUnicode(roleIcon.getEmoji()))); } - } + return option; + } @Override - public void onSelectionMenu(@NotNull final SelectMenuInteractionEvent event, - @NotNull final List args) { - - Guild guild = Objects.requireNonNull(event.getGuild(), "The given guild cannot be null"); - List selectedOptions = Objects.requireNonNull(event.getSelectedOptions(), - "The given selectedOptions cannot be null"); - - List roles = selectedOptions.stream() + 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) .toList(); - List rolesBotCantInteractWith = - roles.stream().filter(role -> !guild.getSelfMember().canInteract(role)).toList(); - - if (!rolesBotCantInteractWith.isEmpty()) { - event.getChannel() - .sendMessageEmbeds(generateCannotInteractWithRolesEmbed(rolesBotCantInteractWith)) - .queue(); + if (!handleInteractableRolesSelected(event, selectedRoles)) { + return; } - List usableRoles = - roles.stream().filter(role -> guild.getSelfMember().canInteract(role)).toList(); - - handleRoleSelection(event, usableRoles, guild); + 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) - .forEach((Role role) -> { - if (selectedRoles.contains(role)) { - rolesToAdd.add(role); - } else { - rolesToRemove.add(role); - } + .map(optionToRole(guild)) + .filter(Optional::isPresent) + .map(Optional::orElseThrow) + .forEach(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); } - /** - * Creates a function that maps the {@link SelectOption} to a {@link Role} from the given - * {@link Guild}. - * - * @param guild The {@link Guild} to grab the roles from. - * @return A {@link Function} which maps {@link SelectOption} to the relating {@link Role}. - */ - @Contract(pure = true) @NotNull - private static Function roleFromSelectOptionFunction(final Guild guild) { - return (SelectOption 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) { - logRemovedRole(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); }; } - /** - * Logs that the role of the given {@link SelectOption} doesn't exist anymore. - * - * @param selectedOption the {@link SelectOption} - */ - private static void logRemovedRole(final @NotNull SelectOption selectedOption) { - logger.info( - "The {} ({}) role has been removed but is still an option in the selection menu", - selectedOption.getLabel(), selectedOption.getValue()); + 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(); } - /** - * 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)) - .queue(); + private static @NotNull MessageEmbed createEmbed(@NotNull String title, + @NotNull CharSequence description) { + return new EmbedBuilder().setTitle(title) + .setDescription(description) + .setColor(AMBIENT_COLOR) + .build(); } }