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