diff --git a/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java b/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java index fff66c10f..575895572 100644 --- a/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java @@ -28,6 +28,18 @@ public class ModerationConfig extends GuildConfigItem { private long adminRoleId = 0; private long expertRoleId = 0; + /** + * The time window, in seconds, that the cross-channel spam automod looks back over when + * counting a user's recent messages. If this is {@code 0}, the cross-channel spam automod + * is disabled. + */ + private int crossChannelSpamWindowSeconds = 0; + + /** + * The number of distinct channels a user must post in within + * {@link #crossChannelSpamWindowSeconds} seconds before the cross-channel spam automod acts. + */ + private int crossChannelSpamMinChannels = 3; /** * ID of the share-knowledge channel. */ diff --git a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCacheListener.java b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCacheListener.java index 061f1c5be..522929f0b 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCacheListener.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCacheListener.java @@ -39,10 +39,10 @@ public void onMessageUpdate(@NotNull MessageUpdateEvent event) { CachedMessage before; if (optional.isPresent()) { CachedMessage inCache= optional.get(); - before = new CachedMessage(inCache.getMessageId(), inCache.getAuthorId(), inCache.getMessageContent(), inCache.getAttachments()); + before = new CachedMessage(inCache.getMessageId(), inCache.getAuthorId(), inCache.getChannelId(),inCache.getMessageContent(), inCache.getAttachments()); inCache.init(event.getMessage()); } else { - before = new CachedMessage(event.getMessageIdLong(), event.getAuthor().getIdLong(), "[unknown content]", List.of()); + before = new CachedMessage(event.getMessageIdLong(), event.getAuthor().getIdLong(), event.getChannel().getIdLong(),"[unknown content]", List.of()); messageCache.cache(event.getMessage()); } messageCache.sendUpdatedMessageToLog(event.getMessage(), before); diff --git a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/dao/MessageCacheRepository.java b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/dao/MessageCacheRepository.java index e86f1408e..8093ed853 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/dao/MessageCacheRepository.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/dao/MessageCacheRepository.java @@ -31,14 +31,15 @@ public class MessageCacheRepository { * @throws SQLException If an error occurs. */ public void insertList(@NotNull List messages) throws DataAccessException { - jdbcTemplate.batchUpdate("MERGE INTO message_cache (message_id, author_id, message_content) VALUES (?, ?, ?)", + jdbcTemplate.batchUpdate("MERGE INTO message_cache (message_id, author_id, channel_id, message_content) VALUES (?, ?, ?, ?)", new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement stmt, int i) throws SQLException { CachedMessage msg = messages.get(i); stmt.setLong(1, msg.getMessageId()); stmt.setLong(2, msg.getAuthorId()); - stmt.setString(3, msg.getMessageContent()); + stmt.setLong(3,msg.getChannelId()); + stmt.setString(4, msg.getMessageContent()); stmt.executeUpdate(); } @@ -88,7 +89,7 @@ public List getAll() throws DataAccessException { messages.merge(msg.getMessageId(), msg, (oldValue, value) -> { ArrayList attachments = new ArrayList<>(oldValue.getAttachments()); attachments.addAll(value.getAttachments()); - return new CachedMessage(oldValue.getMessageId(), oldValue.getAuthorId(), oldValue.getMessageContent(), attachments); + return new CachedMessage(oldValue.getMessageId(), oldValue.getAuthorId(), oldValue.getChannelId(),oldValue.getMessageContent(), attachments); }); } return new ArrayList<>(messages.values()); @@ -119,6 +120,7 @@ private CachedMessage read(ResultSet rs) throws SQLException { return new CachedMessage( rs.getLong("message_cache.message_id"), rs.getLong("author_id"), + rs.getLong("channel_id"), rs.getString("message_content"), attachments); } diff --git a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/model/CachedMessage.java b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/model/CachedMessage.java index 2d7976a28..cce1cd483 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/model/CachedMessage.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/model/CachedMessage.java @@ -19,25 +19,29 @@ public class CachedMessage { private final long messageId; private final long authorId; + private final long channelId; private String messageContent; private List attachments=new ArrayList<>(); - private CachedMessage(long messageId, long authorId) { + private CachedMessage(long messageId, long authorId,long channelId) { this.messageId = messageId; this.authorId = authorId; + this.channelId = channelId; } /** * Creates a {@link CachedMessage} with the given information. * @param messageId The Discord ID of the message * @param authorId The Discord ID of the message author + * @param channelId The Discord ID of the message channel * @param messageContent the textual content of the message * @param attachments The attachment URLs */ - public CachedMessage(long messageId, long authorId, String messageContent, List attachments) { + public CachedMessage(long messageId, long authorId, long channelId, String messageContent, List attachments) { super(); this.messageId = messageId; this.authorId = authorId; + this.channelId = channelId; this.messageContent = messageContent; this.attachments = List.copyOf(attachments); } @@ -49,7 +53,7 @@ public CachedMessage(long messageId, long authorId, String messageContent, List< * @return The built {@link CachedMessage}. */ public static CachedMessage of(Message message) { - CachedMessage cachedMessage = new CachedMessage(message.getIdLong(), message.getAuthor().getIdLong()); + CachedMessage cachedMessage = new CachedMessage(message.getIdLong(), message.getAuthor().getIdLong(),message.getChannelIdLong()); cachedMessage.init(message); return cachedMessage; } diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java b/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java index f04ab922d..7dc3232fe 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java @@ -3,6 +3,7 @@ import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.h2db.message_cache.MessageCache; +import net.discordjug.javabot.data.h2db.message_cache.model.CachedMessage; import net.discordjug.javabot.systems.moderation.warn.model.WarnSeverity; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.ExceptionLogger; @@ -24,9 +25,7 @@ import java.net.URL; import java.time.Duration; import java.time.temporal.ChronoUnit; -import java.util.Collections; -import java.util.List; -import java.util.Scanner; +import java.util.*; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -50,18 +49,19 @@ public class AutoMod extends ListenerAdapter { private final MessageCache messageCache; /** - * Constructor of the class, that creates a list of strings with potential spam/scam urls. + * Constructor of the class, that creates a list of strings with potential spam/scam URLs. + * * @param notificationService The {@link QOTWPointsService} - * @param botConfig The main configuration of the bot - * @param moderationService Service object for moderating members - * @param messageCache service for retrieving cached messages + * @param botConfig The main configuration of the bot + * @param moderationService Service object for moderating members + * @param messageCache service for retrieving cached messages */ public AutoMod(NotificationService notificationService, BotConfig botConfig, ModerationService moderationService, MessageCache messageCache) { this.notificationService = notificationService; this.botConfig = botConfig; this.moderationService = moderationService; this.messageCache = messageCache; - try(Scanner scan = new Scanner(new URL("https://raw.githubusercontent.com/DevSpen/scam-links/master/src/links.txt").openStream()).useDelimiter("\\A")) { + try (Scanner scan = new Scanner(new URL("https://raw.githubusercontent.com/DevSpen/scam-links/master/src/links.txt").openStream()).useDelimiter("\\A")) { String response = scan.next(); spamUrls = List.of(response.split("\n")); } catch (IOException e) { @@ -105,21 +105,46 @@ private boolean canBypassAutomod(Member member) { private void checkNewMessageAutomod(@Nonnull Message message) { // spam long spamCount = messageCache.getMessagesAfter(message.getTimeCreated().minusSeconds(6)) - .stream() - .filter(cached -> cached.getMessageId() != message.getIdLong()) // exclude new/current message - .filter(cached -> cached.getAuthorId() == message.getAuthor().getIdLong()) - .filter(cached -> - // only java files -> not spam - cached.getAttachments().isEmpty() || - cached.getAttachments().stream() - .anyMatch(attachment -> !attachment.contains(".java?"))) - .count() + 1; // include new message - + .stream() + .filter(cached -> cached.getMessageId() != message.getIdLong()) // exclude new/current message + .filter(cached -> cached.getAuthorId() == message.getAuthor().getIdLong()) + .filter(cached -> + // only java files -> not spam + cached.getAttachments().isEmpty() || + cached.getAttachments().stream() + .anyMatch(attachment -> !attachment.contains(".java?"))) + .count() + 1; // include new message + if (spamCount >= 5) { - handleSpam(message, message.getMember()); + handleSpam(message); } - checkContentAutomod(message); + checkCrossChannelSpam(message); + } + + private void checkCrossChannelSpam(@Nonnull Message message) { + int spamWindowSeconds = (botConfig.get(message.getGuild()).getModerationConfig()).getCrossChannelSpamWindowSeconds(); + Set channelIds = new HashSet<>(); + List spamMessages = new ArrayList<>(); + + if (spamWindowSeconds <= 0) { + return; + } + + for (CachedMessage cachedMessage : messageCache.getMessagesAfter(message.getTimeCreated().minusSeconds(spamWindowSeconds))) { + if (cachedMessage.getMessageId() == message.getIdLong()) { + continue; + } + if (cachedMessage.getAuthorId() != message.getAuthor().getIdLong()) { + continue; + } + channelIds.add(cachedMessage.getChannelId()); + spamMessages.add(cachedMessage); + } + + if (channelIds.size() >= (botConfig.get(message.getGuild()).getModerationConfig()).getCrossChannelSpamMinChannels()) { + handleSpam(spamMessages, message); + } } /** @@ -130,7 +155,7 @@ private void checkNewMessageAutomod(@Nonnull Message message) { private void checkContentAutomod(@Nonnull Message message) { //Check for Advertising Links if (hasAdvertisingLink(message)) { - doAutomodActions(message,"Advertising"); + doAutomodActions(message, "Advertising"); } //Check for suspicious Links @@ -158,20 +183,29 @@ private void doAutomodActions(Message message, String reason) { /** * Handles detected spam messages. * - * @param msg the (last) spam message - * @param member the member to be potentially warned + * @param msg the (last) spam message */ - private void handleSpam(@Nonnull Message msg, Member member) { + private void handleSpam(@Nonnull Message msg) { + timeoutForSpam(msg); + msg.delete().queue(); + } + + private void handleSpam(@Nonnull List cachedMessages, Message message) { + timeoutForSpam(message); + cachedMessages.forEach(cachedMessage -> message.getGuild().getTextChannelById(cachedMessage.getChannelId()) + .deleteMessageById(cachedMessage.getMessageId()).queue()); + } + + private void timeoutForSpam(@Nonnull Message message) { moderationService .timeout( - member.getUser(), + message.getAuthor(), "Automod: Spam", - msg.getGuild().getSelfMember(), + message.getGuild().getSelfMember(), Duration.of(6, ChronoUnit.HOURS), - msg.getChannel(), + message.getChannel(), false ); - msg.delete().queue(); } /** @@ -237,5 +271,5 @@ private boolean isSuggestionsChannel(@NotNull MessageChannelUnion channel) { return channel.getType().isGuild() && channel.getIdLong() == botConfig.get(channel.asGuildMessageChannel().getGuild()).getModerationConfig().getSuggestionChannel().getIdLong(); } - + } \ No newline at end of file diff --git a/src/main/resources/database/migrations/07-19-2026_add_channel_column_to_cache.sql b/src/main/resources/database/migrations/07-19-2026_add_channel_column_to_cache.sql new file mode 100644 index 000000000..8ac312be8 --- /dev/null +++ b/src/main/resources/database/migrations/07-19-2026_add_channel_column_to_cache.sql @@ -0,0 +1 @@ +ALTER TABLE message_cache ADD COLUMN channel_id BIGINT DEFAULT -1;