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.