diff --git a/application/config.json.template b/application/config.json.template
index a32f8fd440..fdbd8a1870 100644
--- a/application/config.json.template
+++ b/application/config.json.template
@@ -175,5 +175,9 @@
"fallbackChannelPattern": "java-news-and-changes",
"pollIntervalInMinutes": 10
},
+ "roleApplicationSystem": {
+ "submissionsChannelPattern": "staff-applications",
+ "defaultQuestion": "What makes you a good addition to the team?"
+ },
"memberCountCategoryPattern": "Info"
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java
index 79c04e6cad..7ff4a80f38 100644
--- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java
+++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java
@@ -48,6 +48,7 @@ public final class Config {
private final RSSFeedsConfig rssFeedsConfig;
private final String selectRolesChannelPattern;
private final String memberCountCategoryPattern;
+ private final RoleApplicationSystemConfig roleApplicationSystemConfig;
@SuppressWarnings("ConstructorWithTooManyParameters")
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
@@ -100,7 +101,9 @@ private Config(@JsonProperty(value = "token", required = true) String token,
required = true) FeatureBlacklistConfig featureBlacklistConfig,
@JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig,
@JsonProperty(value = "selectRolesChannelPattern",
- required = true) String selectRolesChannelPattern) {
+ required = true) String selectRolesChannelPattern,
+ @JsonProperty(value = "roleApplicationSystem",
+ required = true) RoleApplicationSystemConfig roleApplicationSystemConfig) {
this.token = Objects.requireNonNull(token);
this.githubApiKey = Objects.requireNonNull(githubApiKey);
this.databasePath = Objects.requireNonNull(databasePath);
@@ -135,6 +138,7 @@ private Config(@JsonProperty(value = "token", required = true) String token,
this.featureBlacklistConfig = Objects.requireNonNull(featureBlacklistConfig);
this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig);
this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern);
+ this.roleApplicationSystemConfig = roleApplicationSystemConfig;
}
/**
@@ -437,6 +441,15 @@ public String getMemberCountCategoryPattern() {
return memberCountCategoryPattern;
}
+ /**
+ * The configuration related to the application form.
+ *
+ * @return the application form config
+ */
+ public RoleApplicationSystemConfig getRoleApplicationSystemConfig() {
+ return roleApplicationSystemConfig;
+ }
+
/**
* Gets the RSS feeds configuration.
*
diff --git a/application/src/main/java/org/togetherjava/tjbot/config/RoleApplicationSystemConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/RoleApplicationSystemConfig.java
new file mode 100644
index 0000000000..0979556b58
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/config/RoleApplicationSystemConfig.java
@@ -0,0 +1,38 @@
+package org.togetherjava.tjbot.config;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import net.dv8tion.jda.api.interactions.components.text.TextInput;
+
+import java.util.Objects;
+
+/**
+ * Represents the configuration for an application form, including roles and application channel
+ * pattern.
+ *
+ * @param submissionsChannelPattern the pattern used to identify the submissions channel where
+ * applications are sent
+ * @param defaultQuestion the default question that will be asked in the role application form
+ */
+public record RoleApplicationSystemConfig(
+ @JsonProperty(value = "submissionsChannelPattern",
+ required = true) String submissionsChannelPattern,
+ @JsonProperty(value = "defaultQuestion", required = true) String defaultQuestion) {
+
+ /**
+ * Constructs an instance of {@link RoleApplicationSystemConfig} with the provided parameters.
+ *
+ * This constructor ensures that {@code submissionsChannelPattern} and {@code defaultQuestion}
+ * are not null and that the length of the {@code defaultQuestion} does not exceed the maximum
+ * allowed length.
+ */
+ public RoleApplicationSystemConfig {
+ Objects.requireNonNull(submissionsChannelPattern);
+ Objects.requireNonNull(defaultQuestion);
+
+ if (defaultQuestion.length() > TextInput.MAX_LABEL_LENGTH) {
+ throw new IllegalArgumentException(
+ "defaultQuestion length is too long! Cannot be greater than %d"
+ .formatted(TextInput.MAX_LABEL_LENGTH));
+ }
+ }
+}
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 99241f5689..578cdd2357 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java
@@ -66,6 +66,7 @@
import org.togetherjava.tjbot.features.projects.ProjectsThreadCreatedListener;
import org.togetherjava.tjbot.features.reminder.RemindRoutine;
import org.togetherjava.tjbot.features.reminder.ReminderCommand;
+import org.togetherjava.tjbot.features.roleapplication.CreateRoleApplicationCommand;
import org.togetherjava.tjbot.features.system.BotCore;
import org.togetherjava.tjbot.features.system.LogLevelCommand;
import org.togetherjava.tjbot.features.tags.TagCommand;
@@ -197,6 +198,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 CreateRoleApplicationCommand(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/CreateRoleApplicationCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/CreateRoleApplicationCommand.java
new file mode 100644
index 0000000000..984e3b4ee6
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/CreateRoleApplicationCommand.java
@@ -0,0 +1,286 @@
+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.Config;
+import org.togetherjava.tjbot.config.RoleApplicationSystemConfig;
+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.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 CreateRoleApplicationCommand extends SlashCommandAdapter {
+ 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 OPTION_PARAM_ID_DELIMITER = "_";
+ private static final int OPTIONS_PER_ROLE = 3;
+ private static final int MINIMUM_ANSWER_LENGTH = 50;
+ private static final int MAXIMUM_ANSWER_LENGTH = 500;
+
+ private final RoleApplicationHandler handler;
+ private final RoleApplicationSystemConfig config;
+
+ private static final Logger logger =
+ LoggerFactory.getLogger(CreateRoleApplicationCommand.class);
+
+ /**
+ * Constructs a new {@link CreateRoleApplicationCommand} 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 CreateRoleApplicationCommand(Config config) {
+ super("application-form", "Generates an application form for members to apply for roles.",
+ CommandVisibility.GUILD);
+
+ this.config = config.getRoleApplicationSystemConfig();
+
+ generateRoleOptions();
+ handler = new RoleApplicationHandler(this.config);
+ }
+
+ /**
+ * Populates this command's instance {@link SlashCommandData} object with the proper arguments
+ * for this command.
+ */
+ private void generateRoleOptions() {
+ final SlashCommandData data = getData();
+
+ 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, OPTION_PARAM_ID_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();
+ Member member = event.getMember();
+
+ if (member == null) {
+ logger.error("Member was null during onStringSelectSelection()");
+ return;
+ }
+
+ if (selectOption == null) {
+ logger.error("selectOption was null during onStringSelectSelection()");
+ return;
+ }
+
+ long remainingMinutes = handler.getMemberCooldownMinutes(member);
+ if (remainingMinutes > 0) {
+ String correctMinutesWord = remainingMinutes == 1 ? "minute" : "minutes";
+
+ event
+ .reply("Please wait %d %s before sending a new application form."
+ .formatted(remainingMinutes, correctMinutesWord))
+ .setEphemeral(true)
+ .queue();
+ return;
+ }
+
+ TextInput body = TextInput
+ .create(generateComponentId(event.getUser().getId()), config.defaultQuestion(),
+ TextInputStyle.PARAGRAPH)
+ .setRequired(true)
+ .setRequiredRange(MINIMUM_ANSWER_LENGTH, MAXIMUM_ANSWER_LENGTH)
+ .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(OPTION_PARAM_ID_DELIMITER)[1])
+ .forEach(number -> frequencyMap.merge(number, 1, Integer::sum));
+
+ return frequencyMap.values().stream().filter(value -> value != OPTIONS_PER_ROLE).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 += OPTIONS_PER_ROLE) {
+ 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;
+ }
+
+ 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 RoleApplicationHandler getApplicationApplyHandler() {
+ return handler;
+ }
+
+ @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/RoleApplicationHandler.java b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/RoleApplicationHandler.java
new file mode 100644
index 0000000000..4e8e45bc08
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/RoleApplicationHandler.java
@@ -0,0 +1,162 @@
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.togetherjava.tjbot.config.RoleApplicationSystemConfig;
+
+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 final class RoleApplicationHandler {
+ private static final Logger logger = LoggerFactory.getLogger(RoleApplicationHandler.class);
+
+ private static final int APPLICATION_SUBMIT_COOLDOWN_MINUTES = 5;
+ private final Cache applicationSubmitCooldown;
+ private final Predicate applicationChannelPattern;
+ private final RoleApplicationSystemConfig roleApplicationSystemConfig;
+
+ /**
+ * Constructs a new {@code RoleApplicationHandler} instance.
+ *
+ * @param roleApplicationSystemConfig the configuration that contains the details for the
+ * application form including the cooldown duration and channel pattern.
+ */
+ public RoleApplicationHandler(RoleApplicationSystemConfig roleApplicationSystemConfig) {
+ this.roleApplicationSystemConfig = roleApplicationSystemConfig;
+ this.applicationChannelPattern =
+ Pattern.compile(roleApplicationSystemConfig.submissionsChannelPattern())
+ .asMatchPredicate();
+
+ final Duration applicationSubmitCooldownDuration =
+ Duration.ofMinutes(APPLICATION_SUBMIT_COOLDOWN_MINUTES);
+ 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
+ */
+ private void sendApplicationResult(final ModalInteractionEvent event, List args,
+ String answer) throws IllegalArgumentException {
+ Guild guild = event.getGuild();
+
+ if (guild == null) {
+ throw new IllegalArgumentException(
+ "sendApplicationResult() got fired in a non-guild environment.");
+ }
+
+ if (args.size() != 2) {
+ throw new IllegalArgumentException(
+ "Received application result after user submitted one, and did not receive 2 arguments. Args: "
+ + args);
+ }
+
+ String roleString = args.get(1);
+
+ Optional applicationChannel = getApplicationChannel(guild);
+ if (applicationChannel.isEmpty()) {
+ throw new IllegalArgumentException("Application channel %s could not be found."
+ .formatted(roleApplicationSystemConfig.submissionsChannelPattern()));
+ }
+
+ User applicant = event.getUser();
+ EmbedBuilder embed =
+ new EmbedBuilder().setAuthor(applicant.getName(), null, applicant.getAvatarUrl())
+ .setColor(CreateRoleApplicationCommand.AMBIENT_COLOR)
+ .setFooter("Submitted at")
+ .setTimestamp(Instant.now());
+
+ MessageEmbed.Field roleField = new MessageEmbed.Field("Role", roleString, false);
+ embed.addField(roleField);
+
+ MessageEmbed.Field answerField = new MessageEmbed.Field(
+ roleApplicationSystemConfig.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;
+ }
+
+ void submitApplicationFromModalInteraction(ModalInteractionEvent event, List args) {
+ Guild guild = event.getGuild();
+
+ if (guild == null) {
+ return;
+ }
+
+ ModalMapping modalAnswer = event.getValues().getFirst();
+
+ try {
+ sendApplicationResult(event, args, modalAnswer.getAsString());
+ event.reply("Your application has been submitted. Thank you for applying! 😎")
+ .setEphemeral(true)
+ .queue();
+ } catch (IllegalArgumentException e) {
+ logger.error("A role application could not be submitted. ", e);
+ event.reply("Your application could not be submitted. Please contact the staff team.")
+ .setEphemeral(true)
+ .queue();
+ }
+
+ applicationSubmitCooldown.put(event.getMember(), OffsetDateTime.now());
+ }
+
+ long getMemberCooldownMinutes(Member member) {
+ OffsetDateTime timeSentCache = getApplicationSubmitCooldown().getIfPresent(member);
+ if (timeSentCache != null) {
+ Duration duration = Duration.between(timeSentCache, OffsetDateTime.now());
+ return APPLICATION_SUBMIT_COOLDOWN_MINUTES - duration.toMinutes();
+ }
+ return 0L;
+ }
+}
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..cc527ac009
--- /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.CreateRoleApplicationCommand}.
+ */
+@MethodsReturnNonnullByDefault
+@ParametersAreNonnullByDefault
+package org.togetherjava.tjbot.features.roleapplication;
+
+import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault;
+
+import javax.annotation.ParametersAreNonnullByDefault;