Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion application/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
25 changes: 18 additions & 7 deletions application/config.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -38,21 +39,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",
Expand All @@ -66,7 +73,8 @@
"telegra.ph",
"shorturl.at",
"cheatings.xyz",
"transfer.sh"
"transfer.sh",
"tobimoller.space"
],
"suspiciousHostKeywords": [
"discord",
Expand All @@ -75,9 +83,12 @@
"free",
"cheat",
"crypto",
"tele"
"telegra",
"telety"
],
"isHostSimilarToKeywordDistanceThreshold": 2
"isHostSimilarToKeywordDistanceThreshold": 2,
"suspiciousAttachmentsThreshold": 3,
"suspiciousAttachmentNamePattern": "(image|\\d{1,2})\\.[^.]{0,5}"
},
"wolframAlphaAppId": "79J52T-6239TVXHR7",
"helpSystem": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,34 +18,47 @@ public final class ScamBlockerConfig {
private final Mode mode;
private final String reportChannelPattern;
private final String botTrapChannelPattern;
private final String trustedUserRolePattern;
private final Set<String> suspiciousKeywords;
private final Set<String> hostWhitelist;
private final Set<String> hostBlacklist;
private final Set<String> 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,
@JsonProperty(value = "reportChannelPattern",
required = true) String reportChannelPattern,
@JsonProperty(value = "botTrapChannelPattern",
required = true) String botTrapChannelPattern,
@JsonProperty(value = "trustedUserRolePattern",
required = true) String trustedUserRolePattern,
@JsonProperty(value = "suspiciousKeywords",
required = true) Set<String> suspiciousKeywords,
@JsonProperty(value = "hostWhitelist", required = true) Set<String> hostWhitelist,
@JsonProperty(value = "hostBlacklist", required = true) Set<String> hostBlacklist,
@JsonProperty(value = "suspiciousHostKeywords",
required = true) Set<String> 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);
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));
this.suspiciousHostKeywords = new HashSet<>(Objects.requireNonNull(suspiciousHostKeywords));
this.isHostSimilarToKeywordDistanceThreshold = isHostSimilarToKeywordDistanceThreshold;
this.suspiciousAttachmentsThreshold = suspiciousAttachmentsThreshold;
this.suspiciousAttachmentNamePattern =
Objects.requireNonNull(suspiciousAttachmentNamePattern);
}

/**
Expand Down Expand Up @@ -77,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.
*
Expand Down Expand Up @@ -125,6 +147,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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
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;
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;

Expand All @@ -18,6 +26,8 @@
public final class ScamDetector {
private static final Pattern TOKENIZER = Pattern.compile("[\\s,]");
private final ScamBlockerConfig config;
private final Predicate<String> isSuspiciousAttachmentName;
private final Predicate<String> hasTrustedRole;

/**
* Creates a new instance with the given configuration
Expand All @@ -26,6 +36,36 @@ public final class ScamDetector {
*/
public ScamDetector(Config config) {
this.config = config.getScamBlocker();

isSuspiciousAttachmentName =
Pattern.compile(this.config.getSuspiciousAttachmentNamePattern())
.asMatchPredicate();
hasTrustedRole =
Pattern.compile(this.config.getTrustedUserRolePattern()).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) {
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<Message.Attachment> attachments = message.getAttachments();

if (content.isBlank()) {
return areAttachmentsSuspicious(attachments);
}

return isScam(content);
}

/**
Expand Down Expand Up @@ -115,14 +155,32 @@ 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);
});
}

private boolean areAttachmentsSuspicious(Collection<? extends Message.Attachment> 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.
Expand Down Expand Up @@ -150,11 +208,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();
}
}
}
Loading
Loading