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 92a777b65b..de704a1f7a 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java
@@ -5,6 +5,9 @@
import org.togetherjava.tjbot.commands.basic.PingCommand;
import org.togetherjava.tjbot.commands.basic.VcActivityCommand;
import org.togetherjava.tjbot.commands.mathcommands.TeXCommand;
+import org.togetherjava.tjbot.commands.moderation.BanCommand;
+import org.togetherjava.tjbot.commands.moderation.KickCommand;
+import org.togetherjava.tjbot.commands.moderation.UnbanCommand;
import org.togetherjava.tjbot.commands.tags.TagCommand;
import org.togetherjava.tjbot.commands.tags.TagManageCommand;
import org.togetherjava.tjbot.commands.tags.TagSystem;
@@ -49,6 +52,9 @@ public enum Commands {
commands.add(new TagManageCommand(tagSystem));
commands.add(new TagsCommand(tagSystem));
commands.add(new VcActivityCommand());
+ commands.add(new KickCommand());
+ commands.add(new BanCommand());
+ commands.add(new UnbanCommand());
return commands;
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java
new file mode 100644
index 0000000000..427eb63ee4
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java
@@ -0,0 +1,196 @@
+package org.togetherjava.tjbot.commands.moderation;
+
+import net.dv8tion.jda.api.Permission;
+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.exceptions.ErrorResponseException;
+import net.dv8tion.jda.api.interactions.Interaction;
+import net.dv8tion.jda.api.interactions.InteractionHook;
+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.requests.ErrorResponse;
+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.Optional;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+/**
+ * This command can ban users and optionally remove their messages from the past days. Banning can
+ * also be paired with a ban 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 ban other users or
+ * to ban the specific given user (for example a moderator attempting to ban an admin).
+ */
+public final class BanCommand extends SlashCommandAdapter {
+ private static final Logger logger = LoggerFactory.getLogger(BanCommand.class);
+ private static final String TARGET_OPTION = "user";
+ private static final String DELETE_HISTORY_OPTION = "delete-history";
+ private static final String REASON_OPTION = "reason";
+ private static final String COMMAND_NAME = "ban";
+ private static final String ACTION_VERB = "ban";
+ private final Predicate hasRequiredRole;
+
+ /**
+ * Constructs an instance.
+ */
+ public BanCommand() {
+ super(COMMAND_NAME, "Bans the given user from the server", SlashCommandVisibility.GUILD);
+
+ getData().addOption(OptionType.USER, TARGET_OPTION, "The user who you want to ban", true)
+ .addOption(OptionType.STRING, REASON_OPTION, "Why the user should be banned", true)
+ .addOptions(new OptionData(OptionType.INTEGER, DELETE_HISTORY_OPTION,
+ "the amount of days of the message history to delete, none means no messages are deleted.",
+ true).addChoice("none", 0).addChoice("recent", 1).addChoice("all", 7));
+
+ hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern())
+ .asMatchPredicate();
+ }
+
+ private static RestAction handleAlreadyBanned(@NotNull Guild.Ban ban,
+ @NotNull Interaction event) {
+ String reason = ban.getReason();
+ String reasonText =
+ reason == null || reason.isBlank() ? "" : " (reason: %s)".formatted(reason);
+
+ String message = "The user '%s' is already banned%s.".formatted(ban.getUser().getAsTag(),
+ reasonText);
+ return event.reply(message).setEphemeral(true);
+ }
+
+ @SuppressWarnings("MethodWithTooManyParameters")
+ private static RestAction banUserFlow(@NotNull User target,
+ @NotNull Member author, @NotNull String reason, int deleteHistoryDays,
+ @NotNull Guild guild, @NotNull SlashCommandEvent event) {
+ return sendDm(target, reason, guild, event)
+ .flatMap(hasSentDm -> banUser(target, author, reason, deleteHistoryDays, guild)
+ .map(banResult -> hasSentDm))
+ .map(hasSentDm -> sendFeedback(hasSentDm, target, author, reason))
+ .flatMap(event::replyEmbeds);
+ }
+
+ private static RestAction sendDm(@NotNull ISnowflake target, @NotNull String reason,
+ @NotNull Guild guild, @NotNull GenericEvent event) {
+ return event.getJDA()
+ .openPrivateChannelById(target.getId())
+ .flatMap(channel -> channel.sendMessage(
+ """
+ Hey there, sorry to tell you but unfortunately you have been banned from the server %s.
+ If you think this was a mistake, please contact a moderator or admin of the server.
+ The reason for the ban is: %s
+ """
+ .formatted(guild.getName(), reason)))
+ .mapToResult()
+ .map(Result::isSuccess);
+ }
+
+ private static AuditableRestAction banUser(@NotNull User target, @NotNull Member author,
+ @NotNull String reason, int deleteHistoryDays, @NotNull Guild guild) {
+ logger.info(
+ "'{}' ({}) banned the user '{}' ({}) from guild '{}' and deleted their message history of the last {} days, for reason '{}'.",
+ author.getUser().getAsTag(), author.getId(), target.getAsTag(), target.getId(),
+ guild.getName(), deleteHistoryDays, reason);
+
+ return guild.ban(target, deleteHistoryDays, reason);
+ }
+
+ private static @NotNull MessageEmbed sendFeedback(boolean hasSentDm, @NotNull User 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.BAN,
+ target, dmNoticeText, reason);
+ }
+
+ private static Optional> handleNotAlreadyBannedResponse(
+ @NotNull Throwable alreadyBannedFailure, @NotNull Interaction event,
+ @NotNull Guild guild, @NotNull User target) {
+ if (alreadyBannedFailure instanceof ErrorResponseException errorResponseException) {
+ if (errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_BAN) {
+ return Optional.empty();
+ }
+
+ if (errorResponseException.getErrorResponse() == ErrorResponse.MISSING_PERMISSIONS) {
+ logger.error("The bot does not have the '{}' permission on the guild '{}'.",
+ Permission.BAN_MEMBERS, guild.getName());
+ return Optional.of(event.reply(
+ "I can not ban users in this guild since I do not have the %s permission."
+ .formatted(Permission.BAN_MEMBERS))
+ .setEphemeral(true));
+ }
+ }
+ logger.warn("Something unexpected went wrong while trying to ban the user '{}'.",
+ target.getAsTag(), alreadyBannedFailure);
+ return Optional.of(event.reply("Failed to ban the user due to an unexpected problem.")
+ .setEphemeral(true));
+ }
+
+ @SuppressWarnings({"BooleanMethodNameMustStartWithQuestion", "MethodWithTooManyParameters"})
+ private boolean handleChecks(@NotNull Member bot, @NotNull Member author,
+ @Nullable Member target, @NotNull CharSequence reason, @NotNull Guild guild,
+ @NotNull Interaction event) {
+ // Member doesn't exist if attempting to ban a user who is not part of the guild.
+ if (target != null && !ModerationUtils.handleCanInteractWithTarget(ACTION_VERB, bot, author,
+ target, event)) {
+ return false;
+ }
+ if (!ModerationUtils.handleHasAuthorRole(ACTION_VERB, hasRequiredRole, author, event)) {
+ return false;
+ }
+ if (!ModerationUtils.handleHasBotPermissions(ACTION_VERB, Permission.BAN_MEMBERS, bot,
+ guild, event)) {
+ return false;
+ }
+ if (!ModerationUtils.handleHasAuthorPermissions(ACTION_VERB, Permission.BAN_MEMBERS, author,
+ guild, event)) {
+ return false;
+ }
+ return ModerationUtils.handleReason(reason, event);
+ }
+
+ @Override
+ public void onSlashCommand(@NotNull SlashCommandEvent event) {
+ OptionMapping targetOption =
+ Objects.requireNonNull(event.getOption(TARGET_OPTION), "The target is null");
+ User target = targetOption.getAsUser();
+ 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, targetOption.getAsMember(), reason, guild, event)) {
+ return;
+ }
+
+ int deleteHistoryDays = Math
+ .toIntExact(Objects.requireNonNull(event.getOption(DELETE_HISTORY_OPTION)).getAsLong());
+
+ // Ban the user, but only if not already banned
+ guild.retrieveBan(target).mapToResult().flatMap(alreadyBanned -> {
+ if (alreadyBanned.isSuccess()) {
+ return handleAlreadyBanned(alreadyBanned.get(), event);
+ }
+
+ return handleNotAlreadyBannedResponse(Objects
+ .requireNonNull(alreadyBanned.getFailure()), event, guild, target).orElseGet(
+ () -> banUserFlow(target, author, reason, deleteHistoryDays, guild, event));
+ }).queue();
+ }
+}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java
new file mode 100644
index 0000000000..27f81c3501
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java
@@ -0,0 +1,145 @@
+package org.togetherjava.tjbot.commands.moderation;
+
+import net.dv8tion.jda.api.Permission;
+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 kicks users. Kicking can also be paired with a kick 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 kick other users or
+ * to kick the specific given user (for example a moderator attempting to kick an admin).
+ */
+public final class KickCommand extends SlashCommandAdapter {
+ private static final Logger logger = LoggerFactory.getLogger(KickCommand.class);
+ private static final String TARGET_OPTION = "user";
+ private static final String REASON_OPTION = "reason";
+ private static final String COMMAND_NAME = "kick";
+ private static final String ACTION_VERB = "kick";
+ private final Predicate hasRequiredRole;
+
+ /**
+ * Constructs an instance.
+ */
+ public KickCommand() {
+ super(COMMAND_NAME, "Kicks the given user from the server", SlashCommandVisibility.GUILD);
+
+ getData().addOption(OptionType.USER, TARGET_OPTION, "The user who you want to kick", true)
+ .addOption(OptionType.STRING, REASON_OPTION, "Why the user should be kicked", true);
+
+ hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern())
+ .asMatchPredicate();
+ }
+
+ private static void handleAbsentTarget(@NotNull Interaction event) {
+ event.reply("I can not kick the given user since they are not part of the guild anymore.")
+ .setEphemeral(true)
+ .queue();
+ }
+
+ private static void kickUserFlow(@NotNull Member target, @NotNull Member author,
+ @NotNull String reason, @NotNull Guild guild, @NotNull SlashCommandEvent event) {
+ sendDm(target, reason, guild, event)
+ .flatMap(hasSentDm -> kickUser(target, author, reason, guild)
+ .map(kickResult -> hasSentDm))
+ .map(hasSentDm -> sendFeedback(hasSentDm, target, author, reason))
+ .flatMap(event::replyEmbeds)
+ .queue();
+ }
+
+ private static RestAction sendDm(@NotNull ISnowflake target, @NotNull String reason,
+ @NotNull Guild guild, @NotNull GenericEvent event) {
+ return event.getJDA()
+ .openPrivateChannelById(target.getId())
+ .flatMap(channel -> channel.sendMessage(
+ """
+ Hey there, sorry to tell you but unfortunately you have been kicked from the server %s.
+ If you think this was a mistake, please contact a moderator or admin of the server.
+ The reason for the kick is: %s
+ """
+ .formatted(guild.getName(), reason)))
+ .mapToResult()
+ .map(Result::isSuccess);
+ }
+
+ private static AuditableRestAction kickUser(@NotNull Member target,
+ @NotNull Member author, @NotNull String reason, @NotNull Guild guild) {
+ logger.info("'{}' ({}) kicked the user '{}' ({}) from guild '{}' for reason '{}'.",
+ author.getUser().getAsTag(), author.getId(), target.getUser().getAsTag(),
+ target.getId(), guild.getName(), reason);
+
+ return guild.kick(target, reason).reason(reason);
+ }
+
+ 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.KICK,
+ target.getUser(), dmNoticeText, reason);
+ }
+
+ @SuppressWarnings({"BooleanMethodNameMustStartWithQuestion", "MethodWithTooManyParameters"})
+ private boolean handleChecks(@NotNull Member bot, @NotNull Member author,
+ @Nullable Member target, @NotNull CharSequence reason, @NotNull Guild guild,
+ @NotNull Interaction event) {
+ // Member doesn't exist if attempting to kick a user who is not part of the guild anymore.
+ if (target == null) {
+ handleAbsentTarget(event);
+ return false;
+ }
+ if (!ModerationUtils.handleCanInteractWithTarget(ACTION_VERB, bot, author, target, event)) {
+ return false;
+ }
+ if (!ModerationUtils.handleHasAuthorRole(ACTION_VERB, hasRequiredRole, author, event)) {
+ return false;
+ }
+ if (!ModerationUtils.handleHasBotPermissions(ACTION_VERB, Permission.KICK_MEMBERS, bot,
+ guild, event)) {
+ return false;
+ }
+ if (!ModerationUtils.handleHasAuthorPermissions(ACTION_VERB, Permission.KICK_MEMBERS,
+ author, guild, event)) {
+ return false;
+ }
+ return ModerationUtils.handleReason(reason, event);
+ }
+
+ @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;
+ }
+ kickUserFlow(Objects.requireNonNull(target), author, reason, guild, event);
+ }
+}
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
new file mode 100644
index 0000000000..4070c3dfa5
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java
@@ -0,0 +1,244 @@
+package org.togetherjava.tjbot.commands.moderation;
+
+import net.dv8tion.jda.api.EmbedBuilder;
+import net.dv8tion.jda.api.Permission;
+import net.dv8tion.jda.api.entities.*;
+import net.dv8tion.jda.api.interactions.Interaction;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.awt.*;
+import java.time.Instant;
+import java.util.function.Predicate;
+
+/**
+ * Utility class offering helpers revolving around user moderation, such as banning or kicking.
+ */
+enum ModerationUtils {
+ ;
+
+ private static final Logger logger = LoggerFactory.getLogger(ModerationUtils.class);
+ /**
+ * The maximal character limit for the reason of an auditable action, see for example
+ * {@link Guild#ban(User, int, String)}.
+ */
+ private static final int REASON_MAX_LENGTH = 512;
+ private static final Color AMBIENT_COLOR = Color.decode("#895FE8");
+
+ /**
+ * Checks whether the given reason is valid. If not, it will handle the situation and respond to
+ * the user.
+ *
+ * @param reason the reason to check
+ * @param event the event used to respond to the user
+ * @return whether the reason is valid
+ */
+ @SuppressWarnings("BooleanMethodNameMustStartWithQuestion")
+ static boolean handleReason(@NotNull CharSequence reason, @NotNull Interaction event) {
+ if (reason.length() <= REASON_MAX_LENGTH) {
+ return true;
+ }
+
+ event
+ .reply("The reason can not be longer than %d characters (current length is %d)."
+ .formatted(REASON_MAX_LENGTH, reason.length()))
+ .setEphemeral(true)
+ .queue();
+ return false;
+ }
+
+ /**
+ * Checks whether the given author and bot can interact with the target user. For example
+ * whether they have enough permissions to ban the user.
+ *
+ * If not, it will handle the situation and respond to the user.
+ *
+ * @param actionVerb the interaction as verb, for example {@code "ban"} or {@code "kick"}
+ * @param bot the bot attempting to interact with the user
+ * @param author the author triggering the command
+ * @param target the target user of the interaction
+ * @param event the event used to respond to the user
+ * @return Whether the author and bot can interact with the target user
+ */
+ @SuppressWarnings("BooleanMethodNameMustStartWithQuestion")
+ static boolean handleCanInteractWithTarget(@NotNull String actionVerb, @NotNull Member bot,
+ @NotNull Member author, @NotNull Member target, @NotNull Interaction event) {
+ String targetTag = target.getUser().getAsTag();
+ if (!author.canInteract(target)) {
+ event
+ .reply("The user %s is too powerful for you to %s.".formatted(targetTag,
+ actionVerb))
+ .setEphemeral(true)
+ .queue();
+ return false;
+ }
+
+ if (!bot.canInteract(target)) {
+ event
+ .reply("The user %s is too powerful for me to %s.".formatted(targetTag, actionVerb))
+ .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.
+ *
+ * If not, it will handle the situation and respond to the user.
+ *
+ * @param actionVerb the interaction as verb, for example {@code "ban"} or {@code "kick"}
+ * @param permission the required permission to check
+ * @param bot the bot attempting to interact with the user
+ * @param event the event used to respond to the user
+ * @return Whether the bot has the required permission
+ */
+ @SuppressWarnings("BooleanMethodNameMustStartWithQuestion")
+ static boolean handleHasBotPermissions(@NotNull String actionVerb,
+ @NotNull Permission permission, @NotNull IPermissionHolder bot, @NotNull Guild guild,
+ @NotNull Interaction event) {
+ if (!bot.hasPermission(permission)) {
+ event
+ .reply("I can not %s users in this guild since I do not have the %s permission."
+ .formatted(actionVerb, permission))
+ .setEphemeral(true)
+ .queue();
+
+ logger.error("The bot does not have the '{}' permission on the guild '{}'.", permission,
+ guild.getName());
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Checks whether the given author has enough permission to execute the given action. For
+ * example whether they have enough permissions to ban users.
+ *
+ * If not, it will handle the situation and respond to the user.
+ *
+ * @param actionVerb the interaction as verb, for example {@code "ban"} or {@code "kick"}
+ * @param permission the required permission to check
+ * @param author the author attempting to interact with the target user
+ * @param event the event used to respond to the user
+ * @return Whether the author has the required permission
+ */
+ @SuppressWarnings("BooleanMethodNameMustStartWithQuestion")
+ static boolean handleHasAuthorPermissions(@NotNull String actionVerb,
+ @NotNull Permission permission, @NotNull IPermissionHolder author, @NotNull Guild guild,
+ @NotNull Interaction event) {
+ if (!author.hasPermission(permission)) {
+ event
+ .reply("You can not %s users in this guild since you do not have the %s permission."
+ .formatted(actionVerb, permission))
+ .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.
+ *
+ * If not, it will handle the situation and respond to the user.
+ *
+ * @param actionVerb the interaction as verb, for example {@code "ban"} or {@code "kick"}
+ * @param hasRequiredRole a predicate used to identify required roles by their name
+ * @param author the author attempting to interact with the target
+ * @param event the event used to respond to the user
+ * @return Whether the bot has the required permission
+ */
+ @SuppressWarnings("BooleanMethodNameMustStartWithQuestion")
+ static boolean handleHasAuthorRole(@NotNull String actionVerb,
+ @NotNull Predicate super String> hasRequiredRole, @NotNull Member author,
+ @NotNull Interaction event) {
+ if (author.getRoles().stream().map(Role::getName).anyMatch(hasRequiredRole)) {
+ return true;
+ }
+ event
+ .reply("You can not %s users in this guild since you do not have the required role."
+ .formatted(actionVerb))
+ .setEphemeral(true)
+ .queue();
+ return false;
+ }
+
+ /**
+ * 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.".
+ *
+ * @param author the author executing the action
+ * @param action the action that is executed
+ * @param target the target of the action
+ * @param extraMessage an optional extra message to be displayed in the response, {@code null}
+ * if not desired
+ * @param reason an optional reason for why the action is executed, {@code null} if not desired
+ * @return the created response
+ */
+ static @NotNull MessageEmbed createActionResponse(@NotNull User author, @NotNull Action action,
+ @NotNull User target, @Nullable String extraMessage, @Nullable String reason) {
+ String description = "%s **%s** (id: %s).".formatted(action.getVerb(), target.getAsTag(),
+ target.getId());
+ if (extraMessage != null && !extraMessage.isBlank()) {
+ description += "\n" + extraMessage;
+ }
+ if (reason != null && !reason.isBlank()) {
+ description += "\n\nReason: " + reason;
+ }
+ return new EmbedBuilder().setAuthor(author.getAsTag(), null, author.getAvatarUrl())
+ .setDescription(description)
+ .setTimestamp(Instant.now())
+ .setColor(AMBIENT_COLOR)
+ .build();
+ }
+
+ /**
+ * All available moderation actions.
+ */
+ enum Action {
+ /**
+ * When a user bans another user.
+ */
+ BAN("banned"),
+ /**
+ * When a user unbans another user.
+ */
+ UNBAN("unbanned"),
+ /**
+ * When a user kicks another user.
+ */
+ KICK("kicked");
+
+ private final String verb;
+
+ /**
+ * Creates an instance with the given verb
+ *
+ * @param verb the verb of the action, as it would be used in a sentence, such as "banned"
+ * or "kicked"
+ */
+ Action(@NotNull String verb) {
+ this.verb = verb;
+ }
+
+ /**
+ * Gets the verb of the action, as it would be used in a sentence.
+ *
+ * Such as "banned" or "kicked"
+ *
+ * @return the verb of this action
+ */
+ @NotNull
+ String getVerb() {
+ return verb;
+ }
+ }
+}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java
new file mode 100644
index 0000000000..a8908e20fe
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java
@@ -0,0 +1,118 @@
+package org.togetherjava.tjbot.commands.moderation;
+
+import net.dv8tion.jda.api.Permission;
+import net.dv8tion.jda.api.entities.*;
+import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+import net.dv8tion.jda.api.exceptions.ErrorResponseException;
+import net.dv8tion.jda.api.interactions.Interaction;
+import net.dv8tion.jda.api.interactions.commands.OptionType;
+import net.dv8tion.jda.api.requests.ErrorResponse;
+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.config.Config;
+
+import java.util.Objects;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+/**
+ * Unbans a given user. Unbanning can also be paired with a reason. The command fails if the user is
+ * currently not banned.
+ */
+public final class UnbanCommand extends SlashCommandAdapter {
+ private static final Logger logger = LoggerFactory.getLogger(UnbanCommand.class);
+ private static final String TARGET_OPTION = "user";
+ private static final String REASON_OPTION = "reason";
+ private static final String COMMAND_NAME = "unban";
+ private static final String ACTION_VERB = "unban";
+ private final Predicate hasRequiredRole;
+
+ /**
+ * Constructs an instance.
+ */
+ public UnbanCommand() {
+ super(COMMAND_NAME, "Unbans the given user from the server", SlashCommandVisibility.GUILD);
+
+ getData()
+ .addOption(OptionType.USER, TARGET_OPTION, "The banned user who you want to unban",
+ true)
+ .addOption(OptionType.STRING, REASON_OPTION, "Why the user should be unbanned", true);
+
+ hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern())
+ .asMatchPredicate();
+ }
+
+ private static void unban(@NotNull User target, @NotNull Member author, @NotNull String reason,
+ @NotNull Guild guild, @NotNull Interaction event) {
+ guild.unban(target).reason(reason).queue(result -> {
+ MessageEmbed message = ModerationUtils.createActionResponse(author.getUser(),
+ ModerationUtils.Action.UNBAN, target, null, reason);
+ event.replyEmbeds(message).queue();
+
+ logger.info("'{}' ({}) unbanned the user '{}' ({}) from guild '{}' for reason '{}'.",
+ author.getUser().getAsTag(), author.getId(), target.getAsTag(), target.getId(),
+ guild.getName(), reason);
+ }, unbanFailure -> handleFailure(unbanFailure, target, event));
+ }
+
+ private static void handleFailure(@NotNull Throwable unbanFailure, @NotNull User target,
+ @NotNull Interaction event) {
+ String targetTag = target.getAsTag();
+ if (unbanFailure instanceof ErrorResponseException errorResponseException) {
+ if (errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_USER) {
+ event.reply("The specified user does not exist.").setEphemeral(true).queue();
+ logger.debug("Unable to unban the user '{}' because they do not exist.", targetTag);
+ return;
+ }
+
+ if (errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_BAN) {
+ event.reply("The specified user is not banned.").setEphemeral(true).queue();
+ logger.debug("Unable to unban the user '{}' because they are not banned.",
+ targetTag);
+ return;
+ }
+ }
+
+ event.reply("Sorry, but something went wrong.").setEphemeral(true).queue();
+ logger.warn("Something unexpected went wrong while trying to unban the user '{}'.",
+ targetTag, unbanFailure);
+ }
+
+ @SuppressWarnings({"BooleanMethodNameMustStartWithQuestion"})
+ private boolean handleChecks(@NotNull IPermissionHolder bot, @NotNull Member author,
+ @NotNull CharSequence reason, @NotNull Guild guild, @NotNull Interaction event) {
+ if (!ModerationUtils.handleHasAuthorRole(ACTION_VERB, hasRequiredRole, author, event)) {
+ return false;
+ }
+ if (!ModerationUtils.handleHasBotPermissions(ACTION_VERB, Permission.BAN_MEMBERS, bot,
+ guild, event)) {
+ return false;
+ }
+ if (!ModerationUtils.handleHasAuthorPermissions(ACTION_VERB, Permission.BAN_MEMBERS, author,
+ guild, event)) {
+ return false;
+ }
+
+ return ModerationUtils.handleReason(reason, event);
+ }
+
+ @Override
+ public void onSlashCommand(@NotNull SlashCommandEvent event) {
+ User target = Objects.requireNonNull(event.getOption(TARGET_OPTION), "The target is null")
+ .getAsUser();
+ 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, reason, guild, event)) {
+ return;
+ }
+ unban(target, author, reason, guild, event);
+ }
+}
diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/package-info.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/package-info.java
new file mode 100644
index 0000000000..399fc1fa1f
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/package-info.java
@@ -0,0 +1,5 @@
+/**
+ * This package offers all the moderation commands from the application such as banning and kicking
+ * users.
+ */
+package org.togetherjava.tjbot.commands.moderation;
diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java
index 90ed73f9bf..3306915aef 100644
--- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java
+++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java
@@ -25,20 +25,27 @@ public final class Config {
private final String discordGuildInvite;
private final String modAuditLogChannelPattern;
private final String mutedRolePattern;
+ private final String heavyModerationRolePattern;
+ private final String softModerationRolePattern;
+ @SuppressWarnings("ConstructorWithTooManyParameters")
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
private Config(@JsonProperty("token") String token,
@JsonProperty("databasePath") String databasePath,
@JsonProperty("projectWebsite") String projectWebsite,
@JsonProperty("discordGuildInvite") String discordGuildInvite,
@JsonProperty("modAuditLogChannelPattern") String modAuditLogChannelPattern,
- @JsonProperty("mutedRolePattern") String mutedRolePattern) {
+ @JsonProperty("mutedRolePattern") String mutedRolePattern,
+ @JsonProperty("heavyModerationRolePattern") String heavyModerationRolePattern,
+ @JsonProperty("softModerationRolePattern") String softModerationRolePattern) {
this.token = token;
this.databasePath = databasePath;
this.projectWebsite = projectWebsite;
this.discordGuildInvite = discordGuildInvite;
this.modAuditLogChannelPattern = modAuditLogChannelPattern;
this.mutedRolePattern = mutedRolePattern;
+ this.heavyModerationRolePattern = heavyModerationRolePattern;
+ this.softModerationRolePattern = softModerationRolePattern;
}
/**
@@ -119,4 +126,24 @@ public String getProjectWebsite() {
public String getDiscordGuildInvite() {
return discordGuildInvite;
}
+
+ /**
+ * Gets the REGEX pattern used to identify roles that are allowed to use heavy moderation
+ * commands, such as banning, based on role names.
+ *
+ * @return the REGEX pattern
+ */
+ public String getHeavyModerationRolePattern() {
+ return heavyModerationRolePattern;
+ }
+
+ /**
+ * Gets the REGEX pattern used to identify roles that are allowed to use soft moderation
+ * commands, such as kicking, muting or message deletion, based on role names.
+ *
+ * @return the REGEX pattern
+ */
+ public String getSoftModerationRolePattern() {
+ return softModerationRolePattern;
+ }
}