diff --git a/app/services/startup_notification_service.py b/app/services/startup_notification_service.py index ce33e942..7d7d310d 100644 --- a/app/services/startup_notification_service.py +++ b/app/services/startup_notification_service.py @@ -1,6 +1,7 @@ """ Сервис стартового уведомления бота. +Отправляет красивое сообщение с информацией о системе при запуске бота. """ import logging @@ -8,12 +9,12 @@ import os from datetime import datetime from aiogram import Bot -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +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 User, UserStatus +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 @@ -61,9 +62,44 @@ class StartupNotificationService: logger.error(f'Ошибка получения суммы балансов: {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(f'Ошибка получения количества открытых тикетов: {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(f'Ошибка получения количества платных подписок: {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(f'Ошибка получения количества триальных подписок: {e}') + return 0 + async def _check_remnawave_connection(self) -> tuple[bool, str]: """ - Проверяет соединение с панелью RemnaWave. + Проверяет соединение с панелью Remnawave. Returns: Tuple[bool, str]: (is_connected, status_message) @@ -99,7 +135,7 @@ class StartupNotificationService: return False, 'Недоступна' except Exception as e: - logger.error(f'Ошибка проверки соединения с RemnaWave: {e}') + logger.error(f'Ошибка проверки соединения с Remnawave: {e}') return False, 'Ошибка подключения' def _format_balance(self, kopeks: int) -> str: @@ -126,9 +162,12 @@ class StartupNotificationService: 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 remnawave_icon = '🟢' if remnawave_connected else '🔴' # Формируем системную информацию для blockquote @@ -136,7 +175,10 @@ class StartupNotificationService: f'Версия: {version}', f'Пользователей: {users_count:,}'.replace(',', ' '), f'Сумма балансов: {self._format_balance(total_balance_kopeks)}', - f'{remnawave_icon} RemnaWave: {remnawave_status}', + 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) @@ -144,6 +186,7 @@ class StartupNotificationService: message = ( f'Remnawave Bedolaga Bot\n\n' + f'✅ Бот успешно запущен\n\n' f'
{system_info}
\n\n' f'{timestamp}' ) @@ -203,3 +246,76 @@ async def send_bot_startup_notification(bot: Bot) -> bool: """ service = StartupNotificationService(bot) return await service.send_startup_notification() + + +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.utcnow(), '%d.%m.%Y %H:%M:%S') + error_type = type(error).__name__ + error_message = str(error) + + # Формируем содержимое лог-файла + log_content = ( + f'CRASH REPORT\n' + f'{"=" * 50}\n\n' + f'Timestamp: {timestamp}\n' + f'Error Type: {error_type}\n' + f'Error Message: {error_message}\n\n' + f'{"=" * 50}\n' + f'TRACEBACK\n' + f'{"=" * 50}\n\n' + f'{traceback_str}\n' + ) + + # Создаем файл для отправки + file_name = f'crash_report_{datetime.utcnow().strftime("%Y%m%d_%H%M%S")}.txt' + file = BufferedInputFile( + file=log_content.encode('utf-8'), + filename=file_name, + ) + + # Текст сообщения + message_text = ( + f'Remnawave Bedolaga Bot\n\n' + f'❌ Бот упал с ошибкой\n\n' + f'Тип: {error_type}\n' + f'Сообщение: {error_message[:200]}\n\n' + f'{timestamp}' + ) + + message_kwargs: dict = { + 'chat_id': chat_id, + 'document': file, + 'caption': message_text, + 'parse_mode': 'HTML', + } + + if topic_id: + message_kwargs['message_thread_id'] = topic_id + + await bot.send_document(**message_kwargs) + logger.info(f'Уведомление о падении отправлено в чат {chat_id}') + return True + + except Exception as e: + logger.error(f'Ошибка отправки уведомления о падении: {e}') + return False diff --git a/main.py b/main.py index d1fae476..e1c1670e 100644 --- a/main.py +++ b/main.py @@ -915,6 +915,30 @@ async def main(): logger.info('✅ Завершение работы бота завершено') +async def _send_crash_notification_on_error(error: Exception) -> None: + """Отправляет уведомление о падении бота в админский чат.""" + import traceback + + from app.config import settings + + if not getattr(settings, 'BOT_TOKEN', None): + return + + try: + from aiogram import Bot + + from app.services.startup_notification_service import send_crash_notification + + bot = Bot(token=settings.BOT_TOKEN) + try: + traceback_str = traceback.format_exc() + await send_crash_notification(bot, error, traceback_str) + finally: + await bot.session.close() + except Exception as notify_error: + print(f'⚠️ Не удалось отправить уведомление о падении: {notify_error}') + + if __name__ == '__main__': try: asyncio.run(main()) @@ -922,4 +946,12 @@ if __name__ == '__main__': print('\n🛑 Бот остановлен пользователем') except Exception as e: print(f'❌ Критическая ошибка: {e}') + import traceback + + traceback.print_exc() + # Пытаемся отправить уведомление о падении + try: + asyncio.run(_send_crash_notification_on_error(e)) + except Exception: + pass sys.exit(1)