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 super SlashCommand, ? super T, ? super List> commandArgumentConsumer) {
+ @NotNull TriConsumer super UserInteractor, ? super T, ? super List> 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.