diff --git a/application/config.json.template b/application/config.json.template index 809c6260e1..39e980ee7e 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -21,5 +21,6 @@ "channelPattern": "tj_suggestions", "upVoteEmoteName": "peepo_yes", "downVoteEmoteName": "peepo_no" - } + }, + "quarantinedRolePattern": "Quarantined" } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java index b371bf6520..fd105e2092 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -70,7 +70,7 @@ public enum Features { features.add(new SuggestionsUpDownVoter(config)); // Event receivers - features.add(new RejoinMuteListener(actionsStore, config)); + features.add(new RejoinModerationRoleListener(actionsStore, config)); // Slash commands features.add(new PingCommand()); @@ -90,6 +90,8 @@ public enum Features { features.add(new RoleSelectCommand()); features.add(new NoteCommand(actionsStore, config)); features.add(new RemindCommand(database)); + features.add(new QuarantineCommand(actionsStore, config)); + features.add(new UnquarantineCommand(actionsStore, config)); // Mixtures features.add(new FreeCommand(config)); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationAction.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationAction.java index 81ab95345a..76121063ef 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationAction.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationAction.java @@ -30,6 +30,14 @@ public enum ModerationAction { * When a user unmutes another user. */ UNMUTE("unmuted"), + /** + * When a user quarantines another user. + */ + QUARANTINE("quarantined"), + /** + * When a user unquarantines another user. + */ + UNQUARANTINE("unquarantined"), /** * When a user writes a note about another user. */ 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 ec4c9bfb3c..44f4be3de6 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 @@ -348,6 +348,32 @@ public static Predicate getIsMutedRolePredicate(@NotNull Config config) return guild.getRoles().stream().filter(role -> isMutedRole.test(role.getName())).findAny(); } + /** + * Gets a predicate that identifies the role used to quarantine a member in a guild. + * + * @param config the config used to identify the quarantined role + * @return predicate that matches the name of the quarantined role + */ + public static Predicate getIsQuarantinedRolePredicate(@NotNull Config config) { + return Pattern.compile(config.getQuarantinedRolePattern()).asMatchPredicate(); + } + + /** + * Gets the role used to quarantine a member in a guild. + * + * @param guild the guild to get the quarantined role from + * @param config the config used to identify the quarantined role + * @return the quarantined role, if found + */ + public static @NotNull Optional getQuarantinedRole(@NotNull Guild guild, + @NotNull Config config) { + Predicate isQuarantinedRole = getIsQuarantinedRolePredicate(config); + return guild.getRoles() + .stream() + .filter(role -> isQuarantinedRole.test(role.getName())) + .findAny(); + } + /** * Computes a temporary data wrapper representing the action with the given duration. * diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java index 31ff2bfb93..bde61f0c59 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java @@ -129,7 +129,7 @@ private void muteUserFlow(@NotNull Member target, @NotNull Member author, @NotNull Guild guild, @NotNull SlashCommandEvent event) { sendDm(target, temporaryData, reason, guild, event) .flatMap(hasSentDm -> muteUser(target, author, temporaryData, reason, guild) - .map(banResult -> hasSentDm)) + .map(result -> hasSentDm)) .map(hasSentDm -> sendFeedback(hasSentDm, target, author, temporaryData, reason)) .flatMap(event::replyEmbeds) .queue(); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/QuarantineCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/QuarantineCommand.java new file mode 100644 index 0000000000..9d655b0440 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/QuarantineCommand.java @@ -0,0 +1,156 @@ +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 quarantine users. Quarantining 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 quarantine other + * users or to quarantine the specific given user (for example a moderator attempting to quarantine + * an admin). + */ +public final class QuarantineCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(QuarantineCommand.class); + private static final String TARGET_OPTION = "user"; + private static final String REASON_OPTION = "reason"; + private static final String COMMAND_NAME = "quarantine"; + private static final String ACTION_VERB = "quarantine"; + private final Predicate hasRequiredRole; + private final ModerationActionsStore actionsStore; + private final Config config; + + /** + * Constructs an instance. + * + * @param actionsStore used to store actions issued by this command + * @param config the config to use for this + */ + public QuarantineCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) { + super(COMMAND_NAME, + "Puts the given user under quarantine. They can not interact with anyone anymore then.", + SlashCommandVisibility.GUILD); + + getData() + .addOption(OptionType.USER, TARGET_OPTION, "The user who you want to quarantine", true) + .addOption(OptionType.STRING, REASON_OPTION, "Why the user should be quarantined", + true); + + this.config = config; + hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate(); + this.actionsStore = Objects.requireNonNull(actionsStore); + } + + private static void handleAlreadyQuarantinedTarget(@NotNull Interaction event) { + event.reply("The user is already quarantined.").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 put under quarantine in the server %s. + This means you can no longer interact with anyone in the server until you have been unquarantined again. + If you think this was a mistake, or the reason no longer applies, please contact a moderator or admin of the server. + The reason for the quarantine is: %s + """ + .formatted(guild.getName(), reason); + + return event.getJDA() + .openPrivateChannelById(target.getIdLong()) + .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 = "\n(Unable to send them a DM.)"; + } + return ModerationUtils.createActionResponse(author.getUser(), ModerationAction.QUARANTINE, + target.getUser(), dmNoticeText, reason); + } + + private AuditableRestAction quarantineUser(@NotNull Member target, @NotNull Member author, + @NotNull String reason, @NotNull Guild guild) { + logger.info("'{}' ({}) quarantined 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(), + ModerationAction.QUARANTINE, null, reason); + + return guild + .addRoleToMember(target, + ModerationUtils.getQuarantinedRole(guild, config).orElseThrow()) + .reason(reason); + } + + private void quarantineUserFlow(@NotNull Member target, @NotNull Member author, + @NotNull String reason, @NotNull Guild guild, @NotNull SlashCommandEvent event) { + sendDm(target, reason, guild, event) + .flatMap(hasSentDm -> quarantineUser(target, author, reason, guild) + .map(result -> 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.getQuarantinedRole(guild, config).orElse(null), ACTION_VERB, target, + bot, author, guild, hasRequiredRole, reason, event)) { + return false; + } + + if (Objects.requireNonNull(target) + .getRoles() + .stream() + .map(Role::getName) + .anyMatch(ModerationUtils.getIsQuarantinedRolePredicate(config))) { + handleAlreadyQuarantinedTarget(event); + return false; + } + + return true; + } + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + Member target = event.getOption(TARGET_OPTION).getAsMember(); + Member author = event.getMember(); + String reason = event.getOption(REASON_OPTION).getAsString(); + + Guild guild = Objects.requireNonNull(event.getGuild()); + Member bot = guild.getSelfMember(); + + if (!handleChecks(bot, author, target, reason, guild, event)) { + return; + } + + quarantineUserFlow(Objects.requireNonNull(target), author, reason, guild, event); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinModerationRoleListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinModerationRoleListener.java new file mode 100644 index 0000000000..d2822c65f5 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinModerationRoleListener.java @@ -0,0 +1,118 @@ +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.entities.Role; +import net.dv8tion.jda.api.events.GenericEvent; +import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.EventReceiver; +import org.togetherjava.tjbot.config.Config; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +/** + * Reapplies existing moderation roles, such as mute or quarantine, to users who have left and + * rejoined a guild. + *

+ * Such actions 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 these roles in case the user is supposed to be e.g. muted + * still (according to the {@link ModerationActionsStore}). + */ +public final class RejoinModerationRoleListener implements EventReceiver { + private static final Logger logger = + LoggerFactory.getLogger(RejoinModerationRoleListener.class); + + private final ModerationActionsStore actionsStore; + private final List moderationRoles; + + /** + * Constructs an instance. + * + * @param actionsStore used to store actions issued by this command and to retrieve whether a + * user should be e.g. muted + * @param config the config to use for this + */ + public RejoinModerationRoleListener(@NotNull ModerationActionsStore actionsStore, + @NotNull Config config) { + this.actionsStore = actionsStore; + + moderationRoles = List.of( + new ModerationRole("mute", ModerationAction.MUTE, ModerationAction.UNMUTE, + guild -> ModerationUtils.getMutedRole(guild, config).orElseThrow()), + new ModerationRole("quarantine", ModerationAction.QUARANTINE, + ModerationAction.UNQUARANTINE, + guild -> ModerationUtils.getQuarantinedRole(guild, config).orElseThrow())); + } + + @Override + public void onEvent(@NotNull GenericEvent event) { + if (event instanceof GuildMemberJoinEvent joinEvent) { + onGuildMemberJoin(joinEvent); + } + } + + private void onGuildMemberJoin(@NotNull GuildMemberJoinEvent event) { + Member member = event.getMember(); + + for (ModerationRole moderationRole : moderationRoles) { + if (shouldApplyModerationRole(moderationRole, member)) { + applyModerationRole(moderationRole, member); + } + } + } + + private boolean shouldApplyModerationRole(@NotNull ModerationRole moderationRole, + @NotNull IPermissionHolder member) { + Optional lastApplyAction = actionsStore.findLastActionAgainstTargetByType( + member.getGuild().getIdLong(), member.getIdLong(), moderationRole.applyAction); + if (lastApplyAction.isEmpty()) { + // User was never e.g. muted + return false; + } + + Optional lastRevokeAction = actionsStore.findLastActionAgainstTargetByType( + member.getGuild().getIdLong(), member.getIdLong(), moderationRole.revokeAction); + if (lastRevokeAction.isEmpty()) { + // User was never e.g. unmuted + return isActionEffective(lastApplyAction.orElseThrow()); + } + + // The last issued action takes priority + if (lastApplyAction.orElseThrow() + .issuedAt() + .isAfter(lastRevokeAction.orElseThrow().issuedAt())) { + return isActionEffective(lastApplyAction.orElseThrow()); + } + return false; + } + + private static boolean isActionEffective(@NotNull ActionRecord action) { + // Effective if permanent or expires in the future + return action.actionExpiresAt() == null || action.actionExpiresAt().isAfter(Instant.now()); + } + + private static void applyModerationRole(@NotNull ModerationRole moderationRole, + @NotNull Member member) { + Guild guild = member.getGuild(); + logger.info("Reapplied existing {} to user '{}' ({}) in guild '{}' after rejoining.", + moderationRole.actionName, member.getUser().getAsTag(), member.getId(), + guild.getName()); + + guild.addRoleToMember(member, moderationRole.guildToRole.apply(guild)) + .reason("Reapplied existing %s after rejoining the server" + .formatted(moderationRole.actionName)) + .queue(); + } + + private record ModerationRole(@NotNull String actionName, @NotNull ModerationAction applyAction, + @NotNull ModerationAction revokeAction, @NotNull Function guildToRole) { + } +} 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 deleted file mode 100644 index 825c01ffa7..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinMuteListener.java +++ /dev/null @@ -1,95 +0,0 @@ -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.GenericEvent; -import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.togetherjava.tjbot.commands.EventReceiver; -import org.togetherjava.tjbot.config.Config; - -import java.time.Instant; -import java.util.Optional; - -/** - * 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 implements EventReceiver { - private static final Logger logger = LoggerFactory.getLogger(RejoinMuteListener.class); - - private final ModerationActionsStore actionsStore; - private final Config config; - - /** - * Constructs an instance. - * - * @param actionsStore used to store actions issued by this command and to retrieve whether a - * user should be muted - * @param config the config to use for this - */ - public RejoinMuteListener(@NotNull ModerationActionsStore actionsStore, - @NotNull Config config) { - this.actionsStore = actionsStore; - this.config = config; - } - - private void muteMember(@NotNull Member member) { - Guild guild = member.getGuild(); - logger.info("Reapplied existing mute to user '{}' ({}) in guild '{}' after rejoining.", - member.getUser().getAsTag(), member.getId(), guild.getName()); - - guild.addRoleToMember(member, ModerationUtils.getMutedRole(guild, config).orElseThrow()) - .reason("Reapplied existing mute after rejoining the server") - .queue(); - } - - private static boolean isActionEffective(@NotNull ActionRecord action) { - // Effective if permanent or expires in the future - return action.actionExpiresAt() == null || action.actionExpiresAt().isAfter(Instant.now()); - } - - @Override - public void onEvent(@NotNull GenericEvent event) { - if (event instanceof GuildMemberJoinEvent joinEvent) { - onGuildMemberJoin(joinEvent); - } - } - - private void onGuildMemberJoin(@NotNull GuildMemberJoinEvent event) { - Member member = event.getMember(); - if (!shouldMemberBeMuted(member)) { - return; - } - muteMember(member); - } - - private boolean shouldMemberBeMuted(@NotNull IPermissionHolder member) { - Optional lastMute = actionsStore.findLastActionAgainstTargetByType( - member.getGuild().getIdLong(), member.getIdLong(), ModerationAction.MUTE); - if (lastMute.isEmpty()) { - // User was never muted - return false; - } - - Optional lastUnmute = actionsStore.findLastActionAgainstTargetByType( - member.getGuild().getIdLong(), member.getIdLong(), ModerationAction.UNMUTE); - if (lastUnmute.isEmpty()) { - // User was never unmuted - return isActionEffective(lastMute.orElseThrow()); - } - - // The last issued action takes priority - if (lastMute.orElseThrow().issuedAt().isAfter(lastUnmute.orElseThrow().issuedAt())) { - return isActionEffective(lastMute.orElseThrow()); - } - return false; - } -} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java index 751b92b213..dde79efa3a 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java @@ -101,8 +101,8 @@ private AuditableRestAction unmuteUser(@NotNull Member target, @NotNull Me 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)) + .flatMap( + hasSentDm -> unmuteUser(target, author, reason, guild).map(result -> hasSentDm)) .map(hasSentDm -> sendFeedback(hasSentDm, target, author, reason)) .flatMap(event::replyEmbeds) .queue(); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnquarantineCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnquarantineCommand.java new file mode 100644 index 0000000000..0da00e5527 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnquarantineCommand.java @@ -0,0 +1,154 @@ +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 unquarantine quarantined users. Unquarantining 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 unquarantine other + * users or to unquarantine the specific given user (for example a moderator attempting to + * unquarantine an admin). + */ +public final class UnquarantineCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(UnquarantineCommand.class); + private static final String TARGET_OPTION = "user"; + private static final String REASON_OPTION = "reason"; + private static final String COMMAND_NAME = "unquarantine"; + private static final String ACTION_VERB = "unquarantine"; + private final Predicate hasRequiredRole; + private final ModerationActionsStore actionsStore; + private final Config config; + + /** + * Constructs an instance. + * + * @param actionsStore used to store actions issued by this command + * @param config the config to use for this + */ + public UnquarantineCommand(@NotNull ModerationActionsStore actionsStore, + @NotNull Config config) { + super(COMMAND_NAME, + "Unquarantines the given already quarantined user so that they can interact again", + SlashCommandVisibility.GUILD); + + getData() + .addOption(OptionType.USER, TARGET_OPTION, "The user who you want to unquarantine", + true) + .addOption(OptionType.STRING, REASON_OPTION, "Why the user should be unquarantined", + true); + + this.config = config; + hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate(); + this.actionsStore = Objects.requireNonNull(actionsStore); + } + + private static void handleNotQuarantinedTarget(@NotNull Interaction event) { + event.reply("The user is not quarantined.").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 put out of quarantine in the server %s. + This means you can now interact with others in the server again. + The reason for the unquarantine is: %s + """.formatted(guild.getName(), reason); + + return event.getJDA() + .openPrivateChannelById(target.getIdLong()) + .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(), ModerationAction.UNQUARANTINE, + target.getUser(), dmNoticeText, reason); + } + + private AuditableRestAction unquarantineUser(@NotNull Member target, + @NotNull Member author, @NotNull String reason, @NotNull Guild guild) { + logger.info("'{}' ({}) unquarantined 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(), + ModerationAction.UNQUARANTINE, null, reason); + + return guild + .removeRoleFromMember(target, + ModerationUtils.getQuarantinedRole(guild, config).orElseThrow()) + .reason(reason); + } + + private void unquarantineUserFlow(@NotNull Member target, @NotNull Member author, + @NotNull String reason, @NotNull Guild guild, @NotNull SlashCommandEvent event) { + sendDm(target, reason, guild, event) + .flatMap(hasSentDm -> unquarantineUser(target, author, reason, guild) + .map(result -> 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.getQuarantinedRole(guild, config).orElse(null), ACTION_VERB, target, + bot, author, guild, hasRequiredRole, reason, event)) { + return false; + } + + if (Objects.requireNonNull(target) + .getRoles() + .stream() + .map(Role::getName) + .noneMatch(ModerationUtils.getIsQuarantinedRolePredicate(config))) { + handleNotQuarantinedTarget(event); + return false; + } + + return true; + } + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + Member target = event.getOption(TARGET_OPTION).getAsMember(); + Member author = event.getMember(); + String reason = event.getOption(REASON_OPTION).getAsString(); + + Guild guild = Objects.requireNonNull(event.getGuild()); + Member bot = guild.getSelfMember(); + + if (!handleChecks(bot, author, target, reason, guild, event)) { + return; + } + unquarantineUserFlow(Objects.requireNonNull(target), author, reason, guild, event); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java index a193069d1e..26671c196e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java @@ -49,7 +49,9 @@ public TemporaryModerationRoutine(@NotNull JDA jda, this.actionsStore = actionsStore; this.jda = jda; - typeToRevocableAction = Stream.of(new TemporaryBanAction(), new TemporaryMuteAction(config)) + typeToRevocableAction = Stream + .of(new TemporaryBanAction(), new TemporaryMuteAction(config), + new TemporaryQuarantineAction(config)) .collect( Collectors.toMap(RevocableModerationAction::getApplyType, Function.identity())); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryQuarantineAction.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryQuarantineAction.java new file mode 100644 index 0000000000..dd87658551 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryQuarantineAction.java @@ -0,0 +1,84 @@ +package org.togetherjava.tjbot.commands.moderation.temp; + +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.exceptions.ErrorResponseException; +import net.dv8tion.jda.api.requests.ErrorResponse; +import net.dv8tion.jda.api.requests.RestAction; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.moderation.ModerationAction; +import org.togetherjava.tjbot.commands.moderation.ModerationUtils; +import org.togetherjava.tjbot.config.Config; + +/** + * Action to revoke temporary quarantines, as applied by + * {@link org.togetherjava.tjbot.commands.moderation.QuarantineCommand} and executed by + * {@link TemporaryModerationRoutine}. + */ +final class TemporaryQuarantineAction implements RevocableModerationAction { + private static final Logger logger = LoggerFactory.getLogger(TemporaryQuarantineAction.class); + private final Config config; + + /** + * Creates a new instance of a temporary quarantine action. + * + * @param config the config to use to identify the quarantined role + */ + TemporaryQuarantineAction(@NotNull Config config) { + this.config = config; + } + + @Override + public @NotNull ModerationAction getApplyType() { + return ModerationAction.QUARANTINE; + } + + @Override + public @NotNull ModerationAction getRevokeType() { + return ModerationAction.UNQUARANTINE; + } + + @Override + public @NotNull RestAction revokeAction(@NotNull Guild guild, @NotNull User target, + @NotNull String reason) { + return guild + .removeRoleFromMember(target.getIdLong(), + ModerationUtils.getQuarantinedRole(guild, config).orElseThrow()) + .reason(reason); + } + + @Override + public @NotNull FailureIdentification handleRevokeFailure(@NotNull Throwable failure, + long targetId) { + if (failure instanceof ErrorResponseException errorResponseException) { + if (errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_USER) { + logger.debug( + "Attempted to revoke a temporary quarantine but user '{}' does not exist anymore.", + targetId); + return FailureIdentification.KNOWN; + } + + if (errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_MEMBER) { + logger.debug( + "Attempted to revoke a temporary quarantine but user '{}' is not a member of the guild anymore.", + targetId); + return FailureIdentification.KNOWN; + } + + if (errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_ROLE) { + logger.warn( + "Attempted to revoke a temporary quarantine but the quarantine role can not be found."); + return FailureIdentification.KNOWN; + } + + if (errorResponseException.getErrorResponse() == ErrorResponse.MISSING_PERMISSIONS) { + logger.warn( + "Attempted to revoke a temporary quarantine but the bot lacks permission."); + return FailureIdentification.KNOWN; + } + } + return FailureIdentification.UNKNOWN; + } +} 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 0dbd97d39d..6c93a42a6c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -27,6 +27,7 @@ public final class Config { private final List freeCommand; private final String helpChannelPattern; private final SuggestionsConfig suggestions; + private final String quarantinedRolePattern; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -41,7 +42,8 @@ private Config(@JsonProperty("token") String token, @JsonProperty("tagManageRolePattern") String tagManageRolePattern, @JsonProperty("freeCommand") List freeCommand, @JsonProperty("helpChannelPattern") String helpChannelPattern, - @JsonProperty("suggestions") SuggestionsConfig suggestions) { + @JsonProperty("suggestions") SuggestionsConfig suggestions, + @JsonProperty("quarantinedRolePattern") String quarantinedRolePattern) { this.token = token; this.databasePath = databasePath; this.projectWebsite = projectWebsite; @@ -54,6 +56,7 @@ private Config(@JsonProperty("token") String token, this.freeCommand = Collections.unmodifiableList(freeCommand); this.helpChannelPattern = helpChannelPattern; this.suggestions = suggestions; + this.quarantinedRolePattern = quarantinedRolePattern; } /** @@ -181,4 +184,13 @@ public String getHelpChannelPattern() { public SuggestionsConfig getSuggestions() { return suggestions; } + + /** + * Gets the REGEX pattern used to identify the role assigned to quarantined users. + * + * @return the role name pattern + */ + public String getQuarantinedRolePattern() { + return quarantinedRolePattern; + } }