mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-25 13:51:50 +00:00
@@ -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,
|
||||
),
|
||||
],
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user