diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000000..ab0551337b --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,29 @@ +# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. +# +# You can adjust the behavior by modifying this file. +# For more information, see: +# https://github.com/actions/stale +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '0 7 * * *' + +jobs: + stale: + + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' + stale-pr-message: 'This pull request is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' + close-issue-label: 'inactivity-closed' + close-pr-label: 'inactivity-closed' + days-before-stale: 30 + days-before-close: 5 diff --git a/application/build.gradle b/application/build.gradle index a1e75acded..2220065fb5 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -47,9 +47,8 @@ dependencies { implementation 'net.dv8tion:JDA:4.4.0_351' - implementation 'org.apache.logging.log4j:log4j-api:2.15.0' - implementation 'org.apache.logging.log4j:log4j-core:2.15.0' - implementation 'org.apache.logging.log4j:log4j-slf4j18-impl:2.15.0' + implementation 'org.apache.logging.log4j:log4j-core:2.16.0' + runtimeOnly 'org.apache.logging.log4j:log4j-slf4j18-impl:2.16.0' implementation 'org.jooq:jooq:3.15.3' diff --git a/application/src/main/java/org/togetherjava/tjbot/Application.java b/application/src/main/java/org/togetherjava/tjbot/Application.java index e6326df1d5..fb4b93baae 100644 --- a/application/src/main/java/org/togetherjava/tjbot/Application.java +++ b/application/src/main/java/org/togetherjava/tjbot/Application.java @@ -2,13 +2,13 @@ import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; +import net.dv8tion.jda.api.requests.GatewayIntent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.commands.Commands; import org.togetherjava.tjbot.commands.system.CommandSystem; import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.db.Database; -import org.togetherjava.tjbot.routines.ModAuditLogRoutine; import javax.security.auth.login.LoginException; import java.io.IOException; @@ -77,15 +77,12 @@ public static void runBot(String token, Path databasePath) { Database database = new Database("jdbc:sqlite:" + databasePath.toAbsolutePath()); JDA jda = JDABuilder.createDefault(token) - .addEventListeners(new CommandSystem(database)) + .enableIntents(GatewayIntent.GUILD_MEMBERS) .build(); + jda.addEventListener(new CommandSystem(jda, database)); jda.awaitReady(); logger.info("Bot is ready"); - // TODO This should be moved into some proper command system instead (see GH issue #235 - // which adds support for routines) - new ModAuditLogRoutine(jda, database).start(); - Runtime.getRuntime().addShutdownHook(new Thread(Application::onShutdown)); } catch (LoginException e) { logger.error("Failed to login", e); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java b/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java index 7980e34733..054dc5b6d4 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java @@ -1,8 +1,9 @@ package org.togetherjava.tjbot.commands; +import net.dv8tion.jda.api.JDA; import org.jetbrains.annotations.NotNull; -import org.togetherjava.tjbot.commands.basic.DatabaseCommand; import org.togetherjava.tjbot.commands.basic.PingCommand; +import org.togetherjava.tjbot.commands.basic.RoleSelectCommand; import org.togetherjava.tjbot.commands.basic.VcActivityCommand; import org.togetherjava.tjbot.commands.free.FreeCommand; import org.togetherjava.tjbot.commands.mathcommands.TeXCommand; @@ -12,6 +13,7 @@ import org.togetherjava.tjbot.commands.tags.TagSystem; import org.togetherjava.tjbot.commands.tags.TagsCommand; import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.routines.ModAuditLogRoutine; import java.util.ArrayList; import java.util.Collection; @@ -22,7 +24,7 @@ * pick it up from and register it with the system. *

