""" Сервис стартового уведомления бота. Отправляет красивое сообщение с информацией о системе при запуске бота. """ import html from datetime import UTC, datetime from typing import Final import structlog from aiogram import Bot from aiogram.enums import ParseMode from aiogram.types import BufferedInputFile, InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy import func, select from app.config import settings from app.database.database import AsyncSessionLocal from app.database.models import Subscription, SubscriptionStatus, Ticket, TicketStatus, User, UserStatus from app.external.remnawave_api import RemnaWaveAPI, test_api_connection from app.utils.timezone import format_local_datetime logger = structlog.get_logger(__name__) # Константы 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') INLINE_BUTTON_URL_ERROR_KEYWORDS: Final[tuple[str, ...]] = ( 'web app url', 'url host is empty', 'unsupported url protocol', 'button url', ) class StartupNotificationService: """Сервис для отправки стартового уведомления в админский чат.""" def __init__(self, bot: Bot) -> None: self.bot = bot self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None) self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None) self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) def _get_version(self) -> str: """Получает версию из pyproject.toml.""" try: from pathlib import Path pyproject_path = Path(__file__).resolve().parents[2] / 'pyproject.toml' if pyproject_path.exists(): for line in pyproject_path.read_text().splitlines(): if line.strip().startswith('version'): ver = line.split('=', 1)[1].strip().strip('"').strip("'") if ver: return ver except Exception: pass return DEFAULT_VERSION async def _get_users_count(self) -> int: """Получает количество активных пользователей в базе.""" try: async with AsyncSessionLocal() as db: result = await db.execute(select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value)) return result.scalar() or 0 except Exception as e: logger.error('Ошибка получения количества пользователей', e=e) return 0 async def _get_total_balance(self) -> int: """Получает сумму балансов всех пользователей в копейках.""" try: async with AsyncSessionLocal() as db: result = await db.execute( select(func.coalesce(func.sum(User.balance_kopeks), 0)).where( User.status == UserStatus.ACTIVE.value ) ) return result.scalar() or 0 except Exception as e: logger.error('Ошибка получения суммы балансов', e=e) return 0 async def _get_open_tickets_count(self) -> int: """Получает количество открытых тикетов.""" try: async with AsyncSessionLocal() as db: result = await db.execute(select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.OPEN.value)) return result.scalar() or 0 except Exception as e: logger.error('Ошибка получения количества открытых тикетов', e=e) return 0 async def _get_paid_subscriptions_count(self) -> int: """Получает количество платных подписок (не триальных, активных).""" try: async with AsyncSessionLocal() as db: result = await db.execute( select(func.count(Subscription.id)).where( Subscription.is_trial == False, Subscription.status == SubscriptionStatus.ACTIVE.value, ) ) return result.scalar() or 0 except Exception as e: logger.error('Ошибка получения количества платных подписок', e=e) return 0 async def _get_trial_subscriptions_count(self) -> int: """Получает количество триальных подписок.""" try: async with AsyncSessionLocal() as db: result = await db.execute(select(func.count(Subscription.id)).where(Subscription.is_trial == True)) return result.scalar() or 0 except Exception as e: logger.error('Ошибка получения количества триальных подписок', e=e) return 0 async def _check_remnawave_connection(self) -> tuple[bool, str]: """ Проверяет соединение с панелью Remnawave. Returns: Tuple[bool, str]: (is_connected, status_message) """ try: auth_params = settings.get_remnawave_auth_params() base_url = (auth_params.get('base_url') or '').strip() api_key = (auth_params.get('api_key') or '').strip() if not base_url or not api_key: return False, 'Не настроен' secret_key = (auth_params.get('secret_key') or '').strip() or None 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 DEFAULT_AUTH_TYPE).strip() api = RemnaWaveAPI( base_url=base_url, api_key=api_key, secret_key=secret_key, username=username, password=password, caddy_token=caddy_token, auth_type=auth_type, ) async with api: is_connected = await test_api_connection(api) if is_connected: return True, 'Подключено' return False, 'Недоступна' except Exception as e: logger.error('Ошибка проверки соединения с Remnawave', e=e) return False, 'Ошибка подключения' def _format_balance(self, kopeks: int) -> str: """Форматирует баланс в рублях.""" 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: """ Отправляет стартовое уведомление в админский чат. Returns: bool: True если сообщение отправлено успешно """ if not self.enabled or not self.chat_id: logger.debug('Стартовое уведомление отключено или chat_id не задан') return False try: version = self._get_version() users_count = await self._get_users_count() total_balance_kopeks = await self._get_total_balance() open_tickets_count = await self._get_open_tickets_count() paid_subscriptions_count = await self._get_paid_subscriptions_count() trial_subscriptions_count = await self._get_trial_subscriptions_count() remnawave_connected, remnawave_status = await self._check_remnawave_connection() # Иконка статуса Remnawave remnawave_icon = '🟢' if remnawave_connected else '🔴' # Формируем системную информацию для blockquote system_info_lines = [ f'Версия: {version}', f'Пользователей: {users_count:,}'.replace(',', ' '), f'Сумма балансов: {self._format_balance(total_balance_kopeks)}', f'Платных подписок: {paid_subscriptions_count:,}'.replace(',', ' '), f'Триальных подписок: {trial_subscriptions_count:,}'.replace(',', ' '), f'Открытых тикетов: {open_tickets_count:,}'.replace(',', ' '), f'{remnawave_icon} Remnawave: {remnawave_status}', ] system_info = '\n'.join(system_info_lines) timestamp = format_local_datetime(datetime.now(UTC), DATETIME_FORMAT) message = ( f'Remnawave Bedolaga Bot\n\n' f'✅ Бот успешно запущен\n\n' f'
{system_info}\n\n' f'{timestamp}' ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( text='Поставить звезду', url=GITHUB_BOT_URL, ), ], [ InlineKeyboardButton( text='Вебкабинет', url=GITHUB_CABINET_URL, ), ], [ InlineKeyboardButton( text='Сообщество', url=COMMUNITY_URL, ), ], ] ) message_kwargs: dict = { 'chat_id': self.chat_id, 'text': message, 'parse_mode': ParseMode.HTML, 'reply_markup': keyboard, 'disable_web_page_preview': True, } if self.topic_id: message_kwargs['message_thread_id'] = self.topic_id await self.bot.send_message(**message_kwargs) logger.info('Стартовое уведомление отправлено в чат', chat_id=self.chat_id) return True except Exception as e: logger.error('Ошибка отправки стартового уведомления', e=e) return False async def send_bot_startup_notification(bot: Bot) -> bool: """ Удобная функция для отправки стартового уведомления. Args: bot: Экземпляр бота aiogram Returns: bool: True если уведомление отправлено успешно """ service = StartupNotificationService(bot) 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) + '' # Ошибки inline-кнопок с URL (WebApp, кастомные протоколы) if any(keyword in error_lower for keyword in INLINE_BUTTON_URL_ERROR_KEYWORDS): tips = [ '• Проверьте MINIAPP_CUSTOM_URL в .env', '• Проверьте HAPP_CRYPTOLINK_REDIRECT_TEMPLATE', '• Telegram не поддерживает кастомные схемы (happ://, v2ray://, ss://, и т.д.) в inline-кнопках', '• Используйте HTTPS редирект для диплинков', ] return '
💡 Рекомендации:\n' + '\n'.join(tips) + '' return None async def send_crash_notification(bot: Bot, error: Exception, traceback_str: str) -> bool: """ Отправляет уведомление о падении бота с лог-файлом. Args: bot: Экземпляр бота aiogram error: Исключение, вызвавшее падение traceback_str: Строка с полным traceback Returns: bool: True если уведомление отправлено успешно """ chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None) topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None) enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) if not enabled or not chat_id: logger.debug('Уведомление о падении отключено или chat_id не задан') return False try: timestamp = format_local_datetime(datetime.now(UTC), DATETIME_FORMAT) error_type = type(error).__name__ error_message = str(error)[:CRASH_ERROR_MESSAGE_MAX_LENGTH] separator = '=' * REPORT_SEPARATOR_WIDTH # Формируем содержимое лог-файла log_content = ( f'CRASH REPORT\n' f'{separator}\n\n' f'Timestamp: {timestamp}\n' f'Error Type: {error_type}\n' f'Error Message: {error_message}\n\n' f'{separator}\n' f'TRACEBACK\n' f'{separator}\n\n' f'{traceback_str}\n' ) # Создаем файл для отправки file_name = f'crash_report_{datetime.now(UTC).strftime(DATETIME_FORMAT_FILENAME)}.txt' file = BufferedInputFile( file=log_content.encode('utf-8'), filename=file_name, ) # Текст сообщения (escape HTML в error_type/message — они могут содержать
{html.escape(error_type)}\n'
f'Сообщение: {html.escape(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=DEVELOPER_CONTACT_URL,
),
],
]
)
message_kwargs: dict = {
'chat_id': chat_id,
'document': file,
'caption': message_text,
'parse_mode': ParseMode.HTML,
'reply_markup': keyboard,
}
if topic_id:
message_kwargs['message_thread_id'] = topic_id
await bot.send_document(**message_kwargs)
logger.info('Уведомление о падении отправлено в чат', chat_id=chat_id)
return True
except Exception as e:
logger.error('Ошибка отправки уведомления о падении', e=e)
return False