diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 58be1a6a47..225ed14b06 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1 +1 @@
-* @Together-Java/moderators @Together-Java/staff-assistants
+* @Together-Java/moderators @Together-Java/maintainers
diff --git a/PP.md b/PP.md
new file mode 100644
index 0000000000..abad2fdbaa
--- /dev/null
+++ b/PP.md
@@ -0,0 +1,114 @@
+# Privacy Policy
+
+## Definitions
+
+* "**TJ-Bot**" (also "**bot**") refers to the Discord bot that is subject under this policy
+* "**[Together Java](https://github.com/orgs/Together-Java/teams/moderators/members)**" (also "**We**" or "**Us**") is the group of people responsible for the **TJ-Bot** product
+
+## Preample
+
+This Privacy Policy document contains types of information that is collected and recorded by **TJ-Bot** and how **Together Java** uses it.
+
+If you have additional questions or require more information about **Together Java**'s Privacy Policy, do not hesitate to [contact us](#contact).
+
+## General Data Protection Regulation (GDPR)
+
+We are a Data Controller of your information.
+
+**Together Java** legal basis for collecting and using the personal information described in this Privacy Policy depends on the Personal Information we collect and the specific context in which we collect the information:
+
+* You have given **Together Java** permission to do so
+* Processing your personal information is in **Together Java** legitimate interests
+* **Together Java** needs to comply with the law
+
+**Together Java** will retain your personal information only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use your information to the extent necessary to comply with our legal obligations, resolve disputes, and enforce our policies.
+
+If you are a resident of the European Economic Area (EEA), you have certain data protection rights. If you wish to be informed what Personal Information we hold about you and if you want it to be removed from our systems, please [contact us](#contact).
+
+In certain circumstances, you have the following data protection rights:
+
+* The right to access, update or to delete the information we have on you.
+* The right of rectification.
+* The right to object.
+* The right of restriction.
+* The right to data portability
+* The right to withdraw consent
+
+## Usage of Data
+
+**TJ-Bot** may use stored data, as defined below, to offer different features and services. No usage of data outside of the aformentioned cases will happen and the data is not shared with any third-party site or service.
+
+### Databases
+
+**TJ-Bot** uses databases to store information about users, in order to provide its features and services. The database schemas are public source and can be viewed [here](https://github.com/Together-Java/TJ-Bot/tree/develop/application/src/main/resources/db).
+
+The databases may store
+* `user_id` of users (the unique id of a Discord account),
+* `timestamp`s of actions (for example when a command has been used),
+* `guild_id` of guilds the **bot** is member of (the unique id of a Discord guild),
+* `channel_id` of channels belonging to guilds the **bot** is member of (the unique id of a Discord channel),
+* `message_id` of messages send by users in guilds the **bot** is member of (the unique id of a Discord message),
+
+and any combination of those.
+
+For example, **TJ-Bot** may associate your `user_id` with a `message_id` and a `timestamp` for any message that you send in a channel belonging to guilds the **bot** is member of.
+
+**TJ-Bot** may further store data that you explicitly provided for **TJ-Bot** to offer its services. For example the reason of a moderative action when using its moderation commands.
+
+The stored data is not linked to any information that is personally identifiable.
+
+No other personal information outside of the above mentioned one will be stored. In particular, **TJ-Bot** does not store the content of sent messages.
+
+### Log Files
+
+**TJ-Bot** follows a standard procedure of using log files. These files log users when they use one of the **bot**'s provided commands, features or services.
+
+The information collected by log files include
+
+* `user_name` of users (the nickname of a Discord account),
+* `user_discrimator` of users (the unique discriminator of a Discord account),
+* `user_id` of users (the unique id of a Discord account),
+* `timestamp`s of actions (for example when a command has been used),
+* `guild_id` of guilds the **bot** is member of (the unique id of a Discord guild),
+* `channel_id` of channels belonging to guilds the **bot** is member of (the unique id of a Discord channel),
+* `message_id` of messages send by users in guilds the **bot** is member of (the unique id of a Discord message).
+
+The stored data is not linked to any information that is personally identifiable.
+
+No other personal information outside of the above mentioned one will be stored. In particular, **TJ-Bot** does not store the content of sent messages.
+
+The purpose of the information is for analyzing trends, administering the **bot**, and gathering demographic information.
+
+### Temporarely stored Information
+
+**TJ-Bot** may keep the stored information in an internal cacheing mechanic for a certain amount of time. After this time period, the cached information will be dropped and only be re-added when required.
+
+Data may be dropped from cache pre-maturely through actions such as removing the **bot** from a Server.
+
+## Removal of Data
+
+Removal of the data can be requested through e-mail at [together.java.tjbot@gmail.com](mailto:together.java.tjbot@gmail.com).
+
+For security reasons will we ask you to provide us with proof of ownership of the Server, that you wish the data to be removed of. Only a server owner may request removal of data and requesting it will result in the bot being removed from the Server, if still present on it.
+
+## Third Party Privacy Policies
+
+This Privacy Policy does not apply to other linked websites or service providers. Thus, we are advising you to consult the respective Privacy Policies of these third-party websites or services for more detailed information. It may include their practices and instructions about how to opt-out of certain options.
+
+## Children's Information
+
+Another part of our priority is adding protection for children while using the internet. We encourage parents and guardians to observe, participate in, and/or monitor and guide their online activity.
+
+**TJ-Bot** does not knowingly collect any Personal Identifiable Information from children under the age of 13. If you think that your child provided this kind of information to our **bot**, we strongly encourage you to [contact us](#contact) immediately and we will do our best efforts to promptly remove such information from our records.
+
+## Limitations
+
+Our Privacy Policy applies only to **TJ-Bot** instances that are member of a Server owned by **Together Java**.
+
+This policy is not applicable to any information collected by **bot** instances hosted by other parties, even if they have been build based on our official [source](https://github.com/Together-Java/TJ-Bot). This policy is also not applicable to any information collected via channels other than this **bot**, such as the data collected by linked websites.
+
+## Contact
+
+People may get in contact through e-mail at [together.java.tjbot@gmail.com](mailto:together.java.tjbot@gmail.com), or through **Together Java**'s [official Discord](https://discord.com/invite/XXFUXzK).
+
+Other ways of support may be provided but are not guaranteed.
\ No newline at end of file
diff --git a/TOS.md b/TOS.md
new file mode 100644
index 0000000000..a4c82bc60b
--- /dev/null
+++ b/TOS.md
@@ -0,0 +1,74 @@
+# Terms of Service
+
+## Definitions
+
+* "**TJ-Bot**" (also "**bot**") refers to the Discord bot that is subject under these terms
+* "**[Together Java](https://github.com/orgs/Together-Java/teams/moderators/members)**" (also "**We**") is the group of people responsible for the **TJ-Bot** product, to whom you agree to on accepting these terms
+
+## Usage Agreement
+
+By inviting **TJ-Bot** and using its features (accessible via [Discord](https://discord.com/), source at [GitHub](https://github.com/Together-Java/TJ-Bot)), you are agreeing to be bound by these Terms and Conditions of Use and agree that you are responsible for the agreement with any applicable local laws. If you disagree with any of these terms, you are prohibited from using this **bot**. The materials contained and used by the **bot** are protected by copyright and trade mark law.
+
+### Servers
+
+You acknowledge that you must only invite the **bot** to a Server owned by **Together Java**, and only with their explicit approval.
+
+Further, you have the priviledge to build and host the **bot** based on the [source](https://github.com/Together-Java/TJ-Bot) yourself. You may use such a self-hosted **bot** freely on any Discord Server (Server) you share with it, you can invite it to any Server that you have "Manage Server" rights for and you acknowledge that this priviledge might get revoked for you, if you're subject of breaking the terms and/or policy of this **bot**, or the Terms of Service, Privacy Policy and/or Community Guidelines of Discord Inc.
+
+Through Inviting, the **bot** may collect specific data as described in its [Privacy Policy](#your-privacy).
+The intended usage of this data is for core functionalities of the **bot** such as command handling.
+
+## Intended Age
+
+The **bot** may not be used by individuals under the minimal age described in Discord's Terms of Service.
+
+Doing so will be seen as a violation of these terms. Upon violation, your usage right will also be terminated and you have to remove the **bot** from any Server that you have "Manage Server" rights for, as well as destroy any downloaded materials in your possession whether it is printed or electronic format.
+
+## Affiliation
+
+The **bot** is not affiliated with, supported or made by Discord Inc., Sun Microsystems or Oracle.
+
+Any direct connection to Discord, Sun Microsystems, Oracle or any of their Trademark objects is purely coincidental. **Together Java** does not claim to have the copyright ownership of any of Discord's, Sun Microsystems' or Oracle's assets, trademarks or other intellectual property.
+
+## Use License
+
+Through using the **bot**, you acknownledge to adhere to the terms defined by its [license](https://github.com/Together-Java/TJ-Bot/blob/develop/LICENSE).
+
+This will let **Together Java** to terminate upon violations of any of these restrictions. Upon termination, your usage right will also be terminated and you have to remove the **bot** from any Server that you have "Manage Server" rights for, as well as destroy any downloaded materials in your possession whether it is printed or electronic format.
+
+## Disclaimer
+
+All the materials used by **TJ-Bot** are provided "as is". **Together Java** makes no warranties, may it be expressed or implied, therefore negates all other warranties. Furthermore, **Together Java** does not make any representations concerning the accuracy or reliability of the use of the materials on **TJ-Bot** or otherwise relating to such materials or any sites linked by this **bot**.
+
+## Limitations
+
+**Together Java** or its suppliers will not be hold accountable for any damages that will arise with the use or inability to use the materials or services provided by **TJ-Bot**, even if **Together Java** or an authorize representative of this **bot** has been notified, orally or written, of the possibility of such damage. Some jurisdiction does not allow limitations on implied warranties or limitations of liability for incidental damages, these limitations may not apply to you.
+
+**Together Java** may not be made liable for individuals breaking these Terms at any given time.
+**Together Java** has faith in the end users being truthfull about their information and not missusing this **bot** or The Services of Discord Inc in a malicious way.
+
+## Revisions and Errata
+
+The materials used by **TJ-Bot** may include technical, typographical, or photographic errors. **Together Java** will not promise that any of these materials are accurate, complete, or current. **Together Java** may change the materials used by **TJ-Bot** at any time without notice. **Together Java** does not make any commitment to update the materials.
+
+## Links
+
+**Together Java** has not reviewed all of the sites linked to **TJ-Bot** and is not responsible for the contents of any such linked site. The presence of any link does not imply endorsement by **Together Java** of the site. The use of any linked website is at the userโs own risk.
+
+## Terms of Use Modifications
+
+**Together Java** may revise these Terms of Use for **TJ-Bot** at any time without prior notice. By using the **bot**, you are agreeing to be bound by the current version of these Terms and Conditions of Use.
+
+## Your Privacy
+
+Please read our [Privacy Policy](https://github.com/Together-Java/TJ-Bot/blob/develop/PP.md).
+
+## Governing Law
+
+Any claim related to **Together Java**'s **bot** shall be governed by the laws of de without regards to its conflict of law provisions.
+
+## Contact
+
+People may get in contact through e-mail at [together.java.tjbot@gmail.com](mailto:together.java.tjbot@gmail.com), or through **Together Java**'s [official Discord](https://discord.com/invite/XXFUXzK).
+
+Other ways of support may be provided but are not guaranteed.
diff --git a/application/config.json.template b/application/config.json.template
index 6ebd7715db..809c6260e1 100644
--- a/application/config.json.template
+++ b/application/config.json.template
@@ -16,5 +16,10 @@
]
}
],
- "helpChannelPattern": "([a-zA-Z_]+_)?help(_\\d+)?"
+ "helpChannelPattern": "([a-zA-Z_]+_)?help(_\\d+)?",
+ "suggestions": {
+ "channelPattern": "tj_suggestions",
+ "upVoteEmoteName": "peepo_yes",
+ "downVoteEmoteName": "peepo_no"
+ }
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/Application.java b/application/src/main/java/org/togetherjava/tjbot/Application.java
index 7383bebb8d..e26f948db2 100644
--- a/application/src/main/java/org/togetherjava/tjbot/Application.java
+++ b/application/src/main/java/org/togetherjava/tjbot/Application.java
@@ -44,8 +44,9 @@ public static void main(final String[] args) {
}
Path configPath = Path.of(args.length == 1 ? args[0] : DEFAULT_CONFIG_PATH);
+ Config config;
try {
- Config.load(configPath);
+ config = Config.load(configPath);
} catch (IOException e) {
logger.error("Unable to load the configuration file from path '{}'",
configPath.toAbsolutePath(), e);
@@ -53,8 +54,7 @@ public static void main(final String[] args) {
}
try {
- Config config = Config.getInstance();
- runBot(config.getToken(), Path.of(config.getDatabasePath()));
+ runBot(config);
} catch (Exception t) {
logger.error("Unknown error", t);
}
@@ -63,12 +63,13 @@ public static void main(final String[] args) {
/**
* Runs an instance of the bot, connecting to the given token and using the given database.
*
- * @param token the Discord Bot token to connect with
- * @param databasePath the path to the database to use
+ * @param config the configuration to run the bot with
*/
@SuppressWarnings("WeakerAccess")
- public static void runBot(String token, Path databasePath) {
+ public static void runBot(Config config) {
logger.info("Starting bot...");
+
+ Path databasePath = Path.of(config.getDatabasePath());
try {
Path parentDatabasePath = databasePath.toAbsolutePath().getParent();
if (parentDatabasePath != null) {
@@ -76,10 +77,10 @@ public static void runBot(String token, Path databasePath) {
}
Database database = new Database("jdbc:sqlite:" + databasePath.toAbsolutePath());
- JDA jda = JDABuilder.createDefault(token)
+ JDA jda = JDABuilder.createDefault(config.getToken())
.enableIntents(GatewayIntent.GUILD_MEMBERS)
.build();
- jda.addEventListener(new BotCore(jda, database));
+ jda.addEventListener(new BotCore(jda, database, config));
jda.awaitReady();
logger.info("Bot is ready");
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java
index 393172ed99..b371bf6520 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java
@@ -3,11 +3,15 @@
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.SuggestionsUpDownVoter;
import org.togetherjava.tjbot.commands.basic.VcActivityCommand;
import org.togetherjava.tjbot.commands.free.FreeCommand;
import org.togetherjava.tjbot.commands.mathcommands.TeXCommand;
import org.togetherjava.tjbot.commands.moderation.*;
import org.togetherjava.tjbot.commands.moderation.temp.TemporaryModerationRoutine;
+import org.togetherjava.tjbot.commands.reminder.RemindCommand;
+import org.togetherjava.tjbot.commands.reminder.RemindRoutine;
import org.togetherjava.tjbot.commands.system.BotCore;
import org.togetherjava.tjbot.commands.tags.TagCommand;
import org.togetherjava.tjbot.commands.tags.TagManageCommand;
@@ -16,6 +20,7 @@
import org.togetherjava.tjbot.commands.tophelper.TopHelpersCommand;
import org.togetherjava.tjbot.commands.tophelper.TopHelpersMessageListener;
import org.togetherjava.tjbot.commands.tophelper.TopHelpersPurgeMessagesRoutine;
+import org.togetherjava.tjbot.config.Config;
import org.togetherjava.tjbot.db.Database;
import org.togetherjava.tjbot.routines.ModAuditLogRoutine;
@@ -28,7 +33,7 @@
* it with the system.
*
* To add a new slash command, extend the commands returned by
- * {@link #createFeatures(JDA, Database)}.
+ * {@link #createFeatures(JDA, Database, Config)}.
*/
public enum Features {
;
@@ -41,10 +46,11 @@ public enum Features {
*
* @param jda the JDA instance commands will be registered at
* @param database the database of the application, which features can use to persist data
+ * @param config the configuration features should use
* @return a collection of all features
*/
public static @NotNull Collection createFeatures(@NotNull JDA jda,
- @NotNull Database database) {
+ @NotNull Database database, @NotNull Config config) {
TagSystem tagSystem = new TagSystem(database);
ModerationActionsStore actionsStore = new ModerationActionsStore(database);
@@ -54,34 +60,39 @@ public enum Features {
Collection features = new ArrayList<>();
// Routines
- features.add(new ModAuditLogRoutine(database));
- features.add(new TemporaryModerationRoutine(jda, actionsStore));
+ features.add(new ModAuditLogRoutine(database, config));
+ features.add(new TemporaryModerationRoutine(jda, actionsStore, config));
features.add(new TopHelpersPurgeMessagesRoutine(database));
+ features.add(new RemindRoutine(database));
// Message receivers
- features.add(new TopHelpersMessageListener(database));
+ features.add(new TopHelpersMessageListener(database, config));
+ features.add(new SuggestionsUpDownVoter(config));
// Event receivers
- features.add(new RejoinMuteListener(actionsStore));
+ features.add(new RejoinMuteListener(actionsStore, config));
// Slash commands
features.add(new PingCommand());
features.add(new TeXCommand());
features.add(new TagCommand(tagSystem));
- features.add(new TagManageCommand(tagSystem));
+ features.add(new TagManageCommand(tagSystem, config));
features.add(new TagsCommand(tagSystem));
features.add(new VcActivityCommand());
- features.add(new WarnCommand(actionsStore));
- features.add(new KickCommand(actionsStore));
- features.add(new BanCommand(actionsStore));
- features.add(new UnbanCommand(actionsStore));
- features.add(new AuditCommand(actionsStore));
- features.add(new MuteCommand(actionsStore));
- features.add(new UnmuteCommand(actionsStore));
- features.add(new TopHelpersCommand(database));
+ features.add(new WarnCommand(actionsStore, config));
+ features.add(new KickCommand(actionsStore, config));
+ features.add(new BanCommand(actionsStore, config));
+ features.add(new UnbanCommand(actionsStore, config));
+ features.add(new AuditCommand(actionsStore, config));
+ features.add(new MuteCommand(actionsStore, config));
+ features.add(new UnmuteCommand(actionsStore, config));
+ features.add(new TopHelpersCommand(database, config));
+ features.add(new RoleSelectCommand());
+ features.add(new NoteCommand(actionsStore, config));
+ features.add(new RemindCommand(database));
// Mixtures
- features.add(new FreeCommand());
+ features.add(new FreeCommand(config));
return features;
}
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
new file mode 100644
index 0000000000..de5f9c5716
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/basic/RoleSelectCommand.java
@@ -0,0 +1,304 @@
+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.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.
+ *
+ *
+ * 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
+ */
+public final class RoleSelectCommand extends SlashCommandAdapter {
+
+ private static final Logger logger = LoggerFactory.getLogger(RoleSelectCommand.class);
+
+ 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 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);
+
+ getData().addOptions(messageOptions);
+ }
+
+ @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()));
+
+ addMenuOptions(event, menu, "Select the roles to display", 1);
+
+ // 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()));
+
+ event.reply(messageBuilder.build()).setEphemeral(true).queue();
+ }
+
+ /**
+ * Adds role options to a selection menu.
+ *
+ *
+ * @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 guildRoles = guild.getRoles();
+
+ Collection roles = new ArrayList<>(
+ guildRoles.subList(guildRoles.indexOf(highestBotRole) + 1, guildRoles.size()));
+
+ 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 args) {
+
+ Guild guild = Objects.requireNonNull(event.getGuild(), "The given guild cannot be null");
+ List selectedOptions = Objects.requireNonNull(event.getSelectedOptions(),
+ "The given selectedOptions cannot be null");
+
+ List selectedRoles = selectedOptions.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);
+ }
+ }
+
+ /**
+ * Handles selection of a {@link SelectionMenuEvent}.
+ *
+ * @param event the unacknowledged {@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 selectedRoles, final Guild guild) {
+ Collection rolesToAdd = new ArrayList<>(selectedRoles.size());
+ Collection 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 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 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) {
+ 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 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 Interaction 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))
+ .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;
+ }
+ }
+}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/basic/SuggestionsUpDownVoter.java b/application/src/main/java/org/togetherjava/tjbot/commands/basic/SuggestionsUpDownVoter.java
new file mode 100644
index 0000000000..48b05e1bb2
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/basic/SuggestionsUpDownVoter.java
@@ -0,0 +1,66 @@
+package org.togetherjava.tjbot.commands.basic;
+
+import net.dv8tion.jda.api.entities.Emote;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.Message;
+import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.togetherjava.tjbot.commands.MessageReceiverAdapter;
+import org.togetherjava.tjbot.config.Config;
+import org.togetherjava.tjbot.config.SuggestionsConfig;
+
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+/**
+ * Listener that receives all sent messages from suggestion channels and reacts with an up- and
+ * down-vote on them to indicate to users that they can vote on the suggestion.
+ */
+public final class SuggestionsUpDownVoter extends MessageReceiverAdapter {
+ private static final Logger logger = LoggerFactory.getLogger(SuggestionsUpDownVoter.class);
+ private static final String FALLBACK_UP_VOTE = "๐";
+ private static final String FALLBACK_DOWN_VOTE = "๐";
+
+ private final SuggestionsConfig config;
+
+ /**
+ * Creates a new listener to receive all message sent in suggestion channels.
+ *
+ * @param config the config to use for this
+ */
+ public SuggestionsUpDownVoter(@NotNull Config config) {
+ super(Pattern.compile(config.getSuggestions().getChannelPattern()));
+
+ this.config = config.getSuggestions();
+ }
+
+ @Override
+ public void onMessageReceived(@NotNull GuildMessageReceivedEvent event) {
+ if (event.getAuthor().isBot() || event.isWebhookMessage()) {
+ return;
+ }
+
+ Guild guild = event.getGuild();
+ Message message = event.getMessage();
+
+ reactWith(config.getUpVoteEmoteName(), FALLBACK_UP_VOTE, guild, message);
+ reactWith(config.getDownVoteEmoteName(), FALLBACK_DOWN_VOTE, guild, message);
+ }
+
+ private static void reactWith(@NotNull String emoteName, @NotNull String fallbackUnicodeEmote,
+ @NotNull Guild guild, @NotNull Message message) {
+ getEmoteByName(emoteName, guild).map(message::addReaction).orElseGet(() -> {
+ logger.warn(
+ "Unable to vote on a suggestion with the configured emote ('{}'), using fallback instead.",
+ emoteName);
+ return message.addReaction(fallbackUnicodeEmote);
+ }).queue();
+ }
+
+ private static @NotNull Optional getEmoteByName(@NotNull String name,
+ @NotNull Guild guild) {
+ return guild.getEmotesByName(name, false).stream().findAny();
+ }
+}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java
index ee289c0402..dea8890c7c 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java
@@ -61,6 +61,8 @@ public final class FreeCommand extends SlashCommandAdapter implements EventRecei
private static final String COMMAND_NAME = "free";
private static final Color MESSAGE_HIGHLIGHT_COLOR = Color.decode("#CCCC00");
+ private final Config config;
+
// Map to store channel ID's, use Guild.getChannels() to guarantee order for display
private final ChannelMonitor channelMonitor;
private final Map channelIdToMessageIdForStatus;
@@ -73,11 +75,14 @@ public final class FreeCommand extends SlashCommandAdapter implements EventRecei
*
* This fetches configuration information from a json configuration file (see
* {@link FreeCommandConfig}) for further details.
+ *
+ * @param config the config to use for this
*/
- public FreeCommand() {
+ public FreeCommand(@NotNull Config config) {
super(COMMAND_NAME, "Marks this channel as free for another user to ask a question",
SlashCommandVisibility.GUILD);
+ this.config = config;
channelIdToMessageIdForStatus = new HashMap<>();
channelMonitor = new ChannelMonitor();
@@ -339,8 +344,7 @@ public void onEvent(@NotNull GenericEvent event) {
}
private void initChannelsToMonitor() {
- Config.getInstance()
- .getFreeCommandConfig()
+ config.getFreeCommandConfig()
.stream()
.map(FreeCommandConfig::getMonitoredChannels)
.flatMap(Collection::stream)
@@ -348,8 +352,7 @@ private void initChannelsToMonitor() {
}
private void initStatusMessageChannels(@NotNull final JDA jda) {
- Config.getInstance()
- .getFreeCommandConfig()
+ config.getFreeCommandConfig()
.stream()
.map(FreeCommandConfig::getStatusChannel)
// throws IllegalStateException if the id's don't match TextChannels
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/AuditCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/AuditCommand.java
index a9391a5871..9a8c67e2d3 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/AuditCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/AuditCommand.java
@@ -39,16 +39,17 @@ public final class AuditCommand extends SlashCommandAdapter {
* Constructs an instance.
*
* @param actionsStore used to store actions issued by this command
+ * @param config the config to use for this
*/
- public AuditCommand(@NotNull ModerationActionsStore actionsStore) {
+ public AuditCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) {
super(COMMAND_NAME, "Lists all moderation actions that have been taken against a user",
SlashCommandVisibility.GUILD);
getData().addOption(OptionType.USER, TARGET_OPTION, "The user who to retrieve actions for",
true);
- hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern())
- .asMatchPredicate();
+ hasRequiredRole =
+ Pattern.compile(config.getHeavyModerationRolePattern()).asMatchPredicate();
this.actionsStore = Objects.requireNonNull(actionsStore);
}
@@ -91,6 +92,7 @@ public AuditCommand(@NotNull ModerationActionsStore actionsStore) {
.getDateTimeString(action.actionExpiresAt().atOffset(ZoneOffset.UTC)));
return jda.retrieveUserById(action.authorId())
+ .onErrorMap(error -> null)
.map(author -> new EmbedBuilder().setTitle(action.actionType().name())
.setAuthor(author == null ? "(unknown user)" : author.getAsTag(), null,
author == null ? null : author.getAvatarUrl())
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java
index f11d15772c..d4a700d02b 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java
@@ -55,8 +55,9 @@ public final class BanCommand extends SlashCommandAdapter {
* Constructs an instance.
*
* @param actionsStore used to store actions issued by this command
+ * @param config the config to use for this
*/
- public BanCommand(@NotNull ModerationActionsStore actionsStore) {
+ public BanCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) {
super(COMMAND_NAME, "Bans the given user from the server", SlashCommandVisibility.GUILD);
OptionData durationData = new OptionData(OptionType.STRING, DURATION_OPTION,
@@ -70,8 +71,8 @@ public BanCommand(@NotNull ModerationActionsStore actionsStore) {
"the amount of days of the message history to delete, none means no messages are deleted.",
true).addChoice("none", 0).addChoice("recent", 1).addChoice("all", 7));
- hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern())
- .asMatchPredicate();
+ hasRequiredRole =
+ Pattern.compile(config.getHeavyModerationRolePattern()).asMatchPredicate();
this.actionsStore = Objects.requireNonNull(actionsStore);
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java
index 8c9fcf5dfe..b943b1e27d 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java
@@ -42,15 +42,15 @@ public final class KickCommand extends SlashCommandAdapter {
* Constructs an instance.
*
* @param actionsStore used to store actions issued by this command
+ * @param config the config to use for this
*/
- public KickCommand(@NotNull ModerationActionsStore actionsStore) {
+ public KickCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) {
super(COMMAND_NAME, "Kicks the given user from the server", SlashCommandVisibility.GUILD);
getData().addOption(OptionType.USER, TARGET_OPTION, "The user who you want to kick", true)
.addOption(OptionType.STRING, REASON_OPTION, "Why the user should be kicked", true);
- hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern())
- .asMatchPredicate();
+ hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate();
this.actionsStore = Objects.requireNonNull(actionsStore);
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationAction.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationAction.java
index 505830864e..81ab95345a 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationAction.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationAction.java
@@ -29,7 +29,11 @@ public enum ModerationAction {
/**
* When a user unmutes another user.
*/
- UNMUTE("unmuted");
+ UNMUTE("unmuted"),
+ /**
+ * When a user writes a note about another user.
+ */
+ NOTE("wrote a note about");
private final String verb;
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java
index fcb916be95..ec4c9bfb3c 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java
@@ -40,12 +40,6 @@ public enum ModerationUtils {
* embeds.
*/
static final Color AMBIENT_COLOR = Color.decode("#895FE8");
- /**
- * Matches the name of the role that is used to mute users, as used by {@link MuteCommand} and
- * similar.
- */
- public static final Predicate isMuteRole =
- Pattern.compile(Config.getInstance().getMutedRolePattern()).asMatchPredicate();
/**
* Checks whether the given reason is valid. If not, it will handle the situation and respond to
@@ -331,14 +325,27 @@ static boolean handleHasAuthorRole(@NotNull String actionVerb,
.build();
}
+ /**
+ * Gets a predicate that identifies the role used to mute a member in a guild.
+ *
+ * @param config the config used to identify the muted role
+ * @return predicate that matches the name of the muted role
+ */
+ public static Predicate getIsMutedRolePredicate(@NotNull Config config) {
+ return Pattern.compile(config.getMutedRolePattern()).asMatchPredicate();
+ }
+
/**
* Gets the role used to mute a member in a guild.
*
* @param guild the guild to get the muted role from
+ * @param config the config used to identify the muted role
* @return the muted role, if found
*/
- public static @NotNull Optional getMutedRole(@NotNull Guild guild) {
- return guild.getRoles().stream().filter(role -> isMuteRole.test(role.getName())).findAny();
+ public static @NotNull Optional getMutedRole(@NotNull Guild guild,
+ @NotNull Config config) {
+ Predicate isMutedRole = getIsMutedRolePredicate(config);
+ return guild.getRoles().stream().filter(role -> isMutedRole.test(role.getName())).findAny();
}
/**
@@ -362,8 +369,7 @@ static boolean handleHasAuthorRole(@NotNull String actionVerb,
case "minute", "minutes" -> ChronoUnit.MINUTES;
case "hour", "hours" -> ChronoUnit.HOURS;
case "day", "days" -> ChronoUnit.DAYS;
- default -> throw new IllegalArgumentException(
- "Unsupported mute duration: " + durationText);
+ default -> throw new IllegalArgumentException("Unsupported duration: " + durationText);
};
return Optional.of(new TemporaryData(Instant.now().plus(duration, unit), durationText));
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java
index f10606c8a4..31ff2bfb93 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java
@@ -43,13 +43,15 @@ public final class MuteCommand extends SlashCommandAdapter {
"3 hours", "1 day", "3 days", "7 days", ModerationUtils.PERMANENT_DURATION);
private final Predicate hasRequiredRole;
private final ModerationActionsStore actionsStore;
+ private final Config config;
/**
* Constructs an instance.
*
* @param actionsStore used to store actions issued by this command
+ * @param config the config to use for this
*/
- public MuteCommand(@NotNull ModerationActionsStore actionsStore) {
+ public MuteCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) {
super(COMMAND_NAME, "Mutes the given user so that they can not send messages anymore",
SlashCommandVisibility.GUILD);
@@ -61,8 +63,8 @@ public MuteCommand(@NotNull ModerationActionsStore actionsStore) {
.addOptions(durationData)
.addOption(OptionType.STRING, REASON_OPTION, "Why the user should be muted", true);
- hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern())
- .asMatchPredicate();
+ this.config = config;
+ hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate();
this.actionsStore = Objects.requireNonNull(actionsStore);
}
@@ -116,7 +118,8 @@ private AuditableRestAction muteUser(@NotNull Member target, @NotNull Memb
actionsStore.addAction(guild.getIdLong(), author.getIdLong(), target.getIdLong(),
ModerationAction.MUTE, expiresAt, reason);
- return guild.addRoleToMember(target, ModerationUtils.getMutedRole(guild).orElseThrow())
+ return guild
+ .addRoleToMember(target, ModerationUtils.getMutedRole(guild, config).orElseThrow())
.reason(reason);
}
@@ -137,15 +140,15 @@ private boolean handleChecks(@NotNull Member bot, @NotNull Member author,
@Nullable Member target, @NotNull CharSequence reason, @NotNull Guild guild,
@NotNull Interaction event) {
if (!ModerationUtils.handleRoleChangeChecks(
- ModerationUtils.getMutedRole(guild).orElse(null), ACTION_VERB, target, bot, author,
- guild, hasRequiredRole, reason, event)) {
+ ModerationUtils.getMutedRole(guild, config).orElse(null), ACTION_VERB, target, bot,
+ author, guild, hasRequiredRole, reason, event)) {
return false;
}
if (Objects.requireNonNull(target)
.getRoles()
.stream()
.map(Role::getName)
- .anyMatch(ModerationUtils.isMuteRole)) {
+ .anyMatch(ModerationUtils.getIsMutedRolePredicate(config))) {
handleAlreadyMutedTarget(event);
return false;
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/NoteCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/NoteCommand.java
new file mode 100644
index 0000000000..bc027947b8
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/NoteCommand.java
@@ -0,0 +1,109 @@
+package org.togetherjava.tjbot.commands.moderation;
+
+import net.dv8tion.jda.api.entities.*;
+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 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.config.Config;
+
+import java.util.Objects;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+
+/**
+ * This command allows users to write notes about others. Notes are persisted and can be retrieved
+ * using {@link AuditCommand}, like other moderative actions.
+ *
+ * The command fails if the user triggering it is lacking permissions to either write a note about
+ * other users or to write a note about the specific given user (for example a moderator attempting
+ * to write a note about an admin).
+ */
+public final class NoteCommand extends SlashCommandAdapter {
+ private static final Logger logger = LoggerFactory.getLogger(NoteCommand.class);
+ private static final String USER_OPTION = "user";
+ private static final String CONTENT_OPTION = "content";
+ private static final String ACTION_VERB = "write a note about";
+ private final ModerationActionsStore actionsStore;
+ private final Predicate hasRequiredRole;
+
+ /**
+ * Creates a new instance.
+ *
+ * @param actionsStore used to store actions issued by this command
+ * @param config the config to use for this
+ */
+ public NoteCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) {
+ super("note", "Writes a note about the given user", SlashCommandVisibility.GUILD);
+
+ getData()
+ .addOption(OptionType.USER, USER_OPTION, "The user who you want to write a note about",
+ true)
+ .addOption(OptionType.STRING, CONTENT_OPTION,
+ "The content of the note you want to write", true);
+
+ hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate();
+ this.actionsStore = Objects.requireNonNull(actionsStore);
+ }
+
+ @Override
+ public void onSlashCommand(@NotNull SlashCommandEvent event) {
+ OptionMapping targetOption = event.getOption(USER_OPTION);
+ Member author = event.getMember();
+ Guild guild = event.getGuild();
+ String content = event.getOption(CONTENT_OPTION).getAsString();
+
+ if (!handleChecks(guild.getSelfMember(), author, targetOption.getAsMember(), content,
+ event)) {
+ return;
+ }
+
+ sendNote(targetOption.getAsUser(), author, content, guild, event);
+ }
+
+ @SuppressWarnings("BooleanMethodNameMustStartWithQuestion")
+ private boolean handleChecks(@NotNull Member bot, @NotNull Member author,
+ @Nullable Member target, CharSequence content, @NotNull Interaction event) {
+ if (target != null && !ModerationUtils.handleCanInteractWithTarget(ACTION_VERB, bot, author,
+ target, event)) {
+ return false;
+ }
+
+ if (!ModerationUtils.handleHasAuthorRole(ACTION_VERB, hasRequiredRole, author, event)) {
+ return false;
+ }
+
+ return ModerationUtils.handleReason(content, event);
+ }
+
+ private void sendNote(@NotNull User target, @NotNull Member author, @NotNull String content,
+ @NotNull ISnowflake guild, @NotNull Interaction event) {
+ storeNote(target, author, content, guild);
+ sendFeedback(target, author, content, event);
+ }
+
+ private void storeNote(@NotNull User target, @NotNull Member author, @NotNull String content,
+ @NotNull ISnowflake guild) {
+ logger.info("'{}' ({}) wrote a note about the user '{}' ({}) with content '{}'.",
+ author.getUser().getAsTag(), author.getId(), target.getAsTag(), target.getId(),
+ content);
+
+ actionsStore.addAction(guild.getIdLong(), author.getIdLong(), target.getIdLong(),
+ ModerationAction.NOTE, null, content);
+ }
+
+ private static void sendFeedback(@NotNull User target, @NotNull Member author,
+ @NotNull String noteContent, @NotNull Interaction event) {
+ MessageEmbed feedback = ModerationUtils.createActionResponse(author.getUser(),
+ ModerationAction.NOTE, target, null, noteContent);
+
+ event.replyEmbeds(feedback).queue();
+ }
+}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinMuteListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinMuteListener.java
index 3fa6440a92..825c01ffa7 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinMuteListener.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinMuteListener.java
@@ -9,9 +9,9 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.togetherjava.tjbot.commands.EventReceiver;
+import org.togetherjava.tjbot.config.Config;
import java.time.Instant;
-import java.util.Objects;
import java.util.Optional;
/**
@@ -26,23 +26,27 @@ public final class RejoinMuteListener implements EventReceiver {
private static final Logger logger = LoggerFactory.getLogger(RejoinMuteListener.class);
private final ModerationActionsStore actionsStore;
+ private final Config config;
/**
* Constructs an instance.
*
* @param actionsStore used to store actions issued by this command and to retrieve whether a
* user should be muted
+ * @param config the config to use for this
*/
- public RejoinMuteListener(@NotNull ModerationActionsStore actionsStore) {
- this.actionsStore = Objects.requireNonNull(actionsStore);
+ public RejoinMuteListener(@NotNull ModerationActionsStore actionsStore,
+ @NotNull Config config) {
+ this.actionsStore = actionsStore;
+ this.config = config;
}
- private static void muteMember(@NotNull Member member) {
+ private void muteMember(@NotNull Member member) {
Guild guild = member.getGuild();
logger.info("Reapplied existing mute to user '{}' ({}) in guild '{}' after rejoining.",
member.getUser().getAsTag(), member.getId(), guild.getName());
- guild.addRoleToMember(member, ModerationUtils.getMutedRole(guild).orElseThrow())
+ guild.addRoleToMember(member, ModerationUtils.getMutedRole(guild, config).orElseThrow())
.reason("Reapplied existing mute after rejoining the server")
.queue();
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java
index 516ee88183..b7c72b6948 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java
@@ -35,8 +35,9 @@ public final class UnbanCommand extends SlashCommandAdapter {
* Constructs an instance.
*
* @param actionsStore used to store actions issued by this command
+ * @param config the config to use for this
*/
- public UnbanCommand(@NotNull ModerationActionsStore actionsStore) {
+ public UnbanCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) {
super(COMMAND_NAME, "Unbans the given user from the server", SlashCommandVisibility.GUILD);
getData()
@@ -44,8 +45,8 @@ public UnbanCommand(@NotNull ModerationActionsStore actionsStore) {
true)
.addOption(OptionType.STRING, REASON_OPTION, "Why the user should be unbanned", true);
- hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern())
- .asMatchPredicate();
+ hasRequiredRole =
+ Pattern.compile(config.getHeavyModerationRolePattern()).asMatchPredicate();
this.actionsStore = Objects.requireNonNull(actionsStore);
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java
index fcd0f66a31..751b92b213 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java
@@ -35,13 +35,15 @@ public final class UnmuteCommand extends SlashCommandAdapter {
private static final String ACTION_VERB = "unmute";
private final Predicate hasRequiredRole;
private final ModerationActionsStore actionsStore;
+ private final Config config;
/**
* Constructs an instance.
*
* @param actionsStore used to store actions issued by this command
+ * @param config the config to use for this
*/
- public UnmuteCommand(@NotNull ModerationActionsStore actionsStore) {
+ public UnmuteCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) {
super(COMMAND_NAME,
"Unmutes the given already muted user so that they can send messages again",
SlashCommandVisibility.GUILD);
@@ -49,8 +51,8 @@ public UnmuteCommand(@NotNull ModerationActionsStore actionsStore) {
getData().addOption(OptionType.USER, TARGET_OPTION, "The user who you want to unmute", true)
.addOption(OptionType.STRING, REASON_OPTION, "Why the user should be unmuted", true);
- hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern())
- .asMatchPredicate();
+ this.config = config;
+ hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate();
this.actionsStore = Objects.requireNonNull(actionsStore);
}
@@ -91,7 +93,8 @@ private AuditableRestAction unmuteUser(@NotNull Member target, @NotNull Me
actionsStore.addAction(guild.getIdLong(), author.getIdLong(), target.getIdLong(),
ModerationAction.UNMUTE, null, reason);
- return guild.removeRoleFromMember(target, ModerationUtils.getMutedRole(guild).orElseThrow())
+ return guild
+ .removeRoleFromMember(target, ModerationUtils.getMutedRole(guild, config).orElseThrow())
.reason(reason);
}
@@ -110,15 +113,15 @@ private boolean handleChecks(@NotNull Member bot, @NotNull Member author,
@Nullable Member target, @NotNull CharSequence reason, @NotNull Guild guild,
@NotNull Interaction event) {
if (!ModerationUtils.handleRoleChangeChecks(
- ModerationUtils.getMutedRole(guild).orElse(null), ACTION_VERB, target, bot, author,
- guild, hasRequiredRole, reason, event)) {
+ ModerationUtils.getMutedRole(guild, config).orElse(null), ACTION_VERB, target, bot,
+ author, guild, hasRequiredRole, reason, event)) {
return false;
}
if (Objects.requireNonNull(target)
.getRoles()
.stream()
.map(Role::getName)
- .noneMatch(ModerationUtils.isMuteRole)) {
+ .noneMatch(ModerationUtils.getIsMutedRolePredicate(config))) {
handleNotMutedTarget(event);
return false;
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WarnCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WarnCommand.java
index 2b2c8265c2..f82ffb00b9 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WarnCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WarnCommand.java
@@ -39,15 +39,16 @@ public final class WarnCommand extends SlashCommandAdapter {
* Creates a new instance.
*
* @param actionsStore used to store actions issued by this command
+ * @param config the config to use for this
*/
- public WarnCommand(@NotNull ModerationActionsStore actionsStore) {
+ public WarnCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) {
super("warn", "Warns the given user", SlashCommandVisibility.GUILD);
getData().addOption(OptionType.USER, USER_OPTION, "The user who you want to warn", true)
.addOption(OptionType.STRING, REASON_OPTION, "Why you want to warn the user", true);
- hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern())
- .asMatchPredicate();
+ hasRequiredRole =
+ Pattern.compile(config.getHeavyModerationRolePattern()).asMatchPredicate();
this.actionsStore = Objects.requireNonNull(actionsStore);
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java
index 45fa89203a..a193069d1e 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java
@@ -11,6 +11,7 @@
import org.togetherjava.tjbot.commands.moderation.ActionRecord;
import org.togetherjava.tjbot.commands.moderation.ModerationAction;
import org.togetherjava.tjbot.commands.moderation.ModerationActionsStore;
+import org.togetherjava.tjbot.config.Config;
import java.time.Instant;
import java.util.Map;
@@ -41,13 +42,14 @@ public final class TemporaryModerationRoutine implements Routine {
*
* @param jda the JDA instance to use to send messages and retrieve information
* @param actionsStore the store used to retrieve temporary moderation actions
+ * @param config the config to use for this
*/
public TemporaryModerationRoutine(@NotNull JDA jda,
- @NotNull ModerationActionsStore actionsStore) {
+ @NotNull ModerationActionsStore actionsStore, @NotNull Config config) {
this.actionsStore = actionsStore;
this.jda = jda;
- typeToRevocableAction = Stream.of(new TemporaryBanAction(), new TemporaryMuteAction())
+ typeToRevocableAction = Stream.of(new TemporaryBanAction(), new TemporaryMuteAction(config))
.collect(
Collectors.toMap(RevocableModerationAction::getApplyType, Function.identity()));
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryMuteAction.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryMuteAction.java
index 0edd9f3308..f7c0d29a58 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryMuteAction.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryMuteAction.java
@@ -10,6 +10,7 @@
import org.slf4j.LoggerFactory;
import org.togetherjava.tjbot.commands.moderation.ModerationAction;
import org.togetherjava.tjbot.commands.moderation.ModerationUtils;
+import org.togetherjava.tjbot.config.Config;
/**
* Action to revoke temporary mutes, as applied by
@@ -18,6 +19,16 @@
*/
final class TemporaryMuteAction implements RevocableModerationAction {
private static final Logger logger = LoggerFactory.getLogger(TemporaryMuteAction.class);
+ private final Config config;
+
+ /**
+ * Creates a new instance of a temporary mute action.
+ *
+ * @param config the config to use to identify the muted role
+ */
+ TemporaryMuteAction(@NotNull Config config) {
+ this.config = config;
+ }
@Override
public @NotNull ModerationAction getApplyType() {
@@ -34,7 +45,7 @@ final class TemporaryMuteAction implements RevocableModerationAction {
@NotNull String reason) {
return guild
.removeRoleFromMember(target.getIdLong(),
- ModerationUtils.getMutedRole(guild).orElseThrow())
+ ModerationUtils.getMutedRole(guild, config).orElseThrow())
.reason(reason);
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java
new file mode 100644
index 0000000000..72396dfd37
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java
@@ -0,0 +1,152 @@
+package org.togetherjava.tjbot.commands.reminder;
+
+import net.dv8tion.jda.api.entities.ISnowflake;
+import net.dv8tion.jda.api.entities.User;
+import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+import net.dv8tion.jda.api.interactions.Interaction;
+import net.dv8tion.jda.api.interactions.commands.OptionType;
+import net.dv8tion.jda.api.interactions.commands.build.OptionData;
+import org.jetbrains.annotations.NotNull;
+import org.togetherjava.tjbot.commands.SlashCommandAdapter;
+import org.togetherjava.tjbot.commands.SlashCommandVisibility;
+import org.togetherjava.tjbot.db.Database;
+
+import java.time.*;
+import java.time.temporal.TemporalAmount;
+import java.util.List;
+
+import static org.togetherjava.tjbot.db.generated.Tables.PENDING_REMINDERS;
+
+/**
+ * Implements the '/remind' command which can be used to automatically send reminders to oneself at
+ * a future date.
+ *
+ * Example usage:
+ *
+ *
+ * {@code
+ * /remind amount: 5 unit: weeks content: Hello World!
+ * }
+ *
+ *
+ * Pending reminders are processed and send by {@link RemindRoutine}.
+ */
+public final class RemindCommand extends SlashCommandAdapter {
+ private static final String COMMAND_NAME = "remind";
+ private static final String TIME_AMOUNT_OPTION = "time-amount";
+ private static final String TIME_UNIT_OPTION = "time-unit";
+ private static final String CONTENT_OPTION = "content";
+
+ private static final int MIN_TIME_AMOUNT = 1;
+ private static final int MAX_TIME_AMOUNT = 1_000;
+ private static final List TIME_UNITS =
+ List.of("minutes", "hours", "days", "weeks", "months", "years");
+ private static final Period MAX_TIME_PERIOD = Period.ofYears(3);
+ private static final int MAX_PENDING_REMINDERS_PER_USER = 100;
+
+ private final Database database;
+
+ /**
+ * Creates an instance of the command.
+ *
+ * @param database to store and fetch the reminders from
+ */
+ public RemindCommand(@NotNull Database database) {
+ super(COMMAND_NAME, "Reminds you after a given time period has passed (e.g. in 5 weeks)",
+ SlashCommandVisibility.GUILD);
+
+ // TODO As soon as JDA offers date/time selector input, this should also offer
+ // "/remind at" next to "/remind in" and use subcommands then
+ OptionData timeAmount = new OptionData(OptionType.INTEGER, TIME_AMOUNT_OPTION,
+ "period to remind you in, the amount of time (e.g. [5] weeks)", true)
+ .setRequiredRange(MIN_TIME_AMOUNT, MAX_TIME_AMOUNT);
+ OptionData timeUnit = new OptionData(OptionType.STRING, TIME_UNIT_OPTION,
+ "period to remind you in, the unit of time (e.g. 5 [weeks])", true);
+ TIME_UNITS.forEach(unit -> timeUnit.addChoice(unit, unit));
+
+ getData().addOptions(timeUnit, timeAmount)
+ .addOption(OptionType.STRING, CONTENT_OPTION, "what to remind you about", true);
+
+ this.database = database;
+ }
+
+ @Override
+ public void onSlashCommand(@NotNull SlashCommandEvent event) {
+ int timeAmount = Math.toIntExact(event.getOption(TIME_AMOUNT_OPTION).getAsLong());
+ String timeUnit = event.getOption(TIME_UNIT_OPTION).getAsString();
+ String content = event.getOption(CONTENT_OPTION).getAsString();
+
+ Instant remindAt = parseWhen(timeAmount, timeUnit);
+ User author = event.getUser();
+
+ if (!handleIsRemindAtWithinLimits(remindAt, event)) {
+ return;
+ }
+ if (!handleIsUserBelowMaxPendingReminders(author, event)) {
+ return;
+ }
+
+ event.reply("Will remind you about '%s' in %d %s.".formatted(content, timeAmount, timeUnit))
+ .setEphemeral(true)
+ .queue();
+
+ database.write(context -> context.newRecord(PENDING_REMINDERS)
+ .setCreatedAt(Instant.now())
+ .setGuildId(event.getGuild().getIdLong())
+ .setChannelId(event.getChannel().getIdLong())
+ .setAuthorId(author.getIdLong())
+ .setRemindAt(remindAt)
+ .setContent(content)
+ .insert());
+ }
+
+ private static @NotNull Instant parseWhen(int whenAmount, @NotNull String whenUnit) {
+ TemporalAmount period = switch (whenUnit) {
+ case "second", "seconds" -> Duration.ofSeconds(whenAmount);
+ case "minute", "minutes" -> Duration.ofMinutes(whenAmount);
+ case "hour", "hours" -> Duration.ofHours(whenAmount);
+ case "day", "days" -> Period.ofDays(whenAmount);
+ case "week", "weeks" -> Period.ofWeeks(whenAmount);
+ case "month", "months" -> Period.ofMonths(whenAmount);
+ case "year", "years" -> Period.ofYears(whenAmount);
+ default -> throw new IllegalArgumentException("Unsupported unit, was: " + whenUnit);
+ };
+
+ return ZonedDateTime.now(ZoneOffset.UTC).plus(period).toInstant();
+ }
+
+ private static boolean handleIsRemindAtWithinLimits(@NotNull Instant remindAt,
+ @NotNull Interaction event) {
+ ZonedDateTime maxWhen = ZonedDateTime.now(ZoneOffset.UTC).plus(MAX_TIME_PERIOD);
+
+ if (remindAt.atZone(ZoneOffset.UTC).isBefore(maxWhen)) {
+ return true;
+ }
+
+ event
+ .reply("The reminder is set too far in the future. The maximal allowed period is '%s'."
+ .formatted(MAX_TIME_PERIOD))
+ .setEphemeral(true)
+ .queue();
+
+ return false;
+ }
+
+ private boolean handleIsUserBelowMaxPendingReminders(@NotNull ISnowflake author,
+ @NotNull Interaction event) {
+ int pendingReminders = database.read(context -> context.fetchCount(PENDING_REMINDERS,
+ PENDING_REMINDERS.AUTHOR_ID.equal(author.getIdLong())));
+
+ if (pendingReminders < MAX_PENDING_REMINDERS_PER_USER) {
+ return true;
+ }
+
+ event.reply(
+ "You have reached the maximum amount of pending reminders per user (%s). Please wait until some of them have been sent."
+ .formatted(MAX_PENDING_REMINDERS_PER_USER))
+ .setEphemeral(true)
+ .queue();
+
+ return false;
+ }
+}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java
new file mode 100644
index 0000000000..4e0e167b42
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java
@@ -0,0 +1,142 @@
+package org.togetherjava.tjbot.commands.reminder;
+
+import net.dv8tion.jda.api.EmbedBuilder;
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.entities.*;
+import net.dv8tion.jda.api.requests.RestAction;
+import net.dv8tion.jda.api.requests.restaction.MessageAction;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.togetherjava.tjbot.commands.Routine;
+import org.togetherjava.tjbot.db.Database;
+
+import java.awt.*;
+import java.time.Instant;
+import java.time.temporal.TemporalAccessor;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import static org.togetherjava.tjbot.db.generated.Tables.PENDING_REMINDERS;
+
+/**
+ * Routine that processes and sends pending reminders.
+ *
+ * Reminders can be set by using {@link RemindCommand}.
+ */
+public final class RemindRoutine implements Routine {
+ private static final Logger logger = LoggerFactory.getLogger(RemindRoutine.class);
+ private static final Color AMBIENT_COLOR = Color.decode("#F7F492");
+ private static final int SCHEDULE_INTERVAL_SECONDS = 30;
+ private final Database database;
+
+ /**
+ * Creates a new instance.
+ *
+ * @param database the database that contains the pending reminders to send.
+ */
+ public RemindRoutine(@NotNull Database database) {
+ this.database = database;
+ }
+
+ @Override
+ public @NotNull Schedule createSchedule() {
+ return new Schedule(ScheduleMode.FIXED_RATE, 0, SCHEDULE_INTERVAL_SECONDS,
+ TimeUnit.SECONDS);
+ }
+
+ @Override
+ public void runRoutine(@NotNull JDA jda) {
+ Instant now = Instant.now();
+ database.write(context -> context.selectFrom(PENDING_REMINDERS)
+ .where(PENDING_REMINDERS.REMIND_AT.lessOrEqual(now))
+ .stream()
+ .forEach(pendingReminder -> {
+ sendReminder(jda, pendingReminder.getId(), pendingReminder.getChannelId(),
+ pendingReminder.getAuthorId(), pendingReminder.getContent(),
+ pendingReminder.getCreatedAt());
+
+ pendingReminder.delete();
+ }));
+ }
+
+ private static void sendReminder(@NotNull JDA jda, long id, long channelId, long authorId,
+ @NotNull CharSequence content, @NotNull TemporalAccessor createdAt) {
+ RestAction route = computeReminderRoute(jda, channelId, authorId);
+ sendReminderViaRoute(route, id, content, createdAt);
+ }
+
+ private static RestAction computeReminderRoute(@NotNull JDA jda, long channelId,
+ long authorId) {
+ // If guild channel can still be found, send there
+ TextChannel channel = jda.getTextChannelById(channelId);
+ if (channel != null) {
+ return createGuildReminderRoute(jda, authorId, channel);
+ }
+
+ // Otherwise, attempt to DM the user directly
+ return createDmReminderRoute(jda, authorId);
+ }
+
+ private static @NotNull RestAction createGuildReminderRoute(@NotNull JDA jda,
+ long authorId, @NotNull TextChannel channel) {
+ return jda.retrieveUserById(authorId)
+ .onErrorMap(error -> null)
+ .map(author -> ReminderRoute.toPublic(channel, author));
+ }
+
+ private static @NotNull RestAction createDmReminderRoute(@NotNull JDA jda,
+ long authorId) {
+ return jda.openPrivateChannelById(authorId).map(ReminderRoute::toPrivate);
+ }
+
+ private static void sendReminderViaRoute(@NotNull RestAction routeAction,
+ long id, @NotNull CharSequence content, @NotNull TemporalAccessor createdAt) {
+ Function sendMessage = route -> route.channel
+ .sendMessageEmbeds(createReminderEmbed(content, createdAt, route.target()))
+ .content(route.description());
+
+ Consumer logFailure = failure -> logger.warn(
+ """
+ Failed to send a reminder (id '{}'), skipping it. This can be due to a network issue, \
+ but also happen if the bot disconnected from the target guild and the \
+ user has disabled DMs or has been deleted.""",
+ id);
+
+ routeAction.flatMap(sendMessage).queue(doNothing(), logFailure);
+ }
+
+ private static @NotNull MessageEmbed createReminderEmbed(@NotNull CharSequence content,
+ @NotNull TemporalAccessor createdAt, @Nullable User author) {
+ String authorName = author == null ? "Unknown user" : author.getAsTag();
+ String authorIconUrl = author == null ? null : author.getAvatarUrl();
+
+ return new EmbedBuilder().setAuthor(authorName, null, authorIconUrl)
+ .setDescription(content)
+ .setFooter("reminder from")
+ .setTimestamp(createdAt)
+ .setColor(AMBIENT_COLOR)
+ .build();
+ }
+
+ private static @NotNull Consumer doNothing() {
+ return a -> {
+ };
+ }
+
+ private record ReminderRoute(@NotNull MessageChannel channel, @Nullable User target,
+ @Nullable String description) {
+ static ReminderRoute toPublic(@NotNull TextChannel channel, @Nullable User target) {
+ return new ReminderRoute(channel, target,
+ target == null ? null : target.getAsMention());
+ }
+
+ static ReminderRoute toPrivate(@NotNull PrivateChannel channel) {
+ return new ReminderRoute(channel, channel.getUser(),
+ "(Sending your reminder directly, because I was unable to locate"
+ + " the original channel you wanted it to be send to)");
+ }
+ }
+}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/package-info.java b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/package-info.java
new file mode 100644
index 0000000000..a7f65b1c69
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/package-info.java
@@ -0,0 +1,5 @@
+/**
+ * This packages offers all the functionality for the remind-command. The core class is
+ * {@link org.togetherjava.tjbot.commands.reminder.RemindCommand}.
+ */
+package org.togetherjava.tjbot.commands.reminder;
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java b/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java
index ce68ecde5f..57dce916b2 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java
@@ -54,6 +54,7 @@ public final class BotCore extends ListenerAdapter implements SlashCommandProvid
private static final ExecutorService COMMAND_SERVICE = Executors.newCachedThreadPool();
private static final ScheduledExecutorService ROUTINE_SERVICE =
Executors.newScheduledThreadPool(5);
+ private final Config config;
private final Map nameToSlashCommands;
private final ComponentIdParser componentIdParser;
private final ComponentIdStore componentIdStore;
@@ -66,10 +67,12 @@ public final class BotCore extends ListenerAdapter implements SlashCommandProvid
*
* @param jda the JDA instance that this command system will be used with
* @param database the database that commands may use to persist data
+ * @param config the configuration to use for this system
*/
@SuppressWarnings("ThisEscapedInObjectConstruction")
- public BotCore(@NotNull JDA jda, @NotNull Database database) {
- Collection features = Features.createFeatures(jda, database);
+ public BotCore(@NotNull JDA jda, @NotNull Database database, @NotNull Config config) {
+ this.config = config;
+ Collection features = Features.createFeatures(jda, database, config);
// Message receivers
features.stream()
@@ -271,7 +274,7 @@ private void forwardComponentCommand(@NotNull T
return Objects.requireNonNull(nameToSlashCommands.get(name));
}
- private static void handleRegisterErrors(Throwable ex, Guild guild) {
+ private void handleRegisterErrors(Throwable ex, Guild guild) {
new ErrorHandler().handle(ErrorResponse.MISSING_ACCESS, errorResponse -> {
// Find a channel that we have permissions to write to
// NOTE Unfortunately, there is no better accurate way to find a proper channel
@@ -283,7 +286,6 @@ private static void handleRegisterErrors(Throwable ex, Guild guild) {
.findAny();
// Report the problem to the guild
- Config config = Config.getInstance();
channelToReportTo.ifPresent(textChannel -> textChannel
.sendMessage("I need the commands scope, please invite me correctly."
+ " You can join '%s' or visit '%s' for more info, I will leave your guild now."
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java
index ac15f24a51..73308fd317 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java
@@ -22,8 +22,8 @@
public final class TagCommand extends SlashCommandAdapter {
private final TagSystem tagSystem;
- private static final String ID_OPTION = "id";
- private static final String REPLY_TO_USER_OPTION = "reply-to";
+ static final String ID_OPTION = "id";
+ static final String REPLY_TO_USER_OPTION = "reply-to";
/**
* Creates a new instance, using the given tag system as base.
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java
index e257db4c53..16be87f43c 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java
@@ -43,11 +43,11 @@
*/
public final class TagManageCommand extends SlashCommandAdapter {
private static final Logger logger = LoggerFactory.getLogger(TagManageCommand.class);
- private static final String ID_OPTION = "id";
+ static final String ID_OPTION = "id";
private static final String ID_DESCRIPTION = "the id of the tag";
- private static final String CONTENT_OPTION = "content";
+ static final String CONTENT_OPTION = "content";
private static final String CONTENT_DESCRIPTION = "the content of the tag";
- private static final String MESSAGE_ID_OPTION = "message-id";
+ static final String MESSAGE_ID_OPTION = "message-id";
private static final String MESSAGE_ID_DESCRIPTION = "the id of the message to refer to";
private final TagSystem tagSystem;
private final Predicate hasRequiredRole;
@@ -56,13 +56,13 @@ public final class TagManageCommand extends SlashCommandAdapter {
* Creates a new instance, using the given tag system as base.
*
* @param tagSystem the system providing the actual tag data
+ * @param config the config to use for this
*/
- public TagManageCommand(TagSystem tagSystem) {
+ public TagManageCommand(TagSystem tagSystem, @NotNull Config config) {
super("tag-manage", "Provides commands to manage all tags", SlashCommandVisibility.GUILD);
this.tagSystem = tagSystem;
- hasRequiredRole =
- Pattern.compile(Config.getInstance().getTagManageRolePattern()).asMatchPredicate();
+ hasRequiredRole = Pattern.compile(config.getTagManageRolePattern()).asMatchPredicate();
// TODO Think about adding a "Are you sure"-dialog to 'edit', 'edit-with-message' and
// 'delete'
@@ -295,7 +295,7 @@ private enum TagStatus {
}
- private enum Subcommand {
+ enum Subcommand {
RAW("raw"),
CREATE("create"),
CREATE_WITH_MESSAGE("create-with-message"),
@@ -305,11 +305,16 @@ private enum Subcommand {
private final String name;
- Subcommand(String name) {
+ Subcommand(@NotNull String name) {
this.name = name;
}
- static Subcommand fromName(String name) {
+ @NotNull
+ String getName() {
+ return name;
+ }
+
+ static Subcommand fromName(@NotNull String name) {
for (Subcommand subcommand : Subcommand.values()) {
if (subcommand.name.equals(name)) {
return subcommand;
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagsCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagsCommand.java
index 761faaebe1..e17b8c2160 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagsCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagsCommand.java
@@ -5,11 +5,12 @@
import net.dv8tion.jda.api.events.interaction.ButtonClickEvent;
import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.togetherjava.tjbot.commands.SlashCommandAdapter;
import org.togetherjava.tjbot.commands.SlashCommandVisibility;
+
import java.time.Instant;
-import org.slf4j.Logger;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersCommand.java
index 876ce3dcb2..9e46748efd 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersCommand.java
@@ -55,12 +55,12 @@ public final class TopHelpersCommand extends SlashCommandAdapter {
* Creates a new instance.
*
* @param database the database containing the message counts of top helpers
+ * @param config the config to use for this
*/
- public TopHelpersCommand(@NotNull Database database) {
+ public TopHelpersCommand(@NotNull Database database, @NotNull Config config) {
super(COMMAND_NAME, "Lists top helpers for the last month", SlashCommandVisibility.GUILD);
// TODO Add options to optionally pick a time range once JDA/Discord offers a date-picker
- hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern())
- .asMatchPredicate();
+ hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate();
this.database = database;
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java
index fadb5b50ff..26c314e8f0 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java
@@ -21,9 +21,10 @@ public final class TopHelpersMessageListener extends MessageReceiverAdapter {
* Creates a new listener to receive all message sent in help channels.
*
* @param database to store message meta-data in
+ * @param config the config to use for this
*/
- public TopHelpersMessageListener(@NotNull Database database) {
- super(Pattern.compile(Config.getInstance().getHelpChannelPattern()));
+ public TopHelpersMessageListener(@NotNull Database database, @NotNull Config config) {
+ super(Pattern.compile(config.getHelpChannelPattern()));
this.database = database;
}
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 470758b199..0dbd97d39d 100644
--- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java
+++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java
@@ -10,19 +10,11 @@
import java.util.Collection;
import java.util.Collections;
import java.util.List;
-import java.util.Objects;
/**
- * Configuration of the application, as singleton.
- *
- * Create instances using {@link #load(Path)} and then access them with {@link #getInstance()}.
+ * Configuration of the application. Create instances using {@link #load(Path)}.
*/
-@SuppressWarnings({"Singleton", "ClassCanBeRecord"})
public final class Config {
-
- @SuppressWarnings("RedundantFieldInitialization")
- private static Config config = null;
-
private final String token;
private final String databasePath;
private final String projectWebsite;
@@ -34,6 +26,7 @@ public final class Config {
private final String tagManageRolePattern;
private final List freeCommand;
private final String helpChannelPattern;
+ private final SuggestionsConfig suggestions;
@SuppressWarnings("ConstructorWithTooManyParameters")
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
@@ -47,7 +40,8 @@ private Config(@JsonProperty("token") String token,
@JsonProperty("softModerationRolePattern") String softModerationRolePattern,
@JsonProperty("tagManageRolePattern") String tagManageRolePattern,
@JsonProperty("freeCommand") List freeCommand,
- @JsonProperty("helpChannelPattern") String helpChannelPattern) {
+ @JsonProperty("helpChannelPattern") String helpChannelPattern,
+ @JsonProperty("suggestions") SuggestionsConfig suggestions) {
this.token = token;
this.databasePath = databasePath;
this.projectWebsite = projectWebsite;
@@ -59,30 +53,18 @@ private Config(@JsonProperty("token") String token,
this.tagManageRolePattern = tagManageRolePattern;
this.freeCommand = Collections.unmodifiableList(freeCommand);
this.helpChannelPattern = helpChannelPattern;
+ this.suggestions = suggestions;
}
/**
- * Loads the configuration from the given file. Will override any previously loaded data.
- *
- * Access the instance using {@link #getInstance()}.
+ * Loads the configuration from the given file.
*
* @param path the configuration file, as JSON object
+ * @return the loaded configuration
* @throws IOException if the file could not be loaded
*/
- public static void load(Path path) throws IOException {
- config = new ObjectMapper().readValue(path.toFile(), Config.class);
- }
-
- /**
- * Gets the singleton instance of the configuration.
- *
- * Must be loaded beforehand using {@link #load(Path)}.
- *
- * @return the previously loaded configuration
- */
- public static Config getInstance() {
- return Objects.requireNonNull(config,
- "can not get the configuration before it has been loaded");
+ public static Config load(Path path) throws IOException {
+ return new ObjectMapper().readValue(path.toFile(), Config.class);
}
/**
@@ -190,4 +172,13 @@ public String getTagManageRolePattern() {
public String getHelpChannelPattern() {
return helpChannelPattern;
}
+
+ /**
+ * Gets the config for the suggestion system.
+ *
+ * @return the suggestion system config
+ */
+ public SuggestionsConfig getSuggestions() {
+ return suggestions;
+ }
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/config/SuggestionsConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/SuggestionsConfig.java
new file mode 100644
index 0000000000..01f18beb70
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/config/SuggestionsConfig.java
@@ -0,0 +1,53 @@
+package org.togetherjava.tjbot.config;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonRootName;
+
+/**
+ * Configuration for the suggestion system, see
+ * {@link org.togetherjava.tjbot.commands.basic.SuggestionsUpDownVoter}.
+ */
+@SuppressWarnings("ClassCanBeRecord")
+@JsonRootName("suggestions")
+public final class SuggestionsConfig {
+ private final String channelPattern;
+ private final String upVoteEmoteName;
+ private final String downVoteEmoteName;
+
+ @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+ private SuggestionsConfig(@JsonProperty("channelPattern") String channelPattern,
+ @JsonProperty("upVoteEmoteName") String upVoteEmoteName,
+ @JsonProperty("downVoteEmoteName") String downVoteEmoteName) {
+ this.channelPattern = channelPattern;
+ this.upVoteEmoteName = upVoteEmoteName;
+ this.downVoteEmoteName = downVoteEmoteName;
+ }
+
+ /**
+ * Gets the REGEX pattern used to identify channels that are used for sending suggestions.
+ *
+ * @return the channel name pattern
+ */
+ public String getChannelPattern() {
+ return channelPattern;
+ }
+
+ /**
+ * Gets the name of the emote used to up-vote suggestions.
+ *
+ * @return the name of the up-vote emote
+ */
+ public String getUpVoteEmoteName() {
+ return upVoteEmoteName;
+ }
+
+ /**
+ * Gets the name of the emote used to down-vote suggestions.
+ *
+ * @return the name of the down-vote emote
+ */
+ public String getDownVoteEmoteName() {
+ return downVoteEmoteName;
+ }
+}
diff --git a/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java b/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java
index aa498888ed..2e9cb9f95f 100644
--- a/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java
+++ b/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java
@@ -49,20 +49,24 @@ public final class ModAuditLogRoutine implements Routine {
private static final int HOURS_OF_DAY = 24;
private static final Color AMBIENT_COLOR = Color.decode("#4FC3F7");
+ private final String modAuditLogChannelPattern;
private final Predicate isAuditLogChannel;
private final Database database;
+ private final Config config;
/**
* Creates a new instance.
*
* @param database the database for memorizing audit log dates
+ * @param config the config to use for this
*/
- public ModAuditLogRoutine(@NotNull Database database) {
+ public ModAuditLogRoutine(@NotNull Database database, @NotNull Config config) {
+ modAuditLogChannelPattern = config.getModAuditLogChannelPattern();
Predicate isAuditLogChannelName =
- Pattern.compile(Config.getInstance().getModAuditLogChannelPattern())
- .asMatchPredicate();
+ Pattern.compile(modAuditLogChannelPattern).asMatchPredicate();
isAuditLogChannel = channel -> isAuditLogChannelName.test(channel.getName());
+ this.config = config;
this.database = database;
}
@@ -91,6 +95,7 @@ private static RestAction getTargetTagFromEntry(@NotNull AuditLogEntry e
// If the target is null, the user got deleted in the meantime
return entry.getJDA()
.retrieveUserById(entry.getTargetIdLong())
+ .onErrorMap(error -> null)
.map(target -> target == null ? "(user unknown)" : target.getAsTag());
}
@@ -232,7 +237,7 @@ private void checkAuditLogsRoutine(@NotNull JDA jda) {
if (auditLogChannel.isEmpty()) {
logger.warn(
"Unable to log moderation events, did not find a mod audit log channel matching the configured pattern '{}' for guild '{}'",
- Config.getInstance().getModAuditLogChannelPattern(), guild.getName());
+ modAuditLogChannelPattern, guild.getName());
return;
}
@@ -283,8 +288,8 @@ private void handleAuditLogs(@NotNull MessageChannel auditLogChannel,
});
}
- private static Optional> handleAuditLog(
- @NotNull MessageChannel auditLogChannel, @NotNull AuditLogEntry entry) {
+ private Optional> handleAuditLog(@NotNull MessageChannel auditLogChannel,
+ @NotNull AuditLogEntry entry) {
Optional> maybeMessage = switch (entry.getType()) {
case BAN -> handleBanEntry(entry);
case UNBAN -> handleUnbanEntry(entry);
@@ -296,7 +301,7 @@ private static Optional> handleAuditLog(
return maybeMessage.map(message -> message.flatMap(auditLogChannel::sendMessageEmbeds));
}
- private static @NotNull Optional> handleRoleUpdateEntry(
+ private @NotNull Optional> handleRoleUpdateEntry(
@NotNull AuditLogEntry entry) {
if (containsMutedRole(entry, AuditLogKey.MEMBER_ROLES_ADD)) {
return handleMuteEntry(entry);
@@ -307,8 +312,7 @@ private static Optional> handleAuditLog(
return Optional.empty();
}
- private static boolean containsMutedRole(@NotNull AuditLogEntry entry,
- @NotNull AuditLogKey key) {
+ private boolean containsMutedRole(@NotNull AuditLogEntry entry, @NotNull AuditLogKey key) {
List> roleChanges = Optional.ofNullable(entry.getChangeByKey(key))
.>>map(AuditLogChange::getNewValue)
.orElse(List.of());
@@ -317,7 +321,7 @@ private static boolean containsMutedRole(@NotNull AuditLogEntry entry,
.flatMap(Collection::stream)
.filter(changeEntry -> "name".equals(changeEntry.getKey()))
.map(Map.Entry::getValue)
- .anyMatch(ModerationUtils.isMuteRole);
+ .anyMatch(ModerationUtils.getIsMutedRolePredicate(config));
}
private Optional getModAuditLogChannel(@NotNull Guild guild) {
diff --git a/application/src/main/resources/db/V8__Add_Pending_Reminders.sql b/application/src/main/resources/db/V8__Add_Pending_Reminders.sql
new file mode 100644
index 0000000000..af2512ad40
--- /dev/null
+++ b/application/src/main/resources/db/V8__Add_Pending_Reminders.sql
@@ -0,0 +1,10 @@
+CREATE TABLE pending_reminders
+(
+ id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ created_at TIMESTAMP NOT NULL,
+ guild_id BIGINT NOT NULL,
+ channel_id BIGINT NOT NULL,
+ author_id BIGINT NOT NULL,
+ remind_at TIMESTAMP NOT NULL,
+ content TEXT NOT NULL
+)
\ No newline at end of file
diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/basic/PingCommandTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/basic/PingCommandTest.java
index 457b7e3bec..d363ae2dd2 100644
--- a/application/src/test/java/org/togetherjava/tjbot/commands/basic/PingCommandTest.java
+++ b/application/src/test/java/org/togetherjava/tjbot/commands/basic/PingCommandTest.java
@@ -1,22 +1,39 @@
package org.togetherjava.tjbot.commands.basic;
import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.togetherjava.tjbot.commands.SlashCommand;
import org.togetherjava.tjbot.jda.JdaTester;
-import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
final class PingCommandTest {
- @Test
- void pingCommand() {
- SlashCommand command = new PingCommand();
- JdaTester jdaTester = new JdaTester();
+ private JdaTester jdaTester;
+ private SlashCommand command;
+ private @NotNull SlashCommandEvent triggerSlashCommand() {
SlashCommandEvent event = jdaTester.createSlashCommandEvent(command).build();
command.onSlashCommand(event);
+ return event;
+ }
+
+ @BeforeEach
+ void setUp() {
+ jdaTester = new JdaTester();
+ command = jdaTester.spySlashCommand(new PingCommand());
+ }
+
+ @Test
+ @DisplayName("'/ping' responds with pong")
+ void pingRespondsWithPong() {
+ // GIVEN
+ // WHEN using '/ping'
+ SlashCommandEvent event = triggerSlashCommand();
- verify(event, times(1)).reply("Pong!");
+ // THEN the bot replies with pong
+ verify(event).reply("Pong!");
}
}
diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagCommandTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagCommandTest.java
new file mode 100644
index 0000000000..eb4311e2cf
--- /dev/null
+++ b/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagCommandTest.java
@@ -0,0 +1,97 @@
+package org.togetherjava.tjbot.commands.tags;
+
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.MessageEmbed;
+import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.togetherjava.tjbot.commands.SlashCommand;
+import org.togetherjava.tjbot.db.Database;
+import org.togetherjava.tjbot.db.generated.tables.Tags;
+import org.togetherjava.tjbot.jda.JdaTester;
+import org.togetherjava.tjbot.jda.SlashCommandEventBuilder;
+
+import static org.mockito.Mockito.*;
+
+final class TagCommandTest {
+ private TagSystem system;
+ private JdaTester jdaTester;
+ private SlashCommand command;
+
+ @BeforeEach
+ void setUp() {
+ Database database = Database.createMemoryDatabase(Tags.TAGS);
+ system = spy(new TagSystem(database));
+ jdaTester = new JdaTester();
+ command = new TagCommand(system);
+ }
+
+ private @NotNull SlashCommandEvent triggerSlashCommand(@NotNull String id,
+ @Nullable Member userToReplyTo) {
+ SlashCommandEventBuilder builder =
+ jdaTester.createSlashCommandEvent(command).setOption(TagCommand.ID_OPTION, id);
+ if (userToReplyTo != null) {
+ builder.setOption(TagCommand.REPLY_TO_USER_OPTION, userToReplyTo);
+ }
+
+ SlashCommandEvent event = builder.build();
+ command.onSlashCommand(event);
+ return event;
+ }
+
+ @Test
+ @DisplayName("Respond that the tag could not be found if the system has no tags registered yet")
+ void canNotFindTagInEmptySystem() {
+ // GIVEN a system without any tags registered
+ // WHEN triggering the slash command '/tag id:first'
+ SlashCommandEvent event = triggerSlashCommand("first", null);
+
+ // THEN responds that the tag could not be found
+ verify(event).reply("Could not find any tag with id 'first'.");
+ }
+
+ @Test
+ @DisplayName("Respond that the tag could not be found but suggest a different tag instead, if the system has a different tag registered")
+ void canNotFindTagSuggestDifferentTag() {
+ // GIVEN a system with the tag "first" registered
+ system.putTag("first", "foo");
+
+ // WHEN triggering the slash command '/tag id:second'
+ SlashCommandEvent event = triggerSlashCommand("second", null);
+
+ // THEN responds that the tag could not be found and instead suggests using the other tag
+ verify(event)
+ .reply("Could not find any tag with id 'second', did you perhaps mean 'first'?");
+ }
+
+ @Test
+ @DisplayName("Respond with the tags content if the tag could be found")
+ void canFindTheTagAndRespondWithContent() {
+ // GIVEN a system with the tag "first" registered
+ system.putTag("first", "foo");
+
+ // WHEN triggering the slash command '/tag id:first'
+ SlashCommandEvent event = triggerSlashCommand("first", null);
+
+ // THEN finds the tag and responds with its content
+ verify(event).replyEmbeds(any(MessageEmbed.class));
+ }
+
+ @Test
+ @DisplayName("Replies to given users and responds with the tags content if the tag could be found and a user is given")
+ void canFindTagsAndRepliesToUser() {
+ // GIVEN a system with the tag "first" registered and a user to reply to
+ system.putTag("first", "foo");
+ Member userToReplyTo = jdaTester.createMemberSpy(1);
+
+ // WHEN triggering the slash command '/tag id:first reply-to:...' with that user
+ SlashCommandEvent event = triggerSlashCommand("first", userToReplyTo);
+
+ // THEN responds with the tags content and replies to the user
+ verify(event).replyEmbeds(any(MessageEmbed.class));
+ verify(jdaTester.getReplyActionMock()).setContent(userToReplyTo.getAsMention());
+ }
+}
diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagManageCommandTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagManageCommandTest.java
new file mode 100644
index 0000000000..591b85db89
--- /dev/null
+++ b/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagManageCommandTest.java
@@ -0,0 +1,413 @@
+package org.togetherjava.tjbot.commands.tags;
+
+import net.dv8tion.jda.api.MessageBuilder;
+import net.dv8tion.jda.api.Permission;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.Message;
+import net.dv8tion.jda.api.entities.MessageEmbed;
+import net.dv8tion.jda.api.entities.Role;
+import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+import net.dv8tion.jda.api.requests.ErrorResponse;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.togetherjava.tjbot.commands.SlashCommand;
+import org.togetherjava.tjbot.config.Config;
+import org.togetherjava.tjbot.db.Database;
+import org.togetherjava.tjbot.db.generated.tables.Tags;
+import org.togetherjava.tjbot.jda.JdaTester;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.AdditionalMatchers.aryEq;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+final class TagManageCommandTest {
+ private TagSystem system;
+ private JdaTester jdaTester;
+ private SlashCommand command;
+ private Member moderator;
+
+ private static @NotNull MessageEmbed getResponse(@NotNull SlashCommandEvent event) {
+ ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(MessageEmbed.class);
+ verify(event).replyEmbeds(responseCaptor.capture());
+ return responseCaptor.getValue();
+ }
+
+ @BeforeEach
+ void setUp() {
+ Config config = mock(Config.class);
+ String moderatorRoleName = "Moderator";
+ when(config.getTagManageRolePattern()).thenReturn(moderatorRoleName);
+
+ Database database = Database.createMemoryDatabase(Tags.TAGS);
+ system = spy(new TagSystem(database));
+ jdaTester = new JdaTester();
+ command = new TagManageCommand(system, config);
+
+ moderator = jdaTester.createMemberSpy(1);
+ Role moderatorRole = mock(Role.class);
+ doReturn(true).when(moderator).hasPermission(any(Permission.class));
+ doReturn(List.of(moderatorRole)).when(moderator).getRoles();
+ when(moderatorRole.getName()).thenReturn(moderatorRoleName);
+ }
+
+ private @NotNull SlashCommandEvent triggerRawCommand(@NotNull String tagId) {
+ return triggerRawCommandWithUser(tagId, moderator);
+ }
+
+ private @NotNull SlashCommandEvent triggerRawCommandWithUser(@NotNull String tagId,
+ @NotNull Member user) {
+ SlashCommandEvent event = jdaTester.createSlashCommandEvent(command)
+ .setSubcommand(TagManageCommand.Subcommand.RAW.getName())
+ .setOption(TagManageCommand.ID_OPTION, tagId)
+ .setUserWhoTriggered(user)
+ .build();
+
+ command.onSlashCommand(event);
+ return event;
+ }
+
+ private @NotNull SlashCommandEvent triggerCreateCommand(@NotNull String tagId,
+ @NotNull String content) {
+ return triggerTagContentCommand(TagManageCommand.Subcommand.CREATE, tagId, content);
+ }
+
+ private @NotNull SlashCommandEvent triggerEditCommand(@NotNull String tagId,
+ @NotNull String content) {
+ return triggerTagContentCommand(TagManageCommand.Subcommand.EDIT, tagId, content);
+ }
+
+ private @NotNull SlashCommandEvent triggerTagContentCommand(
+ @NotNull TagManageCommand.Subcommand subcommand, @NotNull String tagId,
+ @NotNull String content) {
+ SlashCommandEvent event = jdaTester.createSlashCommandEvent(command)
+ .setSubcommand(subcommand.getName())
+ .setOption(TagManageCommand.ID_OPTION, tagId)
+ .setOption(TagManageCommand.CONTENT_OPTION, content)
+ .setUserWhoTriggered(moderator)
+ .build();
+
+ command.onSlashCommand(event);
+ return event;
+ }
+
+ private @NotNull SlashCommandEvent triggerCreateWithMessageCommand(@NotNull String tagId,
+ @NotNull String messageId) {
+ return triggerTagMessageCommand(TagManageCommand.Subcommand.CREATE_WITH_MESSAGE, tagId,
+ messageId);
+ }
+
+ private @NotNull SlashCommandEvent triggerEditWithMessageCommand(@NotNull String tagId,
+ @NotNull String messageId) {
+ return triggerTagMessageCommand(TagManageCommand.Subcommand.EDIT_WITH_MESSAGE, tagId,
+ messageId);
+ }
+
+ private @NotNull SlashCommandEvent triggerTagMessageCommand(
+ @NotNull TagManageCommand.Subcommand subcommand, @NotNull String tagId,
+ @NotNull String messageId) {
+ SlashCommandEvent event = jdaTester.createSlashCommandEvent(command)
+ .setSubcommand(subcommand.getName())
+ .setOption(TagManageCommand.ID_OPTION, tagId)
+ .setOption(TagManageCommand.MESSAGE_ID_OPTION, messageId)
+ .setUserWhoTriggered(moderator)
+ .build();
+
+ command.onSlashCommand(event);
+ return event;
+ }
+
+ private @NotNull SlashCommandEvent triggerDeleteCommand(@NotNull String tagId) {
+ SlashCommandEvent event = jdaTester.createSlashCommandEvent(command)
+ .setSubcommand(TagManageCommand.Subcommand.DELETE.getName())
+ .setOption(TagManageCommand.ID_OPTION, tagId)
+ .setUserWhoTriggered(moderator)
+ .build();
+
+ command.onSlashCommand(event);
+ return event;
+ }
+
+ private void postMessage(@NotNull String content, @NotNull String id) {
+ Message message = new MessageBuilder(content).build();
+ doReturn(jdaTester.createSucceededActionMock(message)).when(jdaTester.getTextChannelSpy())
+ .retrieveMessageById(id);
+ }
+
+ private void failOnRetrieveMessage(@NotNull String messageId, @NotNull Throwable failure) {
+ doReturn(jdaTester.createFailedActionMock(failure)).when(jdaTester.getTextChannelSpy())
+ .retrieveMessageById(messageId);
+ }
+
+ @Test
+ @DisplayName("Users without the required role can not use '/tag-manage'")
+ void commandCanNotBeUsedWithoutRoles() {
+ // GIVEN a regular user without roles
+ Member regularUser = jdaTester.createMemberSpy(1);
+
+ // WHEN the regular user triggers any '/tag-manage' command
+ SlashCommandEvent event = triggerRawCommandWithUser("foo", regularUser);
+
+ // THEN the command can not be used since the user lacks roles
+ verify(event).reply("Tags can only be managed by users with a corresponding role.");
+ }
+
+ @Test
+ @DisplayName("'/tag-manage raw' can not be used on unknown tags")
+ void rawTagCanNotFindUnknownTag() {
+ // GIVEN a tag system without any tags
+ // WHEN using '/tag-manage raw id:unknown'
+ SlashCommandEvent event = triggerRawCommand("unknown");
+
+ // THEN the command can not find the tag and responds accordingly
+ verify(event).reply(startsWith("Could not find any tag"));
+ }
+
+ @Test
+ @DisplayName("'/tag-manage raw' shows the raw content of a known tag")
+ void rawTagShowsContentIfFound() {
+ // GIVEN a tag system with the "foo" tag
+ system.putTag("foo", "bar");
+
+ // WHEN using '/tag-manage raw id:foo'
+ triggerRawCommand("foo");
+
+ // THEN the command responds with its content as an attachment
+ verify(jdaTester.getReplyActionMock())
+ .addFile(aryEq("bar".getBytes(StandardCharsets.UTF_8)), anyString());
+ }
+
+ @Test
+ @DisplayName("'/tag-manage create' fails if the tag already exists")
+ void createTagThatAlreadyExistsFails() {
+ // GIVEN a tag system with the "foo" tag
+ system.putTag("foo", "old");
+
+ // WHEN using '/tag-manage create id:foo content:new'
+ SlashCommandEvent event = triggerCreateCommand("foo", "new");
+
+ // THEN the command fails and responds accordingly, the tag is still there and unchanged
+ verify(event).reply("The tag with id 'foo' already exists.");
+ assertTrue(system.hasTag("foo"));
+ assertEquals("old", system.getTag("foo").orElseThrow());
+ }
+
+ @Test
+ @DisplayName("'/tag-manage create' works if the tag is new")
+ void createNewTagWorks() {
+ // GIVEN a tag system without any tags
+ // WHEN using '/tag-manage create id:foo content:bar'
+ SlashCommandEvent event = triggerCreateCommand("foo", "bar");
+
+ // THEN the command succeeds and the system contains the tag
+ assertEquals("Success", getResponse(event).getTitle());
+ assertTrue(system.hasTag("foo"));
+ assertEquals("bar", system.getTag("foo").orElseThrow());
+ }
+
+ @Test
+ @DisplayName("'/tag-manage edit' fails if the tag is unknown")
+ void editUnknownTagFails() {
+ // GIVEN a tag system without any tags
+ // WHEN using '/tag-manage edit id:foo content:new'
+ SlashCommandEvent event = triggerEditCommand("foo", "new");
+
+ // THEN the command fails and responds accordingly, the tag was not created
+ verify(event).reply(startsWith("Could not find any tag with id"));
+ assertFalse(system.hasTag("foo"));
+ }
+
+ @Test
+ @DisplayName("'/tag-manage edit' works if the tag is known")
+ void editExistingTagWorks() {
+ // GIVEN a tag system with the "foo" tag
+ system.putTag("foo", "old");
+
+ // WHEN using '/tag-manage edit id:foo content:new'
+ SlashCommandEvent event = triggerEditCommand("foo", "new");
+
+ // THEN the command succeeds and the content of the tag was changed
+ assertEquals("Success", getResponse(event).getTitle());
+ assertEquals("new", system.getTag("foo").orElseThrow());
+ }
+
+ @Test
+ @DisplayName("'/tag-manage delete' fails if the tag is unknown")
+ void deleteUnknownTagFails() {
+ // GIVEN a tag system without any tags
+ // WHEN using '/tag-manage delete id:foo'
+ SlashCommandEvent event = triggerDeleteCommand("foo");
+
+ // THEN the command fails and responds accordingly
+ verify(event).reply(startsWith("Could not find any tag with id"));
+ }
+
+ @Test
+ @DisplayName("'/tag-manage delete' works if the tag is known")
+ void deleteExistingTagWorks() {
+ // GIVEN a tag system with the "foo" tag
+ system.putTag("foo", "bar");
+
+ // WHEN using '/tag-manage delete id:foo'
+ SlashCommandEvent event = triggerDeleteCommand("foo");
+
+ // THEN the command succeeds and the tag was deleted
+ assertEquals("Success", getResponse(event).getTitle());
+ assertFalse(system.hasTag("foo"));
+ }
+
+ @Test
+ @DisplayName("'/tag-manage create-with-message' fails if the given message id is in an invalid format")
+ void createWithMessageFailsForInvalidMessageId() {
+ // GIVEN a tag system without any tags
+ // WHEN using '/tag-manage create-with-message id:foo message-id:bar'
+ SlashCommandEvent event = triggerCreateWithMessageCommand("foo", "bar");
+
+ // THEN the command fails and responds accordingly, the tag was not created
+ verify(event).reply("The given message id 'bar' is invalid, expected a number.");
+ assertFalse(system.hasTag("foo"));
+ }
+
+ @Test
+ @DisplayName("'/tag-manage create-with-message' fails if the tag is known")
+ void createWithMessageTagThatAlreadyExistsFails() {
+ // GIVEN a tag system with the "foo" tag and a message with id and content
+ system.putTag("foo", "old");
+ postMessage("new", "1");
+
+ // WHEN using '/tag-manage create-with-message id:foo message-id:1'
+ SlashCommandEvent event = triggerCreateWithMessageCommand("foo", "1");
+
+ // THEN the command fails and responds accordingly, the tag is still there and unchanged
+ verify(event).reply("The tag with id 'foo' already exists.");
+ assertTrue(system.hasTag("foo"));
+ assertEquals("old", system.getTag("foo").orElseThrow());
+ }
+
+ @Test
+ @DisplayName("'/tag-manage create-with-message' works if the tag is new")
+ void createWithMessageNewTagWorks() {
+ // GIVEN a tag system without any tags and a message with id and content
+ postMessage("bar", "1");
+
+ // WHEN using '/tag-manage create-with-message id:foo message-id:1'
+ SlashCommandEvent event = triggerCreateWithMessageCommand("foo", "1");
+
+ // THEN the command succeeds and the system contains the tag
+ assertEquals("Success", getResponse(event).getTitle());
+ assertTrue(system.hasTag("foo"));
+ assertEquals("bar", system.getTag("foo").orElseThrow());
+ }
+
+ @Test
+ @DisplayName("'/tag-manage create-with-message' fails if the linked message is unknown")
+ void createWithMessageUnknownMessageFails() {
+ // GIVEN a tag system without any tags and an unknown message id
+ failOnRetrieveMessage("1",
+ jdaTester.createErrorResponseException(ErrorResponse.UNKNOWN_MESSAGE));
+
+ // WHEN using '/tag-manage create-with-message id:foo message-id:1'
+ SlashCommandEvent event = triggerCreateWithMessageCommand("foo", "1");
+
+ // THEN the command fails and responds accordingly, the tag was not created
+ verify(event).reply("The message with id '1' does not exist.");
+ assertFalse(system.hasTag("foo"));
+ }
+
+ @Test
+ @DisplayName("'/tag-manage create-with-message' fails if there is a generic error (such as a network failure)")
+ void createWithMessageGenericErrorFails() {
+ // GIVEN a tag system without any tags and a generic network failure
+ failOnRetrieveMessage("1", new IOException("Generic network failure"));
+
+ // WHEN using '/tag-manage create-with-message id:foo message-id:1'
+ SlashCommandEvent event = triggerCreateWithMessageCommand("foo", "1");
+
+ // THEN the command fails and responds accordingly, the tag was not created
+ verify(event).reply(startsWith("Something unexpected went wrong"));
+ assertFalse(system.hasTag("foo"));
+ }
+
+ @Test
+ @DisplayName("'/tag-manage edit-with-message' fails if the given message id is in an invalid format")
+ void editWithMessageFailsForInvalidMessageId() {
+ // GIVEN a tag system with the "foo" tag
+ system.putTag("foo", "old");
+
+ // WHEN using '/tag-manage edit-with-message id:foo message-id:new'
+ SlashCommandEvent event = triggerEditWithMessageCommand("foo", "bar");
+
+ // THEN the command fails and responds accordingly, the tags content was not changed
+ verify(event).reply("The given message id 'bar' is invalid, expected a number.");
+ assertEquals("old", system.getTag("foo").orElseThrow());
+ }
+
+ @Test
+ @DisplayName("'/tag-manage edit-with-message' fails if the tag is unknown")
+ void editWithMessageUnknownTagFails() {
+ // GIVEN a tag system without any tags
+ postMessage("bar", "1");
+
+ // WHEN using '/tag-manage edit-with-message id:foo message-id:new'
+ SlashCommandEvent event = triggerEditWithMessageCommand("foo", "1");
+
+ // THEN the command fails and responds accordingly, the tag was not created
+ verify(event).reply(startsWith("Could not find any tag with id"));
+ assertFalse(system.hasTag("foo"));
+ }
+
+ @Test
+ @DisplayName("'/tag-manage edit-with-message' works if the tag is known")
+ void editWithMessageExistingTagWorks() {
+ // GIVEN a tag system with the "foo" tag
+ system.putTag("foo", "old");
+ postMessage("new", "1");
+
+ // WHEN using '/tag-manage edit-with-message id:foo message-id:1'
+ SlashCommandEvent event = triggerEditWithMessageCommand("foo", "1");
+
+ // THEN the command succeeds and the content of the tag was changed
+ assertEquals("Success", getResponse(event).getTitle());
+ assertEquals("new", system.getTag("foo").orElseThrow());
+ }
+
+ @Test
+ @DisplayName("'/tag-manage edit-with-message' fails if the linked message is unknown")
+ void editWithMessageUnknownMessageFails() {
+ // GIVEN a tag system with the "foo" tag and an unknown message id
+ system.putTag("foo", "old");
+ failOnRetrieveMessage("1",
+ jdaTester.createErrorResponseException(ErrorResponse.UNKNOWN_MESSAGE));
+
+ // WHEN using '/tag-manage edit-with-message id:foo message-id:1'
+ SlashCommandEvent event = triggerEditWithMessageCommand("foo", "1");
+
+ // THEN the command fails and responds accordingly, the tag has not changed
+ verify(event).reply("The message with id '1' does not exist.");
+ assertTrue(system.hasTag("foo"));
+ assertEquals("old", system.getTag("foo").orElseThrow());
+ }
+
+ @Test
+ @DisplayName("'/tag-manage edit-with-message' fails if there is a generic error (such as a network failure)")
+ void editWithMessageGenericErrorFails() {
+ // GIVEN a tag system with the "foo" tag and a generic network failure
+ system.putTag("foo", "old");
+ failOnRetrieveMessage("1", new IOException("Generic network failure"));
+
+ // WHEN using '/tag-manage edit-with-message id:foo message-id:1'
+ SlashCommandEvent event = triggerEditWithMessageCommand("foo", "1");
+
+ // THEN the command fails and responds accordingly, the tag has not changed
+ verify(event).reply(startsWith("Something unexpected went wrong"));
+ assertTrue(system.hasTag("foo"));
+ assertEquals("old", system.getTag("foo").orElseThrow());
+ }
+}
diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagSystemTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagSystemTest.java
new file mode 100644
index 0000000000..440650a31b
--- /dev/null
+++ b/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagSystemTest.java
@@ -0,0 +1,121 @@
+package org.togetherjava.tjbot.commands.tags;
+
+import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.togetherjava.tjbot.db.Database;
+import org.togetherjava.tjbot.db.generated.tables.Tags;
+import org.togetherjava.tjbot.jda.JdaTester;
+
+import java.util.Optional;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+final class TagSystemTest {
+ private TagSystem system;
+ private Database database;
+ private JdaTester jdaTester;
+
+ private void insertTagRaw(String id, String content) {
+ database
+ .write(context -> context.newRecord(Tags.TAGS).setId(id).setContent(content).insert());
+ }
+
+ private Optional readTagRaw(String id) {
+ return database.read(context -> context.selectFrom(Tags.TAGS)
+ .where(Tags.TAGS.ID.eq(id))
+ .fetchOptional(Tags.TAGS.CONTENT));
+ }
+
+ private int getAmountOfRecords() {
+ return database.read(context -> context.fetchCount(Tags.TAGS));
+ }
+
+ @BeforeEach
+ void setUp() {
+ database = Database.createMemoryDatabase(Tags.TAGS);
+ system = spy(new TagSystem(database));
+ jdaTester = new JdaTester();
+ }
+
+ @Test
+ void createDeleteButton() {
+ assertEquals("foo", TagSystem.createDeleteButton("foo").getId());
+ assertEquals("fooBarFooBar", TagSystem.createDeleteButton("fooBarFooBar").getId());
+ }
+
+ @Test
+ void handleIsUnknownTag() {
+ insertTagRaw("known", "foo");
+ SlashCommandEvent event = jdaTester.createSlashCommandEvent(new TagCommand(system)).build();
+
+ assertFalse(system.handleIsUnknownTag("known", event));
+ verify(event, never()).reply(anyString());
+
+ assertTrue(system.handleIsUnknownTag("unknown", event));
+ verify(event).reply(anyString());
+ }
+
+ @Test
+ void hasTag() {
+ insertTagRaw("known", "foo");
+
+ assertTrue(system.hasTag("known"));
+ assertFalse(system.hasTag("unknown"));
+ }
+
+ @Test
+ void deleteTag() {
+ insertTagRaw("known", "foo");
+
+ assertThrowsExactly(IllegalArgumentException.class, () -> system.deleteTag("unknown"));
+ assertEquals(1, getAmountOfRecords());
+
+ system.deleteTag("known");
+ assertEquals(0, getAmountOfRecords());
+ }
+
+ @Test
+ void putTag() {
+ insertTagRaw("before", "foo");
+
+ system.putTag("now", "bar");
+
+ Optional maybeContent = readTagRaw("now");
+ assertTrue(maybeContent.isPresent());
+ assertEquals("bar", maybeContent.orElseThrow());
+
+ // Overwrite existing content
+ system.putTag("before", "baz");
+ maybeContent = readTagRaw("before");
+ assertTrue(maybeContent.isPresent());
+ assertEquals("baz", maybeContent.orElseThrow());
+ }
+
+ @Test
+ void getTag() {
+ insertTagRaw("known", "foo");
+
+ assertTrue(system.getTag("unknown").isEmpty());
+
+ Optional maybeContent = system.getTag("known");
+ assertTrue(maybeContent.isPresent());
+ assertEquals("foo", maybeContent.orElseThrow());
+ }
+
+ @Test
+ void getAllIds() {
+ assertTrue(system.getAllIds().isEmpty());
+
+ insertTagRaw("first", "foo");
+ assertEquals(Set.of("first"), system.getAllIds());
+
+ insertTagRaw("second", "bar");
+ assertEquals(Set.of("first", "second"), system.getAllIds());
+
+ insertTagRaw("third", "baz");
+ assertEquals(Set.of("first", "second", "third"), system.getAllIds());
+ }
+}
diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagsCommandTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagsCommandTest.java
new file mode 100644
index 0000000000..4773035e8b
--- /dev/null
+++ b/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagsCommandTest.java
@@ -0,0 +1,147 @@
+package org.togetherjava.tjbot.commands.tags;
+
+import net.dv8tion.jda.api.Permission;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.MessageEmbed;
+import net.dv8tion.jda.api.events.interaction.ButtonClickEvent;
+import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+import net.dv8tion.jda.api.interactions.components.ActionRow;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.togetherjava.tjbot.commands.SlashCommand;
+import org.togetherjava.tjbot.db.Database;
+import org.togetherjava.tjbot.db.generated.tables.Tags;
+import org.togetherjava.tjbot.jda.JdaTester;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.*;
+
+final class TagsCommandTest {
+ private TagSystem system;
+ private JdaTester jdaTester;
+ private SlashCommand command;
+
+ private static @Nullable String getResponseDescription(@NotNull SlashCommandEvent event) {
+ ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(MessageEmbed.class);
+ verify(event).replyEmbeds(responseCaptor.capture());
+ return responseCaptor.getValue().getDescription();
+ }
+
+ private @NotNull SlashCommandEvent triggerSlashCommand() {
+ SlashCommandEvent event = jdaTester.createSlashCommandEvent(command).build();
+ command.onSlashCommand(event);
+ return event;
+ }
+
+ private @NotNull ButtonClickEvent triggerButtonClick(@NotNull Member userWhoClicked,
+ long idOfAuthor) {
+ ButtonClickEvent event = jdaTester.createButtonClickEvent()
+ .setUserWhoClicked(userWhoClicked)
+ .setActionRows(ActionRow.of(TagSystem.createDeleteButton("foo")))
+ .buildWithSingleButton();
+ command.onButtonClick(event, List.of(Long.toString(idOfAuthor)));
+ return event;
+ }
+
+ @BeforeEach
+ void setUp() {
+ system = spy(new TagSystem(Database.createMemoryDatabase(Tags.TAGS)));
+ jdaTester = new JdaTester();
+ command = jdaTester.spySlashCommand(new TagsCommand(system));
+ }
+
+ @Test
+ @DisplayName("The list of tags is empty if there are no tags registered")
+ void noResponseForEmptySystem() {
+ // GIVEN a tag system without any tags
+ // WHEN using '/tags'
+ SlashCommandEvent event = triggerSlashCommand();
+
+ // THEN the response has no description
+ assertNull(getResponseDescription(event));
+ }
+
+ @Test
+ @DisplayName("The list of tags shows a single element if there is one tag registered")
+ void singleElementListForOneTag() {
+ // GIVEN a tag system with the 'first' tag
+ system.putTag("first", "foo");
+
+ // WHEN using '/tags'
+ SlashCommandEvent event = triggerSlashCommand();
+
+ // THEN the response consists of the single element
+ assertEquals("โข first", getResponseDescription(event));
+ }
+
+ @Test
+ @DisplayName("The list of tags shows multiply elements if there are multiple tags registered")
+ void multipleElementListForMultipleTag() {
+ // GIVEN a tag system with some tags
+ system.putTag("first", "foo");
+ system.putTag("second", "bar");
+ system.putTag("third", "baz");
+
+ // WHEN using '/tags'
+ SlashCommandEvent event = triggerSlashCommand();
+
+ // THEN the response contains all tags
+ String expectedDescription = """
+ โข first
+ โข second
+ โข third""";
+ assertEquals(expectedDescription, getResponseDescription(event));
+ }
+
+ @Test
+ @DisplayName("The list of tags can be deleted by the original author")
+ void authorCanDeleteList() {
+ // GIVEN a '/tags' message send by an author
+ long idOfAuthor = 1;
+ Member messageAuthor = jdaTester.createMemberSpy(idOfAuthor);
+
+ // WHEN the original author clicks the delete button
+ ButtonClickEvent event = triggerButtonClick(messageAuthor, idOfAuthor);
+
+ // THEN the '/tags' message is deleted
+ verify(event.getMessage()).delete();
+ }
+
+ @Test
+ @DisplayName("The list of tags can be deleted by a moderator")
+ void moderatorCanDeleteList() {
+ // GIVEN a '/tags' message send by an author and a moderator
+ long idOfAuthor = 1;
+ Member moderator = jdaTester.createMemberSpy(2);
+ doReturn(true).when(moderator).hasPermission(any(Permission.class));
+
+ // WHEN the moderator clicks the delete button
+ ButtonClickEvent event = triggerButtonClick(moderator, idOfAuthor);
+
+ // THEN the '/tags' message is deleted
+ verify(event.getMessage()).delete();
+ }
+
+ @Test
+ @DisplayName("The list of tags can not deleted by other users")
+ void othersCanNotDeleteList() {
+ // GIVEN a '/tags' message send by an author and another user
+ long idOfAuthor = 1;
+ Member otherUser = jdaTester.createMemberSpy(3);
+ doReturn(false).when(otherUser).hasPermission(any(Permission.class));
+
+ // WHEN the other clicks the delete button
+ ButtonClickEvent event = triggerButtonClick(otherUser, idOfAuthor);
+
+ // THEN the '/tags' message is not deleted
+ verify(event.getMessage(), never()).delete();
+ verify(event).reply(anyString());
+ }
+}
diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/ButtonClickEventBuilder.java b/application/src/test/java/org/togetherjava/tjbot/jda/ButtonClickEventBuilder.java
new file mode 100644
index 0000000000..dbb4178e27
--- /dev/null
+++ b/application/src/test/java/org/togetherjava/tjbot/jda/ButtonClickEventBuilder.java
@@ -0,0 +1,245 @@
+package org.togetherjava.tjbot.jda;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import net.dv8tion.jda.api.MessageBuilder;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.Message;
+import net.dv8tion.jda.api.entities.MessageEmbed;
+import net.dv8tion.jda.api.entities.User;
+import net.dv8tion.jda.api.events.interaction.ButtonClickEvent;
+import net.dv8tion.jda.api.interactions.components.ActionRow;
+import net.dv8tion.jda.api.interactions.components.Button;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.togetherjava.tjbot.commands.SlashCommand;
+
+import java.util.List;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.function.UnaryOperator;
+import java.util.stream.Stream;
+
+import static org.mockito.Mockito.when;
+
+/**
+ * Builder to create button click events that can be used for example with
+ * {@link SlashCommand#onButtonClick(ButtonClickEvent, List)}.
+ *
+ * Create instances of this class by using {@link JdaTester#createButtonClickEvent()}.
+ *
+ * Among other Discord related things, the builder optionally accepts a message
+ * ({@link #setMessage(Message)}) and the user who clicked on the button
+ * ({@link #setUserWhoClicked(Member)} ). As well as several ways to modify the message directly for
+ * convenience, such as {@link #setContent(String)} or {@link #setActionRows(ActionRow...)}. The
+ * builder is by default already setup with a valid dummy message and the user who clicked the
+ * button is set to the author of the message.
+ *
+ * In order to build the event, at least one button has to be added to the message and marked as
+ * clicked . Therefore, use {@link #setActionRows(ActionRow...)} or modify the message
+ * manually using {@link #setMessage(Message)}. Then mark the desired button as clicked using
+ * {@link #build(Button)} or, if the message only contains a single button,
+ * {@link #buildWithSingleButton()} will automatically select the button.
+ *
+ * Refer to the following examples:
+ *
+ *
+ * {@code
+ * // Default message with a delete button
+ * jdaTester.createButtonClickEvent()
+ * .setActionRows(ActionRow.of(Button.of(ButtonStyle.DANGER, "1", "Delete"))
+ * .buildWithSingleButton();
+ *
+ * // More complex message with a user who clicked the button that is not the message author and multiple buttons
+ * Button clickedButton = Button.of(ButtonStyle.PRIMARY, "1", "Next");
+ * jdaTester.createButtonClickEvent()
+ * .setMessage(new MessageBuilder()
+ * .setContent("See the following entry")
+ * .setEmbeds(
+ * new EmbedBuilder()
+ * .setDescription("John")
+ * .build())
+ * .build())
+ * .setUserWhoClicked(jdaTester.createMemberSpy(5))
+ * .setActionRows(
+ * ActionRow.of(Button.of(ButtonStyle.PRIMARY, "1", "Previous"),
+ * clickedButton)
+ * .build(clickedButton);
+ * }
+ *
+ */
+public final class ButtonClickEventBuilder {
+ private static final ObjectMapper JSON = new ObjectMapper();
+ private final @NotNull Supplier extends ButtonClickEvent> mockEventSupplier;
+ private final UnaryOperator mockMessageOperator;
+ private MessageBuilder messageBuilder;
+ private Member userWhoClicked;
+
+ ButtonClickEventBuilder(@NotNull Supplier extends ButtonClickEvent> mockEventSupplier,
+ @NotNull UnaryOperator mockMessageOperator) {
+ this.mockEventSupplier = mockEventSupplier;
+ this.mockMessageOperator = mockMessageOperator;
+
+ messageBuilder = new MessageBuilder();
+ messageBuilder.setContent("test message");
+ }
+
+ /**
+ * Sets the given message that this event is associated to. Will override any data previously
+ * set with the more direct methods such as {@link #setContent(String)} or
+ * {@link #setActionRows(ActionRow...)}.
+ *
+ * The message must contain at least one button, or the button has to be added later with
+ * {@link #setActionRows(ActionRow...)}.
+ *
+ * @param message the message to set
+ * @return this builder instance for chaining
+ */
+ public @NotNull ButtonClickEventBuilder setMessage(@NotNull Message message) {
+ messageBuilder = new MessageBuilder(message);
+ return this;
+ }
+
+ /**
+ * Sets the content of the message that this event is associated to. Usage of
+ * {@link #setMessage(Message)} will overwrite any content set by this.
+ *
+ * @param content the content of the message
+ * @return this builder instance for chaining
+ */
+ public @NotNull ButtonClickEventBuilder setContent(@NotNull String content) {
+ messageBuilder.setContent(content);
+ return this;
+ }
+
+ /**
+ * Sets the embeds of the message that this event is associated to. Usage of
+ * {@link #setMessage(Message)} will overwrite any content set by this.
+ *
+ * @param embeds the embeds of the message
+ * @return this builder instance for chaining
+ */
+ public @NotNull ButtonClickEventBuilder setEmbeds(@NotNull MessageEmbed... embeds) {
+ messageBuilder.setEmbeds(embeds);
+ return this;
+ }
+
+ /**
+ * Sets the action rows of the message that this event is associated to. Usage of
+ * {@link #setMessage(Message)} will overwrite any content set by this.
+ *
+ * At least one of the rows must contain a button before {@link #build(Button)} is called.
+ *
+ * @param rows the action rows of the message
+ * @return this builder instance for chaining
+ */
+ public @NotNull ButtonClickEventBuilder setActionRows(@NotNull ActionRow... rows) {
+ messageBuilder.setActionRows(rows);
+ return this;
+ }
+
+ /**
+ * Sets the user who clicked the button, i.e. who triggered the event.
+ *
+ * @param userWhoClicked the user who clicked the button
+ * @return this builder instance for chaining
+ */
+ @NotNull
+ public ButtonClickEventBuilder setUserWhoClicked(@NotNull Member userWhoClicked) {
+ this.userWhoClicked = userWhoClicked;
+ return this;
+ }
+
+ /**
+ * Builds an instance of a button click event, corresponding to the current configuration of the
+ * builder.
+ *
+ * The message must contain exactly one button, which is automatically assumed to be the button
+ * that has been clicked. Use {@link #build(Button)} for messages with multiple buttons instead.
+ *
+ * @return the created slash command instance
+ */
+ public @NotNull ButtonClickEvent buildWithSingleButton() {
+ return createEvent(null);
+ }
+
+ /**
+ * Builds an instance of a button click event, corresponding to the current configuration of the
+ * builder.
+ *
+ * The message must the given button. {@link #buildWithSingleButton()} can be used for
+ * convenience for messages that only have a single button.
+ *
+ * @param clickedButton the button that was clicked, i.e. that triggered the event. Must be
+ * contained in the message.
+ * @return the created slash command instance
+ */
+ public @NotNull ButtonClickEvent build(@NotNull Button clickedButton) {
+ return createEvent(clickedButton);
+ }
+
+ private @NotNull ButtonClickEvent createEvent(@Nullable Button maybeClickedButton) {
+ Message message = mockMessageOperator.apply(messageBuilder.build());
+ Button clickedButton = determineClickedButton(maybeClickedButton, message);
+
+ return mockButtonClickEvent(message, clickedButton);
+ }
+
+ private static @NotNull Button determineClickedButton(@Nullable Button maybeClickedButton,
+ @NotNull Message message) {
+ if (maybeClickedButton != null) {
+ return requireButtonInMessage(maybeClickedButton, message);
+ }
+
+ // Otherwise, attempt to extract the button from the message. Only allow a single button in
+ // this case to prevent ambiguity.
+ return requireSingleButton(getMessageButtons(message));
+ }
+
+ private static @NotNull Button requireButtonInMessage(@NotNull Button clickedButton,
+ @NotNull Message message) {
+ boolean isClickedButtonUnknown =
+ getMessageButtons(message).noneMatch(clickedButton::equals);
+
+ if (isClickedButtonUnknown) {
+ throw new IllegalArgumentException(
+ "The given clicked button is not part of the messages components,"
+ + " make sure to add the button to one of the messages action rows first.");
+ }
+ return clickedButton;
+ }
+
+ private static @NotNull Button requireSingleButton(@NotNull Stream extends Button> stream) {
+ Function descriptionToException =
+ IllegalArgumentException::new;
+
+ return stream.reduce((x, y) -> {
+ throw descriptionToException
+ .apply("The message contains more than a single button, unable to automatically determine the clicked button."
+ + " Either only use a single button or explicitly state the clicked button");
+ })
+ .orElseThrow(() -> descriptionToException.apply(
+ "The message contains no buttons, unable to automatically determine the clicked button."
+ + " Add the button to the message first."));
+ }
+
+ private static @NotNull Stream getMessageButtons(@NotNull Message message) {
+ return message.getActionRows().stream().map(ActionRow::getButtons).flatMap(List::stream);
+ }
+
+ private @NotNull ButtonClickEvent mockButtonClickEvent(@NotNull Message message,
+ @NotNull Button clickedButton) {
+ ButtonClickEvent event = mockEventSupplier.get();
+
+ when(event.getMessage()).thenReturn(message);
+ when(event.getButton()).thenReturn(clickedButton);
+ when(event.getComponent()).thenReturn(clickedButton);
+ when(event.getComponentId()).thenReturn(clickedButton.getId());
+ when(event.getComponentType()).thenReturn(clickedButton.getType());
+
+ when(event.getMember()).thenReturn(userWhoClicked);
+ User asUser = userWhoClicked.getUser();
+ when(event.getUser()).thenReturn(asUser);
+
+ return event;
+ }
+}
diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java
index 5c6ed68067..a9d9500e73 100644
--- a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java
+++ b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java
@@ -1,26 +1,45 @@
package org.togetherjava.tjbot.jda;
+import net.dv8tion.jda.api.AccountType;
import net.dv8tion.jda.api.Permission;
-import net.dv8tion.jda.api.entities.SelfUser;
+import net.dv8tion.jda.api.entities.*;
+import net.dv8tion.jda.api.events.interaction.ButtonClickEvent;
import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
-import net.dv8tion.jda.api.requests.restaction.MessageAction;
+import net.dv8tion.jda.api.exceptions.ErrorResponseException;
+import net.dv8tion.jda.api.interactions.Interaction;
+import net.dv8tion.jda.api.interactions.components.Component;
+import net.dv8tion.jda.api.requests.ErrorResponse;
+import net.dv8tion.jda.api.requests.Response;
+import net.dv8tion.jda.api.requests.RestAction;
+import net.dv8tion.jda.api.requests.restaction.interactions.ReplyAction;
+import net.dv8tion.jda.api.utils.AttachmentOption;
import net.dv8tion.jda.api.utils.ConcurrentSessionController;
import net.dv8tion.jda.api.utils.cache.CacheFlag;
import net.dv8tion.jda.internal.JDAImpl;
import net.dv8tion.jda.internal.entities.*;
import net.dv8tion.jda.internal.requests.Requester;
+import net.dv8tion.jda.internal.requests.restaction.AuditableRestActionImpl;
import net.dv8tion.jda.internal.requests.restaction.MessageActionImpl;
import net.dv8tion.jda.internal.requests.restaction.interactions.ReplyActionImpl;
import net.dv8tion.jda.internal.utils.config.AuthorizationConfig;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import org.mockito.ArgumentMatchers;
+import org.mockito.stubbing.Answer;
import org.togetherjava.tjbot.commands.SlashCommand;
+import org.togetherjava.tjbot.commands.componentids.ComponentIdGenerator;
import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
import java.util.function.UnaryOperator;
+import static org.mockito.AdditionalMatchers.not;
import static org.mockito.Mockito.*;
/**
@@ -31,7 +50,7 @@
* be exploited for testing.
*
* An example test using this class might look like:
- *
+ *
*
* {
* @code
@@ -41,7 +60,7 @@
* SlashCommandEvent event = jdaTester.createSlashCommandEvent(command).build();
* command.onSlashCommand(event);
*
- * verify(event, times(1)).reply("Pong!");
+ * verify(event).reply("Pong!");
* }
*
*/
@@ -51,6 +70,7 @@ public final class JdaTester {
new ScheduledThreadPoolExecutor(4);
private static final String TEST_TOKEN = "TEST_TOKEN";
private static final long USER_ID = 1;
+ private static final long SELF_USER_ID = 2;
private static final long APPLICATION_ID = 1;
private static final long PRIVATE_CHANNEL_ID = 1;
private static final long GUILD_ID = 1;
@@ -58,6 +78,12 @@ public final class JdaTester {
private final JDAImpl jda;
private final MemberImpl member;
+ private final GuildImpl guild;
+ private final ReplyActionImpl replyAction;
+ private final AuditableRestActionImpl auditableRestAction;
+ private final MessageActionImpl messageAction;
+ private final TextChannelImpl textChannel;
+ private final PrivateChannelImpl privateChannel;
/**
* Creates a new instance. The instance uses a fresh and isolated mocked JDA setup.
@@ -66,29 +92,34 @@ public final class JdaTester {
* which can have an impact on tests. For example a previous text that already send messages to
* a channel, the messages will then still be present in the instance.
*/
+ @SuppressWarnings("unchecked")
public JdaTester() {
// TODO Extend this functionality, make it nicer.
// Maybe offer a builder for multiple users and channels and what not
jda = mock(JDAImpl.class);
when(jda.getCacheFlags()).thenReturn(EnumSet.noneOf(CacheFlag.class));
- SelfUser selfUser = mock(SelfUserImpl.class);
+ SelfUserImpl selfUser = spy(new SelfUserImpl(SELF_USER_ID, jda));
UserImpl user = spy(new UserImpl(USER_ID, jda));
- GuildImpl guild = spy(new GuildImpl(jda, GUILD_ID));
+ guild = spy(new GuildImpl(jda, GUILD_ID));
+ Member selfMember = spy(new MemberImpl(guild, selfUser));
member = spy(new MemberImpl(guild, user));
- TextChannelImpl textChannel = spy(new TextChannelImpl(TEXT_CHANNEL_ID, guild));
- PrivateChannelImpl privateChannel = spy(new PrivateChannelImpl(PRIVATE_CHANNEL_ID, user));
- MessageAction messageAction = mock(MessageActionImpl.class);
+ textChannel = spy(new TextChannelImpl(TEXT_CHANNEL_ID, guild));
+ privateChannel = spy(new PrivateChannelImpl(PRIVATE_CHANNEL_ID, user));
+ messageAction = mock(MessageActionImpl.class);
EntityBuilder entityBuilder = mock(EntityBuilder.class);
+ Role everyoneRole = new RoleImpl(GUILD_ID, guild);
- // TODO Depending on the commands we might need a lot more mocking here
when(entityBuilder.createUser(any())).thenReturn(user);
when(entityBuilder.createMember(any(), any())).thenReturn(member);
- // TODO Giving out all permissions makes it impossible to test permission requirements on
- // commands
- doReturn(true).when(member).hasPermission(ArgumentMatchers.any());
- when(selfUser.getApplicationId()).thenReturn(String.valueOf(APPLICATION_ID));
- when(selfUser.getApplicationIdLong()).thenReturn(APPLICATION_ID);
+ doReturn(true).when(member).hasPermission(any(Permission.class));
+ doReturn(true).when(member).hasPermission(any(GuildChannel.class), any(Permission.class));
+ doReturn(true).when(selfMember).hasPermission(any(Permission.class));
+ doReturn(true).when(selfMember)
+ .hasPermission(any(GuildChannel.class), any(Permission.class));
+
+ doReturn(String.valueOf(APPLICATION_ID)).when(selfUser).getApplicationId();
+ doReturn(APPLICATION_ID).when(selfUser).getApplicationIdLong();
doReturn(selfUser).when(jda).getSelfUser();
when(jda.getGuildChannelById(anyLong())).thenReturn(textChannel);
when(jda.getPrivateChannelById(anyLong())).thenReturn(privateChannel);
@@ -99,8 +130,29 @@ public JdaTester() {
when(jda.getRateLimitPool()).thenReturn(RATE_LIMIT_POOL);
when(jda.getSessionController()).thenReturn(new ConcurrentSessionController());
doReturn(new Requester(jda, new AuthorizationConfig(TEST_TOKEN))).when(jda).getRequester();
+ when(jda.getAccountType()).thenReturn(AccountType.BOT);
doReturn(messageAction).when(privateChannel).sendMessage(anyString());
+
+ replyAction = mock(ReplyActionImpl.class);
+ when(replyAction.setEphemeral(anyBoolean())).thenReturn(replyAction);
+ when(replyAction.addActionRow(anyCollection())).thenReturn(replyAction);
+ when(replyAction.addActionRow(ArgumentMatchers.any())).thenReturn(replyAction);
+ when(replyAction.setContent(anyString())).thenReturn(replyAction);
+ when(replyAction.addFile(any(byte[].class), any(String.class), any(AttachmentOption.class)))
+ .thenReturn(replyAction);
+ doNothing().when(replyAction).queue();
+
+ auditableRestAction = (AuditableRestActionImpl) mock(AuditableRestActionImpl.class);
+ doNothing().when(auditableRestAction).queue();
+
+ doNothing().when(messageAction).queue();
+
+ doReturn(everyoneRole).when(guild).getPublicRole();
+ doReturn(selfMember).when(guild).getMember(selfUser);
+ doReturn(member).when(guild).getMember(not(eq(selfUser)));
+
+ doReturn(null).when(textChannel).retrieveMessageById(any());
}
/**
@@ -117,22 +169,225 @@ public JdaTester() {
@NotNull SlashCommand command) {
UnaryOperator mockOperator = event -> {
SlashCommandEvent slashCommandEvent = spy(event);
- ReplyActionImpl replyAction = mock(ReplyActionImpl.class);
+ mockInteraction(slashCommandEvent);
+ return slashCommandEvent;
+ };
- doReturn(replyAction).when(slashCommandEvent).reply(anyString());
- when(replyAction.setEphemeral(anyBoolean())).thenReturn(replyAction);
- doReturn(member).when(slashCommandEvent).getMember();
+ return new SlashCommandEventBuilder(jda, mockOperator).setCommand(command)
+ .setToken(TEST_TOKEN)
+ .setChannelId(String.valueOf(TEXT_CHANNEL_ID))
+ .setApplicationId(String.valueOf(APPLICATION_ID))
+ .setGuildId(String.valueOf(GUILD_ID))
+ .setUserId(String.valueOf(USER_ID))
+ .setUserWhoTriggered(member);
+ }
- return slashCommandEvent;
+ /**
+ * Creates a Mockito mocked button click event, which can be used for
+ * {@link SlashCommand#onButtonClick(ButtonClickEvent, List)}.
+ *
+ * The method creates a builder that can be used to further adjust the event before creation,
+ * e.g. provide options.
+ *
+ * @return a builder used to create a Mockito mocked slash command event
+ */
+ public @NotNull ButtonClickEventBuilder createButtonClickEvent() {
+ Supplier mockEventSupplier = () -> {
+ ButtonClickEvent event = mock(ButtonClickEvent.class);
+ mockButtonClickEvent(event);
+ return event;
};
- return new SlashCommandEventBuilder(jda, mockOperator).command(command)
- .token(TEST_TOKEN)
- .channelId(String.valueOf(TEXT_CHANNEL_ID))
- .applicationId(String.valueOf(APPLICATION_ID))
- .guildId(String.valueOf(GUILD_ID))
- .userId(String.valueOf(USER_ID));
+ UnaryOperator mockMessageOperator = event -> {
+ Message message = spy(event);
+ mockMessage(message);
+ return message;
+ };
+
+ return new ButtonClickEventBuilder(mockEventSupplier, mockMessageOperator)
+ .setUserWhoClicked(member);
+ }
+
+ /**
+ * Creates a Mockito spy on the given slash command.
+ *
+ * The spy is also prepared for mocked execution, e.g. attributes such as
+ * {@link SlashCommand#acceptComponentIdGenerator(ComponentIdGenerator)} are filled with mocks.
+ *
+ * @param command the command to spy on
+ * @param the type of the command to spy on
+ * @return the created spy
+ */
+ public @NotNull T spySlashCommand(@NotNull T command) {
+ T spiedCommand = spy(command);
+ spiedCommand
+ .acceptComponentIdGenerator((componentId, lifespan) -> UUID.randomUUID().toString());
+ return spiedCommand;
}
- // TODO Add methods to create button and menu events as well
+ /**
+ * Creates a Mockito spy for a member with the given user id.
+ *
+ * @param userId the id of the member to create
+ * @return the created spy
+ */
+ public @NotNull Member createMemberSpy(long userId) {
+ UserImpl user = spy(new UserImpl(userId, jda));
+ return spy(new MemberImpl(guild, user));
+ }
+
+ /**
+ * Gets the Mockito mock used as universal reply action by all mocks created by this tester
+ * instance.
+ *
+ * For example the events created by {@link #createSlashCommandEvent(SlashCommand)} will return
+ * this mock on several of their methods.
+ *
+ * @return the reply action mock used by this tester
+ */
+ public @NotNull ReplyAction getReplyActionMock() {
+ return replyAction;
+ }
+
+ /**
+ * Gets the text channel spy used as universal text channel by all mocks created by this tester
+ * instance.
+ *
+ * For example the events created by {@link #createSlashCommandEvent(SlashCommand)} will return
+ * this spy on several of their methods.
+ *
+ * @return the text channel spy used by this tester
+ */
+ public @NotNull TextChannel getTextChannelSpy() {
+ return textChannel;
+ }
+
+ /**
+ * Creates a mocked action that always succeeds and consumes the given object.
+ *
+ * Such an action is useful for testing things involving calls like
+ * {@link TextChannel#retrieveMessageById(long)} or similar, example:
+ *
+ *
+ * {
+ * @code
+ * var jdaTester = new JdaTester();
+ *
+ * var message = new MessageBuilder("Hello World!").build();
+ * var action = jdaTester.createSucceededActionMock(message);
+ *
+ * doReturn(action).when(jdaTester.getTextChannelSpy()).retrieveMessageById("1");
+ * }
+ *
+ *
+ * @param t the object to consume on success
+ * @param the type of the object to consume
+ * @return the mocked action
+ */
+ @SuppressWarnings("unchecked")
+ public @NotNull RestAction createSucceededActionMock(@Nullable T t) {
+ RestAction action = (RestAction) mock(RestAction.class);
+
+ Answer successExecution = invocation -> {
+ Consumer super T> successConsumer = invocation.getArgument(0);
+ successConsumer.accept(t);
+ return null;
+ };
+
+ doNothing().when(action).queue();
+
+ doAnswer(successExecution).when(action).queue(any());
+ doAnswer(successExecution).when(action).queue(any(), any());
+
+ return action;
+ }
+
+ /**
+ * Creates a mocked action that always fails and consumes the given failure reason.
+ *
+ * Such an action is useful for testing things involving calls like
+ * {@link TextChannel#retrieveMessageById(long)} or similar, example:
+ *
+ *
+ * {
+ * @code
+ * var jdaTester = new JdaTester();
+ *
+ * var reason = new FooException();
+ * var action = jdaTester.createFailedActionMock(reason);
+ *
+ * doReturn(action).when(jdaTester.getTextChannelSpy()).retrieveMessageById("1");
+ * }
+ *
+ *
+ * @param failureReason the reason to consume on failure
+ * @param the type of the object the action would contain if it would succeed
+ * @return the mocked action
+ */
+ @SuppressWarnings("unchecked")
+ public @NotNull RestAction createFailedActionMock(@NotNull Throwable failureReason) {
+ RestAction action = (RestAction) mock(RestAction.class);
+
+ Answer failureExecution = invocation -> {
+ Consumer super Throwable> failureConsumer = invocation.getArgument(1);
+ failureConsumer.accept(failureReason);
+ return null;
+ };
+
+ doNothing().when(action).queue();
+ doNothing().when(action).queue(any());
+
+ doAnswer(failureExecution).when(action).queue(any(), any());
+
+ return action;
+ }
+
+ /**
+ * Creates an exception used by JDA on failure in most calls to the Discord API.
+ *
+ * The exception merely wraps around the given reason and has no valid error code or message
+ * set.
+ *
+ * @param reason the reason of the error
+ * @return the created exception
+ */
+ public @NotNull ErrorResponseException createErrorResponseException(
+ @NotNull ErrorResponse reason) {
+ return ErrorResponseException.create(reason, new Response(null, -1, "", -1, Set.of()));
+ }
+
+ private void mockInteraction(@NotNull Interaction interaction) {
+ doReturn(replyAction).when(interaction).reply(anyString());
+ doReturn(replyAction).when(interaction).replyEmbeds(ArgumentMatchers.any());
+ doReturn(replyAction).when(interaction).replyEmbeds(anyCollection());
+
+ doReturn(member).when(interaction).getMember();
+ doReturn(member.getUser()).when(interaction).getUser();
+
+ doReturn(textChannel).when(interaction).getChannel();
+ doReturn(textChannel).when(interaction).getMessageChannel();
+ doReturn(textChannel).when(interaction).getTextChannel();
+ doReturn(textChannel).when(interaction).getGuildChannel();
+ doReturn(privateChannel).when(interaction).getPrivateChannel();
+ }
+
+ private void mockButtonClickEvent(@NotNull ButtonClickEvent event) {
+ mockInteraction(event);
+
+ doReturn(replyAction).when(event).editButton(any());
+ }
+
+ private void mockMessage(@NotNull Message message) {
+ doReturn(messageAction).when(message).reply(anyString());
+ doReturn(messageAction).when(message).replyEmbeds(ArgumentMatchers.any());
+ doReturn(messageAction).when(message).replyEmbeds(anyCollection());
+
+ doReturn(auditableRestAction).when(message).delete();
+
+ doReturn(auditableRestAction).when(message).addReaction(any(Emote.class));
+ doReturn(auditableRestAction).when(message).addReaction(any(String.class));
+
+ doReturn(member).when(message).getMember();
+ doReturn(member.getUser()).when(message).getAuthor();
+ }
}
diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandEventBuilder.java b/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandEventBuilder.java
index 6cca185720..af1f4a34a1 100644
--- a/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandEventBuilder.java
+++ b/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandEventBuilder.java
@@ -2,6 +2,8 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
@@ -12,12 +14,19 @@
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.togetherjava.tjbot.commands.SlashCommand;
+import org.togetherjava.tjbot.jda.payloads.PayloadMember;
+import org.togetherjava.tjbot.jda.payloads.PayloadUser;
+import org.togetherjava.tjbot.jda.payloads.slashcommand.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;
+import java.util.stream.Collectors;
+
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
/**
* Builder to create slash command events that can be used for example with
@@ -26,12 +35,12 @@
* Create instances of this class by using {@link JdaTester#createSlashCommandEvent(SlashCommand)}.
*
* Among other Discord related things, the builder optionally accepts a subcommand
- * ({@link #subcommand(String)}) and options ({@link #option(String, String)}). An already set
- * subcommand can be cleared by using {@link #subcommand(String)} with {@code null}, options are
+ * ({@link #setSubcommand(String)}) and options ({@link #setOption(String, String)}). An already set
+ * subcommand can be cleared by using {@link #setSubcommand(String)} with {@code null}, options are
* cleared using {@link #clearOptions()}.
*
* Refer to the following examples: the command {@code ping} is build using
- *
+ *
*
* {@code
* // /ping
@@ -39,15 +48,15 @@
*
* // /days start:10.01.2021 end:13.01.2021
* jdaTester.createSlashCommandEvent(command)
- * .option("start", "10.01.2021")
- * .option("end", "13.01.2021")
+ * .setOption("start", "10.01.2021")
+ * .setOption("end", "13.01.2021")
* .build();
*
* // /db put key:foo value:bar
* jdaTester.createSlashCommandEvent(command)
- * .subcommand("put")
- * .option("key", "foo")
- * .option("value", "bar")
+ * .setSubcommand("put")
+ * .setOption("key", "foo")
+ * .setOption("value", "bar")
* .build();
* }
*
@@ -63,8 +72,9 @@ public final class SlashCommandEventBuilder {
private String guildId;
private String userId;
private SlashCommand command;
- private final Map nameToOption = new HashMap<>();
+ private final Map> nameToOption = new HashMap<>();
private String subcommand;
+ private Member userWhoTriggered;
SlashCommandEventBuilder(@NotNull JDAImpl jda, UnaryOperator mockOperator) {
this.jda = jda;
@@ -74,7 +84,46 @@ public final class SlashCommandEventBuilder {
/**
* Sets the given option, overriding an existing value under the same name.
*
- * If {@link #subcommand(String)} is set, this option will be interpreted as option to the
+ * If {@link #setSubcommand(String)} is set, this option will be interpreted as option to the
+ * subcommand.
+ *
+ * Use {@link #clearOptions()} to clear any set options.
+ *
+ * @param name the name of the option
+ * @param value the value of the option
+ * @return this builder instance for chaining
+ * @throws IllegalArgumentException if the option does not exist in the corresponding command,
+ * as specified by its {@link SlashCommand#getData()}
+ */
+ public @NotNull SlashCommandEventBuilder setOption(@NotNull String name,
+ @NotNull String value) {
+ putOptionRaw(name, value, OptionType.STRING);
+ return this;
+ }
+
+ /**
+ * Sets the given option, overriding an existing value under the same name.
+ *
+ * If {@link #setSubcommand(String)} is set, this option will be interpreted as option to the
+ * subcommand.
+ *
+ * Use {@link #clearOptions()} to clear any set options.
+ *
+ * @param name the name of the option
+ * @param value the value of the option
+ * @return this builder instance for chaining
+ * @throws IllegalArgumentException if the option does not exist in the corresponding command,
+ * as specified by its {@link SlashCommand#getData()}
+ */
+ public @NotNull SlashCommandEventBuilder setOption(@NotNull String name, @NotNull User value) {
+ putOptionRaw(name, value, OptionType.USER);
+ return this;
+ }
+
+ /**
+ * Sets the given option, overriding an existing value under the same name.
+ *
+ * If {@link #setSubcommand(String)} is set, this option will be interpreted as option to the
* subcommand.
*
* Use {@link #clearOptions()} to clear any set options.
@@ -85,15 +134,14 @@ public final class SlashCommandEventBuilder {
* @throws IllegalArgumentException if the option does not exist in the corresponding command,
* as specified by its {@link SlashCommand#getData()}
*/
- public @NotNull SlashCommandEventBuilder option(@NotNull String name, @NotNull String value) {
- // TODO Also add overloads for other types
- requireOption(name, OptionType.STRING);
- nameToOption.put(name, new Option(name, value, OptionType.STRING));
+ public @NotNull SlashCommandEventBuilder setOption(@NotNull String name,
+ @NotNull Member value) {
+ putOptionRaw(name, value, OptionType.USER);
return this;
}
/**
- * Clears all options previously set with {@link #option(String, String)}.
+ * Clears all options previously set with {@link #setOption(String, String)}.
*
* @return this builder instance for chaining
*/
@@ -105,15 +153,15 @@ public final class SlashCommandEventBuilder {
/**
* Sets the given subcommand. Call with {@code null} to clear any previously set subcommand.
*
- * Once set, all options set by {@link #option(String, String)} will be interpreted as options
- * to this subcommand.
+ * Once set, all options set by {@link #setOption(String, String)} will be interpreted as
+ * options to this subcommand.
*
* @param subcommand the name of the subcommand or {@code null} to clear it
* @return this builder instance for chaining
* @throws IllegalArgumentException if the subcommand does not exist in the corresponding
* command, as specified by its {@link SlashCommand#getData()}
*/
- public @NotNull SlashCommandEventBuilder subcommand(@Nullable String subcommand) {
+ public @NotNull SlashCommandEventBuilder setSubcommand(@Nullable String subcommand) {
if (subcommand != null) {
requireSubcommand(subcommand);
}
@@ -122,38 +170,50 @@ public final class SlashCommandEventBuilder {
return this;
}
+ /**
+ * Sets the user who triggered the slash command.
+ *
+ * @param userWhoTriggered the user who triggered the slash command
+ * @return this builder instance for chaining
+ */
@NotNull
- SlashCommandEventBuilder command(@NotNull SlashCommand command) {
+ public SlashCommandEventBuilder setUserWhoTriggered(@NotNull Member userWhoTriggered) {
+ this.userWhoTriggered = userWhoTriggered;
+ return this;
+ }
+
+ @NotNull
+ SlashCommandEventBuilder setCommand(@NotNull SlashCommand command) {
this.command = command;
return this;
}
@NotNull
- SlashCommandEventBuilder channelId(@NotNull String channelId) {
+ SlashCommandEventBuilder setChannelId(@NotNull String channelId) {
this.channelId = channelId;
return this;
}
@NotNull
- SlashCommandEventBuilder token(@NotNull String token) {
+ SlashCommandEventBuilder setToken(@NotNull String token) {
this.token = token;
return this;
}
@NotNull
- SlashCommandEventBuilder applicationId(@NotNull String applicationId) {
+ SlashCommandEventBuilder setApplicationId(@NotNull String applicationId) {
this.applicationId = applicationId;
return this;
}
@NotNull
- SlashCommandEventBuilder guildId(@NotNull String guildId) {
+ SlashCommandEventBuilder setGuildId(@NotNull String guildId) {
this.guildId = guildId;
return this;
}
@NotNull
- SlashCommandEventBuilder userId(@NotNull String userId) {
+ SlashCommandEventBuilder setUserId(@NotNull String userId) {
this.userId = userId;
return this;
}
@@ -174,19 +234,29 @@ SlashCommandEventBuilder userId(@NotNull String userId) {
throw new IllegalStateException(e);
}
- return mockOperator.apply(new SlashCommandEvent(jda, 0,
- new CommandInteractionImpl(jda, DataObject.fromJson(json))));
+ return spySlashCommandEvent(json);
+ }
+
+ private SlashCommandEvent spySlashCommandEvent(String jsonData) {
+ SlashCommandEvent event = spy(new SlashCommandEvent(jda, 0,
+ new CommandInteractionImpl(jda, DataObject.fromJson(jsonData))));
+ event = mockOperator.apply(event);
+
+ when(event.getMember()).thenReturn(userWhoTriggered);
+ User asUser = userWhoTriggered.getUser();
+ when(event.getUser()).thenReturn(asUser);
+
+ return event;
}
private @NotNull PayloadSlashCommand createEvent() {
// TODO Validate that required options are set, check that subcommand is given if the
// command has one
// TODO Make as much of this configurable as needed
- PayloadSlashCommandUser user = new PayloadSlashCommandUser(0, userId,
- "286b894dc74634202d251d591f63537d", "Test-User", "3452");
- PayloadSlashCommandMember member =
- new PayloadSlashCommandMember(null, null, "2021-09-07T18:25:16.615000+00:00",
- "1099511627775", List.of(), false, false, false, null, false, user);
+ PayloadUser user = new PayloadUser(false, 0, userId, "286b894dc74634202d251d591f63537d",
+ "Test-User", "3452");
+ PayloadMember member = new PayloadMember(null, null, "2021-09-07T18:25:16.615000+00:00",
+ "1099511627775", List.of(), false, false, false, null, false, user);
List options;
if (subcommand == null) {
@@ -195,25 +265,94 @@ SlashCommandEventBuilder userId(@NotNull String userId) {
options = List.of(new PayloadSlashCommandOption(subcommand, 1, null,
extractOptionsOrNull(nameToOption)));
}
- PayloadSlashCommandData data =
- new PayloadSlashCommandData(command.getName(), "1", 1, options);
+ PayloadSlashCommandData data = new PayloadSlashCommandData(command.getName(), "1", 1,
+ options, extractResolvedOrNull(nameToOption));
return new PayloadSlashCommand(guildId, "897425767397466123", 2, 1, channelId,
applicationId, token, member, data);
}
private static @Nullable List extractOptionsOrNull(
- @NotNull Map nameToOption) {
+ @NotNull Map> nameToOption) {
if (nameToOption.isEmpty()) {
return null;
}
return nameToOption.values()
.stream()
- .map(option -> new PayloadSlashCommandOption(option.name(), option.type.ordinal(),
- option.value(), null))
+ .map(option -> new PayloadSlashCommandOption(option.name(), option.type().ordinal(),
+ serializeOptionValue(option.value(), option.type()), null))
.toList();
}
+ private static @NotNull String serializeOptionValue(@NotNull T value,
+ @NotNull OptionType type) {
+ if (type == OptionType.STRING) {
+ return (String) value;
+ } else if (type == OptionType.USER) {
+ if (value instanceof User user) {
+ return user.getId();
+ } else if (value instanceof Member member) {
+ return member.getId();
+ }
+
+ throw new IllegalArgumentException(
+ "Expected a user or member, since the type was set to USER. But got '%s'"
+ .formatted(value.getClass()));
+ }
+
+ throw new IllegalArgumentException(
+ "Unsupported type ('%s'), can not deserialize yet. Value is of type '%s'"
+ .formatted(type, value.getClass()));
+ }
+
+ private static @Nullable PayloadSlashCommandResolved extractResolvedOrNull(
+ @NotNull Map> nameToOption) {
+ PayloadSlashCommandUsers users = extractUsersOrNull(nameToOption);
+ PayloadSlashCommandMembers members = extractMembersOrNull(nameToOption);
+
+ if (users == null && members == null) {
+ return null;
+ }
+
+ return new PayloadSlashCommandResolved(members, users);
+ }
+
+ private static @Nullable PayloadSlashCommandUsers extractUsersOrNull(
+ @NotNull Map> nameToOption) {
+ Map idToUser = nameToOption.values()
+ .stream()
+ .filter(option -> option.type == OptionType.USER)
+ .map(Option::value)
+ .map(userOrMember -> {
+ if (userOrMember instanceof Member member) {
+ return member.getUser();
+ }
+ return (User) userOrMember;
+ })
+ .collect(Collectors.toMap(User::getId, PayloadUser::of));
+
+ return idToUser.isEmpty() ? null : new PayloadSlashCommandUsers(idToUser);
+ }
+
+ private static @Nullable PayloadSlashCommandMembers extractMembersOrNull(
+ @NotNull Map> nameToOption) {
+ Map idToMember = nameToOption.values()
+ .stream()
+ .filter(option -> option.type == OptionType.USER)
+ .map(Option::value)
+ .filter(Member.class::isInstance)
+ .map(Member.class::cast)
+ .collect(Collectors.toMap(Member::getId, PayloadMember::of));
+
+ return idToMember.isEmpty() ? null : new PayloadSlashCommandMembers(idToMember);
+ }
+
+ private void putOptionRaw(@NotNull String name, @NotNull T value,
+ @NotNull OptionType type) {
+ requireOption(name, type);
+ nameToOption.put(name, new Option<>(name, value, type));
+ }
+
@SuppressWarnings("UnusedReturnValue")
private @NotNull OptionData requireOption(@NotNull String name, @NotNull OptionType type) {
List options = subcommand == null ? command.getData().getOptions()
@@ -247,6 +386,6 @@ SlashCommandEventBuilder userId(@NotNull String userId) {
});
}
- private record Option(@NotNull String name, @NotNull String value, @NotNull OptionType type) {
+ private record Option (@NotNull String name, @NotNull T value, @NotNull OptionType type) {
}
}
diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommandMember.java b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/PayloadMember.java
similarity index 76%
rename from application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommandMember.java
rename to application/src/test/java/org/togetherjava/tjbot/jda/payloads/PayloadMember.java
index 7bef0d3d52..774e6b76df 100644
--- a/application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommandMember.java
+++ b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/PayloadMember.java
@@ -1,6 +1,9 @@
-package org.togetherjava.tjbot.jda;
+package org.togetherjava.tjbot.jda.payloads;
import com.fasterxml.jackson.annotation.JsonProperty;
+import net.dv8tion.jda.api.Permission;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.Role;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -9,7 +12,7 @@
import java.util.List;
@SuppressWarnings("ClassWithTooManyFields")
-final class PayloadSlashCommandMember {
+public final class PayloadMember {
@JsonProperty("premium_since")
private String premiumSince;
private String nick;
@@ -23,13 +26,13 @@ final class PayloadSlashCommandMember {
private String avatar;
@JsonProperty("is_pending")
private boolean isPending;
- private PayloadSlashCommandUser user;
+ private PayloadUser user;
@SuppressWarnings("ConstructorWithTooManyParameters")
- PayloadSlashCommandMember(@Nullable String premiumSince, @Nullable String nick,
+ public PayloadMember(@Nullable String premiumSince, @Nullable String nick,
@NotNull String joinedAt, @NotNull String permissions, @NotNull List roles,
boolean pending, boolean deaf, boolean mute, @Nullable String avatar, boolean isPending,
- PayloadSlashCommandUser user) {
+ PayloadUser user) {
this.premiumSince = premiumSince;
this.nick = nick;
this.joinedAt = joinedAt;
@@ -43,11 +46,22 @@ final class PayloadSlashCommandMember {
this.user = user;
}
- public @NotNull PayloadSlashCommandUser getUser() {
+ public static @NotNull PayloadMember of(@NotNull Member member) {
+ String permissions = Long
+ .toString(Permission.getRaw(member.getPermissions().toArray(Permission[]::new)));
+ List roles = member.getRoles().stream().map(Role::getId).toList();
+ PayloadUser user = PayloadUser.of(member.getUser());
+
+ return new PayloadMember(null, member.getNickname(), member.getTimeJoined().toString(),
+ permissions, roles, member.isPending(), false, false, member.getAvatarId(),
+ member.isPending(), user);
+ }
+
+ public @NotNull PayloadUser getUser() {
return user;
}
- public void setUser(@NotNull PayloadSlashCommandUser user) {
+ public void setUser(@NotNull PayloadUser user) {
this.user = user;
}
diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommandUser.java b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/PayloadUser.java
similarity index 59%
rename from application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommandUser.java
rename to application/src/test/java/org/togetherjava/tjbot/jda/payloads/PayloadUser.java
index 8042bab3e9..caa79dcc55 100644
--- a/application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommandUser.java
+++ b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/PayloadUser.java
@@ -1,17 +1,20 @@
-package org.togetherjava.tjbot.jda;
+package org.togetherjava.tjbot.jda.payloads;
import com.fasterxml.jackson.annotation.JsonProperty;
+import net.dv8tion.jda.api.entities.User;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
-final class PayloadSlashCommandUser {
+public final class PayloadUser {
+ private boolean bot;
@JsonProperty("public_flags")
- private int publicFlags;
+ private long publicFlags;
private String id;
private String avatar;
private String username;
private String discriminator;
- PayloadSlashCommandUser(int publicFlags, @NotNull String id, @NotNull String avatar,
+ public PayloadUser(boolean bot, long publicFlags, @NotNull String id, @Nullable String avatar,
@NotNull String username, @NotNull String discriminator) {
this.publicFlags = publicFlags;
this.id = id;
@@ -20,11 +23,24 @@ final class PayloadSlashCommandUser {
this.discriminator = discriminator;
}
- public int getPublicFlags() {
+ public static @NotNull PayloadUser of(@NotNull User user) {
+ return new PayloadUser(user.isBot(), user.getFlagsRaw(), user.getId(), user.getAvatarId(),
+ user.getName(), user.getDiscriminator());
+ }
+
+ public boolean isBot() {
+ return bot;
+ }
+
+ public void setBot(boolean bot) {
+ this.bot = bot;
+ }
+
+ public long getPublicFlags() {
return publicFlags;
}
- public void setPublicFlags(int publicFlags) {
+ public void setPublicFlags(long publicFlags) {
this.publicFlags = publicFlags;
}
@@ -37,12 +53,12 @@ public void setId(@NotNull String id) {
this.id = id;
}
- @NotNull
+ @Nullable
public String getAvatar() {
return avatar;
}
- public void setAvatar(@NotNull String avatar) {
+ public void setAvatar(@Nullable String avatar) {
this.avatar = avatar;
}
diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommand.java b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommand.java
similarity index 82%
rename from application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommand.java
rename to application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommand.java
index 2dca9e0837..8246909bc6 100644
--- a/application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommand.java
+++ b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommand.java
@@ -1,9 +1,10 @@
-package org.togetherjava.tjbot.jda;
+package org.togetherjava.tjbot.jda.payloads.slashcommand;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.jetbrains.annotations.NotNull;
+import org.togetherjava.tjbot.jda.payloads.PayloadMember;
-final class PayloadSlashCommand {
+public final class PayloadSlashCommand {
@JsonProperty("guild_id")
private String guildId;
private String id;
@@ -14,13 +15,13 @@ final class PayloadSlashCommand {
@JsonProperty("application_id")
private String applicationId;
private String token;
- private PayloadSlashCommandMember member;
+ private PayloadMember member;
private PayloadSlashCommandData data;
@SuppressWarnings("ConstructorWithTooManyParameters")
- PayloadSlashCommand(@NotNull String guildId, @NotNull String id, int type, int version,
+ public PayloadSlashCommand(@NotNull String guildId, @NotNull String id, int type, int version,
@NotNull String channelId, @NotNull String applicationId, @NotNull String token,
- @NotNull PayloadSlashCommandMember member, @NotNull PayloadSlashCommandData data) {
+ @NotNull PayloadMember member, @NotNull PayloadSlashCommandData data) {
this.guildId = guildId;
this.id = id;
this.type = type;
@@ -94,11 +95,11 @@ public void setToken(@NotNull String token) {
}
@NotNull
- public PayloadSlashCommandMember getMember() {
+ public PayloadMember getMember() {
return member;
}
- public void setMember(@NotNull PayloadSlashCommandMember member) {
+ public void setMember(@NotNull PayloadMember member) {
this.member = member;
}
diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommandData.java b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandData.java
similarity index 62%
rename from application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommandData.java
rename to application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandData.java
index 18a79c32ab..b84a318961 100644
--- a/application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommandData.java
+++ b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandData.java
@@ -1,4 +1,4 @@
-package org.togetherjava.tjbot.jda;
+package org.togetherjava.tjbot.jda.payloads.slashcommand;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.jetbrains.annotations.NotNull;
@@ -8,19 +8,23 @@
import java.util.Collections;
import java.util.List;
-final class PayloadSlashCommandData {
+public final class PayloadSlashCommandData {
private String name;
private String id;
private int type;
@JsonInclude(JsonInclude.Include.NON_NULL)
private List options;
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private PayloadSlashCommandResolved resolved;
- PayloadSlashCommandData(@NotNull String name, @NotNull String id, int type,
- @Nullable List options) {
+ public PayloadSlashCommandData(@NotNull String name, @NotNull String id, int type,
+ @Nullable List options,
+ @Nullable PayloadSlashCommandResolved resolved) {
this.name = name;
this.id = id;
this.type = type;
this.options = options == null ? null : new ArrayList<>(options);
+ this.resolved = resolved;
}
@NotNull
@@ -49,12 +53,19 @@ public void setType(int type) {
this.type = type;
}
- @Nullable
- public List getOptions() {
+ public @Nullable List getOptions() {
return options == null ? null : Collections.unmodifiableList(options);
}
public void setOptions(@Nullable List options) {
this.options = options == null ? null : new ArrayList<>(options);
}
+
+ public @Nullable PayloadSlashCommandResolved getResolved() {
+ return resolved;
+ }
+
+ public void setResolved(@Nullable PayloadSlashCommandResolved resolved) {
+ this.resolved = resolved;
+ }
}
diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandMembers.java b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandMembers.java
new file mode 100644
index 0000000000..99e9fbc51e
--- /dev/null
+++ b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandMembers.java
@@ -0,0 +1,28 @@
+package org.togetherjava.tjbot.jda.payloads.slashcommand;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import org.jetbrains.annotations.NotNull;
+import org.togetherjava.tjbot.jda.payloads.PayloadMember;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+public final class PayloadSlashCommandMembers {
+ private Map idToMember;
+
+ public PayloadSlashCommandMembers(@NotNull Map idToMember) {
+ this.idToMember = new HashMap<>(idToMember);
+ }
+
+ @JsonAnyGetter
+ public @NotNull Map getIdToMember() {
+ return Collections.unmodifiableMap(idToMember);
+ }
+
+ @JsonAnySetter
+ public void setIdToMember(@NotNull Map idToMember) {
+ this.idToMember = new HashMap<>(idToMember);
+ }
+}
diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommandOption.java b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandOption.java
similarity index 87%
rename from application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommandOption.java
rename to application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandOption.java
index deb18329e4..cd63baf3c8 100644
--- a/application/src/test/java/org/togetherjava/tjbot/jda/PayloadSlashCommandOption.java
+++ b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandOption.java
@@ -1,4 +1,4 @@
-package org.togetherjava.tjbot.jda;
+package org.togetherjava.tjbot.jda.payloads.slashcommand;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.jetbrains.annotations.NotNull;
@@ -8,14 +8,14 @@
import java.util.Collections;
import java.util.List;
-final class PayloadSlashCommandOption {
+public final class PayloadSlashCommandOption {
private String name;
private int type;
private String value;
@JsonInclude(JsonInclude.Include.NON_NULL)
private List options;
- PayloadSlashCommandOption(@NotNull String name, int type, @Nullable String value,
+ public PayloadSlashCommandOption(@NotNull String name, int type, @Nullable String value,
@Nullable List options) {
this.name = name;
this.type = type;
diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandResolved.java b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandResolved.java
new file mode 100644
index 0000000000..ae286c51e6
--- /dev/null
+++ b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandResolved.java
@@ -0,0 +1,33 @@
+package org.togetherjava.tjbot.jda.payloads.slashcommand;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import org.jetbrains.annotations.Nullable;
+
+public final class PayloadSlashCommandResolved {
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private PayloadSlashCommandMembers members;
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private PayloadSlashCommandUsers users;
+
+ public PayloadSlashCommandResolved(@Nullable PayloadSlashCommandMembers members,
+ @Nullable PayloadSlashCommandUsers users) {
+ this.members = members;
+ this.users = users;
+ }
+
+ public @Nullable PayloadSlashCommandMembers getMembers() {
+ return members;
+ }
+
+ public void setMembers(@Nullable PayloadSlashCommandMembers members) {
+ this.members = members;
+ }
+
+ public @Nullable PayloadSlashCommandUsers getUsers() {
+ return users;
+ }
+
+ public void setUsers(@Nullable PayloadSlashCommandUsers users) {
+ this.users = users;
+ }
+}
diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandUsers.java b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandUsers.java
new file mode 100644
index 0000000000..437beed587
--- /dev/null
+++ b/application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandUsers.java
@@ -0,0 +1,28 @@
+package org.togetherjava.tjbot.jda.payloads.slashcommand;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import org.jetbrains.annotations.NotNull;
+import org.togetherjava.tjbot.jda.payloads.PayloadUser;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+public final class PayloadSlashCommandUsers {
+ private Map idToUser;
+
+ public PayloadSlashCommandUsers(@NotNull Map idToUser) {
+ this.idToUser = new HashMap<>(idToUser);
+ }
+
+ @JsonAnyGetter
+ public @NotNull Map getIdToUser() {
+ return Collections.unmodifiableMap(idToUser);
+ }
+
+ @JsonAnySetter
+ public void setIdToUser(@NotNull Map idToUser) {
+ this.idToUser = new HashMap<>(idToUser);
+ }
+}
diff --git a/application/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/application/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..1f0955d450
--- /dev/null
+++ b/application/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline
diff --git a/database/src/main/java/org/togetherjava/tjbot/db/Database.java b/database/src/main/java/org/togetherjava/tjbot/db/Database.java
index 221f6364b4..2986571170 100644
--- a/database/src/main/java/org/togetherjava/tjbot/db/Database.java
+++ b/database/src/main/java/org/togetherjava/tjbot/db/Database.java
@@ -3,6 +3,7 @@
import org.flywaydb.core.Flyway;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
+import org.jooq.Table;
import org.jooq.exception.DataAccessException;
import org.jooq.impl.DSL;
import org.sqlite.SQLiteConfig;
@@ -53,6 +54,22 @@ public Database(String jdbcUrl) throws SQLException {
dslContext = DSL.using(dataSource.getConnection(), SQLDialect.SQLITE);
}
+ /**
+ * Creates a new empty database that is hold in memory.
+ *
+ * @param tables the tables the database will hold if desired, otherwise null
+ * @return the created database
+ */
+ public static Database createMemoryDatabase(Table>... tables) {
+ try {
+ Database database = new Database("jdbc:sqlite:");
+ database.write(context -> context.ddl(tables).executeBatch());
+ return database;
+ } catch (SQLException e) {
+ throw new DatabaseException(e);
+ }
+ }
+
/**
* Acquires read-only access to the database.
*
diff --git a/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/Application.java b/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/Application.java
index 13ecfc5302..b8661bd91d 100644
--- a/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/Application.java
+++ b/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/Application.java
@@ -5,6 +5,7 @@
import com.vaadin.flow.component.page.Push;
import com.vaadin.flow.server.PWA;
import com.vaadin.flow.theme.Theme;
+import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -34,9 +35,11 @@
@Push
public class Application extends SpringBootServletInitializer implements AppShellConfigurator {
+ private static final Logger applicationLogger = LoggerFactory.getLogger(Application.class);
+
public static void main(String[] args) {
if (args.length > 1) {
- LoggerFactory.getLogger(Application.class)
+ applicationLogger
.error("Usage: Provide a single Argument, containing the Path to the Config-File");
System.exit(1);
}
diff --git a/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/DatabaseProvider.java b/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/DatabaseProvider.java
index 87d7469ace..914687850d 100644
--- a/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/DatabaseProvider.java
+++ b/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/DatabaseProvider.java
@@ -1,5 +1,6 @@
package org.togetherjava.tjbot.logwatcher;
+import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.FatalBeanException;
import org.springframework.beans.factory.config.BeanDefinition;
@@ -21,6 +22,8 @@ public class DatabaseProvider {
private final Database db;
private final Config config;
+ private static final Logger logger = LoggerFactory.getLogger(DatabaseProvider.class);
+
public DatabaseProvider(final Config config) {
this.config = config;
this.db = createDB();
@@ -34,8 +37,7 @@ private Database createDB() {
try {
return new Database("jdbc:sqlite:%s".formatted(dbPath.toAbsolutePath()));
} catch (final SQLException e) {
- LoggerFactory.getLogger(DatabaseProvider.class)
- .error("Exception while creating Database.", e);
+ logger.error("Exception while creating Database.", e);
throw new FatalBeanException("Could not create Database.", e);
}
}
@@ -46,8 +48,7 @@ private Path getDBPath() {
try {
Files.createDirectories(dbPath.getParent());
} catch (final IOException e) {
- LoggerFactory.getLogger(DatabaseProvider.class)
- .error("Exception while creating Database-Path.", e);
+ logger.error("Exception while creating Database-Path.", e);
}
return dbPath;
diff --git a/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/views/MainLayout.java b/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/views/MainLayout.java
index 496ee5aad0..d3f60008d7 100644
--- a/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/views/MainLayout.java
+++ b/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/views/MainLayout.java
@@ -10,6 +10,7 @@
import com.vaadin.flow.component.html.*;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.RouterLink;
+import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.togetherjava.tjbot.logwatcher.accesscontrol.AllowedRoles;
import org.togetherjava.tjbot.logwatcher.accesscontrol.Role;
@@ -33,6 +34,7 @@ public class MainLayout extends AppLayout {
private final transient AuthenticatedUser authenticatedUser;
private H1 viewTitle;
+ private static final Logger logger = LoggerFactory.getLogger(MainLayout.class);
public MainLayout(AuthenticatedUser authUser) {
this.authenticatedUser = authUser;
@@ -124,8 +126,7 @@ private boolean checkAccess(MenuItemInfo menuItemInfo) {
final AllowedRoles annotation = view.getAnnotation(AllowedRoles.class);
if (annotation == null) {
- LoggerFactory.getLogger(MainLayout.class)
- .warn("Class {} not properly secured with Annotation", view);
+ logger.warn("Class {} not properly secured with Annotation", view);
return false;
}
diff --git a/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/views/logs/LogsView.java b/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/views/logs/LogsView.java
index 2f87902681..97e23e70e5 100644
--- a/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/views/logs/LogsView.java
+++ b/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/views/logs/LogsView.java
@@ -13,6 +13,7 @@
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.router.RouteAlias;
+import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.togetherjava.tjbot.logwatcher.accesscontrol.AllowedRoles;
import org.togetherjava.tjbot.logwatcher.accesscontrol.Role;
@@ -43,6 +44,8 @@
@PermitAll
public class LogsView extends VerticalLayout {
+ private static final Logger logger = LoggerFactory.getLogger(LogsView.class);
+
private static final Pattern LOGLEVEL_MATCHER =
Pattern.compile("(%s)".formatted(String.join("|", LogUtils.LogLevel.getAllNames())));
@@ -156,7 +159,7 @@ private List getLogFiles() {
try {
return this.watcher.getLogs();
} catch (final UncheckedIOException e) {
- LoggerFactory.getLogger(LogsView.class).error("Exception while gathering LogFiles", e);
+ logger.error("Exception while gathering LogFiles", e);
NotificationUtils.getNotificationForError(e).open();
return Collections.emptyList();
}
@@ -172,7 +175,7 @@ private List getLogEntries(final Path logFile) {
try {
return this.watcher.readLog(logFile);
} catch (final UncheckedIOException e) {
- LoggerFactory.getLogger(LogsView.class).error("Exception while gathering LogFiles", e);
+ logger.error("Exception while gathering LogFiles", e);
NotificationUtils.getNotificationForError(e).open();
return Collections.emptyList();
}
diff --git a/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/views/usermanagement/UserManagement.java b/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/views/usermanagement/UserManagement.java
index 45d6f6b69b..6b09965d39 100644
--- a/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/views/usermanagement/UserManagement.java
+++ b/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/views/usermanagement/UserManagement.java
@@ -15,6 +15,7 @@
import com.vaadin.flow.data.provider.Query;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
+import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.togetherjava.tjbot.logwatcher.accesscontrol.AllowedRoles;
import org.togetherjava.tjbot.logwatcher.accesscontrol.Role;
@@ -42,6 +43,7 @@ public class UserManagement extends VerticalLayout {
private final transient UserRepository repo;
private final Grid grid = new Grid<>(UserWrapper.class, false);
+ private static final Logger logger = LoggerFactory.getLogger(UserManagement.class);
public UserManagement(UserRepository repository) {
this.repo = repository;
@@ -81,8 +83,7 @@ private Stream onAll(Query query) {
.map(user -> new UserWrapper(user.getDiscordid(), user.getUsername(),
this.repo.fetchRolesForUser(user)));
} catch (final DatabaseException e) {
- LoggerFactory.getLogger(UserManagement.class)
- .error("Exception occurred while fetching.", e);
+ logger.error("Exception occurred while fetching.", e);
NotificationUtils.getNotificationForError(e).open();
return Stream.empty();
}
@@ -133,8 +134,7 @@ private void doUpdate(UserWrapper user) {
UserManagement.this.repo.saveRolesForUser(toSave, user.getRoles());
} catch (DatabaseException e) {
- LoggerFactory.getLogger(UserManagement.class)
- .error("Exception occurred while saving.", e);
+ logger.error("Exception occurred while saving.", e);
NotificationUtils.getNotificationForError(e).open();
}
@@ -193,8 +193,7 @@ private void doRemove(UserWrapper user) {
try {
UserManagement.this.repo.delete(new Users(user.getDiscordID(), user.getUserName()));
} catch (DatabaseException e) {
- LoggerFactory.getLogger(UserManagement.class)
- .error("Exception occurred while removing.", e);
+ logger.error("Exception occurred while removing.", e);
NotificationUtils.getNotificationForError(e).open();
}
}
diff --git a/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/watcher/StreamWatcher.java b/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/watcher/StreamWatcher.java
index 3507b5df48..5ff8365a4a 100644
--- a/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/watcher/StreamWatcher.java
+++ b/logviewer/src/main/java/org/togetherjava/tjbot/logwatcher/watcher/StreamWatcher.java
@@ -1,5 +1,6 @@
package org.togetherjava.tjbot.logwatcher.watcher;
+import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
@@ -10,6 +11,7 @@ public class StreamWatcher {
private static final int EXPECTED_CONCURRENT_LOG_WATCHERS = 3;
private static final Map consumerMap =
new ConcurrentHashMap<>(EXPECTED_CONCURRENT_LOG_WATCHERS);
+ private static final Logger logger = LoggerFactory.getLogger(StreamWatcher.class);
private StreamWatcher() {}
@@ -48,7 +50,7 @@ private static void notifySubscriber(Runnable run) {
try {
run.run();
} catch (final Exception e) {
- LoggerFactory.getLogger(StreamWatcher.class).error("Runnable threw Exception.", e);
+ logger.error("Runnable threw Exception.", e);
}
}
}