* To add a new slash command, extend the commands returned by - * {@link #createSlashCommands(Database)}. + * {@link #createSlashCommands(JDA, Database)}. */ public enum Commands { ; @@ -33,20 +35,29 @@ public enum Commands { * Calling this method multiple times will result in multiple commands being created, which * generally should be avoided. * + * @param jda the JDA instance commands will be registered at * @param database the database of the application, which commands can use to persist data * @return a collection of all slash commands */ - public static @NotNull Collection createSlashCommands( + public static @NotNull Collection createSlashCommands(@NotNull JDA jda, @NotNull Database database) { TagSystem tagSystem = new TagSystem(database); ModerationActionsStore actionsStore = new ModerationActionsStore(database); + + // TODO This should be moved into some proper command system instead (see GH issue #235 + // which adds support for routines) + new ModAuditLogRoutine(jda, database).start(); + + // TODO This should be moved into some proper command system instead (see GH issue #236 + // which adds support for listeners) + jda.addEventListener(new RejoinMuteListener(actionsStore)); + // NOTE The command system can add special system relevant commands also by itself, // hence this list may not necessarily represent the full list of all commands actually // available. Collection commands = new ArrayList<>(); commands.add(new PingCommand()); - commands.add(new DatabaseCommand(database)); commands.add(new TeXCommand()); commands.add(new TagCommand(tagSystem)); commands.add(new TagManageCommand(tagSystem)); @@ -57,6 +68,9 @@ public enum Commands { commands.add(new UnbanCommand(actionsStore)); commands.add(new FreeCommand()); commands.add(new AuditCommand(actionsStore)); + commands.add(new MuteCommand(actionsStore)); + commands.add(new UnmuteCommand(actionsStore)); + commands.add(new RoleSelectCommand()); return commands; } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/basic/DatabaseCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/basic/DatabaseCommand.java deleted file mode 100644 index cc8d158ae5..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/basic/DatabaseCommand.java +++ /dev/null @@ -1,143 +0,0 @@ -package org.togetherjava.tjbot.commands.basic; - -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; -import net.dv8tion.jda.api.interactions.commands.CommandInteraction; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; -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 org.togetherjava.tjbot.db.Database; -import org.togetherjava.tjbot.db.DatabaseException; -import org.togetherjava.tjbot.db.generated.tables.Storage; -import org.togetherjava.tjbot.db.generated.tables.records.StorageRecord; - -import java.util.Objects; -import java.util.Optional; - -/** - * Implementation of an example command to illustrate how to use a database. - *

- * The implemented command is {@code /db}. It has two subcommands {@code get} and {@code put}. It - * acts like some sort of simple {@code Map}, allowing the user to store and - * retrieve key-value pairs from the database. - *

- * For example: - * - *

- * {@code
- * /db put hello Hello World!
- * // TJ-Bot: Saved under 'hello'.
- *
- * /db get hello
- * // TJ-Bot: Saved message: Hello World!
- * }
- * 
- */ -public final class DatabaseCommand extends SlashCommandAdapter { - private static final Logger logger = LoggerFactory.getLogger(DatabaseCommand.class); - private static final String GET_COMMAND = "get"; - private static final String PUT_COMMAND = "put"; - private static final String KEY_OPTION = "key"; - private static final String VALUE_OPTION = "value"; - private final Database database; - - /** - * Creates a new database command, using the given database. - * - * @param database the database to store the key-value pairs in - */ - public DatabaseCommand(@NotNull Database database) { - super("db", "Storage and retrieval of key-value pairs", SlashCommandVisibility.GUILD); - this.database = database; - - getData().addSubcommands( - new SubcommandData(GET_COMMAND, - "Gets a value corresponding to a key from a database").addOption( - OptionType.STRING, KEY_OPTION, "the key of the value to retrieve", - true), - new SubcommandData(PUT_COMMAND, - "Puts a key-value pair into a database for later retrieval") - .addOption(OptionType.STRING, KEY_OPTION, - "the key of the value to save", true) - .addOption(OptionType.STRING, VALUE_OPTION, "the value to save", true)); - } - - @Override - public void onSlashCommand(@NotNull SlashCommandEvent event) { - switch (Objects.requireNonNull(event.getSubcommandName())) { - case GET_COMMAND -> handleGetCommand(event); - case PUT_COMMAND -> handlePutCommand(event); - default -> throw new AssertionError(); - } - } - - /** - * Handles {@code /db get key} commands. Retrieves the value saved under the given key and - * responds with the results to the user. - * - * @param event the event of the command - */ - private void handleGetCommand(@NotNull CommandInteraction event) { - // /db get hello - String key = Objects.requireNonNull(event.getOption(KEY_OPTION)).getAsString(); - try { - Optional value = database.read( - context -> Optional - .ofNullable(context.selectFrom(Storage.STORAGE) - .where(Storage.STORAGE.KEY.eq(key)) - .fetchOne()) - .map(StorageRecord::getValue)); - if (value.isEmpty()) { - event.reply("Nothing found for the key '" + key + "'").setEphemeral(true).queue(); - return; - } - - event.reply("Saved message: " + value.orElseThrow()).queue(); - } catch (DatabaseException e) { - logger.error("Failed to get message", e); - event.reply("Sorry, something went wrong.").setEphemeral(true).queue(); - } - } - - /** - * Handles {@code /db put key value} commands. Saves the value under the given key and responds - * with the results to the user. - *

- * This command can only be used by users with the {@code MESSAGE_MANAGE} permission. - * - * @param event the event of the command - */ - private void handlePutCommand(@NotNull CommandInteraction event) { - // To prevent people from saving malicious content, only users with - // elevated permissions are allowed to use this command - if (!Objects.requireNonNull(event.getMember()).hasPermission(Permission.MESSAGE_MANAGE)) { - event.reply("You need the MESSAGE_MANAGE permission to use this command") - .setEphemeral(true) - .queue(); - return; - } - - // /db put hello Hello World! - String key = Objects.requireNonNull(event.getOption(KEY_OPTION)).getAsString(); - String value = Objects.requireNonNull(event.getOption(VALUE_OPTION)).getAsString(); - - try { - database.write(context -> { - StorageRecord storageRecord = - context.newRecord(Storage.STORAGE).setKey(key).setValue(value); - if (storageRecord.update() == 0) { - storageRecord.insert(); - } - }); - - event.reply("Saved under '" + key + "'.").queue(); - } catch (DatabaseException e) { - logger.error("Failed to put message", e); - event.reply("Sorry, something went wrong.").setEphemeral(true).queue(); - } - } -} 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..98bb6951af --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/basic/RoleSelectCommand.java @@ -0,0 +1,262 @@ +package org.togetherjava.tjbot.commands.basic; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.events.interaction.SelectionMenuEvent; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +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.commands.build.SubcommandData; +import net.dv8tion.jda.api.interactions.components.selections.SelectOption; +import net.dv8tion.jda.api.interactions.components.selections.SelectionMenu; +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 java.awt.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + + +/** + * 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 below its highest one + */ +public class RoleSelectCommand extends SlashCommandAdapter { + + private static final Logger logger = LoggerFactory.getLogger(RoleSelectCommand.class); + + private static final String ALL_OPTION = "all"; + private static final String CHOOSE_OPTION = "choose"; + + private static final String TITLE_OPTION = "title"; + private static final String DESCRIPTION_OPTION = "description"; + + 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 + * + * @see RoleSelectCommand + */ + public RoleSelectCommand() { + super("role-select", "Sends a message where users can select their roles", + SlashCommandVisibility.GUILD); + + SubcommandData allRoles = + new SubcommandData(ALL_OPTION, "Lists all the rolls in the server for users") + .addOptions(messageOptions); + + SubcommandData selectRoles = + new SubcommandData(CHOOSE_OPTION, "Choose the roles for users to select") + .addOptions(messageOptions); + + getData().addSubcommands(allRoles, selectRoles); + } + + @Override + public void onSlashCommand(@NotNull 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.warn("The bot needs the manage role permissions"); + return; + } + + SelectionMenu.Builder menu = SelectionMenu.create(generateComponentId(member.getId())); + boolean ephemeral = false; + + if (Objects.equals(event.getSubcommandName(), CHOOSE_OPTION)) { + addMenuOptions(event, menu, "Select the roles to display", 1); + ephemeral = true; + } else { + addMenuOptions(event, menu, "Select your roles", 0); + } + + // Handle Optional arguments + OptionMapping titleOption = event.getOption(TITLE_OPTION); + OptionMapping descriptionOption = event.getOption(DESCRIPTION_OPTION); + + String title = handleOption(titleOption); + String description = handleOption(descriptionOption); + + if (ephemeral) { + event.replyEmbeds(makeEmbed(title, description)) + .addActionRow(menu.build()) + .setEphemeral(true) + .queue(); + } else { + event.getChannel() + .sendMessageEmbeds(makeEmbed(title, description)) + .setActionRow(menu.build()) + .queue(); + + event.reply("Message sent successfully!").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 SlashCommandEvent event, + @NotNull SelectionMenu.Builder menu, @NotNull String placeHolder, + @Nullable Integer minValues) { + + Role highestBotRole = + Objects.requireNonNull(event.getGuild()).getSelfMember().getRoles().get(1); + List guildRoles = Objects.requireNonNull(event.getGuild()).getRoles(); + + List roles = new ArrayList<>( + guildRoles.subList(guildRoles.indexOf(highestBotRole) + 1, guildRoles.size())); + + if (minValues != null) { + menu.setMinValues(minValues); + } + + menu.setPlaceholder(placeHolder).setMaxValues(roles.size()); + + for (Role role : roles) { + if (role.getName().equals("@everyone") || role.getTags().getBotId() != null) { + continue; + } + menu.addOption(role.getName(), role.getId()); + } + } + + /** + * 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 String title, + @Nullable String description) { + + if (title == null) { + title = "Select your roles:"; + } + + return new EmbedBuilder().setTitle(title) + .setDescription(description) + .setColor(new Color(24, 221, 136)) + .build(); + } + + @Override + public void onSelectionMenu(@NotNull SelectionMenuEvent event, @NotNull List args) { + Member member = Objects.requireNonNull(event.getMember(), "Member is null"); + + Guild guild = Objects.requireNonNull(event.getGuild()); + + List selectedRoles = new ArrayList<>(); + for (SelectOption selectedOption : Objects.requireNonNull(event.getSelectedOptions())) { + Role selectedRole = guild.getRoleById(selectedOption.getValue()); + if (selectedRole != null && guild.getSelfMember().canInteract(selectedRole)) { + selectedRoles.add(selectedRole); + } + } + + // True if the event option was 'choose' + if (event.getMessage().isEphemeral()) { + + SelectionMenu.Builder menu = SelectionMenu.create(generateComponentId(member.getId())); + menu.setPlaceholder("Select your roles") + .setMaxValues(selectedRoles.size()) + .setMinValues(0); + + // Add selected options to the menu + 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(); + + return; + } + + List menuOptions = + Objects.requireNonNull(event.getInteraction().getComponent()).getOptions(); + + + for (SelectOption selectedOption : menuOptions) { + Role role = guild.getRoleById(selectedOption.getValue()); + + if (role == null) { + logger.info( + "The {} ({}) role has been removed but is still an option in the selection menu", + selectedOption.getLabel(), selectedOption.getValue()); + continue; + } + + if (selectedRoles.contains(role)) { + guild.addRoleToMember(member, role).queue(); + } else { + event.getGuild().removeRoleFromMember(member, role).queue(); + } + } + + event.reply("Updated your roles!").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} + */ + private static @Nullable String handleOption(@Nullable OptionMapping option) { + + if (option == null) { + return null; + } + + if (option.getType() == OptionType.STRING) { + return option.getAsString(); + } else if (option.getType() == OptionType.BOOLEAN) { + return option.getAsBoolean() ? "true" : "false"; + } else { + return null; + } + } +} 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 1b756927d2..e33f3feded 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 @@ -16,12 +16,10 @@ import org.togetherjava.tjbot.config.Config; import java.time.ZoneOffset; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Objects; +import java.util.*; import java.util.function.Predicate; import java.util.regex.Pattern; +import java.util.stream.Collectors; /** * This command lists all moderation actions that have been taken against a given user, for example @@ -54,20 +52,39 @@ public AuditCommand(@NotNull ModerationActionsStore actionsStore) { this.actionsStore = Objects.requireNonNull(actionsStore); } - private static MessageEmbed createSummaryMessage(@NotNull User user, + private static @NotNull MessageEmbed createSummaryMessage(@NotNull User user, @NotNull Collection actions) { - int actionAmount = actions.size(); - String description = actionAmount == 0 ? "There are **no actions** against the user." - : "There are **%d actions** against the user.".formatted(actionAmount); - return new EmbedBuilder().setTitle("Audit log of **%s**".formatted(user.getAsTag())) .setAuthor(user.getName(), null, user.getAvatarUrl()) - .setDescription(description) + .setDescription(createSummaryMessageDescription(actions)) .setColor(ModerationUtils.AMBIENT_COLOR) .build(); } - private static RestAction actionToMessage(@NotNull ActionRecord action, + private static @NotNull String createSummaryMessageDescription( + @NotNull Collection actions) { + int actionAmount = actions.size(); + if (actionAmount == 0) { + return "There are **no actions** against the user."; + } + + String shortSummary = "There are **%d actions** against the user.".formatted(actionAmount); + + // Summary of all actions with their count, like "- Warn: 5", descending + Map actionTypeToCount = actions.stream() + .collect(Collectors.groupingBy(ActionRecord::actionType, Collectors.counting())); + String typeCountSummary = actionTypeToCount.entrySet() + .stream() + .filter(typeAndCount -> typeAndCount.getValue() > 0) + .sorted(Map.Entry.comparingByValue().reversed()) + .map(typeAndCount -> "- **%s**: %d".formatted(typeAndCount.getKey(), + typeAndCount.getValue())) + .collect(Collectors.joining("\n")); + + return shortSummary + "\n" + typeCountSummary; + } + + private static @NotNull RestAction actionToMessage(@NotNull ActionRecord action, @NotNull JDA jda) { String footer = action.actionExpiresAt() == null ? null : "Temporary action, expires at %s".formatted(TimeUtil @@ -84,7 +101,8 @@ private static RestAction actionToMessage(@NotNull ActionRecord ac .build()); } - private static List prependElement(E element, Collection elements) { + private static @NotNull List prependElement(@NotNull E element, + @NotNull Collection elements) { List allElements = new ArrayList<>(elements.size() + 1); allElements.add(element); allElements.addAll(elements); 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 deaa4a6c95..339541f8e0 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 @@ -8,15 +8,18 @@ import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.config.Config; import java.awt.*; import java.time.Instant; +import java.util.Optional; import java.util.function.Predicate; +import java.util.regex.Pattern; /** * Utility class offering helpers revolving around user moderation, such as banning or kicking. */ -enum ModerationUtils { +public enum ModerationUtils { ; private static final Logger logger = LoggerFactory.getLogger(ModerationUtils.class); @@ -26,6 +29,8 @@ enum ModerationUtils { */ private static final int REASON_MAX_LENGTH = 512; static final Color AMBIENT_COLOR = Color.decode("#895FE8"); + 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 @@ -85,6 +90,41 @@ static boolean handleCanInteractWithTarget(@NotNull String actionVerb, @NotNull return true; } + /** + * Checks whether the given author and bot can interact with the given role. For example whether + * they have enough permissions to add or remove this role to users. + *

+ * If not, it will handle the situation and respond to the user. + * + * @param bot the bot attempting to interact with the user + * @param author the author triggering the command + * @param role the role to interact with + * @param event the event used to respond to the user + * @return Whether the author and bot can interact with the role + */ + @SuppressWarnings("BooleanMethodNameMustStartWithQuestion") + static boolean handleCanInteractWithRole(@NotNull Member bot, @NotNull Member author, + @NotNull Role role, @NotNull Interaction event) { + if (!author.canInteract(role)) { + event + .reply("The role %s is too powerful for you to interact with." + .formatted(role.getAsMention())) + .setEphemeral(true) + .queue(); + return false; + } + + if (!bot.canInteract(role)) { + event + .reply("The role %s is too powerful for me to interact with." + .formatted(role.getAsMention())) + .setEphemeral(true) + .queue(); + return false; + } + return true; + } + /** * Checks whether the given bot has enough permission to execute the given action. For example * whether it has enough permissions to ban users. @@ -115,6 +155,81 @@ static boolean handleHasBotPermissions(@NotNull String actionVerb, return true; } + private static void handleAbsentTarget(@NotNull String actionVerb, @NotNull Interaction event) { + event + .reply("I can not %s the given user since they are not part of the guild anymore." + .formatted(actionVerb)) + .setEphemeral(true) + .queue(); + } + + /** + * Checks whether the given bot and author have enough permission to change the roles of a given + * target. For example whether they have enough permissions to add a role to a user. + *

+ * If not, it will handle the situation and respond to the user. + *

+ * The checks include: + *

    + *
  • the role does not exist on the guild
  • + *
  • the target is not member of the guild
  • + *
  • the bot or author do not have enough permissions to interact with the target
  • + *
  • the bot or author do not have enough permissions to interact with the role
  • + *
  • the author does not have the required role for this interaction
  • + *
  • the bot does not have the MANAGE_ROLES permission
  • + *
  • the given reason is too long
  • + *
+ * + * @param role the role to change, or {@code null} if it does not exist on the guild + * @param actionVerb the interaction as verb, for example {@code "mute"} or {@code "unmute"} + * @param target the target user to change roles from, or {@code null} if the user is not member + * of the guild + * @param bot the bot executing this interaction + * @param author the author attempting to interact with the target + * @param guild the guild this interaction is executed on + * @param hasRequiredRole a predicate used to identify required roles by their name + * @param reason the reason for this interaction + * @param event the event used to respond to the user + * @return Whether the bot and the author have enough permission + */ + @SuppressWarnings({"MethodWithTooManyParameters", "BooleanMethodNameMustStartWithQuestion", + "squid:S107"}) + static boolean handleRoleChangeChecks(@Nullable Role role, @NotNull String actionVerb, + @Nullable Member target, @NotNull Member bot, @NotNull Member author, + @NotNull Guild guild, @NotNull Predicate hasRequiredRole, + @NotNull CharSequence reason, @NotNull Interaction event) { + if (role == null) { + event + .reply("Can not %s the user, unable to find the corresponding role on this server" + .formatted(actionVerb)) + .setEphemeral(true) + .queue(); + logger.warn("The guild '{}' does not have a role to {} users.", guild.getName(), + actionVerb); + return false; + } + + // Member doesn't exist if attempting to change roles of a user who is not part of the guild + // anymore. + if (target == null) { + handleAbsentTarget(actionVerb, event); + return false; + } + if (!handleCanInteractWithTarget(actionVerb, bot, author, target, event)) { + return false; + } + if (!handleCanInteractWithRole(bot, author, role, event)) { + return false; + } + if (!handleHasAuthorRole(actionVerb, hasRequiredRole, author, event)) { + return false; + } + if (!handleHasBotPermissions(actionVerb, Permission.MANAGE_ROLES, bot, guild, event)) { + return false; + } + return ModerationUtils.handleReason(reason, event); + } + /** * Checks whether the given author has enough permission to execute the given action. For * example whether they have enough permissions to ban users. @@ -171,7 +286,7 @@ static boolean handleHasAuthorRole(@NotNull String actionVerb, /** * Creates a message to be displayed as response to a moderation action. - * + *

* Essentially, it informs others about the action, such as "John banned Bob for playing with * the fire.". * @@ -200,6 +315,16 @@ static boolean handleHasAuthorRole(@NotNull String actionVerb, .build(); } + /** + * Gets the role used to mute a member in a guild. + * + * @param guild the guild to get the muted role from + * @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(); + } + /** * All available moderation actions. */ @@ -215,7 +340,15 @@ enum Action { /** * When a user kicks another user. */ - KICK("kicked"); + KICK("kicked"), + /** + * When a user mutes another user. + */ + MUTE("muted"), + /** + * When a user unmutes another user. + */ + UNMUTE("unmuted"); private final String verb; 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 new file mode 100644 index 0000000000..cb363190fc --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java @@ -0,0 +1,147 @@ +package org.togetherjava.tjbot.commands.moderation; + +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.events.GenericEvent; +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.requests.RestAction; +import net.dv8tion.jda.api.requests.restaction.AuditableRestAction; +import net.dv8tion.jda.api.utils.Result; +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 can mute users. Muting can also be paired with a reason. The command will also try + * to DM the user to inform them about the action and the reason. + *

+ * The command fails if the user triggering it is lacking permissions to either mute other users or + * to mute the specific given user (for example a moderator attempting to mute an admin). + */ +public final class MuteCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(MuteCommand.class); + private static final String TARGET_OPTION = "user"; + private static final String REASON_OPTION = "reason"; + private static final String COMMAND_NAME = "mute"; + private static final String ACTION_VERB = "mute"; + private final Predicate hasRequiredRole; + private final ModerationActionsStore actionsStore; + + /** + * Constructs an instance. + * + * @param actionsStore used to store actions issued by this command + */ + public MuteCommand(@NotNull ModerationActionsStore actionsStore) { + super(COMMAND_NAME, "Mutes the given user so that they can not send messages anymore", + SlashCommandVisibility.GUILD); + + getData().addOption(OptionType.USER, TARGET_OPTION, "The user who you want to mute", true) + .addOption(OptionType.STRING, REASON_OPTION, "Why the user should be muted", true); + + hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern()) + .asMatchPredicate(); + this.actionsStore = Objects.requireNonNull(actionsStore); + } + + private static void handleAlreadyMutedTarget(@NotNull Interaction event) { + event.reply("The user is already muted.").setEphemeral(true).queue(); + } + + private static RestAction sendDm(@NotNull ISnowflake target, @NotNull String reason, + @NotNull Guild guild, @NotNull GenericEvent event) { + String dmMessage = + """ + Hey there, sorry to tell you but unfortunately you have been muted in the server %s. + This means you can no longer send any messages in the server until you have been unmuted again. + If you think this was a mistake, please contact a moderator or admin of the server. + The reason for the mute is: %s + """ + .formatted(guild.getName(), reason); + return event.getJDA() + .openPrivateChannelById(target.getId()) + .flatMap(channel -> channel.sendMessage(dmMessage)) + .mapToResult() + .map(Result::isSuccess); + } + + private static @NotNull MessageEmbed sendFeedback(boolean hasSentDm, @NotNull Member target, + @NotNull Member author, @NotNull String reason) { + String dmNoticeText = ""; + if (!hasSentDm) { + dmNoticeText = "(Unable to send them a DM.)"; + } + return ModerationUtils.createActionResponse(author.getUser(), ModerationUtils.Action.MUTE, + target.getUser(), dmNoticeText, reason); + } + + private AuditableRestAction muteUser(@NotNull Member target, @NotNull Member author, + @NotNull String reason, @NotNull Guild guild) { + logger.info("'{}' ({}) muted the user '{}' ({}) in guild '{}' for reason '{}'.", + author.getUser().getAsTag(), author.getId(), target.getUser().getAsTag(), + target.getId(), guild.getName(), reason); + + actionsStore.addAction(guild.getIdLong(), author.getIdLong(), target.getIdLong(), + ModerationUtils.Action.MUTE, null, reason); + + return guild.addRoleToMember(target, ModerationUtils.getMutedRole(guild).orElseThrow()) + .reason(reason); + } + + private void muteUserFlow(@NotNull Member target, @NotNull Member author, + @NotNull String reason, @NotNull Guild guild, @NotNull SlashCommandEvent event) { + sendDm(target, reason, guild, event) + .flatMap(hasSentDm -> muteUser(target, author, reason, guild) + .map(banResult -> hasSentDm)) + .map(hasSentDm -> sendFeedback(hasSentDm, target, author, reason)) + .flatMap(event::replyEmbeds) + .queue(); + } + + @SuppressWarnings({"BooleanMethodNameMustStartWithQuestion", "MethodWithTooManyParameters"}) + 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)) { + return false; + } + if (Objects.requireNonNull(target) + .getRoles() + .stream() + .map(Role::getName) + .anyMatch(ModerationUtils.isMuteRole)) { + handleAlreadyMutedTarget(event); + return false; + } + return true; + } + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + Member target = Objects.requireNonNull(event.getOption(TARGET_OPTION), "The target is null") + .getAsMember(); + Member author = Objects.requireNonNull(event.getMember(), "The author is null"); + String reason = Objects.requireNonNull(event.getOption(REASON_OPTION), "The reason is null") + .getAsString(); + + Guild guild = Objects.requireNonNull(event.getGuild()); + Member bot = guild.getSelfMember(); + + if (!handleChecks(bot, author, target, reason, guild, event)) { + return; + } + + muteUserFlow(Objects.requireNonNull(target), author, reason, guild, event); + } +} 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 new file mode 100644 index 0000000000..0278ce1c7c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinMuteListener.java @@ -0,0 +1,81 @@ +package org.togetherjava.tjbot.commands.moderation; + +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.IPermissionHolder; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import java.util.*; + +/** + * Reapplies existing mutes to users who have left and rejoined a guild. + *

+ * Mutes are realized with roles and roles are removed upon leaving a guild, making it possible for + * users to otherwise bypass a mute by simply leaving and rejoining a guild. This class listens for + * join events and reapplies the mute role in case the user is supposed to be muted still (according + * to the {@link ModerationActionsStore}). + */ +public final class RejoinMuteListener extends ListenerAdapter { + private static final Logger logger = LoggerFactory.getLogger(RejoinMuteListener.class); + + private final ModerationActionsStore actionsStore; + + /** + * Constructs an instance. + * + * @param actionsStore used to store actions issued by this command and to retrieve whether a + * user should be muted + */ + public RejoinMuteListener(@NotNull ModerationActionsStore actionsStore) { + this.actionsStore = Objects.requireNonNull(actionsStore); + } + + private static 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()) + .reason("Reapplied existing mute after rejoining the server") + .queue(); + } + + @Override + public void onGuildMemberJoin(@Nonnull GuildMemberJoinEvent event) { + Member member = event.getMember(); + if (!shouldMemberBeMuted(member)) { + return; + } + muteMember(member); + } + + private boolean shouldMemberBeMuted(@NotNull IPermissionHolder member) { + List actions = new ArrayList<>(actionsStore + .getActionsByTargetAscending(member.getGuild().getIdLong(), member.getIdLong())); + Collections.reverse(actions); + + Optional lastMute = actions.stream() + .filter(action -> action.actionType() == ModerationUtils.Action.MUTE) + .findFirst(); + if (lastMute.isEmpty()) { + // User was never muted + return false; + } + + Optional lastUnmute = actions.stream() + .filter(action -> action.actionType() == ModerationUtils.Action.UNMUTE) + .findFirst(); + if (lastUnmute.isEmpty()) { + // User was never unmuted + return true; + } + + // The last issued action takes priority + return lastMute.orElseThrow().issuedAt().isAfter(lastUnmute.orElseThrow().issuedAt()); + } +} 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 new file mode 100644 index 0000000000..6828981c68 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java @@ -0,0 +1,144 @@ +package org.togetherjava.tjbot.commands.moderation; + +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.events.GenericEvent; +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.requests.RestAction; +import net.dv8tion.jda.api.requests.restaction.AuditableRestAction; +import net.dv8tion.jda.api.utils.Result; +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 can unmute muted users. Unmuting can also be paired with a reason. The command will + * also try to DM the user to inform them about the action and the reason. + *

+ * The command fails if the user triggering it is lacking permissions to either unmute other users + * or to unmute the specific given user (for example a moderator attempting to unmute an admin). + */ +public final class UnmuteCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(UnmuteCommand.class); + private static final String TARGET_OPTION = "user"; + private static final String REASON_OPTION = "reason"; + private static final String COMMAND_NAME = "unmute"; + private static final String ACTION_VERB = "unmute"; + private final Predicate hasRequiredRole; + private final ModerationActionsStore actionsStore; + + /** + * Constructs an instance. + * + * @param actionsStore used to store actions issued by this command + */ + public UnmuteCommand(@NotNull ModerationActionsStore actionsStore) { + super(COMMAND_NAME, + "Unmutes the given already muted user so that they can send messages again", + SlashCommandVisibility.GUILD); + + 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.actionsStore = Objects.requireNonNull(actionsStore); + } + + private static void handleNotMutedTarget(@NotNull Interaction event) { + event.reply("The user is not muted.").setEphemeral(true).queue(); + } + + private static RestAction sendDm(@NotNull ISnowflake target, @NotNull String reason, + @NotNull Guild guild, @NotNull GenericEvent event) { + String dmMessage = """ + Hey there, you have been unmuted in the server %s. + This means you can now send messages in the server again. + The reason for the unmute is: %s + """.formatted(guild.getName(), reason); + return event.getJDA() + .openPrivateChannelById(target.getId()) + .flatMap(channel -> channel.sendMessage(dmMessage)) + .mapToResult() + .map(Result::isSuccess); + } + + private static @NotNull MessageEmbed sendFeedback(boolean hasSentDm, @NotNull Member target, + @NotNull Member author, @NotNull String reason) { + String dmNoticeText = ""; + if (!hasSentDm) { + dmNoticeText = "(Unable to send them a DM.)"; + } + return ModerationUtils.createActionResponse(author.getUser(), ModerationUtils.Action.UNMUTE, + target.getUser(), dmNoticeText, reason); + } + + private AuditableRestAction unmuteUser(@NotNull Member target, @NotNull Member author, + @NotNull String reason, @NotNull Guild guild) { + logger.info("'{}' ({}) unmuted the user '{}' ({}) in guild '{}' for reason '{}'.", + author.getUser().getAsTag(), author.getId(), target.getUser().getAsTag(), + target.getId(), guild.getName(), reason); + + actionsStore.addAction(guild.getIdLong(), author.getIdLong(), target.getIdLong(), + ModerationUtils.Action.UNMUTE, null, reason); + + return guild.removeRoleFromMember(target, ModerationUtils.getMutedRole(guild).orElseThrow()) + .reason(reason); + } + + private void unmuteUserFlow(@NotNull Member target, @NotNull Member author, + @NotNull String reason, @NotNull Guild guild, @NotNull SlashCommandEvent event) { + sendDm(target, reason, guild, event) + .flatMap(hasSentDm -> unmuteUser(target, author, reason, guild) + .map(banResult -> hasSentDm)) + .map(hasSentDm -> sendFeedback(hasSentDm, target, author, reason)) + .flatMap(event::replyEmbeds) + .queue(); + } + + @SuppressWarnings({"BooleanMethodNameMustStartWithQuestion", "MethodWithTooManyParameters"}) + 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)) { + return false; + } + if (Objects.requireNonNull(target) + .getRoles() + .stream() + .map(Role::getName) + .noneMatch(ModerationUtils.isMuteRole)) { + handleNotMutedTarget(event); + return false; + } + return true; + } + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + Member target = Objects.requireNonNull(event.getOption(TARGET_OPTION), "The target is null") + .getAsMember(); + Member author = Objects.requireNonNull(event.getMember(), "The author is null"); + String reason = Objects.requireNonNull(event.getOption(REASON_OPTION), "The reason is null") + .getAsString(); + + Guild guild = Objects.requireNonNull(event.getGuild()); + Member bot = guild.getSelfMember(); + + if (!handleChecks(bot, author, target, reason, guild, event)) { + return; + } + unmuteUserFlow(Objects.requireNonNull(target), author, reason, guild, event); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java b/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java index e829790546..4be65e20ae 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java @@ -1,5 +1,6 @@ package org.togetherjava.tjbot.commands.system; +import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.TextChannel; @@ -55,11 +56,12 @@ public final class CommandSystem extends ListenerAdapter implements SlashCommand *

