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 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; + } }