Files
remnawave-bedolaga-telegram…/app/services/startup_notification_service.py
2026-02-02 01:05:30 +03:00

322 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Сервис стартового уведомления бота.
Отправляет красивое сообщение с информацией о системе при запуске бота.
"""
import logging
import os
from datetime import datetime
from aiogram import Bot
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 = logging.getLogger(__name__)
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:
"""Получает версию из переменной окружения VERSION."""
version = os.getenv('VERSION', '').strip()
if version:
return version
return 'dev'
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(f'Ошибка получения количества пользователей: {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(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.
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 'api_key').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(f'Ошибка проверки соединения с Remnawave: {e}')
return False, 'Ошибка подключения'
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'
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.utcnow(), '%d.%m.%Y %H:%M:%S')
message = (
f'<b>Remnawave Bedolaga Bot</b>\n\n'
f'✅ Бот успешно запущен\n\n'
f'<blockquote expandable>{system_info}</blockquote>\n\n'
f'<i>{timestamp}</i>'
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text='Поставить звезду',
url='https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot',
),
],
[
InlineKeyboardButton(
text='Вебкабинет',
url='https://github.com/BEDOLAGA-DEV/bedolaga-cabinet',
),
],
[
InlineKeyboardButton(
text='Сообщество',
url='https://t.me/+wTdMtSWq8YdmZmVi',
),
],
]
)
message_kwargs: dict = {
'chat_id': self.chat_id,
'text': message,
'parse_mode': '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(f'Стартовое уведомление отправлено в чат {self.chat_id}')
return True
except Exception as e:
logger.error(f'Ошибка отправки стартового уведомления: {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()
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'<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>'
)
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