From 03ea888d901cd6d07c99121360216ddbbca01ffd Mon Sep 17 00:00:00 2001 From: billpapat <50835814+billpapat@users.noreply.github.com> Date: Mon, 7 Jul 2025 10:36:18 +0300 Subject: [PATCH 1/7] fix: Handle empty message content for suggestion threads (#1265) * fix: handle empty message content for thread titles Ensures a fallback thread title is provided when the original message content is empty. The thread title will now default to the message author's name in such cases, preventing empty or uninformative thread titles. Co-authored-by: billpapat <50835814+billpapat@users.noreply.github.com> Co-authored-by: Chris Sdogkos Mentored-by: Chris Sdogkos * style: apply gradle task `spotlessApply` CI/CD is failing without this commit. Co-authored-by: billpapat <50835814+billpapat@users.noreply.github.com> Co-authored-by: Chris Sdogkos Mentored-by: Chris Sdogkos * fix: thread titles #1265 1) Created channels without title will default to "username's suggestions" 2) Change String threadTitle to final 3) ThreadTitle logic implemented in a helper method Co-authored-by: Ethan McCue <5004262+bowbahdoe@users.noreply.github.com> * docs(ThreadTitle): add JavaDocs and rename param Addresses code review by @christolis. Co-authorized-by: Chris Sdogkos Signed-off-by: Chris Sdogkos Signed-off-by: billpapat <50835814+billpapat@users.noreply.github.com> * Changes as per requested by Zabuzard * Changes as per requested by Zabuzard * Changes as per requested by Taz03 --------- Signed-off-by: Chris Sdogkos Signed-off-by: billpapat <50835814+billpapat@users.noreply.github.com> Co-authored-by: Chris Sdogkos Co-authored-by: Ethan McCue <5004262+bowbahdoe@users.noreply.github.com> --- .../basic/SuggestionsUpDownVoter.java | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/basic/SuggestionsUpDownVoter.java b/application/src/main/java/org/togetherjava/tjbot/features/basic/SuggestionsUpDownVoter.java index 0a5add113b..5dbbdbf8b2 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/basic/SuggestionsUpDownVoter.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/SuggestionsUpDownVoter.java @@ -23,9 +23,9 @@ */ public final class SuggestionsUpDownVoter extends MessageReceiverAdapter { private static final Logger logger = LoggerFactory.getLogger(SuggestionsUpDownVoter.class); - private static final int TITLE_MAX_LENGTH = 60; private static final Emoji FALLBACK_UP_VOTE = Emoji.fromUnicode("👍"); private static final Emoji FALLBACK_DOWN_VOTE = Emoji.fromUnicode("👎"); + private static final int THREAD_TITLE_MAX_LENGTH = 60; private final SuggestionsConfig config; @@ -55,19 +55,33 @@ public void onMessageReceived(MessageReceivedEvent event) { } private static void createThread(Message message) { - String title = message.getContentRaw(); + String threadTitle = generateThreadTitle(message); + message.createThreadChannel(threadTitle).queue(); + } - if (title.length() >= TITLE_MAX_LENGTH) { - int lastWordEnd = title.lastIndexOf(' ', TITLE_MAX_LENGTH); + /** + * Generates a title for the given message. The maximum length of the title is + * {@value #THREAD_TITLE_MAX_LENGTH}. + * + * @param message The message for which to generate the title. + * @return The generated and truncated thread title. + */ + private static String generateThreadTitle(Message message) { + String primaryTitle = message.getContentStripped(); + String fallbackTitle = message.getAuthor().getEffectiveName() + "'s suggestion"; + String title = primaryTitle.isEmpty() ? fallbackTitle : primaryTitle; - if (lastWordEnd == -1) { - lastWordEnd = TITLE_MAX_LENGTH; - } + if (title.length() <= THREAD_TITLE_MAX_LENGTH) { + return title; + } + + int lastWordEnd = title.lastIndexOf(' ', THREAD_TITLE_MAX_LENGTH); - title = title.substring(0, lastWordEnd); + if (lastWordEnd == -1) { + return title.substring(0, THREAD_TITLE_MAX_LENGTH); } - message.createThreadChannel(title).queue(); + return title.substring(0, lastWordEnd); } private static void reactWith(String emojiName, Emoji fallbackEmoji, Guild guild, From 1592157b72d15a0ee0842e27e433cf8d4f906e8e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Jul 2025 07:56:59 +0200 Subject: [PATCH 2/7] Bump com.diffplug.spotless from 7.0.4 to 7.1.0 (#1275) Bumps com.diffplug.spotless from 7.0.4 to 7.1.0. --- updated-dependencies: - dependency-name: com.diffplug.spotless dependency-version: 7.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index d0588c0496..9dae2935ee 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id "com.diffplug.spotless" version "7.0.4" + id "com.diffplug.spotless" version "7.1.0" id "org.sonarqube" version "6.2.0.5505" id "name.remal.sonarlint" version "5.1.10" } From 82a8932af284151e830356a8ef1129db1589beea Mon Sep 17 00:00:00 2001 From: Daniel Tischner Date: Wed, 16 Jul 2025 08:55:11 +0200 Subject: [PATCH 3/7] Extend ScamBlocker to detect image-only scam messages, plus unit tests (#1279) Added two new config params: * suspiciousAttachmentsThreshold * suspiciousAttachmentNamePattern --- application/config.json.template | 4 +- .../tjbot/config/ScamBlockerConfig.java | 31 +++++- .../features/moderation/scam/ScamBlocker.java | 3 +- .../moderation/scam/ScamDetector.java | 36 +++++++ .../moderation/scam/ScamDetectorTest.java | 98 +++++++++++++++++++ 5 files changed, 168 insertions(+), 4 deletions(-) diff --git a/application/config.json.template b/application/config.json.template index 3e568329a1..02835ca9e0 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -77,7 +77,9 @@ "crypto", "tele" ], - "isHostSimilarToKeywordDistanceThreshold": 2 + "isHostSimilarToKeywordDistanceThreshold": 2, + "suspiciousAttachmentsThreshold": 3, + "suspiciousAttachmentNamePattern": "(image|\\d{1,2})\\.[^.]{0,5}" }, "wolframAlphaAppId": "79J52T-6239TVXHR7", "helpSystem": { diff --git a/application/src/main/java/org/togetherjava/tjbot/config/ScamBlockerConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/ScamBlockerConfig.java index d95d9f9cce..0321a8a7b8 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/ScamBlockerConfig.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/ScamBlockerConfig.java @@ -23,6 +23,8 @@ public final class ScamBlockerConfig { private final Set hostBlacklist; private final Set suspiciousHostKeywords; private final int isHostSimilarToKeywordDistanceThreshold; + private final int suspiciousAttachmentsThreshold; + private final String suspiciousAttachmentNamePattern; @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) private ScamBlockerConfig(@JsonProperty(value = "mode", required = true) Mode mode, @@ -37,7 +39,11 @@ private ScamBlockerConfig(@JsonProperty(value = "mode", required = true) Mode mo @JsonProperty(value = "suspiciousHostKeywords", required = true) Set suspiciousHostKeywords, @JsonProperty(value = "isHostSimilarToKeywordDistanceThreshold", - required = true) int isHostSimilarToKeywordDistanceThreshold) { + required = true) int isHostSimilarToKeywordDistanceThreshold, + @JsonProperty(value = "suspiciousAttachmentsThreshold", + required = true) int suspiciousAttachmentsThreshold, + @JsonProperty(value = "suspiciousAttachmentNamePattern", + required = true) String suspiciousAttachmentNamePattern) { this.mode = Objects.requireNonNull(mode); this.reportChannelPattern = Objects.requireNonNull(reportChannelPattern); this.botTrapChannelPattern = Objects.requireNonNull(botTrapChannelPattern); @@ -46,6 +52,9 @@ private ScamBlockerConfig(@JsonProperty(value = "mode", required = true) Mode mo this.hostBlacklist = new HashSet<>(Objects.requireNonNull(hostBlacklist)); this.suspiciousHostKeywords = new HashSet<>(Objects.requireNonNull(suspiciousHostKeywords)); this.isHostSimilarToKeywordDistanceThreshold = isHostSimilarToKeywordDistanceThreshold; + this.suspiciousAttachmentsThreshold = suspiciousAttachmentsThreshold; + this.suspiciousAttachmentNamePattern = + Objects.requireNonNull(suspiciousAttachmentNamePattern); } /** @@ -125,6 +134,26 @@ public int getIsHostSimilarToKeywordDistanceThreshold() { return isHostSimilarToKeywordDistanceThreshold; } + /** + * Gets the minimum amount of suspicious attachments that are required in a message to flag it + * as suspicious for its contained attachments. + * + * @return the minimum amount of suspicious attachments + */ + public int getSuspiciousAttachmentsThreshold() { + return suspiciousAttachmentsThreshold; + } + + /** + * Gets the REGEX pattern used to identify an attachment file name that is considered + * suspicious. The file name includes the extension. + * + * @return the attachment file name pattern + */ + public String getSuspiciousAttachmentNamePattern() { + return suspiciousAttachmentNamePattern; + } + /** * Mode of a scam blocker. Controls which actions it takes when detecting scam. */ diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java index 057a71cee6..730d7eef14 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java @@ -137,8 +137,7 @@ public void onMessageReceived(MessageReceivedEvent event) { } Message message = event.getMessage(); - String content = message.getContentDisplay(); - if (isSafe && scamDetector.isScam(content)) { + if (isSafe && scamDetector.isScam(message)) { isSafe = false; } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java index 33a9a28743..035de5ca0c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java @@ -1,11 +1,16 @@ package org.togetherjava.tjbot.features.moderation.scam; +import net.dv8tion.jda.api.entities.Message; + import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.config.ScamBlockerConfig; import org.togetherjava.tjbot.features.utils.StringDistances; import java.net.URI; +import java.util.Collection; +import java.util.List; import java.util.Locale; +import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -18,6 +23,7 @@ public final class ScamDetector { private static final Pattern TOKENIZER = Pattern.compile("[\\s,]"); private final ScamBlockerConfig config; + private final Predicate isSuspiciousAttachmentName; /** * Creates a new instance with the given configuration @@ -26,6 +32,26 @@ public final class ScamDetector { */ public ScamDetector(Config config) { this.config = config.getScamBlocker(); + isSuspiciousAttachmentName = + Pattern.compile(config.getScamBlocker().getSuspiciousAttachmentNamePattern()) + .asMatchPredicate(); + } + + /** + * 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(Message message) { + String content = message.getContentDisplay(); + List attachments = message.getAttachments(); + + if (content.isBlank()) { + return areAttachmentsSuspicious(attachments); + } + + return isScam(content); } /** @@ -123,6 +149,16 @@ private boolean containsSuspiciousKeyword(String token) { }); } + private boolean areAttachmentsSuspicious(Collection attachments) { + long suspiciousAttachments = + attachments.stream().filter(this::isAttachmentSuspicious).count(); + return suspiciousAttachments >= config.getSuspiciousAttachmentsThreshold(); + } + + private boolean isAttachmentSuspicious(Message.Attachment attachment) { + return attachment.isImage() && isSuspiciousAttachmentName.test(attachment.getFileName()); + } + private boolean isHostSimilarToKeyword(String host, String keyword) { // NOTE This algorithm is far from optimal. // It is good enough for our purpose though and not that complex. diff --git a/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetectorTest.java b/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetectorTest.java index ba1ea14f08..19127401b6 100644 --- a/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetectorTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetectorTest.java @@ -1,5 +1,6 @@ package org.togetherjava.tjbot.features.moderation.scam; +import net.dv8tion.jda.api.entities.Message; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,6 +10,8 @@ import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.config.ScamBlockerConfig; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Set; @@ -18,6 +21,9 @@ import static org.mockito.Mockito.when; final class ScamDetectorTest { + private static final int SUSPICIOUS_ATTACHMENTS_THRESHOLD = 3; + private static final String SUSPICIOUS_ATTACHMENT_NAME = "scam.png"; + private ScamDetector scamDetector; @BeforeEach @@ -38,6 +44,10 @@ void setUp() { when(scamConfig.getSuspiciousHostKeywords()) .thenReturn(Set.of("discord", "nitro", "premium", "free", "cheat", "crypto", "tele")); when(scamConfig.getIsHostSimilarToKeywordDistanceThreshold()).thenReturn(2); + when(scamConfig.getSuspiciousAttachmentsThreshold()) + .thenReturn(SUSPICIOUS_ATTACHMENTS_THRESHOLD); + when(scamConfig.getSuspiciousAttachmentNamePattern()) + .thenReturn(SUSPICIOUS_ATTACHMENT_NAME); scamDetector = new ScamDetector(config); } @@ -121,6 +131,94 @@ void websitesWithTooManyDifferencesAreNotSuspicious() { assertFalse(isScamResult); } + @Test + @DisplayName("Messages containing multiple suspicious attachments are flagged as scam") + void detectsSuspiciousAttachments() { + // GIVEN an empty message containing suspicious attachments + String content = ""; + Message.Attachment attachment = createImageAttachmentMock(SUSPICIOUS_ATTACHMENT_NAME); + List attachments = + Collections.nCopies(SUSPICIOUS_ATTACHMENTS_THRESHOLD, attachment); + Message message = createMessageMock(content, attachments); + + // WHEN analyzing it + boolean isScamResult = scamDetector.isScam(message); + + // THEN flags it as scam + assertTrue(isScamResult); + } + + @Test + @DisplayName("Messages containing text content are not flagged for suspicious attachments") + void ignoresAttachmentsIfContentProvided() { + // GIVEN a non-empty message containing suspicious attachments + String content = "Hello World"; + Message.Attachment attachment = createImageAttachmentMock(SUSPICIOUS_ATTACHMENT_NAME); + List attachments = + Collections.nCopies(SUSPICIOUS_ATTACHMENTS_THRESHOLD, attachment); + Message message = createMessageMock(content, attachments); + + // WHEN analyzing it + boolean isScamResult = scamDetector.isScam(message); + + // THEN flags it as harmless + assertFalse(isScamResult); + } + + @Test + @DisplayName("Messages containing not enough suspicious attachments are not flagged") + void ignoresIfNotEnoughSuspiciousAttachments() { + // GIVEN an empty message containing some, but not enough suspicious attachments + String content = ""; + + Message.Attachment badAttachment = createImageAttachmentMock(SUSPICIOUS_ATTACHMENT_NAME); + Message.Attachment goodAttachment = createImageAttachmentMock("good.png"); + int badAttachmentAmount = SUSPICIOUS_ATTACHMENTS_THRESHOLD - 1; + List attachments = + new ArrayList<>(Collections.nCopies(badAttachmentAmount, badAttachment)); + attachments.add(goodAttachment); + + Message message = createMessageMock(content, attachments); + + // WHEN analyzing it + boolean isScamResult = scamDetector.isScam(message); + + // THEN flags it as harmless + assertFalse(isScamResult); + } + + @Test + @DisplayName("Messages containing harmless attachments are not flagged") + void ignoresHarmlessAttachments() { + // GIVEN an empty message containing only harmless attachments + String content = ""; + Message.Attachment attachment = createImageAttachmentMock("good.png"); + List attachments = + Collections.nCopies(SUSPICIOUS_ATTACHMENTS_THRESHOLD, attachment); + Message message = createMessageMock(content, attachments); + + // WHEN analyzing it + boolean isScamResult = scamDetector.isScam(message); + + // THEN flags it as harmless + assertFalse(isScamResult); + } + + private static Message createMessageMock(String content, List attachments) { + Message message = mock(Message.class); + when(message.getContentRaw()).thenReturn(content); + when(message.getContentDisplay()).thenReturn(content); + when(message.getAttachments()).thenReturn(attachments); + return message; + } + + private static Message.Attachment createImageAttachmentMock(String name) { + Message.Attachment attachment = mock(Message.Attachment.class); + when(attachment.isImage()).thenReturn(true); + when(attachment.getFileName()).thenReturn(name); + return attachment; + } + private static List provideRealScamMessages() { return List.of(""" 🤩bro steam gived nitro - https://nitro-ds.online/LfgUfMzqYyx12""", From 7c643c2c6eef86a37f2dba499f5bd30c23cc76b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 08:09:19 +0200 Subject: [PATCH 4/7] Bump com.diffplug.spotless from 7.1.0 to 7.2.0 (#1280) Bumps com.diffplug.spotless from 7.1.0 to 7.2.0. --- updated-dependencies: - dependency-name: com.diffplug.spotless dependency-version: 7.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 9dae2935ee..da03a76d96 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id "com.diffplug.spotless" version "7.1.0" + id "com.diffplug.spotless" version "7.2.0" id "org.sonarqube" version "6.2.0.5505" id "name.remal.sonarlint" version "5.1.10" } From e6522bedcd6b4e7978f845880297387802e9d4cb Mon Sep 17 00:00:00 2001 From: Daniel Tischner Date: Mon, 21 Jul 2025 12:42:53 +0200 Subject: [PATCH 5/7] Scam Blocker fine-tuning (#1281) * added new scam and false positives, fine-tuned, ability to use "foo$" * exact matches (^foo$) --- application/config.json.template | 20 ++- .../moderation/scam/ScamDetector.java | 24 +++ .../moderation/scam/ScamDetectorTest.java | 139 ++++++++++++++++-- 3 files changed, 163 insertions(+), 20 deletions(-) diff --git a/application/config.json.template b/application/config.json.template index 02835ca9e0..c53120ddd8 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -38,21 +38,27 @@ "trading", "whatsapp", "crypto", - "claim", + "^claim", "teen", "adobe", "hack", "steamcommunity", "freenitro", - "^earn", - ".exe" + "^earn$", + "^earning", + ".exe$" ], "hostWhitelist": [ "discord.com", "discord.media", "discordapp.com", "discordapp.net", - "discordstatus.com" + "discordstatus.com", + "thehackernews.com", + "gradle.org", + "help.gradle.org", + "youtube.com", + "www.youtube.com" ], "hostBlacklist": [ "bit.ly", @@ -66,7 +72,8 @@ "telegra.ph", "shorturl.at", "cheatings.xyz", - "transfer.sh" + "transfer.sh", + "tobimoller.space" ], "suspiciousHostKeywords": [ "discord", @@ -75,7 +82,8 @@ "free", "cheat", "crypto", - "tele" + "telegra", + "telety" ], "isHostSimilarToKeywordDistanceThreshold": 2, "suspiciousAttachmentsThreshold": 3, diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java index 035de5ca0c..b914f03ecc 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java @@ -10,6 +10,7 @@ import java.util.Collection; import java.util.List; import java.util.Locale; +import java.util.StringJoiner; import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -141,10 +142,18 @@ private boolean containsSuspiciousKeyword(String token) { .stream() .map(keyword -> keyword.toLowerCase(Locale.US)) .anyMatch(keyword -> { + // Exact match "^foo$" + if (startsWith(keyword, '^') && endsWith(keyword, '$')) { + return preparedToken.equals(keyword.substring(1, keyword.length() - 1)); + } // Simple regex-inspired syntax "^foo" if (startsWith(keyword, '^')) { return preparedToken.startsWith(keyword.substring(1)); } + // Simple regex-inspired syntax "foo$" + if (endsWith(keyword, '$')) { + return preparedToken.endsWith(keyword.substring(0, keyword.length() - 1)); + } return preparedToken.contains(keyword); }); } @@ -186,11 +195,26 @@ private static boolean startsWith(CharSequence text, char prefixToTest) { return !text.isEmpty() && text.charAt(0) == prefixToTest; } + private static boolean endsWith(CharSequence text, char suffixToTest) { + return !text.isEmpty() && text.charAt(text.length() - 1) == suffixToTest; + } + private static class AnalyseResults { private boolean pingsEveryone; private boolean containsSuspiciousKeyword; private boolean containsDollarSign; private boolean hasUrl; private boolean hasSuspiciousUrl; + + @Override + public String toString() { + return new StringJoiner(", ", AnalyseResults.class.getSimpleName() + "[", "]") + .add("pingsEveryone=" + pingsEveryone) + .add("containsSuspiciousKeyword=" + containsSuspiciousKeyword) + .add("containsDollarSign=" + containsDollarSign) + .add("hasUrl=" + hasUrl) + .add("hasSuspiciousUrl=" + hasSuspiciousUrl) + .toString(); + } } } diff --git a/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetectorTest.java b/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetectorTest.java index 19127401b6..10d624d108 100644 --- a/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetectorTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetectorTest.java @@ -32,17 +32,18 @@ void setUp() { ScamBlockerConfig scamConfig = mock(ScamBlockerConfig.class); when(config.getScamBlocker()).thenReturn(scamConfig); - when(scamConfig.getSuspiciousKeywords()) - .thenReturn(Set.of("nitro", "boob", "sexy", "sexi", "esex", "steam", "gift", "onlyfans", - "bitcoin", "btc", "promo", "trader", "trading", "whatsapp", "crypto", "claim", - "teen", "adobe", "hack", "steamcommunity", "freenitro", "^earn", ".exe")); + when(scamConfig.getSuspiciousKeywords()).thenReturn(Set.of("nitro", "boob", "sexy", "sexi", + "esex", "steam", "gift", "onlyfans", "bitcoin", "btc", "promo", "trader", "trading", + "whatsapp", "crypto", "^claim", "teen", "adobe", "hack", "steamcommunity", + "freenitro", "^earn$", "^earning", ".exe$")); when(scamConfig.getHostWhitelist()).thenReturn(Set.of("discord.com", "discord.media", - "discordapp.com", "discordapp.net", "discordstatus.com")); + "discordapp.com", "discordapp.net", "discordstatus.com", "thehackernews.com", + "gradle.org", "help.gradle.org", "youtube.com", "www.youtube.com")); when(scamConfig.getHostBlacklist()).thenReturn(Set.of("bit.ly", "discord.gg", "teletype.in", "t.me", "corematrix.us", "u.to", "steamcommunity.com", "goo.su", "telegra.ph", - "shorturl.at", "cheatings.xyz", "transfer.sh")); - when(scamConfig.getSuspiciousHostKeywords()) - .thenReturn(Set.of("discord", "nitro", "premium", "free", "cheat", "crypto", "tele")); + "shorturl.at", "cheatings.xyz", "transfer.sh", "tobimoller.space")); + when(scamConfig.getSuspiciousHostKeywords()).thenReturn(Set.of("discord", "nitro", + "premium", "free", "cheat", "crypto", "telegra", "telety")); when(scamConfig.getIsHostSimilarToKeywordDistanceThreshold()).thenReturn(2); when(scamConfig.getSuspiciousAttachmentsThreshold()) .thenReturn(SUSPICIOUS_ATTACHMENTS_THRESHOLD); @@ -335,14 +336,124 @@ private static List provideRealScamMessages() { Or via TG: https://t.me/Charlie_Adamo """, "Urgently looking for mods & collab managers https://discord.gg/cryptohireo", - "Check this - https://transfer.sh/get/ajmkh3l7tzop/Setup.exe"); + "Check this - https://transfer.sh/get/ajmkh3l7tzop/Setup.exe", + """ + Secrets of the crypto market that top traders don’t want you to know! I’m looking to help some individuals who + are serious about earning over $100K weekly in the market. Remember, I’ll require just 15% of your profits once + you start seeing earnings. Note: I’m only looking for serious and truly interested individuals. + Text me on TG/WhatApps for more info on how to get started +(123)123-1230 https://t.me/officialjohnsmith""", + """ + 💻 Senior Full Stack Engineer | 8+ Years Experience with me + Hi, I’m a Senior Software Engineer with over 8 years of experience building scalable website, cloud-native software solutions across industries like healthcare, fintech, e-commerce, gaming, logistics, and energy. + 🧰 Core Skills: + Frontend: React, Vue, Angular, Next.js, TypeScript, Web3 integration, Svelte, Three.js, Pixi.js + Backend: Node.js, NestJS, PHP (Laravel, Symfony), Python (FastAPI/Flask), .Net, Rails + Databases: MongoDB, MySQL, PostgreSQL, Redis + Ecommerce platforms: MedusaJS, MercurJS, Shopify (Gadget) + Automation & Bots: Token Swap / Trading Bots, AI/ML & Generative AI & CRM, Automation online sites + 🔍 Notable Projects: + Property Shield: Scalable backend with NestJS, Redis Streams, MongoDB, Supabase + Ready Education: Frontend state architecture with NgRx, Next / Vue, TypeScript with Web3, + Kozoom Multimedia: Secure enterprise login using React, Redux, Azure + B2CWorkflow Builder (React Flow) + 📂 Portfolio: https://tobimoller.space/ + 📬 Open to freelance gigs, contracts, and bounties — let’s talk!""", + """ + I'll help the first 10 people interested on how to start earning $100k or more within a week, + but you will reimburse me 10% of your profits when you receive it. Note: only interested people should + send a friend request or send me a dm! ask me (HOW) via Telegram username @JohnSmith_123""", + """ + Ready to unlock your earning potential in the digital market? you can start earning $100,000 and even more + as a beginner from the digital market, DM me for expert guidance or contact me directly on telegram and start building your financial future. + Telegram username @JohnSmith123""", + "Grab it before it's deleted (available for Windows and macOS): https://www.reddit.com/r/TVBaFreeHub/comments/12345t/ninaatradercrackedfullpowertradingfreefor123/"); } private static List provideRealFalsePositiveMessages() { - return List - .of(""" - https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/types/anonymous-types""", - """ - And according to quick google search. Median wage is about $23k usd"""); + return List.of( + """ + https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/types/anonymous-types""", + "And according to quick google search. Median wage is about $23k usd", + """ + $ docker image prune -a + WARNING! This will remove all images without at least one container associated to them. + Are you sure you want to continue? [y/N] y + ... + Total reclaimed space: 37.73GB""", + """ + Exception in thread "main" java.lang.NoSuchMethodError: 'java.lang.String org.junit.platform.engine.discovery.MethodSelector.getMethodParameterTypes()' + at com.intellij.junit5.JUnit5TestRunnerUtil.loadMethodByReflection(JUnit5TestRunnerUtil.java:127) + at com.intellij.junit5.JUnit5TestRunnerUtil.buildRequest(JUnit5TestRunnerUtil.java:102) + at com.intellij.junit5.JUnit5IdeaTestRunner.startRunnerWithArgs(JUnit5IdeaTestRunner.java:43) + at com.intellij.rt.junit.IdeaTestRunner$Repeater$1.execute(IdeaTestRunner.java:38) + at com.intellij.rt.execution.junit.TestsRepeater.repeat(TestsRepeater.java:11) + at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:35) + at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:232) + at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:55)""", + """ + The average wage here (not the median, which is lower) gives you a take-home of about $68k in New Zealand dollars. + The median house-price in my city (which is not at all the most expensive city) is ~$740k. + That's an 11 year save for an average earner for an average house without spending anything.""", + "https://thehackernews.com/2025/07/alert-exposed-jdwp-interfaces-lead-to.html", + """ + ~/Developer/TJ-Bot develop ❯ ./gradlew build 10:20:05 PM + FAILURE: Build failed with an exception. + What went wrong: + class name.remal.gradleplugins.sonarlint.SonarLintPlugin + tried to access private field org.gradle.api.plugins.quality.internal.AbstractCodeQualityPlugin.extension + (name.remal.gradleplugins.sonarlint.SonarLintPlugin is in unnamed module of loader + org.gradle.internal.classloader.VisitableURLClassLoader$InstrumentingVisitableURLClassLoader @55f4c79b; + org.gradle.api.plugins.quality.internal.AbstractCodeQualityPlugin is in unnamed module of + loader org.gradle.initialization.MixInLegacyTypesClassLoader @49b2a47d) + Try: + Run with --stacktrace option to get the stack trace. + Run with --info or --debug option to get more log output. + Run with --scan to get full insights. + Get more help at https://help.gradle.org/. + BUILD FAILED in 795ms + 7 actionable tasks: 7 up-to-date + ~/Developer/TJ-Bot develop ❯""", + """ + For example. I enter 3.45 for the price and 3 for the count. It results in 10.350000000000001 for some reason. I followed Bro Code's video: + https://www.youtube.com/watch?v=P8CVPIaRmys&list=PLZPZq0rRZOOjNOZYq_R2PECIMglLemc&index=6 + and his does not do this. Why is this? + import java.util.Scanner; + public class ShoppingCart { + public static void main(String[] args){ + // Shopping Cart Arithmetic Practice + Scanner input = new Scanner(System.in); + String item; + double price; + int count; + char currency = '$'; + double total; + System.out.print("What item would you like to buy?: "); + item = input.nextLine(); + System.out.print("What is the price of this item?: "); + price = input.nextDouble(); + System.out.print("How many " + item + "(s) would you like to buy?: "); + count = input.nextInt(); + total = price * count; + System.out.println("\\nYou bought " + count + " " + item + "(s).\\n"); + System.out.println("Your total is " + currency + total); + } + }""", + "@squidxtv https://cdn.steamusercontent.com/ugc/12827361819537692968/A7B3AC5A176E7B2287B5E84B9A0BE9754F5A6388/", + """ + today i understood, why security is joke, even for people on top + https://micahsmith.com/ddosecrets-publishes-410-gb-of-heap-dumps-hacked-from-telemessages-archive-server/""", + """ + Hey guys @everyone, apologise for disturbing, + I wanted to ask what's the scope of Java in future like after 2030 in USA, like the newer frameworks will + replace Spring Boot ... and how AI will play it role ... + I am very much confused, what to do, I tired exploring Machine Learning, but I don't know why it felt more + like a burden then enjoyment, but spring boot was fun, although exploring microservice architecture + is was tricky mostly when it came to deployment and it become really confusing...""", + "https://www.cloudflare.com/learning/email-security/dmarc-dkim-spf/", + """ + It was pretty pricey, and the costs likely differ a lot from country to country + (keeping in mind that a portion is importing of equipment to NZ and some is labour in a very different market). + We have 13.5KW of storage, a 10KW inverter, 11.5KW of generation and an EV charger. + All up, on a 1% 'green loan', it was $40k NZD (~$23k USD)"""); } } From c33d235bb0155173b3177f05a32c4ce02a1af5d1 Mon Sep 17 00:00:00 2001 From: Daniel Tischner Date: Mon, 21 Jul 2025 13:03:11 +0200 Subject: [PATCH 6/7] Ignore "scam" send by trusted users (#1282) * Ignore "scam" send by trusted users * added extra "trustedUserRolePattern" config entry --- application/config.json.template | 1 + .../tjbot/config/ScamBlockerConfig.java | 13 +++++++ .../moderation/scam/ScamDetector.java | 15 +++++++- .../moderation/scam/ScamDetectorTest.java | 34 +++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/application/config.json.template b/application/config.json.template index c53120ddd8..0c0989ba96 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -22,6 +22,7 @@ "mode": "AUTO_DELETE_BUT_APPROVE_QUARANTINE", "reportChannelPattern": "commands", "botTrapChannelPattern": "bot-trap", + "trustedUserRolePattern": "Top Helpers .+|Moderator|Community Ambassador|Expert", "suspiciousKeywords": [ "nitro", "boob", diff --git a/application/src/main/java/org/togetherjava/tjbot/config/ScamBlockerConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/ScamBlockerConfig.java index 0321a8a7b8..b93119022e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/ScamBlockerConfig.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/ScamBlockerConfig.java @@ -18,6 +18,7 @@ public final class ScamBlockerConfig { private final Mode mode; private final String reportChannelPattern; private final String botTrapChannelPattern; + private final String trustedUserRolePattern; private final Set suspiciousKeywords; private final Set hostWhitelist; private final Set hostBlacklist; @@ -32,6 +33,8 @@ private ScamBlockerConfig(@JsonProperty(value = "mode", required = true) Mode mo required = true) String reportChannelPattern, @JsonProperty(value = "botTrapChannelPattern", required = true) String botTrapChannelPattern, + @JsonProperty(value = "trustedUserRolePattern", + required = true) String trustedUserRolePattern, @JsonProperty(value = "suspiciousKeywords", required = true) Set suspiciousKeywords, @JsonProperty(value = "hostWhitelist", required = true) Set hostWhitelist, @@ -47,6 +50,7 @@ private ScamBlockerConfig(@JsonProperty(value = "mode", required = true) Mode mo this.mode = Objects.requireNonNull(mode); this.reportChannelPattern = Objects.requireNonNull(reportChannelPattern); this.botTrapChannelPattern = Objects.requireNonNull(botTrapChannelPattern); + this.trustedUserRolePattern = Objects.requireNonNull(trustedUserRolePattern); this.suspiciousKeywords = new HashSet<>(Objects.requireNonNull(suspiciousKeywords)); this.hostWhitelist = new HashSet<>(Objects.requireNonNull(hostWhitelist)); this.hostBlacklist = new HashSet<>(Objects.requireNonNull(hostBlacklist)); @@ -86,6 +90,15 @@ public String getBotTrapChannelPattern() { return botTrapChannelPattern; } + /** + * Gets the REGEX pattern used to identify roles that will be ignored for scam detection. + * + * @return the REGEX pattern + */ + public String getTrustedUserRolePattern() { + return trustedUserRolePattern; + } + /** * Gets the set of keywords that are considered suspicious if they appear in a message. * diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java index b914f03ecc..3240530f50 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java @@ -1,6 +1,8 @@ package org.togetherjava.tjbot.features.moderation.scam; +import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.Role; import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.config.ScamBlockerConfig; @@ -25,6 +27,7 @@ public final class ScamDetector { private static final Pattern TOKENIZER = Pattern.compile("[\\s,]"); private final ScamBlockerConfig config; private final Predicate isSuspiciousAttachmentName; + private final Predicate hasTrustedRole; /** * Creates a new instance with the given configuration @@ -33,9 +36,12 @@ public final class ScamDetector { */ public ScamDetector(Config config) { this.config = config.getScamBlocker(); + isSuspiciousAttachmentName = - Pattern.compile(config.getScamBlocker().getSuspiciousAttachmentNamePattern()) + Pattern.compile(this.config.getSuspiciousAttachmentNamePattern()) .asMatchPredicate(); + hasTrustedRole = + Pattern.compile(this.config.getTrustedUserRolePattern()).asMatchPredicate(); } /** @@ -45,6 +51,13 @@ public ScamDetector(Config config) { * @return Whether the message classifies as scam */ public boolean isScam(Message message) { + Member author = message.getMember(); + boolean isTrustedUser = author != null + && author.getRoles().stream().map(Role::getName).noneMatch(hasTrustedRole); + if (isTrustedUser) { + return false; + } + String content = message.getContentDisplay(); List attachments = message.getAttachments(); diff --git a/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetectorTest.java b/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetectorTest.java index 10d624d108..609e4beeb4 100644 --- a/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetectorTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetectorTest.java @@ -1,6 +1,8 @@ package org.togetherjava.tjbot.features.moderation.scam; +import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.Role; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -50,6 +52,8 @@ void setUp() { when(scamConfig.getSuspiciousAttachmentNamePattern()) .thenReturn(SUSPICIOUS_ATTACHMENT_NAME); + when(scamConfig.getTrustedUserRolePattern()).thenReturn("Moderator"); + scamDetector = new ScamDetector(config); } @@ -205,6 +209,23 @@ void ignoresHarmlessAttachments() { assertFalse(isScamResult); } + @Test + @DisplayName("Suspicious messages send by trusted users are not flagged") + void ignoreTrustedUser() { + // GIVEN a scam message send by a trusted user + String content = "Checkout https://bit.ly/3IhcLiO to get your free nitro !"; + Member trustedUser = createAuthorMock(List.of("Moderator")); + Message message = createMessageMock(content, List.of()); + + when(message.getMember()).thenReturn(trustedUser); + + // WHEN analyzing it + boolean isScamResult = scamDetector.isScam(message); + + // THEN flags it as harmless + assertTrue(isScamResult); + } + private static Message createMessageMock(String content, List attachments) { Message message = mock(Message.class); when(message.getContentRaw()).thenReturn(content); @@ -220,6 +241,19 @@ private static Message.Attachment createImageAttachmentMock(String name) { return attachment; } + private static Member createAuthorMock(List roleNames) { + List roles = new ArrayList<>(); + for (String roleName : roleNames) { + Role role = mock(Role.class); + when(role.getName()).thenReturn(roleName); + roles.add(role); + } + + Member member = mock(Member.class); + when(member.getRoles()).thenReturn(roles); + return member; + } + private static List provideRealScamMessages() { return List.of(""" 🤩bro steam gived nitro - https://nitro-ds.online/LfgUfMzqYyx12""", From c391084b874d58a96ac2382b4a1d9bebde585cff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 25 Jul 2025 08:36:47 +0200 Subject: [PATCH 7/7] Bump org.apache.commons:commons-text from 1.13.1 to 1.14.0 (#1284) Bumps [org.apache.commons:commons-text](https://github.com/apache/commons-text) from 1.13.1 to 1.14.0. - [Changelog](https://github.com/apache/commons-text/blob/master/RELEASE-NOTES.txt) - [Commits](https://github.com/apache/commons-text/compare/rel/commons-text-1.13.1...rel/commons-text-1.14.0) --- updated-dependencies: - dependency-name: org.apache.commons:commons-text dependency-version: 1.14.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- application/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/build.gradle b/application/build.gradle index 78c3254c47..15cd32dcbb 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -77,7 +77,7 @@ dependencies { implementation 'org.kohsuke:github-api:1.327' - implementation 'org.apache.commons:commons-text:1.13.1' + implementation 'org.apache.commons:commons-text:1.14.0' implementation 'com.apptasticsoftware:rssreader:3.9.3' testImplementation 'org.mockito:mockito-core:5.18.0'