Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion application/config.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@
"channelPattern": "tj_suggestions",
"upVoteEmoteName": "peepo_yes",
"downVoteEmoteName": "peepo_no"
}
},
"quarantinedRolePattern": "Quarantined"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,32 @@ public static Predicate<String> 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<String> 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<Role> getQuarantinedRole(@NotNull Guild guild,
@NotNull Config config) {
Predicate<String> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <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)
.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);
}
}
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) {
}
}
Loading