From f8e27bbfe966a2bcbcb5fb996c7f717e0a4bfae1 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Fri, 19 Jun 2026 14:18:34 +0200 Subject: [PATCH 1/5] Adds "this-is-scam" message context command --- .../togetherjava/tjbot/features/Features.java | 2 + .../moderation/ThisIsScamCommand.java | 302 ++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/moderation/ThisIsScamCommand.java 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..debf068c32 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/ThisIsScamCommand.java @@ -0,0 +1,302 @@ +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.ActionRow; +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 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 componentId = generateComponentId(String.valueOf(guildId), String.valueOf(authorId)); + + return auditChannel.sendMessageEmbeds(reportEmbed) + .addActionRow(Button.success(componentId, "Yes"), Button.danger(componentId, "No")); + } + + 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); + + List