Files
remnawave-bedolaga-telegram…/app/services/startup_notification_service.py
2026-02-02 02:20:41 +03:00

453 lines
19 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 typing import Final
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 = 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')
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:
"""Получает версию из переменной окружения VERSION."""
version = os.getenv(VERSION_ENV_VAR, '').strip()
if version:
return version
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('Ошибка получения количества пользователей: %s', 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('Ошибка получения суммы балансов: %s', 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('Ошибка получения количества открытых тикетов: %s', 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('Ошибка получения количества платных подписок: %s', 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('Ошибка получения количества триальных подписок: %s', 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: %s', 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.utcnow(), DATETIME_FORMAT)
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=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('Стартовое уведомление отправлено в чат %s', self.chat_id)
return True
except Exception as e:
logger.error('Ошибка отправки стартового уведомления: %s', 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 '<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>'
# Ошибки 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 '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
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.utcnow(), 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.utcnow().strftime(DATETIME_FORMAT_FILENAME)}.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[: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=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('Уведомление о падении отправлено в чат %s', chat_id)
return True
except Exception as e:
logger.error('Ошибка отправки уведомления о падении: %s', e)
return False