diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/ThisIsScamCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/ThisIsScamCommand.java index 3180b61c57..9becd612d6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/ThisIsScamCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/ThisIsScamCommand.java @@ -14,9 +14,9 @@ import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; import net.dv8tion.jda.api.exceptions.ErrorHandler; +import net.dv8tion.jda.api.interactions.InteractionHook; import net.dv8tion.jda.api.interactions.commands.build.Commands; import net.dv8tion.jda.api.interactions.components.buttons.Button; -import net.dv8tion.jda.api.interactions.components.buttons.ButtonStyle; import net.dv8tion.jda.api.requests.ErrorResponse; import net.dv8tion.jda.api.requests.RestAction; import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; @@ -49,19 +49,29 @@ import java.util.stream.Collectors; /** - * Allows users to report a message as potential scam. Moderators can confirm the report from the - * audit log, causing the author to be quarantined plus message history getting deleted. + * Allows users to alert moderators about a message that violates the rules. Moderators then decide + * how to handle it: + * + * The ban and unban only serves to delete the messages, the user is free to join back afterward. */ public final class ThisIsScamCommand extends BotCommandAdapter implements MessageContextCommand { private static final Logger logger = LoggerFactory.getLogger(ThisIsScamCommand.class); - private static final String COMMAND_NAME = "this-is-scam"; + private static final String COMMAND_NAME = "this-is-against-rules"; - private static final String ACTION_TITLE = "Quarantine"; - private static final String ACTION_REASON = "Message was reported and confirmed as scam"; + private static final String SCAM_REASON = "Message was reported and confirmed as scam"; + private static final String JOB_AD_REASON = + "Message was reported and confirmed as a job advertisement"; private static final String FAILED_MESSAGE = - "Sorry, there was an issue forwarding your scam report to the moderators. We are investigating."; + "Sorry, there was an issue forwarding your report to the moderators. We are investigating."; + private static final Duration USER_COMMAND_COOLDOWN = Duration.ofMinutes(1); private final Config config; @@ -78,8 +88,8 @@ public final class ThisIsScamCommand extends BotCommandAdapter implements Messag /** * Creates a new instance. * - * @param config to resolve the moderation audit log channel and quarantined role - * @param actionsStore used to store issued quarantine actions + * @param config resolve the mod channel + * @param actionsStore used to store issued moderative actions */ public ThisIsScamCommand(Config config, ModerationActionsStore actionsStore) { super(Commands.message(COMMAND_NAME), CommandVisibility.GUILD); @@ -98,16 +108,18 @@ public void onMessageContext(MessageContextInteractionEvent event) { return; } - Optional modAuditLog = findModMailChannel(event); - if (modAuditLog.isEmpty()) { + Message message = event.getTarget(); + Optional modChannel = findModChannel(event); + if (modChannel.isEmpty()) { + reportedMessageToTimestamp.invalidate(message.getIdLong()); event.reply(FAILED_MESSAGE).setEphemeral(true).queue(); return; } - Message message = event.getTarget(); - reportToMods(message, modAuditLog.orElseThrow()).mapToResult().map(result -> { + reportToMods(message, modChannel.orElseThrow()).mapToResult().map(result -> { if (result.isFailure()) { - logger.warn("Unable to forward a scam report to the mod audit log channel.", + reportedMessageToTimestamp.invalidate(message.getIdLong()); + logger.warn("Unable to forward a report to the mod mail channel.", result.getFailure()); return FAILED_MESSAGE; } @@ -130,18 +142,14 @@ private boolean handleIsOnCooldown(MessageContextInteractionEvent event) { return false; } - event.reply("You just reported a message as scam, please wait a bit.") - .setEphemeral(true) - .queue(); + event.reply("You just reported a message, please wait a bit.").setEphemeral(true).queue(); return true; } private boolean handleWasAlreadyReportedMessage(MessageContextInteractionEvent event) { long messageId = event.getTarget().getIdLong(); if (reportedMessageToTimestamp.getIfPresent(messageId) != null) { - event.reply("This message was already reported as potential scam.") - .setEphemeral(true) - .queue(); + event.reply("This message was already reported.").setEphemeral(true).queue(); return true; } @@ -149,24 +157,24 @@ private boolean handleWasAlreadyReportedMessage(MessageContextInteractionEvent e return false; } - private Optional findModMailChannel(MessageContextInteractionEvent event) { + private Optional findModChannel(MessageContextInteractionEvent event) { Guild guild = Objects.requireNonNull(event.getGuild()); Optional modMailChannel = Guilds.findTextChannel(guild, isModMailChannel); if (modMailChannel.isEmpty()) { logger.warn( - "Cannot find the designated mod audit log channel in guild '{}' with the pattern '{}'", - guild.getId(), config.getModAuditLogChannelPattern()); + "Cannot find the designated mod mail channel in guild '{}' with the pattern '{}'", + guild.getId(), config.getModMailChannelPattern()); } return modMailChannel; } - private MessageCreateAction reportToMods(Message message, TextChannel auditChannel) { + private MessageCreateAction reportToMods(Message message, TextChannel modMailChannel) { User author = message.getAuthor(); String description = createDescription(message); long accountAgeDays = ChronoUnit.DAYS.between(author.getTimeCreated(), OffsetDateTime.now(ZoneOffset.UTC)); - MessageEmbed reportEmbed = new EmbedBuilder().setTitle("Is this Scam?") + MessageEmbed reportEmbed = new EmbedBuilder().setTitle("Rule Violation") .setDescription( MessageUtils.abbreviate(description, MessageEmbed.DESCRIPTION_MAX_LENGTH)) .setAuthor(author.getName(), null, author.getEffectiveAvatarUrl()) @@ -175,13 +183,19 @@ private MessageCreateAction reportToMods(Message message, TextChannel auditChann .setFooter("%s - account age %d days".formatted(author.getId(), accountAgeDays)) .build(); - long guildId = message.getGuild().getIdLong(); - long authorId = author.getIdLong(); - String[] args = {String.valueOf(guildId), String.valueOf(authorId)}; - - return auditChannel.sendMessageEmbeds(reportEmbed) - .addActionRow(Button.success(generateComponentId(args), "Yes"), - Button.danger(generateComponentId(args), "No")); + String guildId = String.valueOf(message.getGuild().getIdLong()); + String authorId = String.valueOf(author.getIdLong()); + + return modMailChannel.sendMessageEmbeds(reportEmbed) + .addActionRow( + Button.success(generateComponentId(guildId, authorId, ReportAction.SCAM.name()), + "Scam"), + Button.success( + generateComponentId(guildId, authorId, ReportAction.JOB_AD.name()), + "Job-Ad"), + Button.danger( + generateComponentId(guildId, authorId, ReportAction.DISMISS.name()), + "Dismiss")); } private static String createDescription(Message target) { @@ -209,29 +223,20 @@ private static String createDescription(Message target) { public void onButtonClick(ButtonInteractionEvent event, List args) { long guildId = Long.parseLong(args.get(0)); long targetId = Long.parseLong(args.get(1)); + ReportAction action = ReportAction.valueOf(args.get(2)); - ButtonStyle clickedStyle = event.getButton().getStyle(); - boolean isScam = clickedStyle == ButtonStyle.SUCCESS; - - MessageEmbed resultEmbed = new EmbedBuilder() - .setDescription( - isScam ? "This is scam. The user was quarantined and messages were deleted." - : "This is not scam, no action executed.") - .setColor(isScam ? Color.GREEN : Color.RED) - .build(); - - List embeds = new ArrayList<>(event.getMessage().getEmbeds()); - embeds.add(resultEmbed); - - event.editMessageEmbeds(embeds).setComponents().queue(); + Member moderator = Objects.requireNonNull(event.getMember()); + List existingEmbeds = new ArrayList<>(event.getMessage().getEmbeds()); - if (!isScam) { + if (action == ReportAction.DISMISS) { + event + .editMessageEmbeds(createResponseEmbeds(existingEmbeds, + "Dismissed, no action executed.", Color.RED)) + .setComponents() + .queue(); return; } - Guild guild = Objects.requireNonNull(event.getJDA().getGuildById(guildId)); - Member moderator = Objects.requireNonNull(event.getMember()); - ErrorHandler errorHandler = new ErrorHandler() .handle(ErrorResponse.UNKNOWN_USER, failure -> logger.debug(LogMarkers.SENSITIVE, "Attempted to handle user-reported scam, but user '{}' does not exist anymore.", @@ -240,41 +245,76 @@ public void onButtonClick(ButtonInteractionEvent event, List args) { "Attempted to handle user-reported scam, but user '{}' is not a member of guild '{}' anymore.", targetId, guildId)); + Guild guild = Objects.requireNonNull(event.getJDA().getGuildById(guildId)); + guild.retrieveMemberById(targetId) - .queue(target -> handleConfirmedScam(guild, target, moderator, event), errorHandler); + .queue(target -> handleConfirmedViolation(event, action, guild, moderator, target, + existingEmbeds), errorHandler); } - private void handleConfirmedScam(Guild guild, Member target, Member moderator, - ButtonInteractionEvent event) { - if (!handleCanQuarantineAndBan(guild, target, event)) { + private void handleConfirmedViolation(ButtonInteractionEvent event, ReportAction action, + Guild guild, Member moderator, Member target, List alertEmbeds) { + + Member bot = guild.getSelfMember(); + + boolean canExecute = switch (action) { + case SCAM -> { + Role quarantinedRole = + ModerationUtils.getQuarantinedRole(guild, config).orElse(null); + yield ModerationUtils.handleRoleChangeChecks(quarantinedRole, "quarantine", target, + bot, moderator, guild, SCAM_REASON, event); + } + case JOB_AD -> + ModerationUtils.handleCanInteractWithTarget("ban", bot, moderator, target, event); + default -> false; + }; + + canExecute &= ModerationUtils.handleHasBotPermissions("ban", Permission.BAN_MEMBERS, bot, + guild, event); + + if (!canExecute) { + logger.warn(LogMarkers.SENSITIVE, + "Moderator '{}' tried to execute report for user '{}' in guild '{}', but checks failed.", + moderator.getId(), target.getId(), guild.getId()); return; } - Consumer onSuccess = _ -> { + String successText; + RestAction handling = switch (action) { + case SCAM -> { + successText = "This is scam. The user was quarantined and messages were deleted."; + yield quarantineAndRemove(guild, target, moderator); + } + case JOB_AD -> { + successText = "This is a job-ad. The user was warned and messages were deleted."; + yield warnAndRemove(guild, target, moderator); + } + default -> throw new IllegalStateException("Unknown action provided: " + action); }; - Consumer onFailure = failure -> logger.warn(LogMarkers.SENSITIVE, - "Failed to finish user-reported scam handling for user '{}' in guild '{}'.", - target.getId(), guild.getId(), failure); - - sendQuarantineDm(target.getUser(), guild) - .flatMap(hasSentDm -> quarantineUser(guild, target, moderator)) - .flatMap(_ -> deleteMessagesByBanAndUnban(guild, target.getUser())) - .queue(onSuccess, onFailure); - } - - private boolean handleCanQuarantineAndBan(Guild guild, Member target, - ButtonInteractionEvent event) { - Member bot = guild.getSelfMember(); - Member moderator = Objects.requireNonNull(event.getMember()); - Role quarantinedRole = ModerationUtils.getQuarantinedRole(guild, config).orElse(null); - return ModerationUtils.handleRoleChangeChecks(quarantinedRole, "quarantine", target, bot, - moderator, guild, ACTION_REASON, event) - && ModerationUtils.handleHasBotPermissions("ban", Permission.BAN_MEMBERS, bot, - guild, event); + String pendingText = "%s is handling this alert...".formatted(moderator.getAsMention()); + + event.editMessageEmbeds(createResponseEmbeds(alertEmbeds, pendingText, Color.ORANGE)) + .setComponents() + .queue(hook -> { + Consumer onSuccess = ignored -> showResult(hook, alertEmbeds, + "%s (handled by %s)".formatted(successText, moderator.getAsMention()), + Color.GREEN); + + Consumer onFailure = failure -> { + logger.warn(LogMarkers.SENSITIVE, + "Failed to handle alert for user '{}' in guild '{}'.", target.getId(), + guild.getId(), failure); + showResult(hook, alertEmbeds, + "Handling this alert failed, please check the logs and handle the user manually.", + Color.RED); + }; + + handling.queue(onSuccess, onFailure); + }); } - private RestAction sendQuarantineDm(User target, Guild guild) { + private RestAction quarantineAndRemove(Guild guild, Member target, Member moderator) { String description = """ Hey there, sorry to tell you but unfortunately you have been put under quarantine after sending scam. @@ -284,28 +324,71 @@ private RestAction sendQuarantineDm(User target, Guild guild) { After you regained control of your account make sure you secure it properly, for example by using 2FA. You can then join back our server and contact the mods to get unquarantined."""; - return ModerationUtils.sendModActionDm(ModerationUtils.getModActionEmbed(guild, - ACTION_TITLE, description, ACTION_REASON, true), target); + return sendDm(guild, target.getUser(), "Quarantine", description, SCAM_REASON) + .flatMap(_ -> { + recordAction(guild, target, moderator, ModerationAction.QUARANTINE, SCAM_REASON); + return guild + .addRoleToMember(target, + ModerationUtils.getQuarantinedRole(guild, config).orElseThrow()) + .reason(SCAM_REASON); + }) + .flatMap(_ -> deleteMessagesByBanThenUnban(guild, target.getUser(), SCAM_REASON)); } - private RestAction quarantineUser(Guild guild, Member target, Member moderator) { - logger.info(LogMarkers.SENSITIVE, - "'{}' ({}) quarantined the user '{}' ({}) in guild '{}' for reason '{}'.", - moderator.getUser().getName(), moderator.getId(), target.getUser().getName(), - target.getId(), guild.getName(), ACTION_REASON); + private RestAction warnAndRemove(Guild guild, Member target, Member moderator) { + String description = + """ + Hey there, sorry to tell you but unfortunately you have been warned in the server for posting a job advertisement. + To delete your messages you were briefly removed from the server, you are welcome to join back."""; + + return sendDm(guild, target.getUser(), "Warning", description, JOB_AD_REASON).flatMap(_ -> { + recordAction(guild, target, moderator, ModerationAction.WARN, JOB_AD_REASON); + return deleteMessagesByBanThenUnban(guild, target.getUser(), JOB_AD_REASON); + }).flatMap(_ -> deleteMessagesByBanThenUnban(guild, target.getUser(), JOB_AD_REASON)); + } - actionsStore.addAction(guild.getIdLong(), moderator.getIdLong(), target.getIdLong(), - ModerationAction.QUARANTINE, null, ACTION_REASON); + private static RestAction sendDm(Guild guild, User target, String title, + String description, String reason) { + return ModerationUtils.sendModActionDm( + ModerationUtils.getModActionEmbed(guild, title, description, reason, true), target); + } + + private void recordAction(Guild guild, Member target, Member moderator, ModerationAction action, + String reason) { + logger.info(LogMarkers.SENSITIVE, + "'{}' ({}) executed '{}' against the user '{}' ({}) in guild '{}' for reason '{}'.", + moderator.getUser().getName(), moderator.getId(), action, + target.getUser().getName(), target.getId(), guild.getName(), reason); - return guild - .addRoleToMember(target, - ModerationUtils.getQuarantinedRole(guild, config).orElseThrow()) - .reason(ACTION_REASON); + actionsStore.addAction(guild.getIdLong(), moderator.getIdLong(), target.getIdLong(), action, + null, reason); } - private RestAction deleteMessagesByBanAndUnban(Guild guild, User target) { + private static RestAction deleteMessagesByBanThenUnban(Guild guild, User target, + String reason) { return guild.ban(target, 1, TimeUnit.DAYS) - .reason(ACTION_REASON) - .flatMap(_ -> guild.unban(target).reason(ACTION_REASON)); + .reason(reason) + .flatMap(_ -> guild.unban(target).reason(reason)); + } + + private static List createResponseEmbeds(List stagedEmbeds, + String text, Color color) { + MessageEmbed resultEmbed = new EmbedBuilder().setDescription(text).setColor(color).build(); + + List embeds = new ArrayList<>(stagedEmbeds); + embeds.add(resultEmbed); + + return embeds; + } + + private static void showResult(InteractionHook hook, List alertEmbeds, + String text, Color color) { + hook.editOriginalEmbeds(createResponseEmbeds(alertEmbeds, text, color)).queue(); + } + + private enum ReportAction { + SCAM, + JOB_AD, + DISMISS } }