diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index 893adbc00f..ce5af3875d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -64,6 +64,7 @@ import org.togetherjava.tjbot.features.moderation.temp.TemporaryModerationRoutine; import org.togetherjava.tjbot.features.reminder.RemindRoutine; import org.togetherjava.tjbot.features.reminder.ReminderCommand; +import org.togetherjava.tjbot.features.roleapplication.ApplicationCreateCommand; import org.togetherjava.tjbot.features.system.BotCore; import org.togetherjava.tjbot.features.system.LogLevelCommand; import org.togetherjava.tjbot.features.tags.TagCommand; @@ -192,6 +193,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService, helpSystemHelper)); features.add(new JShellCommand(jshellEval)); + features.add(new ApplicationCreateCommand(config)); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/ApplicationApplyHandler.java b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/ApplicationApplyHandler.java new file mode 100644 index 0000000000..8f6f9729fe --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/ApplicationApplyHandler.java @@ -0,0 +1,132 @@ +package org.togetherjava.tjbot.features.roleapplication; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.interactions.modals.ModalMapping; + +import org.togetherjava.tjbot.config.ApplicationFormConfig; + +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Handles the actual process of submitting role applications. + *

+ * This class is responsible for managing application submissions via modal interactions, ensuring + * that submissions are sent to the appropriate application channel, and enforcing cooldowns for + * users to prevent spamming. + */ +public class ApplicationApplyHandler { + + private final Cache applicationSubmitCooldown; + private final Predicate applicationChannelPattern; + private final ApplicationFormConfig formConfig; + + /** + * Constructs a new {@code ApplicationApplyHandler} instance. + * + * @param formConfig the configuration that contains the details for the application form + * including the cooldown duration and channel pattern. + */ + public ApplicationApplyHandler(ApplicationFormConfig formConfig) { + this.formConfig = formConfig; + this.applicationChannelPattern = + Pattern.compile(formConfig.applicationChannelPattern()).asMatchPredicate(); + + final Duration applicationSubmitCooldownDuration = + Duration.ofMinutes(formConfig.applicationSubmitCooldownMinutes()); + applicationSubmitCooldown = + Caffeine.newBuilder().expireAfterWrite(applicationSubmitCooldownDuration).build(); + } + + /** + * Sends the result of an application submission to the designated application channel in the + * guild. + *

+ * The {@code args} parameter should contain the applicant's name and the role they are applying + * for. + * + * @param event the modal interaction event triggering the application submission + * @param args the arguments provided in the application submission + * @param answer the answer provided by the applicant to the default question + */ + protected void sendApplicationResult(final ModalInteractionEvent event, List args, + String answer) { + Guild guild = event.getGuild(); + if (args.size() != 2 || guild == null) { + return; + } + + Optional applicationChannel = getApplicationChannel(guild); + if (applicationChannel.isEmpty()) { + return; + } + + User applicant = event.getUser(); + EmbedBuilder embed = + new EmbedBuilder().setAuthor(applicant.getName(), null, applicant.getAvatarUrl()) + .setColor(ApplicationCreateCommand.AMBIENT_COLOR) + .setTimestamp(Instant.now()) + .setFooter("Submitted at"); + + String roleString = args.getLast(); + MessageEmbed.Field roleField = new MessageEmbed.Field("Role", roleString, false); + embed.addField(roleField); + + MessageEmbed.Field answerField = + new MessageEmbed.Field(formConfig.defaultQuestion(), answer, false); + embed.addField(answerField); + + applicationChannel.get().sendMessageEmbeds(embed.build()).queue(); + } + + /** + * Retrieves the application channel from the given {@link Guild}. + * + * @param guild the guild from which to retrieve the application channel + * @return an {@link Optional} containing the {@link TextChannel} representing the application + * channel, or an empty {@link Optional} if no such channel is found + */ + private Optional getApplicationChannel(Guild guild) { + return guild.getChannels() + .stream() + .filter(channel -> applicationChannelPattern.test(channel.getName())) + .filter(channel -> channel.getType().isMessage()) + .map(TextChannel.class::cast) + .findFirst(); + } + + public Cache getApplicationSubmitCooldown() { + return applicationSubmitCooldown; + } + + protected void submitApplicationFromModalInteraction(ModalInteractionEvent event, + List args) { + Guild guild = event.getGuild(); + + if (guild == null) { + return; + } + + ModalMapping modalAnswer = event.getValues().getFirst(); + + sendApplicationResult(event, args, modalAnswer.getAsString()); + event.reply("Your application has been submitted. Thank you for applying! 😎") + .setEphemeral(true) + .queue(); + + applicationSubmitCooldown.put(event.getMember(), OffsetDateTime.now()); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/ApplicationCreateCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/ApplicationCreateCommand.java new file mode 100644 index 0000000000..067291024c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/ApplicationCreateCommand.java @@ -0,0 +1,297 @@ +package org.togetherjava.tjbot.features.roleapplication; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import net.dv8tion.jda.api.entities.emoji.EmojiUnion; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; +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.SlashCommandData; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.selections.SelectOption; +import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu; +import net.dv8tion.jda.api.interactions.components.text.TextInput; +import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; +import net.dv8tion.jda.api.interactions.modals.Modal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.ApplicationFormConfig; +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.componentids.Lifespan; + +import javax.annotation.Nullable; + +import java.awt.Color; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +/** + * Represents a command to create an application form for members to apply for roles. + *

+ * This command is designed to generate an application form for members to apply for roles within a + * guild. + */ +public class ApplicationCreateCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(ApplicationCreateCommand.class); + + protected static final Color AMBIENT_COLOR = new Color(24, 221, 136, 255); + private static final int OPTIONAL_ROLES_AMOUNT = 5; + private static final String ROLE_COMPONENT_ID_HEADER = "application-create"; + private static final String VALUE_DELIMITER = "_"; + private static final int ARG_COUNT = 3; + + private final ApplicationApplyHandler applicationApplyHandler; + private final ApplicationFormConfig formConfig; + + /** + * Constructs a new {@link ApplicationCreateCommand} with the specified configuration. + *

+ * This command is designed to generate an application form for members to apply for roles. + * + * @param config the configuration containing the settings for the application form + */ + public ApplicationCreateCommand(Config config) { + super("application-form", "Generates an application form for members to apply for roles.", + CommandVisibility.GUILD); + + this.formConfig = config.getApplicationFormConfig(); + + generateRoleOptions(getData()); + applicationApplyHandler = new ApplicationApplyHandler(formConfig); + } + + /** + * Populates a {@link SlashCommandData} object with the proper arguments. + * + * @param data the object to populate + */ + private void generateRoleOptions(SlashCommandData data) { + IntStream.range(1, OPTIONAL_ROLES_AMOUNT + 1).forEach(index -> { + data.addOption(OptionType.STRING, generateOptionId("title", index), + "The title of the role"); + data.addOption(OptionType.STRING, generateOptionId("description", index), + "The description of the role"); + data.addOption(OptionType.STRING, generateOptionId("emoji", index), + "The emoji of the role"); + }); + } + + private static String generateOptionId(String name, int id) { + return "%s%s%d".formatted(name, VALUE_DELIMITER, id); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + if (!handleHasPermissions(event)) { + return; + } + + final List optionMappings = event.getInteraction().getOptions(); + if (optionMappings.isEmpty()) { + event.reply("You have to select at least one role.").setEphemeral(true).queue(); + return; + } + + long incorrectArgsCount = getIncorrectRoleArgsCount(optionMappings); + if (incorrectArgsCount > 0) { + event.reply("Missing information for %d roles.".formatted(incorrectArgsCount)) + .setEphemeral(true) + .queue(); + return; + } + + sendMenu(event); + } + + @Override + public void onStringSelectSelection(StringSelectInteractionEvent event, List args) { + SelectOption selectOption = event.getSelectedOptions().getFirst(); + + if (selectOption == null) { + return; + } + + OffsetDateTime timeSentCache = applicationApplyHandler.getApplicationSubmitCooldown() + .getIfPresent(event.getMember()); + if (timeSentCache != null) { + Duration duration = Duration.between(timeSentCache, OffsetDateTime.now()); + long remainingMinutes = + formConfig.applicationSubmitCooldownMinutes() - duration.toMinutes(); + + if (duration.toMinutes() < formConfig.applicationSubmitCooldownMinutes()) { + event + .reply("Please wait %d minutes before sending a new application form." + .formatted(remainingMinutes)) + .setEphemeral(true) + .queue(); + return; + } + } + + String questionLabel = formConfig.defaultQuestion(); + if (questionLabel.length() > TextInput.MAX_LABEL_LENGTH) { + questionLabel = questionLabel.substring(0, TextInput.MAX_LABEL_LENGTH); + } + + TextInput body = TextInput + .create(generateComponentId(event.getUser().getId()), questionLabel, + TextInputStyle.PARAGRAPH) + .setRequired(true) + .setRequiredRange(formConfig.minimumAnswerLength(), formConfig.maximumAnswerLength()) + .setPlaceholder("Enter your answer here") + .build(); + + EmojiUnion emoji = selectOption.getEmoji(); + String roleDisplayName; + + if (emoji == null) { + roleDisplayName = selectOption.getLabel(); + } else { + roleDisplayName = "%s %s".formatted(emoji.getFormatted(), selectOption.getLabel()); + } + + Modal modal = Modal + .create(generateComponentId(event.getUser().getId(), roleDisplayName), + String.format("Application form - %s", selectOption.getLabel())) + .addActionRow(ActionRow.of(body).getComponents()) + .build(); + + event.replyModal(modal).queue(); + } + + /** + * Checks a given list of passed arguments (from a user) and calculates how many roles have + * missing data. + * + * @param args the list of passed arguments + * @return the amount of roles with missing data + */ + private static long getIncorrectRoleArgsCount(final List args) { + final Map frequencyMap = new HashMap<>(); + + args.stream() + .map(OptionMapping::getName) + .map(name -> name.split(VALUE_DELIMITER)[1]) + .forEach(number -> frequencyMap.merge(number, 1, Integer::sum)); + + return frequencyMap.values().stream().filter(value -> value != 3).count(); + } + + /** + * Populates a {@link StringSelectMenu.Builder} with application roles. + * + * @param menuBuilder the menu builder to populate + * @param args the arguments which contain data about the roles + */ + private void addRolesToMenu(StringSelectMenu.Builder menuBuilder, + final List args) { + final Map roles = new HashMap<>(); + + for (int i = 0; i < args.size(); i += ARG_COUNT) { + OptionMapping optionTitle = args.get(i); + OptionMapping optionDescription = args.get(i + 1); + OptionMapping optionEmoji = args.get(i + 2); + + roles.put(i, + new MenuRole(optionTitle.getAsString(), + generateComponentId(ROLE_COMPONENT_ID_HEADER, + optionTitle.getAsString()), + optionDescription.getAsString(), + Emoji.fromFormatted(optionEmoji.getAsString()))); + } + + roles.values() + .forEach(role -> menuBuilder.addOption(role.title(), role.value(), role.description(), + role.emoji())); + } + + private boolean handleHasPermissions(SlashCommandInteractionEvent event) { + Member member = event.getMember(); + Guild guild = event.getGuild(); + + if (member == null || guild == null) { + return false; + } + + if (!member.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 = guild.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 /{}.", getName()); + return false; + } + + return true; + } + + /** + * Sends the initial embed and a button which displays role openings. + * + * @param event the command interaction event triggering the menu + */ + private void sendMenu(final CommandInteraction event) { + MessageEmbed embed = createApplicationEmbed(); + + StringSelectMenu.Builder menuBuilder = StringSelectMenu + .create(generateComponentId(Lifespan.PERMANENT, event.getUser().getId())) + .setPlaceholder("Select role to apply for") + .setRequiredRange(1, 1); + + addRolesToMenu(menuBuilder, event.getOptions()); + + event.replyEmbeds(embed).addActionRow(menuBuilder.build()).queue(); + } + + private static MessageEmbed createApplicationEmbed() { + return new EmbedBuilder().setTitle("Apply for roles") + .setDescription( + """ + We are always looking for community members that want to contribute to our community \ + and take charge. If you are interested, you can apply for various positions here!""") + .setColor(AMBIENT_COLOR) + .build(); + } + + public ApplicationApplyHandler getApplicationApplyHandler() { + return applicationApplyHandler; + } + + @Override + public void onModalSubmitted(ModalInteractionEvent event, List args) { + getApplicationApplyHandler().submitApplicationFromModalInteraction(event, args); + } + + /** + * Wrapper class which represents a menu role for the application create command. + *

+ * The reason this exists is due to the fact that {@link StringSelectMenu.Builder} does not have + * a method which takes emojis as input as of writing this, so we have to elegantly pass in + * custom data from this POJO. + */ + private record MenuRole(String title, String value, String description, @Nullable Emoji emoji) { + + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/package-info.java new file mode 100644 index 0000000000..ac6ed5b52b --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/package-info.java @@ -0,0 +1,12 @@ +/** + * This packages offers all the functionality for the application-create command as well as the + * application system. The core class is + * {@link org.togetherjava.tjbot.features.roleapplication.ApplicationCreateCommand}. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.roleapplication; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault;