Skip to content

Commit 0981b2c

Browse files
nltbeenltbee
authored andcommitted
Revert "Revert "Adding /quarantine and /unquarantine (#398)""
This reverts commit 33fb776.
1 parent 33fb776 commit 0981b2c

13 files changed

+570
-102
lines changed

application/config.json.template

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,6 @@
2121
"channelPattern": "tj_suggestions",
2222
"upVoteEmoteName": "peepo_yes",
2323
"downVoteEmoteName": "peepo_no"
24-
}
24+
},
25+
"quarantinedRolePattern": "Quarantined"
2526
}

application/src/main/java/org/togetherjava/tjbot/commands/Features.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public enum Features {
6969
features.add(new SuggestionsUpDownVoter(config));
7070

7171
// Event receivers
72-
features.add(new RejoinMuteListener(actionsStore, config));
72+
features.add(new RejoinModerationRoleListener(actionsStore, config));
7373

7474
// Slash commands
7575
features.add(new PingCommand());
@@ -89,6 +89,8 @@ public enum Features {
8989
features.add(new RoleSelectCommand());
9090
features.add(new NoteCommand(actionsStore, config));
9191
features.add(new RemindCommand(database));
92+
features.add(new QuarantineCommand(actionsStore, config));
93+
features.add(new UnquarantineCommand(actionsStore, config));
9294

9395
// Mixtures
9496
features.add(new FreeCommand(config));

application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationAction.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ public enum ModerationAction {
3030
* When a user unmutes another user.
3131
*/
3232
UNMUTE("unmuted"),
33+
/**
34+
* When a user quarantines another user.
35+
*/
36+
QUARANTINE("quarantined"),
37+
/**
38+
* When a user unquarantines another user.
39+
*/
40+
UNQUARANTINE("unquarantined"),
3341
/**
3442
* When a user writes a note about another user.
3543
*/

application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,32 @@ public static Predicate<String> getIsMutedRolePredicate(@NotNull Config config)
348348
return guild.getRoles().stream().filter(role -> isMutedRole.test(role.getName())).findAny();
349349
}
350350

351+
/**
352+
* Gets a predicate that identifies the role used to quarantine a member in a guild.
353+
*
354+
* @param config the config used to identify the quarantined role
355+
* @return predicate that matches the name of the quarantined role
356+
*/
357+
public static Predicate<String> getIsQuarantinedRolePredicate(@NotNull Config config) {
358+
return Pattern.compile(config.getQuarantinedRolePattern()).asMatchPredicate();
359+
}
360+
361+
/**
362+
* Gets the role used to quarantine a member in a guild.
363+
*
364+
* @param guild the guild to get the quarantined role from
365+
* @param config the config used to identify the quarantined role
366+
* @return the quarantined role, if found
367+
*/
368+
public static @NotNull Optional<Role> getQuarantinedRole(@NotNull Guild guild,
369+
@NotNull Config config) {
370+
Predicate<String> isQuarantinedRole = getIsQuarantinedRolePredicate(config);
371+
return guild.getRoles()
372+
.stream()
373+
.filter(role -> isQuarantinedRole.test(role.getName()))
374+
.findAny();
375+
}
376+
351377
/**
352378
* Computes a temporary data wrapper representing the action with the given duration.
353379
*

application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ private void muteUserFlow(@NotNull Member target, @NotNull Member author,
129129
@NotNull Guild guild, @NotNull SlashCommandEvent event) {
130130
sendDm(target, temporaryData, reason, guild, event)
131131
.flatMap(hasSentDm -> muteUser(target, author, temporaryData, reason, guild)
132-
.map(banResult -> hasSentDm))
132+
.map(result -> hasSentDm))
133133
.map(hasSentDm -> sendFeedback(hasSentDm, target, author, temporaryData, reason))
134134
.flatMap(event::replyEmbeds)
135135
.queue();
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package org.togetherjava.tjbot.commands.moderation;
2+
3+
import net.dv8tion.jda.api.entities.*;
4+
import net.dv8tion.jda.api.events.GenericEvent;
5+
import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
6+
import net.dv8tion.jda.api.interactions.Interaction;
7+
import net.dv8tion.jda.api.interactions.commands.OptionType;
8+
import net.dv8tion.jda.api.requests.RestAction;
9+
import net.dv8tion.jda.api.requests.restaction.AuditableRestAction;
10+
import net.dv8tion.jda.api.utils.Result;
11+
import org.jetbrains.annotations.NotNull;
12+
import org.jetbrains.annotations.Nullable;
13+
import org.slf4j.Logger;
14+
import org.slf4j.LoggerFactory;
15+
import org.togetherjava.tjbot.commands.SlashCommandAdapter;
16+
import org.togetherjava.tjbot.commands.SlashCommandVisibility;
17+
import org.togetherjava.tjbot.config.Config;
18+
19+
import java.util.Objects;
20+
import java.util.function.Predicate;
21+
import java.util.regex.Pattern;
22+
23+
/**
24+
* This command can quarantine users. Quarantining can also be paired with a reason. The command
25+
* will also try to DM the user to inform them about the action and the reason.
26+
* <p>
27+
* The command fails if the user triggering it is lacking permissions to either quarantine other
28+
* users or to quarantine the specific given user (for example a moderator attempting to quarantine
29+
* an admin).
30+
*/
31+
public final class QuarantineCommand extends SlashCommandAdapter {
32+
private static final Logger logger = LoggerFactory.getLogger(QuarantineCommand.class);
33+
private static final String TARGET_OPTION = "user";
34+
private static final String REASON_OPTION = "reason";
35+
private static final String COMMAND_NAME = "quarantine";
36+
private static final String ACTION_VERB = "quarantine";
37+
private final Predicate<String> hasRequiredRole;
38+
private final ModerationActionsStore actionsStore;
39+
private final Config config;
40+
41+
/**
42+
* Constructs an instance.
43+
*
44+
* @param actionsStore used to store actions issued by this command
45+
* @param config the config to use for this
46+
*/
47+
public QuarantineCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) {
48+
super(COMMAND_NAME,
49+
"Puts the given user under quarantine. They can not interact with anyone anymore then.",
50+
SlashCommandVisibility.GUILD);
51+
52+
getData()
53+
.addOption(OptionType.USER, TARGET_OPTION, "The user who you want to quarantine", true)
54+
.addOption(OptionType.STRING, REASON_OPTION, "Why the user should be quarantined",
55+
true);
56+
57+
this.config = config;
58+
hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate();
59+
this.actionsStore = Objects.requireNonNull(actionsStore);
60+
}
61+
62+
private static void handleAlreadyQuarantinedTarget(@NotNull Interaction event) {
63+
event.reply("The user is already quarantined.").setEphemeral(true).queue();
64+
}
65+
66+
private static RestAction<Boolean> sendDm(@NotNull ISnowflake target, @NotNull String reason,
67+
@NotNull Guild guild, @NotNull GenericEvent event) {
68+
String dmMessage =
69+
"""
70+
Hey there, sorry to tell you but unfortunately you have been put under quarantine in the server %s.
71+
This means you can no longer interact with anyone in the server until you have been unquarantined again.
72+
If you think this was a mistake, or the reason no longer applies, please contact a moderator or admin of the server.
73+
The reason for the quarantine is: %s
74+
"""
75+
.formatted(guild.getName(), reason);
76+
77+
return event.getJDA()
78+
.openPrivateChannelById(target.getIdLong())
79+
.flatMap(channel -> channel.sendMessage(dmMessage))
80+
.mapToResult()
81+
.map(Result::isSuccess);
82+
}
83+
84+
private static @NotNull MessageEmbed sendFeedback(boolean hasSentDm, @NotNull Member target,
85+
@NotNull Member author, @NotNull String reason) {
86+
String dmNoticeText = "";
87+
if (!hasSentDm) {
88+
dmNoticeText = "\n(Unable to send them a DM.)";
89+
}
90+
return ModerationUtils.createActionResponse(author.getUser(), ModerationAction.QUARANTINE,
91+
target.getUser(), dmNoticeText, reason);
92+
}
93+
94+
private AuditableRestAction<Void> quarantineUser(@NotNull Member target, @NotNull Member author,
95+
@NotNull String reason, @NotNull Guild guild) {
96+
logger.info("'{}' ({}) quarantined the user '{}' ({}) in guild '{}' for reason '{}'.",
97+
author.getUser().getAsTag(), author.getId(), target.getUser().getAsTag(),
98+
target.getId(), guild.getName(), reason);
99+
100+
actionsStore.addAction(guild.getIdLong(), author.getIdLong(), target.getIdLong(),
101+
ModerationAction.QUARANTINE, null, reason);
102+
103+
return guild
104+
.addRoleToMember(target,
105+
ModerationUtils.getQuarantinedRole(guild, config).orElseThrow())
106+
.reason(reason);
107+
}
108+
109+
private void quarantineUserFlow(@NotNull Member target, @NotNull Member author,
110+
@NotNull String reason, @NotNull Guild guild, @NotNull SlashCommandEvent event) {
111+
sendDm(target, reason, guild, event)
112+
.flatMap(hasSentDm -> quarantineUser(target, author, reason, guild)
113+
.map(result -> hasSentDm))
114+
.map(hasSentDm -> sendFeedback(hasSentDm, target, author, reason))
115+
.flatMap(event::replyEmbeds)
116+
.queue();
117+
}
118+
119+
@SuppressWarnings({"BooleanMethodNameMustStartWithQuestion", "MethodWithTooManyParameters"})
120+
private boolean handleChecks(@NotNull Member bot, @NotNull Member author,
121+
@Nullable Member target, @NotNull CharSequence reason, @NotNull Guild guild,
122+
@NotNull Interaction event) {
123+
if (!ModerationUtils.handleRoleChangeChecks(
124+
ModerationUtils.getQuarantinedRole(guild, config).orElse(null), ACTION_VERB, target,
125+
bot, author, guild, hasRequiredRole, reason, event)) {
126+
return false;
127+
}
128+
129+
if (Objects.requireNonNull(target)
130+
.getRoles()
131+
.stream()
132+
.map(Role::getName)
133+
.anyMatch(ModerationUtils.getIsQuarantinedRolePredicate(config))) {
134+
handleAlreadyQuarantinedTarget(event);
135+
return false;
136+
}
137+
138+
return true;
139+
}
140+
141+
@Override
142+
public void onSlashCommand(@NotNull SlashCommandEvent event) {
143+
Member target = event.getOption(TARGET_OPTION).getAsMember();
144+
Member author = event.getMember();
145+
String reason = event.getOption(REASON_OPTION).getAsString();
146+
147+
Guild guild = Objects.requireNonNull(event.getGuild());
148+
Member bot = guild.getSelfMember();
149+
150+
if (!handleChecks(bot, author, target, reason, guild, event)) {
151+
return;
152+
}
153+
154+
quarantineUserFlow(Objects.requireNonNull(target), author, reason, guild, event);
155+
}
156+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package org.togetherjava.tjbot.commands.moderation;
2+
3+
import net.dv8tion.jda.api.entities.Guild;
4+
import net.dv8tion.jda.api.entities.IPermissionHolder;
5+
import net.dv8tion.jda.api.entities.Member;
6+
import net.dv8tion.jda.api.entities.Role;
7+
import net.dv8tion.jda.api.events.GenericEvent;
8+
import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent;
9+
import org.jetbrains.annotations.NotNull;
10+
import org.slf4j.Logger;
11+
import org.slf4j.LoggerFactory;
12+
import org.togetherjava.tjbot.commands.EventReceiver;
13+
import org.togetherjava.tjbot.config.Config;
14+
15+
import java.time.Instant;
16+
import java.util.List;
17+
import java.util.Optional;
18+
import java.util.function.Function;
19+
20+
/**
21+
* Reapplies existing moderation roles, such as mute or quarantine, to users who have left and
22+
* rejoined a guild.
23+
* <p>
24+
* Such actions are realized with roles and roles are removed upon leaving a guild, making it
25+
* possible for users to otherwise bypass a mute by simply leaving and rejoining a guild. This class
26+
* listens for join events and reapplies these roles in case the user is supposed to be e.g. muted
27+
* still (according to the {@link ModerationActionsStore}).
28+
*/
29+
public final class RejoinModerationRoleListener implements EventReceiver {
30+
private static final Logger logger =
31+
LoggerFactory.getLogger(RejoinModerationRoleListener.class);
32+
33+
private final ModerationActionsStore actionsStore;
34+
private final List<ModerationRole> moderationRoles;
35+
36+
/**
37+
* Constructs an instance.
38+
*
39+
* @param actionsStore used to store actions issued by this command and to retrieve whether a
40+
* user should be e.g. muted
41+
* @param config the config to use for this
42+
*/
43+
public RejoinModerationRoleListener(@NotNull ModerationActionsStore actionsStore,
44+
@NotNull Config config) {
45+
this.actionsStore = actionsStore;
46+
47+
moderationRoles = List.of(
48+
new ModerationRole("mute", ModerationAction.MUTE, ModerationAction.UNMUTE,
49+
guild -> ModerationUtils.getMutedRole(guild, config).orElseThrow()),
50+
new ModerationRole("quarantine", ModerationAction.QUARANTINE,
51+
ModerationAction.UNQUARANTINE,
52+
guild -> ModerationUtils.getQuarantinedRole(guild, config).orElseThrow()));
53+
}
54+
55+
@Override
56+
public void onEvent(@NotNull GenericEvent event) {
57+
if (event instanceof GuildMemberJoinEvent joinEvent) {
58+
onGuildMemberJoin(joinEvent);
59+
}
60+
}
61+
62+
private void onGuildMemberJoin(@NotNull GuildMemberJoinEvent event) {
63+
Member member = event.getMember();
64+
65+
for (ModerationRole moderationRole : moderationRoles) {
66+
if (shouldApplyModerationRole(moderationRole, member)) {
67+
applyModerationRole(moderationRole, member);
68+
}
69+
}
70+
}
71+
72+
private boolean shouldApplyModerationRole(@NotNull ModerationRole moderationRole,
73+
@NotNull IPermissionHolder member) {
74+
Optional<ActionRecord> lastApplyAction = actionsStore.findLastActionAgainstTargetByType(
75+
member.getGuild().getIdLong(), member.getIdLong(), moderationRole.applyAction);
76+
if (lastApplyAction.isEmpty()) {
77+
// User was never e.g. muted
78+
return false;
79+
}
80+
81+
Optional<ActionRecord> lastRevokeAction = actionsStore.findLastActionAgainstTargetByType(
82+
member.getGuild().getIdLong(), member.getIdLong(), moderationRole.revokeAction);
83+
if (lastRevokeAction.isEmpty()) {
84+
// User was never e.g. unmuted
85+
return isActionEffective(lastApplyAction.orElseThrow());
86+
}
87+
88+
// The last issued action takes priority
89+
if (lastApplyAction.orElseThrow()
90+
.issuedAt()
91+
.isAfter(lastRevokeAction.orElseThrow().issuedAt())) {
92+
return isActionEffective(lastApplyAction.orElseThrow());
93+
}
94+
return false;
95+
}
96+
97+
private static boolean isActionEffective(@NotNull ActionRecord action) {
98+
// Effective if permanent or expires in the future
99+
return action.actionExpiresAt() == null || action.actionExpiresAt().isAfter(Instant.now());
100+
}
101+
102+
private static void applyModerationRole(@NotNull ModerationRole moderationRole,
103+
@NotNull Member member) {
104+
Guild guild = member.getGuild();
105+
logger.info("Reapplied existing {} to user '{}' ({}) in guild '{}' after rejoining.",
106+
moderationRole.actionName, member.getUser().getAsTag(), member.getId(),
107+
guild.getName());
108+
109+
guild.addRoleToMember(member, moderationRole.guildToRole.apply(guild))
110+
.reason("Reapplied existing %s after rejoining the server"
111+
.formatted(moderationRole.actionName))
112+
.queue();
113+
}
114+
115+
private record ModerationRole(@NotNull String actionName, @NotNull ModerationAction applyAction,
116+
@NotNull ModerationAction revokeAction, @NotNull Function<Guild, Role> guildToRole) {
117+
}
118+
}

0 commit comments

Comments
 (0)