Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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,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)
.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