-
-
Notifications
You must be signed in to change notification settings - Fork 105
Adding /quarantine and /unquarantine #398
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
153 changes: 153 additions & 0 deletions
153
application/src/main/java/org/togetherjava/tjbot/commands/moderation/QuarantineCommand.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| 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. | ||
| * <p> | ||
| * 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<String> 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<Boolean> 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<Void> 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) | ||
Zabuzard marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .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); | ||
| } | ||
| } | ||
118 changes: 118 additions & 0 deletions
118
...rc/main/java/org/togetherjava/tjbot/commands/moderation/RejoinModerationRoleListener.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| * <p> | ||
| * 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<ModerationRole> 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<ActionRecord> lastApplyAction = actionsStore.findLastActionAgainstTargetByType( | ||
| member.getGuild().getIdLong(), member.getIdLong(), moderationRole.applyAction); | ||
| if (lastApplyAction.isEmpty()) { | ||
| // User was never e.g. muted | ||
| return false; | ||
| } | ||
|
|
||
| Optional<ActionRecord> 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<Guild, Role> guildToRole) { | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.