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 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 mockEventSupplier; + private final UnaryOperator mockMessageOperator; + private MessageBuilder messageBuilder; + private Member userWhoClicked; + + ButtonClickEventBuilder(@NotNull Supplier 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 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