Merge pull request #2494 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-02-02 02:08:12 +03:00
committed by GitHub
2 changed files with 184 additions and 52 deletions

View File

@@ -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'<b>Контекст:</b> {context}\n'
# Добавляем рекомендации если есть
recommendations = _get_error_recommendations(error_message)
if recommendations:
message_text += f'\n{recommendations}\n'
message_text += f'\n<i>{timestamp}</i>'
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,
),
],
]

View File

@@ -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'<b>Remnawave Bedolaga Bot</b>\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 '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
# Ошибки подключения к БД
if any(keyword in error_lower for keyword in DATABASE_ERROR_KEYWORDS):
tips = [
'• Проверьте что PostgreSQL запущен',
'• Проверьте DATABASE_URL в .env',
'• Проверьте сеть Docker между контейнерами',
'• Попробуйте: docker compose restart db',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
# Ошибки Redis
if REDIS_ERROR_KEYWORD in error_lower:
tips = [
'• Проверьте что Redis запущен',
'• Проверьте REDIS_URL в .env',
'• Попробуйте: docker compose restart redis',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
# Ошибки Remnawave API
if any(keyword in error_lower for keyword in REMNAWAVE_ERROR_KEYWORDS):
tips = [
'• Проверьте REMNAWAVE_API_URL в .env',
'• Проверьте REMNAWAVE_API_KEY',
'• Убедитесь что панель Remnawave доступна',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
# Ошибки токена бота
if any(keyword in error_lower for keyword in AUTH_ERROR_KEYWORDS):
tips = [
'• Проверьте BOT_TOKEN в .env',
'• Убедитесь что токен актуален (@BotFather)',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
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'<b>Remnawave Bedolaga Bot</b>\n\n'
f'❌ Бот упал с ошибкой\n\n'
f'<b>Тип:</b> <code>{error_type}</code>\n'
f'<b>Сообщение:</b> <code>{error_message[:200]}</code>\n\n'
f'<i>{timestamp}</i>'
f'<b>Сообщение:</b> <code>{error_message[:CRASH_ERROR_PREVIEW_LENGTH]}</code>\n'
)
# Добавляем рекомендации если есть
recommendations = _get_error_recommendations(error_message)
if recommendations:
message_text += f'\n{recommendations}\n'
message_text += f'\n<i>{timestamp}</i>'
# Кнопка для связи с разработчиком
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text='💬 Сообщить разработчику',
url='https://t.me/fringg',
url=DEVELOPER_CONTACT_URL,
),
],
]