diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java index 393172ed99..94c8e74a90 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -3,6 +3,7 @@ import net.dv8tion.jda.api.JDA; import org.jetbrains.annotations.NotNull; import org.togetherjava.tjbot.commands.basic.PingCommand; +import org.togetherjava.tjbot.commands.basic.RoleSelectCommand; import org.togetherjava.tjbot.commands.basic.VcActivityCommand; import org.togetherjava.tjbot.commands.free.FreeCommand; import org.togetherjava.tjbot.commands.mathcommands.TeXCommand; @@ -78,6 +79,7 @@ public enum Features { features.add(new AuditCommand(actionsStore)); features.add(new MuteCommand(actionsStore)); features.add(new UnmuteCommand(actionsStore)); + features.add(new RoleSelectCommand()); features.add(new TopHelpersCommand(database)); // Mixtures 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 new file mode 100644 index 0000000000..c7931e86ef --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/basic/RoleSelectCommand.java @@ -0,0 +1,328 @@ +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.SelectionMenuEvent; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.interactions.Interaction; +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.commands.build.SubcommandData; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.ComponentInteraction; +import net.dv8tion.jda.api.interactions.components.selections.SelectOption; +import net.dv8tion.jda.api.interactions.components.selections.SelectionMenu; +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.util.function.Function; + + +/** + * Implements the {@code roleSelect} 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 + */ +public final class RoleSelectCommand extends SlashCommandAdapter { + + private static final Logger logger = LoggerFactory.getLogger(RoleSelectCommand.class); + + private static final String ALL_OPTION = "all"; + private static final String CHOOSE_OPTION = "choose"; + + private static final String TITLE_OPTION = "title"; + private static final String DESCRIPTION_OPTION = "description"; + + 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)); + + + /** + * Construct an instance. + */ + public RoleSelectCommand() { + super("role-select", "Sends a message where users can select their roles", + SlashCommandVisibility.GUILD); + + SubcommandData allRoles = + new SubcommandData(ALL_OPTION, "Lists all the rolls in the server for users") + .addOptions(messageOptions); + + SubcommandData selectRoles = + new SubcommandData(CHOOSE_OPTION, "Choose the roles for users to select") + .addOptions(messageOptions); + + getData().addSubcommands(allRoles, selectRoles); + } + + @NotNull + 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()))); + } + } + + @Override + public void onSlashCommand(@NotNull final SlashCommandEvent 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") + .setEphemeral(true) + .queue(); + return; + } + + Member selfMember = Objects.requireNonNull(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; + } + + SelectionMenu.Builder menu = + SelectionMenu.create(generateComponentId(Lifespan.PERMANENT, member.getId())); + boolean isEphemeral = false; + + if (CHOOSE_OPTION.equals(event.getSubcommandName())) { + addMenuOptions(event, menu, "Select the roles to display", 1); + isEphemeral = true; + } else { + addMenuOptions(event, menu, "Select your roles", 0); + } + + // Handle Optional arguments + OptionMapping titleOption = event.getOption(TITLE_OPTION); + OptionMapping descriptionOption = event.getOption(DESCRIPTION_OPTION); + + String title = handleOption(titleOption); + String description = handleOption(descriptionOption); + + MessageBuilder messageBuilder = new MessageBuilder(makeEmbed(title, description)) + .setActionRows(ActionRow.of(menu.build())); + + if (isEphemeral) { + event.reply(messageBuilder.build()).setEphemeral(true).queue(); + } else { + event.getChannel().sendMessage(messageBuilder.build()).queue(); + + event.reply("Message sent successfully!").setEphemeral(true).queue(); + } + } + + /** + * Adds role options to a selection menu. + *

+ * + * @param event the {@link SlashCommandEvent} + * @param menu the menu to add options to {@link SelectionMenu.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 SelectionMenu.Builder menu, @NotNull final String placeHolder, + @Nullable final Integer minValues) { + + Guild guild = Objects.requireNonNull(event.getGuild(), "The given guild cannot be null"); + + Role highestBotRole = guild.getSelfMember().getRoles().get(0); + List guildRoles = guild.getRoles(); + + Collection roles = new ArrayList<>( + guildRoles.subList(guildRoles.indexOf(highestBotRole) + 1, guildRoles.size())); + + if (null != minValues) { + menu.setMinValues(minValues); + } + + menu.setPlaceholder(placeHolder) + .setMaxValues(roles.size()) + .addOptions(roles.stream() + .filter(role -> !role.isPublicRole()) + .filter(role -> !role.getTags().isBot()) + .map(RoleSelectCommand::mapToSelectOption) + .toList()); + } + + /** + * 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) { + + String effectiveTitle = (null == title) ? "Select your roles:" : title; + + return new EmbedBuilder().setTitle(effectiveTitle) + .setDescription(description) + .setColor(AMBIENT_COLOR) + .build(); + } + + @Override + public void onSelectionMenu(@NotNull final SelectionMenuEvent 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 selectedRoles = selectedOptions.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); + } + } + + /** + * Handles selection of a {@link SelectionMenuEvent}. + * + * @param event the unacknowledged {@link SelectionMenuEvent} + * @param selectedRoles the {@link Role roles} selected + * @param guild the {@link Guild} + */ + private static void handleRoleSelection(final @NotNull SelectionMenuEvent event, + final @NotNull Collection selectedRoles, final Guild guild) { + Collection rolesToAdd = new ArrayList<>(selectedRoles.size()); + Collection rolesToRemove = new ArrayList<>(selectedRoles.size()); + + event.getInteraction() + .getComponent() + .getOptions() + .stream() + .map(roleFromSelectOptionFunction(guild)) + .filter(Objects::nonNull) + .forEach(role -> { + if (selectedRoles.contains(role)) { + rolesToAdd.add(role); + } else { + rolesToRemove.add(role); + } + }); + + handleRoleModifications(event, event.getMember(), guild, rolesToAdd, rolesToRemove); + } + + @NotNull + private static Function roleFromSelectOptionFunction(Guild guild) { + return selectedOption -> { + Role role = guild.getRoleById(selectedOption.getValue()); + + if (null == role) { + handleNullRole(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) { + SelectionMenu.Builder menu = + SelectionMenu.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 Interaction 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(); + } + + /** + * 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; + } + } +}