From 6c4c39b454fe8680bfc20e67e2f09cdf23723ce7 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 30 Aug 2025 14:35:38 +0300 Subject: [PATCH] Add files via upload --- app/bot.py | 51 ++++- app/config.py | 17 ++ app/handlers/admin/maintenance.py | 257 ++++++++++++++++++++++++ app/keyboards/admin.py | 26 ++- app/localization/texts.py | 21 ++ app/middlewares/maintenance.py | 52 +++++ app/services/maintenance_service.py | 290 ++++++++++++++++++++++++++++ main.py | 106 ++++++++-- requirements.txt | 3 + 9 files changed, 801 insertions(+), 22 deletions(-) create mode 100644 app/handlers/admin/maintenance.py create mode 100644 app/middlewares/maintenance.py create mode 100644 app/services/maintenance_service.py diff --git a/app/bot.py b/app/bot.py index aab1d5f3..99a95f04 100644 --- a/app/bot.py +++ b/app/bot.py @@ -9,6 +9,8 @@ from app.middlewares.auth import AuthMiddleware from app.middlewares.logging import LoggingMiddleware from app.middlewares.throttling import ThrottlingMiddleware from app.middlewares.subscription_checker import SubscriptionStatusMiddleware +from app.middlewares.maintenance import MaintenanceMiddleware # Новый middleware +from app.services.maintenance_service import maintenance_service # Новый сервис from app.utils.cache import cache from app.handlers import ( @@ -20,7 +22,8 @@ from app.handlers.admin import ( promocodes as admin_promocodes, messages as admin_messages, monitoring as admin_monitoring, referrals as admin_referrals, rules as admin_rules, remnawave as admin_remnawave, - statistics as admin_statistics, servers as admin_servers + statistics as admin_statistics, servers as admin_servers, + maintenance as admin_maintenance ) logger = logging.getLogger(__name__) @@ -37,9 +40,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: try: await cache.connect() - logger.info("✅ Кеш инициализирован") + logger.info("Кеш инициализирован") except Exception as e: - logger.warning(f"⚠️ Кеш не инициализирован: {e}") + logger.warning(f"Кеш не инициализирован: {e}") from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode @@ -53,24 +56,32 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: redis_client = redis.from_url(settings.REDIS_URL) await redis_client.ping() storage = RedisStorage(redis_client) - logger.info("✅ Подключено к Redis для FSM storage") + logger.info("Подключено к Redis для FSM storage") except Exception as e: - logger.warning(f"⚠️ Не удалось подключиться к Redis: {e}") - logger.info("🔄 Используется MemoryStorage для FSM") + logger.warning(f"Не удалось подключиться к Redis: {e}") + logger.info("Используется MemoryStorage для FSM") storage = MemoryStorage() dp = Dispatcher(storage=storage) + # Порядок middleware важен! dp.message.middleware(LoggingMiddleware()) dp.callback_query.middleware(LoggingMiddleware()) + + # AuthMiddleware должен быть раньше MaintenanceMiddleware dp.message.middleware(AuthMiddleware()) dp.callback_query.middleware(AuthMiddleware()) + + # MaintenanceMiddleware проверяет режим техработ после авторизации + dp.message.middleware(MaintenanceMiddleware()) + dp.callback_query.middleware(MaintenanceMiddleware()) + dp.message.middleware(ThrottlingMiddleware()) dp.callback_query.middleware(ThrottlingMiddleware()) dp.message.middleware(SubscriptionStatusMiddleware()) dp.callback_query.middleware(SubscriptionStatusMiddleware()) - + # Регистрация обработчиков start.register_handlers(dp) menu.register_handlers(dp) subscription.register_handlers(dp) @@ -79,6 +90,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: referral.register_handlers(dp) support.register_handlers(dp) + # Админские обработчики admin_main.register_handlers(dp) admin_users.register_handlers(dp) admin_subscriptions.register_handlers(dp) @@ -90,9 +102,32 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_rules.register_handlers(dp) admin_remnawave.register_handlers(dp) admin_statistics.register_handlers(dp) + admin_maintenance.register_handlers(dp) # Новые обработчики техработ common.register_handlers(dp) - logger.info("✅ Бот успешно настроен") + # Запуск мониторинга техработ + try: + await maintenance_service.start_monitoring() + logger.info("Мониторинг техработ запущен") + except Exception as e: + logger.error(f"Ошибка запуска мониторинга техработ: {e}") + + logger.info("Бот успешно настроен") return bot, dp + + +async def shutdown_bot(): + """Корректное завершение работы бота""" + try: + await maintenance_service.stop_monitoring() + logger.info("Мониторинг техработ остановлен") + except Exception as e: + logger.error(f"Ошибка остановки мониторинга: {e}") + + try: + await cache.close() + logger.info("Соединения с кешем закрыты") + except Exception as e: + logger.error(f"Ошибка закрытия кеша: {e}") diff --git a/app/config.py b/app/config.py index 1208ed9c..34e9e836 100644 --- a/app/config.py +++ b/app/config.py @@ -65,6 +65,11 @@ class Settings(BaseSettings): MONITORING_INTERVAL: int = 60 INACTIVE_USER_DELETE_MONTHS: int = 3 + + MAINTENANCE_MODE: bool = False + MAINTENANCE_CHECK_INTERVAL: int = 30 + MAINTENANCE_AUTO_ENABLE: bool = True + MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже." TELEGRAM_STARS_ENABLED: bool = True @@ -199,6 +204,18 @@ class Settings(BaseSettings): elif self.WEBHOOK_URL: return f"{self.WEBHOOK_URL}/payment-success" return "https://t.me/" + + def is_maintenance_mode(self) -> bool: + return self.MAINTENANCE_MODE + + def get_maintenance_message(self) -> str: + return self.MAINTENANCE_MESSAGE + + def get_maintenance_check_interval(self) -> int: + return self.MAINTENANCE_CHECK_INTERVAL + + def is_maintenance_auto_enable(self) -> bool: + return self.MAINTENANCE_AUTO_ENABLE model_config = { "env_file": ".env", diff --git a/app/handlers/admin/maintenance.py b/app/handlers/admin/maintenance.py new file mode 100644 index 00000000..07cfd2c2 --- /dev/null +++ b/app/handlers/admin/maintenance.py @@ -0,0 +1,257 @@ +import logging +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.services.maintenance_service import maintenance_service +from app.keyboards.admin import get_maintenance_keyboard, get_admin_main_keyboard +from app.localization.texts import get_texts +from app.utils.decorators import admin_required, error_handler + +logger = logging.getLogger(__name__) + + +class MaintenanceStates(StatesGroup): + waiting_for_reason = State() + + +@admin_required +@error_handler +async def show_maintenance_panel( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + """Показывает панель управления техработами""" + texts = get_texts(db_user.language) + + status_info = maintenance_service.get_status_info() + + # Формируем информацию о статусе + status_emoji = "🔧" if status_info["is_active"] else "✅" + status_text = "Включен" if status_info["is_active"] else "Выключен" + + api_emoji = "✅" if status_info["api_status"] else "❌" + api_text = "Доступно" if status_info["api_status"] else "Недоступно" + + monitoring_emoji = "🔄" if status_info["monitoring_active"] else "⏹️" + monitoring_text = "Запущен" if status_info["monitoring_active"] else "Остановлен" + + # Информация о включении + enabled_info = "" + if status_info["is_active"] and status_info["enabled_at"]: + enabled_time = status_info["enabled_at"].strftime("%d.%m.%Y %H:%M:%S") + enabled_info = f"\n📅 Включен: {enabled_time}" + if status_info["reason"]: + enabled_info += f"\n📝 Причина: {status_info['reason']}" + + # Информация о последней проверке + last_check_info = "" + if status_info["last_check"]: + last_check_time = status_info["last_check"].strftime("%H:%M:%S") + last_check_info = f"\n🕐 Последняя проверка: {last_check_time}" + + # Информация о неудачных попытках + failures_info = "" + if status_info["consecutive_failures"] > 0: + failures_info = f"\n⚠️ Неудачных проверок подряд: {status_info['consecutive_failures']}" + + message_text = f""" +🔧 Режим технических работ + +{status_emoji} Статус: {status_text} +{api_emoji} API RemnaWave: {api_text} +{monitoring_emoji} Мониторинг: {monitoring_text} +⏱️ Интервал проверки: {status_info['check_interval']}с +🤖 Автовключение: {'Включено' if status_info['auto_enable_configured'] else 'Отключено'} +{enabled_info} +{last_check_info} +{failures_info} + +ℹ️ В режиме техработ обычные пользователи не могут использовать бота. Администраторы имеют полный доступ. +""" + + await callback.message.edit_text( + message_text, + reply_markup=get_maintenance_keyboard(db_user.language, status_info["is_active"], status_info["monitoring_active"]) + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_maintenance_mode( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + """Переключает режим техработ""" + is_active = maintenance_service.is_maintenance_active() + + if is_active: + # Выключаем техработы + success = await maintenance_service.disable_maintenance() + if success: + await callback.answer("Режим техработ выключен", show_alert=True) + else: + await callback.answer("Ошибка выключения режима техработ", show_alert=True) + else: + # Включаем техработы - спрашиваем причину + await state.set_state(MaintenanceStates.waiting_for_reason) + await callback.message.edit_text( + "🔧 Включение режима техработ\n\nВведите причину включения техработ или отправьте /skip для пропуска:", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")] + ]) + ) + + await callback.answer() + + +@admin_required +@error_handler +async def process_maintenance_reason( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext +): + """Обрабатывает ввод причины техработ""" + current_state = await state.get_state() + + if current_state != MaintenanceStates.waiting_for_reason: + return + + reason = None + if message.text and message.text != "/skip": + reason = message.text[:200] # Ограничиваем длину + + success = await maintenance_service.enable_maintenance(reason=reason, auto=False) + + if success: + response_text = "Режим техработ включен" + if reason: + response_text += f"\nПричина: {reason}" + else: + response_text = "Ошибка включения режима техработ" + + await message.answer(response_text) + await state.clear() + + # Показываем обновленную панель + status_info = maintenance_service.get_status_info() + await message.answer( + "Вернуться к панели управления техработами:", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔧 Панель техработ", callback_data="maintenance_panel")] + ]) + ) + + +@admin_required +@error_handler +async def toggle_monitoring( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Переключает мониторинг API""" + status_info = maintenance_service.get_status_info() + + if status_info["monitoring_active"]: + success = await maintenance_service.stop_monitoring() + message = "Мониторинг остановлен" if success else "Ошибка остановки мониторинга" + else: + success = await maintenance_service.start_monitoring() + message = "Мониторинг запущен" if success else "Ошибка запуска мониторинга" + + await callback.answer(message, show_alert=True) + + # Обновляем панель + await show_maintenance_panel(callback, db_user, db, None) + + +@admin_required +@error_handler +async def force_api_check( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Принудительная проверка API""" + await callback.answer("Проверка API...", show_alert=False) + + check_result = await maintenance_service.force_api_check() + + if check_result["success"]: + status_text = "доступно" if check_result["api_available"] else "недоступно" + message = f"API {status_text}\nВремя ответа: {check_result['response_time']}с" + else: + message = f"Ошибка проверки: {check_result.get('error', 'Неизвестная ошибка')}" + + await callback.message.answer(message) + + # Обновляем панель + await show_maintenance_panel(callback, db_user, db, None) + + +@admin_required +@error_handler +async def back_to_admin_panel( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Возвращение в главную админку""" + texts = get_texts(db_user.language) + + await callback.message.edit_text( + texts.ADMIN_PANEL, + reply_markup=get_admin_main_keyboard(db_user.language) + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + """Регистрирует обработчики техработ""" + + # Панель управления техработами + dp.callback_query.register( + show_maintenance_panel, + F.data == "maintenance_panel" + ) + + # Переключение режима техработ + dp.callback_query.register( + toggle_maintenance_mode, + F.data == "maintenance_toggle" + ) + + # Переключение мониторинга + dp.callback_query.register( + toggle_monitoring, + F.data == "maintenance_monitoring" + ) + + # Принудительная проверка API + dp.callback_query.register( + force_api_check, + F.data == "maintenance_check_api" + ) + + # Возврат в админку + dp.callback_query.register( + back_to_admin_panel, + F.data == "admin_panel" + ) + + # Обработка ввода причины техработ + dp.message.register( + process_maintenance_reason, + MaintenanceStates.waiting_for_reason + ) \ No newline at end of file diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index da58e068..919a18eb 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -25,7 +25,8 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave") ], [ - InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics") + InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics"), + InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel") ], [ InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") @@ -575,3 +576,26 @@ def get_admin_pagination_keyboard( ]) return InlineKeyboardMarkup(inline_keyboard=keyboard) + +def get_maintenance_keyboard(language: str = "ru", is_active: bool = False, monitoring_active: bool = False) -> InlineKeyboardMarkup: + """Клавиатура для управления техработами""" + + if language == "en": + toggle_text = "🔴 Disable maintenance" if is_active else "🔧 Enable maintenance" + monitoring_text = "⏹️ Stop monitoring" if monitoring_active else "🔄 Start monitoring" + check_api_text = "🔍 Check API" + back_text = "⬅️ Back to admin" + else: + toggle_text = "🔴 Выключить техработы" if is_active else "🔧 Включить техработы" + monitoring_text = "⏹️ Остановить мониторинг" if monitoring_active else "🔄 Запустить мониторинг" + check_api_text = "🔍 Проверить API" + back_text = "⬅️ Назад в админку" + + keyboard = [ + [InlineKeyboardButton(text=toggle_text, callback_data="maintenance_toggle")], + [InlineKeyboardButton(text=monitoring_text, callback_data="maintenance_monitoring")], + [InlineKeyboardButton(text=check_api_text, callback_data="maintenance_check_api")], + [InlineKeyboardButton(text=back_text, callback_data="admin_panel")] + ] + + return InlineKeyboardMarkup(inline_keyboard=keyboard) diff --git a/app/localization/texts.py b/app/localization/texts.py index 4cde46c9..9a540250 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -280,6 +280,27 @@ class RussianTexts(Texts): • Поддержка до 3 устройств ⚡️ Успейте оформить до окончания тестового периода! +""" + + MAINTENANCE_MODE_ACTIVE = """ +🔧 Технические работы! + +Сервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания. + +⏰ Ориентировочное время завершения: неизвестно +🔄 Попробуйте позже + +Приносим извинения за временные неудобства. +""" + + MAINTENANCE_MODE_API_ERROR = """ +🔧 Технические работы! + +Сервис временно недоступен из-за проблем с подключением к серверам. + +⏰ Мы работаем над восстановлением. Попробуйте через несколько минут. + +🔄 Последняя проверка: {last_check} """ SUBSCRIPTION_EXPIRING_PAID = """ diff --git a/app/middlewares/maintenance.py b/app/middlewares/maintenance.py new file mode 100644 index 00000000..75c3ec70 --- /dev/null +++ b/app/middlewares/maintenance.py @@ -0,0 +1,52 @@ +import logging +from typing import Callable, Dict, Any, Awaitable +from aiogram import BaseMiddleware +from aiogram.types import Message, CallbackQuery, TelegramObject, User as TgUser + +from app.config import settings +from app.services.maintenance_service import maintenance_service + +logger = logging.getLogger(__name__) + + +class MaintenanceMiddleware(BaseMiddleware): + """ + Middleware для блокировки пользователей во время техработ. + Админы могут использовать бота в любое время. + """ + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + + user: TgUser = None + if isinstance(event, (Message, CallbackQuery)): + user = event.from_user + + if not user or user.is_bot: + return await handler(event, data) + + # Проверяем, включен ли режим техработ + if not maintenance_service.is_maintenance_active(): + return await handler(event, data) + + # Админы могут работать с ботом всегда + if settings.is_admin(user.id): + return await handler(event, data) + + # Блокируем обычных пользователей + maintenance_message = maintenance_service.get_maintenance_message() + + try: + if isinstance(event, Message): + await event.answer(maintenance_message, parse_mode="HTML") + elif isinstance(event, CallbackQuery): + await event.answer(maintenance_message, show_alert=True) + except Exception as e: + logger.error(f"Ошибка отправки сообщения о техработах пользователю {user.id}: {e}") + + logger.info(f"🔧 Пользователь {user.id} заблокирован во время техработ") + return # Прерываем выполнение хендлера \ No newline at end of file diff --git a/app/services/maintenance_service.py b/app/services/maintenance_service.py new file mode 100644 index 00000000..31dab4f2 --- /dev/null +++ b/app/services/maintenance_service.py @@ -0,0 +1,290 @@ +import asyncio +import logging +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from dataclasses import dataclass + +from app.config import settings +from app.external.remnawave_api import RemnaWaveAPI, test_api_connection +from app.utils.cache import cache + +logger = logging.getLogger(__name__) + + +@dataclass +class MaintenanceStatus: + is_active: bool + enabled_at: Optional[datetime] = None + last_check: Optional[datetime] = None + reason: Optional[str] = None + auto_enabled: bool = False + api_status: bool = True + consecutive_failures: int = 0 + + +class MaintenanceService: + """ + Сервис для управления режимом технических работ + """ + + def __init__(self): + self._status = MaintenanceStatus(is_active=False) + self._check_task: Optional[asyncio.Task] = None + self._is_checking = False + self._max_consecutive_failures = 3 # После 3 неудачных проверок включаем техработы + + @property + def status(self) -> MaintenanceStatus: + return self._status + + def is_maintenance_active(self) -> bool: + """Проверяет, активен ли режим техработ""" + return self._status.is_active + + def get_maintenance_message(self) -> str: + """Получает сообщение о техработах""" + if self._status.auto_enabled: + return f""" +🔧 Технические работы + +Сервис временно недоступен из-за проблем с подключением к серверам. + +⏰ Мы работаем над восстановлением. Попробуйте через несколько минут. + +🔄 Последняя проверка: {self._status.last_check.strftime('%H:%M:%S') if self._status.last_check else 'неизвестно'} +""" + else: + return settings.get_maintenance_message() + + async def enable_maintenance(self, reason: Optional[str] = None, auto: bool = False) -> bool: + """Включает режим техработ""" + try: + if self._status.is_active: + logger.warning("Режим техработ уже включен") + return True + + self._status.is_active = True + self._status.enabled_at = datetime.utcnow() + self._status.reason = reason or ("Автоматическое включение" if auto else "Включено администратором") + self._status.auto_enabled = auto + + # Сохраняем состояние в кеше + await self._save_status_to_cache() + + logger.warning(f"🔧 Режим техработ ВКЛЮЧЕН. Причина: {self._status.reason}") + return True + + except Exception as e: + logger.error(f"Ошибка включения режима техработ: {e}") + return False + + async def disable_maintenance(self) -> bool: + """Выключает режим техработ""" + try: + if not self._status.is_active: + logger.info("Режим техработ уже выключен") + return True + + self._status.is_active = False + self._status.enabled_at = None + self._status.reason = None + self._status.auto_enabled = False + self._status.consecutive_failures = 0 + + # Сохраняем состояние в кеше + await self._save_status_to_cache() + + logger.info("✅ Режим техработ ВЫКЛЮЧЕН") + return True + + except Exception as e: + logger.error(f"Ошибка выключения режима техработ: {e}") + return False + + async def start_monitoring(self) -> bool: + """Запускает мониторинг API RemnaWave""" + try: + if self._check_task and not self._check_task.done(): + logger.warning("Мониторинг уже запущен") + return True + + # Загружаем состояние из кеша + await self._load_status_from_cache() + + self._check_task = asyncio.create_task(self._monitoring_loop()) + logger.info(f"🔄 Запущен мониторинг API RemnaWave (интервал: {settings.get_maintenance_check_interval()}с)") + return True + + except Exception as e: + logger.error(f"Ошибка запуска мониторинга: {e}") + return False + + async def stop_monitoring(self) -> bool: + """Останавливает мониторинг API""" + try: + if self._check_task and not self._check_task.done(): + self._check_task.cancel() + try: + await self._check_task + except asyncio.CancelledError: + pass + + logger.info("⏹️ Мониторинг API остановлен") + return True + + except Exception as e: + logger.error(f"Ошибка остановки мониторинга: {e}") + return False + + async def check_api_status(self) -> bool: + """Проверяет доступность API RemnaWave""" + try: + if self._is_checking: + return self._status.api_status + + self._is_checking = True + self._status.last_check = datetime.utcnow() + + api = RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) + + async with api: + # Проверяем подключение к API + is_connected = await test_api_connection(api) + + if is_connected: + self._status.api_status = True + self._status.consecutive_failures = 0 + + # Если техработы были включены автоматически и API восстановился + if self._status.is_active and self._status.auto_enabled: + await self.disable_maintenance() + logger.info("✅ API восстановился, режим техработ автоматически отключен") + + return True + else: + self._status.api_status = False + self._status.consecutive_failures += 1 + + # Включаем техработы автоматически при множественных сбоях + if (self._status.consecutive_failures >= self._max_consecutive_failures and + not self._status.is_active and + settings.is_maintenance_auto_enable()): + + await self.enable_maintenance( + reason=f"Автоматическое включение после {self._status.consecutive_failures} неудачных проверок API", + auto=True + ) + + return False + + except Exception as e: + logger.error(f"Ошибка проверки API: {e}") + self._status.api_status = False + self._status.consecutive_failures += 1 + return False + finally: + self._is_checking = False + await self._save_status_to_cache() + + async def _monitoring_loop(self): + """Основной цикл мониторинга""" + while True: + try: + await self.check_api_status() + await asyncio.sleep(settings.get_maintenance_check_interval()) + + except asyncio.CancelledError: + logger.info("Мониторинг отменен") + break + except Exception as e: + logger.error(f"Ошибка в цикле мониторинга: {e}") + await asyncio.sleep(30) # Ждем полминуты при ошибке + + async def _save_status_to_cache(self): + """Сохраняет состояние в кеше""" + try: + status_data = { + "is_active": self._status.is_active, + "enabled_at": self._status.enabled_at.isoformat() if self._status.enabled_at else None, + "reason": self._status.reason, + "auto_enabled": self._status.auto_enabled, + "consecutive_failures": self._status.consecutive_failures, + "last_check": self._status.last_check.isoformat() if self._status.last_check else None + } + + await cache.set("maintenance_status", status_data, expire=3600) + + except Exception as e: + logger.error(f"Ошибка сохранения состояния в кеш: {e}") + + async def _load_status_from_cache(self): + """Загружает состояние из кеша""" + try: + status_data = await cache.get("maintenance_status") + if not status_data: + return + + self._status.is_active = status_data.get("is_active", False) + self._status.reason = status_data.get("reason") + self._status.auto_enabled = status_data.get("auto_enabled", False) + self._status.consecutive_failures = status_data.get("consecutive_failures", 0) + + if status_data.get("enabled_at"): + self._status.enabled_at = datetime.fromisoformat(status_data["enabled_at"]) + + if status_data.get("last_check"): + self._status.last_check = datetime.fromisoformat(status_data["last_check"]) + + logger.info(f"📥 Состояние техработ загружено из кеша: активен={self._status.is_active}") + + except Exception as e: + logger.error(f"Ошибка загрузки состояния из кеша: {e}") + + def get_status_info(self) -> Dict[str, Any]: + """Возвращает информацию о статусе техработ для админки""" + return { + "is_active": self._status.is_active, + "enabled_at": self._status.enabled_at, + "last_check": self._status.last_check, + "reason": self._status.reason, + "auto_enabled": self._status.auto_enabled, + "api_status": self._status.api_status, + "consecutive_failures": self._status.consecutive_failures, + "monitoring_active": self._check_task is not None and not self._check_task.done(), + "auto_enable_configured": settings.is_maintenance_auto_enable(), + "check_interval": settings.get_maintenance_check_interval() + } + + async def force_api_check(self) -> Dict[str, Any]: + """Принудительная проверка API для админки""" + start_time = datetime.utcnow() + + try: + api_status = await self.check_api_status() + end_time = datetime.utcnow() + response_time = (end_time - start_time).total_seconds() + + return { + "success": True, + "api_available": api_status, + "response_time": round(response_time, 2), + "checked_at": end_time, + "consecutive_failures": self._status.consecutive_failures + } + + except Exception as e: + end_time = datetime.utcnow() + response_time = (end_time - start_time).total_seconds() + + return { + "success": False, + "api_available": False, + "error": str(e), + "response_time": round(response_time, 2), + "checked_at": end_time, + "consecutive_failures": self._status.consecutive_failures + } + + +# Глобальный экземпляр сервиса +maintenance_service = MaintenanceService() \ No newline at end of file diff --git a/main.py b/main.py index 25588950..4bf4ecf8 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ import asyncio import logging import sys import os +import signal from pathlib import Path sys.path.append(str(Path(__file__).parent)) @@ -10,10 +11,22 @@ from app.bot import setup_bot from app.config import settings from app.database.database import init_db from app.services.monitoring_service import monitoring_service +from app.services.maintenance_service import maintenance_service from app.external.webhook_server import WebhookServer from app.database.universal_migration import run_universal_migration +class GracefulExit: + """Класс для корректного завершения работы бота""" + + def __init__(self): + self.exit = False + + def exit_gracefully(self, signum, frame): + logging.getLogger(__name__).info(f"Получен сигнал {signum}. Корректное завершение работы...") + self.exit = True + + async def main(): logging.basicConfig( level=getattr(logging, settings.LOG_LEVEL), @@ -27,7 +40,15 @@ async def main(): logger = logging.getLogger(__name__) logger.info("🚀 Запуск Bedolaga Remnawave Bot...") + # Настройка обработчика сигналов + killer = GracefulExit() + signal.signal(signal.SIGINT, killer.exit_gracefully) + signal.signal(signal.SIGTERM, killer.exit_gracefully) + webhook_server = None + monitoring_task = None + maintenance_task = None + polling_task = None try: logger.info("📊 Инициализация базы данных...") @@ -54,6 +75,7 @@ async def main(): logger.info("🤖 Настройка бота...") bot, dp = await setup_bot() + # Устанавливаем ссылку на бота в сервисы monitoring_service.bot = bot if settings.TRIBUTE_ENABLED: @@ -66,28 +88,86 @@ async def main(): logger.info("🔍 Запуск службы мониторинга...") monitoring_task = asyncio.create_task(monitoring_service.start_monitoring()) - logger.info("🔄 Запуск polling...") + logger.info("🔧 Запуск службы техработ...") + maintenance_task = asyncio.create_task(maintenance_service.start_monitoring()) + logger.info("🔄 Запуск polling...") + polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True)) + + # Ожидание сигнала завершения или исключения try: - await asyncio.gather( - dp.start_polling(bot), - monitoring_task - ) + while not killer.exit: + await asyncio.sleep(1) + + # Проверяем, не завершились ли задачи с ошибкой + if monitoring_task.done(): + exception = monitoring_task.exception() + if exception: + logger.error(f"Служба мониторинга завершилась с ошибкой: {exception}") + monitoring_task = asyncio.create_task(monitoring_service.start_monitoring()) + + if maintenance_task.done(): + exception = maintenance_task.exception() + if exception: + logger.error(f"Служба техработ завершилась с ошибкой: {exception}") + maintenance_task = asyncio.create_task(maintenance_service.start_monitoring()) + + if polling_task.done(): + exception = polling_task.exception() + if exception: + logger.error(f"Polling завершился с ошибкой: {exception}") + break + except Exception as e: logger.error(f"Ошибка в основном цикле: {e}") - monitoring_service.stop_monitoring() - if webhook_server: - await webhook_server.stop() - raise - + except Exception as e: logger.error(f"❌ Критическая ошибка при запуске: {e}") raise + finally: - logger.info("🛑 Завершение работы бота") - monitoring_service.stop_monitoring() + logger.info("🛑 Начинается корректное завершение работы...") + + # Останавливаем службы + if monitoring_task and not monitoring_task.done(): + logger.info("⏹️ Остановка службы мониторинга...") + monitoring_service.stop_monitoring() + monitoring_task.cancel() + try: + await monitoring_task + except asyncio.CancelledError: + pass + + if maintenance_task and not maintenance_task.done(): + logger.info("⏹️ Остановка службы техработ...") + await maintenance_service.stop_monitoring() + maintenance_task.cancel() + try: + await maintenance_task + except asyncio.CancelledError: + pass + + if polling_task and not polling_task.done(): + logger.info("⏹️ Остановка polling...") + polling_task.cancel() + try: + await polling_task + except asyncio.CancelledError: + pass + if webhook_server: + logger.info("⏹️ Остановка webhook сервера...") await webhook_server.stop() + + # Закрываем сессию бота + if 'bot' in locals(): + try: + await bot.session.close() + logger.info("✅ Сессия бота закрыта") + except Exception as e: + logger.error(f"Ошибка закрытия сессии бота: {e}") + + logger.info("✅ Завершение работы бота завершено") if __name__ == "__main__": @@ -97,4 +177,4 @@ if __name__ == "__main__": print("\n🛑 Бот остановлен пользователем") except Exception as e: print(f"❌ Критическая ошибка: {e}") - sys.exit(1) + sys.exit(1) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 8992d261..597778a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,6 +18,9 @@ yookassa==3.0.0 # Логирование и мониторинг structlog==23.2.0 +# Планировщик задач для техработ +APScheduler==3.10.4 + # Утилиты python-dateutil==2.8.2 pytz==2023.4