diff --git a/app/middlewares/global_error.py b/app/middlewares/global_error.py index 847a462a..e9a3b93a 100644 --- a/app/middlewares/global_error.py +++ b/app/middlewares/global_error.py @@ -3,7 +3,7 @@ import logging import traceback from collections.abc import Awaitable, Callable from datetime import datetime, timedelta -from typing import Any +from typing import Any, Final from aiogram import BaseMiddleware, Bot from aiogram.enums import ParseMode @@ -11,16 +11,49 @@ from aiogram.exceptions import TelegramBadRequest from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, TelegramObject from app.config import settings +from app.services.startup_notification_service import _get_error_recommendations from app.utils.timezone import format_local_datetime logger = logging.getLogger(__name__) +# Константы +ERROR_NOTIFICATION_COOLDOWN_MINUTES: Final[int] = 5 +ERROR_BUFFER_MAX_SIZE: Final[int] = 10 +ERROR_MESSAGE_MAX_LENGTH: Final[int] = 500 +REPORT_SEPARATOR_WIDTH: Final[int] = 50 +DATETIME_FORMAT: Final[str] = '%d.%m.%Y %H:%M:%S' +DATETIME_FORMAT_FILENAME: Final[str] = '%Y%m%d_%H%M%S' +DEVELOPER_CONTACT_URL: Final[str] = 'https://t.me/fringg' + +# Фразы ошибок Telegram API +OLD_QUERY_PHRASES: Final[tuple[str, ...]] = ( + 'query is too old', + 'query id is invalid', + 'response timeout expired', +) +BAD_REQUEST_PHRASES: Final[tuple[str, ...]] = ( + 'message not found', + 'chat not found', + 'bot was blocked by the user', + 'user is deactivated', +) +TOPIC_ERROR_PHRASES: Final[tuple[str, ...]] = ( + 'topic must be specified', + 'topic_closed', + 'topic_deleted', + 'forum_closed', +) +MESSAGE_NOT_MODIFIED_PHRASE: Final[str] = 'message is not modified' +BOT_BLOCKED_PHRASE: Final[str] = 'bot was blocked' +USER_DEACTIVATED_PHRASE: Final[str] = 'user is deactivated' +CHAT_NOT_FOUND_PHRASE: Final[str] = 'chat not found' +MESSAGE_NOT_FOUND_PHRASE: Final[str] = 'message not found' + # Троттлинг для предотвращения спама ошибками _last_error_notification: datetime | None = None -_error_notification_cooldown = timedelta(minutes=5) # Минимум 5 минут между уведомлениями +_error_notification_cooldown = timedelta(minutes=ERROR_NOTIFICATION_COOLDOWN_MINUTES) _error_buffer: list[tuple[str, str, str]] = [] # (error_type, error_message, traceback) -_max_buffer_size = 10 class GlobalErrorMiddleware(BaseMiddleware): @@ -66,25 +99,16 @@ class GlobalErrorMiddleware(BaseMiddleware): raise error def _is_old_query_error(self, error_message: str) -> bool: - return any( - phrase in error_message - for phrase in ['query is too old', 'query id is invalid', 'response timeout expired'] - ) + return any(phrase in error_message for phrase in OLD_QUERY_PHRASES) def _is_message_not_modified_error(self, error_message: str) -> bool: - return 'message is not modified' in error_message + return MESSAGE_NOT_MODIFIED_PHRASE in error_message def _is_bad_request_error(self, error_message: str) -> bool: - return any( - phrase in error_message - for phrase in ['message not found', 'chat not found', 'bot was blocked by the user', 'user is deactivated'] - ) + return any(phrase in error_message for phrase in BAD_REQUEST_PHRASES) def _is_topic_required_error(self, error_message: str) -> bool: - return any( - phrase in error_message - for phrase in ['topic must be specified', 'topic_closed', 'topic_deleted', 'forum_closed'] - ) + return any(phrase in error_message for phrase in TOPIC_ERROR_PHRASES) async def _handle_old_query(self, event: TelegramObject, error: TelegramBadRequest): if isinstance(event, CallbackQuery): @@ -107,15 +131,15 @@ class GlobalErrorMiddleware(BaseMiddleware): async def _handle_bad_request(self, event: TelegramObject, error: TelegramBadRequest): error_message = str(error).lower() - if 'bot was blocked' in error_message: + if BOT_BLOCKED_PHRASE in error_message: user_info = self._get_user_info(event) if hasattr(event, 'from_user') else 'Unknown' logger.info('[GlobalErrorMiddleware] Бот заблокирован пользователем %s', user_info) return - if 'user is deactivated' in error_message: + if USER_DEACTIVATED_PHRASE in error_message: user_info = self._get_user_info(event) if hasattr(event, 'from_user') else 'Unknown' logger.info('[GlobalErrorMiddleware] Пользователь деактивирован %s', user_info) return - if 'chat not found' in error_message or 'message not found' in error_message: + if CHAT_NOT_FOUND_PHRASE in error_message or MESSAGE_NOT_FOUND_PHRASE in error_message: logger.warning('[GlobalErrorMiddleware] Чат или сообщение не найдено: %s', error) return logger.error('[GlobalErrorMiddleware] Неизвестная bad request ошибка: %s', error) @@ -154,13 +178,13 @@ class ErrorStatisticsMiddleware(BaseMiddleware): def _count_error(self, error: TelegramBadRequest): error_message = str(error).lower() - if 'query is too old' in error_message: + if OLD_QUERY_PHRASES[0] in error_message: self.error_counts['old_queries'] += 1 - elif 'message is not modified' in error_message: + elif MESSAGE_NOT_MODIFIED_PHRASE in error_message: self.error_counts['message_not_modified'] += 1 - elif 'bot was blocked' in error_message: + elif BOT_BLOCKED_PHRASE in error_message: self.error_counts['bot_blocked'] += 1 - elif 'user is deactivated' in error_message: + elif USER_DEACTIVATED_PHRASE in error_message: self.error_counts['user_deactivated'] += 1 else: self.error_counts['other_errors'] += 1 @@ -195,12 +219,12 @@ async def send_error_to_admin_chat(bot: Bot, error: Exception, context: str = '' return False error_type = type(error).__name__ - error_message = str(error)[:500] + error_message = str(error)[:ERROR_MESSAGE_MAX_LENGTH] tb_str = traceback.format_exc() # Добавляем в буфер _error_buffer.append((error_type, error_message, tb_str)) - if len(_error_buffer) > _max_buffer_size: + if len(_error_buffer) > ERROR_BUFFER_MAX_SIZE: _error_buffer.pop(0) # Проверяем троттлинг @@ -212,12 +236,13 @@ async def send_error_to_admin_chat(bot: Bot, error: Exception, context: str = '' _last_error_notification = now try: - timestamp = format_local_datetime(now, '%d.%m.%Y %H:%M:%S') + timestamp = format_local_datetime(now, DATETIME_FORMAT) + separator = '=' * REPORT_SEPARATOR_WIDTH # Формируем лог-файл со всеми ошибками из буфера log_lines = [ 'ERROR REPORT', - '=' * 50, + separator, f'Timestamp: {timestamp}', f'Errors in buffer: {len(_error_buffer)}', '', @@ -226,9 +251,9 @@ async def send_error_to_admin_chat(bot: Bot, error: Exception, context: str = '' for i, (err_type, err_msg, err_tb) in enumerate(_error_buffer): log_lines.extend( [ - f'{"=" * 50}', + separator, f'ERROR #{i}: {err_type}', - f'{"=" * 50}', + separator, f'Message: {err_msg}', '', 'Traceback:', @@ -243,7 +268,7 @@ async def send_error_to_admin_chat(bot: Bot, error: Exception, context: str = '' errors_count = len(_error_buffer) _error_buffer.clear() - file_name = f'error_report_{now.strftime("%Y%m%d_%H%M%S")}.txt' + file_name = f'error_report_{now.strftime(DATETIME_FORMAT_FILENAME)}.txt' file = BufferedInputFile( file=log_content.encode('utf-8'), filename=file_name, @@ -257,6 +282,12 @@ async def send_error_to_admin_chat(bot: Bot, error: Exception, context: str = '' ) if context: message_text += f'Контекст: {context}\n' + + # Добавляем рекомендации если есть + recommendations = _get_error_recommendations(error_message) + if recommendations: + message_text += f'\n{recommendations}\n' + message_text += f'\n{timestamp}' keyboard = InlineKeyboardMarkup( @@ -264,7 +295,7 @@ async def send_error_to_admin_chat(bot: Bot, error: Exception, context: str = '' [ InlineKeyboardButton( text='💬 Сообщить разработчику', - url='https://t.me/fringg', + url=DEVELOPER_CONTACT_URL, ), ], ] diff --git a/app/services/startup_notification_service.py b/app/services/startup_notification_service.py index 6caa225f..a644792d 100644 --- a/app/services/startup_notification_service.py +++ b/app/services/startup_notification_service.py @@ -7,6 +7,7 @@ import logging import os from datetime import datetime +from typing import Final from aiogram import Bot from aiogram.enums import ParseMode @@ -22,6 +23,36 @@ from app.utils.timezone import format_local_datetime logger = logging.getLogger(__name__) +# Константы +VERSION_ENV_VAR: Final[str] = 'VERSION' +DEFAULT_VERSION: Final[str] = 'dev' +DEFAULT_AUTH_TYPE: Final[str] = 'api_key' + +# Форматирование +KOPEKS_IN_RUBLE: Final[int] = 100 +MILLION: Final[int] = 1_000_000 +THOUSAND: Final[int] = 1_000 +DATETIME_FORMAT: Final[str] = '%d.%m.%Y %H:%M:%S' +DATETIME_FORMAT_FILENAME: Final[str] = '%Y%m%d_%H%M%S' +REPORT_SEPARATOR_WIDTH: Final[int] = 50 + +# Лимиты сообщений +CRASH_ERROR_MESSAGE_MAX_LENGTH: Final[int] = 1000 +CRASH_ERROR_PREVIEW_LENGTH: Final[int] = 200 + +# URL-ы +GITHUB_BOT_URL: Final[str] = 'https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot' +GITHUB_CABINET_URL: Final[str] = 'https://github.com/BEDOLAGA-DEV/bedolaga-cabinet' +COMMUNITY_URL: Final[str] = 'https://t.me/+wTdMtSWq8YdmZmVi' +DEVELOPER_CONTACT_URL: Final[str] = 'https://t.me/fringg' + +# Ключевые слова для определения типа ошибки +WEBHOOK_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('webhook', 'failed to resolve host') +DATABASE_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('database', 'postgres', 'connection refused') +REDIS_ERROR_KEYWORD: Final[str] = 'redis' +REMNAWAVE_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('remnawave', 'panel') +AUTH_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('unauthorized', 'bot token') + class StartupNotificationService: """Сервис для отправки стартового уведомления в админский чат.""" @@ -34,10 +65,10 @@ class StartupNotificationService: def _get_version(self) -> str: """Получает версию из переменной окружения VERSION.""" - version = os.getenv('VERSION', '').strip() + version = os.getenv(VERSION_ENV_VAR, '').strip() if version: return version - return 'dev' + return DEFAULT_VERSION async def _get_users_count(self) -> int: """Получает количество активных пользователей в базе.""" @@ -117,7 +148,7 @@ class StartupNotificationService: username = (auth_params.get('username') or '').strip() or None password = (auth_params.get('password') or '').strip() or None caddy_token = (auth_params.get('caddy_token') or '').strip() or None - auth_type = (auth_params.get('auth_type') or 'api_key').strip() + auth_type = (auth_params.get('auth_type') or DEFAULT_AUTH_TYPE).strip() api = RemnaWaveAPI( base_url=base_url, @@ -141,11 +172,11 @@ class StartupNotificationService: def _format_balance(self, kopeks: int) -> str: """Форматирует баланс в рублях.""" - rubles = kopeks / 100 - if rubles >= 1_000_000: - return f'{rubles / 1_000_000:.2f}M RUB' - if rubles >= 1_000: - return f'{rubles / 1_000:.1f}K RUB' + rubles = kopeks / KOPEKS_IN_RUBLE + if rubles >= MILLION: + return f'{rubles / MILLION:.2f}M RUB' + if rubles >= THOUSAND: + return f'{rubles / THOUSAND:.1f}K RUB' return f'{rubles:.2f} RUB' async def send_startup_notification(self) -> bool: @@ -183,7 +214,7 @@ class StartupNotificationService: ] system_info = '\n'.join(system_info_lines) - timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S') + timestamp = format_local_datetime(datetime.utcnow(), DATETIME_FORMAT) message = ( f'Remnawave Bedolaga Bot\n\n' @@ -197,19 +228,19 @@ class StartupNotificationService: [ InlineKeyboardButton( text='Поставить звезду', - url='https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot', + url=GITHUB_BOT_URL, ), ], [ InlineKeyboardButton( text='Вебкабинет', - url='https://github.com/BEDOLAGA-DEV/bedolaga-cabinet', + url=GITHUB_CABINET_URL, ), ], [ InlineKeyboardButton( text='Сообщество', - url='https://t.me/+wTdMtSWq8YdmZmVi', + url=COMMUNITY_URL, ), ], ] @@ -249,6 +280,69 @@ async def send_bot_startup_notification(bot: Bot) -> bool: return await service.send_startup_notification() +def _get_error_recommendations(error_message: str) -> str | None: + """ + Возвращает рекомендации по исправлению ошибки на основе текста ошибки. + + Args: + error_message: Текст ошибки + + Returns: + Рекомендации в формате HTML blockquote или None + """ + error_lower = error_message.lower() + + # Ошибки вебхука + if any(keyword in error_lower for keyword in WEBHOOK_ERROR_KEYWORDS): + tips = [ + '• Проверьте WEBHOOK_HOST в .env', + '• Убедитесь что домен доступен извне', + '• Проверьте SSL сертификат (должен быть валидный)', + '• Проверьте reverse proxy (nginx/caddy)', + '• Проверьте сеть Docker (docker network)', + '• Попробуйте: docker compose restart', + ] + return '
💡 Рекомендации:\n' + '\n'.join(tips) + '
' + + # Ошибки подключения к БД + if any(keyword in error_lower for keyword in DATABASE_ERROR_KEYWORDS): + tips = [ + '• Проверьте что PostgreSQL запущен', + '• Проверьте DATABASE_URL в .env', + '• Проверьте сеть Docker между контейнерами', + '• Попробуйте: docker compose restart db', + ] + return '
💡 Рекомендации:\n' + '\n'.join(tips) + '
' + + # Ошибки Redis + if REDIS_ERROR_KEYWORD in error_lower: + tips = [ + '• Проверьте что Redis запущен', + '• Проверьте REDIS_URL в .env', + '• Попробуйте: docker compose restart redis', + ] + return '
💡 Рекомендации:\n' + '\n'.join(tips) + '
' + + # Ошибки Remnawave API + if any(keyword in error_lower for keyword in REMNAWAVE_ERROR_KEYWORDS): + tips = [ + '• Проверьте REMNAWAVE_API_URL в .env', + '• Проверьте REMNAWAVE_API_KEY', + '• Убедитесь что панель Remnawave доступна', + ] + return '
💡 Рекомендации:\n' + '\n'.join(tips) + '
' + + # Ошибки токена бота + if any(keyword in error_lower for keyword in AUTH_ERROR_KEYWORDS): + tips = [ + '• Проверьте BOT_TOKEN в .env', + '• Убедитесь что токен актуален (@BotFather)', + ] + return '
💡 Рекомендации:\n' + '\n'.join(tips) + '
' + + return None + + async def send_crash_notification(bot: Bot, error: Exception, traceback_str: str) -> bool: """ Отправляет уведомление о падении бота с лог-файлом. @@ -270,25 +364,26 @@ async def send_crash_notification(bot: Bot, error: Exception, traceback_str: str return False try: - timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S') + timestamp = format_local_datetime(datetime.utcnow(), DATETIME_FORMAT) error_type = type(error).__name__ - error_message = str(error)[:1000] # Ограничиваем длину сообщения + error_message = str(error)[:CRASH_ERROR_MESSAGE_MAX_LENGTH] + separator = '=' * REPORT_SEPARATOR_WIDTH # Формируем содержимое лог-файла log_content = ( f'CRASH REPORT\n' - f'{"=" * 50}\n\n' + f'{separator}\n\n' f'Timestamp: {timestamp}\n' f'Error Type: {error_type}\n' f'Error Message: {error_message}\n\n' - f'{"=" * 50}\n' + f'{separator}\n' f'TRACEBACK\n' - f'{"=" * 50}\n\n' + f'{separator}\n\n' f'{traceback_str}\n' ) # Создаем файл для отправки - file_name = f'crash_report_{datetime.utcnow().strftime("%Y%m%d_%H%M%S")}.txt' + file_name = f'crash_report_{datetime.utcnow().strftime(DATETIME_FORMAT_FILENAME)}.txt' file = BufferedInputFile( file=log_content.encode('utf-8'), filename=file_name, @@ -299,17 +394,23 @@ async def send_crash_notification(bot: Bot, error: Exception, traceback_str: str f'Remnawave Bedolaga Bot\n\n' f'❌ Бот упал с ошибкой\n\n' f'Тип: {error_type}\n' - f'Сообщение: {error_message[:200]}\n\n' - f'{timestamp}' + f'Сообщение: {error_message[:CRASH_ERROR_PREVIEW_LENGTH]}\n' ) + # Добавляем рекомендации если есть + recommendations = _get_error_recommendations(error_message) + if recommendations: + message_text += f'\n{recommendations}\n' + + message_text += f'\n{timestamp}' + # Кнопка для связи с разработчиком keyboard = InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( text='💬 Сообщить разработчику', - url='https://t.me/fringg', + url=DEVELOPER_CONTACT_URL, ), ], ]