Skip to content

Migrate kick, ban and unban command #244

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 21 commits into from
Nov 16, 2021
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import org.togetherjava.tjbot.commands.basic.PingCommand;
import org.togetherjava.tjbot.commands.basic.VcActivityCommand;
import org.togetherjava.tjbot.commands.mathcommands.TeXCommand;
import org.togetherjava.tjbot.commands.moderation.BanCommand;
import org.togetherjava.tjbot.commands.moderation.KickCommand;
import org.togetherjava.tjbot.commands.moderation.UnbanCommand;
import org.togetherjava.tjbot.commands.tags.TagCommand;
import org.togetherjava.tjbot.commands.tags.TagManageCommand;
import org.togetherjava.tjbot.commands.tags.TagSystem;
Expand Down Expand Up @@ -49,6 +52,9 @@ public enum Commands {
commands.add(new TagManageCommand(tagSystem));
commands.add(new TagsCommand(tagSystem));
commands.add(new VcActivityCommand());
commands.add(new KickCommand());
commands.add(new BanCommand());
commands.add(new UnbanCommand());

return commands;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package org.togetherjava.tjbot.commands.moderation;

import net.dv8tion.jda.api.Permission;
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.exceptions.ErrorResponseException;
import net.dv8tion.jda.api.interactions.Interaction;
import net.dv8tion.jda.api.interactions.InteractionHook;
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.requests.ErrorResponse;
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.Optional;
import java.util.function.Predicate;
import java.util.regex.Pattern;

/**
* This command can ban users and optionally remove their messages from the past days. Banning can
* also be paired with a ban 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 ban other users or
* to ban the specific given user (for example a moderator attempting to ban an admin).
*/
public final class BanCommand extends SlashCommandAdapter {
private static final Logger logger = LoggerFactory.getLogger(BanCommand.class);
private static final String TARGET_OPTION = "user";
private static final String DELETE_HISTORY_OPTION = "delete-history";
private static final String REASON_OPTION = "reason";
private static final String COMMAND_NAME = "ban";
private static final String ACTION_VERB = "ban";
private final Predicate<String> hasRequiredRole;

/**
* Constructs an instance.
*/
public BanCommand() {
super(COMMAND_NAME, "Bans the given user from the server", SlashCommandVisibility.GUILD);

getData().addOption(OptionType.USER, TARGET_OPTION, "The user who you want to ban", true)
.addOption(OptionType.STRING, REASON_OPTION, "Why the user should be banned", true)
.addOptions(new OptionData(OptionType.INTEGER, DELETE_HISTORY_OPTION,
"the amount of days of the message history to delete, none means no messages are deleted.",
true).addChoice("none", 0).addChoice("recent", 1).addChoice("all", 7));

hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern())
.asMatchPredicate();
}

private static RestAction<InteractionHook> handleAlreadyBanned(@NotNull Guild.Ban ban,
@NotNull Interaction event) {
String reason = ban.getReason();
String reasonText =
reason == null || reason.isBlank() ? "" : " (reason: %s)".formatted(reason);

String message = "The user '%s' is already banned%s.".formatted(ban.getUser().getAsTag(),
reasonText);
return event.reply(message).setEphemeral(true);
}

@SuppressWarnings("MethodWithTooManyParameters")
private static RestAction<InteractionHook> banUserFlow(@NotNull User target,
@NotNull Member author, @NotNull String reason, int deleteHistoryDays,
@NotNull Guild guild, @NotNull SlashCommandEvent event) {
return sendDm(target, reason, guild, event)
.flatMap(hasSentDm -> banUser(target, author, reason, deleteHistoryDays, guild)
.map(banResult -> hasSentDm))
.map(hasSentDm -> sendFeedback(hasSentDm, target, author, reason))
.flatMap(event::replyEmbeds);
}

private static RestAction<Boolean> sendDm(@NotNull ISnowflake target, @NotNull String reason,
@NotNull Guild guild, @NotNull GenericEvent event) {
return event.getJDA()
.openPrivateChannelById(target.getId())
.flatMap(channel -> channel.sendMessage(
"""
Hey there, sorry to tell you but unfortunately you have been banned from the server %s.
If you think this was a mistake, please contact a moderator or admin of the server.
The reason for the ban is: %s
"""
.formatted(guild.getName(), reason)))
.mapToResult()
.map(Result::isSuccess);
}

private static AuditableRestAction<Void> banUser(@NotNull User target, @NotNull Member author,
@NotNull String reason, int deleteHistoryDays, @NotNull Guild guild) {
logger.info(
"'{}' ({}) banned the user '{}' ({}) from guild '{}' and deleted their message history of the last {} days, for reason '{}'.",
author.getUser().getAsTag(), author.getId(), target.getAsTag(), target.getId(),
guild.getName(), deleteHistoryDays, reason);

return guild.ban(target, deleteHistoryDays, reason);
}

private static @NotNull MessageEmbed sendFeedback(boolean hasSentDm, @NotNull User target,
@NotNull Member author, @NotNull String reason) {
String dmNoticeText = "";
if (!hasSentDm) {
dmNoticeText = "(Unable to send them a DM.)";
}
return ModerationUtils.createActionResponse(author.getUser(), ModerationUtils.Action.BAN,
target, dmNoticeText, reason);
}

private static Optional<RestAction<InteractionHook>> handleNotAlreadyBannedResponse(
@NotNull Throwable alreadyBannedFailure, @NotNull Interaction event,
@NotNull Guild guild, @NotNull User target) {
if (alreadyBannedFailure instanceof ErrorResponseException errorResponseException) {
if (errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_BAN) {
return Optional.empty();
}

if (errorResponseException.getErrorResponse() == ErrorResponse.MISSING_PERMISSIONS) {
logger.error("The bot does not have the '{}' permission on the guild '{}'.",
Permission.BAN_MEMBERS, guild.getName());
return Optional.of(event.reply(
"I can not ban users in this guild since I do not have the %s permission."
.formatted(Permission.BAN_MEMBERS))
.setEphemeral(true));
}
}
logger.warn("Something unexpected went wrong while trying to ban the user '{}'.",
target.getAsTag(), alreadyBannedFailure);
return Optional.of(event.reply("Failed to ban the user due to an unexpected problem.")
.setEphemeral(true));
}

@SuppressWarnings({"BooleanMethodNameMustStartWithQuestion", "MethodWithTooManyParameters"})
private boolean handleChecks(@NotNull Member bot, @NotNull Member author,
@Nullable Member target, @NotNull CharSequence reason, @NotNull Guild guild,
@NotNull Interaction event) {
// Member doesn't exist if attempting to ban a user who is not part of the guild.
if (target != null && !ModerationUtils.handleCanInteractWithTarget(ACTION_VERB, bot, author,
target, event)) {
return false;
}
if (!ModerationUtils.handleHasAuthorRole(ACTION_VERB, hasRequiredRole, author, event)) {
return false;
}
if (!ModerationUtils.handleHasBotPermissions(ACTION_VERB, Permission.BAN_MEMBERS, bot,
guild, event)) {
return false;
}
if (!ModerationUtils.handleHasAuthorPermissions(ACTION_VERB, Permission.BAN_MEMBERS, author,
guild, event)) {
return false;
}
return ModerationUtils.handleReason(reason, event);
}

@Override
public void onSlashCommand(@NotNull SlashCommandEvent event) {
OptionMapping targetOption =
Objects.requireNonNull(event.getOption(TARGET_OPTION), "The target is null");
User target = targetOption.getAsUser();
Member author = Objects.requireNonNull(event.getMember(), "The author is null");
String reason = Objects.requireNonNull(event.getOption(REASON_OPTION), "The reason is null")
.getAsString();

Guild guild = Objects.requireNonNull(event.getGuild());
Member bot = guild.getSelfMember();

if (!handleChecks(bot, author, targetOption.getAsMember(), reason, guild, event)) {
return;
}

int deleteHistoryDays = Math
.toIntExact(Objects.requireNonNull(event.getOption(DELETE_HISTORY_OPTION)).getAsLong());

// Ban the user, but only if not already banned
guild.retrieveBan(target).mapToResult().flatMap(alreadyBanned -> {
if (alreadyBanned.isSuccess()) {
return handleAlreadyBanned(alreadyBanned.get(), event);
}

return handleNotAlreadyBannedResponse(Objects
.requireNonNull(alreadyBanned.getFailure()), event, guild, target).orElseGet(
() -> banUserFlow(target, author, reason, deleteHistoryDays, guild, event));
}).queue();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package org.togetherjava.tjbot.commands.moderation;

import net.dv8tion.jda.api.Permission;
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 kicks users. Kicking can also be paired with a kick 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 kick other users or
* to kick the specific given user (for example a moderator attempting to kick an admin).
*/
public final class KickCommand extends SlashCommandAdapter {
private static final Logger logger = LoggerFactory.getLogger(KickCommand.class);
private static final String TARGET_OPTION = "user";
private static final String REASON_OPTION = "reason";
private static final String COMMAND_NAME = "kick";
private static final String ACTION_VERB = "kick";
private final Predicate<String> hasRequiredRole;

/**
* Constructs an instance.
*/
public KickCommand() {
super(COMMAND_NAME, "Kicks the given user from the server", SlashCommandVisibility.GUILD);

getData().addOption(OptionType.USER, TARGET_OPTION, "The user who you want to kick", true)
.addOption(OptionType.STRING, REASON_OPTION, "Why the user should be kicked", true);

hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern())
.asMatchPredicate();
}

private static void handleAbsentTarget(@NotNull Interaction event) {
event.reply("I can not kick the given user since they are not part of the guild anymore.")
.setEphemeral(true)
.queue();
}

private static void kickUserFlow(@NotNull Member target, @NotNull Member author,
@NotNull String reason, @NotNull Guild guild, @NotNull SlashCommandEvent event) {
sendDm(target, reason, guild, event)
.flatMap(hasSentDm -> kickUser(target, author, reason, guild)
.map(kickResult -> hasSentDm))
.map(hasSentDm -> sendFeedback(hasSentDm, target, author, reason))
.flatMap(event::replyEmbeds)
.queue();
}

private static RestAction<Boolean> sendDm(@NotNull ISnowflake target, @NotNull String reason,
@NotNull Guild guild, @NotNull GenericEvent event) {
return event.getJDA()
.openPrivateChannelById(target.getId())
.flatMap(channel -> channel.sendMessage(
"""
Hey there, sorry to tell you but unfortunately you have been kicked from the server %s.
If you think this was a mistake, please contact a moderator or admin of the server.
The reason for the kick is: %s
"""
.formatted(guild.getName(), reason)))
.mapToResult()
.map(Result::isSuccess);
}

private static AuditableRestAction<Void> kickUser(@NotNull Member target,
@NotNull Member author, @NotNull String reason, @NotNull Guild guild) {
logger.info("'{}' ({}) kicked the user '{}' ({}) from guild '{}' for reason '{}'.",
author.getUser().getAsTag(), author.getId(), target.getUser().getAsTag(),
target.getId(), guild.getName(), reason);

return guild.kick(target, reason).reason(reason);
}

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(), ModerationUtils.Action.KICK,
target.getUser(), dmNoticeText, reason);
}

