Skip to content

Reaction roles using a SelectionMenu #351

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Feb 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* Allows users to select their roles without using reactions, instead it uses selection menus where
* you can select multiple roles. <br />
* 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<OptionData> 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.
* <p>
*
* @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<Role> guildRoles = guild.getRoles();

Collection<Role> roles = new ArrayList<>(
guildRoles.subList(guildRoles.indexOf(highestBotRole) + 1, guildRoles.size()));
Comment on lines +153 to +157
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic might be weird to someone not familiar with it, I would recommend moving it into a helper method like getRolesBotCanUse or similar (maybe u have a better name)


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<String> args) {

Guild guild = Objects.requireNonNull(event.getGuild(), "The given guild cannot be null");
List<SelectOption> selectedOptions = Objects.requireNonNull(event.getSelectedOptions(),
"The given selectedOptions cannot be null");

List<Role> selectedRoles = selectedOptions.stream()
.map(SelectOption::getValue)
.map(guild::getRoleById)
.filter(Objects::nonNull)
.filter(role -> guild.getSelfMember().canInteract(role))
.toList();


Comment on lines +204 to +205
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two newlines is one too much

if (event.getMessage().isEphemeral()) {
handleNewRoleBuilderSelection(event, selectedRoles);
} else {
handleRoleSelection(event, selectedRoles, guild);
}
}

/**
* Handles selection of a {@link SelectionMenuEvent}.
*
* @param event the <b>unacknowledged</b> {@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<Role> selectedRoles, final Guild guild) {
Collection<Role> rolesToAdd = new ArrayList<>(selectedRoles.size());
Collection<Role> 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<SelectOption, Role> 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 <b>unacknowledged</b> {@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) {
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 <b>unacknowledged</b> {@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<Role> additionRoles,
final Collection<Role> 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;
}
}
}