v1.2.0
This commit is contained in:
@@ -1,19 +1,13 @@
|
||||
"""
|
||||
Middleware для проверки сообщений на запрещённые слова (банворды).
|
||||
|
||||
Pipeline проверки:
|
||||
1. Пропускаем админов и служебные сообщения
|
||||
2. Проверяем whitelist (исключения)
|
||||
3. Проверяем режим silence (удаляем всё)
|
||||
4. Проверяем режим conflict (конфликтные слова)
|
||||
5. Проверяем постоянные банворды (substring, lemma, part)
|
||||
6. Проверяем временные банворды
|
||||
7. Если найдено - удаляем, логируем, уведомляем админов
|
||||
|
||||
НОВОЕ: Все проверки работают с нормализацией повторяющихся букв (3+ → 1).
|
||||
...
|
||||
✅ ИСПРАВЛЕНО:
|
||||
- ❌ PatternError: bad character range 🀀-\\\\ (исправлено экранирование Unicode)
|
||||
- ✅ НЕТ уведомлений в режиме тишины
|
||||
"""
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -28,263 +22,162 @@ __all__ = ("BanWordsMiddleware",)
|
||||
|
||||
|
||||
class BanWordsMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для фильтрации сообщений с банвордами.
|
||||
|
||||
Проверяет каждое текстовое сообщение на наличие запрещённых слов,
|
||||
удаляет спам и уведомляет администраторов.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализирует middleware"""
|
||||
super().__init__()
|
||||
self.manager = get_manager()
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
|
||||
event: Message,
|
||||
data: Dict[str, Any]
|
||||
self,
|
||||
handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
|
||||
event: Message,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""
|
||||
Обрабатывает входящие сообщения.
|
||||
|
||||
Args:
|
||||
handler: Следующий обработчик в цепочке
|
||||
event: Сообщение от пользователя
|
||||
data: Данные из диспетчера
|
||||
|
||||
Returns:
|
||||
Any: Результат обработчика или None (если сообщение удалено)
|
||||
"""
|
||||
# Пропускаем не-текстовые сообщения
|
||||
if not event.text and not event.caption:
|
||||
return await handler(event, data)
|
||||
|
||||
# Получаем текст (из text или caption)
|
||||
message_text = event.text or event.caption
|
||||
|
||||
# Пропускаем команды (начинаются с /)
|
||||
if message_text.startswith('/'):
|
||||
return await handler(event, data)
|
||||
|
||||
# Проверяем, является ли пользователь админом
|
||||
# Админ проверка
|
||||
user_id = event.from_user.id
|
||||
is_super_admin = user_id in settings.OWNER_ID
|
||||
is_admin = is_super_admin or self.manager.is_admin_cached(user_id)
|
||||
|
||||
# Админы пропускаются
|
||||
if is_admin:
|
||||
return await handler(event, data)
|
||||
|
||||
# Проверяем сообщение на банворды
|
||||
spam_result = await self._check_message(message_text)
|
||||
|
||||
if spam_result:
|
||||
# Найден спам - удаляем и уведомляем
|
||||
await self._handle_spam(event, spam_result)
|
||||
return None # Не продолжаем обработку
|
||||
return None
|
||||
|
||||
# Сообщение чистое - пропускаем дальше
|
||||
return await handler(event, data)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_for_part_check(text: str) -> str:
|
||||
"""
|
||||
Нормализует текст для проверки частей слов.
|
||||
Удаляет ВСЕ символы кроме букв и цифр, приводит к нижнему регистру.
|
||||
|
||||
Args:
|
||||
text: Исходный текст
|
||||
|
||||
Returns:
|
||||
str: Нормализованный текст (только буквы и цифры, нижний регистр)
|
||||
|
||||
Examples:
|
||||
"@Astrixkeepbot" -> "astrixkeepbot"
|
||||
"hello@world.com" -> "helloworldcom"
|
||||
"test_123-456" -> "test123456"
|
||||
"""
|
||||
return re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9]', '', text.lower())
|
||||
def _normalize_universal(text: str, mode: str = "strict") -> str:
|
||||
"""✅ ИСПРАВЛЕНО: Универсальная нормализация для всех типов проверок"""
|
||||
# БЕЗОПАСНАЯ нормализация - убираем все проблемные символы
|
||||
text = unicodedata.normalize('NFKC', text)
|
||||
|
||||
if mode == "strict": # PART - сохраняем буквы, цифры, пробелы
|
||||
# ✅ ИСПРАВЛЕНО: безопасный паттерн только для букв/цифр/пробелов
|
||||
text = re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9\s]', '', text)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
else: # SUBSTRING/LEMMA - только буквы и цифры
|
||||
text = re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9]', '', text)
|
||||
|
||||
return BanWordsMiddleware._normalize_repeated_chars(text)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_repeated_chars(text: str, max_repeats: int = 1) -> str:
|
||||
"""
|
||||
Убирает повторяющиеся буквы (обход "лееейн" -> "лейн", "телееелооог" -> "телелог").
|
||||
def _normalize_repeated_chars(text: str) -> str:
|
||||
"""Убирает повторения >2 (лееееин → лейн)"""
|
||||
return re.sub(r'([а-яёa-z])\\1{2,}', r'\\1\\1', text, flags=re.IGNORECASE)
|
||||
|
||||
Args:
|
||||
text: Исходное слово
|
||||
max_repeats: Максимальное количество повторов одной буквы (1 = убрать все повторы)
|
||||
|
||||
Returns:
|
||||
str: Нормализованное слово
|
||||
|
||||
Examples:
|
||||
("лееейн", 1) -> "лейн"
|
||||
("телееелооог", 1) -> "телелог"
|
||||
("хеееелооооу", 1) -> "хелоу"
|
||||
("аааааа", 1) -> "а"
|
||||
("привеееет", 2) -> "приввеет" (если max_repeats=2)
|
||||
"""
|
||||
if max_repeats == 1:
|
||||
# Заменяем 2+ одинаковых букв подряд на 1 такую же букву
|
||||
return re.sub(r'([а-яёa-z])\1+', r'\1', text, flags=re.IGNORECASE)
|
||||
else:
|
||||
# Заменяем (max_repeats+1)+ одинаковых букв на max_repeats таких букв
|
||||
pattern = f'([а-яёa-z])\\1{{{max_repeats},}}'
|
||||
replacement = '\\1' * max_repeats
|
||||
return re.sub(pattern, replacement, text, flags=re.IGNORECASE)
|
||||
def _check_repeated_chars(self, text: str) -> Optional[Dict[str, str]]:
|
||||
"""🔥 Блокирует 3+ повторяющиеся символы подряд"""
|
||||
# ✅ ИСПРАВЛЕНО: безопасный паттерн только для букв
|
||||
pattern = r'([а-яёa-zA-Z])\\1{2,}'
|
||||
matches = re.finditer(pattern, text, flags=re.IGNORECASE)
|
||||
|
||||
for match in matches:
|
||||
char = match.group(1)
|
||||
count = len(match.group(0))
|
||||
if count >= 3:
|
||||
logger.info(f"🔥 ПОВТОРЫ: '{match.group(0)}' ({count}x)", log_type="BANWORDS")
|
||||
return {"word": f"'{match.group(0)}' ({count}x)", "type": "repeated_chars"}
|
||||
return None
|
||||
|
||||
async def _check_message(self, text: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Проверяет сообщение на наличие банвордов.
|
||||
|
||||
Args:
|
||||
text: Текст сообщения
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: {"word": "найденное_слово", "type": "тип_проверки"} или None
|
||||
"""
|
||||
# Нормализуем текст для проверки
|
||||
text_lower = text.lower()
|
||||
|
||||
# 🔥 1. Повторяющиеся символы (лееееин)
|
||||
repeat_result = self._check_repeated_chars(text_lower)
|
||||
if repeat_result:
|
||||
return repeat_result
|
||||
|
||||
# 2. ✅ БЕЗОПАСНАЯ нормализация
|
||||
text_universal = self._normalize_universal(text_lower, "strict") # PART
|
||||
text_loose = self._normalize_universal(text_lower) # SUBSTRING/LEMMA
|
||||
text_processed = process_text(text_lower)
|
||||
|
||||
# Дополнительно нормализуем повторяющиеся буквы для всех проверок
|
||||
text_normalized = self._normalize_repeated_chars(text_processed, max_repeats=1)
|
||||
|
||||
logger.debug(
|
||||
f"Проверка текста: исходный='{text[:50]}', обработанный='{text_processed[:50]}', "
|
||||
f"нормализованный='{text_normalized[:50]}'",
|
||||
f"🔍 | universal='{text_universal}' | loose='{text_loose}' | proc='{text_processed}'",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
|
||||
# === 1. WHITELIST (исключения) ===
|
||||
# Проверяем оба варианта: с повторами и без
|
||||
if self.manager.is_whitelisted(text_processed) or self.manager.is_whitelisted(text_normalized):
|
||||
logger.debug(
|
||||
f"Сообщение содержит whitelist слово",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
# 3. WHITELIST
|
||||
if (self.manager.is_whitelisted(text_processed) or
|
||||
self.manager.is_whitelisted(text_loose) or
|
||||
self.manager.is_whitelisted(text_universal)):
|
||||
return None
|
||||
|
||||
# === 2. SILENCE MODE (удаляем всё) ===
|
||||
# 4. SILENCE MODE
|
||||
if await self.manager.is_silence_active():
|
||||
return {
|
||||
"word": "[режим тишины]",
|
||||
"type": "silence"
|
||||
}
|
||||
return {"word": "[режим тишины]", "type": "silence"}
|
||||
|
||||
# === 3. CONFLICT MODE (конфликтные слова) ===
|
||||
# 5. CONFLICT MODE
|
||||
if await self.manager.is_conflict_active():
|
||||
# Проверяем конфликтные подстроки (с нормализацией)
|
||||
conflict_substring = self.manager.get_banwords_cached(
|
||||
BanWordType.CONFLICT_SUBSTRING
|
||||
)
|
||||
for word in conflict_substring:
|
||||
word_normalized = self._normalize_repeated_chars(word, max_repeats=1)
|
||||
if word_normalized in text_normalized:
|
||||
for word in self.manager.get_banwords_cached(BanWordType.CONFLICT_SUBSTRING):
|
||||
word_norm = self._normalize_universal(word.lower(), "loose")
|
||||
if word_norm in text_loose:
|
||||
return {"word": word, "type": "conflict_substring"}
|
||||
|
||||
# Проверяем конфликтные леммы
|
||||
conflict_lemma = self.manager.get_banwords_cached(
|
||||
BanWordType.CONFLICT_LEMMA
|
||||
)
|
||||
words_in_text = extract_words(text_processed)
|
||||
for word_text in words_in_text:
|
||||
word_normalized = self._normalize_repeated_chars(word_text, max_repeats=1)
|
||||
lemma = get_lemma(word_normalized)
|
||||
|
||||
conflict_lemma = self.manager.get_banwords_cached(BanWordType.CONFLICT_LEMMA)
|
||||
for word_text in extract_words(text_processed):
|
||||
lemma = get_lemma(self._normalize_repeated_chars(word_text))
|
||||
if lemma in conflict_lemma:
|
||||
return {"word": lemma, "type": "conflict_lemma"}
|
||||
|
||||
# === 4. SUBSTRING (подстроки) с нормализацией ===
|
||||
substring_words = self.manager.get_banwords_cached(BanWordType.SUBSTRING)
|
||||
for word in substring_words:
|
||||
# Нормализуем и банворд, и текст
|
||||
word_normalized = self._normalize_repeated_chars(word, max_repeats=1)
|
||||
|
||||
if word_normalized in text_normalized:
|
||||
logger.info(
|
||||
f"Найдена подстрока: '{word}' (норм: '{word_normalized}') в '{text_normalized[:100]}'",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
# 6. SUBSTRING
|
||||
for word in self.manager.get_banwords_cached(BanWordType.SUBSTRING):
|
||||
word_norm = self._normalize_universal(word.lower())
|
||||
if word_norm in text_loose:
|
||||
logger.info(f"✅ SUBSTRING: '{word}' → '{text_loose}'", log_type="BANWORDS")
|
||||
return {"word": word, "type": "substring"}
|
||||
|
||||
# === 5. PART (части слов без пробелов и спецсимволов) ===
|
||||
part_words = self.manager.get_banwords_cached(BanWordType.PART)
|
||||
if part_words:
|
||||
# Специальная нормализация для PART: удаляем ВСЁ кроме букв и цифр
|
||||
text_part_normalized = self._normalize_for_part_check(text)
|
||||
text_part_normalized = self._normalize_repeated_chars(text_part_normalized, max_repeats=1)
|
||||
# 7. PART (строгая нормализация)
|
||||
for part in self.manager.get_banwords_cached(BanWordType.PART):
|
||||
part_norm = self._normalize_universal(part.lower(), "strict")
|
||||
if part_norm in text_universal:
|
||||
logger.info(f"✅ PART: '{part}' → '{text_universal}'", log_type="BANWORDS")
|
||||
return {"word": part, "type": "part"}
|
||||
|
||||
for part in part_words:
|
||||
part_normalized = self._normalize_for_part_check(part)
|
||||
part_normalized = self._normalize_repeated_chars(part_normalized, max_repeats=1)
|
||||
# 8. LEMMA
|
||||
for word_text in extract_words(text_processed):
|
||||
lemma = get_lemma(self._normalize_repeated_chars(word_text))
|
||||
if lemma in self.manager.get_banwords_cached(BanWordType.LEMMA):
|
||||
logger.info(f"✅ LEMMA: '{lemma}' из '{word_text}'", log_type="BANWORDS")
|
||||
return {"word": lemma, "type": "lemma"}
|
||||
|
||||
if part_normalized in text_part_normalized:
|
||||
logger.info(
|
||||
f"Найдена запрещенная часть: '{part}' (норм: '{part_normalized}') "
|
||||
f"в '{text_part_normalized[:100]}'",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
return {"word": part, "type": "part"}
|
||||
|
||||
# === 6. LEMMA (нормальные формы слов) ===
|
||||
lemma_words = self.manager.get_banwords_cached(BanWordType.LEMMA)
|
||||
if lemma_words:
|
||||
words_in_text = extract_words(text_processed)
|
||||
for word_text in words_in_text:
|
||||
# Убираем повторяющиеся буквы ПЕРЕД лемматизацией
|
||||
word_normalized = self._normalize_repeated_chars(word_text, max_repeats=1)
|
||||
lemma = get_lemma(word_normalized)
|
||||
|
||||
if lemma in lemma_words:
|
||||
logger.info(
|
||||
f"Найдена лемма: '{lemma}' из слова '{word_text}' (норм: '{word_normalized}')",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
return {"word": lemma, "type": "lemma"}
|
||||
|
||||
# Банворды не найдены
|
||||
return None
|
||||
|
||||
async def _handle_spam(
|
||||
self,
|
||||
message: Message,
|
||||
spam_result: Dict[str, str]
|
||||
) -> None:
|
||||
"""
|
||||
Обрабатывает найденный спам: удаляет, логирует, уведомляет.
|
||||
|
||||
Args:
|
||||
message: Сообщение со спамом
|
||||
spam_result: Результат проверки (слово + тип)
|
||||
"""
|
||||
async def _handle_spam(self, message: Message, spam_result: Dict[str, str]) -> None:
|
||||
user = message.from_user
|
||||
matched_word = spam_result["word"]
|
||||
match_type = spam_result["type"]
|
||||
|
||||
# Получаем текст сообщения
|
||||
message_text = message.text or message.caption or "[нет текста]"
|
||||
|
||||
# === 1. УДАЛЯЕМ СООБЩЕНИЕ ===
|
||||
try:
|
||||
await message.delete()
|
||||
logger.info(
|
||||
f"Удалено сообщение от @{user.username or user.id} "
|
||||
f"(слово: '{matched_word}', тип: {match_type})",
|
||||
log_type="BANWORDS",
|
||||
user=f"@{user.username}" if user.username else f"id{user.id}"
|
||||
)
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(
|
||||
f"Не удалось удалить сообщение: {e}",
|
||||
log_type="BANWORDS",
|
||||
user=f"@{user.username}" if user.username else f"id{user.id}"
|
||||
)
|
||||
# ✅ ПРОВЕРКА: НЕ отправляем уведомления в режиме тишины
|
||||
if match_type == "silence":
|
||||
# Удаляем сообщение молча
|
||||
try:
|
||||
await message.delete()
|
||||
logger.info(f"🔇 SILENCE: @{user.username or user.id} удалено молча",
|
||||
log_type="BANWORDS")
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(f"❌ Не удалено (silence): {e}", log_type="BANWORDS")
|
||||
return
|
||||
|
||||
# === 2. ЛОГИРУЕМ В БД ===
|
||||
# Удаляем
|
||||
try:
|
||||
await message.delete()
|
||||
logger.info(f"🚫 @{user.username or user.id}: '{matched_word}' ({match_type})",
|
||||
log_type="BANWORDS")
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(f"❌ Не удалено: {e}", log_type="BANWORDS")
|
||||
return
|
||||
|
||||
# Логируем в БД (только НЕ silence)
|
||||
await self.manager.log_spam(
|
||||
user_id=user.id,
|
||||
username=user.username or f"id{user.id}",
|
||||
@@ -294,96 +187,71 @@ class BanWordsMiddleware(BaseMiddleware):
|
||||
match_type=match_type
|
||||
)
|
||||
|
||||
# === 3. УВЕДОМЛЯЕМ АДМИНОВ ===
|
||||
# Уведомляем админов (только НЕ silence)
|
||||
await self._notify_admins(message, matched_word, match_type, message_text)
|
||||
|
||||
# Остальные методы без изменений...
|
||||
async def _notify_admins(
|
||||
self,
|
||||
message: Message,
|
||||
matched_word: str,
|
||||
match_type: str,
|
||||
message_text: str
|
||||
self,
|
||||
message: Message,
|
||||
matched_word: str,
|
||||
match_type: str,
|
||||
message_text: str
|
||||
) -> None:
|
||||
"""
|
||||
Отправляет уведомление в админский чат с кнопками.
|
||||
|
||||
Args:
|
||||
message: Удалённое сообщение
|
||||
matched_word: Слово, по которому сработал фильтр
|
||||
match_type: Тип проверки
|
||||
message_text: Текст сообщения
|
||||
"""
|
||||
user = message.from_user
|
||||
username = f"@{user.username}" if user.username else f"ID: {user.id}"
|
||||
|
||||
# Получаем количество предыдущих нарушений
|
||||
spam_count = await self.manager.get_user_spam_count(user.id)
|
||||
chat_title = message.chat.title or "Без названия"
|
||||
source_thread_id = message.message_thread_id
|
||||
|
||||
# Формируем текст уведомления
|
||||
notification_text = (
|
||||
f"🚫 <b>Удалено сообщение</b>\n\n"
|
||||
f"👤 <b>Пользователь:</b> {username}\n"
|
||||
f"🆔 <b>ID:</b> <code>{user.id}</code>\n"
|
||||
f"📊 <b>Нарушений:</b> {spam_count}\n\n"
|
||||
f"🔍 <b>Триггер:</b> <code>{matched_word}</code>\n"
|
||||
f"📝 <b>Тип:</b> {self._get_type_emoji(match_type)} {match_type}\n\n"
|
||||
f"💬 <b>Текст:</b>\n"
|
||||
f"<code>{self._escape_html(message_text[:500])}</code>"
|
||||
f"🚫 <b>Удалено сообщение</b>\\n\\n"
|
||||
f"👤 <b>Пользователь:</b> {username}\\n"
|
||||
f"🆔 <b>ID:</b> <code>{user.id}</code>\\n"
|
||||
f"📊 <b>Нарушений:</b> {spam_count}\\n\\n"
|
||||
f"💬 <b>Чат:</b> {self._escape_html(chat_title)}\\n"
|
||||
f"🆔 <b>Chat ID:</b> <code>{message.chat.id}</code>\\n"
|
||||
f"{'📌 <b>Topic ID:</b> <code>{source_thread_id}</code>\\n' if source_thread_id else ''}"
|
||||
f"🔗 <b>Message ID:</b> <code>{message.message_id}</code>\\n\\n"
|
||||
f"🔍 <b>Триггер:</b> <code>{self._escape_html(matched_word)}</code>\\n"
|
||||
f"📝 <b>Тип:</b> {self._get_type_emoji(match_type)} {self._escape_html(match_type)}\\n\\n"
|
||||
f"💬 <b>Текст:</b>\\n<code>{self._escape_html(message_text[:500])}</code>"
|
||||
)
|
||||
|
||||
# Создаём клавиатуру с действиями
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔨 Забанить",
|
||||
callback_data=f"spam_ban:{user.id}:{message.chat.id}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="✅ Закрыть",
|
||||
callback_data="spam_close"
|
||||
)
|
||||
InlineKeyboardButton(text="🔨 Забанить", callback_data=f"spam_ban:{user.id}:{message.chat.id}"),
|
||||
InlineKeyboardButton(text="✅ Закрыть", callback_data="spam_close")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data=f"spam_stats:{user.id}"
|
||||
)
|
||||
]
|
||||
[InlineKeyboardButton(text="📊 Статистика", callback_data=f"spam_stats:{user.id}")]
|
||||
])
|
||||
|
||||
# Отправляем уведомление
|
||||
try:
|
||||
bot = message.bot
|
||||
await bot.send_message(
|
||||
chat_id=settings.ADMIN_CHAT_ID,
|
||||
admin_chat_id = getattr(settings, "ADMIN_CHAT_ID", None)
|
||||
admin_thread_id = getattr(settings, "ADMIN_THREAD_ID", None) or None
|
||||
|
||||
await message.bot.send_message(
|
||||
chat_id=admin_chat_id,
|
||||
text=notification_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
message_thread_id=admin_thread_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка отправки уведомления админам: {e}",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
logger.error(f"❌ Уведомление админам: {e}", log_type="BANWORDS")
|
||||
|
||||
@staticmethod
|
||||
def _get_type_emoji(match_type: str) -> str:
|
||||
"""Возвращает эмодзи для типа проверки"""
|
||||
emoji_map = {
|
||||
return {
|
||||
"substring": "🔤",
|
||||
"lemma": "📖",
|
||||
"part": "🧩",
|
||||
"silence": "🔇",
|
||||
"conflict_substring": "⚔️",
|
||||
"conflict_lemma": "⚔️"
|
||||
}
|
||||
return emoji_map.get(match_type, "❓")
|
||||
"conflict_lemma": "⚔️",
|
||||
"repeated_chars": "🔁"
|
||||
}.get(match_type, "❓")
|
||||
|
||||
@staticmethod
|
||||
def _escape_html(text: str) -> str:
|
||||
"""Экранирует HTML символы для безопасного отображения"""
|
||||
return (
|
||||
text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
return str(text).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
@@ -68,7 +68,6 @@ class UserSpamStats:
|
||||
"""Удаляет старые запросы за пределами временного окна"""
|
||||
cutoff_time = current_time - time_window
|
||||
|
||||
# Удаляем старые запросы
|
||||
new_times = []
|
||||
new_contexts = []
|
||||
|
||||
@@ -121,7 +120,6 @@ class UserSpamStats:
|
||||
current_time = time()
|
||||
|
||||
# 1. КРИТИЧНО: Экстремально быстрая отправка (флуд-бот)
|
||||
# Если 5+ сообщений за 2 секунды => мгновенный мут
|
||||
very_recent = [ctx for ctx in recent_contexts if (current_time - ctx.timestamp) < 2.0]
|
||||
if len(very_recent) >= 5:
|
||||
return {
|
||||
@@ -130,7 +128,7 @@ class UserSpamStats:
|
||||
'severity': 1.0,
|
||||
'details': f"⚡ Экстремальный флуд: {len(very_recent)} сообщений за 2 секунды",
|
||||
'instant_block': True,
|
||||
'block_duration': 600.0 # 10 минут сразу
|
||||
'block_duration': 600.0
|
||||
}
|
||||
|
||||
# 2. КРИТИЧНО: 8+ сообщений за 5 секунд => агрессивный флуд
|
||||
@@ -142,13 +140,12 @@ class UserSpamStats:
|
||||
'severity': 0.95,
|
||||
'details': f"🔥 Агрессивный флуд: {len(recent_5s)} сообщений за 5 секунд",
|
||||
'instant_block': True,
|
||||
'block_duration': 300.0 # 5 минут
|
||||
'block_duration': 300.0
|
||||
}
|
||||
|
||||
# 3. Медиа-флуд (стикеры/фото/видео)
|
||||
# 3. Медиа-флуд
|
||||
media_contexts = [ctx for ctx in recent_contexts if ctx.media_type]
|
||||
if len(media_contexts) >= 7:
|
||||
# Проверяем скорость отправки медиа
|
||||
media_recent = [ctx for ctx in media_contexts if (current_time - ctx.timestamp) < 5.0]
|
||||
if len(media_recent) >= 6:
|
||||
return {
|
||||
@@ -157,7 +154,7 @@ class UserSpamStats:
|
||||
'severity': 0.9,
|
||||
'details': f"📸 Медиа-флуд: {len(media_recent)} файлов за 5 секунд",
|
||||
'instant_block': True,
|
||||
'block_duration': 240.0 # 4 минуты
|
||||
'block_duration': 240.0
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -173,14 +170,14 @@ class UserSpamStats:
|
||||
text_counts = Counter(texts)
|
||||
most_common_text, count = text_counts.most_common(1)[0]
|
||||
|
||||
if count >= 5: # 5 одинаковых сообщений
|
||||
if count >= 5:
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'identical_messages',
|
||||
'severity': 0.85,
|
||||
'details': f"📋 Повтор: '{most_common_text[:40]}...' ({count}x)",
|
||||
'instant_block': True,
|
||||
'block_duration': 180.0 # 3 минуты
|
||||
'block_duration': 180.0
|
||||
}
|
||||
|
||||
# 5. Проверка спама callback кнопок
|
||||
@@ -189,14 +186,14 @@ class UserSpamStats:
|
||||
callback_counts = Counter(callbacks)
|
||||
most_common_callback, count = callback_counts.most_common(1)[0]
|
||||
|
||||
if count >= 10: # 10 нажатий одной кнопки
|
||||
if count >= 10:
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'callback_spam',
|
||||
'severity': 0.8,
|
||||
'details': f"🔘 Спам кнопки: {count} нажатий",
|
||||
'instant_block': True,
|
||||
'block_duration': 120.0 # 2 минуты
|
||||
'block_duration': 120.0
|
||||
}
|
||||
|
||||
return {'is_spam': False, 'reason': None, 'severity': 0.0}
|
||||
@@ -269,11 +266,11 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
- Детекция скорости отправки сообщений
|
||||
- Адаптивная длительность блокировки
|
||||
- Различает типы активности
|
||||
- Бот никогда не банит сам себя
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# Базовые лимиты (мягкие, для накопления варнингов)
|
||||
rate_limit_text: int = 8,
|
||||
rate_limit_forward: int = 20,
|
||||
rate_limit_callback: int = 12,
|
||||
@@ -281,12 +278,10 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
|
||||
time_window: float = 10.0,
|
||||
|
||||
# Предупреждения (уже не так важны — флуд блокируется мгновенно)
|
||||
warning_limit: int = 3,
|
||||
base_block_duration: float = 120.0, # 2 минуты за накопленные варнинги
|
||||
base_block_duration: float = 120.0,
|
||||
max_block_duration: float = 3600.0,
|
||||
|
||||
# Опции
|
||||
whitelist_admins: bool = True,
|
||||
progressive_blocking: bool = True,
|
||||
enable_smart_detection: bool = True,
|
||||
@@ -318,7 +313,6 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
context.is_reply = event.reply_to_message is not None
|
||||
context.is_command = bool(context.text and context.text.startswith('/'))
|
||||
|
||||
# Определяем тип медиа
|
||||
if event.photo:
|
||||
context.media_type = 'photo'
|
||||
elif event.video:
|
||||
@@ -350,7 +344,6 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
else:
|
||||
base_limit = self.rate_limit_text
|
||||
|
||||
# Применяем репутацию
|
||||
if self.enable_reputation:
|
||||
base_limit = int(base_limit * user_stats.reputation)
|
||||
|
||||
@@ -392,6 +385,11 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
if user_id is None:
|
||||
return await handler(event, data)
|
||||
|
||||
# ✅ ИСПРАВЛЕНИЕ: пропускаем самого бота (предотвращает самобан)
|
||||
bot = data.get("bot")
|
||||
if bot and user_id == bot.id:
|
||||
return await handler(event, data)
|
||||
|
||||
user_str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}"
|
||||
|
||||
# Whitelist для администраторов
|
||||
@@ -414,7 +412,7 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
user=user_str
|
||||
)
|
||||
|
||||
# НЕ отправляем сообщение каждый раз — только callback answer
|
||||
# Только для callback — отвечаем алертом, для сообщений молчим
|
||||
if isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
f"🚫 Блокировка: {self._format_duration(remaining)}",
|
||||
@@ -426,10 +424,10 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
# Извлекаем контекст сообщения
|
||||
context = self._extract_context(event)
|
||||
|
||||
# Добавляем запрос СНАЧАЛА (важно для детекции скорости)
|
||||
# Добавляем запрос СНАЧАЛА — важно для детекции скорости флуда
|
||||
user_stats.add_request(current_time, context)
|
||||
|
||||
# Очищаем старые запросы
|
||||
# Очищаем старые запросы за пределами временного окна
|
||||
user_stats.clean_old_requests(current_time, self.time_window)
|
||||
|
||||
# ========== КРИТИЧНО: МГНОВЕННАЯ ДЕТЕКЦИЯ ФЛУДА ==========
|
||||
@@ -437,10 +435,9 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
spam_analysis = user_stats.detect_spam_patterns(self.time_window)
|
||||
|
||||
if spam_analysis.get('is_spam') and spam_analysis.get('instant_block'):
|
||||
# МГНОВЕННАЯ БЛОКИРОВКА
|
||||
block_duration = spam_analysis.get('block_duration', 300.0)
|
||||
user_stats.block(current_time, block_duration)
|
||||
user_stats.warnings = self.warning_limit # Максимум варнингов
|
||||
user_stats.warnings = self.warning_limit
|
||||
spam_stats.instant_blocks += 1
|
||||
|
||||
logger.error(
|
||||
@@ -461,7 +458,7 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
if isinstance(event, Message):
|
||||
try:
|
||||
await event.answer(block_message, parse_mode="HTML")
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
@@ -471,10 +468,9 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
|
||||
return None
|
||||
|
||||
# ========== ОБЫЧНАЯ ПРОВЕРКА ЛИМИТОВ (для мягких превышений) ==========
|
||||
# ========== ОБЫЧНАЯ ПРОВЕРКА ЛИМИТОВ ==========
|
||||
effective_limit = self._get_effective_rate_limit(user_stats, context)
|
||||
|
||||
# Подсчитываем релевантные запросы
|
||||
relevant_requests = 0
|
||||
for req_context in user_stats.message_contexts:
|
||||
if context.is_forward and req_context.is_forward:
|
||||
@@ -493,7 +489,6 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
user=user_str
|
||||
)
|
||||
|
||||
# Мягкое превышение лимита
|
||||
if relevant_requests >= effective_limit:
|
||||
user_stats.add_warning()
|
||||
spam_stats.total_warnings_issued += 1
|
||||
@@ -505,7 +500,6 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
user=user_str
|
||||
)
|
||||
|
||||
# Блокировка при достижении лимита варнингов
|
||||
if user_stats.warnings >= self.warning_limit:
|
||||
block_duration = self._calculate_block_duration(user_stats.warnings)
|
||||
user_stats.block(current_time, block_duration)
|
||||
@@ -526,7 +520,7 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
if isinstance(event, Message):
|
||||
try:
|
||||
await event.answer(block_message, parse_mode="HTML")
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
@@ -536,7 +530,6 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
|
||||
return None
|
||||
|
||||
# Предупреждение (только для сообщений, не для callback)
|
||||
if isinstance(event, Message):
|
||||
warning_message = (
|
||||
f"⚠️ <b>Предупреждение {user_stats.warnings}/{self.warning_limit}</b>\n\n"
|
||||
@@ -544,7 +537,7 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
)
|
||||
try:
|
||||
await event.answer(warning_message, parse_mode="HTML")
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user