diff --git a/application/build.gradle b/application/build.gradle index 15cd32dcbb..914075d835 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -75,10 +75,10 @@ dependencies { implementation 'com.github.ben-manes.caffeine:caffeine:3.2.0' - implementation 'org.kohsuke:github-api:1.327' + implementation 'org.kohsuke:github-api:1.329' implementation 'org.apache.commons:commons-text:1.14.0' - implementation 'com.apptasticsoftware:rssreader:3.9.3' + implementation 'com.apptasticsoftware:rssreader:3.10.0' testImplementation 'org.mockito:mockito-core:5.18.0' testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" diff --git a/application/config.json.template b/application/config.json.template index d1ec6559f3..f8a14abc2a 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -60,7 +60,9 @@ "gradle.org", "help.gradle.org", "youtube.com", - "www.youtube.com" + "www.youtube.com", + "cdn.discordapp.com", + "media.discordapp.net" ], "hostBlacklist": [ "bit.ly", diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/AnalyseResults.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/AnalyseResults.java new file mode 100644 index 0000000000..e791d58c8c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/AnalyseResults.java @@ -0,0 +1,105 @@ +package org.togetherjava.tjbot.features.moderation.scam; + +import javax.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Objects; +import java.util.StringJoiner; + +final class AnalyseResults { + private boolean pingsEveryone; + private boolean containsSuspiciousKeyword; + private boolean containsDollarSign; + private boolean onlyContainsUrls = true; + private final Collection urls = new ArrayList<>(); + + void addUrlResult(AnalyseUrlResult result) { + urls.add(result); + } + + boolean hasUrl() { + return !urls.isEmpty(); + } + + boolean hasSuspiciousUrl() { + return urls.stream().anyMatch(url -> url.isSuspicious); + } + + boolean areAllUrlsWithAttachments() { + return urls.stream().allMatch(url -> url.containedAttachment != null); + } + + Collection getUrlAttachments() { + return urls.stream().map(url -> url.containedAttachment).filter(Objects::nonNull).toList(); + } + + boolean pingsEveryone() { + return pingsEveryone; + } + + void markPingsEveryone() { + pingsEveryone = true; + } + + boolean containsSuspiciousKeyword() { + return containsSuspiciousKeyword; + } + + void markContainsSuspiciousKeyword() { + containsSuspiciousKeyword = true; + } + + boolean containsDollarSign() { + return containsDollarSign; + } + + void markContainsDollarSign() { + containsDollarSign = true; + } + + boolean onlyContainsUrls() { + return onlyContainsUrls; + } + + void markNonUrlTokenFound() { + onlyContainsUrls = false; + } + + @Override + public String toString() { + return new StringJoiner(", ", AnalyseResults.class.getSimpleName() + "[", "]") + .add("pingsEveryone=" + pingsEveryone) + .add("containsSuspiciousKeyword=" + containsSuspiciousKeyword) + .add("containsDollarSign=" + containsDollarSign) + .add("onlyContainsUrls=" + onlyContainsUrls) + .add("urls=" + urls) + .toString(); + } + + static final class AnalyseUrlResult { + private boolean isSuspicious; + @Nullable + private Attachment containedAttachment; + + @Override + public String toString() { + return new StringJoiner(", ", AnalyseUrlResult.class.getSimpleName() + "[", "]") + .add("isSuspicious=" + isSuspicious) + .add("containedAttachment=" + containedAttachment) + .toString(); + } + + boolean isSuspicious() { + return isSuspicious; + } + + void markSuspicious() { + isSuspicious = true; + } + + void setContainedAttachment(Attachment containedAttachment) { + this.containedAttachment = containedAttachment; + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/Attachment.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/Attachment.java new file mode 100644 index 0000000000..4a7c8ad528 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/Attachment.java @@ -0,0 +1,34 @@ +package org.togetherjava.tjbot.features.moderation.scam; + +import net.dv8tion.jda.api.entities.Message; + +import java.util.Optional; +import java.util.Set; + +record Attachment(String fileName) { + private static final Set IMAGE_EXTENSIONS = + Set.of("jpg", "jpeg", "png", "gif", "webp", "tiff", "svg", "apng"); + + boolean isImage() { + return getFileExtension().map(IMAGE_EXTENSIONS::contains).orElse(false); + } + + private Optional getFileExtension() { + int dot = fileName.lastIndexOf('.'); + if (dot == -1) { + return Optional.empty(); + } + String extension = fileName.substring(dot + 1); + return Optional.of(extension); + } + + static Attachment fromDiscord(Message.Attachment attachment) { + return new Attachment(attachment.getFileName()); + } + + static Attachment fromUrlPath(String urlPath) { + int fileNameStart = urlPath.lastIndexOf('/'); + String fileName = fileNameStart == -1 ? "" : urlPath.substring(fileNameStart + 1); + return new Attachment(fileName); + } +} 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 4898316d19..df2272e795 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 @@ -6,13 +6,9 @@ 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.StringJoiner; import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -28,6 +24,7 @@ public final class ScamDetector { private final ScamBlockerConfig config; private final Predicate isSuspiciousAttachmentName; private final Predicate hasTrustedRole; + private final TokenAnalyse tokenAnalyse; /** * Creates a new instance with the given configuration @@ -42,6 +39,8 @@ public ScamDetector(Config config) { .asMatchPredicate(); hasTrustedRole = Pattern.compile(this.config.getTrustedUserRolePattern()).asMatchPredicate(); + + tokenAnalyse = new TokenAnalyse(this.config); } /** @@ -59,10 +58,11 @@ public boolean isScam(Message message) { } String content = message.getContentDisplay(); - List attachments = message.getAttachments(); + List attachments = + message.getAttachments().stream().map(Attachment::fromDiscord).toList(); if (content.isBlank()) { - return areAttachmentsSuspicious(attachments); + return areAttachmentsScam(attachments); } return isScam(content); @@ -76,158 +76,36 @@ public boolean isScam(Message message) { */ public boolean isScam(CharSequence message) { AnalyseResults results = new AnalyseResults(); - TOKENIZER.splitAsStream(message).forEach(token -> analyzeToken(token, results)); + TOKENIZER.splitAsStream(message).forEach(token -> tokenAnalyse.analyze(token, results)); return isScam(results); } private boolean isScam(AnalyseResults results) { - if (results.pingsEveryone && (results.containsSuspiciousKeyword || results.hasUrl - || results.containsDollarSign)) { + if (results.pingsEveryone() && (results.containsSuspiciousKeyword() || results.hasUrl() + || results.containsDollarSign())) { return true; } - return Stream - .of(results.containsSuspiciousKeyword, results.hasSuspiciousUrl, - results.containsDollarSign) + boolean hasTooManySuspiciousFlags = Stream + .of(results.containsSuspiciousKeyword(), results.hasSuspiciousUrl(), + results.containsDollarSign()) .filter(flag -> flag) .count() >= 2; - } - - private void analyzeToken(String token, AnalyseResults results) { - if (token.isBlank()) { - return; - } - - if (!results.pingsEveryone - && ("@everyone".equalsIgnoreCase(token) || "@here".equalsIgnoreCase(token))) { - results.pingsEveryone = true; - } - - if (!results.containsSuspiciousKeyword && containsSuspiciousKeyword(token)) { - results.containsSuspiciousKeyword = true; - } - - if (!results.containsDollarSign && (token.contains("$") || "usd".equalsIgnoreCase(token))) { - results.containsDollarSign = true; - } - - if (token.startsWith("http")) { - analyzeUrl(token, results); - } - } - - private void analyzeUrl(String url, AnalyseResults results) { - String host; - try { - host = URI.create(url).getHost(); - } catch (IllegalArgumentException _) { - // 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; - } + if (hasTooManySuspiciousFlags) { + return true; } - } - private boolean containsSuspiciousKeyword(String token) { - String preparedToken = token.toLowerCase(Locale.US); - - return config.getSuspiciousKeywords() - .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); - }); + return results.onlyContainsUrls() && results.areAllUrlsWithAttachments() + && areAttachmentsScam(results.getUrlAttachments()); } - private boolean areAttachmentsSuspicious(Collection attachments) { + private boolean areAttachmentsScam(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. - - // 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 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(); - } + private boolean isAttachmentSuspicious(Attachment attachment) { + return attachment.isImage() && isSuspiciousAttachmentName.test(attachment.fileName()); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/TokenAnalyse.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/TokenAnalyse.java new file mode 100644 index 0000000000..01ea2fff2f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/TokenAnalyse.java @@ -0,0 +1,157 @@ +package org.togetherjava.tjbot.features.moderation.scam; + +import org.togetherjava.tjbot.config.ScamBlockerConfig; +import org.togetherjava.tjbot.features.utils.StringDistances; + +import java.net.URI; +import java.util.Locale; + +/** + * Analyzes a given text token. Populates various metrics regarding the token possibly being + * suspicious, returning back results of the token analyze. + * + * Highly configurable, using {@link ScamBlockerConfig}. Entry point to use is + * {@link #analyze(String, AnalyseResults)}. + */ +final class TokenAnalyse { + private final ScamBlockerConfig config; + + TokenAnalyse(ScamBlockerConfig config) { + this.config = config; + } + + /** + * Analyzes the given token about being suspicious. + * + * @param token the token to analyze + * @param results metrics representing how suspicious the token is + */ + void analyze(String token, AnalyseResults results) { + if (token.isBlank()) { + return; + } + + if (!results.pingsEveryone() + && ("@everyone".equalsIgnoreCase(token) || "@here".equalsIgnoreCase(token))) { + results.markPingsEveryone(); + } + + if (!results.containsSuspiciousKeyword() && containsSuspiciousKeyword(token)) { + results.markContainsSuspiciousKeyword(); + } + + if (!results.containsDollarSign() + && (token.contains("$") || "usd".equalsIgnoreCase(token))) { + results.markContainsDollarSign(); + } + + if (token.startsWith("http")) { + analyzeUrl(token, results); + } else { + results.markNonUrlTokenFound(); + } + } + + private boolean containsSuspiciousKeyword(String token) { + String preparedToken = token.toLowerCase(Locale.US); + + // Checks the token against various keywords from the config + // The keywords support some regex-inspired syntax + return config.getSuspiciousKeywords() + .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); + }); + } + + private void analyzeUrl(String url, AnalyseResults results) { + String host; + String path; + try { + URI uri = URI.create(url); + host = uri.getHost(); + path = uri.getPath(); + } catch (IllegalArgumentException _) { + // Invalid urls are not scam + return; + } + + if (host == null) { + return; + } + + AnalyseResults.AnalyseUrlResult result = new AnalyseResults.AnalyseUrlResult(); + results.addUrlResult(result); + + if (path != null && path.startsWith("/attachments")) { + // The url represents an attachment link, for example a Discord CDN link + result.setContainedAttachment(Attachment.fromUrlPath(path)); + } + + if (isHostSuspicious(host)) { + result.markSuspicious(); + } + } + + private boolean isHostSuspicious(String host) { + if (config.getHostWhitelist().contains(host)) { + return false; + } + + if (config.getHostBlacklist().contains(host)) { + return true; + } + + for (String keyword : config.getSuspiciousHostKeywords()) { + if (isHostSimilarToKeyword(host, keyword)) { + return true; + } + } + + return false; + } + + 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. + + // 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 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; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/LinkPreviews.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/LinkPreviews.java index 6126e05e57..44e53a8538 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/utils/LinkPreviews.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/LinkPreviews.java @@ -18,15 +18,15 @@ import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.function.Predicate; import java.util.stream.IntStream; /** - * Provides means to create previews of links. See - * {@link LinkDetection#extractLinks(String, boolean, boolean)} and - * {@link #createLinkPreviews(List)}. + * Provides means to create previews of links. See {@link LinkDetection#extractLinks(String, Set)} + * and {@link #createLinkPreviews(List)}. */ public final class LinkPreviews { private static final Logger logger = LoggerFactory.getLogger(LinkPreviews.class); diff --git a/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/AttachmentTest.java b/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/AttachmentTest.java new file mode 100644 index 0000000000..4e77dab509 --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/features/moderation/scam/AttachmentTest.java @@ -0,0 +1,85 @@ +package org.togetherjava.tjbot.features.moderation.scam; + +import net.dv8tion.jda.api.entities.Message; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +final class AttachmentTest { + @ParameterizedTest + @ValueSource(strings = {"foo.jpg", "a.png", ".jpeg", "image.gif"}) + @DisplayName("Can detect attachments that represent images") + void detectsImage(String fileName) { + // GIVEN an attachment representing an image + Attachment imageAttachment = new Attachment(fileName); + + // WHEN checking if image + boolean isImage = imageAttachment.isImage(); + + // THEN detects it as image + assertTrue(isImage); + } + + @ParameterizedTest + @ValueSource(strings = {"foo", "foo.pdf", "foo/bar", "", "jpg"}) + @DisplayName("Can detect attachments that do not represent images") + void detectsNonImage(String fileName) { + // GIVEN an attachment not representing an image + Attachment nonImageAttachment = new Attachment(fileName); + + // WHEN checking if image + boolean isImage = nonImageAttachment.isImage(); + + // THEN detects that it is not an image + assertFalse(isImage); + } + + @ParameterizedTest + @MethodSource("provideUrlPathTests") + @DisplayName("Can extract attachment from a URL path") + void extractsFromUrlPath(String urlPath, String expectedFileName, boolean expectedIsImage) { + // GIVEN a URL path + // WHEN extracting the attachment + Attachment attachment = Attachment.fromUrlPath(urlPath); + + // THEN values are extracted correctly + assertEquals(expectedFileName, attachment.fileName()); + assertEquals(expectedIsImage, attachment.isImage()); + } + + private static Stream provideUrlPathTests() { + return Stream.of(Arguments.of("http://foo.com/bar/baz.png", "baz.png", true), + Arguments.of("http://foo.com/bar/baz.exe", "baz.exe", false), + Arguments.of("foo/bar", "bar", false), Arguments.of("foo", "", false)); + } + + @ParameterizedTest + @MethodSource("provideDiscordTests") + @DisplayName("Can extract attachment from a Discord attachment") + void extractsFromDiscord(String expectedFileName, boolean expectedIsImage) { + // GIVEN a Discord attachment + Message.Attachment discordAttachment = mock(Message.Attachment.class); + when(discordAttachment.getFileName()).thenReturn(expectedFileName); + + // WHEN extracting the attachment + Attachment attachment = Attachment.fromDiscord(discordAttachment); + + // THEN values are extracted correctly + assertEquals(expectedFileName, attachment.fileName()); + assertEquals(expectedIsImage, attachment.isImage()); + } + + private static Stream provideDiscordTests() { + return Stream.of(Arguments.of("foo.png", true), Arguments.of("foo/bar.jpg", true), + Arguments.of("foo.exe", false), Arguments.of("foo", false), + Arguments.of("", false)); + } +} 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 f197cc7708..4cf982b3f4 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 @@ -24,7 +24,7 @@ final class ScamDetectorTest { private static final int SUSPICIOUS_ATTACHMENTS_THRESHOLD = 3; - private static final String SUSPICIOUS_ATTACHMENT_NAME = "scam.png"; + private static final String SUSPICIOUS_ATTACHMENT_NAME = "image.png"; private ScamDetector scamDetector; @@ -40,7 +40,8 @@ void setUp() { "freenitro", "^earn$", "^earning", ".exe$", "mrbeast")); when(scamConfig.getHostWhitelist()).thenReturn(Set.of("discord.com", "discord.media", "discordapp.com", "discordapp.net", "discordstatus.com", "thehackernews.com", - "gradle.org", "help.gradle.org", "youtube.com", "www.youtube.com")); + "gradle.org", "help.gradle.org", "youtube.com", "www.youtube.com", + "cdn.discordapp.com", "media.discordapp.net")); 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", "tobimoller.space")); @@ -50,7 +51,7 @@ void setUp() { when(scamConfig.getSuspiciousAttachmentsThreshold()) .thenReturn(SUSPICIOUS_ATTACHMENTS_THRESHOLD); when(scamConfig.getSuspiciousAttachmentNamePattern()) - .thenReturn(SUSPICIOUS_ATTACHMENT_NAME); + .thenReturn("(image|\\d{1,2})\\.[^.]{0,5}"); when(scamConfig.getTrustedUserRolePattern()).thenReturn("Moderator"); @@ -401,7 +402,16 @@ B2CWorkflow Builder (React Flow) 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/", - "Bro, claim 0.1 BTC now! Use promo code \"mrbeast\" at expmcoins.com screen @everyone"); + "Bro, claim 0.1 BTC now! Use promo code \"mrbeast\" at expmcoins.com screen @everyone", + """ + https://cdn.discordapp.com/attachments/1234/5678/image.png?ex=688cd552&is=688b83d2&hm=5787b53f08a488a22df6e3d2d43b4445ed0ced5f790e4f6e6e82810e38dba2aa& + https://cdn.discordapp.com/attachments/1234/5678/image.png?ex=688cd552&is=688b83d2&hm=5787b53f08a488a22df6e3d2d43b4445ed0ced5f790e4f6e6e82810e38dba2aa& + https://cdn.discordapp.com/attachments/1234/5678/image.png?ex=688cd552&is=688b83d2&hm=5787b53f08a488a22df6e3d2d43b4445ed0ced5f790e4f6e6e82810e38dba2aa&""", + """ + https://cdn.discordapp.com/attachments/1234/5678/1.png?ex=688cd552&is=688b83d2&hm=5787b53f08a488a22df6e3d2d43b4445ed0ced5f790e4f6e6e82810e38dba2aa& + https://cdn.discordapp.com/attachments/1234/5678/2.png?ex=688cd552&is=688b83d2&hm=5787b53f08a488a22df6e3d2d43b4445ed0ced5f790e4f6e6e82810e38dba2aa& + https://cdn.discordapp.com/attachments/1234/5678/3.png?ex=688cd552&is=688b83d2&hm=5787b53f08a488a22df6e3d2d43b4445ed0ced5f790e4f6e6e82810e38dba2aa& + https://cdn.discordapp.com/attachments/1234/5678/4.png?ex=688cd552&is=688b83d2&hm=5787b53f08a488a22df6e3d2d43b4445ed0ced5f790e4f6e6e82810e38dba2aa&"""); } private static List provideRealFalsePositiveMessages() {