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 extends E> elements) {
+ private static @NotNull List prependElement(@NotNull E element,
+ @NotNull Collection extends E> 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 super String> 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