diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index bc4e580441..ad852119ac 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -53,6 +53,7 @@ import org.togetherjava.tjbot.features.moderation.QuarantineCommand; import org.togetherjava.tjbot.features.moderation.RejoinModerationRoleListener; import org.togetherjava.tjbot.features.moderation.ReportCommand; +import org.togetherjava.tjbot.features.moderation.ThisIsScamCommand; import org.togetherjava.tjbot.features.moderation.TransferQuestionCommand; import org.togetherjava.tjbot.features.moderation.UnbanCommand; import org.togetherjava.tjbot.features.moderation.UnmuteCommand; @@ -191,6 +192,7 @@ public static Collection createFeatures(JDA jda, Database database, Con // Message context commands features.add(new TransferQuestionCommand(config, chatGptService)); + features.add(new ThisIsScamCommand(config, actionsStore)); // User context commands 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 new file mode 100644 index 0000000000..b68aa438c5 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/ThisIsScamCommand.java @@ -0,0 +1,300 @@ +package org.togetherjava.tjbot.features.moderation; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +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.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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.features.BotCommandAdapter; +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.MessageContextCommand; +import org.togetherjava.tjbot.features.utils.Guilds; +import org.togetherjava.tjbot.features.utils.MessageUtils; +import org.togetherjava.tjbot.logging.LogMarkers; + +import java.awt.Color; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.regex.Pattern; +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. + */ +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 ACTION_TITLE = "Quarantine"; + private static final String ACTION_REASON = "Message was reported and confirmed as scam"; + + private static final String FAILED_MESSAGE = + "Sorry, there was an issue forwarding your scam report to the moderators. We are investigating."; + private static final Duration USER_COMMAND_COOLDOWN = Duration.ofMinutes(1); + private static final Color AMBIENT_COLOR = Color.decode("#CFBFF5"); + + private final Cache reportedMessageToTimestamp = + Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(Duration.ofDays(1)).build(); + private final Cache userToLastCommandUse = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(USER_COMMAND_COOLDOWN) + .build(); + + private final Config config; + private final ModerationActionsStore actionsStore; + private final Predicate isModAuditLogChannel; + + /** + * Creates a new instance. + * + * @param config to resolve the moderation audit log channel and quarantined role + * @param actionsStore used to store issued quarantine actions + */ + public ThisIsScamCommand(Config config, ModerationActionsStore actionsStore) { + super(Commands.message(COMMAND_NAME), CommandVisibility.GUILD); + + this.config = Objects.requireNonNull(config); + this.actionsStore = Objects.requireNonNull(actionsStore); + isModAuditLogChannel = + Pattern.compile(config.getModAuditLogChannelPattern()).asMatchPredicate(); + } + + @Override + public void onMessageContext(MessageContextInteractionEvent event) { + if (handleIsOnCooldown(event)) { + return; + } + if (handleWasAlreadyReportedMessage(event)) { + return; + } + + Optional modAuditLog = getModAuditLogChannel(event); + if (modAuditLog.isEmpty()) { + event.reply(FAILED_MESSAGE).setEphemeral(true).queue(); + return; + } + + Message message = event.getTarget(); + reportToMods(message, modAuditLog.orElseThrow()).mapToResult().map(result -> { + if (result.isFailure()) { + logger.warn("Unable to forward a scam report to the mod audit log channel.", + result.getFailure()); + return FAILED_MESSAGE; + } + return "Thank you for your report, a moderator will take care of it 👍"; + }).flatMap(response -> event.reply(response).setEphemeral(true)).queue(); + } + + private boolean handleIsOnCooldown(MessageContextInteractionEvent event) { + Instant lastCommandUse = userToLastCommandUse.getIfPresent(event.getUser().getIdLong()); + Runnable isNotOnCooldownAction = + () -> userToLastCommandUse.put(event.getUser().getIdLong(), Instant.now()); + + if (lastCommandUse == null) { + isNotOnCooldownAction.run(); + return false; + } + Instant momentCooldownEnds = lastCommandUse.plus(USER_COMMAND_COOLDOWN); + if (Instant.now().isAfter(momentCooldownEnds)) { + isNotOnCooldownAction.run(); + return false; + } + + event.reply("You just reported a message as scam, 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(); + return true; + } + + reportedMessageToTimestamp.put(messageId, Instant.now()); + return false; + } + + private Optional getModAuditLogChannel(MessageContextInteractionEvent event) { + Guild guild = Objects.requireNonNull(event.getGuild()); + Optional modAuditLogChannel = + Guilds.findTextChannel(guild, isModAuditLogChannel); + if (modAuditLogChannel.isEmpty()) { + logger.warn( + "Cannot find the designated mod audit log channel in guild '{}' with the pattern '{}'", + guild.getId(), config.getModAuditLogChannelPattern()); + } + return modAuditLogChannel; + } + + private MessageCreateAction reportToMods(Message message, TextChannel auditChannel) { + User author = message.getAuthor(); + String description = createDescription(message); + + MessageEmbed reportEmbed = new EmbedBuilder().setTitle("Is this Scam?") + .setDescription( + MessageUtils.abbreviate(description, MessageEmbed.DESCRIPTION_MAX_LENGTH)) + .setAuthor(author.getName(), null, author.getEffectiveAvatarUrl()) + .setTimestamp(message.getTimeCreated()) + .setColor(AMBIENT_COLOR) + .setFooter(author.getId()) + .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")); + } + + @SuppressWarnings("squid:S3457") // %n is wrong, markdown must use \n + private static String createDescription(Message target) { + String content = target.getContentStripped(); + String description = content.isBlank() ? "(empty message)" : content; + + List attachments = target.getAttachments(); + if (!attachments.isEmpty()) { + String attachmentInfo = attachments.stream() + .map(Message.Attachment::getFileName) + .collect(Collectors.joining("\n")); + description += "\n\nAttachments:\n" + attachmentInfo; + } + + description += "\n\n[Go to message](%s)".formatted(target.getJumpUrl()); + return description; + } + + @Override + public void onButtonClick(ButtonInteractionEvent event, List args) { + long guildId = Long.parseLong(args.get(0)); + long targetId = Long.parseLong(args.get(1)); + + 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(); + + if (!isScam) { + 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.", + targetId)) + .handle(ErrorResponse.UNKNOWN_MEMBER, failure -> logger.debug(LogMarkers.SENSITIVE, + "Attempted to handle user-reported scam, but user '{}' is not a member of guild '{}' anymore.", + targetId, guildId)); + + guild.retrieveMemberById(targetId) + .queue(target -> handleConfirmedScam(guild, target, moderator, event), errorHandler); + } + + private void handleConfirmedScam(Guild guild, Member target, Member moderator, + ButtonInteractionEvent event) { + if (!handleCanQuarantineAndBan(guild, target, event)) { + return; + } + + Consumer onSuccess = _ -> { + }; + 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); + } + + private RestAction sendQuarantineDm(User target, Guild guild) { + String description = + """ + Hey there, sorry to tell you but unfortunately you have been put under quarantine. + This means you can no longer interact with anyone in the server until you have been unquarantined again."""; + + return ModerationUtils.sendModActionDm(ModerationUtils.getModActionEmbed(guild, + ACTION_TITLE, description, ACTION_REASON, true), target); + } + + 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); + + actionsStore.addAction(guild.getIdLong(), moderator.getIdLong(), target.getIdLong(), + ModerationAction.QUARANTINE, null, ACTION_REASON); + + return guild + .addRoleToMember(target, + ModerationUtils.getQuarantinedRole(guild, config).orElseThrow()) + .reason(ACTION_REASON); + } + + private RestAction deleteMessagesByBanAndUnban(Guild guild, User target) { + return guild.ban(target, 1, TimeUnit.DAYS) + .reason(ACTION_REASON) + .flatMap(_ -> guild.unban(target).reason(ACTION_REASON)); + } +}