Первый коммит

This commit is contained in:
2026-02-17 11:24:55 +07:00
commit a06448ca4b
109 changed files with 21165 additions and 0 deletions

137
bot/middlewares/__init__.py Normal file
View 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

View 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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)

View 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

View 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']}"
)

View 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
View 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
View 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
View 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
)