675 lines
23 KiB
Python
675 lines
23 KiB
Python
"""
|
||
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
|