diff --git a/PP.md b/PP.md index abad2fdbaa..a82deaac35 100644 --- a/PP.md +++ b/PP.md @@ -111,4 +111,4 @@ This policy is not applicable to any information collected by **bot** instances People may get in contact through e-mail at [together.java.tjbot@gmail.com](mailto:together.java.tjbot@gmail.com), or through **Together Java**'s [official Discord](https://discord.com/invite/XXFUXzK). -Other ways of support may be provided but are not guaranteed. \ No newline at end of file +Other ways of support may be provided but are not guaranteed. diff --git a/application/build.gradle b/application/build.gradle index 28e4136531..b9b1f3e3aa 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -60,8 +60,8 @@ dependencies { implementation 'com.github.ben-manes.caffeine:caffeine:3.0.4' testImplementation 'org.mockito:mockito-core:4.0.0' - testRuntimeOnly 'org.mockito:mockito-core:4.0.0' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.1' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' } diff --git a/application/config.json.template b/application/config.json.template index 39e980ee7e..58bb204c19 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -22,5 +22,13 @@ "upVoteEmoteName": "peepo_yes", "downVoteEmoteName": "peepo_no" }, - "quarantinedRolePattern": "Quarantined" + "quarantinedRolePattern": "Quarantined", + "scamBlocker": { + "mode": "AUTO_DELETE_BUT_APPROVE_QUARANTINE", + "reportChannelPattern": "commands", + "hostWhitelist": ["discord.com", "discord.gg", "discord.media", "discordapp.com", "discordapp.net", "discordstatus.com"], + "hostBlacklist": ["bit.ly"], + "suspiciousHostKeywords": ["discord", "nitro", "premium"], + "isHostSimilarToKeywordDistanceThreshold": 2 + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java index 904c831439..095ee74662 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -2,10 +2,16 @@ import net.dv8tion.jda.api.JDA; import org.jetbrains.annotations.NotNull; -import org.togetherjava.tjbot.commands.basic.*; +import org.togetherjava.tjbot.commands.basic.PingCommand; +import org.togetherjava.tjbot.commands.basic.RoleSelectCommand; +import org.togetherjava.tjbot.commands.basic.SuggestionsUpDownVoter; +import org.togetherjava.tjbot.commands.basic.VcActivityCommand; import org.togetherjava.tjbot.commands.free.FreeCommand; import org.togetherjava.tjbot.commands.mathcommands.TeXCommand; import org.togetherjava.tjbot.commands.moderation.*; +import org.togetherjava.tjbot.commands.moderation.scam.ScamBlocker; +import org.togetherjava.tjbot.commands.moderation.scam.ScamHistoryPurgeRoutine; +import org.togetherjava.tjbot.commands.moderation.scam.ScamHistoryStore; import org.togetherjava.tjbot.commands.moderation.temp.TemporaryModerationRoutine; import org.togetherjava.tjbot.commands.reminder.RemindCommand; import org.togetherjava.tjbot.commands.reminder.RemindRoutine; @@ -52,6 +58,7 @@ public enum Features { TagSystem tagSystem = new TagSystem(database); ModerationActionsStore actionsStore = new ModerationActionsStore(database); ModAuditLogWriter modAuditLogWriter = new ModAuditLogWriter(config); + ScamHistoryStore scamHistoryStore = new ScamHistoryStore(database); // NOTE The system can add special system relevant commands also by itself, // hence this list may not necessarily represent the full list of all commands actually @@ -63,10 +70,12 @@ public enum Features { features.add(new TemporaryModerationRoutine(jda, actionsStore, config)); features.add(new TopHelpersPurgeMessagesRoutine(database)); features.add(new RemindRoutine(database)); + features.add(new ScamHistoryPurgeRoutine(scamHistoryStore)); // Message receivers features.add(new TopHelpersMessageListener(database, config)); features.add(new SuggestionsUpDownVoter(config)); + features.add(new ScamBlocker(actionsStore, scamHistoryStore, config)); // Event receivers features.add(new RejoinModerationRoleListener(actionsStore, config)); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommand.java index fd1c2c2c08..20f5c936b7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommand.java @@ -8,7 +8,6 @@ import net.dv8tion.jda.api.interactions.commands.build.Commands; import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; import net.dv8tion.jda.api.interactions.components.ComponentInteraction; -import net.dv8tion.jda.api.interactions.components.buttons.Button; import net.dv8tion.jda.api.interactions.components.buttons.ButtonStyle; import org.jetbrains.annotations.NotNull; import org.togetherjava.tjbot.commands.componentids.ComponentId; @@ -38,20 +37,7 @@ *