@SuppressWarnings({"BooleanMethodNameMustStartWithQuestion", "MethodWithTooManyParameters"})
private boolean handleChecks(@NotNull Member bot, @NotNull Member author,
@Nullable Member target, @NotNull CharSequence reason, @NotNull Guild guild,
@NotNull Interaction event) {
// Member doesn't exist if attempting to kick a user who is not part of the guild anymore.
if (target == null) {
handleAbsentTarget(event);
return false;
}
if (!ModerationUtils.handleCanInteractWithTarget(ACTION_VERB, bot, author, target, event)) {
return false;
}
if (!ModerationUtils.handleHasAuthorRole(ACTION_VERB, hasRequiredRole, author, event)) {
return false;
}
if (!ModerationUtils.handleHasBotPermissions(ACTION_VERB, Permission.KICK_MEMBERS, bot,
guild, event)) {
return false;
}
if (!ModerationUtils.handleHasAuthorPermissions(ACTION_VERB, Permission.KICK_MEMBERS,
author, guild, event)) {
return false;
}
return ModerationUtils.handleReason(reason, event);
}

@Override
public void onSlashCommand(@NotNull SlashCommandEvent event) {
Member target = Objects.requireNonNull(event.getOption(TARGET_OPTION), "The target is null")
.getAsMember();
Member author = Objects.requireNonNull(event.getMember(), "The author is null");
String reason = Objects.requireNonNull(event.getOption(REASON_OPTION), "The reason is null")
.getAsString();

Guild guild = Objects.requireNonNull(event.getGuild());
Member bot = guild.getSelfMember();

if (!handleChecks(bot, author, target, reason, guild, event)) {
return;
}
kickUserFlow(Objects.requireNonNull(target), author, reason, guild, event);
}
}
Loading