feat: anti-swear obfuscation bypass + filter statistics
- SwearNormalizer: expanded leet/lookalike map (q→р, digits), strips separators inside words, collapses repeated letters; single source of truth for AhoCorasick - SwearFilter: detects obfuscated profanity on normalized text, masks tolerant matches in replace mode via tolerant word patterns - FilterStatistics: per-filter block counters -> /lochat filterstats subcommand - tests: SwearNormalizerTest, FilterStatisticsTest
This commit is contained in:
parent
cda89c78e4
commit
689ab1a9ae
9 changed files with 435 additions and 46 deletions
6
TODO.md
6
TODO.md
|
|
@ -72,9 +72,9 @@
|
|||
- Party chat: private groups `/party`.
|
||||
- Network channels (Bungee/Velocity) sync.
|
||||
|
||||
### 2. Anti-swear hardening + filter statistics
|
||||
- Bypass obfuscation: `q→р` leet/Unicode substitution, repeated chars between letters, spaces/dots inside words.
|
||||
- Track filter stats (how many messages blocked per filter) → expose via command/placeholder.
|
||||
### 2. Anti-swear hardening + filter statistics ✅
|
||||
- Obfuscation bypass: `SwearNormalizer` (q→р / leet-подстановки, повторы букв «сууука», разделители внутри слова «с.у.к.а» / «с у к а») + tolerant-маскирование в replace-режиме. Single source of truth для Aho-Corasick.
|
||||
- Filter stats: `FilterStatistics` считает блокировки по каждому фильтру → `/lochat filterstats`.
|
||||
|
||||
### 3. Stable plugin API + API events
|
||||
- Publish `api/` modules to a repo so third-party plugins can depend on it.
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ public class LoChatCommand implements CommandExecutor, TabCompleter {
|
|||
sender.sendMessage(ChatFormatter.parse("ẲE9LoChat &#B798A8v" + plugin.getPluginMeta().getVersion()));
|
||||
sender.sendMessage(ChatFormatter.parse("⚖C9/lochat reload &#B798A8— перезагрузить конфиги"));
|
||||
sender.sendMessage(ChatFormatter.parse("⚖C9/lochat commands [reload] &#B798A8— кастомные команды"));
|
||||
sender.sendMessage(ChatFormatter.parse("⚖C9/lochat filterstats &#B798A8— статистика фильтров чата"));
|
||||
return true;
|
||||
}
|
||||
switch (args[0].toLowerCase()) {
|
||||
|
|
@ -49,6 +50,23 @@ public class LoChatCommand implements CommandExecutor, TabCompleter {
|
|||
ChatFormatter.parse("ẲE9/" + c.name() + " &#B798A8(" + c.type() + ")")));
|
||||
}
|
||||
}
|
||||
case "filterstats" -> {
|
||||
var filter = plugin.getChatFilter();
|
||||
if (filter == null) {
|
||||
sender.sendMessage(ChatFormatter.parse("&#CF6679Фильтр чата недоступен"));
|
||||
break;
|
||||
}
|
||||
var stats = filter.getStatistics();
|
||||
long total = stats.getTotalBlocked();
|
||||
sender.sendMessage(ChatFormatter.parse(
|
||||
"&#B798A8Фильтры чата: ⚖C9заблокировано ẲE9" + total + "⚖C9 сообщений"));
|
||||
if (total == 0) {
|
||||
sender.sendMessage(ChatFormatter.parse("&#B798A8Пока нечего показывать"));
|
||||
break;
|
||||
}
|
||||
stats.snapshot().forEach((name, count) -> sender.sendMessage(
|
||||
ChatFormatter.parse("ẲE9" + name + ":⚖C9 " + count)));
|
||||
}
|
||||
default -> sender.sendMessage(ChatFormatter.parse("&#CF6679Неизвестная команда"));
|
||||
}
|
||||
return true;
|
||||
|
|
@ -63,7 +81,8 @@ public class LoChatCommand implements CommandExecutor, TabCompleter {
|
|||
return new ArrayList<>();
|
||||
}
|
||||
if (args.length == 1) {
|
||||
return List.of("reload", "commands").stream().filter(s -> s.startsWith(args[0].toLowerCase())).toList();
|
||||
return List.of("reload", "commands", "filterstats").stream()
|
||||
.filter(s -> s.startsWith(args[0].toLowerCase())).toList();
|
||||
}
|
||||
if (args.length == 2 && args[0].equalsIgnoreCase("commands")) {
|
||||
return List.of("reload");
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ public class AdvancedMessageFilter {
|
|||
private final SpamFilter spamFilter;
|
||||
private final UrlFilter urlFilter;
|
||||
private final CharacterFilter characterFilter;
|
||||
private final FilterStatistics statistics = new FilterStatistics();
|
||||
|
||||
public AdvancedMessageFilter(FileConfiguration config, JavaPlugin plugin) {
|
||||
this.filtersConfig = resolveFiltersConfig(plugin);
|
||||
|
|
@ -72,6 +73,7 @@ public class AdvancedMessageFilter {
|
|||
if (filtersConfig.isCapsEnabled()) {
|
||||
String filtered = capsFilter.filter(player, message);
|
||||
if (filtered == null) {
|
||||
statistics.increment("caps");
|
||||
return FilterResult.blocked("&#CF6679Не кричите в чате");
|
||||
}
|
||||
message = filtered;
|
||||
|
|
@ -81,6 +83,7 @@ public class AdvancedMessageFilter {
|
|||
if (filtersConfig.isSwearEnabled() && !canBypassFilter(player, "swear")) {
|
||||
FilterResult swear = swearFilter.filter(player, message);
|
||||
if (!swear.allowed()) {
|
||||
statistics.increment("swear");
|
||||
return swear;
|
||||
}
|
||||
message = swear.filteredMessage();
|
||||
|
|
@ -90,12 +93,14 @@ public class AdvancedMessageFilter {
|
|||
if (filtersConfig.isAdvertisingEnabled()) {
|
||||
FilterResult r = urlFilter.filterUrls(player, message);
|
||||
if (!r.allowed()) {
|
||||
statistics.increment("url");
|
||||
return r;
|
||||
}
|
||||
message = r.filteredMessage();
|
||||
|
||||
r = urlFilter.filterHiddenUrls(player, message);
|
||||
if (!r.allowed()) {
|
||||
statistics.increment("hidden-url");
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
|
@ -104,6 +109,7 @@ public class AdvancedMessageFilter {
|
|||
if (filtersConfig.isIpEnabled()) {
|
||||
FilterResult r = urlFilter.filterIPs(player, message);
|
||||
if (!r.allowed()) {
|
||||
statistics.increment("ip");
|
||||
return r;
|
||||
}
|
||||
message = r.filteredMessage();
|
||||
|
|
@ -118,6 +124,7 @@ public class AdvancedMessageFilter {
|
|||
if (filtersConfig.isFloodEnabled() && !canBypassFilter(player, "flood")) {
|
||||
FilterResult r = floodFilter.filter(player);
|
||||
if (!r.allowed()) {
|
||||
statistics.increment("flood");
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
|
@ -126,6 +133,7 @@ public class AdvancedMessageFilter {
|
|||
if (filtersConfig.isSpamEnabled() && !canBypassFilter(player, "spam")) {
|
||||
FilterResult r = spamFilter.filter(player, message);
|
||||
if (!r.allowed()) {
|
||||
statistics.increment("spam");
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
|
@ -133,6 +141,13 @@ public class AdvancedMessageFilter {
|
|||
return FilterResult.ok(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Статистика блокировок по фильтрам (с момента запуска).
|
||||
*/
|
||||
public FilterStatistics getStatistics() {
|
||||
return statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка данных игрока при выходе
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
package com.loki.lochat.core.filter;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.LongAdder;
|
||||
|
||||
/**
|
||||
* Счётчики заблокированных сообщений по каждому фильтру (с момента запуска / сброса).
|
||||
*/
|
||||
public final class FilterStatistics {
|
||||
|
||||
private final Map<String, LongAdder> blocked = new ConcurrentHashMap<>();
|
||||
|
||||
public void increment(String filterType) {
|
||||
blocked.computeIfAbsent(filterType, k -> new LongAdder()).increment();
|
||||
}
|
||||
|
||||
public long getBlocked(String filterType) {
|
||||
LongAdder counter = blocked.get(filterType);
|
||||
return counter == null ? 0L : counter.sum();
|
||||
}
|
||||
|
||||
public long getTotalBlocked() {
|
||||
return blocked.values().stream().mapToLong(LongAdder::sum).sum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимок счётчиков, отсортированный по убыванию (заблокировавших больше всего — первыми).
|
||||
*/
|
||||
public Map<String, Long> snapshot() {
|
||||
return blocked.entrySet().stream()
|
||||
.sorted(Map.Entry.<String, LongAdder>comparingByValue(
|
||||
(a, b) -> Long.compare(b.sum(), a.sum())))
|
||||
.collect(LinkedHashMap::new,
|
||||
(m, e) -> m.put(e.getKey(), e.getValue().sum()),
|
||||
LinkedHashMap::putAll);
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
blocked.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package com.loki.lochat.core.filter.filters;
|
|||
import com.loki.lochat.config.FiltersConfig;
|
||||
import com.loki.lochat.core.filter.FilterResult;
|
||||
import com.loki.lochat.utils.text.AhoCorasick;
|
||||
import com.loki.lochat.utils.text.SwearNormalizer;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
|
@ -13,17 +14,16 @@ import java.io.InputStreamReader;
|
|||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
public class SwearFilter {
|
||||
private final boolean enabled;
|
||||
private final boolean replaceMode;
|
||||
private final String replacement;
|
||||
private AhoCorasick ahoCorasick;
|
||||
|
||||
public SwearFilter(FiltersConfig filters, JavaPlugin plugin) {
|
||||
this.enabled = filters.isSwearEnabled();
|
||||
this.replaceMode = "replace".equals(filters.getSwearAction());
|
||||
this.replacement = filters.getSwearReplacementChar();
|
||||
|
||||
if (enabled) {
|
||||
loadWordList(plugin);
|
||||
|
|
@ -49,13 +49,30 @@ public class SwearFilter {
|
|||
return FilterResult.ok(message);
|
||||
}
|
||||
|
||||
if (ahoCorasick.matches(message)) {
|
||||
String normalized = SwearNormalizer.normalizeForSearch(message);
|
||||
if (ahoCorasick.matches(normalized)) {
|
||||
if (replaceMode) {
|
||||
return FilterResult.ok(ahoCorasick.replaceAll(message, replacement));
|
||||
return FilterResult.ok(maskMatches(message, normalized));
|
||||
}
|
||||
return FilterResult.blocked("Сообщение содержит запрещенные слова");
|
||||
}
|
||||
|
||||
return FilterResult.ok(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Маскирует в оригинальном сообщении каждое найденное (возможно, обфусцированное) слово.
|
||||
*/
|
||||
private String maskMatches(String original, String normalized) {
|
||||
String result = original;
|
||||
for (String word : ahoCorasick.findMatches(normalized)) {
|
||||
Matcher matcher = SwearNormalizer.wordPattern(word).matcher(result);
|
||||
result = matcher.replaceAll(maskFor(word.length()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String maskFor(int wordLength) {
|
||||
return "*".repeat(Math.max(1, wordLength));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,43 +9,6 @@ import java.util.Map;
|
|||
|
||||
public final class AhoCorasick {
|
||||
|
||||
private static final Map<Character, Character> NORMALIZE = new HashMap<>();
|
||||
|
||||
static {
|
||||
NORMALIZE.put('a', 'а');
|
||||
NORMALIZE.put('A', 'А');
|
||||
NORMALIZE.put('e', 'е');
|
||||
NORMALIZE.put('E', 'Е');
|
||||
NORMALIZE.put('o', 'о');
|
||||
NORMALIZE.put('O', 'О');
|
||||
NORMALIZE.put('p', 'р');
|
||||
NORMALIZE.put('P', 'Р');
|
||||
NORMALIZE.put('c', 'с');
|
||||
NORMALIZE.put('C', 'С');
|
||||
NORMALIZE.put('y', 'у');
|
||||
NORMALIZE.put('Y', 'У');
|
||||
NORMALIZE.put('x', 'х');
|
||||
NORMALIZE.put('X', 'Х');
|
||||
NORMALIZE.put('k', 'к');
|
||||
NORMALIZE.put('K', 'К');
|
||||
NORMALIZE.put('m', 'м');
|
||||
NORMALIZE.put('M', 'М');
|
||||
NORMALIZE.put('h', 'н');
|
||||
NORMALIZE.put('H', 'Н');
|
||||
NORMALIZE.put('n', 'н');
|
||||
NORMALIZE.put('N', 'Н');
|
||||
NORMALIZE.put('t', 'т');
|
||||
NORMALIZE.put('T', 'Т');
|
||||
NORMALIZE.put('b', 'в');
|
||||
NORMALIZE.put('B', 'В');
|
||||
|
||||
NORMALIZE.put('@', 'а');
|
||||
NORMALIZE.put('0', 'о');
|
||||
NORMALIZE.put('3', 'е');
|
||||
NORMALIZE.put('1', 'и');
|
||||
NORMALIZE.put('6', 'б');
|
||||
}
|
||||
|
||||
private final Node root;
|
||||
|
||||
public AhoCorasick(List<String> words) {
|
||||
|
|
@ -150,7 +113,7 @@ public final class AhoCorasick {
|
|||
}
|
||||
|
||||
private static char normalize(char c) {
|
||||
return NORMALIZE.getOrDefault(c, Character.toLowerCase(c));
|
||||
return SwearNormalizer.normalize(c);
|
||||
}
|
||||
|
||||
private static final class Node {
|
||||
|
|
|
|||
196
src/main/java/com/loki/lochat/utils/text/SwearNormalizer.java
Normal file
196
src/main/java/com/loki/lochat/utils/text/SwearNormalizer.java
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package com.loki.lochat.utils.text;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Нормализация поискового текста для anti-swear.
|
||||
* Борется с обфускацией: leet/substitution (q→р, 0→о), повторами букв (сууука),
|
||||
* разделителями внутри слова (с.у.к.а, с у к а).
|
||||
*/
|
||||
public final class SwearNormalizer {
|
||||
|
||||
/**
|
||||
* Таблица подстановок: «сырой» символ → каноническая (нижний регистр) русская буква.
|
||||
* Latin-кириллические похожие (a→а, e→е, p→р...) и leet-цифры/символы.
|
||||
*/
|
||||
private static final Map<Character, Character> SUBSTITUTIONS = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Символы-разделители, которые вырезаются внутри слова (точки, дефисы, пробелы и т.п.).
|
||||
* Не должно пересекаться с ключами SUBSTITUTIONS.
|
||||
*/
|
||||
private static final Set<Character> NOISE_CHARS = new HashSet<>();
|
||||
|
||||
/**
|
||||
* Обратная карта: каноническая буква → все сырые варианты её написания (включая саму букву).
|
||||
*/
|
||||
private static final Map<Character, Set<Character>> VARIANTS = new HashMap<>();
|
||||
|
||||
private static final Map<String, Pattern> PATTERN_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
static {
|
||||
// Latin-кириллические омоглифы
|
||||
put('a', 'а');
|
||||
put('e', 'е');
|
||||
put('o', 'о');
|
||||
put('p', 'р');
|
||||
put('c', 'с');
|
||||
put('y', 'у');
|
||||
put('x', 'х');
|
||||
put('k', 'к');
|
||||
put('m', 'м');
|
||||
put('h', 'н');
|
||||
put('n', 'н');
|
||||
put('t', 'т');
|
||||
put('b', 'в');
|
||||
put('r', 'г');
|
||||
put('d', 'д');
|
||||
put('u', 'у');
|
||||
put('g', 'г');
|
||||
put('i', 'и');
|
||||
put('l', 'л');
|
||||
put('s', 'с');
|
||||
put('z', 'з');
|
||||
put('q', 'р');
|
||||
|
||||
// Leet-цифры и символы
|
||||
put('@', 'а');
|
||||
put('0', 'о');
|
||||
put('3', 'е');
|
||||
put('1', 'и');
|
||||
put('6', 'б');
|
||||
put('4', 'а');
|
||||
put('5', 'с');
|
||||
put('7', 'т');
|
||||
put('8', 'б');
|
||||
put('9', 'г');
|
||||
put('2', 'з');
|
||||
put('$', 'с');
|
||||
put('!', 'и');
|
||||
put('|', 'л');
|
||||
|
||||
NOISE_CHARS.add(' ');
|
||||
NOISE_CHARS.add('\t');
|
||||
NOISE_CHARS.add('\n');
|
||||
NOISE_CHARS.add('\r');
|
||||
NOISE_CHARS.add('.');
|
||||
NOISE_CHARS.add(',');
|
||||
NOISE_CHARS.add(';');
|
||||
NOISE_CHARS.add(':');
|
||||
NOISE_CHARS.add('?');
|
||||
NOISE_CHARS.add('-');
|
||||
NOISE_CHARS.add('_');
|
||||
NOISE_CHARS.add('*');
|
||||
NOISE_CHARS.add('+');
|
||||
NOISE_CHARS.add('/');
|
||||
NOISE_CHARS.add('\\');
|
||||
NOISE_CHARS.add('~');
|
||||
NOISE_CHARS.add('^');
|
||||
NOISE_CHARS.add('#');
|
||||
NOISE_CHARS.add('&');
|
||||
NOISE_CHARS.add('%');
|
||||
NOISE_CHARS.add('=');
|
||||
NOISE_CHARS.add('\'');
|
||||
NOISE_CHARS.add('"');
|
||||
NOISE_CHARS.add('`');
|
||||
NOISE_CHARS.add('(');
|
||||
NOISE_CHARS.add(')');
|
||||
NOISE_CHARS.add('[');
|
||||
NOISE_CHARS.add(']');
|
||||
NOISE_CHARS.add('{');
|
||||
NOISE_CHARS.add('}');
|
||||
NOISE_CHARS.add('<');
|
||||
NOISE_CHARS.add('>');
|
||||
NOISE_CHARS.add('·');
|
||||
NOISE_CHARS.add('«');
|
||||
NOISE_CHARS.add('»');
|
||||
NOISE_CHARS.add('„');
|
||||
NOISE_CHARS.add('“');
|
||||
NOISE_CHARS.add('”');
|
||||
NOISE_CHARS.add('…');
|
||||
}
|
||||
|
||||
private SwearNormalizer() {
|
||||
}
|
||||
|
||||
private static void put(char raw, char letter) {
|
||||
SUBSTITUTIONS.put(raw, letter);
|
||||
VARIANTS.computeIfAbsent(letter, k -> new HashSet<>()).add(raw);
|
||||
VARIANTS.computeIfAbsent(letter, k -> new HashSet<>()).add(letter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализация одного символа: нижний регистр + замена омоглифов/leet.
|
||||
*/
|
||||
public static char normalize(char c) {
|
||||
char lower = Character.toLowerCase(c);
|
||||
return SUBSTITUTIONS.getOrDefault(lower, lower);
|
||||
}
|
||||
|
||||
/**
|
||||
* Приводит текст к «поисковому» виду: подстановки + удаление разделителей + схлопывание
|
||||
* повторяющихся букв. Результат — поток канонических букв, по которому можно искать слова.
|
||||
*/
|
||||
public static String normalizeForSearch(String text) {
|
||||
StringBuilder sb = new StringBuilder(text.length());
|
||||
char prev = 0;
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
if (NOISE_CHARS.contains(c)) {
|
||||
continue;
|
||||
}
|
||||
char n = normalize(c);
|
||||
if (n == prev) {
|
||||
continue;
|
||||
}
|
||||
sb.append(n);
|
||||
prev = n;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит tolerant-регулярку для поиска конкретного слова в оригинальном тексте:
|
||||
* каждая буква — «один или несколько» вариантов написания, между буквами допускаются любые
|
||||
* разделители (точки, пробелы и т.п.). Используется для маскирования в replace-режиме.
|
||||
*/
|
||||
public static Pattern wordPattern(String normalizedWord) {
|
||||
return PATTERN_CACHE.computeIfAbsent(normalizedWord, SwearNormalizer::buildPattern);
|
||||
}
|
||||
|
||||
private static Pattern buildPattern(String word) {
|
||||
StringBuilder sb = new StringBuilder("(?iu)");
|
||||
for (int i = 0; i < word.length(); i++) {
|
||||
char letter = word.charAt(i);
|
||||
Set<Character> variants = VARIANTS.getOrDefault(letter, Set.of(letter));
|
||||
sb.append("(?:");
|
||||
boolean first = true;
|
||||
for (char v : variants) {
|
||||
if (!first) {
|
||||
sb.append('|');
|
||||
}
|
||||
sb.append(Pattern.quote(String.valueOf(v)));
|
||||
first = false;
|
||||
}
|
||||
sb.append(")+");
|
||||
if (i < word.length() - 1) {
|
||||
sb.append("(?:");
|
||||
boolean firstNoise = true;
|
||||
for (char n : NOISE_CHARS) {
|
||||
if (!firstNoise) {
|
||||
sb.append('|');
|
||||
}
|
||||
sb.append(Pattern.quote(String.valueOf(n)));
|
||||
firstNoise = false;
|
||||
}
|
||||
sb.append(")*");
|
||||
}
|
||||
}
|
||||
return Pattern.compile(sb.toString());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.loki.lochat.core.filter;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class FilterStatisticsTest {
|
||||
|
||||
@Test
|
||||
void increment_countsPerFilter() {
|
||||
FilterStatistics stats = new FilterStatistics();
|
||||
stats.increment("swear");
|
||||
stats.increment("swear");
|
||||
stats.increment("flood");
|
||||
|
||||
assertEquals(2, stats.getBlocked("swear"));
|
||||
assertEquals(1, stats.getBlocked("flood"));
|
||||
assertEquals(0, stats.getBlocked("caps"));
|
||||
assertEquals(3, stats.getTotalBlocked());
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshot_sortedDescending() {
|
||||
FilterStatistics stats = new FilterStatistics();
|
||||
stats.increment("flood");
|
||||
stats.increment("swear");
|
||||
stats.increment("swear");
|
||||
stats.increment("spam");
|
||||
stats.increment("spam");
|
||||
stats.increment("spam");
|
||||
|
||||
Map<String, Long> snapshot = stats.snapshot();
|
||||
assertEquals(3, snapshot.get("spam"));
|
||||
assertEquals(2, snapshot.get("swear"));
|
||||
assertEquals(1, snapshot.get("flood"));
|
||||
|
||||
// Первый элемент — фильтр с наибольшим количеством блокировок
|
||||
String first = snapshot.keySet().iterator().next();
|
||||
assertEquals("spam", first);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reset_clearsCounters() {
|
||||
FilterStatistics stats = new FilterStatistics();
|
||||
stats.increment("swear");
|
||||
stats.reset();
|
||||
assertEquals(0, stats.getTotalBlocked());
|
||||
assertTrue(stats.snapshot().isEmpty());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package com.loki.lochat.utils.text;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SwearNormalizerTest {
|
||||
|
||||
@Test
|
||||
void normalizeForSearch_stripsSeparatorsInsideWord() {
|
||||
assertEquals("сука", SwearNormalizer.normalizeForSearch("с.у.к.а"));
|
||||
assertEquals("сука", SwearNormalizer.normalizeForSearch("с-у-к-а"));
|
||||
assertEquals("сука", SwearNormalizer.normalizeForSearch("с у к а"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizeForSearch_collapsesRepeatedLetters() {
|
||||
assertEquals("сука", SwearNormalizer.normalizeForSearch("сууука"));
|
||||
assertEquals("сука", SwearNormalizer.normalizeForSearch("сссуккаа"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizeForSearch_appliesLeetSubstitution() {
|
||||
assertEquals("сока", SwearNormalizer.normalizeForSearch("с0ка"));
|
||||
assertEquals("мет", SwearNormalizer.normalizeForSearch("м3т"));
|
||||
assertEquals("игра", SwearNormalizer.normalizeForSearch("1гра"));
|
||||
assertEquals("пизда", SwearNormalizer.normalizeForSearch("п!зда"));
|
||||
assertEquals("бан", SwearNormalizer.normalizeForSearch("6ан"));
|
||||
assertEquals("хуй", SwearNormalizer.normalizeForSearch("хyй"));
|
||||
assertEquals("хуй", SwearNormalizer.normalizeForSearch("ХУЙ"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizeForSearch_mixedObfuscation() {
|
||||
assertEquals("сука", SwearNormalizer.normalizeForSearch("с-уу.к.а"));
|
||||
assertEquals("сука", SwearNormalizer.normalizeForSearch("СУ К А"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizeForSearch_preservesCleanText() {
|
||||
assertEquals("приветмир", SwearNormalizer.normalizeForSearch("привет мир"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ahoCorasick_detectsObfuscatedWord() {
|
||||
AhoCorasick ac = new AhoCorasick(List.of("сука"));
|
||||
assertTrue(ac.matches(SwearNormalizer.normalizeForSearch("с.у.к.а")));
|
||||
assertTrue(ac.matches(SwearNormalizer.normalizeForSearch("сууука")));
|
||||
assertTrue(ac.matches(SwearNormalizer.normalizeForSearch("СУ К А")));
|
||||
assertTrue(ac.matches(SwearNormalizer.normalizeForSearch("эта с.у.к.а сволочь")));
|
||||
assertFalse(ac.matches(SwearNormalizer.normalizeForSearch("чистое сообщение")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wordPattern_matchesObfuscatedForms() {
|
||||
var pattern = SwearNormalizer.wordPattern("сука");
|
||||
assertTrue(pattern.matcher("с.у.к.а").find());
|
||||
assertTrue(pattern.matcher("сука").find());
|
||||
assertTrue(pattern.matcher("СУУУКА").find());
|
||||
assertTrue(pattern.matcher("с у к а").find());
|
||||
assertTrue(pattern.matcher("это с-у-к-а сообщение").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void wordPattern_masksOriginalText() {
|
||||
var pattern = SwearNormalizer.wordPattern("сука");
|
||||
Matcher matcher = pattern.matcher("это с.у.к.а сообщение");
|
||||
String masked = matcher.replaceAll("***");
|
||||
assertEquals("это *** сообщение", masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void wordPattern_doesNotMatchSimilarWord() {
|
||||
var pattern = SwearNormalizer.wordPattern("сука");
|
||||
assertFalse(pattern.matcher("сучка").find());
|
||||
assertFalse(pattern.matcher("сук").find());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue