mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-28 07:11:37 +00:00
453 lines
19 KiB
Python
453 lines
19 KiB
Python
"""
|
||
Сервис стартового уведомления бота.
|
||
|
||
Отправляет красивое сообщение с информацией о системе при запуске бота.
|
||
"""
|
||
|
||
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
|