diff --git a/application/config.json.template b/application/config.json.template
index a1aec8f470..876d30714b 100644
--- a/application/config.json.template
+++ b/application/config.json.template
@@ -115,5 +115,12 @@
"fallbackChannelPattern": "java-news-and-changes",
"pollIntervalInMinutes": 10
},
+ "roleApplicationSystem": {
+ "submissionsChannelPattern": "staff-applications",
+ "defaultQuestion": "What makes you a good addition to the team?",
+ "minimumAnswerLength": 50,
+ "maximumAnswerLength": 500,
+ "applicationSubmitCooldownMinutes": 5
+ },
"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 e819f8e7d1..780c7089cc 100644
--- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java
+++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java
@@ -46,6 +46,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)
@@ -94,7 +95,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);
@@ -127,6 +130,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;
}
/**
@@ -410,6 +414,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..8446484585
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/config/RoleApplicationSystemConfig.java
@@ -0,0 +1,46 @@
+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
+ * @param minimumAnswerLength the minimum number of characters required for the applicant's answer
+ * @param maximumAnswerLength the maximum number of characters allowed for the applicant's answer
+ * @param applicationSubmitCooldownMinutes the cooldown time in minutes before the user can submit
+ * another application
+ */
+public record RoleApplicationSystemConfig(
+ @JsonProperty(value = "submissionsChannelPattern",
+ required = true) String submissionsChannelPattern,
+ @JsonProperty(value = "defaultQuestion", required = true) String defaultQuestion,
+ @JsonProperty(value = "minimumAnswerLength", required = true) int minimumAnswerLength,
+ @JsonProperty(value = "maximumAnswerLength", required = true) int maximumAnswerLength,
+ @JsonProperty(value = "applicationSubmitCooldownMinutes",
+ required = true) int applicationSubmitCooldownMinutes) {
+
+ /**
+ * 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 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..0c8b9443d5
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/ApplicationApplyHandler.java
@@ -0,0 +1,142 @@
+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.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 class ApplicationApplyHandler {
+ private final Cache applicationSubmitCooldown;
+ private final Predicate applicationChannelPattern;
+ private final RoleApplicationSystemConfig roleApplicationSystemConfig;
+
+ /**
+ * Constructs a new {@code ApplicationApplyHandler} instance.
+ *
+ * @param roleApplicationSystemConfig the configuration that contains the details for the
+ * application form including the cooldown duration and channel pattern.
+ */
+ public ApplicationApplyHandler(RoleApplicationSystemConfig roleApplicationSystemConfig) {
+ this.roleApplicationSystemConfig = roleApplicationSystemConfig;
+ this.applicationChannelPattern =
+ Pattern.compile(roleApplicationSystemConfig.submissionsChannelPattern())
+ .asMatchPredicate();
+
+ final Duration applicationSubmitCooldownDuration =
+ Duration.ofMinutes(roleApplicationSystemConfig.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(
+ 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;
+ }
+
+ 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());
+ }
+
+ protected long getMemberCooldownMinutes(Member member) {
+ OffsetDateTime timeSentCache = getApplicationSubmitCooldown().getIfPresent(member);
+ if (timeSentCache != null) {
+ Duration duration = Duration.between(timeSentCache, OffsetDateTime.now());
+ return roleApplicationSystemConfig.applicationSubmitCooldownMinutes()
+ - duration.toMinutes();
+ }
+ return 0L;
+ }
+}
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..f0460beb1e
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/ApplicationCreateCommand.java
@@ -0,0 +1,290 @@
+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.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 ApplicationCreateCommand 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 VALUE_DELIMITER = "_";
+ private static final int ARG_COUNT = 3;
+
+ private final ApplicationApplyHandler applicationApplyHandler;
+ private final RoleApplicationSystemConfig roleApplicationSystemConfig;
+
+ /**
+ * 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.roleApplicationSystemConfig = config.getRoleApplicationSystemConfig();
+
+ generateRoleOptions(getData());
+ applicationApplyHandler = new ApplicationApplyHandler(roleApplicationSystemConfig);
+ }
+
+ /**
+ * 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();
+ Member member = event.getMember();
+
+ if (selectOption == null || member == null) {
+ return;
+ }
+
+ long remainingMinutes = applicationApplyHandler.getMemberCooldownMinutes(member);
+ String correctMinutesWord = selectWordFromCount(remainingMinutes, "minute", "minutes");
+
+ if (remainingMinutes > 0) {
+ 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()),
+ roleApplicationSystemConfig.defaultQuestion(), TextInputStyle.PARAGRAPH)
+ .setRequired(true)
+ .setRequiredRange(roleApplicationSystemConfig.minimumAnswerLength(),
+ roleApplicationSystemConfig.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();
+ }
+
+ /**
+ * Selects and returns the appropriate singular or plural form of a word based on the given
+ * count.
+ *
+ * @param singularForm the word in its singular form
+ * @param pluralForm the word in its plural form
+ * @param count the number used to determine whether to return the singular or plural form
+ * @return the singular form if count equals 1, otherwise the plural form
+ */
+ private String selectWordFromCount(final Number count, final String singularForm,
+ final String pluralForm) {
+ if (count.intValue() == 1) {
+ return singularForm;
+ }
+ return pluralForm;
+ }
+
+ /**
+ * 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;
+ }
+
+ 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;