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:
+ *
+ * - {@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)}.
+ *
+ * @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 extends Role> 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 extends Role> 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();
}
}