forked from Muzifs/LGBot
Фильтрация бранных слов:
- Добавлена полная транслитерация латиница→кириллица (все 26 букв)
- Добавлен маппинг цифр→буквы (0→о, 3→з, 4→ч, 6→б, 8→в и др.)
- Добавлено определение ASCII-art паттернов (><→х, }|{→ж и др.)
- Реализована 3-уровневая система категоризации паттернов:
* whole_word - только целые слова с границами \b
* word_start - только начало слова (новая категория)
* contains - любое вхождение (минимум 4 буквы)
- Добавлен расширенный список исключений для предотвращения ложных срабатываний
(требует, употреблять, скребу, гребу, республика, документ и др.)
Система кармы:
- Добавлен штраф за эмодзи клоуна 🤡 (-20 кармы тому, кто добавил)
- Карма не возвращается при снятии клоуна
- Реализована возможность отключения кармы в конкретных топиках:
* Новая таблица disabled_karma_topics в БД
* Методы: is_karma_disabled_in_topic(), disable/enable_karma_in_topic()
- Перенесены команды управления кармой из /badwords в /karma:
* /karma disable - отключить карму в текущем топике
* /karma enable - включить карму в текущем топике
* /karma status - показать статус кармы во всех топиках
- Убраны упоминания пользователей в команде /top (используется <code>)
Аудит и логирование:
- Добавлена отправка в канал аудита для операций со словами:
* Добавление/удаление бранных слов
* Добавление/удаление исключений
- Расширен action_reporter для поддержки операций без привязки к пользователю
448 lines
19 KiB
Python
448 lines
19 KiB
Python
# Система управления бранными словами
|
||
# Список слов хранится в JSON файле для возможности управления через команды
|
||
|
||
import json
|
||
import os
|
||
import logging
|
||
import re
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Путь к файлу с бранными словами
|
||
BAD_WORDS_FILE = os.path.join(os.path.dirname(__file__), 'data', 'bad_words.json')
|
||
|
||
# Кэш для загруженных слов
|
||
_bad_words_cache = None
|
||
_exceptions_cache = None
|
||
_whole_word_patterns_cache = None
|
||
_word_start_patterns_cache = None
|
||
_contains_patterns_cache = None
|
||
|
||
def load_bad_words():
|
||
"""
|
||
Загружает список бранных слов из JSON файла.
|
||
Поддерживает как новый формат (patterns), так и старый (bad_words).
|
||
|
||
Returns:
|
||
tuple: (список бранных слов, список исключений)
|
||
"""
|
||
global _bad_words_cache, _exceptions_cache, _whole_word_patterns_cache, _word_start_patterns_cache, _contains_patterns_cache
|
||
|
||
try:
|
||
with open(BAD_WORDS_FILE, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
|
||
# Проверяем, какой формат используется
|
||
if 'patterns' in data:
|
||
# Новый формат с паттернами
|
||
patterns = data['patterns']
|
||
_whole_word_patterns_cache = patterns.get('whole_word', [])
|
||
_word_start_patterns_cache = patterns.get('word_start', [])
|
||
_contains_patterns_cache = patterns.get('contains', [])
|
||
_exceptions_cache = data.get('exceptions', [])
|
||
|
||
# Для обратной совместимости объединяем в один список
|
||
_bad_words_cache = _whole_word_patterns_cache + _word_start_patterns_cache + _contains_patterns_cache
|
||
|
||
logger.info(
|
||
f"Загружено паттернов: {len(_whole_word_patterns_cache)} whole_word, "
|
||
f"{len(_word_start_patterns_cache)} word_start, "
|
||
f"{len(_contains_patterns_cache)} contains, {len(_exceptions_cache)} исключений"
|
||
)
|
||
else:
|
||
# Старый формат для обратной совместимости
|
||
_bad_words_cache = data.get('bad_words', [])
|
||
_exceptions_cache = data.get('exceptions', [])
|
||
_whole_word_patterns_cache = []
|
||
_word_start_patterns_cache = []
|
||
_contains_patterns_cache = _bad_words_cache.copy()
|
||
|
||
logger.info(f"Загружено {len(_bad_words_cache)} бранных слов (старый формат) и {len(_exceptions_cache)} исключений")
|
||
|
||
return _bad_words_cache, _exceptions_cache
|
||
except FileNotFoundError:
|
||
logger.error(f"Файл {BAD_WORDS_FILE} не найден")
|
||
return [], []
|
||
except json.JSONDecodeError as e:
|
||
logger.error(f"Ошибка чтения JSON: {e}")
|
||
return [], []
|
||
|
||
def save_bad_words(bad_words: list, exceptions: list):
|
||
"""
|
||
Сохраняет список бранных слов в JSON файл.
|
||
|
||
Args:
|
||
bad_words: Список бранных слов
|
||
exceptions: Список исключений
|
||
|
||
Returns:
|
||
bool: True если успешно, иначе False
|
||
"""
|
||
global _bad_words_cache, _exceptions_cache
|
||
|
||
try:
|
||
# Создаем директорию, если её нет
|
||
os.makedirs(os.path.dirname(BAD_WORDS_FILE), exist_ok=True)
|
||
|
||
data = {
|
||
'bad_words': sorted(list(set(bad_words))), # Убираем дубликаты и сортируем
|
||
'exceptions': sorted(list(set(exceptions)))
|
||
}
|
||
|
||
with open(BAD_WORDS_FILE, 'w', encoding='utf-8') as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
|
||
# Обновляем кэш
|
||
_bad_words_cache = data['bad_words']
|
||
_exceptions_cache = data['exceptions']
|
||
|
||
logger.info(f"Сохранено {len(_bad_words_cache)} бранных слов и {len(_exceptions_cache)} исключений")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Ошибка сохранения: {e}")
|
||
return False
|
||
|
||
def get_bad_words():
|
||
"""Возвращает список бранных слов (с кэшированием)"""
|
||
global _bad_words_cache
|
||
if _bad_words_cache is None:
|
||
load_bad_words()
|
||
return _bad_words_cache or []
|
||
|
||
def get_exceptions():
|
||
"""Возвращает список исключений (с кэшированием)"""
|
||
global _exceptions_cache
|
||
if _exceptions_cache is None:
|
||
load_bad_words()
|
||
return _exceptions_cache or []
|
||
|
||
def reload_words():
|
||
"""Перезагружает списки из файла (сбрасывает кэш)"""
|
||
global _bad_words_cache, _exceptions_cache, _whole_word_patterns_cache, _word_start_patterns_cache, _contains_patterns_cache
|
||
_bad_words_cache = None
|
||
_exceptions_cache = None
|
||
_whole_word_patterns_cache = None
|
||
_word_start_patterns_cache = None
|
||
_contains_patterns_cache = None
|
||
return load_bad_words()
|
||
|
||
def normalize_text(text: str) -> str:
|
||
"""
|
||
Нормализует текст для обхода обфускации.
|
||
|
||
Убирает:
|
||
- Транслитерацию латиницы в кириллицу (xyu → хуй, p0rn → porn)
|
||
- Цифры похожие на буквы (0→о, 3→з, 6→б)
|
||
- Языковые варианты (ё→е)
|
||
- ASCII-имитации (><→х, |\|→и, /\→л)
|
||
- Звездочки, точки, подчеркивания между буквами (х*й, х.у.й, х_у_й → хуй)
|
||
- Повторяющиеся символы (хууууууй → хуй)
|
||
- ОДИНОЧНЫЕ пробелы между ОДИНОЧНЫМИ буквами (х у й → хуй, но "не бу" остаётся "не бу")
|
||
|
||
Args:
|
||
text: Исходный текст
|
||
|
||
Returns:
|
||
Нормализованный текст
|
||
"""
|
||
if not text:
|
||
return text
|
||
|
||
# Приводим к нижнему регистру
|
||
normalized = text.lower()
|
||
|
||
# 1. Транслитерация латиницы → кириллица (защита от обхода через латиницу)
|
||
# Основано на визуальном сходстве букв
|
||
transliteration_map = {
|
||
'a': 'а', # a → а
|
||
'b': 'в', # b → в
|
||
'c': 'с', # c → с (латинская c похожа на кириллическую с)
|
||
'd': 'д', # d → д
|
||
'e': 'е', # e → е
|
||
'f': 'ф', # f → ф
|
||
'g': 'г', # g → г
|
||
'h': 'н', # h → н
|
||
'i': 'и', # i → и
|
||
'j': 'ж', # j → ж
|
||
'k': 'к', # k → к
|
||
'l': 'л', # l → л
|
||
'm': 'м', # m → м
|
||
'n': 'н', # n → н
|
||
'o': 'о', # o → о
|
||
'p': 'р', # p → р
|
||
'q': 'к', # q → к
|
||
'r': 'р', # r → р
|
||
's': 'с', # s → с
|
||
't': 'т', # t → т
|
||
'u': 'и', # u → и
|
||
'v': 'в', # v → в
|
||
'w': 'в', # w → в
|
||
'x': 'х', # x → х
|
||
'y': 'у', # y → у
|
||
'z': 'з', # z → з
|
||
}
|
||
|
||
# Применяем транслитерацию
|
||
for lat, cyr in transliteration_map.items():
|
||
normalized = normalized.replace(lat, cyr)
|
||
|
||
# 2. Замена цифр на похожие буквы
|
||
digit_map = {
|
||
'0': 'о', # 0 → о
|
||
'1': 'и', # 1 → и (или l)
|
||
'3': 'з', # 3 → з
|
||
'4': 'ч', # 4 → ч
|
||
'6': 'б', # 6 → б
|
||
'8': 'в', # 8 → в
|
||
}
|
||
|
||
for digit, letter in digit_map.items():
|
||
normalized = normalized.replace(digit, letter)
|
||
|
||
# 3. Языковые паттерны (унификация написания)
|
||
normalized = normalized.replace('ё', 'е') # ё → е
|
||
|
||
# 4. ASCII-имитации букв (сложные комбинации символов)
|
||
# Важно: делать это ДО удаления других символов
|
||
ascii_patterns = [
|
||
(r'\|\|', 'и'), # |\| → и (вертикальные черты)
|
||
(r'/\\', 'л'), # /\ → л (треугольник)
|
||
(r'><', 'х'), # >< → х
|
||
(r'\}\{', 'х'), # }{ → х
|
||
(r'\)\(', 'х'), # )( → х
|
||
(r'>\|<', 'ж'), # >|< → ж
|
||
(r'\}\|\{', 'ж'), # }|{ → ж
|
||
]
|
||
|
||
for pattern, replacement in ascii_patterns:
|
||
normalized = re.sub(pattern, replacement, normalized)
|
||
|
||
# Циклически убираем обфускацию, пока что-то меняется
|
||
max_iterations = 10
|
||
for _ in range(max_iterations):
|
||
before = normalized
|
||
|
||
# Убираем звёздочки, точки, дефисы, подчёркивания между буквами
|
||
# х*й, х.у.й, х_у_й → хуй
|
||
normalized = re.sub(r'([а-яё])[\*\.\-_]+([а-яё])', r'\1\2', normalized)
|
||
|
||
# Убираем ОДИНОЧНЫЕ пробелы между ОДИНОЧНЫМИ буквами (обфускация)
|
||
# "х у й" → "хуй", но "не бу" → "не бу" (не склеиваем обычные слова)
|
||
# Паттерн: одиночная буква + пробелы + одиночная буква
|
||
normalized = re.sub(r'\b([а-яё])\s+([а-яё])\b', r'\1\2', normalized)
|
||
|
||
if before == normalized:
|
||
break
|
||
|
||
# Убираем повторяющиеся буквы (более 2 подряд)
|
||
# хууууууй → хуй, пииииздец → пиздец
|
||
normalized = re.sub(r'([а-яё])\1{2,}', r'\1', normalized)
|
||
|
||
return normalized
|
||
|
||
# Предзагружаем слова при импорте модуля в кэш
|
||
load_bad_words()
|
||
|
||
def contains_bad_word(text: str) -> bool:
|
||
"""
|
||
Проверяет, содержит ли текст бранные слова.
|
||
|
||
Использует комбинированный подход для минимизации ложных срабатываний:
|
||
- Нормализацию текста для обхода обфускации
|
||
- Проверку границ слов для whole_word паттернов
|
||
- Проверку начала слова для word_start паттернов (НОВОЕ!)
|
||
- Проверку подстрок для contains паттернов с минимальной длиной
|
||
- Расширенный список исключений
|
||
|
||
Args:
|
||
text: Текст для проверки
|
||
|
||
Returns:
|
||
True, если найдено бранное слово, иначе False
|
||
"""
|
||
if not text:
|
||
return False
|
||
|
||
# Нормализуем текст (обход обфускации)
|
||
normalized_text = normalize_text(text)
|
||
|
||
# Получаем паттерны и исключения
|
||
global _whole_word_patterns_cache, _word_start_patterns_cache, _contains_patterns_cache, _exceptions_cache
|
||
|
||
# Если кэш не загружен, загружаем
|
||
if _whole_word_patterns_cache is None:
|
||
load_bad_words()
|
||
|
||
whole_word_patterns = _whole_word_patterns_cache or []
|
||
word_start_patterns = _word_start_patterns_cache or []
|
||
contains_patterns = _contains_patterns_cache or []
|
||
exceptions = _exceptions_cache or []
|
||
|
||
# Вспомогательная функция для проверки исключений
|
||
def is_in_exception(pattern_pos, pattern_len, text, exceptions_list):
|
||
"""Проверяет, входит ли найденный паттерн в слово-исключение"""
|
||
for exception in exceptions_list:
|
||
# Нормализуем исключение
|
||
norm_exception = normalize_text(exception)
|
||
if pattern_len == 0 or len(norm_exception) == 0:
|
||
continue
|
||
|
||
# Ищем исключение в тексте около найденного паттерна
|
||
# Проверяем область от (pos - len(exception)) до (pos + len(pattern))
|
||
search_start = max(0, pattern_pos - len(norm_exception))
|
||
search_end = min(len(text), pattern_pos + pattern_len + len(norm_exception))
|
||
search_area = text[search_start:search_end]
|
||
|
||
if norm_exception in search_area:
|
||
exc_pos = search_area.find(norm_exception)
|
||
abs_exc_pos = search_start + exc_pos
|
||
abs_exc_end = abs_exc_pos + len(norm_exception)
|
||
|
||
# Проверяем, что паттерн полностью внутри исключения
|
||
if abs_exc_pos <= pattern_pos < abs_exc_end:
|
||
return True
|
||
return False
|
||
|
||
# 1. Проверяем whole_word паттерны (только целые слова с границами \b)
|
||
for pattern in whole_word_patterns:
|
||
# Используем границы слов \b для поиска только целых слов
|
||
regex = r'\b' + re.escape(pattern) + r'\b'
|
||
match = re.search(regex, normalized_text, re.IGNORECASE)
|
||
if match:
|
||
if not is_in_exception(match.start(), len(pattern), normalized_text, exceptions):
|
||
return True
|
||
|
||
# 2. Проверяем word_start паттерны (НОВОЕ! только начало слова)
|
||
# Паттерн должен быть либо в начале строки, либо после не-буквы
|
||
for pattern in word_start_patterns:
|
||
# Regex: начало строки ИЛИ не-буква, затем паттерн
|
||
# (?:^|(?<=[^а-яёa-z])) - положительный lookbehind для начала или не-буквы
|
||
regex = r'(?:^|(?<=[^а-яёa-z]))' + re.escape(pattern)
|
||
match = re.search(regex, normalized_text, re.IGNORECASE)
|
||
if match:
|
||
if not is_in_exception(match.start(), len(pattern), normalized_text, exceptions):
|
||
return True
|
||
|
||
# 3. Проверяем contains паттерны (любое вхождение)
|
||
# Применяем минимальную длину для снижения false positives
|
||
MIN_CONTAINS_LENGTH = 4
|
||
|
||
for pattern in contains_patterns:
|
||
# Пропускаем слишком короткие паттерны (для безопасности)
|
||
if len(pattern) < MIN_CONTAINS_LENGTH:
|
||
logger.warning(f"Паттерн '{pattern}' слишком короткий для contains, пропускаем")
|
||
continue
|
||
|
||
if pattern in normalized_text:
|
||
# Проверяем все вхождения паттерна
|
||
start = 0
|
||
while True:
|
||
pos = normalized_text.find(pattern, start)
|
||
if pos == -1:
|
||
break
|
||
|
||
if not is_in_exception(pos, len(pattern), normalized_text, exceptions):
|
||
return True
|
||
|
||
start = pos + 1
|
||
|
||
return False
|
||
|
||
def get_bad_words_from_text(text: str) -> list:
|
||
"""
|
||
Возвращает список найденных бранных слов в тексте.
|
||
|
||
Использует комбинированный подход для минимизации ложных срабатываний:
|
||
- Нормализацию текста для обхода обфускации
|
||
- Проверку границ слов для whole_word паттернов
|
||
- Проверку начала слова для word_start паттернов (НОВОЕ!)
|
||
- Проверку подстрок для contains паттернов с минимальной длиной
|
||
- Расширенный список исключений
|
||
|
||
Args:
|
||
text: Текст для проверки
|
||
|
||
Returns:
|
||
Список найденных бранных слов
|
||
"""
|
||
if not text:
|
||
return []
|
||
|
||
# Нормализуем текст (обход обфускации)
|
||
normalized_text = normalize_text(text)
|
||
found_words = []
|
||
|
||
# Получаем паттерны и исключения
|
||
global _whole_word_patterns_cache, _word_start_patterns_cache, _contains_patterns_cache, _exceptions_cache
|
||
|
||
# Если кэш не загружен, загружаем
|
||
if _whole_word_patterns_cache is None:
|
||
load_bad_words()
|
||
|
||
whole_word_patterns = _whole_word_patterns_cache or []
|
||
word_start_patterns = _word_start_patterns_cache or []
|
||
contains_patterns = _contains_patterns_cache or []
|
||
exceptions = _exceptions_cache or []
|
||
|
||
# Вспомогательная функция для проверки исключений
|
||
def is_in_exception(pattern_pos, pattern_len, text, exceptions_list):
|
||
"""Проверяет, входит ли найденный паттерн в слово-исключение"""
|
||
for exception in exceptions_list:
|
||
norm_exception = normalize_text(exception)
|
||
if pattern_len == 0 or len(norm_exception) == 0:
|
||
continue
|
||
|
||
search_start = max(0, pattern_pos - len(norm_exception))
|
||
search_end = min(len(text), pattern_pos + pattern_len + len(norm_exception))
|
||
search_area = text[search_start:search_end]
|
||
|
||
if norm_exception in search_area:
|
||
exc_pos = search_area.find(norm_exception)
|
||
abs_exc_pos = search_start + exc_pos
|
||
abs_exc_end = abs_exc_pos + len(norm_exception)
|
||
|
||
if abs_exc_pos <= pattern_pos < abs_exc_end:
|
||
return True
|
||
return False
|
||
|
||
# 1. Проверяем whole_word паттерны (только целые слова)
|
||
for pattern in whole_word_patterns:
|
||
regex = r'\b' + re.escape(pattern) + r'\b'
|
||
match = re.search(regex, normalized_text, re.IGNORECASE)
|
||
if match:
|
||
if not is_in_exception(match.start(), len(pattern), normalized_text, exceptions) and pattern not in found_words:
|
||
found_words.append(pattern)
|
||
|
||
# 2. Проверяем word_start паттерны (НОВОЕ! только начало слова)
|
||
for pattern in word_start_patterns:
|
||
regex = r'(?:^|(?<=[^а-яёa-z]))' + re.escape(pattern)
|
||
match = re.search(regex, normalized_text, re.IGNORECASE)
|
||
if match:
|
||
if not is_in_exception(match.start(), len(pattern), normalized_text, exceptions) and pattern not in found_words:
|
||
found_words.append(pattern)
|
||
|
||
# 3. Проверяем contains паттерны (любое вхождение)
|
||
MIN_CONTAINS_LENGTH = 4
|
||
|
||
for pattern in contains_patterns:
|
||
if len(pattern) < MIN_CONTAINS_LENGTH:
|
||
continue
|
||
|
||
if pattern in normalized_text:
|
||
start = 0
|
||
word_is_valid = False
|
||
while True:
|
||
pos = normalized_text.find(pattern, start)
|
||
if pos == -1:
|
||
break
|
||
|
||
if not is_in_exception(pos, len(pattern), normalized_text, exceptions):
|
||
word_is_valid = True
|
||
break
|
||
|
||
start = pos + 1
|
||
|
||
if word_is_valid and pattern not in found_words:
|
||
found_words.append(pattern)
|
||
|
||
return found_words |