* Commands are fetched from {@link Commands}. * + * @param jda the JDA instance that this command system will be used with * @param database the database that commands may use to persist data */ @SuppressWarnings("ThisEscapedInObjectConstruction") - public CommandSystem(@NotNull Database database) { - nameToSlashCommands = Commands.createSlashCommands(database) + public CommandSystem(@NotNull JDA jda, @NotNull Database database) { + nameToSlashCommands = Commands.createSlashCommands(jda, database) .stream() .collect(Collectors.toMap(SlashCommand::getName, Function.identity())); diff --git a/application/src/main/java/org/togetherjava/tjbot/logging/FlaggedFilter.java b/application/src/main/java/org/togetherjava/tjbot/logging/FlaggedFilter.java new file mode 100644 index 0000000000..0789f7738c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/logging/FlaggedFilter.java @@ -0,0 +1,68 @@ +package org.togetherjava.tjbot.logging; + +import org.apache.logging.log4j.core.Core; +import org.apache.logging.log4j.core.Filter; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.config.plugins.Plugin; +import org.apache.logging.log4j.core.config.plugins.PluginAttribute; +import org.apache.logging.log4j.core.config.plugins.PluginFactory; +import org.apache.logging.log4j.core.filter.AbstractFilter; +import org.jetbrains.annotations.NotNull; + + +/** + * A custom Filter for Log4j2, which only lets an event pass through if a Logging Flag is set in the + * environment. Intended to be used for local development for devs do not want to also run the + * logviewer project. No errors in console or Log should appear, if the Flag is not set and the + * logviewer is not running. + */ +@Plugin(name = "FlaggedFilter", category = Core.CATEGORY_NAME, elementType = Filter.ELEMENT_TYPE) +public class FlaggedFilter extends AbstractFilter { + + /** + * The environment Variable that needs to bet set in order for this Filter to let events through + */ + public static final String LOGGING_FLAG = "TJ_APPENDER"; + + /** + * Create a FlaggedFilter. + * + * @param onMatch The action to take on a match. + * @param onMismatch The action to take on a mismatch. + */ + public FlaggedFilter(@NotNull Result onMatch, @NotNull Result onMismatch) { + super(onMatch, onMismatch); + } + + /** + * The actual filtering occurs here. If the Flag {@link #LOGGING_FLAG} is not set returns + * {@link Result#DENY} so nothing goes through. If the Flag is set it returns + * {@link Result#NEUTRAL} so other configured Filter still work. + * + * @param event The Event to log. + * @return {@link Result#DENY} if the Flag is not set, else {@link Result#NEUTRAL} + */ + @Override + public Result filter(LogEvent event) { + return isLoggingEnabled() ? Result.NEUTRAL : Result.DENY; + } + + boolean isLoggingEnabled() { + return System.getenv().containsKey(LOGGING_FLAG); + } + + /** + * Required by the Log4j2 - Plugin framework in order to create an instance of this Filter. + * + * @param onMatch The action to take on a match. + * @param onMismatch The action to take on a mismatch. + * @return The created FlaggedFilter. + */ + @PluginFactory + public static FlaggedFilter createFilter( + @NotNull @PluginAttribute(value = "onMatch", defaultString = "NEUTRAL") Result onMatch, + @NotNull + @PluginAttribute(value = "onMismatch", defaultString = "DENY") Result onMismatch) { + return new FlaggedFilter(onMatch, onMismatch); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/logging/package-info.java b/application/src/main/java/org/togetherjava/tjbot/logging/package-info.java new file mode 100644 index 0000000000..ecac734f57 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/logging/package-info.java @@ -0,0 +1,4 @@ +/** + * This package is for custom logging plugins of the bot. + */ +package org.togetherjava.tjbot.logging; 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 e4ef54ce36..c32b8b5d10 100644 --- a/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java @@ -15,6 +15,7 @@ import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.moderation.ModerationUtils; import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.db.generated.tables.ModAuditLogGuildProcess; @@ -49,7 +50,6 @@ public final class ModAuditLogRoutine { private static final Color AMBIENT_COLOR = Color.decode("#4FC3F7"); private final Predicate isAuditLogChannel; - private final Predicate isMutedRole; private final Database database; private final JDA jda; private final ScheduledExecutorService checkAuditLogService = @@ -67,8 +67,6 @@ public ModAuditLogRoutine(@NotNull JDA jda, @NotNull Database database) { .asMatchPredicate(); isAuditLogChannel = channel -> isAuditLogChannelName.test(channel.getName()); - isMutedRole = - Pattern.compile(Config.getInstance().getMutedRolePattern()).asMatchPredicate(); this.database = database; this.jda = jda; } @@ -293,8 +291,8 @@ private void handleAuditLogs(@NotNull MessageChannel auditLogChannel, }); } - private Optional> handleAuditLog(@NotNull MessageChannel auditLogChannel, - @NotNull AuditLogEntry entry) { + private static Optional> handleAuditLog( + @NotNull MessageChannel auditLogChannel, @NotNull AuditLogEntry entry) { Optional> maybeMessage = switch (entry.getType()) { case BAN -> handleBanEntry(entry); case UNBAN -> handleUnbanEntry(entry); @@ -306,7 +304,7 @@ private Optional> handleAuditLog(@NotNull MessageChannel aud return maybeMessage.map(message -> message.flatMap(auditLogChannel::sendMessageEmbeds)); } - private @NotNull Optional> handleRoleUpdateEntry( + private static @NotNull Optional> handleRoleUpdateEntry( @NotNull AuditLogEntry entry) { if (containsMutedRole(entry, AuditLogKey.MEMBER_ROLES_ADD)) { return handleMuteEntry(entry); @@ -317,7 +315,8 @@ private Optional> handleAuditLog(@NotNull MessageChannel aud return Optional.empty(); } - private boolean containsMutedRole(@NotNull AuditLogEntry entry, @NotNull AuditLogKey key) { + private static boolean containsMutedRole(@NotNull AuditLogEntry entry, + @NotNull AuditLogKey key) { List> roleChanges = Optional.ofNullable(entry.getChangeByKey(key)) .>>map(AuditLogChange::getNewValue) .orElse(List.of()); @@ -326,7 +325,7 @@ private boolean containsMutedRole(@NotNull AuditLogEntry entry, @NotNull AuditLo .flatMap(Collection::stream) .filter(changeEntry -> "name".equals(changeEntry.getKey())) .map(Map.Entry::getValue) - .anyMatch(isMutedRole); + .anyMatch(ModerationUtils.isMuteRole); } private Optional getModAuditLogChannel(@NotNull Guild guild) { diff --git a/application/src/main/resources/db/V6__Remove_Database_Listener.sql b/application/src/main/resources/db/V6__Remove_Database_Listener.sql new file mode 100644 index 0000000000..8eca5c38d3 --- /dev/null +++ b/application/src/main/resources/db/V6__Remove_Database_Listener.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS storage; \ No newline at end of file diff --git a/application/src/main/resources/log4j2.xml b/application/src/main/resources/log4j2.xml index d82af3842e..43540687ee 100644 --- a/application/src/main/resources/log4j2.xml +++ b/application/src/main/resources/log4j2.xml @@ -1,5 +1,5 @@ - + @@ -17,6 +17,7 @@ + diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/basic/DatabaseCommandTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/basic/DatabaseCommandTest.java deleted file mode 100644 index a85bf288db..0000000000 --- a/application/src/test/java/org/togetherjava/tjbot/commands/basic/DatabaseCommandTest.java +++ /dev/null @@ -1,151 +0,0 @@ -package org.togetherjava.tjbot.commands.basic; - -import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; -import org.jetbrains.annotations.NotNull; -import org.jooq.Record1; -import org.jooq.Result; -import org.junit.jupiter.api.BeforeEach; -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.Storage; -import org.togetherjava.tjbot.db.generated.tables.records.StorageRecord; -import org.togetherjava.tjbot.jda.JdaTester; - -import java.sql.SQLException; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -final class DatabaseCommandTest { - - private Database database; - - private static SlashCommandEvent createGet(@NotNull String value, @NotNull SlashCommand command, - @NotNull JdaTester jdaTester) { - return jdaTester.createSlashCommandEvent(command) - .subcommand("get") - .option("key", value) - .build(); - } - - private static SlashCommandEvent createPut(@NotNull String key, @NotNull String value, - @NotNull SlashCommand command, @NotNull JdaTester jdaTester) { - return jdaTester.createSlashCommandEvent(command) - .subcommand("put") - .option("key", key) - .option("value", value) - .build(); - } - - @BeforeEach - void setupDatabase() throws SQLException { - // TODO This has to be done dynamically by the Flyway script, adjust gradle test settings - database = new Database("jdbc:sqlite:"); - database.write(context -> context.ddl(Storage.STORAGE).executeBatch()); - } - - @Test - void getNoKey() { - SlashCommand command = new DatabaseCommand(database); - JdaTester jdaTester = new JdaTester(); - - SlashCommandEvent event = createGet("foo", command, jdaTester); - command.onSlashCommand(event); - - verify(event, times(1)).reply("Nothing found for the key 'foo'"); - } - - @Test - void getValidKey() { - SlashCommand command = new DatabaseCommand(database); - JdaTester jdaTester = new JdaTester(); - - putIntoDatabase("foo", "bar"); - - SlashCommandEvent event = createGet("foo", command, jdaTester); - command.onSlashCommand(event); - - verify(event, times(1)).reply("Saved message: bar"); - } - - @Test - void putEmpty() { - SlashCommand command = new DatabaseCommand(database); - JdaTester jdaTester = new JdaTester(); - - SlashCommandEvent event = createPut("foo", "bar", command, jdaTester); - command.onSlashCommand(event); - - verify(event, times(1)).reply("Saved under 'foo'."); - assertValueInDatabase("foo", "bar"); - } - - @Test - void putOverride() { - SlashCommand command = new DatabaseCommand(database); - JdaTester jdaTester = new JdaTester(); - - SlashCommandEvent event = createPut("foo", "bar", command, jdaTester); - command.onSlashCommand(event); - - event = createPut("foo", "baz", command, jdaTester); - command.onSlashCommand(event); - - verify(event, times(1)).reply("Saved under 'foo'."); - assertValueInDatabase("foo", "baz"); - } - - @Test - void getPutGet() { - SlashCommand command = new DatabaseCommand(database); - JdaTester jdaTester = new JdaTester(); - - SlashCommandEvent getEvent = createGet("foo", command, jdaTester); - command.onSlashCommand(getEvent); - verify(getEvent, times(1)).reply("Nothing found for the key 'foo'"); - - SlashCommandEvent putEvent = createPut("foo", "bar", command, jdaTester); - command.onSlashCommand(putEvent); - verify(putEvent, times(1)).reply("Saved under 'foo'."); - - command.onSlashCommand(getEvent); - verify(getEvent, times(1)).reply("Saved message: bar"); - } - - @Test - void getOrPutWithNoTable() throws SQLException { - SlashCommand command = new DatabaseCommand(new Database("jdbc:sqlite:")); - JdaTester jdaTester = new JdaTester(); - - SlashCommandEvent event = createGet("foo", command, jdaTester); - command.onSlashCommand(event); - verify(event, times(1)).reply("Sorry, something went wrong."); - - event = createPut("foo", "bar", command, jdaTester); - command.onSlashCommand(event); - verify(event, times(1)).reply("Sorry, something went wrong."); - } - - private void assertValueInDatabase(@NotNull String key, @NotNull String value) { - Result> results = - database.read(context -> context.select(Storage.STORAGE.VALUE) - .from(Storage.STORAGE) - .where(Storage.STORAGE.KEY.eq(key)) - .fetch()); - assertEquals(1, results.size()); - assertEquals(value, results.get(0).get(Storage.STORAGE.VALUE)); - } - - private void putIntoDatabase(@NotNull String key, @NotNull String value) { - database.write(context -> { - StorageRecord storageRecord = - context.newRecord(Storage.STORAGE).setKey(key).setValue(value); - if (storageRecord.update() == 0) { - storageRecord.insert(); - } - }); - } - -} diff --git a/application/src/test/java/org/togetherjava/tjbot/logging/FilterTest.java b/application/src/test/java/org/togetherjava/tjbot/logging/FilterTest.java new file mode 100644 index 0000000000..52251b6bae --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/logging/FilterTest.java @@ -0,0 +1,36 @@ +package org.togetherjava.tjbot.logging; + +import org.apache.logging.log4j.core.Filter; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.impl.Log4jLogEvent; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +final class FilterTest { + + private FlaggedFilter filter; + private LogEvent event; + + @BeforeEach + void setUp() { + this.filter = FlaggedFilter.createFilter(Filter.Result.NEUTRAL, Filter.Result.DENY); + this.event = Log4jLogEvent.newBuilder().build(); + } + + @Test + void shouldPassFilter() { + FlaggedFilter spy = Mockito.spy(this.filter); + Mockito.when(spy.isLoggingEnabled()).thenReturn(true); + Assertions.assertEquals(Filter.Result.NEUTRAL, spy.filter(this.event)); + } + + + @Test + void shouldNotPassFilter() { + FlaggedFilter spy = Mockito.spy(this.filter); + Mockito.when(spy.isLoggingEnabled()).thenReturn(false); + Assertions.assertEquals(Filter.Result.DENY, spy.filter(this.event)); + } +} diff --git a/logviewer/build.gradle b/logviewer/build.gradle index 0884229b88..2086804732 100644 --- a/logviewer/build.gradle +++ b/logviewer/build.gradle @@ -1,6 +1,6 @@ plugins { id "com.google.cloud.tools.jib" version "3.1.4" - id "org.springframework.boot" version "2.5.5" + id "org.springframework.boot" version "2.6.1" id "io.spring.dependency-management" version "1.0.11.RELEASE" id "com.vaadin" version "21.0.2" id 'java' @@ -36,35 +36,47 @@ jooq { } dependencies { - compileOnly 'org.apache.logging.log4j:log4j-api:2.15.0' - runtimeOnly 'org.apache.logging.log4j:log4j-jul:2.15.0' - runtimeOnly 'org.apache.logging.log4j:log4j-slf4j-impl:2.15.0' + implementation('org.apache.logging.log4j:log4j-api') { + version { + require '2.16.0' + } + because 'Log4Shell happened' + } + runtimeOnly('org.apache.logging.log4j:log4j-core') { + version { + require '2.16.0' + } + because 'Log4Shell happened' + } + + runtimeOnly 'org.apache.logging.log4j:log4j-jul:2.16.0' + runtimeOnly 'org.apache.logging.log4j:log4j-slf4j-impl:2.16.0' implementation(project(":database")) implementation 'org.jooq:jooq:3.15.3' implementation 'com.vaadin:vaadin-core:21.0.2' - implementation ('com.vaadin:vaadin-spring:18.0.0') + implementation('com.vaadin:vaadin-spring:18.0.0') implementation 'org.vaadin.artur:a-vaadin-helper:1.7.2' implementation 'org.vaadin.crudui:crudui:4.6.0' implementation 'com.vaadin.componentfactory:enhanced-dialog:21.0.0' - implementation ('org.springframework.boot:spring-boot-starter-web:2.5.5'){ + implementation('org.springframework.boot:spring-boot-starter-web:2.6.1') { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' } - implementation ('org.springframework.boot:spring-boot-starter-security:2.5.5'){ + implementation('org.springframework.boot:spring-boot-starter-security:2.6.1') { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' } - implementation ('org.springframework.boot:spring-boot-starter-oauth2-client:2.5.5'){ + implementation('org.springframework.boot:spring-boot-starter-oauth2-client:2.6.1') { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' } - developmentOnly ('org.springframework.boot:spring-boot-starter-actuator:2.5.5'){ + developmentOnly('org.springframework.boot:spring-boot-starter-actuator:2.6.1') { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' } - developmentOnly ('org.springframework.boot:spring-boot-devtools:2.5.5'){ + developmentOnly('org.springframework.boot:spring-boot-devtools:2.6.1') { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' } } @@ -79,7 +91,7 @@ jib { password = System.getenv('REGISTRY_PASSWORD') ?: '' } } - container{ + container { setPorts(["5050"].asList()) setCreationTime(Instant.now().toString()) }