Первый коммит
This commit is contained in:
137
bot/middlewares/__init__.py
Normal file
137
bot/middlewares/__init__.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Middleware для бота PrimoGuardBot.
|
||||
|
||||
Порядок выполнения middleware важен:
|
||||
1. TimingMiddleware - замер времени выполнения
|
||||
2. LoggingMiddleware - логирование всех событий
|
||||
3. BanCheckMiddleware - проверка статуса бана (блокирует забаненных)
|
||||
4. ErrorHandlingMiddleware - обработка ошибок (последний)
|
||||
|
||||
Message-level middleware:
|
||||
1. RateLimitMiddleware/AntiSpamMiddleware - защита от флуда
|
||||
2. SubscriptionMiddleware - проверка подписки на каналы
|
||||
3. ReferralMiddleware - обработка реферальных ссылок
|
||||
"""
|
||||
from aiogram import Dispatcher, Bot
|
||||
|
||||
from configs import settings
|
||||
from middleware.loggers import logger
|
||||
from .error_mdw import ErrorHandlingMiddleware
|
||||
from .logging_mdw import LoggingMiddleware
|
||||
from .referal_mdw import ReferralMiddleware
|
||||
from .spam_mdw import AntiSpamMiddleware, spam_stats
|
||||
from .sub_mdw import SubscriptionMiddleware
|
||||
from .time_mdw import TimingMiddleware
|
||||
from .banwords_mdw import BanWordsMiddleware
|
||||
|
||||
__all__ = (
|
||||
# Middleware классы
|
||||
"TimingMiddleware",
|
||||
"LoggingMiddleware",
|
||||
"ErrorHandlingMiddleware",
|
||||
"AntiSpamMiddleware",
|
||||
"SubscriptionMiddleware",
|
||||
"ReferralMiddleware",
|
||||
"BanWordsMiddleware",
|
||||
|
||||
# Статистика
|
||||
"spam_stats",
|
||||
|
||||
# Утилиты
|
||||
"setup_middlewares",
|
||||
)
|
||||
|
||||
|
||||
def setup_middlewares(
|
||||
dp: Dispatcher,
|
||||
bot: Bot,
|
||||
admin_ids: list[int] = settings.ADMIN_ID+settings.OWNER_ID,
|
||||
channel_ids: list[int | str] | None = None,
|
||||
enable_spam_check: bool = False,
|
||||
enable_subscription_check: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
Регистрирует все middleware в диспетчере.
|
||||
|
||||
Args:
|
||||
dp: Диспетчер aiogram
|
||||
bot: Экземпляр бота
|
||||
admin_ids: ID администраторов (для защиты и уведомлений)
|
||||
channel_ids: ID каналов для проверки подписки
|
||||
enable_spam_check: Включить антиспам
|
||||
enable_subscription_check: Включить проверку подписки
|
||||
|
||||
Returns:
|
||||
dict: Словарь с экземплярами middleware для доступа к методам
|
||||
"""
|
||||
channel_ids = channel_ids or []
|
||||
|
||||
# === UPDATE LEVEL MIDDLEWARE (для всех событий) ===
|
||||
middlewares_updates = []
|
||||
instances = {}
|
||||
|
||||
# 1. Timing - замер времени (первый!)
|
||||
timing_mdw = TimingMiddleware()
|
||||
middlewares_updates.append(timing_mdw)
|
||||
instances['timing'] = timing_mdw
|
||||
|
||||
# 2. Logging - логирование всех событий
|
||||
loggings_mdw = LoggingMiddleware()
|
||||
middlewares_updates.append(loggings_mdw)
|
||||
instances['logging'] = loggings_mdw
|
||||
|
||||
# 3. ErrorHandling - обработка ошибок (последний!)
|
||||
errors_mdw = ErrorHandlingMiddleware(admin_ids=admin_ids)
|
||||
middlewares_updates.append(errors_mdw)
|
||||
instances['error'] = errors_mdw
|
||||
|
||||
# === MESSAGE LEVEL MIDDLEWARE (только для сообщений) ===
|
||||
middlewares_msg = []
|
||||
|
||||
# 1. AntiSpam - защита от флуда (опционально)
|
||||
if enable_spam_check:
|
||||
spams_mdw = AntiSpamMiddleware()
|
||||
middlewares_msg.append(spams_mdw)
|
||||
instances['spam'] = spams_mdw
|
||||
|
||||
# 2. Subscription - проверка подписки на каналы (опционально)
|
||||
if enable_subscription_check and channel_ids:
|
||||
subs_mdw = SubscriptionMiddleware(bot=bot, channels=channel_ids)
|
||||
middlewares_msg.append(subs_mdw)
|
||||
instances['subscription'] = subs_mdw
|
||||
|
||||
dp.message.middleware(BanWordsMiddleware())
|
||||
|
||||
# 3. Referral - обработка реферальных ссылок
|
||||
referral_mdw = ReferralMiddleware()
|
||||
middlewares_msg.append(referral_mdw)
|
||||
instances['referral'] = referral_mdw
|
||||
|
||||
# === РЕГИСТРАЦИЯ MIDDLEWARE ===
|
||||
|
||||
# Регистрируем update-level middleware
|
||||
for middleware in middlewares_updates:
|
||||
dp.update.middleware(middleware)
|
||||
|
||||
# Регистрируем message-level middleware
|
||||
for middleware in middlewares_msg:
|
||||
dp.message.middleware(middleware)
|
||||
|
||||
# Логируем успешную регистрацию
|
||||
enabled_features = []
|
||||
if enable_spam_check:
|
||||
enabled_features.append("AntiSpam")
|
||||
if enable_subscription_check:
|
||||
enabled_features.append("Subscription")
|
||||
|
||||
logger.info(
|
||||
text=(
|
||||
f"Middleware зарегистрированы: "
|
||||
f"Update={len(middlewares_updates)}, "
|
||||
f"Message={len(middlewares_msg)}, "
|
||||
f"Функции=[{', '.join(enabled_features) if enabled_features else 'базовые'}]"
|
||||
),
|
||||
log_type="MIDDLEWARE_SETUP"
|
||||
)
|
||||
|
||||
return instances
|
||||
337
bot/middlewares/banwords_mdw.py
Normal file
337
bot/middlewares/banwords_mdw.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Middleware для проверки сообщений на запрещённые слова (банворды).
|
||||
|
||||
Pipeline проверки:
|
||||
1. Пропускаем админов и служебные сообщения
|
||||
2. Проверяем whitelist (исключения)
|
||||
3. Проверяем режим silence (удаляем всё)
|
||||
4. Проверяем режим conflict (конфликтные слова)
|
||||
5. Проверяем постоянные банворды (substring, lemma, part)
|
||||
6. Проверяем временные банворды
|
||||
7. Если найдено - удаляем, логируем, уведомляем админов
|
||||
"""
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||
import re
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from configs import settings
|
||||
from database import get_manager, BanWordType
|
||||
from bot.special import process_text, extract_words, get_lemma
|
||||
from middleware.loggers import logger
|
||||
|
||||
__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]
|
||||
) -> 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 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())
|
||||
|
||||
async def _check_message(self, text: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Проверяет сообщение на наличие банвордов.
|
||||
|
||||
Args:
|
||||
text: Текст сообщения
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: {"word": "найденное_слово", "type": "тип_проверки"} или None
|
||||
"""
|
||||
# Нормализуем текст для проверки
|
||||
text_lower = text.lower()
|
||||
text_processed = process_text(text_lower)
|
||||
|
||||
# === 1. WHITELIST (исключения) ===
|
||||
if self.manager.is_whitelisted(text_processed):
|
||||
logger.debug(
|
||||
f"Сообщение содержит whitelist слово: '{text_processed[:50]}'",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
return None
|
||||
|
||||
# === 2. SILENCE MODE (удаляем всё) ===
|
||||
if await self.manager.is_silence_active():
|
||||
return {
|
||||
"word": "[режим тишины]",
|
||||
"type": "silence"
|
||||
}
|
||||
|
||||
# === 3. CONFLICT MODE (конфликтные слова) ===
|
||||
if await self.manager.is_conflict_active():
|
||||
# Проверяем конфликтные подстроки
|
||||
conflict_substring = self.manager.get_banwords_cached(
|
||||
BanWordType.CONFLICT_SUBSTRING
|
||||
)
|
||||
for word in conflict_substring:
|
||||
if word in text_processed:
|
||||
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:
|
||||
lemma = get_lemma(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:
|
||||
if word in text_processed:
|
||||
return {"word": word, "type": "substring"}
|
||||
|
||||
# === 5. PART (части слов без пробелов и спецсимволов) ===
|
||||
part_words = self.manager.get_banwords_cached(BanWordType.PART)
|
||||
if part_words:
|
||||
# Специальная нормализация для PART: удаляем ВСЁ кроме букв и цифр
|
||||
text_normalized = self._normalize_for_part_check(text)
|
||||
|
||||
logger.debug(
|
||||
f"Проверка PART: исходный='{text[:50]}', нормализованный='{text_normalized[:50]}'",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
|
||||
for part in part_words:
|
||||
# Нормализуем само запрещенное слово тоже
|
||||
part_normalized = self._normalize_for_part_check(part)
|
||||
|
||||
if part_normalized in text_normalized:
|
||||
logger.info(
|
||||
f"Найдена запрещенная часть: '{part}' (нормализовано: '{part_normalized}') "
|
||||
f"в тексте '{text_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:
|
||||
lemma = get_lemma(word_text)
|
||||
if lemma in lemma_words:
|
||||
return {"word": lemma, "type": "lemma"}
|
||||
|
||||
# Банворды не найдены
|
||||
return None
|
||||
|
||||
async def _handle_spam(
|
||||
self,
|
||||
message: Message,
|
||||
spam_result: Dict[str, str]
|
||||
) -> None:
|
||||
"""
|
||||
Обрабатывает найденный спам: удаляет, логирует, уведомляет.
|
||||
|
||||
Args:
|
||||
message: Сообщение со спамом
|
||||
spam_result: Результат проверки (слово + тип)
|
||||
"""
|
||||
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",
|
||||
message=message
|
||||
)
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(
|
||||
f"Не удалось удалить сообщение: {e}",
|
||||
log_type="ERROR",
|
||||
message=message
|
||||
)
|
||||
return
|
||||
|
||||
# === 2. ЛОГИРУЕМ В БД ===
|
||||
await self.manager.log_spam(
|
||||
user_id=user.id,
|
||||
username=user.username or f"id{user.id}",
|
||||
chat_id=message.chat.id,
|
||||
message_text=message_text,
|
||||
matched_word=matched_word,
|
||||
match_type=match_type
|
||||
)
|
||||
|
||||
# === 3. УВЕДОМЛЯЕМ АДМИНОВ ===
|
||||
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
|
||||
) -> 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)
|
||||
|
||||
# Формируем текст уведомления
|
||||
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>"
|
||||
)
|
||||
|
||||
# Создаём клавиатуру с действиями
|
||||
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_stats:{user.id}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
# Отправляем уведомление
|
||||
try:
|
||||
bot = message.bot
|
||||
await bot.send_message(
|
||||
chat_id=settings.ADMIN_CHAT_ID,
|
||||
text=notification_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка отправки уведомления админам: {e}",
|
||||
log_type="ERROR"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_type_emoji(match_type: str) -> str:
|
||||
"""Возвращает эмодзи для типа проверки"""
|
||||
emoji_map = {
|
||||
"substring": "🔤",
|
||||
"lemma": "📖",
|
||||
"part": "🧩",
|
||||
"silence": "🔇",
|
||||
"conflict_substring": "⚔️",
|
||||
"conflict_lemma": "⚔️"
|
||||
}
|
||||
return emoji_map.get(match_type, "❓")
|
||||
|
||||
@staticmethod
|
||||
def _escape_html(text: str) -> str:
|
||||
"""Экранирует HTML символы для безопасного отображения"""
|
||||
return (
|
||||
text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
674
bot/middlewares/error_mdw.py
Normal file
674
bot/middlewares/error_mdw.py
Normal file
@@ -0,0 +1,674 @@
|
||||
"""
|
||||
Middleware для глобальной обработки ошибок
|
||||
"""
|
||||
from typing import Callable, Awaitable, Any, Dict, Optional, List, Set
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from enum import Enum
|
||||
import traceback
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, Message, CallbackQuery, Update
|
||||
from aiogram.exceptions import (
|
||||
TelegramBadRequest,
|
||||
TelegramForbiddenError,
|
||||
TelegramNotFound,
|
||||
TelegramUnauthorizedError,
|
||||
TelegramRetryAfter,
|
||||
TelegramAPIError
|
||||
)
|
||||
|
||||
from middleware.loggers import logger
|
||||
from bot.utils import (
|
||||
username,
|
||||
format_content_info,
|
||||
get_content_type,
|
||||
safe_answer_callback,
|
||||
format_duration,
|
||||
format_timestamp
|
||||
)
|
||||
from bot.templates import msg
|
||||
|
||||
__all__ = ('ErrorHandlingMiddleware', 'ErrorCategory')
|
||||
|
||||
|
||||
class ErrorCategory(str, Enum):
|
||||
"""Категории ошибок"""
|
||||
TELEGRAM_API = "telegram_api" # Ошибки Telegram API
|
||||
RATE_LIMIT = "rate_limit" # Rate limiting
|
||||
PERMISSION = "permission" # Права доступа
|
||||
VALIDATION = "validation" # Валидация данных
|
||||
DATABASE = "database" # Ошибки БД
|
||||
HANDLER = "handler" # Ошибки в хендлерах
|
||||
UNKNOWN = "unknown" # Неизвестные ошибки
|
||||
|
||||
|
||||
class ErrorStats:
|
||||
"""Статистика ошибок"""
|
||||
|
||||
def __init__(self):
|
||||
# Счетчики по категориям
|
||||
self.by_category: Dict[ErrorCategory, int] = defaultdict(int)
|
||||
|
||||
# Счетчики по типам исключений
|
||||
self.by_exception: Dict[str, int] = defaultdict(int)
|
||||
|
||||
# Последние ошибки (последние 10)
|
||||
self.recent_errors: List[Dict[str, Any]] = []
|
||||
self.max_recent = 10
|
||||
|
||||
# Общая статистика
|
||||
self.total_errors: int = 0
|
||||
self.start_time: datetime = datetime.now()
|
||||
|
||||
def add_error(
|
||||
self,
|
||||
exception: Exception,
|
||||
category: ErrorCategory,
|
||||
user_id: Optional[int] = None,
|
||||
details: Optional[Dict] = None
|
||||
):
|
||||
"""Добавляет ошибку в статистику"""
|
||||
self.total_errors += 1
|
||||
self.by_category[category] += 1
|
||||
self.by_exception[type(exception).__name__] += 1
|
||||
|
||||
# Добавляем в последние ошибки
|
||||
error_info = {
|
||||
'timestamp': datetime.now(),
|
||||
'exception': type(exception).__name__,
|
||||
'message': str(exception),
|
||||
'category': category,
|
||||
'user_id': user_id,
|
||||
'details': details or {}
|
||||
}
|
||||
|
||||
self.recent_errors.append(error_info)
|
||||
if len(self.recent_errors) > self.max_recent:
|
||||
self.recent_errors.pop(0)
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Возвращает сводку по статистике"""
|
||||
uptime = datetime.now() - self.start_time
|
||||
|
||||
return {
|
||||
'total_errors': self.total_errors,
|
||||
'uptime': format_duration(int(uptime.total_seconds())),
|
||||
'by_category': dict(self.by_category),
|
||||
'by_exception': dict(self.by_exception),
|
||||
'recent_errors': self.recent_errors
|
||||
}
|
||||
|
||||
|
||||
class ErrorHandlingMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для глобальной обработки ошибок.
|
||||
|
||||
Features:
|
||||
- Категоризация ошибок
|
||||
- Уведомление администраторов
|
||||
- Статистика ошибок
|
||||
- Rate limiting уведомлений
|
||||
- Retry механизм для некоторых ошибок
|
||||
- Детальное логирование
|
||||
- Graceful degradation
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
admin_ids: List[int],
|
||||
notify_admins: bool = True,
|
||||
notify_users: bool = True,
|
||||
log_errors: bool = True,
|
||||
notify_rate_limit: int = 60 # Не чаще раза в минуту для одного типа ошибки
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
admin_ids: Список ID администраторов
|
||||
notify_admins: Уведомлять администраторов
|
||||
notify_users: Уведомлять пользователей
|
||||
log_errors: Логировать ошибки
|
||||
notify_rate_limit: Минимальный интервал между уведомлениями (секунды)
|
||||
"""
|
||||
super().__init__()
|
||||
self.admin_ids = admin_ids
|
||||
self.notify_admins = notify_admins
|
||||
self.notify_users = notify_users
|
||||
self.log_errors = log_errors
|
||||
self.notify_rate_limit = notify_rate_limit
|
||||
|
||||
# Статистика
|
||||
self.stats = ErrorStats()
|
||||
|
||||
# Rate limiting для уведомлений
|
||||
# {error_type: last_notification_time}
|
||||
self._last_notifications: Dict[str, datetime] = {}
|
||||
|
||||
# Игнорируемые ошибки (для которых не нужно уведомлять)
|
||||
self.ignored_errors: Set[type] = {
|
||||
TelegramRetryAfter, # Rate limit Telegram
|
||||
}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""
|
||||
Обрабатывает ошибки в хендлерах.
|
||||
|
||||
Args:
|
||||
handler: Следующий обработчик
|
||||
event: Входящее событие
|
||||
data: Контекстные данные
|
||||
|
||||
Returns:
|
||||
Результат выполнения обработчика или None при ошибке
|
||||
"""
|
||||
try:
|
||||
# Выполняем хендлер
|
||||
return await handler(event, data)
|
||||
|
||||
except Exception as e:
|
||||
# Обрабатываем ошибку
|
||||
await self._handle_error(e, event, data)
|
||||
return None
|
||||
|
||||
async def _handle_error(
|
||||
self,
|
||||
exception: Exception,
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Централизованная обработка ошибки.
|
||||
|
||||
Args:
|
||||
exception: Исключение
|
||||
event: Событие
|
||||
data: Контекстные данные
|
||||
"""
|
||||
# Определяем категорию ошибки
|
||||
category = self._categorize_error(exception)
|
||||
|
||||
# Извлекаем информацию о событии
|
||||
event_info = self._extract_event_info(event)
|
||||
|
||||
# Добавляем в статистику
|
||||
self.stats.add_error(
|
||||
exception=exception,
|
||||
category=category,
|
||||
user_id=event_info.get('user_id'),
|
||||
details=event_info
|
||||
)
|
||||
|
||||
# Логируем ошибку
|
||||
if self.log_errors:
|
||||
await self._log_error(exception, category, event_info)
|
||||
|
||||
# Уведомляем администраторов
|
||||
if self.notify_admins and not self._is_ignored(exception):
|
||||
await self._notify_admins_about_error(exception, category, event_info, event)
|
||||
|
||||
# Уведомляем пользователя
|
||||
if self.notify_users:
|
||||
await self._notify_user_about_error(exception, category, event)
|
||||
|
||||
@staticmethod
|
||||
def _categorize_error(exception: Exception) -> ErrorCategory:
|
||||
"""
|
||||
Определяет категорию ошибки.
|
||||
|
||||
Args:
|
||||
exception: Исключение
|
||||
|
||||
Returns:
|
||||
Категория ошибки
|
||||
"""
|
||||
# Ошибки Telegram API
|
||||
if isinstance(exception, TelegramRetryAfter):
|
||||
return ErrorCategory.RATE_LIMIT
|
||||
|
||||
if isinstance(exception, (TelegramForbiddenError, TelegramUnauthorizedError)):
|
||||
return ErrorCategory.PERMISSION
|
||||
|
||||
if isinstance(exception, (TelegramBadRequest, TelegramNotFound)):
|
||||
return ErrorCategory.TELEGRAM_API
|
||||
|
||||
if isinstance(exception, TelegramAPIError):
|
||||
return ErrorCategory.TELEGRAM_API
|
||||
|
||||
# Ошибки валидации
|
||||
if isinstance(exception, (ValueError, TypeError, AttributeError)):
|
||||
return ErrorCategory.VALIDATION
|
||||
|
||||
# Ошибки БД (примеры, замени на свои)
|
||||
# if isinstance(exception, (DatabaseError, OperationalError)):
|
||||
# return ErrorCategory.DATABASE
|
||||
|
||||
# Остальные ошибки
|
||||
return ErrorCategory.HANDLER
|
||||
|
||||
@staticmethod
|
||||
def _extract_event_info(event: TelegramObject) -> Dict[str, Any]:
|
||||
"""
|
||||
Извлекает информацию о событии.
|
||||
|
||||
Args:
|
||||
event: Объект события
|
||||
|
||||
Returns:
|
||||
Словарь с информацией
|
||||
"""
|
||||
info: Dict[str, Any] = {
|
||||
'event_type': type(event).__name__,
|
||||
'timestamp': datetime.now(),
|
||||
'user_str': '@System',
|
||||
'user_id': None,
|
||||
'chat_id': None,
|
||||
'chat_type': None,
|
||||
'message_id': None,
|
||||
'content_type': None,
|
||||
'content_info': None,
|
||||
'text': None
|
||||
}
|
||||
|
||||
# Обработка разных типов событий
|
||||
message = None
|
||||
|
||||
if isinstance(event, Message):
|
||||
message = event
|
||||
elif isinstance(event, CallbackQuery):
|
||||
message = event.message
|
||||
info['callback_data'] = event.data
|
||||
elif isinstance(event, Update):
|
||||
message = (
|
||||
event.message or
|
||||
event.edited_message or
|
||||
event.channel_post or
|
||||
event.edited_channel_post
|
||||
)
|
||||
|
||||
if event.callback_query:
|
||||
info['callback_data'] = event.callback_query.data
|
||||
|
||||
# Извлекаем информацию из сообщения
|
||||
if message:
|
||||
# Пользователь
|
||||
if message.from_user:
|
||||
info['user_str'] = username(message)
|
||||
info['user_id'] = message.from_user.id
|
||||
|
||||
# Чат
|
||||
info['chat_id'] = message.chat.id
|
||||
info['chat_type'] = message.chat.type
|
||||
info['message_id'] = message.message_id
|
||||
|
||||
# Контент
|
||||
info['content_type'] = get_content_type(message)
|
||||
info['content_info'] = format_content_info(message, include_text=False)
|
||||
|
||||
# Текст
|
||||
if message.text:
|
||||
text = message.text
|
||||
info['text'] = text if len(text) <= 100 else text[:100] + "..."
|
||||
elif message.caption:
|
||||
caption = message.caption
|
||||
info['caption'] = caption if len(caption) <= 100 else caption[:100] + "..."
|
||||
|
||||
return info
|
||||
|
||||
@staticmethod
|
||||
async def _log_error(
|
||||
exception: Exception,
|
||||
category: ErrorCategory,
|
||||
event_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Логирует ошибку.
|
||||
|
||||
Args:
|
||||
exception: Исключение
|
||||
category: Категория ошибки
|
||||
event_info: Информация о событии
|
||||
"""
|
||||
# Формируем сообщение для лога
|
||||
error_type = type(exception).__name__
|
||||
error_msg = str(exception)
|
||||
|
||||
# Получаем traceback
|
||||
tb = ''.join(traceback.format_exception(
|
||||
type(exception),
|
||||
exception,
|
||||
exception.__traceback__
|
||||
))
|
||||
|
||||
# Базовое сообщение
|
||||
log_msg = (
|
||||
f"🚨 Ошибка в хендлере\n"
|
||||
f"├─ Тип: {error_type}\n"
|
||||
f"├─ Категория: {category.value}\n"
|
||||
f"├─ Сообщение: {error_msg}\n"
|
||||
f"├─ Событие: {event_info['event_type']}\n"
|
||||
)
|
||||
|
||||
if event_info.get('text'):
|
||||
log_msg += f"├─ Текст: {event_info['text']}\n"
|
||||
|
||||
if event_info.get('callback_data'):
|
||||
log_msg += f"├─ Callback: {event_info['callback_data']}\n"
|
||||
|
||||
if event_info.get('content_info'):
|
||||
log_msg += f"└─ Контент: {event_info['content_info']}"
|
||||
|
||||
# Логируем с полным traceback
|
||||
logger.error(
|
||||
text=log_msg,
|
||||
log_type=f"ERROR_{category.value.upper()}",
|
||||
user=event_info['user_str'],
|
||||
)
|
||||
|
||||
# Дополнительно логируем traceback отдельно для детального анализа
|
||||
logger.debug(
|
||||
text=f"Полный traceback:\n{tb}",
|
||||
log_type=f"ERROR_{category.value.upper()}_TRACEBACK",
|
||||
user=event_info['user_str']
|
||||
)
|
||||
|
||||
async def _notify_admins_about_error(
|
||||
self,
|
||||
exception: Exception,
|
||||
category: ErrorCategory,
|
||||
event_info: Dict[str, Any],
|
||||
event: TelegramObject
|
||||
):
|
||||
"""
|
||||
Уведомляет администраторов об ошибке.
|
||||
|
||||
Args:
|
||||
exception: Исключение
|
||||
category: Категория ошибки
|
||||
event_info: Информация о событии
|
||||
event: Объект события
|
||||
"""
|
||||
# Проверяем rate limit
|
||||
error_key = type(exception).__name__
|
||||
|
||||
if not self._should_notify(error_key):
|
||||
logger.debug(
|
||||
f"Пропуск уведомления админов о {error_key} (rate limit)",
|
||||
log_type="ADMIN_NOTIFY_SKIP"
|
||||
)
|
||||
return
|
||||
|
||||
# Обновляем время последнего уведомления
|
||||
self._last_notifications[error_key] = datetime.now()
|
||||
|
||||
# Получаем bot
|
||||
bot = event.bot if hasattr(event, 'bot') else None
|
||||
if not bot:
|
||||
return
|
||||
|
||||
# Формируем сообщение
|
||||
error_type = type(exception).__name__
|
||||
error_msg = str(exception)
|
||||
|
||||
# Определяем emoji для категории
|
||||
category_emoji = self._get_category_emoji(category)
|
||||
|
||||
notification = (
|
||||
f"{category_emoji} <b>Ошибка в боте</b>\n\n"
|
||||
f"📊 <b>Информация:</b>\n"
|
||||
f"├─ Тип: <code>{error_type}</code>\n"
|
||||
f"├─ Категория: <code>{category.value}</code>\n"
|
||||
f"├─ Время: {format_timestamp(datetime.now())}\n"
|
||||
)
|
||||
|
||||
# Добавляем информацию о пользователе
|
||||
if event_info.get('user_str') and event_info['user_str'] != '@System':
|
||||
notification += f"└─ Пользователь: {event_info['user_str']}\n\n"
|
||||
else:
|
||||
notification += "\n"
|
||||
|
||||
# Добавляем сообщение ошибки
|
||||
if len(error_msg) <= 200:
|
||||
notification += f"💬 <b>Сообщение:</b>\n<code>{error_msg}</code>\n\n"
|
||||
else:
|
||||
notification += f"💬 <b>Сообщение:</b>\n<code>{error_msg[:200]}...</code>\n\n"
|
||||
|
||||
# Добавляем контекст события
|
||||
notification += f"📋 <b>Контекст:</b>\n"
|
||||
|
||||
if event_info.get('text'):
|
||||
notification += f"├─ Текст: <code>{event_info['text']}</code>\n"
|
||||
|
||||
if event_info.get('callback_data'):
|
||||
notification += f"├─ Callback: <code>{event_info['callback_data']}</code>\n"
|
||||
|
||||
if event_info.get('content_info'):
|
||||
notification += f"├─ Контент: {event_info['content_info']}\n"
|
||||
|
||||
if event_info.get('chat_type'):
|
||||
notification += f"└─ Тип чата: <code>{event_info['chat_type']}</code>\n"
|
||||
|
||||
# Добавляем статистику
|
||||
stats = self.stats.get_summary()
|
||||
notification += (
|
||||
f"\n📊 <b>Статистика:</b>\n"
|
||||
f"└─ Всего ошибок: {stats['total_errors']}"
|
||||
)
|
||||
|
||||
# Отправляем администраторам
|
||||
for admin_id in self.admin_ids:
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=admin_id,
|
||||
text=notification,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Администратор {admin_id} уведомлен об ошибке",
|
||||
log_type="ADMIN_NOTIFIED"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Не удалось уведомить админа {admin_id}: {e}",
|
||||
log_type="ADMIN_NOTIFY_ERROR"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _notify_user_about_error(
|
||||
exception: Exception,
|
||||
category: ErrorCategory,
|
||||
event: TelegramObject
|
||||
):
|
||||
"""
|
||||
Уведомляет пользователя об ошибке.
|
||||
|
||||
Args:
|
||||
exception: Исключение
|
||||
category: Категория ошибки
|
||||
event: Объект события
|
||||
"""
|
||||
# Формируем сообщение в зависимости от категории
|
||||
error_messages = {
|
||||
ErrorCategory.TELEGRAM_API: (
|
||||
"⚠️ Произошла техническая ошибка.\n"
|
||||
"Попробуйте повторить действие."
|
||||
),
|
||||
ErrorCategory.RATE_LIMIT: (
|
||||
"⏳ Слишком много запросов.\n"
|
||||
"Пожалуйста, подождите немного."
|
||||
),
|
||||
ErrorCategory.PERMISSION: (
|
||||
"🔒 Недостаточно прав для выполнения действия."
|
||||
),
|
||||
ErrorCategory.VALIDATION: (
|
||||
"❌ Некорректные данные.\n"
|
||||
"Проверьте правильность ввода."
|
||||
),
|
||||
ErrorCategory.DATABASE: (
|
||||
"💾 Ошибка базы данных.\n"
|
||||
"Попробуйте позже."
|
||||
),
|
||||
ErrorCategory.HANDLER: (
|
||||
"⚠️ Произошла непредвиденная ошибка.\n"
|
||||
"Разработчики уже уведомлены."
|
||||
),
|
||||
ErrorCategory.UNKNOWN: (
|
||||
"⚠️ Произошла ошибка.\n"
|
||||
"Попробуйте повторить позже."
|
||||
)
|
||||
}
|
||||
|
||||
error_text = error_messages.get(
|
||||
category,
|
||||
error_messages[ErrorCategory.UNKNOWN]
|
||||
)
|
||||
|
||||
error_text += "\n\nПопробуйте нажать /start или обратитесь к администратору."
|
||||
|
||||
try:
|
||||
# Отправляем сообщение
|
||||
if isinstance(event, Message):
|
||||
await msg(event, text=error_text)
|
||||
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await safe_answer_callback(event, error_text[:200], show_alert=True)
|
||||
|
||||
# Также отправляем в чат если сообщение доступно
|
||||
if event.message:
|
||||
try:
|
||||
await msg(event.message, text=error_text)
|
||||
except:
|
||||
pass
|
||||
|
||||
elif isinstance(event, Update):
|
||||
if event.message:
|
||||
await msg(event.message, text=error_text)
|
||||
elif event.callback_query:
|
||||
await safe_answer_callback(
|
||||
event.callback_query,
|
||||
error_text[:200],
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Пользователь уведомлен об ошибке",
|
||||
log_type="USER_ERROR_NOTIFIED"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Не удалось уведомить пользователя об ошибке: {e}",
|
||||
log_type="USER_NOTIFY_ERROR"
|
||||
)
|
||||
|
||||
def _should_notify(self, error_key: str) -> bool:
|
||||
"""
|
||||
Проверяет, нужно ли отправлять уведомление (rate limiting).
|
||||
|
||||
Args:
|
||||
error_key: Ключ ошибки
|
||||
|
||||
Returns:
|
||||
True если можно отправить уведомление
|
||||
"""
|
||||
if error_key not in self._last_notifications:
|
||||
return True
|
||||
|
||||
last_time = self._last_notifications[error_key]
|
||||
time_passed = (datetime.now() - last_time).total_seconds()
|
||||
|
||||
return time_passed >= self.notify_rate_limit
|
||||
|
||||
def _is_ignored(self, exception: Exception) -> bool:
|
||||
"""
|
||||
Проверяет, игнорируется ли ошибка.
|
||||
|
||||
Args:
|
||||
exception: Исключение
|
||||
|
||||
Returns:
|
||||
True если ошибка игнорируется
|
||||
"""
|
||||
return type(exception) in self.ignored_errors
|
||||
|
||||
@staticmethod
|
||||
def _get_category_emoji(category: ErrorCategory) -> str:
|
||||
"""Возвращает emoji для категории ошибки"""
|
||||
emoji_map = {
|
||||
ErrorCategory.TELEGRAM_API: "🔌",
|
||||
ErrorCategory.RATE_LIMIT: "⏳",
|
||||
ErrorCategory.PERMISSION: "🔒",
|
||||
ErrorCategory.VALIDATION: "❌",
|
||||
ErrorCategory.DATABASE: "💾",
|
||||
ErrorCategory.HANDLER: "🚨",
|
||||
ErrorCategory.UNKNOWN: "⚠️"
|
||||
}
|
||||
|
||||
return emoji_map.get(category, "⚠️")
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Возвращает статистику ошибок"""
|
||||
return self.stats.get_summary()
|
||||
|
||||
def reset_stats(self):
|
||||
"""Сбрасывает статистику"""
|
||||
self.stats = ErrorStats()
|
||||
|
||||
def add_ignored_error(self, error_type: type):
|
||||
"""Добавляет тип ошибки в игнорируемые"""
|
||||
self.ignored_errors.add(error_type)
|
||||
|
||||
def remove_ignored_error(self, error_type: type):
|
||||
"""Удаляет тип ошибки из игнорируемых"""
|
||||
self.ignored_errors.discard(error_type)
|
||||
|
||||
|
||||
# ================= УТИЛИТЫ =================
|
||||
|
||||
def format_error_stats(stats: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Форматирует статистику ошибок.
|
||||
|
||||
Args:
|
||||
stats: Словарь со статистикой
|
||||
|
||||
Returns:
|
||||
Отформатированная строка
|
||||
|
||||
Example:
|
||||
>> stats = middleware.get_stats()
|
||||
>> print(format_error_stats(stats))
|
||||
"""
|
||||
text = (
|
||||
f"🚨 <b>Статистика ошибок</b>\n\n"
|
||||
f"📊 <b>Общая информация:</b>\n"
|
||||
f"├─ Всего ошибок: {stats['total_errors']}\n"
|
||||
f"└─ Время работы: {stats['uptime']}\n\n"
|
||||
)
|
||||
|
||||
# По категориям
|
||||
if stats['by_category']:
|
||||
text += f"📁 <b>По категориям:</b>\n"
|
||||
for category, count in stats['by_category'].items():
|
||||
text += f"├─ {category}: {count}\n"
|
||||
text += "\n"
|
||||
|
||||
# По типам исключений
|
||||
if stats['by_exception']:
|
||||
text += f"🔧 <b>По типам (топ-5):</b>\n"
|
||||
sorted_exceptions = sorted(
|
||||
stats['by_exception'].items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)[:5]
|
||||
|
||||
for exc_type, count in sorted_exceptions:
|
||||
text += f"├─ {exc_type}: {count}\n"
|
||||
|
||||
return text
|
||||
350
bot/middlewares/logging_mdw.py
Normal file
350
bot/middlewares/logging_mdw.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Middleware для логирования всех событий бота
|
||||
"""
|
||||
from typing import Callable, Awaitable, Any, Dict, Optional, Tuple
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import (
|
||||
TelegramObject,
|
||||
Update,
|
||||
Message,
|
||||
CallbackQuery,
|
||||
InlineQuery,
|
||||
ChatMemberUpdated
|
||||
)
|
||||
|
||||
from middleware.loggers import logger
|
||||
from ..utils import (
|
||||
username,
|
||||
get_content_type,
|
||||
is_command,
|
||||
parse_command,
|
||||
is_group_chat
|
||||
)
|
||||
|
||||
__all__ = ('LoggingMiddleware',)
|
||||
|
||||
|
||||
class LoggingMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для детального логирования всех событий бота.
|
||||
|
||||
Типы логов:
|
||||
- CMD: Команды бота
|
||||
- MSG: Текстовые сообщения
|
||||
- MEDIA: Медиа сообщения
|
||||
- CBD: Callback queries
|
||||
- INLINE: Inline queries
|
||||
- MEMBER: Изменения участников чата
|
||||
"""
|
||||
|
||||
def __init__(self, project_prefix: str = "PRIMO"):
|
||||
super().__init__()
|
||||
self.project_prefix = project_prefix
|
||||
|
||||
# Статистика
|
||||
self.stats = {
|
||||
'total': 0,
|
||||
'commands': 0,
|
||||
'messages': 0,
|
||||
'callbacks': 0,
|
||||
'errors': 0
|
||||
}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""Обрабатывает входящее событие"""
|
||||
self.stats['total'] += 1
|
||||
start_time = datetime.now()
|
||||
|
||||
# Анализируем событие
|
||||
log_info = self._analyze_event(event)
|
||||
|
||||
if not log_info:
|
||||
return await handler(event, data)
|
||||
|
||||
log_type, log_text, user_str = log_info
|
||||
|
||||
# Добавляем префикс проекта
|
||||
prefixed_log_type = f"{self.project_prefix}-{log_type}"
|
||||
|
||||
# Логируем получение события
|
||||
logger.info(text=log_text, log_type=prefixed_log_type, user=user_str)
|
||||
|
||||
try:
|
||||
# Выполняем обработчик
|
||||
result = await handler(event, data)
|
||||
|
||||
# Вычисляем время обработки
|
||||
processing_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
# Логируем успешное выполнение для команд
|
||||
if log_type == "CMD":
|
||||
self.stats['commands'] += 1
|
||||
logger.debug(
|
||||
text=f"✅ Команда обработана за {processing_time:.3f}s",
|
||||
log_type=prefixed_log_type,
|
||||
user=user_str
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.stats['errors'] += 1
|
||||
logger.error(
|
||||
text=f"❌ Ошибка обработки: {str(e)}",
|
||||
log_type=prefixed_log_type,
|
||||
user=user_str,
|
||||
)
|
||||
raise
|
||||
|
||||
def _analyze_event(self, event: TelegramObject) -> Optional[Tuple[str, str, str]]:
|
||||
"""
|
||||
Анализирует событие и извлекает информацию для логирования.
|
||||
|
||||
Returns:
|
||||
Tuple: (тип_лога, текст_лога, пользователь) или None
|
||||
"""
|
||||
if isinstance(event, Update):
|
||||
return self._analyze_update(event)
|
||||
elif isinstance(event, Message):
|
||||
return self._analyze_message(event)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
return self._analyze_callback(event)
|
||||
elif isinstance(event, InlineQuery):
|
||||
return self._analyze_inline_query(event)
|
||||
elif isinstance(event, ChatMemberUpdated):
|
||||
return self._analyze_member_update(event)
|
||||
|
||||
return None
|
||||
|
||||
def _analyze_update(self, update: Update) -> Optional[Tuple[str, str, str]]:
|
||||
"""Анализирует Update объект"""
|
||||
if update.message:
|
||||
return self._analyze_message(update.message)
|
||||
elif update.edited_message:
|
||||
result = self._analyze_message(update.edited_message)
|
||||
if result:
|
||||
log_type, log_text, user_str = result
|
||||
log_text = f"✏️ [РЕДАКТИРОВАНО] {log_text}"
|
||||
return log_type, log_text, user_str
|
||||
elif update.channel_post:
|
||||
return self._analyze_message(update.channel_post, is_channel=True)
|
||||
elif update.edited_channel_post:
|
||||
result = self._analyze_message(update.edited_channel_post, is_channel=True)
|
||||
if result:
|
||||
log_type, log_text, user_str = result
|
||||
log_text = f"✏️ [РЕДАКТИРОВАНО] {log_text}"
|
||||
return log_type, log_text, user_str
|
||||
elif update.callback_query:
|
||||
return self._analyze_callback(update.callback_query)
|
||||
elif update.inline_query:
|
||||
return self._analyze_inline_query(update.inline_query)
|
||||
elif update.my_chat_member:
|
||||
return self._analyze_member_update(update.my_chat_member)
|
||||
elif update.chat_member:
|
||||
return self._analyze_member_update(update.chat_member)
|
||||
|
||||
return None
|
||||
|
||||
def _analyze_message(self, message: Message, is_channel: bool = False) -> Tuple[str, str, str]:
|
||||
"""Анализирует сообщение"""
|
||||
user_str = username(message)
|
||||
|
||||
# Формируем префикс с информацией о чате
|
||||
chat_info = ""
|
||||
if is_group_chat(message):
|
||||
chat_info = f"[{message.chat.type.upper()} {message.chat.id}] "
|
||||
elif is_channel:
|
||||
chat_info = f"[CHANNEL {message.chat.id}] "
|
||||
else:
|
||||
chat_info = f"[PM {message.chat.id}] "
|
||||
|
||||
# Проверяем команду
|
||||
if message.text and is_command(message.text):
|
||||
self.stats['messages'] += 1
|
||||
parsed = parse_command(message.text)
|
||||
|
||||
if parsed:
|
||||
log_text = f"{chat_info}📝 Команда: /{parsed.command}"
|
||||
|
||||
if parsed.args:
|
||||
args_str = ' '.join(parsed.args[:3])
|
||||
if len(parsed.args) > 3:
|
||||
args_str += f" ... (+{len(parsed.args) - 3})"
|
||||
log_text += f" | Аргументы: {args_str}"
|
||||
|
||||
if parsed.flags:
|
||||
flags_str = ', '.join(f"--{k}" for k in list(parsed.flags.keys())[:3])
|
||||
if len(parsed.flags) > 3:
|
||||
flags_str += f" ... (+{len(parsed.flags) - 3})"
|
||||
log_text += f" | Флаги: {flags_str}"
|
||||
|
||||
return "CMD", log_text, user_str
|
||||
|
||||
# Обычное сообщение
|
||||
self.stats['messages'] += 1
|
||||
|
||||
content_type = get_content_type(message, russian=True)
|
||||
content_emoji = self._get_content_emoji(message)
|
||||
|
||||
# Текстовое сообщение
|
||||
if message.text:
|
||||
text_preview = message.text
|
||||
if len(text_preview) > 100:
|
||||
text_preview = text_preview[:100] + "..."
|
||||
|
||||
log_text = f"{chat_info}{content_emoji} Сообщение ({len(message.text)} симв.): {text_preview!r}"
|
||||
|
||||
# Медиа с caption
|
||||
elif message.caption:
|
||||
caption_preview = message.caption
|
||||
if len(caption_preview) > 50:
|
||||
caption_preview = caption_preview[:50] + "..."
|
||||
|
||||
log_text = f"{chat_info}{content_emoji} {content_type}"
|
||||
|
||||
# Добавляем детали медиа
|
||||
media_details = self._get_media_details_str(message)
|
||||
if media_details:
|
||||
log_text += f" {media_details}"
|
||||
|
||||
log_text += f" | Описание: {caption_preview!r}"
|
||||
|
||||
# Медиа без caption
|
||||
else:
|
||||
log_text = f"{chat_info}{content_emoji} {content_type}"
|
||||
|
||||
media_details = self._get_media_details_str(message)
|
||||
if media_details:
|
||||
log_text += f" {media_details}"
|
||||
|
||||
# Определяем тип лога
|
||||
log_type = "MEDIA" if message.content_type != "text" else "MSG"
|
||||
|
||||
# Добавляем префикс канала
|
||||
if is_channel:
|
||||
log_text = f"📢 {log_text}"
|
||||
|
||||
return log_type, log_text, user_str
|
||||
|
||||
def _analyze_callback(self, callback: CallbackQuery) -> Tuple[str, str, str]:
|
||||
"""Анализирует callback query"""
|
||||
self.stats['callbacks'] += 1
|
||||
|
||||
user_str = f"@{callback.from_user.username}" if callback.from_user.username else f"id{callback.from_user.id}"
|
||||
|
||||
callback_data = callback.data or "None"
|
||||
if len(callback_data) > 50:
|
||||
callback_data = callback_data[:50] + "..."
|
||||
|
||||
chat_info = f"[MSG {callback.message.message_id}] " if callback.message else ""
|
||||
log_text = f"{chat_info}🔘 Callback: {callback_data!r}"
|
||||
|
||||
return "CBD", log_text, user_str
|
||||
|
||||
@staticmethod
|
||||
def _analyze_inline_query(inline_query: InlineQuery) -> Tuple[str, str, str]:
|
||||
"""Анализирует inline query"""
|
||||
user_str = f"@{inline_query.from_user.username}" if inline_query.from_user.username else f"id{inline_query.from_user.id}"
|
||||
|
||||
query = inline_query.query or ""
|
||||
if len(query) > 50:
|
||||
query = query[:50] + "..."
|
||||
|
||||
log_text = f"🔍 Inline запрос: {query!r}"
|
||||
|
||||
return "INLINE", log_text, user_str
|
||||
|
||||
@staticmethod
|
||||
def _analyze_member_update(update: ChatMemberUpdated) -> Tuple[str, str, str]:
|
||||
"""Анализирует изменения участников"""
|
||||
user_str = f"@{update.from_user.username}" if update.from_user.username else f"id{update.from_user.id}"
|
||||
|
||||
old_status = update.old_chat_member.status
|
||||
new_status = update.new_chat_member.status
|
||||
|
||||
chat_info = f"[{update.chat.type.upper()} {update.chat.id}] "
|
||||
log_text = f"{chat_info}👥 Изменение статуса: {old_status} → {new_status}"
|
||||
|
||||
return "MEMBER", log_text, user_str
|
||||
|
||||
@staticmethod
|
||||
def _get_content_emoji(message: Message) -> str:
|
||||
"""Возвращает emoji для типа контента"""
|
||||
emoji_map = {
|
||||
'text': '💬',
|
||||
'photo': '📷',
|
||||
'video': '🎥',
|
||||
'animation': '🎞️',
|
||||
'audio': '🎵',
|
||||
'voice': '🎤',
|
||||
'video_note': '🎬',
|
||||
'document': '📄',
|
||||
'sticker': '🎨',
|
||||
'location': '📍',
|
||||
'contact': '👤',
|
||||
'poll': '📊',
|
||||
'dice': '🎲'
|
||||
}
|
||||
|
||||
return emoji_map.get(message.content_type, '📎')
|
||||
|
||||
@staticmethod
|
||||
def _get_media_details_str(message: Message) -> Optional[str]:
|
||||
"""Возвращает строку с деталями медиа файла"""
|
||||
from ..utils import get_media_info
|
||||
|
||||
try:
|
||||
media_info = get_media_info(message)
|
||||
details = []
|
||||
|
||||
# Размер файла
|
||||
if 'file_size_mb' in media_info:
|
||||
details.append(f"{media_info['file_size_mb']} MB")
|
||||
elif 'file_size_kb' in media_info:
|
||||
details.append(f"{media_info['file_size_kb']} KB")
|
||||
|
||||
# Длительность
|
||||
if 'duration_formatted' in media_info:
|
||||
details.append(media_info['duration_formatted'])
|
||||
|
||||
# Разрешение
|
||||
if 'width' in media_info and 'height' in media_info:
|
||||
details.append(f"{media_info['width']}x{media_info['height']}")
|
||||
|
||||
return f"({', '.join(details)})" if details else None
|
||||
|
||||
except:
|
||||
return None
|
||||
|
||||
def get_stats(self) -> Dict[str, int]:
|
||||
"""Возвращает статистику middleware"""
|
||||
return self.stats.copy()
|
||||
|
||||
def reset_stats(self):
|
||||
"""Сбрасывает статистику"""
|
||||
self.stats = {
|
||||
'total': 0,
|
||||
'commands': 0,
|
||||
'messages': 0,
|
||||
'callbacks': 0,
|
||||
'errors': 0
|
||||
}
|
||||
|
||||
|
||||
def format_log_stats(stats: Dict[str, int]) -> str:
|
||||
"""Форматирует статистику для вывода"""
|
||||
return (
|
||||
f"📊 Статистика логирования:\n"
|
||||
f"├─ 📨 Всего событий: {stats['total']}\n"
|
||||
f"├─ 📝 Команд: {stats['commands']}\n"
|
||||
f"├─ 💬 Сообщений: {stats['messages']}\n"
|
||||
f"├─ 🔘 Callbacks: {stats['callbacks']}\n"
|
||||
f"└─ ❌ Ошибок: {stats['errors']}"
|
||||
)
|
||||
544
bot/middlewares/referal_mdw.py
Normal file
544
bot/middlewares/referal_mdw.py
Normal file
@@ -0,0 +1,544 @@
|
||||
"""
|
||||
Middleware для обработки реферальных ссылок и deep links
|
||||
"""
|
||||
from typing import Callable, Awaitable, Any, Dict, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
import re
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.filters.command import CommandObject
|
||||
from aiogram.types import TelegramObject, Message, User
|
||||
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = (
|
||||
'ReferralMiddleware',
|
||||
'DeepLinkData',
|
||||
'referral_stats',
|
||||
'ReferralType'
|
||||
)
|
||||
|
||||
|
||||
class ReferralType:
|
||||
"""Типы реферальных ссылок"""
|
||||
REFERRAL = 'ref' # Обычная реферальная ссылка
|
||||
PROMO = 'promo' # Промокод
|
||||
UTM = 'utm' # UTM метки
|
||||
INVITE = 'invite' # Инвайт-ссылка
|
||||
DEEPLINK = 'deeplink' # Произвольный deep link
|
||||
CUSTOM = 'custom' # Кастомный тип
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeepLinkData:
|
||||
"""
|
||||
Данные deep link.
|
||||
|
||||
Attributes:
|
||||
raw: Исходная строка (все после /start)
|
||||
type: Тип ссылки (ref, promo, utm, и т.д.)
|
||||
params: Распарсенные параметры
|
||||
user_id: ID пользователя, перешедшего по ссылке
|
||||
username: Username пользователя
|
||||
timestamp: Время перехода
|
||||
is_valid: Валидна ли ссылка
|
||||
"""
|
||||
raw: str
|
||||
type: str = ReferralType.DEEPLINK
|
||||
params: Dict[str, Any] = field(default_factory=dict)
|
||||
user_id: Optional[int] = None
|
||||
username: Optional[str] = None
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
is_valid: bool = True
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Получает параметр по ключу"""
|
||||
return self.params.get(key, default)
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Позволяет использовать data['key']"""
|
||||
return self.params[key]
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
"""Позволяет использовать 'key' in data"""
|
||||
return key in self.params
|
||||
|
||||
|
||||
class ReferralStatistics:
|
||||
"""
|
||||
Статистика реферальных переходов.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Счетчики переходов по типам: {type: count}
|
||||
self.clicks_by_type: Dict[str, int] = defaultdict(int)
|
||||
|
||||
# Переходы по кодам: {ref_code: count}
|
||||
self.clicks_by_code: Dict[str, int] = defaultdict(int)
|
||||
|
||||
# История переходов: [(timestamp, user_id, ref_code, type), ...]
|
||||
self.history: list[tuple[datetime, int, str, str]] = []
|
||||
|
||||
# Уникальные пользователи: {ref_code: set(user_ids)}
|
||||
self.unique_users: Dict[str, set[int]] = defaultdict(set)
|
||||
|
||||
def record(self, deep_link: DeepLinkData) -> None:
|
||||
"""Записывает переход"""
|
||||
# Счетчик по типу
|
||||
self.clicks_by_type[deep_link.type] += 1
|
||||
|
||||
# Счетчик по коду (если есть реферальный код)
|
||||
ref_code = deep_link.get('ref_code') or deep_link.get('code') or deep_link.raw
|
||||
if ref_code:
|
||||
self.clicks_by_code[ref_code] += 1
|
||||
|
||||
# Уникальные пользователи
|
||||
if deep_link.user_id:
|
||||
self.unique_users[ref_code].add(deep_link.user_id)
|
||||
|
||||
# История
|
||||
if deep_link.user_id:
|
||||
self.history.append((
|
||||
deep_link.timestamp,
|
||||
deep_link.user_id,
|
||||
ref_code,
|
||||
deep_link.type
|
||||
))
|
||||
|
||||
def get_stats(self, ref_code: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Возвращает статистику.
|
||||
|
||||
Args:
|
||||
ref_code: Код для фильтрации (если None, возвращает общую статистику)
|
||||
"""
|
||||
if ref_code:
|
||||
return {
|
||||
'ref_code': ref_code,
|
||||
'total_clicks': self.clicks_by_code.get(ref_code, 0),
|
||||
'unique_users': len(self.unique_users.get(ref_code, set()))
|
||||
}
|
||||
|
||||
return {
|
||||
'total_clicks': sum(self.clicks_by_type.values()),
|
||||
'clicks_by_type': dict(self.clicks_by_type),
|
||||
'top_codes': self.get_top_codes(10),
|
||||
'total_unique_users': sum(len(users) for users in self.unique_users.values())
|
||||
}
|
||||
|
||||
def get_top_codes(self, limit: int = 10) -> list[tuple[str, int]]:
|
||||
"""Возвращает топ реферальных кодов"""
|
||||
sorted_codes = sorted(
|
||||
self.clicks_by_code.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)
|
||||
return sorted_codes[:limit]
|
||||
|
||||
|
||||
# Глобальная статистика
|
||||
referral_stats = ReferralStatistics()
|
||||
|
||||
|
||||
class ReferralMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для обработки реферальных ссылок и deep links.
|
||||
|
||||
Возможности:
|
||||
- Парсинг различных форматов deep links
|
||||
- Автоматическое определение типа ссылки
|
||||
- Валидация параметров
|
||||
- Сбор статистики
|
||||
- Интеграция с базой данных через callback
|
||||
- Поддержка сложных параметров (ref_123_promo_abc)
|
||||
|
||||
Поддерживаемые форматы:
|
||||
- /start ref123 → {'ref_code': 'ref123'}
|
||||
- /start promo_SUMMER2024 → {'type': 'promo', 'code': 'SUMMER2024'}
|
||||
- /start ref_123_bonus_50 → {'ref_code': '123', 'bonus': '50'}
|
||||
- /start utm_source_telegram → {'utm_source': 'telegram'}
|
||||
|
||||
Attributes:
|
||||
on_referral: Callback функция для сохранения в БД
|
||||
validator: Функция валидации кодов
|
||||
parse_complex: Парсить ли сложные параметры
|
||||
collect_stats: Собирать ли статистику
|
||||
|
||||
Example:
|
||||
```python
|
||||
from middleware.referral import ReferralMiddleware, DeepLinkData
|
||||
|
||||
async def save_referral(deep_link: DeepLinkData):
|
||||
# Сохранение в БД
|
||||
await db.save_referral(
|
||||
user_id=deep_link.user_id,
|
||||
ref_code=deep_link.get('ref_code'),
|
||||
timestamp=deep_link.timestamp
|
||||
)
|
||||
|
||||
# Регистрация middleware
|
||||
referral_mdw = ReferralMiddleware(
|
||||
on_referral=save_referral,
|
||||
parse_complex=True,
|
||||
collect_stats=True
|
||||
)
|
||||
|
||||
dp.message.middleware(referral_mdw)
|
||||
|
||||
# В хендлере
|
||||
@router.message(CommandStart())
|
||||
async def start(message: Message, deep_link: Optional[DeepLinkData] = None):
|
||||
if deep_link:
|
||||
ref_code = deep_link.get('ref_code')
|
||||
await message.answer(f"Привет! Вы пришли по ссылке: {ref_code}")
|
||||
else:
|
||||
await message.answer("Привет!")
|
||||
```
|
||||
"""
|
||||
|
||||
# Паттерны для парсинга
|
||||
PATTERNS = {
|
||||
# ref_123 или ref123
|
||||
ReferralType.REFERRAL: re.compile(r'^ref[_-]?(\w+)$', re.IGNORECASE),
|
||||
|
||||
# promo_SUMMER2024
|
||||
ReferralType.PROMO: re.compile(r'^promo[_-]?(\w+)$', re.IGNORECASE),
|
||||
|
||||
# invite_abc123
|
||||
ReferralType.INVITE: re.compile(r'^invite[_-]?(\w+)$', re.IGNORECASE),
|
||||
|
||||
# utm_source_telegram_campaign_ads
|
||||
ReferralType.UTM: re.compile(r'^utm[_-]', re.IGNORECASE),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_referral: Optional[Callable[[DeepLinkData], Awaitable[None]]] = None,
|
||||
validator: Optional[Callable[[str], bool]] = None,
|
||||
parse_complex: bool = True,
|
||||
collect_stats: bool = True,
|
||||
max_length: int = 64
|
||||
):
|
||||
"""
|
||||
Инициализация middleware.
|
||||
|
||||
Args:
|
||||
on_referral: Callback для обработки реферала (сохранение в БД)
|
||||
validator: Функция валидации кода (должна вернуть True если валиден)
|
||||
parse_complex: Парсить ли сложные параметры (ref_123_bonus_50)
|
||||
collect_stats: Собирать ли статистику
|
||||
max_length: Максимальная длина deep link
|
||||
"""
|
||||
super().__init__()
|
||||
self.on_referral = on_referral
|
||||
self.validator = validator
|
||||
self.parse_complex = parse_complex
|
||||
self.collect_stats = collect_stats
|
||||
self.max_length = max_length
|
||||
|
||||
def _parse_simple(self, args: str) -> tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Парсит простые форматы deep links.
|
||||
|
||||
Args:
|
||||
args: Аргументы команды /start
|
||||
|
||||
Returns:
|
||||
tuple: (тип, параметры)
|
||||
"""
|
||||
# Проверка по паттернам
|
||||
for link_type, pattern in self.PATTERNS.items():
|
||||
match = pattern.match(args)
|
||||
if match:
|
||||
if link_type == ReferralType.REFERRAL:
|
||||
return link_type, {'ref_code': match.group(1)}
|
||||
elif link_type == ReferralType.PROMO:
|
||||
return link_type, {'code': match.group(1), 'promo_code': match.group(1)}
|
||||
elif link_type == ReferralType.INVITE:
|
||||
return link_type, {'invite_code': match.group(1)}
|
||||
elif link_type == ReferralType.UTM:
|
||||
# Парсим UTM параметры
|
||||
return link_type, self._parse_utm(args)
|
||||
|
||||
# Если не совпало ни с одним паттерном - просто код
|
||||
return ReferralType.DEEPLINK, {'code': args}
|
||||
|
||||
def _parse_utm(self, args: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Парсит UTM параметры: utm_source_telegram_campaign_ads
|
||||
|
||||
Args:
|
||||
args: Строка с UTM параметрами
|
||||
|
||||
Returns:
|
||||
Dict с UTM параметрами
|
||||
"""
|
||||
params = {}
|
||||
|
||||
# Удаляем префикс utm_
|
||||
if args.lower().startswith('utm_'):
|
||||
args = args[4:]
|
||||
|
||||
# Разбиваем по _ и парсим пары ключ-значение
|
||||
parts = args.split('_')
|
||||
|
||||
i = 0
|
||||
while i < len(parts) - 1:
|
||||
key = f"utm_{parts[i]}"
|
||||
value = parts[i + 1]
|
||||
params[key] = value
|
||||
i += 2
|
||||
|
||||
return params
|
||||
|
||||
def _parse_complex(self, args: str) -> tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Парсит сложные форматы: ref_123_bonus_50_promo_SUMMER
|
||||
|
||||
Args:
|
||||
args: Аргументы команды
|
||||
|
||||
Returns:
|
||||
tuple: (тип, параметры)
|
||||
"""
|
||||
params = {}
|
||||
parts = args.split('_')
|
||||
|
||||
# Определяем тип по первому элементу
|
||||
link_type = ReferralType.DEEPLINK
|
||||
|
||||
if parts[0].lower() in ['ref', 'referral']:
|
||||
link_type = ReferralType.REFERRAL
|
||||
if len(parts) > 1:
|
||||
params['ref_code'] = parts[1]
|
||||
parts = parts[2:] # Пропускаем первые 2 элемента
|
||||
elif parts[0].lower() == 'promo':
|
||||
link_type = ReferralType.PROMO
|
||||
if len(parts) > 1:
|
||||
params['promo_code'] = parts[1]
|
||||
parts = parts[2:]
|
||||
elif parts[0].lower() == 'invite':
|
||||
link_type = ReferralType.INVITE
|
||||
if len(parts) > 1:
|
||||
params['invite_code'] = parts[1]
|
||||
parts = parts[2:]
|
||||
|
||||
# Парсим остальные параметры как пары ключ-значение
|
||||
i = 0
|
||||
while i < len(parts) - 1:
|
||||
key = parts[i]
|
||||
value = parts[i + 1]
|
||||
|
||||
# Пытаемся преобразовать в число
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
pass # Оставляем строкой
|
||||
|
||||
params[key] = value
|
||||
i += 2
|
||||
|
||||
return link_type, params
|
||||
|
||||
def _validate_deep_link(self, args: str) -> bool:
|
||||
"""
|
||||
Валидирует deep link.
|
||||
|
||||
Args:
|
||||
args: Строка для валидации
|
||||
|
||||
Returns:
|
||||
bool: True если валиден
|
||||
"""
|
||||
# Проверка длины
|
||||
if len(args) > self.max_length:
|
||||
logger.warning(
|
||||
f"Deep link слишком длинный: {len(args)} > {self.max_length}",
|
||||
log_type='REFERRAL'
|
||||
)
|
||||
return False
|
||||
|
||||
# Проверка на запрещенные символы (только буквы, цифры, _ и -)
|
||||
if not re.match(r'^[a-zA-Z0-9_-]+$', args):
|
||||
logger.warning(
|
||||
f"Deep link содержит недопустимые символы: {args}",
|
||||
log_type='REFERRAL'
|
||||
)
|
||||
return False
|
||||
|
||||
# Кастомная валидация
|
||||
if self.validator:
|
||||
return self.validator(args)
|
||||
|
||||
return True
|
||||
|
||||
def _parse_deep_link(self, args: str, user: User) -> DeepLinkData:
|
||||
"""
|
||||
Парсит deep link и создает объект DeepLinkData.
|
||||
|
||||
Args:
|
||||
args: Аргументы команды /start
|
||||
user: Пользователь, перешедший по ссылке
|
||||
|
||||
Returns:
|
||||
DeepLinkData: Распарсенные данные
|
||||
"""
|
||||
# Валидация
|
||||
is_valid = self._validate_deep_link(args)
|
||||
|
||||
# Парсинг
|
||||
if self.parse_complex and '_' in args:
|
||||
link_type, params = self._parse_complex(args)
|
||||
else:
|
||||
link_type, params = self._parse_simple(args)
|
||||
|
||||
# Создаем объект
|
||||
deep_link = DeepLinkData(
|
||||
raw=args,
|
||||
type=link_type,
|
||||
params=params,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
is_valid=is_valid
|
||||
)
|
||||
|
||||
return deep_link
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""
|
||||
Перехватывает команды /start с аргументами.
|
||||
|
||||
Args:
|
||||
handler: Функция хендлера
|
||||
event: Объект события
|
||||
data: Дополнительные данные
|
||||
|
||||
Returns:
|
||||
Результат хендлера
|
||||
"""
|
||||
# Обрабатываем только сообщения
|
||||
if not isinstance(event, Message):
|
||||
return await handler(event, data)
|
||||
|
||||
# Извлекаем команду
|
||||
command: Optional[CommandObject] = data.get('command')
|
||||
|
||||
# Проверяем, что это /start с аргументами
|
||||
if not command or command.command.lower() != 'start' or not command.args:
|
||||
return await handler(event, data)
|
||||
|
||||
user = event.from_user
|
||||
args = command.args
|
||||
|
||||
# Парсим deep link
|
||||
deep_link = self._parse_deep_link(args, user)
|
||||
|
||||
# Логирование
|
||||
if deep_link.is_valid:
|
||||
logger.info(
|
||||
f"Deep link: type={deep_link.type}, params={deep_link.params}",
|
||||
log_type='REFERRAL',
|
||||
user=f"@{user.username}" if user.username else f"id{user.id}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Невалидный deep link: {args}",
|
||||
log_type='REFERRAL',
|
||||
user=f"@{user.username}" if user.username else f"id{user.id}"
|
||||
)
|
||||
|
||||
# Собираем статистику
|
||||
if self.collect_stats and deep_link.is_valid:
|
||||
referral_stats.record(deep_link)
|
||||
|
||||
# Вызываем callback для сохранения в БД
|
||||
if self.on_referral and deep_link.is_valid:
|
||||
try:
|
||||
await self.on_referral(deep_link)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка в on_referral callback: {e}",
|
||||
log_type='REFERRAL'
|
||||
)
|
||||
|
||||
# Добавляем deep_link в data для хендлера
|
||||
data['deep_link'] = deep_link
|
||||
data['ref_code'] = deep_link.get('ref_code') # Для обратной совместимости
|
||||
|
||||
# Выполняем хендлер
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||
|
||||
def create_deep_link(bot_username: str, **params) -> str:
|
||||
"""
|
||||
Создает deep link для бота.
|
||||
|
||||
Args:
|
||||
bot_username: Username бота (без @)
|
||||
**params: Параметры для ссылки
|
||||
|
||||
Returns:
|
||||
str: Готовая ссылка
|
||||
|
||||
Example:
|
||||
>>> create_deep_link('mybot', ref_code='123', bonus='50')
|
||||
'https://t.me/mybot?start=ref_123_bonus_50'
|
||||
"""
|
||||
# Формируем строку параметров
|
||||
parts = []
|
||||
|
||||
for key, value in params.items():
|
||||
parts.append(str(key))
|
||||
parts.append(str(value))
|
||||
|
||||
param_string = '_'.join(parts)
|
||||
|
||||
return f"https://t.me/{bot_username}?start={param_string}"
|
||||
|
||||
|
||||
def create_referral_link(bot_username: str, ref_code: str) -> str:
|
||||
"""
|
||||
Создает простую реферальную ссылку.
|
||||
|
||||
Args:
|
||||
bot_username: Username бота
|
||||
ref_code: Реферальный код
|
||||
|
||||
Returns:
|
||||
str: Реферальная ссылка
|
||||
|
||||
Example:
|
||||
>>> create_referral_link('mybot', '123')
|
||||
'https://t.me/mybot?start=ref_123'
|
||||
"""
|
||||
return f"https://t.me/{bot_username}?start=ref_{ref_code}"
|
||||
|
||||
|
||||
def create_promo_link(bot_username: str, promo_code: str) -> str:
|
||||
"""
|
||||
Создает ссылку с промокодом.
|
||||
|
||||
Args:
|
||||
bot_username: Username бота
|
||||
promo_code: Промокод
|
||||
|
||||
Returns:
|
||||
str: Ссылка с промокодом
|
||||
|
||||
Example:
|
||||
>>> create_promo_link('mybot', 'SUMMER2024')
|
||||
'https://t.me/mybot?start=promo_SUMMER2024'
|
||||
"""
|
||||
return f"https://t.me/{bot_username}?start=promo_{promo_code}"
|
||||
575
bot/middlewares/spam_mdw.py
Normal file
575
bot/middlewares/spam_mdw.py
Normal file
@@ -0,0 +1,575 @@
|
||||
"""
|
||||
Умный middleware для защиты от спама с адаптивными лимитами
|
||||
"""
|
||||
from time import time
|
||||
from typing import Callable, Awaitable, Any, Dict, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from collections import Counter
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, Message, CallbackQuery
|
||||
|
||||
from middleware.loggers import logger
|
||||
from configs import settings
|
||||
|
||||
__all__ = ('AntiSpamMiddleware', 'spam_stats')
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageContext:
|
||||
"""Контекст сообщения для умной детекции"""
|
||||
text: Optional[str] = None
|
||||
is_forward: bool = False
|
||||
is_reply: bool = False
|
||||
is_command: bool = False
|
||||
media_type: Optional[str] = None
|
||||
callback_data: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserSpamStats:
|
||||
"""
|
||||
Расширенная статистика спама для пользователя.
|
||||
"""
|
||||
user_id: int
|
||||
request_times: list[float] = field(default_factory=list)
|
||||
message_contexts: list[MessageContext] = field(default_factory=list)
|
||||
warnings: int = 0
|
||||
blocked_until: Optional[float] = None
|
||||
total_requests: int = 0
|
||||
total_blocks: int = 0
|
||||
first_seen: Optional[float] = None
|
||||
last_seen: Optional[float] = None
|
||||
reputation: float = 1.0 # Репутация пользователя (0.5 - 2.0)
|
||||
|
||||
def is_blocked(self, current_time: float) -> bool:
|
||||
"""Проверяет, заблокирован ли пользователь"""
|
||||
if self.blocked_until is None:
|
||||
return False
|
||||
|
||||
if current_time < self.blocked_until:
|
||||
return True
|
||||
|
||||
# Разблокировка
|
||||
self.blocked_until = None
|
||||
self.warnings = max(0, self.warnings - 1) # Снижаем предупреждения, но не сбрасываем полностью
|
||||
return False
|
||||
|
||||
def get_remaining_block_time(self, current_time: float) -> float:
|
||||
"""Возвращает оставшееся время блокировки"""
|
||||
if self.blocked_until is None or current_time >= self.blocked_until:
|
||||
return 0.0
|
||||
return self.blocked_until - current_time
|
||||
|
||||
def clean_old_requests(self, current_time: float, time_window: float) -> None:
|
||||
"""Удаляет старые запросы за пределами временного окна"""
|
||||
cutoff_time = current_time - time_window
|
||||
|
||||
# Удаляем старые запросы
|
||||
new_times = []
|
||||
new_contexts = []
|
||||
|
||||
for req_time, context in zip(self.request_times, self.message_contexts):
|
||||
if req_time > cutoff_time:
|
||||
new_times.append(req_time)
|
||||
new_contexts.append(context)
|
||||
|
||||
self.request_times = new_times
|
||||
self.message_contexts = new_contexts
|
||||
|
||||
def add_request(self, current_time: float, context: MessageContext) -> None:
|
||||
"""Добавляет новый запрос с контекстом"""
|
||||
self.request_times.append(current_time)
|
||||
self.message_contexts.append(context)
|
||||
self.total_requests += 1
|
||||
self.last_seen = current_time
|
||||
|
||||
if self.first_seen is None:
|
||||
self.first_seen = current_time
|
||||
|
||||
def add_warning(self) -> None:
|
||||
"""Добавляет предупреждение и снижает репутацию"""
|
||||
self.warnings += 1
|
||||
self.reputation = max(0.5, self.reputation - 0.1)
|
||||
|
||||
def improve_reputation(self) -> None:
|
||||
"""Улучшает репутацию за хорошее поведение"""
|
||||
self.reputation = min(2.0, self.reputation + 0.05)
|
||||
|
||||
def block(self, current_time: float, duration: float) -> None:
|
||||
"""Блокирует пользователя"""
|
||||
self.blocked_until = current_time + duration
|
||||
self.total_blocks += 1
|
||||
self.reputation = max(0.5, self.reputation - 0.3)
|
||||
|
||||
def detect_spam_patterns(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Умная детекция спама на основе паттернов.
|
||||
|
||||
Returns:
|
||||
Dict с результатами анализа
|
||||
"""
|
||||
if len(self.message_contexts) < 3:
|
||||
return {'is_spam': False, 'reason': None, 'severity': 0.0}
|
||||
|
||||
recent_contexts = self.message_contexts[-10:] # Последние 10 сообщений
|
||||
|
||||
# 1. Проверка идентичных текстовых сообщений
|
||||
texts = [ctx.text for ctx in recent_contexts if ctx.text and not ctx.is_command]
|
||||
if texts:
|
||||
text_counts = Counter(texts)
|
||||
most_common_text, count = text_counts.most_common(1)[0]
|
||||
|
||||
if count >= 5: # 5 одинаковых сообщений подряд
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'identical_messages',
|
||||
'severity': 1.0,
|
||||
'details': f"Повторяющееся сообщение: '{most_common_text[:50]}...'"
|
||||
}
|
||||
|
||||
# 2. Проверка спама callback кнопок
|
||||
callbacks = [ctx.callback_data for ctx in recent_contexts if ctx.callback_data]
|
||||
if callbacks:
|
||||
callback_counts = Counter(callbacks)
|
||||
most_common_callback, count = callback_counts.most_common(1)[0]
|
||||
|
||||
if count >= 8: # 8 нажатий одной кнопки
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'callback_spam',
|
||||
'severity': 0.8,
|
||||
'details': f"Спам кнопки: {most_common_callback}"
|
||||
}
|
||||
|
||||
# 3. Проверка флуда медиа
|
||||
media_types = [ctx.media_type for ctx in recent_contexts if ctx.media_type]
|
||||
if len(media_types) >= 7: # 7+ медиафайлов подряд
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'media_flood',
|
||||
'severity': 0.6,
|
||||
'details': f"Флуд медиа: {len(media_types)} файлов"
|
||||
}
|
||||
|
||||
return {'is_spam': False, 'reason': None, 'severity': 0.0}
|
||||
|
||||
|
||||
class SpamStatistics:
|
||||
"""Глобальная статистика по спаму"""
|
||||
|
||||
def __init__(self):
|
||||
self.users: Dict[int, UserSpamStats] = {}
|
||||
self.total_blocked_requests: int = 0
|
||||
self.total_warnings_issued: int = 0
|
||||
|
||||
def get_user(self, user_id: int) -> UserSpamStats:
|
||||
"""Получает или создает статистику пользователя"""
|
||||
if user_id not in self.users:
|
||||
self.users[user_id] = UserSpamStats(user_id=user_id)
|
||||
return self.users[user_id]
|
||||
|
||||
def get_top_spammers(self, limit: int = 10) -> list[tuple[int, int]]:
|
||||
"""Возвращает топ спамеров"""
|
||||
sorted_users = sorted(
|
||||
self.users.items(),
|
||||
key=lambda x: x[1].total_blocks,
|
||||
reverse=True
|
||||
)
|
||||
return [(uid, stats.total_blocks) for uid, stats in sorted_users[:limit]]
|
||||
|
||||
def get_stats_summary(self) -> Dict[str, Any]:
|
||||
"""Возвращает общую статистику"""
|
||||
return {
|
||||
'total_users': len(self.users),
|
||||
'total_blocked_requests': self.total_blocked_requests,
|
||||
'total_warnings': self.total_warnings_issued,
|
||||
'active_blocks': sum(
|
||||
1 for stats in self.users.values()
|
||||
if stats.blocked_until and stats.blocked_until > time()
|
||||
)
|
||||
}
|
||||
|
||||
def cleanup(self, max_age: float = 86400.0) -> int:
|
||||
"""Удаляет старую статистику (24 часа по умолчанию)"""
|
||||
current_time = time()
|
||||
cutoff_time = current_time - max_age
|
||||
|
||||
users_to_delete = [
|
||||
uid for uid, stats in self.users.items()
|
||||
if stats.last_seen and stats.last_seen < cutoff_time
|
||||
and not stats.is_blocked(current_time)
|
||||
]
|
||||
|
||||
for uid in users_to_delete:
|
||||
del self.users[uid]
|
||||
|
||||
return len(users_to_delete)
|
||||
|
||||
|
||||
# Глобальная статистика
|
||||
spam_stats = SpamStatistics()
|
||||
|
||||
|
||||
class AntiSpamMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Умный антиспам с адаптивными лимитами.
|
||||
|
||||
Особенности:
|
||||
- Различает типы активности (текст, форварды, команды, callback)
|
||||
- Адаптивные лимиты в зависимости от типа сообщения
|
||||
- Система репутации пользователей
|
||||
- Умная детекция спам-паттернов
|
||||
- Мягкое отношение к пересылкам и ответам
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# Базовые лимиты
|
||||
rate_limit_text: int = 8, # Текстовых сообщений за окно
|
||||
rate_limit_forward: int = 20, # Пересылок за окно
|
||||
rate_limit_callback: int = 10, # Нажатий кнопок за окно
|
||||
rate_limit_media: int = 10, # Медиа за окно
|
||||
|
||||
time_window: float = 10.0, # Временное окно (секунды)
|
||||
|
||||
# Предупреждения и блокировки
|
||||
warning_limit: int = 3,
|
||||
block_duration: float = 120.0, # 2 минуты базовая блокировка
|
||||
max_block_duration: float = 3600.0, # 1 час максимум
|
||||
|
||||
# Опции
|
||||
whitelist_admins: bool = True,
|
||||
progressive_blocking: bool = True,
|
||||
enable_smart_detection: bool = True,
|
||||
enable_reputation: bool = True,
|
||||
log_all: bool = False
|
||||
):
|
||||
super().__init__()
|
||||
self.rate_limit_text = rate_limit_text
|
||||
self.rate_limit_forward = rate_limit_forward
|
||||
self.rate_limit_callback = rate_limit_callback
|
||||
self.rate_limit_media = rate_limit_media
|
||||
self.time_window = time_window
|
||||
self.warning_limit = warning_limit
|
||||
self.block_duration = block_duration
|
||||
self.max_block_duration = max_block_duration
|
||||
self.whitelist_admins = whitelist_admins
|
||||
self.progressive_blocking = progressive_blocking
|
||||
self.enable_smart_detection = enable_smart_detection
|
||||
self.enable_reputation = enable_reputation
|
||||
self.log_all = log_all
|
||||
|
||||
def _extract_context(self, event: TelegramObject) -> MessageContext:
|
||||
"""Извлекает контекст из события"""
|
||||
context = MessageContext()
|
||||
|
||||
if isinstance(event, Message):
|
||||
context.text = event.text or event.caption
|
||||
context.is_forward = event.forward_date is not None
|
||||
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:
|
||||
context.media_type = 'video'
|
||||
elif event.document:
|
||||
context.media_type = 'document'
|
||||
elif event.audio:
|
||||
context.media_type = 'audio'
|
||||
elif event.voice:
|
||||
context.media_type = 'voice'
|
||||
elif event.sticker:
|
||||
context.media_type = 'sticker'
|
||||
|
||||
elif isinstance(event, CallbackQuery):
|
||||
context.callback_data = event.data
|
||||
|
||||
return context
|
||||
|
||||
def _get_effective_rate_limit(self, user_stats: UserSpamStats, context: MessageContext) -> int:
|
||||
"""Вычисляет эффективный лимит с учётом типа и репутации"""
|
||||
# Базовый лимит по типу
|
||||
if context.is_command:
|
||||
return 999 # Команды не ограничиваем
|
||||
elif context.callback_data:
|
||||
base_limit = self.rate_limit_callback
|
||||
elif context.is_forward:
|
||||
base_limit = self.rate_limit_forward
|
||||
elif context.media_type:
|
||||
base_limit = self.rate_limit_media
|
||||
else:
|
||||
base_limit = self.rate_limit_text
|
||||
|
||||
# Применяем репутацию
|
||||
if self.enable_reputation:
|
||||
base_limit = int(base_limit * user_stats.reputation)
|
||||
|
||||
return max(3, base_limit) # Минимум 3 сообщения
|
||||
|
||||
def _calculate_block_duration(self, warnings: int) -> float:
|
||||
"""Вычисляет длительность блокировки"""
|
||||
if not self.progressive_blocking:
|
||||
return self.block_duration
|
||||
|
||||
multiplier = 2 ** (warnings // self.warning_limit)
|
||||
duration = self.block_duration * multiplier
|
||||
|
||||
return min(duration, self.max_block_duration)
|
||||
|
||||
@staticmethod
|
||||
def _format_duration(seconds: float) -> str:
|
||||
"""Форматирует длительность"""
|
||||
if seconds < 60:
|
||||
return f"{int(seconds)} сек"
|
||||
elif seconds < 3600:
|
||||
return f"{int(seconds / 60)} мин"
|
||||
else:
|
||||
return f"{int(seconds / 3600)} час"
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Optional[Any]:
|
||||
"""Основная логика проверки"""
|
||||
|
||||
# Пропускаем не-сообщения и не-callback
|
||||
if not isinstance(event, (Message, CallbackQuery)):
|
||||
return await handler(event, data)
|
||||
|
||||
user_id = event.from_user.id if event.from_user else None
|
||||
if user_id is None:
|
||||
return await handler(event, data)
|
||||
|
||||
user_str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}"
|
||||
|
||||
# Whitelist для администраторов
|
||||
if self.whitelist_admins and user_id in (settings.OWNER_ID + settings.ADMIN_ID):
|
||||
if self.log_all:
|
||||
logger.debug(f"Администратор {user_str} пропущен", log_type='ANTI_SPAM')
|
||||
return await handler(event, data)
|
||||
|
||||
current_time = time()
|
||||
user_stats = spam_stats.get_user(user_id)
|
||||
|
||||
# Проверка блокировки
|
||||
if user_stats.is_blocked(current_time):
|
||||
remaining = user_stats.get_remaining_block_time(current_time)
|
||||
spam_stats.total_blocked_requests += 1
|
||||
|
||||
logger.warning(
|
||||
f"Запрос от заблокированного пользователя (осталось {self._format_duration(remaining)})",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
block_message = (
|
||||
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
|
||||
f"⏳ Оставшееся время: <b>{self._format_duration(remaining)}</b>\n"
|
||||
f"⚠️ Предупреждений: <b>{user_stats.warnings}</b>"
|
||||
)
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer(block_message, parse_mode="HTML")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
f"🚫 Заблокирован на {self._format_duration(remaining)}",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# Извлекаем контекст сообщения
|
||||
context = self._extract_context(event)
|
||||
|
||||
# Очищаем старые запросы
|
||||
user_stats.clean_old_requests(current_time, self.time_window)
|
||||
|
||||
# Умная детекция спам-паттернов
|
||||
if self.enable_smart_detection:
|
||||
spam_analysis = user_stats.detect_spam_patterns()
|
||||
|
||||
if spam_analysis['is_spam']:
|
||||
user_stats.add_warning()
|
||||
spam_stats.total_warnings_issued += 1
|
||||
|
||||
logger.warning(
|
||||
f"Обнаружен спам-паттерн: {spam_analysis['reason']} - {spam_analysis['details']}",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
# Немедленная блокировка при явном спаме
|
||||
if spam_analysis['severity'] >= 0.9:
|
||||
block_duration = self._calculate_block_duration(user_stats.warnings)
|
||||
user_stats.block(current_time, block_duration)
|
||||
|
||||
logger.error(
|
||||
f"Пользователь заблокирован за спам: {spam_analysis['reason']}",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
block_message = (
|
||||
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
|
||||
f"⏳ Длительность: <b>{self._format_duration(block_duration)}</b>\n"
|
||||
f"⚠️ Причина: {spam_analysis['details']}"
|
||||
)
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer(block_message, parse_mode="HTML")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
f"🚫 Блокировка: {spam_analysis['reason']}",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
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:
|
||||
relevant_requests += 1
|
||||
elif context.callback_data and req_context.callback_data:
|
||||
relevant_requests += 1
|
||||
elif context.media_type and req_context.media_type:
|
||||
relevant_requests += 1
|
||||
elif not (req_context.is_forward or req_context.callback_data or req_context.media_type or req_context.is_command):
|
||||
relevant_requests += 1
|
||||
|
||||
if self.log_all:
|
||||
logger.debug(
|
||||
f"Rate limit: {relevant_requests}/{effective_limit} (тип: {context.media_type or 'text'}, репутация: {user_stats.reputation:.2f})",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
# Проверка лимита
|
||||
if relevant_requests >= effective_limit:
|
||||
user_stats.add_warning()
|
||||
spam_stats.total_warnings_issued += 1
|
||||
|
||||
logger.warning(
|
||||
f"Превышен rate limit ({relevant_requests}/{effective_limit}). "
|
||||
f"Предупреждение {user_stats.warnings}/{self.warning_limit}",
|
||||
log_type='ANTI_SPAM',
|
||||
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)
|
||||
|
||||
logger.error(
|
||||
f"Пользователь заблокирован на {self._format_duration(block_duration)}. "
|
||||
f"Всего блокировок: {user_stats.total_blocks}",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
block_message = (
|
||||
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
|
||||
f"⏳ Длительность: <b>{self._format_duration(block_duration)}</b>\n"
|
||||
f"⚠️ Причина: Превышение лимита запросов\n"
|
||||
f"📊 Это блокировка #{user_stats.total_blocks}"
|
||||
)
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer(block_message, parse_mode="HTML")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
f"🚫 Блокировка на {self._format_duration(block_duration)}",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# Предупреждение
|
||||
warning_message = (
|
||||
f"⚠️ <b>Предупреждение #{user_stats.warnings}</b>\n\n"
|
||||
f"Вы отправляете запросы слишком часто!\n"
|
||||
f"Лимит: {effective_limit} запросов за {self._format_duration(self.time_window)}\n\n"
|
||||
f"При {self.warning_limit} предупреждениях последует блокировка."
|
||||
)
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer(warning_message, parse_mode="HTML")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
f"⚠️ Предупреждение {user_stats.warnings}/{self.warning_limit}",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# Добавляем текущий запрос
|
||||
user_stats.add_request(current_time, context)
|
||||
|
||||
# Улучшаем репутацию за нормальное поведение
|
||||
if self.enable_reputation and user_stats.total_requests % 10 == 0:
|
||||
user_stats.improve_reputation()
|
||||
|
||||
if self.log_all:
|
||||
logger.debug(
|
||||
f"Запрос разрешен. Всего: {user_stats.total_requests}, репутация: {user_stats.reputation:.2f}",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
# ================= УПРАВЛЕНИЕ =================
|
||||
|
||||
async def reset_spam_warnings(user_id: int) -> bool:
|
||||
"""Сбрасывает предупреждения пользователя"""
|
||||
if user_id in spam_stats.users:
|
||||
spam_stats.users[user_id].warnings = 0
|
||||
spam_stats.users[user_id].blocked_until = None
|
||||
logger.info(f"Предупреждения сброшены для id{user_id}", log_type='ANTI_SPAM')
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def unblock_user(user_id: int) -> bool:
|
||||
"""Разблокирует пользователя"""
|
||||
if user_id in spam_stats.users:
|
||||
stats = spam_stats.users[user_id]
|
||||
if stats.blocked_until:
|
||||
stats.blocked_until = None
|
||||
stats.warnings = 0
|
||||
logger.info(f"Пользователь id{user_id} разблокирован вручную", log_type='ANTI_SPAM')
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def get_user_spam_info(user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Получает информацию о спам-статистике пользователя"""
|
||||
if user_id not in spam_stats.users:
|
||||
return None
|
||||
|
||||
stats = spam_stats.users[user_id]
|
||||
current_time = time()
|
||||
|
||||
return {
|
||||
'user_id': user_id,
|
||||
'warnings': stats.warnings,
|
||||
'reputation': stats.reputation,
|
||||
'is_blocked': stats.is_blocked(current_time),
|
||||
'blocked_until': datetime.fromtimestamp(stats.blocked_until) if stats.blocked_until else None,
|
||||
'remaining_block_time': stats.get_remaining_block_time(current_time),
|
||||
'total_requests': stats.total_requests,
|
||||
'total_blocks': stats.total_blocks,
|
||||
'first_seen': datetime.fromtimestamp(stats.first_seen) if stats.first_seen else None,
|
||||
'last_seen': datetime.fromtimestamp(stats.last_seen) if stats.last_seen else None
|
||||
}
|
||||
553
bot/middlewares/sub_mdw.py
Normal file
553
bot/middlewares/sub_mdw.py
Normal file
@@ -0,0 +1,553 @@
|
||||
"""
|
||||
Middleware для проверки подписки пользователей на каналы
|
||||
"""
|
||||
from time import time
|
||||
from typing import Callable, Awaitable, Any, Dict, Optional, Union
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import TelegramObject, Message, CallbackQuery, InlineKeyboardButton, Chat
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
|
||||
from middleware.loggers import logger
|
||||
from configs import settings
|
||||
|
||||
__all__ = ('SubscriptionMiddleware', 'ChannelConfig')
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelConfig:
|
||||
"""
|
||||
Конфигурация канала для проверки подписки.
|
||||
|
||||
Attributes:
|
||||
id: ID или username канала
|
||||
name: Название канала (для отображения)
|
||||
invite_link: Пригласительная ссылка
|
||||
required: Обязательная ли подписка
|
||||
"""
|
||||
id: Union[str, int]
|
||||
name: Optional[str] = None
|
||||
invite_link: Optional[str] = None
|
||||
required: bool = True
|
||||
|
||||
|
||||
class SubscriptionCache:
|
||||
"""
|
||||
Кэш для проверок подписки.
|
||||
|
||||
Уменьшает количество запросов к Telegram API.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl: float = 300.0):
|
||||
"""
|
||||
Args:
|
||||
ttl: Время жизни кэша в секундах (по умолчанию 5 минут)
|
||||
"""
|
||||
self.ttl = ttl
|
||||
# Структура: {(user_id, channel_id): (is_subscribed, timestamp)}
|
||||
self._cache: Dict[tuple[int, Union[str, int]], tuple[bool, float]] = {}
|
||||
|
||||
def get(self, user_id: int, channel_id: Union[str, int]) -> Optional[bool]:
|
||||
"""
|
||||
Получает значение из кэша.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
channel_id: ID канала
|
||||
|
||||
Returns:
|
||||
bool или None: True/False если в кэше и актуально, иначе None
|
||||
"""
|
||||
key = (user_id, channel_id)
|
||||
|
||||
if key in self._cache:
|
||||
is_subscribed, timestamp = self._cache[key]
|
||||
|
||||
# Проверяем актуальность
|
||||
if time() - timestamp < self.ttl:
|
||||
return is_subscribed
|
||||
else:
|
||||
# Удаляем устаревшую запись
|
||||
del self._cache[key]
|
||||
|
||||
return None
|
||||
|
||||
def set(self, user_id: int, channel_id: Union[str, int], is_subscribed: bool) -> None:
|
||||
"""
|
||||
Сохраняет значение в кэш.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
channel_id: ID канала
|
||||
is_subscribed: Статус подписки
|
||||
"""
|
||||
key = (user_id, channel_id)
|
||||
self._cache[key] = (is_subscribed, time())
|
||||
|
||||
def invalidate(self, user_id: Optional[int] = None, channel_id: Optional[Union[str, int]] = None) -> None:
|
||||
"""
|
||||
Инвалидирует кэш.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя (если None, инвалидирует все)
|
||||
channel_id: ID канала (если None, инвалидирует все для пользователя)
|
||||
"""
|
||||
if user_id is None and channel_id is None:
|
||||
# Полная очистка
|
||||
self._cache.clear()
|
||||
elif user_id is not None and channel_id is None:
|
||||
# Удаляем все записи пользователя
|
||||
keys_to_delete = [key for key in self._cache if key[0] == user_id]
|
||||
for key in keys_to_delete:
|
||||
del self._cache[key]
|
||||
elif user_id is not None and channel_id is not None:
|
||||
# Удаляем конкретную запись
|
||||
key = (user_id, channel_id)
|
||||
if key in self._cache:
|
||||
del self._cache[key]
|
||||
|
||||
def cleanup(self) -> int:
|
||||
"""
|
||||
Удаляет устаревшие записи.
|
||||
|
||||
Returns:
|
||||
int: Количество удаленных записей
|
||||
"""
|
||||
current_time = time()
|
||||
keys_to_delete = [
|
||||
key for key, (_, timestamp) in self._cache.items()
|
||||
if current_time - timestamp >= self.ttl
|
||||
]
|
||||
|
||||
for key in keys_to_delete:
|
||||
del self._cache[key]
|
||||
|
||||
return len(keys_to_delete)
|
||||
|
||||
|
||||
class SubscriptionMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для проверки подписки пользователя на каналы.
|
||||
|
||||
Возможности:
|
||||
- Проверка подписки на один или несколько каналов
|
||||
- Кэширование результатов проверки
|
||||
- Whitelist для администраторов
|
||||
- Автоматическое получение ссылок на каналы
|
||||
- Гибкая настройка обязательных/необязательных каналов
|
||||
- Красивое сообщение с кнопками подписки
|
||||
|
||||
Attributes:
|
||||
bot: Экземпляр бота
|
||||
channels: Список конфигураций каналов
|
||||
cache_ttl: Время жизни кэша в секундах
|
||||
whitelist_admins: Пропускать ли администраторов бота
|
||||
show_buttons: Показывать ли кнопки для подписки
|
||||
|
||||
Example:
|
||||
```python
|
||||
from middleware.subscription import SubscriptionMiddleware, ChannelConfig
|
||||
|
||||
channels = [
|
||||
ChannelConfig(
|
||||
id="@my_channel",
|
||||
name="Основной канал",
|
||||
invite_link="https://t.me/my_channel"
|
||||
),
|
||||
ChannelConfig(
|
||||
id=-1001234567890,
|
||||
name="Закрытый канал",
|
||||
required=True
|
||||
)
|
||||
]
|
||||
|
||||
dp.message.middleware(SubscriptionMiddleware(bot, channels))
|
||||
dp.callback_query.middleware(SubscriptionMiddleware(bot, channels))
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot: Bot,
|
||||
channels: list[Union[ChannelConfig, str, int]],
|
||||
cache_ttl: float = 300.0,
|
||||
whitelist_admins: bool = True,
|
||||
show_buttons: bool = True,
|
||||
auto_fetch_links: bool = True
|
||||
):
|
||||
"""
|
||||
Инициализация middleware.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
channels: Список каналов (ChannelConfig, ID или username)
|
||||
cache_ttl: Время жизни кэша в секундах
|
||||
whitelist_admins: Пропускать администраторов бота
|
||||
show_buttons: Показывать кнопки подписки
|
||||
auto_fetch_links: Автоматически получать ссылки на каналы
|
||||
"""
|
||||
super().__init__()
|
||||
self.bot = bot
|
||||
self.cache = SubscriptionCache(ttl=cache_ttl)
|
||||
self.whitelist_admins = whitelist_admins
|
||||
self.show_buttons = show_buttons
|
||||
self.auto_fetch_links = auto_fetch_links
|
||||
|
||||
# Преобразуем channels в ChannelConfig
|
||||
self.channels: list[ChannelConfig] = []
|
||||
for channel in channels:
|
||||
if isinstance(channel, ChannelConfig):
|
||||
self.channels.append(channel)
|
||||
else:
|
||||
# Простой ID/username -> ChannelConfig
|
||||
self.channels.append(ChannelConfig(id=channel))
|
||||
|
||||
# Кэш информации о каналах
|
||||
self._channel_info_cache: Dict[Union[str, int], Optional[Chat]] = {}
|
||||
|
||||
async def _get_channel_info(self, channel_id: Union[str, int]) -> Optional[Chat]:
|
||||
"""
|
||||
Получает информацию о канале.
|
||||
|
||||
Args:
|
||||
channel_id: ID или username канала
|
||||
|
||||
Returns:
|
||||
Chat или None: Информация о канале
|
||||
"""
|
||||
if channel_id in self._channel_info_cache:
|
||||
return self._channel_info_cache[channel_id]
|
||||
|
||||
try:
|
||||
chat = await self.bot.get_chat(channel_id)
|
||||
self._channel_info_cache[channel_id] = chat
|
||||
return chat
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as e:
|
||||
logger.error(
|
||||
f"Не удалось получить информацию о канале {channel_id}: {e}",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
self._channel_info_cache[channel_id] = None
|
||||
return None
|
||||
|
||||
async def _check_subscription(
|
||||
self,
|
||||
user_id: int,
|
||||
channel_config: ChannelConfig
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет подписку пользователя на канал.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
channel_config: Конфигурация канала
|
||||
|
||||
Returns:
|
||||
bool: True если подписан
|
||||
"""
|
||||
channel_id = channel_config.id
|
||||
|
||||
# Проверяем кэш
|
||||
cached = self.cache.get(user_id, channel_id)
|
||||
if cached is not None:
|
||||
logger.debug(
|
||||
f"Использован кэш для проверки подписки на {channel_id}: {cached}",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
return cached
|
||||
|
||||
# Выполняем проверку
|
||||
try:
|
||||
member = await self.bot.get_chat_member(
|
||||
chat_id=channel_id,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
is_subscribed = member.status in (
|
||||
ChatMemberStatus.MEMBER,
|
||||
ChatMemberStatus.ADMINISTRATOR,
|
||||
ChatMemberStatus.CREATOR
|
||||
)
|
||||
|
||||
# Сохраняем в кэш
|
||||
self.cache.set(user_id, channel_id, is_subscribed)
|
||||
|
||||
logger.debug(
|
||||
f"Проверка подписки user={user_id} на канал={channel_id}: "
|
||||
f"{member.status.value} ({'✅' if is_subscribed else '❌'})",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
|
||||
return is_subscribed
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
logger.warning(
|
||||
f"Канал {channel_id} недоступен или неверный: {e}",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
# В случае ошибки считаем что не подписан
|
||||
self.cache.set(user_id, channel_id, False)
|
||||
return False
|
||||
|
||||
except TelegramForbiddenError as e:
|
||||
logger.error(
|
||||
f"Бот не имеет доступа к каналу {channel_id}: {e}",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
self.cache.set(user_id, channel_id, False)
|
||||
return False
|
||||
|
||||
async def _build_subscription_message(
|
||||
self,
|
||||
not_subscribed: list[ChannelConfig]
|
||||
) -> tuple[str, InlineKeyboardBuilder]:
|
||||
"""
|
||||
Создает сообщение и клавиатуру для подписки.
|
||||
|
||||
Args:
|
||||
not_subscribed: Список каналов без подписки
|
||||
|
||||
Returns:
|
||||
tuple: (текст_сообщения, клавиатура)
|
||||
"""
|
||||
# Текст сообщения
|
||||
text = "📢 <b>Для использования бота необходимо подписаться на каналы:</b>\n\n"
|
||||
|
||||
# Клавиатура
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
|
||||
for i, channel_config in enumerate(not_subscribed, 1):
|
||||
# Получаем информацию о канале
|
||||
channel_info = await self._get_channel_info(channel_config.id)
|
||||
|
||||
# Определяем название канала
|
||||
if channel_config.name:
|
||||
channel_name = channel_config.name
|
||||
elif channel_info:
|
||||
channel_name = channel_info.title
|
||||
else:
|
||||
channel_name = f"Канал {i}"
|
||||
|
||||
# Добавляем в текст
|
||||
text += f"{i}. {channel_name}\n"
|
||||
|
||||
# Определяем ссылку
|
||||
invite_link = channel_config.invite_link
|
||||
|
||||
if not invite_link and self.auto_fetch_links and channel_info:
|
||||
# Пытаемся получить ссылку
|
||||
if channel_info.username:
|
||||
invite_link = f"https://t.me/{channel_info.username}"
|
||||
elif channel_info.invite_link:
|
||||
invite_link = channel_info.invite_link
|
||||
|
||||
# Добавляем кнопку если есть ссылка
|
||||
if invite_link and self.show_buttons:
|
||||
keyboard.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"📌 {channel_name}",
|
||||
url=invite_link
|
||||
)
|
||||
)
|
||||
|
||||
text += "\n✅ После подписки нажмите кнопку ниже для проверки."
|
||||
|
||||
# Кнопка проверки подписки
|
||||
keyboard.row(
|
||||
InlineKeyboardButton(
|
||||
text="✅ Я подписался",
|
||||
callback_data="check_subscription"
|
||||
)
|
||||
)
|
||||
|
||||
return text, keyboard
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
Проверяет подписку перед выполнением хендлера.
|
||||
|
||||
Args:
|
||||
handler: Функция хендлера
|
||||
event: Объект события
|
||||
data: Дополнительные данные
|
||||
|
||||
Returns:
|
||||
Результат хендлера или None если не подписан
|
||||
"""
|
||||
# Пропускаем не-сообщения и не-callback
|
||||
if not isinstance(event, (Message, CallbackQuery)):
|
||||
return await handler(event, data)
|
||||
|
||||
# Извлекаем user_id
|
||||
user_id = event.from_user.id if event.from_user else None
|
||||
|
||||
if user_id is None:
|
||||
return await handler(event, data)
|
||||
|
||||
user_str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}"
|
||||
|
||||
# Whitelist для администраторов
|
||||
if self.whitelist_admins and user_id in settings.super_admin_ids:
|
||||
logger.debug(
|
||||
f"Администратор {user_str} пропущен без проверки подписки",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
return await handler(event, data)
|
||||
|
||||
# Проверяем подписку на все каналы
|
||||
not_subscribed: list[ChannelConfig] = []
|
||||
|
||||
for channel_config in self.channels:
|
||||
# Пропускаем необязательные каналы
|
||||
if not channel_config.required:
|
||||
continue
|
||||
|
||||
is_subscribed = await self._check_subscription(user_id, channel_config)
|
||||
|
||||
if not is_subscribed:
|
||||
not_subscribed.append(channel_config)
|
||||
|
||||
# Если есть каналы без подписки
|
||||
if not_subscribed:
|
||||
logger.info(
|
||||
f"Пользователь не подписан на {len(not_subscribed)} каналов",
|
||||
log_type='SUBSCRIPTION',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
# Создаем сообщение
|
||||
text, keyboard = await self._build_subscription_message(not_subscribed)
|
||||
|
||||
# Отправляем сообщение
|
||||
if isinstance(event, Message):
|
||||
await event.answer(
|
||||
text,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
# Для callback отправляем в чат или редактируем
|
||||
if event.message:
|
||||
try:
|
||||
await event.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except:
|
||||
await event.message.answer(
|
||||
text,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await event.answer(
|
||||
"⚠️ Требуется подписка на каналы",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# Все подписки в порядке
|
||||
logger.debug(
|
||||
f"Проверка подписки пройдена",
|
||||
log_type='SUBSCRIPTION',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
def invalidate_cache(
|
||||
self,
|
||||
user_id: Optional[int] = None,
|
||||
channel_id: Optional[Union[str, int]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Публичный метод для инвалидации кэша.
|
||||
|
||||
Используется при обработке callback "check_subscription".
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
channel_id: ID канала
|
||||
"""
|
||||
self.cache.invalidate(user_id, channel_id)
|
||||
|
||||
|
||||
# ================= HANDLER ДЛЯ ПРОВЕРКИ ПОДПИСКИ =================
|
||||
|
||||
async def handle_check_subscription(
|
||||
callback: CallbackQuery,
|
||||
subscription_middleware: SubscriptionMiddleware
|
||||
):
|
||||
"""
|
||||
Обработчик callback для повторной проверки подписки.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from filters.callback import CallbackStartsWith
|
||||
from middleware.subscription import handle_check_subscription, subscription_middleware
|
||||
|
||||
@router.callback_query(CallbackStartsWith("check_subscription"))
|
||||
async def check_sub(callback: CallbackQuery):
|
||||
await handle_check_subscription(callback, subscription_middleware)
|
||||
```
|
||||
"""
|
||||
user_id = callback.from_user.id
|
||||
|
||||
# Инвалидируем кэш для пользователя
|
||||
subscription_middleware.invalidate_cache(user_id=user_id)
|
||||
|
||||
await callback.answer("🔄 Проверяю подписку...", show_alert=False)
|
||||
|
||||
# Перепроверяем подписку
|
||||
not_subscribed = []
|
||||
|
||||
for channel_config in subscription_middleware.channels:
|
||||
if not channel_config.required:
|
||||
continue
|
||||
|
||||
is_subscribed = await subscription_middleware._check_subscription(
|
||||
user_id,
|
||||
channel_config
|
||||
)
|
||||
|
||||
if not is_subscribed:
|
||||
not_subscribed.append(channel_config)
|
||||
|
||||
if not_subscribed:
|
||||
# Все еще не подписан
|
||||
text, keyboard = await subscription_middleware._build_subscription_message(not_subscribed)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer(
|
||||
f"❌ Вы еще не подписаны на {len(not_subscribed)} каналов",
|
||||
show_alert=True
|
||||
)
|
||||
else:
|
||||
# Подписка подтверждена
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
"✅ <b>Подписка подтверждена!</b>\n\n"
|
||||
"Теперь вы можете пользоваться ботом. Используйте /start",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Подписка успешно подтверждена",
|
||||
log_type='SUBSCRIPTION',
|
||||
user=f"@{callback.from_user.username}" if callback.from_user.username else f"id{user_id}"
|
||||
)
|
||||
311
bot/middlewares/time_mdw.py
Normal file
311
bot/middlewares/time_mdw.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Middleware для измерения времени выполнения хендлеров
|
||||
"""
|
||||
from time import time
|
||||
from typing import Callable, Awaitable, Any, Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, Message, CallbackQuery, Update, User
|
||||
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ('TimingMiddleware', 'TimingStats')
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandlerMetrics:
|
||||
"""Метрики одного хендлера"""
|
||||
total_calls: int = 0
|
||||
total_time: float = 0.0
|
||||
min_time: float = float('inf')
|
||||
max_time: float = 0.0
|
||||
last_call: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def avg_time(self) -> float:
|
||||
"""Среднее время выполнения"""
|
||||
return self.total_time / self.total_calls if self.total_calls > 0 else 0.0
|
||||
|
||||
def update(self, execution_time: float) -> None:
|
||||
"""Обновляет метрики"""
|
||||
self.total_calls += 1
|
||||
self.total_time += execution_time
|
||||
self.min_time = min(self.min_time, execution_time)
|
||||
self.max_time = max(self.max_time, execution_time)
|
||||
self.last_call = datetime.now()
|
||||
|
||||
|
||||
class TimingStats:
|
||||
"""
|
||||
Глобальная статистика времени выполнения хендлеров.
|
||||
|
||||
Хранит метрики для каждого хендлера и предоставляет методы для анализа.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.metrics: Dict[str, HandlerMetrics] = defaultdict(HandlerMetrics)
|
||||
|
||||
def record(self, handler_name: str, execution_time: float) -> None:
|
||||
"""
|
||||
Записывает время выполнения хендлера.
|
||||
|
||||
Args:
|
||||
handler_name: Имя хендлера
|
||||
execution_time: Время выполнения в секундах
|
||||
"""
|
||||
self.metrics[handler_name].update(execution_time)
|
||||
|
||||
def get_stats(self, handler_name: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Возвращает статистику по хендлеру или всем хендлерам.
|
||||
|
||||
Args:
|
||||
handler_name: Имя конкретного хендлера (если None, возвращает все)
|
||||
|
||||
Returns:
|
||||
Dict с метриками
|
||||
"""
|
||||
if handler_name:
|
||||
metrics = self.metrics.get(handler_name)
|
||||
if not metrics:
|
||||
return {}
|
||||
|
||||
return {
|
||||
'handler': handler_name,
|
||||
'total_calls': metrics.total_calls,
|
||||
'avg_time': f"{metrics.avg_time:.3f}s",
|
||||
'min_time': f"{metrics.min_time:.3f}s",
|
||||
'max_time': f"{metrics.max_time:.3f}s",
|
||||
'last_call': metrics.last_call.strftime('%Y-%m-%d %H:%M:%S') if metrics.last_call else None
|
||||
}
|
||||
|
||||
# Возвращаем статистику по всем хендлерам
|
||||
return {
|
||||
name: {
|
||||
'total_calls': m.total_calls,
|
||||
'avg_time': f"{m.avg_time:.3f}s",
|
||||
'min_time': f"{m.min_time:.3f}s",
|
||||
'max_time': f"{m.max_time:.3f}s"
|
||||
}
|
||||
for name, m in sorted(
|
||||
self.metrics.items(),
|
||||
key=lambda x: x[1].avg_time,
|
||||
reverse=True
|
||||
)
|
||||
}
|
||||
|
||||
def get_slowest(self, limit: int = 10) -> list[tuple[str, float]]:
|
||||
"""
|
||||
Возвращает список самых медленных хендлеров.
|
||||
|
||||
Args:
|
||||
limit: Количество хендлеров в результате
|
||||
|
||||
Returns:
|
||||
List кортежей (имя_хендлера, среднее_время)
|
||||
"""
|
||||
sorted_handlers = sorted(
|
||||
self.metrics.items(),
|
||||
key=lambda x: x[1].avg_time,
|
||||
reverse=True
|
||||
)
|
||||
return [(name, m.avg_time) for name, m in sorted_handlers[:limit]]
|
||||
|
||||
def reset(self, handler_name: Optional[str] = None) -> None:
|
||||
"""
|
||||
Сбрасывает статистику.
|
||||
|
||||
Args:
|
||||
handler_name: Имя хендлера для сброса (если None, сбрасывает все)
|
||||
"""
|
||||
if handler_name:
|
||||
if handler_name in self.metrics:
|
||||
del self.metrics[handler_name]
|
||||
else:
|
||||
self.metrics.clear()
|
||||
|
||||
|
||||
# Глобальный экземпляр статистики
|
||||
timing_stats = TimingStats()
|
||||
|
||||
|
||||
class TimingMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для измерения времени выполнения хендлеров.
|
||||
|
||||
Возможности:
|
||||
- Измерение времени выполнения каждого хендлера
|
||||
- Автоматическая классификация (быстрый/средний/медленный)
|
||||
- Сбор статистики
|
||||
- Логирование медленных хендлеров
|
||||
- Предупреждения о критически медленных запросах
|
||||
|
||||
Attributes:
|
||||
slow_threshold: Порог медленного хендлера (сек)
|
||||
warning_threshold: Порог критически медленного хендлера (сек)
|
||||
log_all: Логировать все хендлеры (даже быстрые)
|
||||
collect_stats: Собирать статистику
|
||||
|
||||
Example:
|
||||
```python
|
||||
from middleware.timing import TimingMiddleware, timing_stats
|
||||
|
||||
# Регистрация middleware
|
||||
dp.message.middleware(TimingMiddleware(slow_threshold=0.5))
|
||||
|
||||
# Получение статистики
|
||||
stats = timing_stats.get_slowest(5)
|
||||
for handler, avg_time in stats:
|
||||
print(f"{handler}: {avg_time:.3f}s")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
slow_threshold: float = 1.0,
|
||||
warning_threshold: float = 3.0,
|
||||
log_all: bool = False,
|
||||
collect_stats: bool = True
|
||||
):
|
||||
"""
|
||||
Инициализация middleware.
|
||||
|
||||
Args:
|
||||
slow_threshold: Порог медленного хендлера в секундах
|
||||
warning_threshold: Порог критически медленного хендлера
|
||||
log_all: Логировать все хендлеры (иначе только медленные)
|
||||
collect_stats: Собирать статистику выполнения
|
||||
"""
|
||||
super().__init__()
|
||||
self.slow_threshold = slow_threshold
|
||||
self.warning_threshold = warning_threshold
|
||||
self.log_all = log_all
|
||||
self.collect_stats = collect_stats
|
||||
|
||||
@staticmethod
|
||||
def _extract_user_info(event: TelegramObject) -> str:
|
||||
"""
|
||||
Извлекает информацию о пользователе из события.
|
||||
|
||||
Args:
|
||||
event: Объект события
|
||||
|
||||
Returns:
|
||||
str: Форматированная строка с информацией о пользователе
|
||||
"""
|
||||
user: Optional[User] = None
|
||||
|
||||
# Прямое извлечение из Message/CallbackQuery
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user = getattr(event, 'from_user', None)
|
||||
|
||||
# Извлечение из Update
|
||||
elif isinstance(event, Update):
|
||||
for attr in ['message', 'edited_message', 'callback_query',
|
||||
'channel_post', 'edited_channel_post', 'inline_query',
|
||||
'chosen_inline_result', 'my_chat_member', 'chat_member']:
|
||||
obj = getattr(event, attr, None)
|
||||
if obj and hasattr(obj, 'from_user'):
|
||||
user = obj.from_user
|
||||
break
|
||||
|
||||
if user:
|
||||
return f"@{user.username}" if user.username else f"id{user.id}"
|
||||
|
||||
return "@System"
|
||||
|
||||
@staticmethod
|
||||
def _get_handler_name(handler: Callable) -> str:
|
||||
"""
|
||||
Получает имя хендлера для логирования.
|
||||
|
||||
Args:
|
||||
handler: Функция хендлера
|
||||
|
||||
Returns:
|
||||
str: Имя хендлера
|
||||
"""
|
||||
# Пытаемся получить полное имя с модулем
|
||||
if hasattr(handler, '__module__') and hasattr(handler, '__name__'):
|
||||
return f"{handler.__module__}.{handler.__name__}"
|
||||
elif hasattr(handler, '__name__'):
|
||||
return handler.__name__
|
||||
else:
|
||||
return str(handler)
|
||||
|
||||
def _classify_speed(self, execution_time: float) -> tuple[str, str]:
|
||||
"""
|
||||
Классифицирует скорость выполнения.
|
||||
|
||||
Args:
|
||||
execution_time: Время выполнения в секундах
|
||||
|
||||
Returns:
|
||||
tuple: (уровень_лога, тип_лога)
|
||||
"""
|
||||
if execution_time >= self.warning_threshold:
|
||||
return 'ERROR', 'CRITICAL_SLOW'
|
||||
elif execution_time >= self.slow_threshold:
|
||||
return 'WARNING', 'SLOW_HANDLER'
|
||||
elif execution_time >= self.slow_threshold / 2:
|
||||
return 'INFO', 'MEDIUM_HANDLER'
|
||||
else:
|
||||
return 'DEBUG', 'FAST_HANDLER'
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""
|
||||
Основной метод middleware.
|
||||
|
||||
Args:
|
||||
handler: Функция хендлера
|
||||
event: Объект события
|
||||
data: Дополнительные данные
|
||||
|
||||
Returns:
|
||||
Результат выполнения хендлера
|
||||
"""
|
||||
start_time = time()
|
||||
handler_name = self._get_handler_name(handler)
|
||||
user_str = self._extract_user_info(event)
|
||||
|
||||
# Выполняем хендлер
|
||||
try:
|
||||
result = await handler(event, data)
|
||||
return result
|
||||
|
||||
finally:
|
||||
# Измеряем время
|
||||
execution_time = time() - start_time
|
||||
|
||||
# Собираем статистику
|
||||
if self.collect_stats:
|
||||
timing_stats.record(handler_name, execution_time)
|
||||
|
||||
# Классифицируем скорость
|
||||
log_level, log_type = self._classify_speed(execution_time)
|
||||
|
||||
# Логируем результат
|
||||
if self.log_all or execution_time >= self.slow_threshold / 2:
|
||||
# Формируем сообщение
|
||||
if execution_time >= self.warning_threshold:
|
||||
message = f"⚠️ КРИТИЧЕСКИ медленный хендлер '{handler_name}': {execution_time:.3f}с"
|
||||
elif execution_time >= self.slow_threshold:
|
||||
message = f"🐌 Медленный хендлер '{handler_name}': {execution_time:.3f}с"
|
||||
else:
|
||||
message = f"⏱️ Хендлер '{handler_name}': {execution_time:.3f}с"
|
||||
|
||||
# Логируем
|
||||
logger.log_entry(
|
||||
level=log_level,
|
||||
text=message,
|
||||
log_type=log_type,
|
||||
user=user_str
|
||||
)
|
||||
Reference in New Issue
Block a user