* Some example commands are available in {@link org.togetherjava.tjbot.commands.basic}. */ -public interface SlashCommand extends Feature { - - /** - * Gets the name of the command. - *

- * Requirements for this are documented in {@link Commands#slash(String, String)}. - *

- *

- * After registration of the command, the name must not change anymore. - * - * @return the name of the command - */ - @NotNull - String getName(); +public interface SlashCommand extends UserInteractor { /** * Gets the description of the command. @@ -107,9 +93,9 @@ public interface SlashCommand extends Feature { *

* Buttons or menus have to be created with a component ID (see * {@link ComponentInteraction#getComponentId()}, - * {@link Button#of(ButtonStyle, String, Emoji)}}) in a very specific format, otherwise the core - * system will fail to identify the command that corresponded to the button or menu click event - * and is unable to route it back. + * {@link net.dv8tion.jda.api.interactions.components.buttons.Button#of(ButtonStyle, String, Emoji)}) + * in a very specific format, otherwise the core system will fail to identify the command that + * corresponded to the button or menu click event and is unable to route it back. *

* The component ID has to be a UUID-string (see {@link java.util.UUID}), which is associated to * a specific database entry, containing meta information about the command being executed. Such @@ -133,56 +119,4 @@ public interface SlashCommand extends Feature { * @param event the event that triggered this */ void onSlashCommand(@NotNull SlashCommandInteractionEvent event); - - /** - * Triggered by the core system when a button corresponding to this implementation (based on - * {@link #getData()}) has been clicked. - *

- * This method may be called multi-threaded. In particular, there are no guarantees that it will - * be executed on the same thread repeatedly or on the same thread that other event methods have - * been called on. - *

- * Details are available in the given event and the event also enables implementations to - * respond to it. - *

- * This method will be called in a multi-threaded context and the event may not be hold valid - * forever. - * - * @param event the event that triggered this - * @param args the arguments transported with the button, see - * {@link #onSlashCommand(SlashCommandInteractionEvent)} for details on how these are - * created - */ - void onButtonClick(@NotNull ButtonInteractionEvent event, @NotNull List args); - - /** - * Triggered by the core system when a selection menu corresponding to this implementation - * (based on {@link #getData()}) has been clicked. - *

- * This method may be called multi-threaded. In particular, there are no guarantees that it will - * be executed on the same thread repeatedly or on the same thread that other event methods have - * been called on. - *

- * Details are available in the given event and the event also enables implementations to - * respond to it. - *

- * This method will be called in a multi-threaded context and the event may not be hold valid - * forever. - * - * @param event the event that triggered this - * @param args the arguments transported with the selection menu, see - * {@link #onSlashCommand(SlashCommandInteractionEvent)} for details on how these are - * created - */ - void onSelectionMenu(@NotNull SelectMenuInteractionEvent event, @NotNull List args); - - /** - * Triggered by the core system during its setup phase. It will provide the command a component - * id generator through this method, which can be used to generate component ids, as used for - * button or selection menus. See {@link #onSlashCommand(SlashCommandInteractionEvent)} for - * details on how to use this. - * - * @param generator the provided component id generator - */ - void acceptComponentIdGenerator(@NotNull ComponentIdGenerator generator); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/UserInteractor.java b/application/src/main/java/org/togetherjava/tjbot/commands/UserInteractor.java new file mode 100644 index 0000000000..4ad0ed0da2 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/UserInteractor.java @@ -0,0 +1,85 @@ +package org.togetherjava.tjbot.commands; + +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.SelectMenuInteractionEvent; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.componentids.ComponentIdGenerator; + +import java.util.List; + +/** + * Represents a feature that can interact with users. The most important implementation is + * {@link SlashCommand}. + *

+ * An interactor must have a unique name and can react to button clicks and selection menu actions. + */ +public interface UserInteractor extends Feature { + + /** + * Gets the name of the interactor. + *

+ * Requirements for this are documented in + * {@link net.dv8tion.jda.api.interactions.commands.build.Commands#slash(String, String)}. + *

+ *

+ * After registration of the interactor, the name must not change anymore. + * + * @return the name of the interactor + */ + @NotNull + String getName(); + + /** + * Triggered by the core system when a button corresponding to this implementation (based on + * {@link #getName()}) has been clicked. + *

+ * This method may be called multi-threaded. In particular, there are no guarantees that it will + * be executed on the same thread repeatedly or on the same thread that other event methods have + * been called on. + *

+ * Details are available in the given event and the event also enables implementations to + * respond to it. + *

+ * This method will be called in a multi-threaded context and the event may not be hold valid + * forever. + * + * @param event the event that triggered this + * @param args the arguments transported with the button, see + * {@link SlashCommand#onSlashCommand(SlashCommandInteractionEvent)} for details on how + * these are created + */ + void onButtonClick(@NotNull ButtonInteractionEvent event, @NotNull List args); + + /** + * Triggered by the core system when a selection menu corresponding to this implementation + * (based on {@link #getName()}) has been clicked. + *

+ * This method may be called multi-threaded. In particular, there are no guarantees that it will + * be executed on the same thread repeatedly or on the same thread that other event methods have + * been called on. + *

+ * Details are available in the given event and the event also enables implementations to + * respond to it. + *

+ * This method will be called in a multi-threaded context and the event may not be hold valid + * forever. + * + * @param event the event that triggered this + * @param args the arguments transported with the selection menu, see + * {@link SlashCommand#onSlashCommand(SlashCommandInteractionEvent)} for details on how + * these are created + */ + void onSelectionMenu(@NotNull SelectMenuInteractionEvent event, @NotNull List args); + + /** + * Triggered by the core system during its setup phase. It will provide the interactor a + * component id generator through this method, which can be used to generate component ids, as + * used for button or selection menus. See + * {@link SlashCommand#onSlashCommand(SlashCommandInteractionEvent)} for details on how to use + * this. + * + * @param generator the provided component id generator + */ + void acceptComponentIdGenerator(@NotNull ComponentIdGenerator generator); +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentId.java b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentId.java index 4b31487515..49596eec7f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentId.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentId.java @@ -9,9 +9,9 @@ * {@link org.togetherjava.tjbot.commands.SlashCommand#onSlashCommand(SlashCommandInteractionEvent)} * for its usages. * - * @param commandName the name of the command that handles the event associated to this component - * ID, when triggered + * @param userInteractorName the name of the user interactor that handles the event associated to + * this component ID, when triggered * @param elements the additional elements to carry along this component ID, empty if not desired */ -public record ComponentId(@NotNull String commandName, @NotNull List elements) { +public record ComponentId(@NotNull String userInteractorName, @NotNull List elements) { } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdStore.java b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdStore.java index ad4dcde67e..f611d74be7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdStore.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdStore.java @@ -266,8 +266,8 @@ private void evictDatabase() { recordToDelete.delete(); evictedCounter.getAndIncrement(); logger.debug( - "Evicted component id with uuid '{}' from command '{}', last used '{}'", - uuid, componentId.commandName(), lastUsed); + "Evicted component id with uuid '{}' from user interactor '{}', last used '{}'", + uuid, componentId.userInteractorName(), lastUsed); // Remove them from the cache if still in there storeCache.invalidate(uuid); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java index 6ca3a797b8..1f5be8d3ea 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java @@ -4,7 +4,6 @@ import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.components.buttons.Button; -import net.dv8tion.jda.api.interactions.components.buttons.ButtonStyle; import org.jetbrains.annotations.NotNull; import org.scilab.forge.jlatexmath.ParseException; import org.scilab.forge.jlatexmath.TeXConstants; @@ -15,7 +14,8 @@ import org.togetherjava.tjbot.commands.SlashCommandVisibility; import javax.imageio.ImageIO; -import java.awt.*; +import java.awt.Color; +import java.awt.Image; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -88,7 +88,7 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { } event.getHook() .editOriginal(renderedTextImageStream.toByteArray(), "tex.png") - .setActionRow(Button.of(ButtonStyle.DANGER, generateComponentId(userID), "Delete")) + .setActionRow(Button.danger(generateComponentId(userID), "Delete")) .queue(); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamBlocker.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamBlocker.java new file mode 100644 index 0000000000..e4240cedb2 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamBlocker.java @@ -0,0 +1,357 @@ +package org.togetherjava.tjbot.commands.moderation.scam; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.MessageBuilder; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.SelectMenuInteractionEvent; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.exceptions.ErrorHandler; +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 org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.MessageReceiverAdapter; +import org.togetherjava.tjbot.commands.UserInteractor; +import org.togetherjava.tjbot.commands.componentids.ComponentId; +import org.togetherjava.tjbot.commands.componentids.ComponentIdGenerator; +import org.togetherjava.tjbot.commands.componentids.Lifespan; +import org.togetherjava.tjbot.commands.moderation.ModerationAction; +import org.togetherjava.tjbot.commands.moderation.ModerationActionsStore; +import org.togetherjava.tjbot.commands.moderation.ModerationUtils; +import org.togetherjava.tjbot.commands.utils.MessageUtils; +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.ScamBlockerConfig; + +import java.awt.Color; +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Listener that receives all sent messages from channels, checks them for scam and takes + * appropriate action. + *

+ * If scam is detected, depending on the configuration, the blockers actions range from deleting the + * message and banning the author to just logging the message for auditing. + */ +public final class ScamBlocker extends MessageReceiverAdapter implements UserInteractor { + private static final Logger logger = LoggerFactory.getLogger(ScamBlocker.class); + private static final Color AMBIENT_COLOR = Color.decode("#CFBFF5"); + private static final Set MODES_WITH_IMMEDIATE_DELETION = + EnumSet.of(ScamBlockerConfig.Mode.AUTO_DELETE_BUT_APPROVE_QUARANTINE, + ScamBlockerConfig.Mode.AUTO_DELETE_AND_QUARANTINE); + + private final ScamBlockerConfig.Mode mode; + private final String reportChannelPattern; + private final Predicate isReportChannel; + private final ScamDetector scamDetector; + private final Config config; + private final ModerationActionsStore actionsStore; + private final ScamHistoryStore scamHistoryStore; + private final Predicate hasRequiredRole; + + private ComponentIdGenerator componentIdGenerator; + + /** + * Creates a new listener to receive all message sent in any channel. + * + * @param actionsStore to store quarantine actions in + * @param scamHistoryStore to store and retrieve scam history from + * @param config the config to use for this + */ + public ScamBlocker(@NotNull ModerationActionsStore actionsStore, + @NotNull ScamHistoryStore scamHistoryStore, @NotNull Config config) { + super(Pattern.compile(".*")); + + this.actionsStore = actionsStore; + this.scamHistoryStore = scamHistoryStore; + this.config = config; + mode = config.getScamBlocker().getMode(); + scamDetector = new ScamDetector(config); + + reportChannelPattern = config.getScamBlocker().getReportChannelPattern(); + Predicate isReportChannelName = + Pattern.compile(reportChannelPattern).asMatchPredicate(); + isReportChannel = channel -> isReportChannelName.test(channel.getName()); + hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate(); + } + + @Override + public @NotNull String getName() { + return "scam-blocker"; + } + + @Override + public void onSelectionMenu(@NotNull SelectMenuInteractionEvent event, + @NotNull List args) { + throw new UnsupportedOperationException("Not used"); + } + + @Override + public void acceptComponentIdGenerator(@NotNull ComponentIdGenerator generator) { + componentIdGenerator = generator; + } + + @Override + public void onMessageReceived(@NotNull MessageReceivedEvent event) { + if (event.getAuthor().isBot() || event.isWebhookMessage()) { + return; + } + + if (mode == ScamBlockerConfig.Mode.OFF) { + return; + } + + Message message = event.getMessage(); + String content = message.getContentDisplay(); + if (!scamDetector.isScam(content)) { + return; + } + + if (scamHistoryStore.hasRecentScamDuplicate(message)) { + takeActionWasAlreadyReported(event); + return; + } + + takeAction(event); + } + + private void takeActionWasAlreadyReported(@NotNull MessageReceivedEvent event) { + // The user recently send the same scam already, and that was already reported and handled + addScamToHistory(event); + + boolean shouldDeleteMessage = MODES_WITH_IMMEDIATE_DELETION.contains(mode); + if (shouldDeleteMessage) { + deleteMessage(event); + } + } + + private void takeAction(@NotNull MessageReceivedEvent event) { + switch (mode) { + case OFF -> throw new AssertionError( + "The OFF-mode should be detected earlier already to prevent expensive computation"); + case ONLY_LOG -> takeActionLogOnly(event); + case APPROVE_FIRST -> takeActionApproveFirst(event); + case AUTO_DELETE_BUT_APPROVE_QUARANTINE -> takeActionAutoDeleteButApproveQuarantine( + event); + case AUTO_DELETE_AND_QUARANTINE -> takeActionAutoDeleteAndQuarantine(event); + default -> throw new IllegalArgumentException("Mode not supported: " + mode); + } + } + + private void takeActionLogOnly(@NotNull MessageReceivedEvent event) { + addScamToHistory(event); + logScamMessage(event); + } + + private void takeActionApproveFirst(@NotNull MessageReceivedEvent event) { + addScamToHistory(event); + logScamMessage(event); + reportScamMessage(event, "Is this scam?", createConfirmDialog(event)); + } + + private void takeActionAutoDeleteButApproveQuarantine(@NotNull MessageReceivedEvent event) { + addScamToHistory(event); + logScamMessage(event); + deleteMessage(event); + reportScamMessage(event, "Is this scam? (already deleted)", createConfirmDialog(event)); + } + + private void takeActionAutoDeleteAndQuarantine(@NotNull MessageReceivedEvent event) { + addScamToHistory(event); + logScamMessage(event); + deleteMessage(event); + quarantineAuthor(event); + dmUser(event); + reportScamMessage(event, "Detected and handled scam", null); + } + + private void addScamToHistory(@NotNull MessageReceivedEvent event) { + scamHistoryStore.addScam(event.getMessage(), MODES_WITH_IMMEDIATE_DELETION.contains(mode)); + } + + private void logScamMessage(@NotNull MessageReceivedEvent event) { + logger.warn("Detected a scam message ('{}') from user '{}' in channel '{}' of guild '{}'.", + event.getMessageId(), event.getAuthor().getId(), event.getChannel().getId(), + event.getGuild().getId()); + } + + private void deleteMessage(@NotNull MessageReceivedEvent event) { + event.getMessage().delete().queue(); + } + + private void quarantineAuthor(@NotNull MessageReceivedEvent event) { + quarantineAuthor(event.getGuild(), event.getMember(), event.getJDA().getSelfUser()); + } + + private void quarantineAuthor(@NotNull Guild guild, @NotNull Member author, + @NotNull SelfUser bot) { + String reason = "User posted scam that was automatically detected"; + + actionsStore.addAction(guild.getIdLong(), bot.getIdLong(), author.getIdLong(), + ModerationAction.QUARANTINE, null, reason); + + guild + .addRoleToMember(author, + ModerationUtils.getQuarantinedRole(guild, config).orElseThrow()) + .reason(reason) + .queue(); + } + + private void reportScamMessage(@NotNull MessageReceivedEvent event, @NotNull String reportTitle, + @Nullable ActionRow confirmDialog) { + Guild guild = event.getGuild(); + Optional reportChannel = getReportChannel(guild); + if (reportChannel.isEmpty()) { + logger.warn( + "Unable to report a scam message, did not find a report channel matching the configured pattern '{}' for guild '{}'", + reportChannelPattern, guild.getName()); + return; + } + + User author = event.getAuthor(); + MessageEmbed embed = + new EmbedBuilder().setDescription(event.getMessage().getContentStripped()) + .setTitle(reportTitle) + .setAuthor(author.getAsTag(), null, author.getAvatarUrl()) + .setTimestamp(event.getMessage().getTimeCreated()) + .setColor(AMBIENT_COLOR) + .setFooter(author.getId()) + .build(); + Message message = + new MessageBuilder().setEmbeds(embed).setActionRows(confirmDialog).build(); + + reportChannel.orElseThrow().sendMessage(message).queue(); + } + + private void dmUser(@NotNull MessageReceivedEvent event) { + dmUser(event.getGuild(), event.getAuthor().getIdLong(), event.getJDA()); + } + + private void dmUser(@NotNull Guild guild, long userId, @NotNull JDA jda) { + String dmMessage = + """ + Hey there, we detected that you did send scam in the server %s and therefore put you under quarantine. + 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 (for example, your account was hacked, but you got back control over it), + please contact a moderator or admin of the server. + """ + .formatted(guild.getName()); + + jda.openPrivateChannelById(userId) + .flatMap(channel -> channel.sendMessage(dmMessage)) + .queue(); + } + + private @NotNull Optional getReportChannel(@NotNull Guild guild) { + return guild.getTextChannelCache().stream().filter(isReportChannel).findAny(); + } + + private @NotNull ActionRow createConfirmDialog(@NotNull MessageReceivedEvent event) { + ComponentIdArguments args = new ComponentIdArguments(mode, event.getGuild().getIdLong(), + event.getChannel().getIdLong(), event.getMessageIdLong(), + event.getAuthor().getIdLong(), + ScamHistoryStore.hashMessageContent(event.getMessage())); + + return ActionRow.of(Button.success(generateComponentId(args), "Yes"), + Button.danger(generateComponentId(args), "No")); + } + + private @NotNull String generateComponentId(@NotNull ComponentIdArguments args) { + return Objects.requireNonNull(componentIdGenerator) + .generate(new ComponentId(getName(), args.toList()), Lifespan.REGULAR); + } + + @Override + public void onButtonClick(@NotNull ButtonInteractionEvent event, + @NotNull List argsRaw) { + ComponentIdArguments args = ComponentIdArguments.fromList(argsRaw); + if (event.getMember().getRoles().stream().map(Role::getName).noneMatch(hasRequiredRole)) { + event.reply( + "You can not handle scam in this guild, since you do not have the required role.") + .setEphemeral(true) + .queue(); + return; + } + + MessageUtils.disableButtons(event.getMessage()); + event.deferEdit().queue(); + if (event.getButton().getStyle() == ButtonStyle.DANGER) { + logger.info( + "Identified a false-positive scam (id '{}', hash '{}') in guild '{}' sent by author '{}'", + args.messageId, args.contentHash, args.guildId, args.authorId); + return; + } + + Guild guild = event.getJDA().getGuildById(args.guildId); + if (guild == null) { + logger.debug( + "Attempted to handle scam, but the bot is not connected to the guild '{}' anymore, skipping scam handling.", + args.guildId); + return; + } + + Consumer onRetrieveAuthorSuccess = author -> { + quarantineAuthor(guild, author, event.getJDA().getSelfUser()); + dmUser(guild, args.authorId, event.getJDA()); + + // Delete all messages like this + Collection scamMessages = scamHistoryStore + .markScamDuplicatesDeleted(args.guildId, args.authorId, args.contentHash); + + scamMessages.forEach(scamMessage -> { + TextChannel channel = guild.getTextChannelById(scamMessage.channelId()); + if (channel == null) { + logger.debug( + "Attempted to delete scam messages, bot the channel '{}' does not exist anymore, skipping deleting messages for this channel.", + scamMessage.channelId()); + return; + } + + channel.deleteMessageById(scamMessage.messageId()).mapToResult().queue(); + }); + }; + + Consumer onRetrieveAuthorFailure = new ErrorHandler() + .handle(ErrorResponse.UNKNOWN_USER, + failure -> logger.debug( + "Attempted to handle scam, but user '{}' does not exist anymore.", + args.authorId)) + .handle(ErrorResponse.UNKNOWN_MEMBER, failure -> logger.debug( + "Attempted to handle scam, but user '{}' is not a member of the guild anymore.", + args.authorId)); + + guild.retrieveMemberById(args.authorId) + .queue(onRetrieveAuthorSuccess, onRetrieveAuthorFailure); + } + + + private record ComponentIdArguments(@NotNull ScamBlockerConfig.Mode mode, long guildId, + long channelId, long messageId, long authorId, @NotNull String contentHash) { + + static @NotNull ComponentIdArguments fromList(@NotNull List args) { + ScamBlockerConfig.Mode mode = ScamBlockerConfig.Mode.valueOf(args.get(0)); + long guildId = Long.parseLong(args.get(1)); + long channelId = Long.parseLong(args.get(2)); + long messageId = Long.parseLong(args.get(3)); + long authorId = Long.parseLong(args.get(4)); + String contentHash = args.get(5); + return new ComponentIdArguments(mode, guildId, channelId, messageId, authorId, + contentHash); + } + + @NotNull + List toList() { + return List.of(mode.name(), Long.toString(guildId), Long.toString(channelId), + Long.toString(messageId), Long.toString(authorId), contentHash); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamDetector.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamDetector.java new file mode 100644 index 0000000000..15d32d15a9 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamDetector.java @@ -0,0 +1,124 @@ +package org.togetherjava.tjbot.commands.moderation.scam; + +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.utils.StringDistances; +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.ScamBlockerConfig; + +import java.net.URI; +import java.util.regex.Pattern; + +/** + * Detects whether a text message classifies as scam or not, using certain heuristics. + * + * Highly configurable, using {@link ScamBlockerConfig}. Main method to use is + * {@link #isScam(CharSequence)}. + */ +public final class ScamDetector { + private static final Pattern TOKENIZER = Pattern.compile("[\\s,]"); + private final ScamBlockerConfig config; + + /** + * Creates a new instance with the given configuration + * + * @param config the scam blocker config to use + */ + public ScamDetector(@NotNull Config config) { + this.config = config.getScamBlocker(); + } + + /** + * Detects whether the given message classifies as scam or not, using certain heuristics. + * + * @param message the message to analyze + * @return Whether the message classifies as scam + */ + public boolean isScam(@NotNull CharSequence message) { + AnalyseResults results = new AnalyseResults(); + TOKENIZER.splitAsStream(message).forEach(token -> analyzeToken(token, results)); + return isScam(results); + } + + private boolean isScam(@NotNull AnalyseResults results) { + if (results.pingsEveryone && results.containsNitroKeyword && results.hasUrl) { + return true; + } + return results.containsNitroKeyword && results.hasSuspiciousUrl; + } + + private void analyzeToken(@NotNull String token, @NotNull AnalyseResults results) { + if ("@everyone".equalsIgnoreCase(token)) { + results.pingsEveryone = true; + } + + if ("nitro".equalsIgnoreCase(token)) { + results.containsNitroKeyword = true; + } + + if (token.startsWith("http")) { + analyzeUrl(token, results); + } + } + + private void analyzeUrl(@NotNull String url, @NotNull AnalyseResults results) { + String host; + try { + host = URI.create(url).getHost(); + } catch (IllegalArgumentException e) { + // Invalid urls are not scam + return; + } + + if (host == null) { + return; + } + + results.hasUrl = true; + + if (config.getHostWhitelist().contains(host)) { + return; + } + + if (config.getHostBlacklist().contains(host)) { + results.hasSuspiciousUrl = true; + return; + } + + for (String keyword : config.getSuspiciousHostKeywords()) { + if (isHostSimilarToKeyword(host, keyword)) { + results.hasSuspiciousUrl = true; + break; + } + } + } + + private boolean isHostSimilarToKeyword(@NotNull String host, @NotNull String keyword) { + // NOTE This algorithm is far from optimal. + // It is good enough for our purpose though and not that complex. + + // Rolling window of keyword-size over host. + // If any window has a small distance, it is similar + int windowStart = 0; + int windowEnd = keyword.length(); + while (windowEnd <= host.length()) { + String window = host.substring(windowStart, windowEnd); + int distance = StringDistances.editDistance(keyword, window); + + if (distance <= config.getIsHostSimilarToKeywordDistanceThreshold()) { + return true; + } + + windowStart++; + windowEnd++; + } + + return false; + } + + private static class AnalyseResults { + private boolean pingsEveryone; + private boolean containsNitroKeyword; + private boolean hasUrl; + private boolean hasSuspiciousUrl; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamHistoryPurgeRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamHistoryPurgeRoutine.java new file mode 100644 index 0000000000..649f793b88 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamHistoryPurgeRoutine.java @@ -0,0 +1,36 @@ +package org.togetherjava.tjbot.commands.moderation.scam; + +import net.dv8tion.jda.api.JDA; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.Routine; + +import java.time.Instant; +import java.time.Period; +import java.util.concurrent.TimeUnit; + +/** + * Cleanup routine to get rid of old scam history entries in the {@link ScamHistoryStore}. + */ +public final class ScamHistoryPurgeRoutine implements Routine { + private final ScamHistoryStore scamHistoryStore; + private static final Period DELETE_SCAM_RECORDS_AFTER = Period.ofWeeks(2); + + /** + * Creates a new instance. + * + * @param scamHistoryStore containing the scam history to purge + */ + public ScamHistoryPurgeRoutine(@NotNull ScamHistoryStore scamHistoryStore) { + this.scamHistoryStore = scamHistoryStore; + } + + @Override + public @NotNull Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 0, 1, TimeUnit.DAYS); + } + + @Override + public void runRoutine(@NotNull JDA jda) { + scamHistoryStore.deleteHistoryOlderThan(Instant.now().minus(DELETE_SCAM_RECORDS_AFTER)); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamHistoryStore.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamHistoryStore.java new file mode 100644 index 0000000000..3154820dc5 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/ScamHistoryStore.java @@ -0,0 +1,164 @@ +package org.togetherjava.tjbot.commands.moderation.scam; + +import net.dv8tion.jda.api.entities.Message; +import org.jetbrains.annotations.NotNull; +import org.jooq.Result; +import org.togetherjava.tjbot.commands.utils.Hashing; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.records.ScamHistoryRecord; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.Objects; + +import static org.togetherjava.tjbot.db.generated.tables.ScamHistory.SCAM_HISTORY; + +/** + * Store for history of detected scam messages. Can be used to retrieve information about past + * events and further processing and handling of scam. For example, to delete a group of duplicate + * scam messages after a moderator confirmed that it actually is scam and decided for an action. + *

+ * Scam has to be added to the store using {@link #addScam(Message, boolean)} and can then be used + * to determine {@link #hasRecentScamDuplicate(Message)} or for further processing, such as + * {@link #markScamDuplicatesDeleted(Message)}. + *

+ * Entries are only kept for a certain amount of time and will be purged regularly by + * {@link ScamHistoryPurgeRoutine}. + *

+ * The store persists the actions and is thread safe. + */ +public final class ScamHistoryStore { + private final Database database; + private static final Duration RECENT_SCAM_DURATION = Duration.ofMinutes(15); + private static final String HASH_METHOD = "SHA"; + + /** + * Creates a new instance. + * + * @param database containing the scam history to work with + */ + public ScamHistoryStore(@NotNull Database database) { + this.database = database; + } + + /** + * Adds the given scam message to the store. + * + * @param scam the message to add + * @param isDeleted whether the message is already, or about to get, deleted + */ + public void addScam(@NotNull Message scam, boolean isDeleted) { + Objects.requireNonNull(scam); + + database.write(context -> context.newRecord(SCAM_HISTORY) + .setSentAt(scam.getTimeCreated().toInstant()) + .setGuildId(scam.getGuild().getIdLong()) + .setChannelId(scam.getChannel().getIdLong()) + .setMessageId(scam.getIdLong()) + .setAuthorId(scam.getAuthor().getIdLong()) + .setContentHash(hashMessageContent(scam)) + .setIsDeleted(isDeleted) + .insert()); + } + + /** + * Marks all duplicates to the given scam message (i.e. same guild, author, content, ...) as + * deleted. + * + * @param scam the scam message to mark duplicates for + * @return identifications of all scam messages that have just been marked deleted, which + * previously have not been marked accordingly yet + */ + public @NotNull Collection markScamDuplicatesDeleted( + @NotNull Message scam) { + return markScamDuplicatesDeleted(scam.getGuild().getIdLong(), scam.getAuthor().getIdLong(), + hashMessageContent(scam)); + } + + /** + * Marks all duplicates to the given scam message as deleted. + * + * @param guildId the id of the guild to mark duplicates for + * @param authorId the id of the author to mark duplicates for + * @param contentHash a hash identifying the content of the message to mark duplicates for, as + * determined by {@link #hashMessageContent(Message)} + * @return identifications of all scam messages that have just been marked deleted, which + * previously have not been marked accordingly yet + */ + public @NotNull Collection markScamDuplicatesDeleted(long guildId, + long authorId, @NotNull String contentHash) { + return database.writeAndProvide(context -> { + Result undeletedDuplicates = context.selectFrom(SCAM_HISTORY) + .where(SCAM_HISTORY.GUILD_ID.eq(guildId) + .and(SCAM_HISTORY.AUTHOR_ID.eq(authorId)) + .and(SCAM_HISTORY.CONTENT_HASH.eq(contentHash)) + .and(SCAM_HISTORY.IS_DELETED.isFalse())) + .fetch(); + + undeletedDuplicates + .forEach(undeletedDuplicate -> undeletedDuplicate.setIsDeleted(true).update()); + + return undeletedDuplicates.stream().map(ScamIdentification::ofDatabaseRecord).toList(); + }); + } + + /** + * Whether there are recent (a few minutes) duplicates to the given scam message (i.e. same + * guild, author, content, ...). + * + * @param scam the scam message to look for duplicates + * @return whether there are recent duplicates + */ + public boolean hasRecentScamDuplicate(@NotNull Message scam) { + Instant recentScamThreshold = Instant.now().minus(RECENT_SCAM_DURATION); + + return database.read(context -> context.fetchCount(SCAM_HISTORY, + SCAM_HISTORY.SENT_AT.greaterOrEqual(recentScamThreshold) + .and(SCAM_HISTORY.GUILD_ID.eq(scam.getGuild().getIdLong())) + .and(SCAM_HISTORY.AUTHOR_ID.eq(scam.getAuthor().getIdLong())) + .and(SCAM_HISTORY.CONTENT_HASH.eq(hashMessageContent(scam))))) != 0; + } + + /** + * Deletes all scam records from the history, which have been sent earlier than the given time. + * + * @param olderThan all records older than this will be deleted + */ + public void deleteHistoryOlderThan(Instant olderThan) { + database.write(context -> context.deleteFrom(SCAM_HISTORY) + .where(SCAM_HISTORY.SENT_AT.lessOrEqual(olderThan)) + .execute()); + } + + /** + * Hashes the content of the given message to uniquely identify it. + * + * @param message the message to hash + * @return a text representation of the hash + */ + public static @NotNull String hashMessageContent(@NotNull Message message) { + return Hashing.bytesToHex(Hashing.hash(HASH_METHOD, + message.getContentRaw().getBytes(StandardCharsets.UTF_8))); + } + + /** + * Identification of a scam message, consisting mostly of IDs that uniquely identify it. + * + * @param guildId the id of the guild the message was sent in + * @param channelId the id of the channel the message was sent in + * @param messageId the id of the message itself + * @param authorId the id of the author who sent the message + * @param contentHash the unique hash of the message content + */ + public record ScamIdentification(long guildId, long channelId, long messageId, long authorId, + String contentHash) { + private static ScamIdentification ofDatabaseRecord( + @NotNull ScamHistoryRecord scamHistoryRecord) { + return new ScamIdentification(scamHistoryRecord.getGuildId(), + scamHistoryRecord.getChannelId(), scamHistoryRecord.getMessageId(), + scamHistoryRecord.getAuthorId(), scamHistoryRecord.getContentHash()); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/package-info.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/package-info.java new file mode 100644 index 0000000000..40b4605eff --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/scam/package-info.java @@ -0,0 +1,5 @@ +/** + * This package offers classes dealing with detecting scam messages and taking appropriate action, + * see {@link org.togetherjava.tjbot.commands.moderation.scam.ScamBlocker} as main entry point. + */ +package org.togetherjava.tjbot.commands.moderation.scam; diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java b/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java index 55d3dc160b..11705448d8 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java @@ -55,7 +55,7 @@ public final class BotCore extends ListenerAdapter implements SlashCommandProvid private static final ScheduledExecutorService ROUTINE_SERVICE = Executors.newScheduledThreadPool(5); private final Config config; - private final Map nameToSlashCommands; + private final Map nameToInteractor; private final ComponentIdParser componentIdParser; private final ComponentIdStore componentIdStore; private final Map channelNameToMessageReceiver = new HashMap<>(); @@ -104,22 +104,24 @@ public BotCore(@NotNull JDA jda, @NotNull Database database, @NotNull Config con } }); - // Slash commands - nameToSlashCommands = features.stream() - .filter(SlashCommand.class::isInstance) - .map(SlashCommand.class::cast) - .collect(Collectors.toMap(SlashCommand::getName, Function.identity())); + // User Interactors (e.g. slash commands) + nameToInteractor = features.stream() + .filter(UserInteractor.class::isInstance) + .map(UserInteractor.class::cast) + .collect(Collectors.toMap(UserInteractor::getName, Function.identity())); - if (nameToSlashCommands.containsKey(RELOAD_COMMAND)) { + // Reload Command + if (nameToInteractor.containsKey(RELOAD_COMMAND)) { throw new IllegalStateException( - "The 'reload' command is a special reserved command that must not be used by other commands"); + "The 'reload' command is a special reserved command that must not be used by other user interactors"); } - nameToSlashCommands.put(RELOAD_COMMAND, new ReloadCommand(this)); + nameToInteractor.put(RELOAD_COMMAND, new ReloadCommand(this)); + // Component Id Store componentIdStore = new ComponentIdStore(database); componentIdStore.addComponentIdRemovedListener(BotCore::onComponentIdRemoved); componentIdParser = uuid -> componentIdStore.get(UUID.fromString(uuid)); - nameToSlashCommands.values() + nameToInteractor.values() .forEach(slashCommand -> slashCommand .acceptComponentIdGenerator(((componentId, lifespan) -> { UUID uuid = UUID.randomUUID(); @@ -128,18 +130,24 @@ public BotCore(@NotNull JDA jda, @NotNull Database database, @NotNull Config con }))); if (logger.isInfoEnabled()) { - logger.info("Available commands: {}", nameToSlashCommands.keySet()); + logger.info("Available user interactors: {}", nameToInteractor.keySet()); } } @Override public @NotNull Collection getSlashCommands() { - return Collections.unmodifiableCollection(nameToSlashCommands.values()); + return nameToInteractor.values() + .stream() + .filter(SlashCommand.class::isInstance) + .map(SlashCommand.class::cast) + .toList(); } @Override public @NotNull Optional getSlashCommand(@NotNull String name) { - return Optional.ofNullable(nameToSlashCommands.get(name)); + return Optional.ofNullable(nameToInteractor.get(name)) + .filter(SlashCommand.class::isInstance) + .map(SlashCommand.class::cast); } @Override @@ -192,7 +200,8 @@ public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent even public void onButtonInteraction(@NotNull ButtonInteractionEvent event) { logger.debug("Received button click '{}' (#{}) on guild '{}'", event.getComponentId(), event.getId(), event.getGuild()); - COMMAND_SERVICE.execute(() -> forwardComponentCommand(event, SlashCommand::onButtonClick)); + COMMAND_SERVICE + .execute(() -> forwardComponentCommand(event, UserInteractor::onButtonClick)); } @Override @@ -200,7 +209,7 @@ public void onSelectMenuInteraction(@NotNull SelectMenuInteractionEvent event) { logger.debug("Received selection menu event '{}' (#{}) on guild '{}'", event.getComponentId(), event.getId(), event.getGuild()); COMMAND_SERVICE - .execute(() -> forwardComponentCommand(event, SlashCommand::onSelectionMenu)); + .execute(() -> forwardComponentCommand(event, UserInteractor::onSelectionMenu)); } private void registerReloadCommand(@NotNull Guild guild) { @@ -221,32 +230,32 @@ private void registerReloadCommand(@NotNull Guild guild) { } /** - * Forwards the given component event to the associated slash command. + * Forwards the given component event to the associated user interactor. *

*

* An example call might look like: * *

      * {@code
-     * forwardComponentCommand(event, SlashCommand::onSelectionMenu);
+     * forwardComponentCommand(event, UserInteractor::onSelectionMenu);
      * }
      * 
* * @param event the component event that should be forwarded - * @param commandArgumentConsumer the action to trigger on the associated slash command, + * @param interactorArgumentConsumer the action to trigger on the associated user interactor, * providing the event and list of arguments for consumption * @param the type of the component interaction that should be forwarded */ private void forwardComponentCommand(@NotNull T event, - @NotNull TriConsumer> commandArgumentConsumer) { + @NotNull TriConsumer> interactorArgumentConsumer) { Optional componentIdOpt; try { componentIdOpt = componentIdParser.parse(event.getComponentId()); } catch (InvalidComponentIdFormatException e) { logger - .error("Unable to route event (#{}) back to its corresponding slash command. The component ID was in an unexpected format." + .error("Unable to route event (#{}) back to its corresponding user interactor. The component ID was in an unexpected format." + " All button and menu events have to use a component ID created in a specific format" - + " (refer to the documentation of SlashCommand). Component ID was: {}", + + " (refer to the documentation of UserInteractor). Component ID was: {}", event.getId(), event.getComponentId(), e); // Unable to forward, simply fade out the event return; @@ -261,10 +270,10 @@ private void forwardComponentCommand(@NotNull T } ComponentId componentId = componentIdOpt.orElseThrow(); - SlashCommand command = requireSlashCommand(componentId.commandName()); - logger.trace("Routing a component event with id '{}' back to command '{}'", - event.getComponentId(), command.getName()); - commandArgumentConsumer.accept(command, event, componentId.elements()); + UserInteractor interactor = requireUserInteractor(componentId.userInteractorName()); + logger.trace("Routing a component event with id '{}' back to user interactor '{}'", + event.getComponentId(), interactor.getName()); + interactorArgumentConsumer.accept(interactor, event, componentId.elements()); } /** @@ -275,7 +284,19 @@ private void forwardComponentCommand(@NotNull T * @throws NullPointerException if the command with the given name was not registered */ private @NotNull SlashCommand requireSlashCommand(@NotNull String name) { - return Objects.requireNonNull(nameToSlashCommands.get(name)); + return getSlashCommand(name).orElseThrow( + () -> new NullPointerException("There is no slash command with name " + name)); + } + + /** + * Gets the given user interactor by its name and requires that it exists. + * + * @param name the name of the user interactor to get + * @return the user interactor with the given name + * @throws NullPointerException if the user interactor with the given name was not registered + */ + private @NotNull UserInteractor requireUserInteractor(@NotNull String name) { + return Objects.requireNonNull(nameToInteractor.get(name)); } private void handleRegisterErrors(Throwable ex, Guild guild) { diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/system/ReloadCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/system/ReloadCommand.java index cd2c83a223..256faa7d5e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/system/ReloadCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/system/ReloadCommand.java @@ -69,9 +69,8 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { event.reply( "Are you sure? You can only reload commands a few times each day, so do not overdo this.") - .addActionRow( - Button.of(ButtonStyle.SUCCESS, generateComponentId(member.getId()), "Yes"), - Button.of(ButtonStyle.DANGER, generateComponentId(member.getId()), "No")) + .addActionRow(Button.success(generateComponentId(member.getId()), "Yes"), + Button.danger(generateComponentId(member.getId()), "No")) .queue(); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/utils/Hashing.java b/application/src/main/java/org/togetherjava/tjbot/commands/utils/Hashing.java new file mode 100644 index 0000000000..c82ed92bfe --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/Hashing.java @@ -0,0 +1,62 @@ +package org.togetherjava.tjbot.commands.utils; + +import org.jetbrains.annotations.NotNull; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; + +/** + * Utility for hashing data. + */ +public enum Hashing { + ; + + /** + * All characters available in the hexadecimal-system, as UTF-8 encoded array. + */ + private static final byte[] HEX_ARRAY = "0123456789ABCDEF".getBytes(StandardCharsets.UTF_8); + + /** + * Creates a hexadecimal representation of the given binary data. + * + * @param bytes the binary data to convert + * @return a hexadecimal representation + */ + @SuppressWarnings("MagicNumber") + @NotNull + public static String bytesToHex(byte @NotNull [] bytes) { + Objects.requireNonNull(bytes); + // See https://stackoverflow.com/a/9855338/2411243 + // noinspection MultiplyOrDivideByPowerOfTwo + final byte[] hexChars = new byte[bytes.length * 2]; + // noinspection ArrayLengthInLoopCondition + for (int j = 0; j < bytes.length; j++) { + final int v = bytes[j] & 0xFF; + // noinspection MultiplyOrDivideByPowerOfTwo + hexChars[j * 2] = HEX_ARRAY[v >>> 4]; + // noinspection MultiplyOrDivideByPowerOfTwo + hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; + } + return new String(hexChars, StandardCharsets.UTF_8); + } + + /** + * Hashes the given data using the given method. + * + * @param method the method to use for hashing, must be supported by {@link MessageDigest}, e.g. + * {@code "SHA"} + * @param data the data to hash + * @return the computed hash + */ + public static byte @NotNull [] hash(@NotNull String method, byte @NotNull [] data) { + Objects.requireNonNull(method); + Objects.requireNonNull(data); + try { + return MessageDigest.getInstance(method).digest(data); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("Hash method must be supported", e); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index 6c93a42a6c..0581e329cf 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -28,6 +28,7 @@ public final class Config { private final String helpChannelPattern; private final SuggestionsConfig suggestions; private final String quarantinedRolePattern; + private final ScamBlockerConfig scamBlocker; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -43,7 +44,8 @@ private Config(@JsonProperty("token") String token, @JsonProperty("freeCommand") List freeCommand, @JsonProperty("helpChannelPattern") String helpChannelPattern, @JsonProperty("suggestions") SuggestionsConfig suggestions, - @JsonProperty("quarantinedRolePattern") String quarantinedRolePattern) { + @JsonProperty("quarantinedRolePattern") String quarantinedRolePattern, + @JsonProperty("scamBlocker") ScamBlockerConfig scamBlocker) { this.token = token; this.databasePath = databasePath; this.projectWebsite = projectWebsite; @@ -57,6 +59,7 @@ private Config(@JsonProperty("token") String token, this.helpChannelPattern = helpChannelPattern; this.suggestions = suggestions; this.quarantinedRolePattern = quarantinedRolePattern; + this.scamBlocker = scamBlocker; } /** @@ -172,7 +175,7 @@ public String getTagManageRolePattern() { * * @return the channel name pattern */ - public String getHelpChannelPattern() { + public @NotNull String getHelpChannelPattern() { return helpChannelPattern; } @@ -181,7 +184,7 @@ public String getHelpChannelPattern() { * * @return the suggestion system config */ - public SuggestionsConfig getSuggestions() { + public @NotNull SuggestionsConfig getSuggestions() { return suggestions; } @@ -193,4 +196,13 @@ public SuggestionsConfig getSuggestions() { public String getQuarantinedRolePattern() { return quarantinedRolePattern; } + + /** + * Gets the config for the scam blocker system. + * + * @return the scam blocker system config + */ + public @NotNull ScamBlockerConfig getScamBlocker() { + return scamBlocker; + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/ScamBlockerConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/ScamBlockerConfig.java new file mode 100644 index 0000000000..1bb8c918bf --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/ScamBlockerConfig.java @@ -0,0 +1,126 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonRootName; +import org.jetbrains.annotations.NotNull; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Configuration for the scam blocker system, see + * {@link org.togetherjava.tjbot.commands.moderation.scam.ScamBlocker}. + */ +@SuppressWarnings("ClassCanBeRecord") +@JsonRootName("scamBlocker") +public final class ScamBlockerConfig { + private final Mode mode; + private final String reportChannelPattern; + private final Set hostWhitelist; + private final Set hostBlacklist; + private final Set suspiciousHostKeywords; + private final int isHostSimilarToKeywordDistanceThreshold; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + private ScamBlockerConfig(@JsonProperty("mode") Mode mode, + @JsonProperty("reportChannelPattern") String reportChannelPattern, + @JsonProperty("hostWhitelist") Set hostWhitelist, + @JsonProperty("hostBlacklist") Set hostBlacklist, + @JsonProperty("suspiciousHostKeywords") Set suspiciousHostKeywords, + @JsonProperty("isHostSimilarToKeywordDistanceThreshold") int isHostSimilarToKeywordDistanceThreshold) { + this.mode = mode; + this.reportChannelPattern = reportChannelPattern; + this.hostWhitelist = new HashSet<>(hostWhitelist); + this.hostBlacklist = new HashSet<>(hostBlacklist); + this.suspiciousHostKeywords = new HashSet<>(suspiciousHostKeywords); + this.isHostSimilarToKeywordDistanceThreshold = isHostSimilarToKeywordDistanceThreshold; + } + + /** + * Gets the mode of the scam blocker. Controls which actions it takes when detecting scam. + * + * @return the scam blockers mode + */ + public @NotNull Mode getMode() { + return mode; + } + + /** + * Gets the REGEX pattern used to identify the channel that is used to report identified scam + * to. + * + * @return the channel name pattern + */ + public String getReportChannelPattern() { + return reportChannelPattern; + } + + /** + * Gets the set of trusted hosts. Urls using those hosts are not considered scam. + * + * @return the whitelist of hosts + */ + public @NotNull Set getHostWhitelist() { + return Collections.unmodifiableSet(hostWhitelist); + } + + /** + * Gets the set of known scam hosts. Urls using those hosts are considered scam. + * + * @return the blacklist of hosts + */ + public @NotNull Set getHostBlacklist() { + return Collections.unmodifiableSet(hostBlacklist); + } + + /** + * Gets the set of keywords that are considered suspicious if they appear in host names. Urls + * using hosts that have those, or similar, keywords in their name, are considered suspicious. + * + * @return the set of suspicious host keywords + */ + public @NotNull Set getSuspiciousHostKeywords() { + return Collections.unmodifiableSet(suspiciousHostKeywords); + } + + /** + * Gets the threshold used to determine whether a host is similar to a given keyword. If the + * host contains an infix with an edit distance that is below this threshold, they are + * considered similar. + * + * @return the threshold to determine similarity + */ + public int getIsHostSimilarToKeywordDistanceThreshold() { + return isHostSimilarToKeywordDistanceThreshold; + } + + /** + * Mode of a scam blocker. Controls which actions it takes when detecting scam. + */ + public enum Mode { + /** + * The blocker is turned off and will not scan any messages for scam. + */ + OFF, + /** + * The blocker will log any detected scam but will not take action on them. + */ + ONLY_LOG, + /** + * Detected scam will be sent to moderators for review. Any action has to be approved + * explicitly first. + */ + APPROVE_FIRST, + /** + * Detected scam will automatically be deleted. A moderator will be informed for review. + * They can then decide whether the user should be put into quarantine. + */ + AUTO_DELETE_BUT_APPROVE_QUARANTINE, + /** + * The blocker will automatically delete any detected scam and put the user into quarantine. + */ + AUTO_DELETE_AND_QUARANTINE + } +} diff --git a/application/src/main/resources/db/V9__Add_Scam_History.sql b/application/src/main/resources/db/V9__Add_Scam_History.sql new file mode 100644 index 0000000000..d6bff1bdeb --- /dev/null +++ b/application/src/main/resources/db/V9__Add_Scam_History.sql @@ -0,0 +1,11 @@ +CREATE TABLE scam_history +( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + sent_at TIMESTAMP NOT NULL, + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + message_id BIGINT NOT NULL, + author_id BIGINT NOT NULL, + content_hash TEXT NOT NULL, + is_deleted BOOLEAN NOT NULL +) \ No newline at end of file diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/SlashCommandAdapterTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/SlashCommandAdapterTest.java index a665fceb56..d5e579f825 100644 --- a/application/src/test/java/org/togetherjava/tjbot/commands/SlashCommandAdapterTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/commands/SlashCommandAdapterTest.java @@ -66,7 +66,7 @@ void generateComponentId() { // Test that the adapter uses the given generator SlashCommandAdapter adapter = createAdapter(); adapter.acceptComponentIdGenerator((componentId, lifespan) -> "%s;%s;%s" - .formatted(componentId.commandName(), componentId.elements().size(), lifespan)); + .formatted(componentId.userInteractorName(), componentId.elements().size(), lifespan)); // No lifespan given String[] elements = {"foo", "bar", "baz"}; diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/moderation/scam/ScamDetectorTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/moderation/scam/ScamDetectorTest.java new file mode 100644 index 0000000000..a5ab9830a5 --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/commands/moderation/scam/ScamDetectorTest.java @@ -0,0 +1,144 @@ +package org.togetherjava.tjbot.commands.moderation.scam; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.ScamBlockerConfig; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +final class ScamDetectorTest { + private ScamDetector scamDetector; + + @BeforeEach + void setUp() { + Config config = mock(Config.class); + ScamBlockerConfig scamConfig = mock(ScamBlockerConfig.class); + when(config.getScamBlocker()).thenReturn(scamConfig); + + when(scamConfig.getHostWhitelist()).thenReturn(Set.of("discord.com", "discord.gg", + "discord.media", "discordapp.com", "discordapp.net", "discordstatus.com")); + when(scamConfig.getHostBlacklist()).thenReturn(Set.of("bit.ly")); + when(scamConfig.getSuspiciousHostKeywords()) + .thenReturn(Set.of("discord", "nitro", "premium")); + when(scamConfig.getIsHostSimilarToKeywordDistanceThreshold()).thenReturn(2); + + scamDetector = new ScamDetector(config); + } + + @ParameterizedTest + @MethodSource("provideRealScamMessages") + @DisplayName("Can detect real scam messages") + void detectsRealScam(@NotNull String scamMessage) { + // GIVEN a real scam message + // WHEN analyzing it + boolean isScamResult = scamDetector.isScam(scamMessage); + + // THEN flags it as scam + assertTrue(isScamResult); + } + + @Test + @DisplayName("Can detect messages that contain blacklisted websites as scam") + void detectsBlacklistedWebsite() { + // GIVEN a message with a link to a blacklisted website + String scamMessage = "Checkout https://bit.ly/3IhcLiO to get your free nitro !"; + + // WHEN analyzing it + boolean isScamResult = scamDetector.isScam(scamMessage); + + // THEN flags it as scam + assertTrue(isScamResult); + } + + @Test + @DisplayName("Can detect messages that contain whitelisted websites and does not flag them as scam") + void detectsWhitelistedWebsite() { + // GIVEN a message with a link to a whitelisted website + String harmlessMessage = + "Checkout https://discord.com/nitro to get your nitro - but not for free."; + + // WHEN analyzing it + boolean isScamResult = scamDetector.isScam(harmlessMessage); + + // THEN flags it as harmless + assertFalse(isScamResult); + } + + @Test + @DisplayName("Can detect messages that contain links to suspicious websites and flags them as scam") + void detectsSuspiciousWebsites() { + // GIVEN a message with a link to a suspicious website + String scamMessage = "Checkout https://disc0rdS.com/n1tro to get your nitro for free."; + + // WHEN analyzing it + boolean isScamResult = scamDetector.isScam(scamMessage); + + // THEN flags it as scam + assertTrue(isScamResult); + } + + @Test + @DisplayName("Messages that contain links to websites that are not similar enough to suspicious keywords are not flagged as scam") + void websitesWithTooManyDifferencesAreNotSuspicious() { + // GIVEN a message with a link to a website that is not similar enough to a suspicious + // keyword + String notSimilarEnoughMessage = + "Checkout https://dI5c0ndS.com/n1rt0 to get your nitro for free."; + + // WHEN analyzing it + boolean isScamResult = scamDetector.isScam(notSimilarEnoughMessage); + + // THEN flags it as harmless + assertFalse(isScamResult); + } + + private static @NotNull List provideRealScamMessages() { + return List.of(""" + 🤩bro steam gived nitro - https://nitro-ds.online/LfgUfMzqYyx12""", + """ + @everyone, Free subscription for 3 months DISCORD NITRO - https://e-giftpremium.com/x12""", + """ + @everyone + Discord Nitro distribution from STEAM. + Get 3 month of Discord Nitro. Offer ends January 28, 2022 at 11am EDT. Customize your profile, share your screen in HD, update your emoji and more! + https://dlscrod-game.ru/promotionx12""", + """ + @everyone + Gifts for the new year, nitro for 3 months: https://discofdapp.com/newyearsx12""", + """ + @everyone yo , I got some nitro left over here https://steelsseriesnitros.com/billing/promotions/vh98rpaEJZnha5x37agpmOz3x12""", + """ + @everyone + :video_game: • Get Discord Nitro for Free from Steam Store + Free 3 months Discord Nitro + :clock630: • Personalize your profile, screen share in HD, upgrade your emojis, and more. + :gem: • Click to get Nitro: https://discoord-nittro.com/welcomex12 + :Works only with prime go or rust or pubg""", + """ + @everyone, Check this lol, there nitro is handed out for free, take it until everything is sorted out https://dicsord-present.ru/airdropx12""", + """ + @everyone + • Get Discord Nitro for Free from Steam Store + Free 3 months Discord Nitro + • The offer is valid until at 6:00PM on November 30, 2021. Personalize your profile, screen share in HD, upgrade your emojis, and more. + • Click to get Nitro: https://dliscord.shop/welcomex12""", + """ + airdrop discord nitro by steam, take it https://bit.ly/30RzoKx""", + """ + Steam is giving away free discord nitro, have time to pick up at my link https://bit.ly/3nlzmUa before the action is over.""", + """ + @everyone, take nitro faster, it's already running out + https://discordu.gift/u1CHEX2sjpDuR3T5"""); + } +} diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/system/ComponentIdTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/system/ComponentIdTest.java index f36a1012b7..e1a70de0a1 100644 --- a/application/src/test/java/org/togetherjava/tjbot/commands/system/ComponentIdTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/commands/system/ComponentIdTest.java @@ -9,9 +9,10 @@ final class ComponentIdTest { @Test - void getCommandName() { - String commandName = "foo"; - assertEquals(commandName, new ComponentId(commandName, List.of()).commandName()); + void getUserInteractorName() { + String userInteractorName = "foo"; + assertEquals(userInteractorName, + new ComponentId(userInteractorName, List.of()).userInteractorName()); } @Test diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/ButtonClickEventBuilder.java b/application/src/test/java/org/togetherjava/tjbot/jda/ButtonClickEventBuilder.java index 6b9192566b..f68d13fe08 100644 --- a/application/src/test/java/org/togetherjava/tjbot/jda/ButtonClickEventBuilder.java +++ b/application/src/test/java/org/togetherjava/tjbot/jda/ButtonClickEventBuilder.java @@ -46,11 +46,11 @@ * {@code * // Default message with a delete button * jdaTester.createButtonClickEvent() - * .setActionRows(ActionRow.of(Button.of(ButtonStyle.DANGER, "1", "Delete")) + * .setActionRows(ActionRow.of(Button.danger("1", "Delete")) * .buildWithSingleButton(); * * // More complex message with a user who clicked the button that is not the message author and multiple buttons - * Button clickedButton = Button.of(ButtonStyle.PRIMARY, "1", "Next"); + * Button clickedButton = Button.primary("1", "Next"); * jdaTester.createButtonClickEvent() * .setMessage(new MessageBuilder() * .setContent("See the following entry") @@ -61,7 +61,7 @@ * .build()) * .setUserWhoClicked(jdaTester.createMemberSpy(5)) * .setActionRows( - * ActionRow.of(Button.of(ButtonStyle.PRIMARY, "1", "Previous"), + * ActionRow.of(Button.primary("1", "Previous"), * clickedButton) * .build(clickedButton); * } diff --git a/build.gradle b/build.gradle index cae6b31fe6..8f593d152d 100644 --- a/build.gradle +++ b/build.gradle @@ -41,7 +41,7 @@ subprojects { // sonarlint configuration, not to be confused with sonarqube/sonarcloud. sonarlint { excludes { - // Disables "Track uses of "TODO" tags" rule. + // Disables "Track uses of "TO-DO" tags" rule. message 'java:S1135' // Disables "Regular expressions should not overflow the stack" rule.