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
from app.utils.timezone import format_local_datetime
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
self._bot = None
self._last_notification_sent = None
def set_bot(self, bot):
self._bot = bot
logger.info("Бот установлен для maintenance_service")
@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:
last_check_display = format_local_datetime(
self._status.last_check, "%H:%M:%S", "неизвестно"
)
return f"""
🔧 Технические работы!
Сервис временно недоступен из-за проблем с подключением к серверам.
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
🔄 Последняя проверка: {last_check_display}
"""
else:
return settings.get_maintenance_message()
async def _send_admin_notification(self, message: str, alert_type: str = "info"):
if not self._bot:
logger.warning("Бот не установлен, уведомления не могут быть отправлены")
return False
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self._bot)
if not notification_service._is_enabled():
logger.debug("Уведомления администраторов отключены")
return False
emoji_map = {
"error": "🚨",
"warning": "⚠️",
"success": "✅",
"info": "ℹ️"
}
emoji = emoji_map.get(alert_type, "ℹ️")
timestamp = format_local_datetime(
datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z"
)
formatted_message = (
f"{emoji} ТЕХНИЧЕСКИЕ РАБОТЫ\n\n{message}\n\n⏰ {timestamp}"
)
return await notification_service._send_message(formatted_message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления через AdminNotificationService: {e}")
return False
async def _notify_admins(self, message: str, alert_type: str = "info"):
if not self._bot:
logger.warning("Бот не установлен, уведомления не могут быть отправлены")
return
notification_sent = await self._send_admin_notification(message, alert_type)
if notification_sent:
logger.info("Уведомление успешно отправлено через AdminNotificationService")
return
logger.info("Отправляем уведомление напрямую администраторам")
cache_key = f"maintenance_notification_{alert_type}"
if await cache.get(cache_key):
return
admin_ids = settings.get_admin_ids()
if not admin_ids:
logger.warning("Список администраторов пуст")
return
emoji_map = {
"error": "🚨",
"warning": "⚠️",
"success": "✅",
"info": "ℹ️"
}
emoji = emoji_map.get(alert_type, "ℹ️")
formatted_message = f"{emoji} Maintenance Service\n\n{message}"
success_count = 0
for admin_id in admin_ids:
try:
await self._bot.send_message(
chat_id=admin_id,
text=formatted_message,
parse_mode="HTML"
)
success_count += 1
await asyncio.sleep(0.1)
except Exception as e:
logger.error(f"Ошибка отправки уведомления админу {admin_id}: {e}")
if success_count > 0:
logger.info(f"Уведомление отправлено {success_count} администраторам")
await cache.set(cache_key, True, expire=300)
else:
logger.error("Не удалось отправить уведомления ни одному администратору")
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()
enabled_time = format_local_datetime(
self._status.enabled_at, "%d.%m.%Y %H:%M:%S %Z"
)
notification_msg = f"""Режим технических работ ВКЛЮЧЕН
📋 Причина: {self._status.reason}
🤖 Автоматически: {'Да' if auto else 'Нет'}
🕐 Время: {enabled_time}
Обычные пользователи временно не смогут использовать бота."""
await self._notify_admins(notification_msg, "warning" if auto else "info")
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
was_auto = self._status.auto_enabled
duration = None
if self._status.enabled_at:
duration = datetime.utcnow() - self._status.enabled_at
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()
duration_str = ""
if duration:
hours = int(duration.total_seconds() // 3600)
minutes = int((duration.total_seconds() % 3600) // 60)
if hours > 0:
duration_str = f"\n⏱️ Длительность: {hours}ч {minutes}мин"
else:
duration_str = f"\n⏱️ Длительность: {minutes}мин"
notification_time = format_local_datetime(
datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z"
)
notification_msg = f"""Режим технических работ ВЫКЛЮЧЕН
🤖 Автоматически: {'Да' if was_auto else 'Нет'}
🕐 Время: {notification_time}
{duration_str}
Сервис снова доступен для пользователей."""
await self._notify_admins(notification_msg, "success")
logger.info("✅ Режим техработ ВЫКЛЮЧЕН")
return True
except Exception as e:
logger.error(f"Ошибка выключения режима техработ: {e}")
return False
async def start_monitoring(self) -> bool:
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(
"🔄 Запущен мониторинг API Remnawave (интервал: %sс, попыток: %s)",
settings.get_maintenance_check_interval(),
settings.get_maintenance_retry_attempts(),
)
await self._notify_admins(
f"""Мониторинг технических работ запущен
🔄 Интервал проверки: {settings.get_maintenance_check_interval()} секунд
🤖 Автовключение: {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
🎯 Порог ошибок: {self._max_consecutive_failures}
🔁 Повторных попыток: {settings.get_maintenance_retry_attempts()}
Система будет следить за доступностью API.""",
"info",
)
return True
except Exception as e:
logger.error(f"Ошибка запуска мониторинга: {e}")
return False
async def stop_monitoring(self) -> bool:
try:
if self._check_task and not self._check_task.done():
self._check_task.cancel()
try:
await self._check_task
except asyncio.CancelledError:
pass
await self._notify_admins("Мониторинг технических работ остановлен", "info")
logger.info("ℹ️ Мониторинг API остановлен")
return True
except Exception as e:
logger.error(f"Ошибка остановки мониторинга: {e}")
return False
async def check_api_status(self) -> bool:
try:
if self._is_checking:
return self._status.api_status
self._is_checking = True
self._status.last_check = datetime.utcnow()
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()
secret_key = (auth_params.get("secret_key") or "").strip() or None
username = (auth_params.get("username") or "").strip() or None
password = (auth_params.get("password") or "").strip() or None
caddy_token = (auth_params.get("caddy_token") or "").strip() or None
auth_type = (auth_params.get("auth_type") or "api_key").strip()
if not base_url:
logger.error("REMNAWAVE_API_URL не настроен, пропускаем проверку API")
self._status.api_status = False
self._status.consecutive_failures = 0
return False
if not api_key:
logger.error("REMNAWAVE_API_KEY не настроен, пропускаем проверку API")
self._status.api_status = False
self._status.consecutive_failures = 0
return False
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,
)
attempts = settings.get_maintenance_retry_attempts()
async with api:
for attempt in range(1, attempts + 1):
is_connected = await test_api_connection(api)
if is_connected:
if attempt > 1:
logger.info(
"API Remnawave ответило с %s попытки", attempt
)
if not self._status.api_status:
recovery_time = format_local_datetime(
self._status.last_check, "%H:%M:%S %Z"
)
await self._notify_admins(
f"""API Remnawave восстановлено!
✅ Статус: Доступно
🕐 Время восстановления: {recovery_time}
🔄 Неудачных попыток было: {self._status.consecutive_failures}
API снова отвечает на запросы.""",
"success",
)
self._status.api_status = True
self._status.consecutive_failures = 0
if self._status.is_active and self._status.auto_enabled:
await self.disable_maintenance()
logger.info("✅ API восстановился, режим техработ автоматически отключен")
return True
if attempt < attempts:
logger.warning(
"API Remnawave недоступно (попытка %s/%s)",
attempt,
attempts,
)
await asyncio.sleep(1)
was_available = self._status.api_status
self._status.api_status = False
self._status.consecutive_failures += 1
if was_available:
detection_time = format_local_datetime(
self._status.last_check, "%H:%M:%S %Z"
)
await self._notify_admins(
f"""API Remnawave недоступно!
❌ Статус: Недоступно
🕐 Время обнаружения: {detection_time}
🔄 Попытка: {self._status.consecutive_failures}
Началась серия неудачных проверок API.""",
"error",
)
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}")
if self._status.api_status:
error_time = format_local_datetime(datetime.utcnow(), "%H:%M:%S %Z")
await self._notify_admins(
f"""Ошибка при проверке API Remnawave
❌ Ошибка: {str(e)}
🕐 Время: {error_time}
Не удалось выполнить проверку доступности API.""",
"error",
)
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(),
"monitoring_configured": settings.is_maintenance_monitoring_enabled(),
"auto_enable_configured": settings.is_maintenance_auto_enable(),
"check_interval": settings.get_maintenance_check_interval(),
"bot_connected": self._bot is not None
}
async def force_api_check(self) -> Dict[str, Any]:
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
}
async def send_remnawave_status_notification(self, status: str, details: str = "") -> bool:
try:
status_emojis = {
"online": "🟢",
"offline": "🔴",
"warning": "🟡",
"error": "⚠️"
}
emoji = status_emojis.get(status, "ℹ️")
message = f"""Статус панели Remnawave изменился
{emoji} Статус: {status.upper()}
🔗 URL: {settings.REMNAWAVE_API_URL}
{details}"""
alert_type = "error" if status in ["offline", "error"] else "info"
await self._notify_admins(message, alert_type)
logger.info(f"Отправлено уведомление о статусе Remnawave: {status}")
return True
except Exception as e:
logger.error(f"Ошибка отправки уведомления о статусе Remnawave: {e}")
return False
maintenance_service = MaintenanceService()