Skip to content

Commit f6348a9

Browse files
committed
Antispam module
1 parent 0106473 commit f6348a9

23 files changed

Lines changed: 449 additions & 32 deletions

paper/src/main/java/io/wdsj/asw/bukkit/AdvancedSensitiveWords.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import io.wdsj.asw.bukkit.ai.LlmChatDetectionService;
1717
import io.wdsj.asw.bukkit.core.condition.WordResultConditionNumMatch;
1818
import io.wdsj.asw.bukkit.integration.placeholder.ASWExpansion;
19+
import io.wdsj.asw.bukkit.service.chat.antispam.ChatAntiSpamService;
1920
import io.wdsj.asw.bukkit.manage.punish.PlayerAltController;
2021
import io.wdsj.asw.bukkit.manage.punish.PlayerShadowController;
2122
import io.wdsj.asw.bukkit.manage.punish.ViolationCounter;
@@ -170,6 +171,7 @@ public void onDisable() {
170171
TimingUtils.resetStatistics();
171172
ChatContext.forceClearContext();
172173
SignContext.forceClearContext();
174+
ChatAntiSpamService.clearAll();
173175
PlayerShadowController.clear();
174176
PlayerAltController.clear();
175177
BookCache.invalidateAll();

paper/src/main/java/io/wdsj/asw/bukkit/ai/LlmChatDetectionService.java

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,7 @@
1616
import org.bukkit.event.player.PlayerKickEvent;
1717
import org.bukkit.event.player.PlayerQuitEvent;
1818

19-
import java.util.Map;
20-
import java.util.Objects;
21-
import java.util.Optional;
22-
import java.util.UUID;
19+
import java.util.*;
2320
import java.util.concurrent.ArrayBlockingQueue;
2421
import java.util.concurrent.ConcurrentHashMap;
2522
import java.util.concurrent.ConcurrentMap;
@@ -195,7 +192,7 @@ private RuntimeState createRuntime(long nextGeneration) {
195192
ThreadPoolExecutor executor = new ThreadPoolExecutor(
196193
settings.maxConcurrentRequests(),
197194
settings.maxConcurrentRequests(),
198-
30L,
195+
5L,
199196
TimeUnit.SECONDS,
200197
new ArrayBlockingQueue<>(settings.queueCapacity()),
201198
Thread.ofVirtual().name("ASW LangChain Moderation", 0L).factory(),
@@ -390,7 +387,7 @@ private static RawResponse limitRawResponse(String response) {
390387
private static Map<LlmModerationCategory, LlmCategoryPolicy> toCategoryPolicies(
391388
Map<String, SettingsConfiguration.Ai.CategoryPolicy> configuredPolicies
392389
) {
393-
Map<LlmModerationCategory, LlmCategoryPolicy> policies = new java.util.EnumMap<>(LlmModerationCategory.class);
390+
Map<LlmModerationCategory, LlmCategoryPolicy> policies = new EnumMap<>(LlmModerationCategory.class);
394391
for (LlmModerationCategory category : LlmModerationCategory.values()) {
395392
SettingsConfiguration.Ai.CategoryPolicy policy = configuredPolicies.get(category.configurationKey());
396393
policies.put(category, new LlmCategoryPolicy(
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package io.wdsj.asw.bukkit.api.event;
2+
3+
import io.wdsj.asw.bukkit.type.ModuleType;
4+
import org.bukkit.event.Event;
5+
import org.bukkit.event.HandlerList;
6+
import org.jetbrains.annotations.ApiStatus;
7+
import org.jetbrains.annotations.Nullable;
8+
9+
import java.util.List;
10+
import java.util.Objects;
11+
import java.util.UUID;
12+
13+
/**
14+
* Fired after ASW completes a DFA sensitive-word filtering pass.
15+
*
16+
* <p>This event is observational. It cannot change ASW's decision, replacement output, cancellation state,
17+
* notification, logging, or punishment. The event follows the thread of the original Bukkit/Paper event; when
18+
* asynchronous, handlers must not directly access Bukkit world, entity, or inventory APIs.</p>
19+
*/
20+
@SuppressWarnings("unused")
21+
public final class SensitiveFilterPostProcessEvent extends Event {
22+
private static final HandlerList HANDLERS = new HandlerList();
23+
24+
private final ModuleType moduleType;
25+
private final UUID playerId;
26+
private final String playerName;
27+
private final String originalContent;
28+
private final List<String> censoredWords;
29+
private final boolean detected;
30+
31+
@ApiStatus.Internal
32+
public SensitiveFilterPostProcessEvent(
33+
boolean asynchronous,
34+
ModuleType moduleType,
35+
@Nullable UUID playerId,
36+
@Nullable String playerName,
37+
String originalContent,
38+
List<String> censoredWords
39+
) {
40+
super(asynchronous);
41+
this.moduleType = Objects.requireNonNull(moduleType, "moduleType");
42+
this.playerId = playerId;
43+
this.playerName = playerName;
44+
this.originalContent = Objects.requireNonNull(originalContent, "originalContent");
45+
this.censoredWords = List.copyOf(censoredWords);
46+
this.detected = !this.censoredWords.isEmpty();
47+
}
48+
49+
public ModuleType getModuleType() {
50+
return moduleType;
51+
}
52+
53+
/**
54+
* @return player UUID when the filtered content belongs to a player, or {@code null} for playerless
55+
* sources such as server broadcasts
56+
*/
57+
@Nullable
58+
public UUID getPlayerId() {
59+
return playerId;
60+
}
61+
62+
/**
63+
* @return player name when the filtered content belongs to a player, or {@code null} for playerless
64+
* sources such as server broadcasts
65+
*/
66+
@Nullable
67+
public String getPlayerName() {
68+
return playerName;
69+
}
70+
71+
public String getOriginalContent() {
72+
return originalContent;
73+
}
74+
75+
public List<String> getCensoredWords() {
76+
return censoredWords;
77+
}
78+
79+
public boolean isDetected() {
80+
return detected;
81+
}
82+
83+
@Override
84+
public HandlerList getHandlers() {
85+
return HANDLERS;
86+
}
87+
88+
public static HandlerList getHandlerList() {
89+
return HANDLERS;
90+
}
91+
}

paper/src/main/java/io/wdsj/asw/bukkit/service/ListenerService.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,7 @@ public void registerListeners() {
5050
if (configuration.get(PluginSettings.CHAT_BROADCAST_CHECK)) {
5151
registerEventListener(new BroadcastListener(configuration));
5252
}
53-
if (configuration.get(PluginSettings.CLEAN_PLAYER_DATA_CACHE)) {
54-
registerEventListener(new QuitDataCleaner(configuration));
55-
}
53+
registerEventListener(new QuitDataCleaner());
5654
if (configuration.get(PluginSettings.CHECK_FOR_UPDATE)) {
5755
registerEventListener(new JoinUpdateNotifier(configuration));
5856
}

paper/src/main/java/io/wdsj/asw/bukkit/setting/ChineseMessagesConfiguration.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public ChineseMessagesConfiguration() {
1111
book.messageOnBook = "<red>请勿在书中写入敏感词汇.";
1212
name.messageOnName = "<red>您的用户名包含敏感词,请修改您的用户名或联系管理员.";
1313
item.messageOnItem = "<red>您的物品包含敏感词.";
14+
chat.messageOnChatAntiSpam = "<yellow>请放慢聊天速度,避免重复或相似刷屏。";
1415
plugin.messageOnCommandReload = "<green>AdvancedSensitiveWords 已重新加载.";
1516
plugin.messageOnViolationReset = "<gradient:#22d3ee:#4ade80><bold>ASWNotify</bold></gradient> <dark_gray>| <green>已重置所有玩家的违规次数。";
1617
plugin.messageOnCommandStatus = """

paper/src/main/java/io/wdsj/asw/bukkit/setting/MessagesConfiguration.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public abstract class MessagesConfiguration {
2424
public static final class Chat {
2525
@Comment("Message sent when chat or command content is blocked.")
2626
public String messageOnChat = "<red>Your message contains blocked words.";
27+
@Comment("Message sent when chat anti-spam cancels a message.")
28+
public String messageOnChatAntiSpam = "<yellow>Please slow down and avoid repeating similar messages.";
2729
}
2830

2931
@Configuration

paper/src/main/java/io/wdsj/asw/bukkit/setting/PaperConfigurationService.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import java.nio.file.Path;
1414
import java.util.*;
1515
import java.util.stream.Collectors;
16+
import java.util.regex.Pattern;
17+
import java.util.regex.PatternSyntaxException;
1618

1719
/**
1820
* Owns the Paper configuration snapshots and their ConfigLib stores.
@@ -87,6 +89,7 @@ public String message(PluginMessages key) {
8789
}
8890
return switch (key) {
8991
case MESSAGE_ON_CHAT -> snapshot.chat.messageOnChat;
92+
case MESSAGE_ON_CHAT_ANTI_SPAM -> snapshot.chat.messageOnChatAntiSpam;
9093
case MESSAGE_ON_SIGN -> snapshot.sign.messageOnSign;
9194
case MESSAGE_ON_ANVIL_RENAME -> snapshot.anvil.messageOnAnvilRename;
9295
case MESSAGE_ON_BOOK -> snapshot.book.messageOnBook;
@@ -162,6 +165,7 @@ static void validateSettings(SettingsConfiguration settings) {
162165
throw new IllegalArgumentException("ai.minimum-entropy-bits must be a finite non-negative number");
163166
}
164167
validateCategoryPolicy(ai.categoryPolicy);
168+
validateAntiSpam(settings.chat.antiSpam);
165169
CommandArgumentRuleSet.compile(settings.chat.commandWhiteList);
166170

167171
if (!ai.enabled) {
@@ -214,6 +218,39 @@ private static void validateCategoryThreshold(double threshold, LlmModerationCat
214218
}
215219
}
216220

221+
private static void validateAntiSpam(SettingsConfiguration.AntiSpam antiSpam) {
222+
if (antiSpam == null) {
223+
throw new IllegalArgumentException("chat.anti-spam cannot be null");
224+
}
225+
try {
226+
Pattern.compile(antiSpam.preprocessRegex == null ? "" : antiSpam.preprocessRegex);
227+
} catch (PatternSyntaxException exception) {
228+
throw new IllegalArgumentException("chat.anti-spam.preprocess-regex must be a valid regular expression", exception);
229+
}
230+
if (antiSpam.minimumEntropyCodePoints < 1 || antiSpam.minimumSimilarityCodePoints < 1) {
231+
throw new IllegalArgumentException("chat.anti-spam minimum code point settings must be at least 1");
232+
}
233+
if (!Double.isFinite(antiSpam.minimumEntropyBits)
234+
|| (antiSpam.minimumEntropyBits != -1.0D && antiSpam.minimumEntropyBits < 0.0D)) {
235+
throw new IllegalArgumentException("chat.anti-spam.minimum-entropy-bits must be -1.0 or a finite non-negative number");
236+
}
237+
if (!Double.isFinite(antiSpam.minimumAverageEntropy)
238+
|| (antiSpam.minimumAverageEntropy != -1.0D && antiSpam.minimumAverageEntropy < 0.0D)) {
239+
throw new IllegalArgumentException("chat.anti-spam.minimum-average-entropy must be -1.0 or a finite non-negative number");
240+
}
241+
if (antiSpam.historySize < 1 || antiSpam.historyMaxAgeSeconds < 1 || antiSpam.similarCheckAmount < 1) {
242+
throw new IllegalArgumentException("chat.anti-spam history settings must be at least 1");
243+
}
244+
if (antiSpam.similarMinDistance < -1) {
245+
throw new IllegalArgumentException("chat.anti-spam.similar-min-distance must be -1 or greater");
246+
}
247+
if (!Double.isFinite(antiSpam.similarMaxSimilarity)
248+
|| antiSpam.similarMaxSimilarity < 0.0D
249+
|| antiSpam.similarMaxSimilarity > 1.1D) {
250+
throw new IllegalArgumentException("chat.anti-spam.similar-max-similarity must be between 0.0 and 1.1");
251+
}
252+
}
253+
217254
private static void validateHttpUrl(String value, String settingName) {
218255
requireText(value, settingName);
219256
URI uri;

paper/src/main/java/io/wdsj/asw/bukkit/setting/PluginMessages.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
public enum PluginMessages {
77
MESSAGE_ON_CHAT,
8+
MESSAGE_ON_CHAT_ANTI_SPAM,
89
MESSAGE_ON_SIGN,
910
MESSAGE_ON_ANVIL_RENAME,
1011
MESSAGE_ON_BOOK,

paper/src/main/java/io/wdsj/asw/bukkit/setting/PluginSettings.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ public final class PluginSettings {
3535
public static final SettingKey<Boolean> ENABLE_PLAYER_NAME_CHECK = key(settings -> settings.plugin.enablePlayerNameCheck);
3636
public static final SettingKey<Boolean> ENABLE_PLAYER_ITEM_CHECK = key(settings -> settings.plugin.enablePlayerItemCheck);
3737
public static final SettingKey<Boolean> ENABLE_ALTS_CHECK = key(settings -> settings.plugin.enableAltsCheck);
38-
public static final SettingKey<Boolean> CLEAN_PLAYER_DATA_CACHE = key(settings -> settings.plugin.cleanPlayerDataCache);
3938
public static final SettingKey<Boolean> ENABLE_PLACEHOLDER = key(settings -> settings.plugin.enablePlaceholder);
4039
public static final SettingKey<Boolean> HOOK_VELOCITY = key(settings -> settings.plugin.hookVelocity);
4140
public static final SettingKey<Boolean> ENABLE_AUTHME_COMPATIBILITY = key(settings -> settings.plugin.compatibility.authMe);
@@ -63,6 +62,18 @@ public final class PluginSettings {
6362
public static final SettingKey<ProcessMethod> CHAT_METHOD = key(settings -> settings.chat.method);
6463
public static final SettingKey<Boolean> CHAT_FAKE_MESSAGE_ON_CANCEL = key(settings -> settings.chat.fakeMessageOnCancel);
6564
public static final SettingKey<Boolean> CHAT_SEND_MESSAGE = key(settings -> settings.chat.sendMessage);
65+
public static final SettingKey<Boolean> CHAT_ANTI_SPAM_ENABLED = key(settings -> settings.chat.antiSpam.enabled);
66+
public static final SettingKey<String> CHAT_ANTI_SPAM_PREPROCESS_REGEX = key(settings -> settings.chat.antiSpam.preprocessRegex);
67+
public static final SettingKey<Integer> CHAT_ANTI_SPAM_MINIMUM_ENTROPY_CODE_POINTS = key(settings -> settings.chat.antiSpam.minimumEntropyCodePoints);
68+
public static final SettingKey<Integer> CHAT_ANTI_SPAM_MINIMUM_SIMILARITY_CODE_POINTS = key(settings -> settings.chat.antiSpam.minimumSimilarityCodePoints);
69+
public static final SettingKey<Double> CHAT_ANTI_SPAM_MINIMUM_ENTROPY_BITS = key(settings -> settings.chat.antiSpam.minimumEntropyBits);
70+
public static final SettingKey<Double> CHAT_ANTI_SPAM_MINIMUM_AVERAGE_ENTROPY = key(settings -> settings.chat.antiSpam.minimumAverageEntropy);
71+
public static final SettingKey<Integer> CHAT_ANTI_SPAM_HISTORY_SIZE = key(settings -> settings.chat.antiSpam.historySize);
72+
public static final SettingKey<Integer> CHAT_ANTI_SPAM_HISTORY_MAX_AGE_SECONDS = key(settings -> settings.chat.antiSpam.historyMaxAgeSeconds);
73+
public static final SettingKey<Integer> CHAT_ANTI_SPAM_SIMILAR_CHECK_AMOUNT = key(settings -> settings.chat.antiSpam.similarCheckAmount);
74+
public static final SettingKey<Integer> CHAT_ANTI_SPAM_SIMILAR_MIN_DISTANCE = key(settings -> settings.chat.antiSpam.similarMinDistance);
75+
public static final SettingKey<Double> CHAT_ANTI_SPAM_SIMILAR_MAX_SIMILARITY = key(settings -> settings.chat.antiSpam.similarMaxSimilarity);
76+
public static final SettingKey<Boolean> CHAT_ANTI_SPAM_SEND_MESSAGE = key(settings -> settings.chat.antiSpam.sendMessage);
6677
public static final SettingKey<List<String>> CHAT_PUNISHMENT = key(settings -> settings.chat.punishment);
6778
public static final SettingKey<Boolean> CHAT_BROADCAST_CHECK = key(settings -> settings.chat.broadcastCheck);
6879
public static final SettingKey<Boolean> CHAT_CONTEXT_CHECK = key(settings -> settings.chat.contextCheck);

paper/src/main/java/io/wdsj/asw/bukkit/setting/SettingsConfiguration.java

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,6 @@ public static final class Plugin {
8686
public boolean enablePlayerItemCheck = false;
8787
@Comment("Whether to track alternate accounts by IP address.")
8888
public boolean enableAltsCheck = false;
89-
@Comment("Whether to clear player-related caches when players leave.")
90-
public boolean cleanPlayerDataCache = true;
9189
@Comment("Whether to enable PlaceholderAPI support.")
9290
public boolean enablePlaceholder = false;
9391
@Comment("Whether to send violation notifications through Velocity.")
@@ -150,6 +148,8 @@ public static final class Chat {
150148
public boolean fakeMessageOnCancel = false;
151149
@Comment("Whether to send the player a violation message.")
152150
public boolean sendMessage = true;
151+
@Comment("Chat anti-spam settings. This runs before sensitive-word matching and only cancels spam by default.")
152+
public AntiSpam antiSpam = new AntiSpam();
153153
@Comment("Punishment actions for chat and command violations. Leave empty to disable automatic punishment.")
154154
public List<String> punishment = new ArrayList<>();
155155
@Comment("Whether to check server broadcast messages.")
@@ -180,6 +180,34 @@ public static final class Chat {
180180
));
181181
}
182182

183+
@Configuration
184+
public static final class AntiSpam {
185+
@Comment("Whether to enable chat anti-spam before sensitive-word filtering.")
186+
public boolean enabled = false;
187+
@Comment("Regular expression removed before anti-spam checks. Defaults to legacy color and formatting codes.")
188+
public String preprocessRegex = "[§&][0-9A-Fa-fK-Ok-oRr]";
189+
@Comment("Minimum visible code points before entropy-based anti-spam checks run. Must be >= 1; 4-8 avoids noisy entropy results on very short text.")
190+
public int minimumEntropyCodePoints = 4;
191+
@Comment("Minimum visible code points before repeated/similar message checks run. Must be >= 1; use 1 to catch short spam like repeated punctuation.")
192+
public int minimumSimilarityCodePoints = 1;
193+
@Comment("Minimum Shannon entropy in bits. Messages below this value are treated as spam. Use -1.0 to disable.")
194+
public double minimumEntropyBits = -1.0D;
195+
@Comment("Minimum normalized entropy. Messages below this value are treated as spam. Use -1.0 to disable.")
196+
public double minimumAverageEntropy = -1.0D;
197+
@Comment("Maximum recent chat messages retained per player for similarity checks.")
198+
public int historySize = 6;
199+
@Comment("Maximum age in seconds for retained anti-spam history.")
200+
public int historyMaxAgeSeconds = 30;
201+
@Comment("How many recent messages are compared for repeated/similar message spam.")
202+
public int similarCheckAmount = 3;
203+
@Comment("Maximum edit distance still considered too similar. Use -1 to disable distance checks.")
204+
public int similarMinDistance = 2;
205+
@Comment("Similarity threshold from 0.0 to 1.0. Messages at or above this value are treated as spam. Use 1.1 to disable.")
206+
public double similarMaxSimilarity = 0.90D;
207+
@Comment("Whether to send a message when anti-spam cancels chat.")
208+
public boolean sendMessage = true;
209+
}
210+
183211
@Configuration
184212
public static final class Ai {
185213
@Comment("Whether to enable LLM-assisted chat moderation.")
@@ -212,12 +240,12 @@ public static final class Ai {
212240
public int queueCapacity = 32;
213241
@Comment("Minimum seconds between LLM requests from the same player. Must be >= 0; 5-20 reduces spam and provider cost.")
214242
public int perPlayerCooldownSeconds = 10;
215-
@Comment("Minimum visible Unicode code points before an LLM request is considered. Must be >= 1; 4-8 saves requests for short messages.")
216-
public int minimumMessageCodePoints = 6;
243+
@Comment("Minimum visible Unicode code points before an LLM request is considered. Must be >= 1; 3-4 keeps short semantic abuse eligible.")
244+
public int minimumMessageCodePoints = 3;
217245
@Comment("Maximum total Unicode code points accepted by the LLM request gate. Must be >= minimum-message-code-points; 128-512 is recommended.")
218246
public int maximumMessageCodePoints = 256;
219-
@Comment("Minimum Shannon entropy in bits per visible Unicode code point. Must be finite and >= 0; 2.0-3.5 is a typical request-saving gate.")
220-
public double minimumEntropyBits = 2.5D;
247+
@Comment("Minimum Shannon entropy in bits per visible Unicode code point. Use 0.0 to disable this cost-saving gate; 2.0-3.5 reduces requests but may miss short natural abuse.")
248+
public double minimumEntropyBits = 0.0D;
221249
@Comment({
222250
"Per-category LLM notification, punishment confidence, and punishment action policies.",
223251
"Use -1.0 to disable either action. Other values must be between 0.0 and 1.0.",

0 commit comments

Comments
 (0)