diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..e69de29
diff --git a/.history/src/api/synology_20250830104812.py b/.history/src/api/synology_20250830104812.py
new file mode 100644
index 0000000..cf9b72d
--- /dev/null
+++ b/.history/src/api/synology_20250830104812.py
@@ -0,0 +1,1873 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Модуль для взаимодействия с API Synology NAS
+"""
+
+import requests
+from requests.adapters import HTTPAdapter
+import json
+import logging
+import time
+import urllib3
+from urllib3.util import Retry
+from typing import Dict, Any, Optional, List
+import socket
+import struct
+from time import sleep
+
+from src.config.config import (
+ SYNOLOGY_HOST,
+ SYNOLOGY_PORT,
+ SYNOLOGY_USERNAME,
+ SYNOLOGY_PASSWORD,
+ SYNOLOGY_SECURE,
+ SYNOLOGY_TIMEOUT,
+ SYNOLOGY_MAC,
+ WOL_PORT,
+ SYNOLOGY_API_VERSION,
+ SYNOLOGY_POWER_API,
+ SYNOLOGY_INFO_API
+)
+from src.api.api_discovery import discover_available_apis, find_compatible_api
+
+# Отключение предупреждений о небезопасных SSL-соединениях
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+logger = logging.getLogger(__name__)
+
+class SynologyAPI:
+ """Класс для взаимодействия с API Synology NAS"""
+
+ def __init__(self):
+ """Инициализация класса SynologyAPI"""
+ logger.info("Creating API with auto-retry and connection pool")
+ logger.debug(f"Connection details: Host={SYNOLOGY_HOST}, Port={SYNOLOGY_PORT}, Secure={SYNOLOGY_SECURE}")
+
+ self.protocol = "https" if SYNOLOGY_SECURE else "http"
+ self.base_url = f"{self.protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/webapi"
+ self.sid = None
+ self.session = requests.Session()
+
+ # Настройка SSL
+ if self.protocol == "https":
+ logger.debug("SSL enabled, disabling certificate verification for internal network")
+ self.session.verify = False # Отключаем проверку SSL для внутренней сети
+
+ # Добавляем пользовательские заголовки для улучшения совместимости с API
+ custom_headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
+ 'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'Connection': 'keep-alive',
+ 'Referer': f'{self.protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/'
+ }
+ self.session.headers.update(custom_headers)
+ logger.debug("Added browser-like headers for API compatibility")
+
+ # Добавляем повторные попытки для HTTP-запросов
+ retry_strategy = Retry(
+ total=5, # Увеличиваем количество попыток
+ status_forcelist=[429, 500, 502, 503, 504, 404],
+ allowed_methods=["GET", "POST"],
+ backoff_factor=1.5, # Увеличиваем задержку между попытками
+ respect_retry_after_header=True
+ )
+ adapter = HTTPAdapter(
+ max_retries=retry_strategy,
+ pool_connections=3,
+ pool_maxsize=10
+ )
+ self.session.mount("http://", adapter)
+ self.session.mount("https://", adapter)
+
+ # Таймауты будут указаны в запросах
+ self.default_timeout = (SYNOLOGY_TIMEOUT, SYNOLOGY_TIMEOUT*2)
+ logger.debug(f"Setting default request timeout: {self.default_timeout}")
+
+ # Кэш для хранения результатов запросов
+ self._cache = {}
+ self._cache_ttl = {}
+ self._last_online_check = 0
+ self._last_online_status = False
+ self._online_check_interval = 30 # Интервал для кэширования проверок онлайн-статуса
+
+ # Время последней успешной аутентификации и срок действия сессии
+ self._last_auth_time = 0
+ self._auth_expiry = 3600 # По умолчанию 1 час
+
+ # Информация о доступных API
+ self._available_apis = {}
+ self._api_info_ttl = 0
+
+ # Инициализируем API version resolver для автоматического определения совместимых API
+ self.api_resolver = None # Будет создан при необходимости
+
+ def login(self) -> bool:
+ """Авторизация в API Synology NAS"""
+ # Сбрасываем SID для новой сессии
+ self.sid = None
+
+ logger.info("Attempting to authenticate with Synology NAS...")
+ logger.debug(f"Base URL: {self.base_url}")
+
+ # Проверяем доступность NAS перед запросом с помощью прямой TCP-проверки
+ # Избегаем вызова is_online(), чтобы не создавать рекурсию
+ online_status = self._check_tcp_connection()
+ if not online_status:
+ logger.error("Cannot login: Synology NAS is not reachable")
+ return False
+
+ # Пробуем различные версии API для аутентификации
+ # Начинаем с версии 3, которая показала лучшую совместимость в тестах
+ auth_versions_to_try = [3, 2, 1, 6] # Пробуем сначала более стабильные версии
+
+ for auth_version in auth_versions_to_try:
+ try:
+ # Определяем путь к API аутентификации
+ auth_path = "entry.cgi" # Используем entry.cgi вместо auth.cgi по умолчанию
+
+ # Проверка информации API для определения доступных версий API
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": "SYNO.API.Auth"
+ }
+
+ logger.debug(f"Querying API info for auth version {auth_version}")
+ try:
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ auth_info = api_info_data.get("data", {}).get("SYNO.API.Auth", {})
+ max_version = auth_info.get("maxVersion", 6)
+ min_version = auth_info.get("minVersion", 1)
+ auth_path = auth_info.get("path", "entry.cgi")
+ logger.debug(f"Auth API versions: min={min_version}, max={max_version}, path={auth_path}")
+
+ # Проверяем поддержку текущей версии
+ if auth_version < min_version or auth_version > max_version:
+ logger.warning(f"Auth API version {auth_version} not supported (min={min_version}, max={max_version}), trying next version")
+ continue
+ else:
+ logger.warning("Failed to query API info, using default auth path")
+ else:
+ logger.warning(f"API info request failed with status {api_info_response.status_code}, using default auth path")
+ except Exception as e:
+ logger.warning(f"Error querying API info: {str(e)}, using default auth path")
+
+ # Основной запрос авторизации
+ url = f"{self.base_url}/{auth_path}"
+ params = {
+ "api": "SYNO.API.Auth",
+ "version": str(auth_version),
+ "method": "login",
+ "account": SYNOLOGY_USERNAME,
+ "passwd": SYNOLOGY_PASSWORD,
+ "session": "SynologyPowerControlBot",
+ "format": "cookie"
+ }
+
+ # Для версии 6+ используем немного другой формат
+ if auth_version >= 6:
+ params["enable_syno_token"] = "yes"
+
+ logger.debug(f"Sending auth request to {url} with API version {auth_version}")
+ start_time = time.time()
+ response = self.session.get(
+ url,
+ params=params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+ elapsed_time = time.time() - start_time
+ logger.debug(f"Auth request completed in {elapsed_time:.2f}s with status code {response.status_code}")
+
+ # Проверка статуса HTTP
+ if response.status_code != 200:
+ logger.error(f"HTTP error: {response.status_code}")
+ logger.debug(f"Response content: {response.text[:200]}")
+ continue # Пробуем следующую версию
+
+ try:
+ data = response.json()
+ except json.JSONDecodeError:
+ logger.error("Failed to decode JSON response")
+ logger.debug(f"Response content: {response.text[:200]}")
+ continue # Пробуем следующую версию
+
+ if data.get("success"):
+ self.sid = data.get("data", {}).get("sid")
+ self._last_auth_time = time.time()
+ logger.info(f"Successfully logged in to Synology NAS using API version {auth_version}")
+ logger.debug(f"Session ID obtained: {self.sid[:5] if self.sid else 'None'}...")
+
+ # Получаем и сохраняем токен SYNO, если он есть
+ syno_token = data.get("data", {}).get("synotoken")
+ if syno_token:
+ self.session.headers.update({'X-SYNO-TOKEN': syno_token})
+ logger.debug("Added X-SYNO-TOKEN header for improved API compatibility")
+
+ # Также добавляем SID в cookies для улучшения совместимости
+ self.session.cookies.update({
+ 'id': self.sid,
+ 'sid': self.sid
+ })
+ logger.debug("Added SID to session cookies for improved compatibility")
+
+ # Проверка валидности полученной сессии с помощью простого запроса
+ # Будем использовать SYNO.API.Info без проверки сложных методов
+
+ # Даем системе немного времени для инициализации сессии
+ time.sleep(0.5)
+
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to log in with version {auth_version}: Error code {error_code}")
+
+ # Если ошибка связана с версией API, пробуем следующую версию
+ if error_code in [104, 105]:
+ logger.warning(f"Auth version {auth_version} not supported, trying next version")
+ continue
+
+ # Дополнительная диагностика
+ if error_code == 400:
+ logger.error("Authentication error: Invalid credentials")
+ elif error_code == 401:
+ logger.error("Authentication error: Account disabled")
+ elif error_code == 402:
+ logger.error("Authentication error: Permission denied")
+ elif error_code == 403:
+ logger.error("Authentication error: 2-factor authentication required")
+ elif error_code == 404:
+ logger.error("Authentication error: Failed to authenticate with 2-factor authentication")
+
+ # Если ошибка связана с неверными учетными данными, нет смысла пробовать другие версии API
+ if error_code in [400, 401, 402, 403, 404]:
+ return False
+
+ except requests.exceptions.Timeout:
+ logger.error(f"Connection timeout during auth with version {auth_version}")
+ continue # Пробуем следующую версию
+ except requests.exceptions.ConnectionError as e:
+ logger.error(f"Connection error during auth with version {auth_version}: {str(e)}")
+ continue # Пробуем следующую версию
+ except requests.RequestException as e:
+ logger.error(f"Request error during auth with version {auth_version}: {str(e)}")
+ continue # Пробуем следующую версию
+ except Exception as e:
+ logger.error(f"Unexpected error during login with version {auth_version}: {str(e)}", exc_info=True)
+ continue # Пробуем следующую версию
+
+ # Если все версии не сработали
+ logger.error("Failed to authenticate with any API version")
+ return False
+
+ def _validate_session(self) -> bool:
+ """Проверяет валидность сессии после авторизации"""
+ if not self.sid:
+ return False
+
+ # Попробуем сделать простой запрос для проверки сессии
+ test_apis = [
+ {"api": "SYNO.Core.System", "method": "info", "version": 1},
+ {"api": "SYNO.DSM.Info", "method": "getinfo", "version": 1}
+ ]
+
+ for test_api in test_apis:
+ try:
+ url = f"{self.base_url}/entry.cgi"
+ params = {
+ "api": test_api["api"],
+ "version": str(test_api["version"]),
+ "method": test_api["method"],
+ "_sid": self.sid
+ }
+
+ response = self.session.get(url, params=params, timeout=self.default_timeout, verify=False)
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("success"):
+ logger.debug(f"Session validation successful using {test_api['api']}")
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ if error_code != 119: # Не сессия истекла
+ logger.debug(f"Session validation with {test_api['api']} returned error code {error_code}")
+ return True # Считаем сессию валидной, если ошибка не связана с истечением сессии
+ except Exception as e:
+ logger.warning(f"Error during session validation with {test_api['api']}: {str(e)}")
+
+ logger.warning("Session validation failed with all test APIs")
+ return False
+
+ def logout(self) -> bool:
+ """Выход из API Synology NAS"""
+ if not self.sid:
+ return True
+
+ try:
+ url = f"{self.base_url}/auth.cgi"
+ params = {
+ "api": "SYNO.API.Auth",
+ "version": "1",
+ "method": "logout",
+ "session": "SynologyPowerControlBot",
+ "_sid": self.sid
+ }
+
+ response = self.session.get(url, params=params, timeout=SYNOLOGY_TIMEOUT, verify=False)
+ data = response.json()
+
+ if data.get("success"):
+ self.sid = None
+ logger.info("Successfully logged out from Synology NAS")
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to log out from Synology NAS: Error code {error_code}")
+ return False
+
+ except requests.RequestException as e:
+ logger.error(f"Connection error: {str(e)}")
+ return False
+
+ def _make_api_request(self, api_name: str, method: str, version: int = 1,
+ params: Optional[Dict[str, Any]] = None, retry_auth: bool = True, retry_count: int = 0) -> Optional[Dict[str, Any]]:
+ """Обобщенный метод для выполнения API запросов с обработкой ошибок"""
+ # Ограничение на количество повторных попыток
+ if retry_count >= 3:
+ logger.error(f"Too many retries for {api_name}.{method}, giving up")
+ return None
+
+ # Проверка наличия авторизации
+ if not self.sid and not self.login():
+ logger.error(f"Not authenticated for API request: {api_name}.{method}")
+ return None
+
+ # Проверка информации API для определения пути и поддерживаемой версии
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": api_name
+ }
+
+ api_path = "entry.cgi"
+ try:
+ logger.debug(f"Querying API info for {api_name}")
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ api_info = api_info_data.get("data", {}).get(api_name, {})
+ if api_info:
+ max_version = api_info.get("maxVersion", version)
+ min_version = api_info.get("minVersion", version)
+ api_path = api_info.get("path", "entry.cgi")
+
+ # Проверка, поддерживается ли запрошенная версия
+ if version < min_version:
+ logger.warning(f"API version {version} for {api_name} is below minimum {min_version}, using {min_version}")
+ version = min_version
+ elif version > max_version:
+ logger.warning(f"API version {version} for {api_name} exceeds maximum {max_version}, using {max_version}")
+ version = max_version
+
+ logger.debug(f"Using API path: {api_path}, version: {version}")
+ else:
+ logger.warning(f"API {api_name} not found in API info, using defaults")
+ except Exception as e:
+ logger.warning(f"Error querying API info for {api_name}: {str(e)}, using defaults")
+
+ # Подготовка базовых параметров запроса
+ base_params = {
+ "api": api_name,
+ "version": str(version),
+ "method": method,
+ "_sid": self.sid # Используем _sid вместо sid для лучшей совместимости
+ }
+
+ # Добавление дополнительных параметров, если они заданы
+ if params:
+ base_params.update(params)
+
+ url = f"{self.base_url}/{api_path}"
+ logger.debug(f"API request: {api_name}.{method} v{version} to {url}")
+ logger.debug(f"Full request params: {base_params}")
+
+ try:
+ start_time = time.time()
+ response = self.session.get(
+ url,
+ params=base_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+ elapsed_time = time.time() - start_time
+ logger.debug(f"API request completed in {elapsed_time:.2f}s with status {response.status_code}")
+
+ # Проверка статуса HTTP
+ if response.status_code != 200:
+ logger.error(f"HTTP error: {response.status_code} for {api_name}.{method}")
+
+ # Повторная попытка при ошибках соединения
+ if response.status_code in [500, 502, 503, 504]:
+ logger.info(f"Server error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+
+ try:
+ data = response.json()
+ except json.JSONDecodeError:
+ logger.error(f"Failed to decode JSON response for {api_name}.{method}")
+ logger.debug(f"Response content: {response.text[:200]}")
+
+ # Повторная попытка при ошибках декодирования
+ logger.info(f"JSON decode error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ if data.get("success"):
+ logger.info(f"API request successful for {api_name}.{method}")
+ return data.get("data", {})
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ error_desc = self._get_error_description(error_code)
+ logger.error(f"API error for {api_name}.{method}: {error_code} - {error_desc}")
+
+ # Ошибки доступа или прав часто встречаются, но они не критичные
+ # Например, ошибка 102 означает, что нет прав, но NAS доступен
+ if error_code in [102, 103, 104, 105]:
+ logger.warning(f"Permission-related error for {api_name}.{method}: {error_code}")
+ # Возвращаем пустой словарь вместо None,
+ # чтобы вызывающий код мог понять, что запрос выполнен
+ return {}
+
+ # Если ошибка связана с авторизацией и нам разрешено повторить попытку
+ if error_code in [106, 107, 119] and retry_auth:
+ logger.info(f"Session error (code {error_code}), creating fresh session...")
+ self.sid = None # Сбрасываем SID
+
+ # Для ошибки 119 (Session timeout) дадим системе немного времени
+ if error_code == 119:
+ logger.info("Session timeout detected, waiting before retry...")
+ sleep(3)
+
+ if self.login():
+ logger.info("Re-authenticated with fresh session, retrying API request...")
+ # Рекурсивный вызов, но со счетчиком повторов
+ return self._make_api_request(api_name, method, version, params, False, retry_count + 1)
+
+ # Для некоторых ошибок можно автоматически повторить запрос
+ if error_code in [408, 429, 500, 502, 503, 504]:
+ logger.info(f"Temporary error {error_code}, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+
+ except requests.exceptions.Timeout:
+ logger.error(f"Request timeout for {api_name}.{method}")
+
+ # Повторная попытка при таймауте
+ if retry_count < 2:
+ logger.info(f"Timeout, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+ except requests.exceptions.ConnectionError as e:
+ logger.error(f"Connection error for {api_name}.{method}: {str(e)}")
+
+ # Повторная попытка при ошибке соединения
+ if retry_count < 2:
+ logger.info(f"Connection error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+ except Exception as e:
+ logger.error(f"Unexpected error during {api_name}.{method}: {str(e)}")
+ return None
+
+ def get_system_status(self) -> Dict[str, Any]:
+ """Получение статуса системы"""
+ # Проверяем доступность системы
+ if not self.is_online():
+ logger.info("Device is offline, skipping API request")
+ return {"status": "offline"}
+
+ # Проверяем, есть ли кэшированный результат
+ cache_key = "system_status"
+ current_time = time.time()
+ if cache_key in self._cache and current_time - self._cache_ttl.get(cache_key, 0) < 60:
+ logger.debug("Using cached system status")
+ return self._cache[cache_key]
+
+ # Используем рекомендованный API для получения информации о системе
+ logger.info(f"Getting system info using {SYNOLOGY_INFO_API} v{SYNOLOGY_API_VERSION}")
+
+ # Для SYNO.DSM.Info метод getinfo, для других обычно используется get или info
+ if SYNOLOGY_INFO_API == "SYNO.DSM.Info":
+ method = "getinfo"
+ else:
+ method = "get"
+
+ result = self._make_api_request(SYNOLOGY_INFO_API, method, version=SYNOLOGY_API_VERSION)
+
+ if result:
+ logger.info(f"Successfully retrieved system info using {SYNOLOGY_INFO_API}")
+
+ # Формируем расширенный ответ с дополнительной информацией
+ system_info = {
+ "status": "online",
+ "hostname": result.get("hostname", "unknown"),
+ "model": result.get("model", "unknown"),
+ "version": result.get("version", "unknown"),
+ "uptime": result.get("uptime", 0),
+ "time": current_time,
+ "is_online": True,
+ "api_used": SYNOLOGY_INFO_API
+ }
+
+ # Сохраняем в кэше
+ self._cache[cache_key] = system_info
+ self._cache_ttl[cache_key] = current_time
+
+ return system_info
+
+ # Если основной API не сработал, пробуем резервные варианты
+ logger.warning(f"Failed to retrieve system info with {SYNOLOGY_INFO_API}, trying fallback APIs")
+
+ # Пробуем резервные API
+ apis_to_try = [
+ {"name": "SYNO.DSM.Info", "method": "getinfo", "version": 2},
+ {"name": "SYNO.Core.System", "method": "info", "version": 1},
+ {"name": "SYNO.Core.System.Status", "method": "get", "version": 1},
+ {"name": "SYNO.Core.System.Info", "method": "get", "version": 1},
+ ]
+
+ for api in apis_to_try:
+ if api["name"] == SYNOLOGY_INFO_API:
+ continue # Пропускаем уже проверенный API
+
+ logger.info(f"Trying to get system info using {api['name']}.{api['method']} v{api['version']}")
+ result = self._make_api_request(api["name"], api["method"], version=api["version"])
+
+ if result:
+ logger.info(f"Successfully retrieved system info using {api['name']}")
+
+ # Формируем расширенный ответ с дополнительной информацией
+ system_info = {
+ "status": "online",
+ "hostname": result.get("hostname", "unknown"),
+ "model": result.get("model", "unknown"),
+ "version": result.get("version", "unknown"),
+ "uptime": result.get("uptime", 0),
+ "time": current_time,
+ "is_online": True,
+ "api_used": api["name"]
+ }
+
+ # Сохраняем в кэше
+ self._cache[cache_key] = system_info
+ self._cache_ttl[cache_key] = current_time
+
+ return system_info
+
+ # Если все запросы не удались, но система онлайн, возвращаем базовую информацию
+ logger.warning("Failed to retrieve system info with all API methods")
+ return {
+ "status": "error",
+ "error": "Failed to fetch system information",
+ "is_online": True,
+ "time": current_time
+ }
+
+ def shutdown_system(self) -> bool:
+ """Выключение системы"""
+ # Проверяем, включено ли устройство перед попыткой его выключить
+ if not self.is_online(force_check=True):
+ logger.info("Synology NAS is already offline, no need to shut down")
+ return True
+
+ logger.info("Attempting to shutdown Synology NAS...")
+
+ # Попробуем сначала использовать предпочтительный API для управления питанием
+ logger.info(f"Trying shutdown with {SYNOLOGY_POWER_API} v{SYNOLOGY_API_VERSION}")
+
+ # Для SYNO.Core.Hardware.PowerRecovery используется метод setPowerOnState
+ # Для других API обычно используется метод shutdown или reboot
+ if SYNOLOGY_POWER_API == "SYNO.Core.Hardware.PowerRecovery":
+ # Для этого API нужны специальные параметры
+ params = {"state": "powerbtn"} # powerbtn имитирует нажатие кнопки питания
+ result = self._make_api_request(SYNOLOGY_POWER_API, "setPowerOnState", version=SYNOLOGY_API_VERSION, params=params)
+ else:
+ # Пробуем стандартный метод
+ result = self._make_api_request(SYNOLOGY_POWER_API, "shutdown", version=SYNOLOGY_API_VERSION)
+
+ if result is not None:
+ logger.info(f"Successfully initiated system shutdown using {SYNOLOGY_POWER_API}")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ # Проверяем статус
+ if not self.is_online(force_check=True):
+ logger.info("System is now offline. Shutdown confirmed successful.")
+ return True
+ else:
+ logger.info("System still appears to be online, but shutdown may be in progress.")
+ return True
+ else:
+ logger.warning(f"Failed to shutdown using {SYNOLOGY_POWER_API}, trying alternative methods")
+
+ # Если не сработал основной метод, пробуем резервные варианты
+ # Проверка всех доступных методов API для выключения
+ apis_to_try = [
+ {"name": "SYNO.Core.System", "method": "shutdown", "version": 3},
+ {"name": "SYNO.Core.System.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.DSM.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.System.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.DSM.System", "method": "shutdown", "version": 1}
+ ]
+
+ # Проверяем доступные API
+ try:
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": "SYNO.Core.System.Power,SYNO.DSM.Power,SYNO.Core.System,SYNO.System.Power,SYNO.DSM.System"
+ }
+
+ logger.debug("Checking available shutdown APIs")
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ available_apis = api_info_data.get("data", {})
+ logger.debug(f"Available APIs: {list(available_apis.keys())}")
+
+ # Фильтруем только доступные API
+ filtered_apis = []
+ for api in apis_to_try:
+ if api["name"] in available_apis:
+ api_info = available_apis[api["name"]]
+ # Проверка версии API
+ if api_info.get("minVersion", 1) <= api["version"] <= api_info.get("maxVersion", 1):
+ filtered_apis.append(api)
+ logger.debug(f"Adding {api['name']} to available shutdown APIs")
+ else:
+ logger.debug(f"API {api['name']} version {api['version']} not supported. Supported: {api_info.get('minVersion')}-{api_info.get('maxVersion')}")
+
+ if filtered_apis:
+ apis_to_try = filtered_apis
+ else:
+ logger.warning("No compatible APIs found, trying all methods as fallback")
+ else:
+ logger.warning("Failed to query API info, using default methods")
+ else:
+ logger.warning(f"API info request failed with status {api_info_response.status_code}, using default methods")
+ except Exception as e:
+ logger.warning(f"Error querying API info: {str(e)}, using default methods")
+
+ # Пробуем все доступные методы по порядку
+ for api in apis_to_try:
+ logger.info(f"Trying shutdown with {api['name']}.{api['method']} v{api['version']}")
+ result = self._make_api_request(api["name"], api["method"], version=api["version"])
+
+ if result is not None:
+ logger.info(f"Successfully initiated system shutdown using {api['name']}")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ # Проверяем статус
+ if not self.is_online(force_check=True):
+ logger.info("System is now offline. Shutdown confirmed successful.")
+ return True
+ else:
+ logger.info("System still appears to be online, but shutdown may be in progress.")
+ return True
+ else:
+ logger.warning(f"Failed to shutdown using {api['name']}.{api['method']} v{api['version']}")
+
+ # Если ни один метод не сработал, но система стала недоступна
+ if not self.is_online(force_check=True):
+ logger.info("System appears to be shutting down despite API errors")
+ return True
+
+ logger.error("Failed to shutdown system after trying multiple APIs")
+ return False
+
+ def reboot_system(self) -> bool:
+ """Перезагрузка системы"""
+ # Проверяем, включена ли система
+ if not self.is_online(force_check=True):
+ logger.error("Cannot reboot: System is offline")
+ return False
+
+ logger.info("Attempting to reboot Synology NAS...")
+
+ # Список API и методов для попытки перезагрузки
+ apis_to_try = [
+ {"api": "SYNO.Core.System", "method": "reboot", "version": 1},
+ {"api": "SYNO.DSM.System", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.System.Power", "method": "restart", "version": 1},
+ {"api": "SYNO.DSM.Power", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.System", "method": "reboot", "version": 3},
+ {"api": "SYNO.System.Power", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.Hardware.NeedReboot", "method": "reboot", "version": 1}
+ ]
+
+ # Добавляем настроенный в конфигурации API, если он отличается от уже добавленных
+ already_added = [item["api"] for item in apis_to_try]
+ if SYNOLOGY_POWER_API not in already_added:
+ for method in ["restart", "reboot"]:
+ apis_to_try.append({
+ "api": SYNOLOGY_POWER_API,
+ "method": method,
+ "version": SYNOLOGY_API_VERSION
+ })
+
+ # Перебираем все возможные API и методы
+ for api_info in apis_to_try:
+ try:
+ logger.info(f"Trying reboot with {api_info['api']} API using method {api_info['method']}")
+ result = self._make_api_request(api_info['api'], api_info['method'], version=api_info['version'])
+ if result is not None:
+ logger.info(f"Successfully initiated system reboot using {api_info['api']} API")
+
+ # Даем системе время начать процесс перезагрузки
+ logger.info("Waiting for reboot to initialize...")
+ sleep(5)
+
+ # Ждем, пока система станет недоступна (признак перезагрузки)
+ reboot_started = False
+ for i in range(12): # Проверяем в течение 60 секунд (12 * 5сек)
+ if not self.is_online(force_check=True):
+ logger.info(f"System went offline after {i*5} seconds, reboot in progress")
+ reboot_started = True
+ break
+ logger.debug(f"System still online, waiting... ({i+1}/12)")
+ sleep(5)
+
+ if reboot_started:
+ # Успешный вызов API и система ушла оффлайн - это признак успешной перезагрузки
+ return True
+ else:
+ # Успешный вызов API, но система не ушла оффлайн
+ logger.warning("System did not go offline after reboot command, but command was accepted")
+ # Даже если система не ушла оффлайн, команда могла быть принята
+ return True
+ except Exception as e:
+ logger.error(f"Error during reboot with {api_info['api']}: {str(e)}")
+
+ # Если все попытки не удались, возвращаем False
+ logger.error("All reboot attempts failed")
+ return False
+
+ def _get_error_description(self, error_code: int) -> str:
+ """Получение описания ошибки по коду"""
+ error_descriptions = {
+ 100: "Unknown error",
+ 101: "Invalid parameter",
+ 102: "API does not exist",
+ 103: "Method does not exist",
+ 104: "Version does not support",
+ 105: "Permission denied",
+ 106: "Session timeout",
+ 107: "Session interrupted by duplicate login",
+ 400: "Invalid credentials",
+ 401: "Account disabled",
+ 402: "Permission denied",
+ 403: "2FA required",
+ 404: "Failed to authenticate with 2FA"
+ }
+ return error_descriptions.get(error_code, "Unknown error code")
+
+ def _check_tcp_connection(self) -> bool:
+ """Проверка базового TCP-соединения с Synology NAS"""
+ try:
+ socket_obj = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ socket_obj.settimeout(SYNOLOGY_TIMEOUT)
+ result = socket_obj.connect_ex((SYNOLOGY_HOST, SYNOLOGY_PORT))
+ socket_obj.close()
+
+ return result == 0
+ except socket.error as e:
+ logger.error(f"Socket error during connection check: {str(e)}")
+ return False
+ except Exception as e:
+ logger.error(f"Unexpected error during connection check: {str(e)}")
+ return False
+
+ def is_online(self, force_check=False) -> bool:
+ """Проверка онлайн-статуса Synology NAS"""
+ # Используем кэшированное значение, если доступно и не устарело
+ current_time = time.time()
+ if not force_check and (current_time - self._last_online_check) < self._online_check_interval:
+ logger.debug(f"Using cached online status: {self._last_online_status}")
+ return self._last_online_status
+
+ logger.info("Checking if NAS is online...")
+
+ # Проверяем TCP-соединение
+ online_status = self._check_tcp_connection()
+ logger.info(f"Detected Synology NAS online status: {online_status}")
+
+ # Если TCP-соединение успешно и у нас есть действующий SID,
+ # попробуем более детальную проверку через API
+ if online_status and self.sid:
+ logger.info("Trying to fetch more detailed online status through API...")
+
+ # Пробуем разные API для проверки онлайн-статуса
+ api_checks = [
+ {"api": "SYNO.DSM.Info", "version": "2", "method": "getinfo"},
+ {"api": "SYNO.Core.System", "version": "1", "method": "info"},
+ {"api": "SYNO.Core.System.Status", "version": "1", "method": "get"}
+ ]
+
+ api_success = False
+ for api_check in api_checks:
+ try:
+ url = f"{self.base_url}/entry.cgi"
+ params = {
+ "api": api_check["api"],
+ "version": api_check["version"],
+ "method": api_check["method"],
+ "sid": self.sid
+ }
+
+ logger.debug(f"Trying online status check with {api_check['api']}")
+ response = self.session.get(
+ url,
+ params=params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("success"):
+ logger.info(f"API request successful for {api_check['api']}")
+ logger.info("Synology NAS is online with API access")
+ api_success = True
+ break
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.warning(f"API response indicates an error: {error_code}, but NAS is reachable")
+ else:
+ logger.warning(f"API returned status code {response.status_code}")
+ except Exception as e:
+ logger.warning(f"API check failed for {api_check['api']}, but TCP connection succeeded: {str(e)}")
+
+ if not api_success:
+ logger.warning("All API checks failed, but TCP connection is successful")
+
+ # Обновляем кэшированное значение
+ self._last_online_check = current_time
+ self._last_online_status = online_status
+
+ return online_status
+
+ def wake_on_lan(self) -> bool:
+ """Отправка Wake-on-LAN пакета для включения Synology NAS"""
+ if not SYNOLOGY_MAC:
+ logger.error("MAC address not configured")
+ return False
+
+ try:
+ # Преобразование MAC-адреса в байты
+ mac_address = SYNOLOGY_MAC.replace(':', '').replace('-', '')
+ if len(mac_address) != 12:
+ logger.error(f"Invalid MAC address format: {SYNOLOGY_MAC}")
+ return False
+
+ try:
+ mac_bytes = bytes.fromhex(mac_address)
+ except ValueError as e:
+ logger.error(f"Failed to parse MAC address: {str(e)}")
+ logger.error(f"MAC address must contain only hex characters: {SYNOLOGY_MAC}")
+ return False
+
+ # Создание Magic Packet
+ magic_packet = b'\xff' * 6 + mac_bytes * 16
+
+ # Отправка пакета на конкретный адрес
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.sendto(magic_packet, (SYNOLOGY_HOST, WOL_PORT))
+ sock.close()
+ logger.info(f"Wake-on-LAN packet sent to {SYNOLOGY_MAC} at {SYNOLOGY_HOST}:{WOL_PORT}")
+ except Exception as e:
+ logger.error(f"Error sending directed WoL packet: {str(e)}")
+ return False
+
+ # Для надежности отправляем также широковещательный пакет
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+ # Используем стандартный широковещательный адрес
+ broadcast_addr = "255.255.255.255"
+ sock.sendto(magic_packet, (broadcast_addr, WOL_PORT))
+ sock.close()
+ logger.info(f"Wake-on-LAN broadcast packet sent to {broadcast_addr}:{WOL_PORT}")
+ except Exception as e:
+ logger.warning(f"Error sending broadcast WoL packet (non-critical): {str(e)}")
+ # Не считаем ошибкой, т.к. основной пакет уже отправлен
+
+ return True
+
+ except Exception as e:
+ logger.error(f"Unexpected error in wake_on_lan: {str(e)}")
+ return False
+
+ def wait_for_boot(self, max_attempts: int = 30, delay: int = 10) -> bool:
+ """Ожидание загрузки Synology NAS"""
+ logger.info(f"Waiting for Synology NAS to boot (timeout: {max_attempts * delay}s)...")
+
+ for attempt in range(max_attempts):
+ # Принудительно проверяем статус без использования кэша
+ if self.is_online(force_check=True):
+ logger.info(f"Synology NAS is online after {attempt + 1} attempts ({(attempt + 1) * delay}s)")
+
+ # Проверяем, что не только сеть доступна, но и API загрузился
+ api_ready = False
+ logger.info("Waiting for API services to initialize...")
+
+ for api_check in range(5): # Даем еще до 50 секунд для загрузки API
+ if self.sid or self.login():
+ api_ready = True
+ logger.info(f"API services are ready after {api_check + 1} attempts")
+ break
+ logger.debug(f"API not ready yet, waiting... ({api_check + 1}/5)")
+ sleep(10)
+
+ if not api_ready:
+ logger.warning("System is online but API services may not be fully initialized")
+
+ # Дадим дополнительное время для полной загрузки всех сервисов
+ sleep(delay)
+ return True
+
+ sleep(delay)
+ logger.info(f"Waiting for boot... Attempt {attempt + 1}/{max_attempts}")
+
+ logger.error(f"Synology NAS didn't come online after {max_attempts * delay} seconds")
+ return False
+
+ def power_on(self) -> bool:
+ """Включение Synology NAS"""
+ # Принудительная проверка статуса
+ if self.is_online(force_check=True):
+ logger.info("Synology NAS is already online")
+ return True
+
+ logger.info("Powering on Synology NAS via Wake-on-LAN...")
+
+ # Проверяем, настроен ли MAC-адрес
+ if not SYNOLOGY_MAC:
+ logger.error("Cannot power on: MAC address not configured in settings")
+ return False
+
+ # Пробуем отправить несколько WoL пакетов для надежности
+ success = False
+ for attempt in range(3):
+ logger.debug(f"Sending WoL packet, attempt {attempt + 1}/3")
+ if self.wake_on_lan():
+ success = True
+ break
+ sleep(1)
+
+ if not success:
+ logger.error("Failed to send Wake-on-LAN packets")
+ return False
+
+ # Ожидание загрузки
+ logger.info("WoL packets sent successfully, waiting for system to boot...")
+ boot_result = self.wait_for_boot(max_attempts=30, delay=10)
+
+ if boot_result:
+ # Проверяем доступность API после загрузки
+ system_status = self.get_system_status()
+ if system_status.get("status") == "online":
+ logger.info("System booted successfully with API access")
+ return True
+ else:
+ logger.warning("System appears to be online but API may not be fully ready")
+ return True
+ else:
+ logger.error("System did not come online after WoL")
+ return False
+
+ def power_off(self) -> bool:
+ """Выключение Synology NAS"""
+ if not self.is_online(force_check=True):
+ logger.info("Synology NAS is already offline")
+ return True
+
+ logger.info("Powering off Synology NAS...")
+
+ # Список API и методов для попытки выключения
+ apis_to_try = [
+ {"api": "SYNO.Core.System", "method": "shutdown", "version": 1},
+ {"api": "SYNO.DSM.System", "method": "shutdown", "version": 1},
+ {"api": SYNOLOGY_POWER_API, "method": "shutdown", "version": SYNOLOGY_API_VERSION}
+ ]
+
+ # Перебираем все возможные API и методы
+ for api_info in apis_to_try:
+ try:
+ logger.info(f"Trying shutdown with {api_info['api']} API using method {api_info['method']}")
+ api_result = self._make_api_request(api_info['api'], api_info['method'], version=api_info['version'])
+ if api_result is not None:
+ logger.info(f"Successfully initiated system shutdown using {api_info['api']} API")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ return True
+ except Exception as e:
+ logger.error(f"Error during shutdown with {api_info['api']}: {str(e)}")
+
+ # Если все попытки не удались, возвращаем False
+ logger.error("All shutdown attempts failed")
+ return False
+
+ # Если все еще не сработало, используем оригинальный метод shutdown_system
+ if not result:
+ result = self.shutdown_system()
+
+ if result:
+ # Дополнительная проверка, что система действительно выключилась
+ logger.info("Verifying system is offline...")
+ for attempt in range(12): # Проверяем в течение 2 минут (12 * 10 сек)
+ if not self.is_online(force_check=True):
+ logger.info(f"System confirmed offline after {attempt * 10} seconds")
+ return True
+ logger.debug(f"System still shutting down, checking again... ({attempt + 1}/12)")
+ sleep(10)
+
+ logger.warning("System still appears to be online after shutdown command")
+ return False
+ else:
+ logger.error("Failed to initiate shutdown")
+ return False
+
+ # Заглушки для расширенных методов
+ def get_shared_folders(self) -> List[Dict[str, Any]]:
+ """Получение списка общих папок"""
+ logger.info("Getting list of shared folders")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for shared folders request")
+ return []
+
+ try:
+ # Запрашиваем список общих папок через FileStation API
+ result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for shared folders")
+ alt_result = self._make_api_request("SYNO.FileStation.List", "list_share", version=1)
+ if alt_result:
+ return alt_result.get("shares", [])
+ return []
+
+ return result.get("shares", [])
+
+ except Exception as e:
+ logger.error(f"Error getting shared folders: {str(e)}")
+ return []
+
+ def get_system_load(self) -> Dict[str, Any]:
+ """Получение информации о загрузке системы"""
+ logger.info("Getting system load information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for system load request")
+ return {}
+
+ try:
+ # Запрашиваем информацию о загрузке системы
+ result = self._make_api_request("SYNO.Core.System.Utilization", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for system load")
+ alt_result = self._make_api_request("SYNO.Core.System", "info", version=3)
+
+ if not alt_result:
+ return {}
+
+ # Формируем из частичных данных
+ return {
+ "cpu_load": alt_result.get("cpu_usage", 0),
+ "memory": {
+ "total": alt_result.get("memory_size", 0),
+ "used": alt_result.get("memory_usage", 0),
+ "usage_percent": alt_result.get("memory_usage_percent", 0)
+ }
+ }
+
+ # Формируем структурированный результат
+ return {
+ "cpu_load": result.get("cpu", {}).get("user", 0) + result.get("cpu", {}).get("system", 0),
+ "memory": result.get("memory", {}),
+ "network": result.get("network", {})
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting system load: {str(e)}")
+ return {}
+
+ def is_online_api(self) -> bool:
+ """Проверка онлайн-статуса Synology NAS с использованием API"""
+ if not self.is_online():
+ return False
+
+ # Проверяем доступность API через авторизацию
+ if not self.sid and not self.login():
+ return False
+
+ return True
+
+ def get_storage_status(self) -> Dict[str, Any]:
+ """Получение подробной информации о хранилище"""
+ logger.info("Getting storage status information")
+
+ # Проверяем доступность NAS и API
+ if not self.is_online_api():
+ logger.error("Cannot get storage status: NAS is not online or API is not accessible")
+ return {"error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о хранилище через SYNO.Storage.CGI.Storage API
+ result = self._make_api_request("SYNO.Storage.CGI.Storage", "load_info", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for storage info")
+ alt_result = self._make_api_request("SYNO.Core.Storage", "info", version=1)
+
+ if not alt_result:
+ # Пробуем еще один альтернативный API
+ logger.info("Trying SYNO.Core.System API for storage info")
+ sys_result = self._make_api_request("SYNO.Core.System", "info", version=3)
+
+ if not sys_result:
+ return {
+ "volumes": [],
+ "disks": [],
+ "total_size": 0,
+ "total_used": 0,
+ "error": "no_data"
+ }
+
+ # Извлекаем базовую информацию о хранилище из системной информации
+ return {
+ "volumes": [],
+ "disks": [],
+ "total_size": sys_result.get("hdd_total_size", 0) * 1024**2, # МБ в байты
+ "total_used": sys_result.get("hdd_total_size", 0) * sys_result.get("hdd_usage", 0) / 100 * 1024**2,
+ }
+
+ # Обрабатываем данные из альтернативного API
+ volumes = alt_result.get("volumes", [])
+ disks = alt_result.get("disks", [])
+
+ else:
+ # Обрабатываем данные из основного API
+ volumes = result.get("volumes", [])
+ disks = result.get("disks", [])
+
+ # Рассчитываем общие размеры
+ total_size = 0
+ total_used = 0
+
+ for volume in volumes:
+ volume_size = volume.get("size", {}).get("total", 0)
+ volume_used = volume.get("size", {}).get("used", 0)
+
+ total_size += volume_size
+ total_used += volume_used
+
+ return {
+ "volumes": volumes,
+ "disks": disks,
+ "total_size": total_size,
+ "total_used": total_used
+ }
+
+ except Exception as e:
+ logger.error(f"Error in get_storage_status: {str(e)}")
+ return {"error": str(e)}
+
+ def get_security_status(self) -> Dict[str, Any]:
+ """Получение информации о состоянии безопасности"""
+ logger.info("Getting security status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for security status request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о безопасности через API Security Scan
+ result = self._make_api_request("SYNO.Core.SecurityScan.Status", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for security status")
+ # Проверяем статус брандмауэра
+ firewall_result = self._make_api_request("SYNO.Core.Security.Firewall.Status", "get", version=1)
+
+ # Проверяем статус автоматических обновлений
+ update_result = self._make_api_request("SYNO.Core.Upgrade.Server.Setting", "get", version=1)
+
+ # Если ни один из API не отвечает
+ if not firewall_result and not update_result:
+ # Получаем общую информацию о системе для базовой проверки безопасности
+ sys_result = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ if not sys_result:
+ return {
+ "success": False,
+ "status": "unknown",
+ "last_check": None,
+ "is_secure": False,
+ "error": "no_security_api"
+ }
+
+ # Собираем базовые сведения из системной информации
+ return {
+ "success": True,
+ "status": "basic",
+ "last_check": None,
+ "is_secure": True, # Предполагаем, что система в целом безопасна
+ "firewall_enabled": None,
+ "auto_update": None,
+ "version_latest": sys_result.get("version_string", "")
+ }
+
+ # Собираем информацию из доступных результатов
+ firewall_enabled = firewall_result.get("enabled", False) if firewall_result else None
+ auto_update = update_result.get("auto_update", False) if update_result else None
+
+ # Определяем, насколько система безопасна
+ is_secure = True # По умолчанию предполагаем, что система безопасна
+ if firewall_enabled is not None and not firewall_enabled:
+ is_secure = False
+
+ return {
+ "success": True,
+ "status": "partial",
+ "last_check": None,
+ "is_secure": is_secure,
+ "firewall_enabled": firewall_enabled,
+ "auto_update": auto_update
+ }
+
+ # Если основное API отвечает, возвращаем его данные
+ return {
+ "success": True,
+ "status": result.get("status", "unknown"),
+ "last_check": result.get("last_check", None),
+ "is_secure": result.get("is_secure", False),
+ "details": result.get("details", {})
+ }
+
+ except Exception as e:
+ logger.error(f"Error in get_security_status: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_processes(self, limit: int = 20) -> List[Dict[str, Any]]:
+ """Получение списка активных процессов"""
+ logger.info(f"Getting list of active processes (limit={limit})")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for processes request")
+ return []
+
+ try:
+ # Получаем список процессов через API
+ result = self._make_api_request("SYNO.Core.System.Process", "list", version=1,
+ params={"sort_by": "cpu", "order": "DESC", "limit": limit})
+
+ if not result:
+ logger.warning("Failed to get process list")
+ return []
+
+ return result.get("processes", [])
+
+ except Exception as e:
+ logger.error(f"Error getting process list: {str(e)}")
+ return []
+
+ def get_network_status(self) -> Dict[str, Any]:
+ """Получение информации о сетевых подключениях"""
+ logger.info("Getting network status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for network status request")
+ return {}
+
+ try:
+ # Получаем информацию о сетевых интерфейсах
+ interface_result = self._make_api_request("SYNO.Core.Network.Interface", "list", version=1)
+
+ # Получаем статистику использования сети
+ utilization_result = self._make_api_request("SYNO.Core.System.Utilization", "get", version=1)
+
+ interfaces = []
+ if interface_result:
+ interfaces = interface_result.get("interfaces", [])
+
+ network_stats = {}
+ if utilization_result and "network" in utilization_result:
+ network_stats = utilization_result.get("network", {})
+
+ # Объединяем данные
+ for interface in interfaces:
+ iface_id = interface.get("id", "")
+ if iface_id in network_stats:
+ interface["rx"] = network_stats[iface_id].get("rx", 0)
+ interface["tx"] = network_stats[iface_id].get("tx", 0)
+
+ return {
+ "interfaces": interfaces,
+ "statistics": network_stats
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting network status: {str(e)}")
+ return {}
+
+ def get_system_logs(self, limit: int = 20) -> List[Dict[str, Any]]:
+ """Получение журналов системы"""
+ logger.info(f"Getting system logs (limit={limit})")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for system logs request")
+ return []
+
+ try:
+ # Получаем журналы через API
+ result = self._make_api_request("SYNO.Core.SyslogClient.Log", "list", version=1,
+ params={"start": 0, "limit": limit, "sort": "time", "dir": "DESC"})
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for system logs")
+ alt_result = self._make_api_request("SYNO.Core.Log", "list", version=1,
+ params={"start": 0, "limit": limit, "sort": "time", "dir": "DESC"})
+
+ if alt_result:
+ return alt_result.get("logs", [])
+ return []
+
+ return result.get("logs", [])
+
+ except Exception as e:
+ logger.error(f"Error getting system logs: {str(e)}")
+ return []
+
+ def get_power_schedule(self) -> Dict[str, Any]:
+ """Получение расписания включения/выключения"""
+ logger.info("Getting power schedule")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for power schedule request")
+ return {}
+
+ try:
+ # Пробуем сначала более новый API
+ result = self._make_api_request("SYNO.Core.System.PowerSchedule", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ result = self._make_api_request("SYNO.Core.System", "get_power_schedule", version=1)
+
+ if not result:
+ # Если нет результатов, вернем структуру, которую ожидает код
+ logger.warning("PowerSchedule API not available, returning empty schedule structure")
+ return {
+ "boot_tasks": [],
+ "shutdown_tasks": []
+ }
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Error getting power schedule: {str(e)}")
+ return {
+ "boot_tasks": [],
+ "shutdown_tasks": []
+ }
+
+ def set_power_schedule(self, schedule_type: str, days: List[int], time: str, enabled: bool = True) -> bool:
+ """Настройка расписания включения/выключения
+
+ Args:
+ schedule_type: Тип расписания ('boot' или 'shutdown')
+ days: Список дней недели (0-6, где 0 - понедельник)
+ time: Время в формате 'HH:MM'
+ enabled: Включить или выключить расписание
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Setting power schedule for {schedule_type} at {time} on days {days}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for setting power schedule")
+ return False
+
+ try:
+ # Получаем текущее расписание
+ current_schedule = self._make_api_request("SYNO.Core.Hardware.PowerSchedule", "get", version=1)
+
+ if not current_schedule:
+ logger.error("Failed to get current power schedule")
+ return False
+
+ # Подготавливаем новое расписание
+ params = {
+ "enabled": enabled,
+ "type": schedule_type,
+ "day": days,
+ "time": time
+ }
+
+ # Устанавливаем новое расписание
+ result = self._make_api_request("SYNO.Core.Hardware.PowerSchedule", "set", version=1, params=params)
+
+ if not result:
+ logger.error("Failed to set power schedule")
+ return False
+
+ logger.info(f"Power schedule for {schedule_type} set successfully")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error setting power schedule: {str(e)}")
+ return False
+
+ def get_temperature_status(self) -> Dict[str, Any]:
+ """Получение информации о температуре системы и дисков"""
+ logger.info("Getting temperature status")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for temperature status request")
+ return {}
+
+ try:
+ # Получаем информацию о системе для общей температуры
+ system_info = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ # Получаем информацию о дисках для их температуры
+ storage_info = self._make_api_request("SYNO.Storage.CGI.Storage", "load_info", version=1)
+
+ system_temp = None
+ disk_temps = []
+
+ if system_info:
+ system_temp = system_info.get("temperature")
+
+ if storage_info:
+ disks = storage_info.get("disks", [])
+ for disk in disks:
+ name = disk.get("name", "unknown")
+ model = disk.get("model", "unknown")
+ temp = disk.get("temp", None)
+ if temp is not None:
+ disk_temps.append({
+ "name": name,
+ "model": model,
+ "temperature": temp
+ })
+
+ return {
+ "system_temperature": system_temp,
+ "disk_temperatures": disk_temps,
+ "warning": system_info.get("temperature_warn", False) if system_info else False
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting temperature status: {str(e)}")
+ return {}
+
+ def browse_files(self, folder_path: str = "", limit: int = 100) -> Dict[str, Any]:
+ """Просмотр файлов в указанной директории
+
+ Args:
+ folder_path: Путь к папке (пустая строка для корневых общих папок)
+ limit: Максимальное количество элементов для возврата
+
+ Returns:
+ Словарь с информацией о файлах и папках
+ """
+ logger.info(f"Browsing files in {folder_path or 'root'}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file browsing")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ if not folder_path:
+ # Если путь не указан, получаем список общих папок
+ result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not result:
+ logger.error("Failed to list shared folders")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "items": result.get("shares", []),
+ "path": "",
+ "is_root": True
+ }
+ else:
+ # Получаем список файлов в указанной директории
+ params = {
+ "folder_path": folder_path,
+ "limit": limit,
+ "offset": 0,
+ "sort_by": "name",
+ "sort_direction": "ASC"
+ }
+
+ result = self._make_api_request("SYNO.FileStation.List", "list", version=2, params=params)
+
+ if not result:
+ logger.error(f"Failed to list files in {folder_path}")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "items": result.get("files", []),
+ "path": folder_path,
+ "is_root": False,
+ "total": result.get("total", 0)
+ }
+
+ except Exception as e:
+ logger.error(f"Error browsing files: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def manage_service(self, service_name: str, action: str = "status") -> Dict[str, Any]:
+ """Управление системным сервисом
+
+ Args:
+ service_name: Имя сервиса
+ action: Действие (status/start/stop/restart)
+
+ Returns:
+ Словарь с результатом операции
+ """
+ logger.info(f"Managing service {service_name}, action: {action}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for service management")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Проверяем доступное API для управления сервисами
+ if action == "status":
+ result = self._make_api_request("SYNO.Core.Service", "get", version=1,
+ params={"service": service_name})
+ else:
+ result = self._make_api_request("SYNO.Core.Service", action, version=1,
+ params={"service": service_name})
+
+ if not result:
+ logger.error(f"Failed to {action} service {service_name}")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "service": service_name,
+ "action": action,
+ "result": result,
+ "status": result.get("status") if action == "status" else "completed"
+ }
+
+ except Exception as e:
+ logger.error(f"Error managing service {service_name}: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def search_files(self, pattern: str, folder_path: str = "", limit: int = 100) -> Dict[str, Any]:
+ """Поиск файлов по шаблону
+
+ Args:
+ pattern: Шаблон для поиска
+ folder_path: Путь к папке для поиска (пустая строка для всех общих папок)
+ limit: Максимальное количество результатов
+
+ Returns:
+ Словарь с найденными файлами
+ """
+ logger.info(f"Searching for {pattern} in {folder_path or 'all shared folders'}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file search")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ if not folder_path:
+ # Получаем список всех общих папок для поиска
+ shares_result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not shares_result:
+ logger.error("Failed to list shared folders for search")
+ return {"success": False, "error": "api_error"}
+
+ # Формируем список путей для поиска
+ folder_paths = [share.get("path") for share in shares_result.get("shares", [])]
+ else:
+ folder_paths = [folder_path]
+
+ # Запускаем поиск
+ params = {
+ "folder_path": folder_paths,
+ "pattern": pattern,
+ "limit": limit,
+ "offset": 0
+ }
+
+ result = self._make_api_request("SYNO.FileStation.Search", "start", version=2, params=params)
+
+ if not result:
+ logger.error(f"Failed to start search for {pattern}")
+ return {"success": False, "error": "api_error"}
+
+ # Получаем taskid для проверки результатов
+ taskid = result.get("taskid")
+ if not taskid:
+ logger.error("No taskid received for search")
+ return {"success": False, "error": "no_task_id"}
+
+ # Ожидаем завершения поиска
+ search_result = {"finished": False, "progress": 0}
+ for _ in range(10): # Максимум 10 попыток
+ search_status = self._make_api_request("SYNO.FileStation.Search", "status", version=2,
+ params={"taskid": taskid})
+
+ if not search_status:
+ break
+
+ search_result["progress"] = search_status.get("progress", 0)
+
+ if search_status.get("finished", False):
+ search_result["finished"] = True
+ break
+
+ time.sleep(0.5) # Пауза между запросами
+
+ # Получаем результаты поиска
+ if search_result["finished"]:
+ list_result = self._make_api_request("SYNO.FileStation.Search", "list", version=2,
+ params={"taskid": taskid, "limit": limit})
+
+ if list_result:
+ files = list_result.get("files", [])
+ return {
+ "success": True,
+ "pattern": pattern,
+ "results": files,
+ "total": list_result.get("total", len(files))
+ }
+
+ # Если не удалось получить результаты, останавливаем поиск
+ self._make_api_request("SYNO.FileStation.Search", "stop", version=2,
+ params={"taskid": taskid})
+
+ return {
+ "success": False,
+ "error": "search_timeout",
+ "progress": search_result["progress"]
+ }
+
+ except Exception as e:
+ logger.error(f"Error searching files: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_backup_status(self) -> Dict[str, Any]:
+ """Получение информации о резервном копировании"""
+ logger.info("Getting backup status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for backup status request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о Hyper Backup
+ hyper_result = self._make_api_request("SYNO.Backup.Repository", "list", version=1)
+
+ # Пробуем получить информацию о задачах Time Backup
+ time_result = self._make_api_request("SYNO.BackupService.Task", "list", version=1)
+
+ # Проверяем статус резервного копирования USB
+ usb_result = self._make_api_request("SYNO.USBCopy", "status", version=1)
+
+ backups = {
+ "hyper_backup": hyper_result.get("backups", []) if hyper_result else [],
+ "time_backup": time_result.get("tasks", []) if time_result else [],
+ "usb_copy": {"enabled": usb_result.get("enable", False) if usb_result else False}
+ }
+
+ return {
+ "success": True,
+ "backups": backups,
+ "available_apis": {
+ "hyper_backup": hyper_result is not None,
+ "time_backup": time_result is not None,
+ "usb_copy": usb_result is not None
+ }
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting backup status: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def check_for_updates(self) -> Dict[str, Any]:
+ """Проверка наличия обновлений системы"""
+ logger.info("Checking for system updates")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for update check")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Получаем текущую информацию о системе
+ system_info = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ if not system_info:
+ logger.error("Failed to get system info for update check")
+ return {"success": False, "error": "api_error"}
+
+ # Проверяем наличие обновлений
+ update_result = self._make_api_request("SYNO.Core.Upgrade.Server", "check", version=1)
+
+ # Получаем настройки автоматического обновления
+ settings_result = self._make_api_request("SYNO.Core.Upgrade.Server.Setting", "get", version=1)
+
+ # Получаем информацию о доступных обновлениях
+ update_info = self._make_api_request("SYNO.Core.Upgrade.Server.Download", "list", version=1)
+
+ current_version = system_info.get("version_string", "unknown")
+ auto_update_enabled = settings_result.get("auto_update", False) if settings_result else False
+
+ updates = []
+ if update_info and "updates" in update_info:
+ updates = update_info.get("updates", [])
+
+ update_available = len(updates) > 0
+
+ return {
+ "success": True,
+ "current_version": current_version,
+ "update_available": update_available,
+ "auto_update_enabled": auto_update_enabled,
+ "updates": updates
+ }
+
+ except Exception as e:
+ logger.error(f"Error checking for updates: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_quota_info(self) -> Dict[str, Any]:
+ """Получение информации о квотах пользователей"""
+ logger.info("Getting user quota information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for quota info request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Получаем список пользователей
+ users_result = self._make_api_request("SYNO.Core.User", "list", version=1)
+
+ if not users_result:
+ logger.error("Failed to get user list for quota info")
+ return {"success": False, "error": "api_error"}
+
+ users = users_result.get("users", [])
+ user_quotas = []
+
+ # Получаем квоты для каждого пользователя
+ for user in users:
+ user_name = user.get("name")
+ if not user_name:
+ continue
+
+ quota_result = self._make_api_request("SYNO.Core.Quota", "get", version=1,
+ params={"user_name": user_name})
+
+ if quota_result and "quotas" in quota_result:
+ user_quotas.append({
+ "user": user_name,
+ "quotas": quota_result.get("quotas", [])
+ })
+
+ return {
+ "success": True,
+ "user_quotas": user_quotas
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting quota info: {str(e)}")
+ return {"success": False, "error": str(e)}
diff --git a/.history/src/api/synology_20250830104833.py b/.history/src/api/synology_20250830104833.py
new file mode 100644
index 0000000..30b422b
--- /dev/null
+++ b/.history/src/api/synology_20250830104833.py
@@ -0,0 +1,1877 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Модуль для взаимодействия с API Synology NAS
+"""
+
+import requests
+from requests.adapters import HTTPAdapter
+import json
+import logging
+import time
+import urllib3
+from urllib3.util import Retry
+from typing import Dict, Any, Optional, List
+import socket
+import struct
+from time import sleep
+
+from src.config.config import (
+ SYNOLOGY_HOST,
+ SYNOLOGY_PORT,
+ SYNOLOGY_USERNAME,
+ SYNOLOGY_PASSWORD,
+ SYNOLOGY_SECURE,
+ SYNOLOGY_TIMEOUT,
+ SYNOLOGY_MAC,
+ WOL_PORT,
+ SYNOLOGY_API_VERSION,
+ SYNOLOGY_POWER_API,
+ SYNOLOGY_INFO_API
+)
+from src.api.api_discovery import discover_available_apis, find_compatible_api
+
+# Отключение предупреждений о небезопасных SSL-соединениях
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+logger = logging.getLogger(__name__)
+
+class SynologyAPI:
+ """Класс для взаимодействия с API Synology NAS"""
+
+ def __init__(self):
+ """Инициализация класса SynologyAPI"""
+ logger.info("Creating API with auto-retry and connection pool")
+ logger.debug(f"Connection details: Host={SYNOLOGY_HOST}, Port={SYNOLOGY_PORT}, Secure={SYNOLOGY_SECURE}")
+
+ self.protocol = "https" if SYNOLOGY_SECURE else "http"
+ self.base_url = f"{self.protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/webapi"
+ self.sid = None
+ self.session = requests.Session()
+
+ # Настройка SSL
+ if self.protocol == "https":
+ logger.debug("SSL enabled, disabling certificate verification for internal network")
+ self.session.verify = False # Отключаем проверку SSL для внутренней сети
+
+ # Добавляем пользовательские заголовки для улучшения совместимости с API
+ custom_headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
+ 'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'Connection': 'keep-alive',
+ 'Referer': f'{self.protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/'
+ }
+ self.session.headers.update(custom_headers)
+ logger.debug("Added browser-like headers for API compatibility")
+
+ # Добавляем повторные попытки для HTTP-запросов
+ retry_strategy = Retry(
+ total=5, # Увеличиваем количество попыток
+ status_forcelist=[429, 500, 502, 503, 504, 404],
+ allowed_methods=["GET", "POST"],
+ backoff_factor=1.5, # Увеличиваем задержку между попытками
+ respect_retry_after_header=True
+ )
+ adapter = HTTPAdapter(
+ max_retries=retry_strategy,
+ pool_connections=3,
+ pool_maxsize=10
+ )
+ self.session.mount("http://", adapter)
+ self.session.mount("https://", adapter)
+
+ # Таймауты будут указаны в запросах
+ self.default_timeout = (SYNOLOGY_TIMEOUT, SYNOLOGY_TIMEOUT*2)
+ logger.debug(f"Setting default request timeout: {self.default_timeout}")
+
+ # Кэш для хранения результатов запросов
+ self._cache = {}
+ self._cache_ttl = {}
+ self._last_online_check = 0
+ self._last_online_status = False
+ self._online_check_interval = 30 # Интервал для кэширования проверок онлайн-статуса
+
+ # Время последней успешной аутентификации и срок действия сессии
+ self._last_auth_time = 0
+ self._auth_expiry = 3600 # По умолчанию 1 час
+
+ # Информация о доступных API
+ self._available_apis = {}
+ self._api_info_ttl = 0
+
+ # Инициализируем API version resolver для автоматического определения совместимых API
+ self.api_resolver = None # Будет создан при необходимости
+
+ def login(self) -> bool:
+ """Авторизация в API Synology NAS"""
+ # Сбрасываем SID для новой сессии
+ self.sid = None
+
+ logger.info("Attempting to authenticate with Synology NAS...")
+ logger.debug(f"Base URL: {self.base_url}")
+
+ # Проверяем доступность NAS перед запросом с помощью прямой TCP-проверки
+ # Избегаем вызова is_online(), чтобы не создавать рекурсию
+ online_status = self._check_tcp_connection()
+ if not online_status:
+ logger.error("Cannot login: Synology NAS is not reachable")
+ return False
+
+ # Пробуем различные версии API для аутентификации
+ # Начинаем с версии 3, которая показала лучшую совместимость в тестах
+ auth_versions_to_try = [3, 2, 1, 6] # Пробуем сначала более стабильные версии
+
+ for auth_version in auth_versions_to_try:
+ try:
+ # Определяем путь к API аутентификации
+ auth_path = "entry.cgi" # Используем entry.cgi вместо auth.cgi по умолчанию
+
+ # Проверка информации API для определения доступных версий API
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": "SYNO.API.Auth"
+ }
+
+ logger.debug(f"Querying API info for auth version {auth_version}")
+ try:
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ auth_info = api_info_data.get("data", {}).get("SYNO.API.Auth", {})
+ max_version = auth_info.get("maxVersion", 6)
+ min_version = auth_info.get("minVersion", 1)
+ auth_path = auth_info.get("path", "entry.cgi")
+ logger.debug(f"Auth API versions: min={min_version}, max={max_version}, path={auth_path}")
+
+ # Проверяем поддержку текущей версии
+ if auth_version < min_version or auth_version > max_version:
+ logger.warning(f"Auth API version {auth_version} not supported (min={min_version}, max={max_version}), trying next version")
+ continue
+ else:
+ logger.warning("Failed to query API info, using default auth path")
+ else:
+ logger.warning(f"API info request failed with status {api_info_response.status_code}, using default auth path")
+ except Exception as e:
+ logger.warning(f"Error querying API info: {str(e)}, using default auth path")
+
+ # Основной запрос авторизации
+ url = f"{self.base_url}/{auth_path}"
+ params = {
+ "api": "SYNO.API.Auth",
+ "version": str(auth_version),
+ "method": "login",
+ "account": SYNOLOGY_USERNAME,
+ "passwd": SYNOLOGY_PASSWORD,
+ "session": "SynologyPowerControlBot",
+ "format": "cookie"
+ }
+
+ # Для версии 6+ используем немного другой формат
+ if auth_version >= 6:
+ params["enable_syno_token"] = "yes"
+
+ logger.debug(f"Sending auth request to {url} with API version {auth_version}")
+ start_time = time.time()
+ response = self.session.get(
+ url,
+ params=params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+ elapsed_time = time.time() - start_time
+ logger.debug(f"Auth request completed in {elapsed_time:.2f}s with status code {response.status_code}")
+
+ # Проверка статуса HTTP
+ if response.status_code != 200:
+ logger.error(f"HTTP error: {response.status_code}")
+ logger.debug(f"Response content: {response.text[:200]}")
+ continue # Пробуем следующую версию
+
+ try:
+ data = response.json()
+ except json.JSONDecodeError:
+ logger.error("Failed to decode JSON response")
+ logger.debug(f"Response content: {response.text[:200]}")
+ continue # Пробуем следующую версию
+
+ if data.get("success"):
+ self.sid = data.get("data", {}).get("sid")
+ self._last_auth_time = time.time()
+ logger.info(f"Successfully logged in to Synology NAS using API version {auth_version}")
+ logger.debug(f"Session ID obtained: {self.sid[:5] if self.sid else 'None'}...")
+
+ # Получаем и сохраняем токен SYNO, если он есть
+ syno_token = data.get("data", {}).get("synotoken")
+ if syno_token:
+ self.session.headers.update({'X-SYNO-TOKEN': syno_token})
+ logger.debug("Added X-SYNO-TOKEN header for improved API compatibility")
+
+ # Также добавляем SID в cookies для улучшения совместимости
+ self.session.cookies.update({
+ 'id': self.sid,
+ 'sid': self.sid
+ })
+ logger.debug("Added SID to session cookies for improved compatibility")
+
+ # Проверка валидности полученной сессии с помощью простого запроса
+ # Будем использовать SYNO.API.Info без проверки сложных методов
+
+ # Даем системе немного времени для инициализации сессии
+ time.sleep(0.5)
+
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to log in with version {auth_version}: Error code {error_code}")
+
+ # Если ошибка связана с версией API, пробуем следующую версию
+ if error_code in [104, 105]:
+ logger.warning(f"Auth version {auth_version} not supported, trying next version")
+ continue
+
+ # Дополнительная диагностика
+ if error_code == 400:
+ logger.error("Authentication error: Invalid credentials")
+ elif error_code == 401:
+ logger.error("Authentication error: Account disabled")
+ elif error_code == 402:
+ logger.error("Authentication error: Permission denied")
+ elif error_code == 403:
+ logger.error("Authentication error: 2-factor authentication required")
+ elif error_code == 404:
+ logger.error("Authentication error: Failed to authenticate with 2-factor authentication")
+
+ # Если ошибка связана с неверными учетными данными, нет смысла пробовать другие версии API
+ if error_code in [400, 401, 402, 403, 404]:
+ return False
+
+ except requests.exceptions.Timeout:
+ logger.error(f"Connection timeout during auth with version {auth_version}")
+ continue # Пробуем следующую версию
+ except requests.exceptions.ConnectionError as e:
+ logger.error(f"Connection error during auth with version {auth_version}: {str(e)}")
+ continue # Пробуем следующую версию
+ except requests.RequestException as e:
+ logger.error(f"Request error during auth with version {auth_version}: {str(e)}")
+ continue # Пробуем следующую версию
+ except Exception as e:
+ logger.error(f"Unexpected error during login with version {auth_version}: {str(e)}", exc_info=True)
+ continue # Пробуем следующую версию
+
+ # Если все версии не сработали
+ logger.error("Failed to authenticate with any API version")
+ return False
+
+ def _validate_session(self) -> bool:
+ """Проверяет валидность сессии после авторизации"""
+ if not self.sid:
+ return False
+
+ # Попробуем сделать простой запрос для проверки сессии
+ test_apis = [
+ {"api": "SYNO.Core.System", "method": "info", "version": 1},
+ {"api": "SYNO.DSM.Info", "method": "getinfo", "version": 1}
+ ]
+
+ for test_api in test_apis:
+ try:
+ url = f"{self.base_url}/entry.cgi"
+ params = {
+ "api": test_api["api"],
+ "version": str(test_api["version"]),
+ "method": test_api["method"],
+ "_sid": self.sid
+ }
+
+ response = self.session.get(url, params=params, timeout=self.default_timeout, verify=False)
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("success"):
+ logger.debug(f"Session validation successful using {test_api['api']}")
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ if error_code != 119: # Не сессия истекла
+ logger.debug(f"Session validation with {test_api['api']} returned error code {error_code}")
+ return True # Считаем сессию валидной, если ошибка не связана с истечением сессии
+ except Exception as e:
+ logger.warning(f"Error during session validation with {test_api['api']}: {str(e)}")
+
+ logger.warning("Session validation failed with all test APIs")
+ return False
+
+ def logout(self) -> bool:
+ """Выход из API Synology NAS"""
+ if not self.sid:
+ return True
+
+ try:
+ url = f"{self.base_url}/auth.cgi"
+ params = {
+ "api": "SYNO.API.Auth",
+ "version": "1",
+ "method": "logout",
+ "session": "SynologyPowerControlBot",
+ "_sid": self.sid
+ }
+
+ response = self.session.get(url, params=params, timeout=SYNOLOGY_TIMEOUT, verify=False)
+ data = response.json()
+
+ if data.get("success"):
+ self.sid = None
+ logger.info("Successfully logged out from Synology NAS")
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to log out from Synology NAS: Error code {error_code}")
+ return False
+
+ except requests.RequestException as e:
+ logger.error(f"Connection error: {str(e)}")
+ return False
+
+ def _make_api_request(self, api_name: str, method: str, version: int = 1,
+ params: Optional[Dict[str, Any]] = None, retry_auth: bool = True, retry_count: int = 0) -> Optional[Dict[str, Any]]:
+ """Обобщенный метод для выполнения API запросов с обработкой ошибок"""
+ # Ограничение на количество повторных попыток
+ if retry_count >= 3:
+ logger.error(f"Too many retries for {api_name}.{method}, giving up")
+ return None
+
+ # Проверка наличия авторизации
+ if not self.sid and not self.login():
+ logger.error(f"Not authenticated for API request: {api_name}.{method}")
+ return None
+
+ # Проверка информации API для определения пути и поддерживаемой версии
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": api_name
+ }
+
+ api_path = "entry.cgi"
+ try:
+ logger.debug(f"Querying API info for {api_name}")
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ api_info = api_info_data.get("data", {}).get(api_name, {})
+ if api_info:
+ max_version = api_info.get("maxVersion", version)
+ min_version = api_info.get("minVersion", version)
+ api_path = api_info.get("path", "entry.cgi")
+
+ # Проверка, поддерживается ли запрошенная версия
+ if version < min_version:
+ logger.warning(f"API version {version} for {api_name} is below minimum {min_version}, using {min_version}")
+ version = min_version
+ elif version > max_version:
+ logger.warning(f"API version {version} for {api_name} exceeds maximum {max_version}, using {max_version}")
+ version = max_version
+
+ logger.debug(f"Using API path: {api_path}, version: {version}")
+ else:
+ logger.warning(f"API {api_name} not found in API info, using defaults")
+ except Exception as e:
+ logger.warning(f"Error querying API info for {api_name}: {str(e)}, using defaults")
+
+ # Подготовка базовых параметров запроса
+ base_params = {
+ "api": api_name,
+ "version": str(version),
+ "method": method,
+ "_sid": self.sid # Используем _sid вместо sid для лучшей совместимости
+ }
+
+ # Добавление дополнительных параметров, если они заданы
+ if params:
+ base_params.update(params)
+
+ url = f"{self.base_url}/{api_path}"
+ logger.debug(f"API request: {api_name}.{method} v{version} to {url}")
+ logger.debug(f"Full request params: {base_params}")
+
+ try:
+ start_time = time.time()
+ response = self.session.get(
+ url,
+ params=base_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+ elapsed_time = time.time() - start_time
+ logger.debug(f"API request completed in {elapsed_time:.2f}s with status {response.status_code}")
+
+ # Проверка статуса HTTP
+ if response.status_code != 200:
+ logger.error(f"HTTP error: {response.status_code} for {api_name}.{method}")
+
+ # Повторная попытка при ошибках соединения
+ if response.status_code in [500, 502, 503, 504]:
+ logger.info(f"Server error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+
+ try:
+ data = response.json()
+ except json.JSONDecodeError:
+ logger.error(f"Failed to decode JSON response for {api_name}.{method}")
+ logger.debug(f"Response content: {response.text[:200]}")
+
+ # Повторная попытка при ошибках декодирования
+ logger.info(f"JSON decode error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ if data.get("success"):
+ logger.info(f"API request successful for {api_name}.{method}")
+ return data.get("data", {})
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ error_desc = self._get_error_description(error_code)
+ logger.error(f"API error for {api_name}.{method}: {error_code} - {error_desc}")
+
+ # Ошибки доступа или прав часто встречаются, но они не критичные
+ # Например, ошибка 102 означает, что нет прав, но NAS доступен
+ if error_code in [102, 103, 104, 105]:
+ logger.warning(f"Permission-related error for {api_name}.{method}: {error_code}")
+ # Возвращаем пустой словарь вместо None,
+ # чтобы вызывающий код мог понять, что запрос выполнен
+ return {}
+
+ # Если ошибка связана с авторизацией и нам разрешено повторить попытку
+ if error_code in [106, 107, 119] and retry_auth:
+ logger.info(f"Session error (code {error_code}), creating fresh session...")
+ self.sid = None # Сбрасываем SID
+
+ # Для ошибки 119 (Session timeout) дадим системе немного времени
+ if error_code == 119:
+ logger.info("Session timeout detected, waiting before retry...")
+ sleep(3)
+
+ if self.login():
+ logger.info("Re-authenticated with fresh session, retrying API request...")
+ # Рекурсивный вызов, но со счетчиком повторов
+ return self._make_api_request(api_name, method, version, params, False, retry_count + 1)
+
+ # Для некоторых ошибок можно автоматически повторить запрос
+ if error_code in [408, 429, 500, 502, 503, 504]:
+ logger.info(f"Temporary error {error_code}, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+
+ except requests.exceptions.Timeout:
+ logger.error(f"Request timeout for {api_name}.{method}")
+
+ # Повторная попытка при таймауте
+ if retry_count < 2:
+ logger.info(f"Timeout, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+ except requests.exceptions.ConnectionError as e:
+ logger.error(f"Connection error for {api_name}.{method}: {str(e)}")
+
+ # Повторная попытка при ошибке соединения
+ if retry_count < 2:
+ logger.info(f"Connection error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+ except Exception as e:
+ logger.error(f"Unexpected error during {api_name}.{method}: {str(e)}")
+ return None
+
+ def get_system_status(self) -> Dict[str, Any]:
+ """Получение статуса системы"""
+ # Проверяем доступность системы
+ if not self.is_online():
+ logger.info("Device is offline, skipping API request")
+ return {"status": "offline"}
+
+ # Проверяем, есть ли кэшированный результат
+ cache_key = "system_status"
+ current_time = time.time()
+ if cache_key in self._cache and current_time - self._cache_ttl.get(cache_key, 0) < 60:
+ logger.debug("Using cached system status")
+ return self._cache[cache_key]
+
+ # Используем рекомендованный API для получения информации о системе
+ logger.info(f"Getting system info using {SYNOLOGY_INFO_API} v{SYNOLOGY_API_VERSION}")
+
+ # Для SYNO.DSM.Info метод getinfo, для других обычно используется get или info
+ if SYNOLOGY_INFO_API == "SYNO.DSM.Info":
+ method = "getinfo"
+ else:
+ method = "get"
+
+ result = self._make_api_request(SYNOLOGY_INFO_API, method, version=SYNOLOGY_API_VERSION)
+
+ if result:
+ logger.info(f"Successfully retrieved system info using {SYNOLOGY_INFO_API}")
+
+ # Формируем расширенный ответ с дополнительной информацией
+ system_info = {
+ "status": "online",
+ "hostname": result.get("hostname", "unknown"),
+ "model": result.get("model", "unknown"),
+ "version": result.get("version", "unknown"),
+ "uptime": result.get("uptime", 0),
+ "time": current_time,
+ "is_online": True,
+ "api_used": SYNOLOGY_INFO_API
+ }
+
+ # Сохраняем в кэше
+ self._cache[cache_key] = system_info
+ self._cache_ttl[cache_key] = current_time
+
+ return system_info
+
+ # Если основной API не сработал, пробуем резервные варианты
+ logger.warning(f"Failed to retrieve system info with {SYNOLOGY_INFO_API}, trying fallback APIs")
+
+ # Пробуем резервные API
+ apis_to_try = [
+ {"name": "SYNO.DSM.Info", "method": "getinfo", "version": 2},
+ {"name": "SYNO.Core.System", "method": "info", "version": 1},
+ {"name": "SYNO.Core.System.Status", "method": "get", "version": 1},
+ {"name": "SYNO.Core.System.Info", "method": "get", "version": 1},
+ ]
+
+ for api in apis_to_try:
+ if api["name"] == SYNOLOGY_INFO_API:
+ continue # Пропускаем уже проверенный API
+
+ logger.info(f"Trying to get system info using {api['name']}.{api['method']} v{api['version']}")
+ result = self._make_api_request(api["name"], api["method"], version=api["version"])
+
+ if result:
+ logger.info(f"Successfully retrieved system info using {api['name']}")
+
+ # Формируем расширенный ответ с дополнительной информацией
+ system_info = {
+ "status": "online",
+ "hostname": result.get("hostname", "unknown"),
+ "model": result.get("model", "unknown"),
+ "version": result.get("version", "unknown"),
+ "uptime": result.get("uptime", 0),
+ "time": current_time,
+ "is_online": True,
+ "api_used": api["name"]
+ }
+
+ # Сохраняем в кэше
+ self._cache[cache_key] = system_info
+ self._cache_ttl[cache_key] = current_time
+
+ return system_info
+
+ # Если все запросы не удались, но система онлайн, возвращаем базовую информацию
+ logger.warning("Failed to retrieve system info with all API methods")
+ return {
+ "status": "error",
+ "error": "Failed to fetch system information",
+ "is_online": True,
+ "time": current_time
+ }
+
+ def shutdown_system(self) -> bool:
+ """Выключение системы"""
+ # Проверяем, включено ли устройство перед попыткой его выключить
+ if not self.is_online(force_check=True):
+ logger.info("Synology NAS is already offline, no need to shut down")
+ return True
+
+ logger.info("Attempting to shutdown Synology NAS...")
+
+ # Попробуем сначала использовать предпочтительный API для управления питанием
+ logger.info(f"Trying shutdown with {SYNOLOGY_POWER_API} v{SYNOLOGY_API_VERSION}")
+
+ # Для SYNO.Core.Hardware.PowerRecovery используется метод setPowerOnState
+ # Для других API обычно используется метод shutdown или reboot
+ if SYNOLOGY_POWER_API == "SYNO.Core.Hardware.PowerRecovery":
+ # Для этого API нужны специальные параметры
+ params = {"state": "powerbtn"} # powerbtn имитирует нажатие кнопки питания
+ result = self._make_api_request(SYNOLOGY_POWER_API, "setPowerOnState", version=SYNOLOGY_API_VERSION, params=params)
+ else:
+ # Пробуем стандартный метод
+ result = self._make_api_request(SYNOLOGY_POWER_API, "shutdown", version=SYNOLOGY_API_VERSION)
+
+ if result is not None:
+ logger.info(f"Successfully initiated system shutdown using {SYNOLOGY_POWER_API}")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ # Проверяем статус
+ if not self.is_online(force_check=True):
+ logger.info("System is now offline. Shutdown confirmed successful.")
+ return True
+ else:
+ logger.info("System still appears to be online, but shutdown may be in progress.")
+ return True
+ else:
+ logger.warning(f"Failed to shutdown using {SYNOLOGY_POWER_API}, trying alternative methods")
+
+ # Если не сработал основной метод, пробуем резервные варианты
+ # Проверка всех доступных методов API для выключения
+ apis_to_try = [
+ {"name": "SYNO.Core.System", "method": "shutdown", "version": 3},
+ {"name": "SYNO.Core.System.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.DSM.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.System.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.DSM.System", "method": "shutdown", "version": 1}
+ ]
+
+ # Проверяем доступные API
+ try:
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": "SYNO.Core.System.Power,SYNO.DSM.Power,SYNO.Core.System,SYNO.System.Power,SYNO.DSM.System"
+ }
+
+ logger.debug("Checking available shutdown APIs")
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ available_apis = api_info_data.get("data", {})
+ logger.debug(f"Available APIs: {list(available_apis.keys())}")
+
+ # Фильтруем только доступные API
+ filtered_apis = []
+ for api in apis_to_try:
+ if api["name"] in available_apis:
+ api_info = available_apis[api["name"]]
+ # Проверка версии API
+ if api_info.get("minVersion", 1) <= api["version"] <= api_info.get("maxVersion", 1):
+ filtered_apis.append(api)
+ logger.debug(f"Adding {api['name']} to available shutdown APIs")
+ else:
+ logger.debug(f"API {api['name']} version {api['version']} not supported. Supported: {api_info.get('minVersion')}-{api_info.get('maxVersion')}")
+
+ if filtered_apis:
+ apis_to_try = filtered_apis
+ else:
+ logger.warning("No compatible APIs found, trying all methods as fallback")
+ else:
+ logger.warning("Failed to query API info, using default methods")
+ else:
+ logger.warning(f"API info request failed with status {api_info_response.status_code}, using default methods")
+ except Exception as e:
+ logger.warning(f"Error querying API info: {str(e)}, using default methods")
+
+ # Пробуем все доступные методы по порядку
+ for api in apis_to_try:
+ logger.info(f"Trying shutdown with {api['name']}.{api['method']} v{api['version']}")
+ result = self._make_api_request(api["name"], api["method"], version=api["version"])
+
+ if result is not None:
+ logger.info(f"Successfully initiated system shutdown using {api['name']}")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ # Проверяем статус
+ if not self.is_online(force_check=True):
+ logger.info("System is now offline. Shutdown confirmed successful.")
+ return True
+ else:
+ logger.info("System still appears to be online, but shutdown may be in progress.")
+ return True
+ else:
+ logger.warning(f"Failed to shutdown using {api['name']}.{api['method']} v{api['version']}")
+
+ # Если ни один метод не сработал, но система стала недоступна
+ if not self.is_online(force_check=True):
+ logger.info("System appears to be shutting down despite API errors")
+ return True
+
+ logger.error("Failed to shutdown system after trying multiple APIs")
+ return False
+
+ def reboot_system(self) -> bool:
+ """Перезагрузка системы"""
+ # Проверяем, включена ли система
+ if not self.is_online(force_check=True):
+ logger.error("Cannot reboot: System is offline")
+ return False
+
+ logger.info("Attempting to reboot Synology NAS...")
+
+ # Список API и методов для попытки перезагрузки
+ apis_to_try = [
+ {"api": "SYNO.Core.System", "method": "reboot", "version": 1},
+ {"api": "SYNO.DSM.System", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.System.Power", "method": "restart", "version": 1},
+ {"api": "SYNO.DSM.Power", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.System", "method": "reboot", "version": 3},
+ {"api": "SYNO.System.Power", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.Hardware.NeedReboot", "method": "reboot", "version": 1}
+ ]
+
+ # Добавляем настроенный в конфигурации API, если он отличается от уже добавленных
+ already_added = [item["api"] for item in apis_to_try]
+ if SYNOLOGY_POWER_API not in already_added:
+ for method in ["restart", "reboot"]:
+ apis_to_try.append({
+ "api": SYNOLOGY_POWER_API,
+ "method": method,
+ "version": SYNOLOGY_API_VERSION
+ })
+
+ # Перебираем все возможные API и методы
+ for api_info in apis_to_try:
+ try:
+ logger.info(f"Trying reboot with {api_info['api']} API using method {api_info['method']}")
+ result = self._make_api_request(api_info['api'], api_info['method'], version=api_info['version'])
+ if result is not None:
+ logger.info(f"Successfully initiated system reboot using {api_info['api']} API")
+
+ # Даем системе время начать процесс перезагрузки
+ logger.info("Waiting for reboot to initialize...")
+ sleep(5)
+
+ # Ждем, пока система станет недоступна (признак перезагрузки)
+ reboot_started = False
+ for i in range(12): # Проверяем в течение 60 секунд (12 * 5сек)
+ if not self.is_online(force_check=True):
+ logger.info(f"System went offline after {i*5} seconds, reboot in progress")
+ reboot_started = True
+ break
+ logger.debug(f"System still online, waiting... ({i+1}/12)")
+ sleep(5)
+
+ if reboot_started:
+ # Успешный вызов API и система ушла оффлайн - это признак успешной перезагрузки
+ return True
+ else:
+ # Успешный вызов API, но система не ушла оффлайн
+ logger.warning("System did not go offline after reboot command, but command was accepted")
+ # Даже если система не ушла оффлайн, команда могла быть принята
+ return True
+ except Exception as e:
+ logger.error(f"Error during reboot with {api_info['api']}: {str(e)}")
+
+ # Если все попытки не удались, возвращаем False
+ logger.error("All reboot attempts failed")
+ return False
+
+ def _get_error_description(self, error_code: int) -> str:
+ """Получение описания ошибки по коду"""
+ error_descriptions = {
+ 100: "Unknown error",
+ 101: "Invalid parameter",
+ 102: "API does not exist",
+ 103: "Method does not exist",
+ 104: "Version does not support",
+ 105: "Permission denied",
+ 106: "Session timeout",
+ 107: "Session interrupted by duplicate login",
+ 400: "Invalid credentials",
+ 401: "Account disabled",
+ 402: "Permission denied",
+ 403: "2FA required",
+ 404: "Failed to authenticate with 2FA"
+ }
+ return error_descriptions.get(error_code, "Unknown error code")
+
+ def _check_tcp_connection(self) -> bool:
+ """Проверка базового TCP-соединения с Synology NAS"""
+ try:
+ socket_obj = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ socket_obj.settimeout(SYNOLOGY_TIMEOUT)
+ result = socket_obj.connect_ex((SYNOLOGY_HOST, SYNOLOGY_PORT))
+ socket_obj.close()
+
+ return result == 0
+ except socket.error as e:
+ logger.error(f"Socket error during connection check: {str(e)}")
+ return False
+ except Exception as e:
+ logger.error(f"Unexpected error during connection check: {str(e)}")
+ return False
+
+ def is_online(self, force_check=False) -> bool:
+ """Проверка онлайн-статуса Synology NAS"""
+ # Используем кэшированное значение, если доступно и не устарело
+ current_time = time.time()
+ if not force_check and (current_time - self._last_online_check) < self._online_check_interval:
+ logger.debug(f"Using cached online status: {self._last_online_status}")
+ return self._last_online_status
+
+ logger.info("Checking if NAS is online...")
+
+ # Проверяем TCP-соединение
+ online_status = self._check_tcp_connection()
+ logger.info(f"Detected Synology NAS online status: {online_status}")
+
+ # Если TCP-соединение успешно и у нас есть действующий SID,
+ # попробуем более детальную проверку через API
+ if online_status and self.sid:
+ logger.info("Trying to fetch more detailed online status through API...")
+
+ # Пробуем разные API для проверки онлайн-статуса
+ api_checks = [
+ {"api": "SYNO.DSM.Info", "version": "2", "method": "getinfo"},
+ {"api": "SYNO.Core.System", "version": "1", "method": "info"},
+ {"api": "SYNO.Core.System.Status", "version": "1", "method": "get"}
+ ]
+
+ api_success = False
+ for api_check in api_checks:
+ try:
+ url = f"{self.base_url}/entry.cgi"
+ params = {
+ "api": api_check["api"],
+ "version": api_check["version"],
+ "method": api_check["method"],
+ "sid": self.sid
+ }
+
+ logger.debug(f"Trying online status check with {api_check['api']}")
+ response = self.session.get(
+ url,
+ params=params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("success"):
+ logger.info(f"API request successful for {api_check['api']}")
+ logger.info("Synology NAS is online with API access")
+ api_success = True
+ break
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.warning(f"API response indicates an error: {error_code}, but NAS is reachable")
+ else:
+ logger.warning(f"API returned status code {response.status_code}")
+ except Exception as e:
+ logger.warning(f"API check failed for {api_check['api']}, but TCP connection succeeded: {str(e)}")
+
+ if not api_success:
+ logger.warning("All API checks failed, but TCP connection is successful")
+
+ # Обновляем кэшированное значение
+ self._last_online_check = current_time
+ self._last_online_status = online_status
+
+ return online_status
+
+ def wake_on_lan(self) -> bool:
+ """Отправка Wake-on-LAN пакета для включения Synology NAS"""
+ if not SYNOLOGY_MAC:
+ logger.error("MAC address not configured")
+ return False
+
+ try:
+ # Преобразование MAC-адреса в байты
+ mac_address = SYNOLOGY_MAC.replace(':', '').replace('-', '')
+ if len(mac_address) != 12:
+ logger.error(f"Invalid MAC address format: {SYNOLOGY_MAC}")
+ return False
+
+ try:
+ mac_bytes = bytes.fromhex(mac_address)
+ except ValueError as e:
+ logger.error(f"Failed to parse MAC address: {str(e)}")
+ logger.error(f"MAC address must contain only hex characters: {SYNOLOGY_MAC}")
+ return False
+
+ # Создание Magic Packet
+ magic_packet = b'\xff' * 6 + mac_bytes * 16
+
+ # Отправка пакета на конкретный адрес
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.sendto(magic_packet, (SYNOLOGY_HOST, WOL_PORT))
+ sock.close()
+ logger.info(f"Wake-on-LAN packet sent to {SYNOLOGY_MAC} at {SYNOLOGY_HOST}:{WOL_PORT}")
+ except Exception as e:
+ logger.error(f"Error sending directed WoL packet: {str(e)}")
+ return False
+
+ # Для надежности отправляем также широковещательный пакет
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+ # Используем стандартный широковещательный адрес
+ broadcast_addr = "255.255.255.255"
+ sock.sendto(magic_packet, (broadcast_addr, WOL_PORT))
+ sock.close()
+ logger.info(f"Wake-on-LAN broadcast packet sent to {broadcast_addr}:{WOL_PORT}")
+ except Exception as e:
+ logger.warning(f"Error sending broadcast WoL packet (non-critical): {str(e)}")
+ # Не считаем ошибкой, т.к. основной пакет уже отправлен
+
+ return True
+
+ except Exception as e:
+ logger.error(f"Unexpected error in wake_on_lan: {str(e)}")
+ return False
+
+ def wait_for_boot(self, max_attempts: int = 30, delay: int = 10) -> bool:
+ """Ожидание загрузки Synology NAS"""
+ logger.info(f"Waiting for Synology NAS to boot (timeout: {max_attempts * delay}s)...")
+
+ for attempt in range(max_attempts):
+ # Принудительно проверяем статус без использования кэша
+ if self.is_online(force_check=True):
+ logger.info(f"Synology NAS is online after {attempt + 1} attempts ({(attempt + 1) * delay}s)")
+
+ # Проверяем, что не только сеть доступна, но и API загрузился
+ api_ready = False
+ logger.info("Waiting for API services to initialize...")
+
+ for api_check in range(5): # Даем еще до 50 секунд для загрузки API
+ if self.sid or self.login():
+ api_ready = True
+ logger.info(f"API services are ready after {api_check + 1} attempts")
+ break
+ logger.debug(f"API not ready yet, waiting... ({api_check + 1}/5)")
+ sleep(10)
+
+ if not api_ready:
+ logger.warning("System is online but API services may not be fully initialized")
+
+ # Дадим дополнительное время для полной загрузки всех сервисов
+ sleep(delay)
+ return True
+
+ sleep(delay)
+ logger.info(f"Waiting for boot... Attempt {attempt + 1}/{max_attempts}")
+
+ logger.error(f"Synology NAS didn't come online after {max_attempts * delay} seconds")
+ return False
+
+ def power_on(self) -> bool:
+ """Включение Synology NAS"""
+ # Принудительная проверка статуса
+ if self.is_online(force_check=True):
+ logger.info("Synology NAS is already online")
+ return True
+
+ logger.info("Powering on Synology NAS via Wake-on-LAN...")
+
+ # Проверяем, настроен ли MAC-адрес
+ if not SYNOLOGY_MAC:
+ logger.error("Cannot power on: MAC address not configured in settings")
+ return False
+
+ # Пробуем отправить несколько WoL пакетов для надежности
+ success = False
+ for attempt in range(3):
+ logger.debug(f"Sending WoL packet, attempt {attempt + 1}/3")
+ if self.wake_on_lan():
+ success = True
+ break
+ sleep(1)
+
+ if not success:
+ logger.error("Failed to send Wake-on-LAN packets")
+ return False
+
+ # Ожидание загрузки
+ logger.info("WoL packets sent successfully, waiting for system to boot...")
+ boot_result = self.wait_for_boot(max_attempts=30, delay=10)
+
+ if boot_result:
+ # Проверяем доступность API после загрузки
+ system_status = self.get_system_status()
+ if system_status.get("status") == "online":
+ logger.info("System booted successfully with API access")
+ return True
+ else:
+ logger.warning("System appears to be online but API may not be fully ready")
+ return True
+ else:
+ logger.error("System did not come online after WoL")
+ return False
+
+ def power_off(self) -> bool:
+ """Выключение Synology NAS"""
+ if not self.is_online(force_check=True):
+ logger.info("Synology NAS is already offline")
+ return True
+
+ logger.info("Powering off Synology NAS...")
+
+ # Список API и методов для попытки выключения
+ apis_to_try = [
+ {"api": "SYNO.Core.System", "method": "shutdown", "version": 1},
+ {"api": "SYNO.DSM.System", "method": "shutdown", "version": 1},
+ {"api": SYNOLOGY_POWER_API, "method": "shutdown", "version": SYNOLOGY_API_VERSION}
+ ]
+
+ # Перебираем все возможные API и методы
+ for api_info in apis_to_try:
+ try:
+ logger.info(f"Trying shutdown with {api_info['api']} API using method {api_info['method']}")
+ api_result = self._make_api_request(api_info['api'], api_info['method'], version=api_info['version'])
+ if api_result is not None:
+ logger.info(f"Successfully initiated system shutdown using {api_info['api']} API")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ return True
+ except Exception as e:
+ logger.error(f"Error during shutdown with {api_info['api']}: {str(e)}")
+
+ # Если все попытки не удались, возвращаем False
+ logger.error("All shutdown attempts failed")
+ return False
+
+ # Если все еще не сработало, используем оригинальный метод shutdown_system
+ if not result:
+ result = self.shutdown_system()
+
+ if result:
+ # Дополнительная проверка, что система действительно выключилась
+ logger.info("Verifying system is offline...")
+ for attempt in range(12): # Проверяем в течение 2 минут (12 * 10 сек)
+ if not self.is_online(force_check=True):
+ logger.info(f"System confirmed offline after {attempt * 10} seconds")
+ return True
+ logger.debug(f"System still shutting down, checking again... ({attempt + 1}/12)")
+ sleep(10)
+
+ logger.warning("System still appears to be online after shutdown command")
+ return False
+ else:
+ logger.error("Failed to initiate shutdown")
+ return False
+
+ # Заглушки для расширенных методов
+ def get_shared_folders(self) -> List[Dict[str, Any]]:
+ """Получение списка общих папок"""
+ logger.info("Getting list of shared folders")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for shared folders request")
+ return []
+
+ try:
+ # Запрашиваем список общих папок через FileStation API
+ result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for shared folders")
+ alt_result = self._make_api_request("SYNO.FileStation.List", "list_share", version=1)
+ if alt_result:
+ return alt_result.get("shares", [])
+ return []
+
+ return result.get("shares", [])
+
+ except Exception as e:
+ logger.error(f"Error getting shared folders: {str(e)}")
+ return []
+
+ def get_system_load(self) -> Dict[str, Any]:
+ """Получение информации о загрузке системы"""
+ logger.info("Getting system load information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for system load request")
+ return {}
+
+ try:
+ # Запрашиваем информацию о загрузке системы
+ result = self._make_api_request("SYNO.Core.System.Utilization", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for system load")
+ alt_result = self._make_api_request("SYNO.Core.System", "info", version=3)
+
+ if not alt_result:
+ return {}
+
+ # Формируем из частичных данных
+ return {
+ "cpu_load": alt_result.get("cpu_usage", 0),
+ "memory": {
+ "total": alt_result.get("memory_size", 0),
+ "used": alt_result.get("memory_usage", 0),
+ "usage_percent": alt_result.get("memory_usage_percent", 0)
+ }
+ }
+
+ # Формируем структурированный результат
+ return {
+ "cpu_load": result.get("cpu", {}).get("user", 0) + result.get("cpu", {}).get("system", 0),
+ "memory": result.get("memory", {}),
+ "network": result.get("network", {})
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting system load: {str(e)}")
+ return {}
+
+ def is_online_api(self) -> bool:
+ """Проверка онлайн-статуса Synology NAS с использованием API"""
+ if not self.is_online():
+ return False
+
+ # Проверяем доступность API через авторизацию
+ if not self.sid and not self.login():
+ return False
+
+ return True
+
+ def get_storage_status(self) -> Dict[str, Any]:
+ """Получение подробной информации о хранилище"""
+ logger.info("Getting storage status information")
+
+ # Проверяем доступность NAS и API
+ if not self.is_online_api():
+ logger.error("Cannot get storage status: NAS is not online or API is not accessible")
+ return {"error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о хранилище через SYNO.Storage.CGI.Storage API
+ result = self._make_api_request("SYNO.Storage.CGI.Storage", "load_info", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for storage info")
+ alt_result = self._make_api_request("SYNO.Core.Storage", "info", version=1)
+
+ if not alt_result:
+ # Пробуем еще один альтернативный API
+ logger.info("Trying SYNO.Core.System API for storage info")
+ sys_result = self._make_api_request("SYNO.Core.System", "info", version=3)
+
+ if not sys_result:
+ return {
+ "volumes": [],
+ "disks": [],
+ "total_size": 0,
+ "total_used": 0,
+ "error": "no_data"
+ }
+
+ # Извлекаем базовую информацию о хранилище из системной информации
+ return {
+ "volumes": [],
+ "disks": [],
+ "total_size": sys_result.get("hdd_total_size", 0) * 1024**2, # МБ в байты
+ "total_used": sys_result.get("hdd_total_size", 0) * sys_result.get("hdd_usage", 0) / 100 * 1024**2,
+ }
+
+ # Обрабатываем данные из альтернативного API
+ volumes = alt_result.get("volumes", [])
+ disks = alt_result.get("disks", [])
+
+ else:
+ # Обрабатываем данные из основного API
+ volumes = result.get("volumes", [])
+ disks = result.get("disks", [])
+
+ # Рассчитываем общие размеры
+ total_size = 0
+ total_used = 0
+
+ for volume in volumes:
+ volume_size = volume.get("size", {}).get("total", 0)
+ volume_used = volume.get("size", {}).get("used", 0)
+
+ total_size += volume_size
+ total_used += volume_used
+
+ return {
+ "volumes": volumes,
+ "disks": disks,
+ "total_size": total_size,
+ "total_used": total_used
+ }
+
+ except Exception as e:
+ logger.error(f"Error in get_storage_status: {str(e)}")
+ return {"error": str(e)}
+
+ def get_security_status(self) -> Dict[str, Any]:
+ """Получение информации о состоянии безопасности"""
+ logger.info("Getting security status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for security status request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о безопасности через API Security Scan
+ result = self._make_api_request("SYNO.Core.SecurityScan.Status", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for security status")
+ # Проверяем статус брандмауэра
+ firewall_result = self._make_api_request("SYNO.Core.Security.Firewall.Status", "get", version=1)
+
+ # Проверяем статус автоматических обновлений
+ update_result = self._make_api_request("SYNO.Core.Upgrade.Server.Setting", "get", version=1)
+
+ # Если ни один из API не отвечает
+ if not firewall_result and not update_result:
+ # Получаем общую информацию о системе для базовой проверки безопасности
+ sys_result = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ if not sys_result:
+ return {
+ "success": False,
+ "status": "unknown",
+ "last_check": None,
+ "is_secure": False,
+ "error": "no_security_api"
+ }
+
+ # Собираем базовые сведения из системной информации
+ return {
+ "success": True,
+ "status": "basic",
+ "last_check": None,
+ "is_secure": True, # Предполагаем, что система в целом безопасна
+ "firewall_enabled": None,
+ "auto_update": None,
+ "version_latest": sys_result.get("version_string", "")
+ }
+
+ # Собираем информацию из доступных результатов
+ firewall_enabled = firewall_result.get("enabled", False) if firewall_result else None
+ auto_update = update_result.get("auto_update", False) if update_result else None
+
+ # Определяем, насколько система безопасна
+ is_secure = True # По умолчанию предполагаем, что система безопасна
+ if firewall_enabled is not None and not firewall_enabled:
+ is_secure = False
+
+ return {
+ "success": True,
+ "status": "partial",
+ "last_check": None,
+ "is_secure": is_secure,
+ "firewall_enabled": firewall_enabled,
+ "auto_update": auto_update
+ }
+
+ # Если основное API отвечает, возвращаем его данные
+ return {
+ "success": True,
+ "status": result.get("status", "unknown"),
+ "last_check": result.get("last_check", None),
+ "is_secure": result.get("is_secure", False),
+ "details": result.get("details", {})
+ }
+
+ except Exception as e:
+ logger.error(f"Error in get_security_status: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_processes(self, limit: int = 20) -> List[Dict[str, Any]]:
+ """Получение списка активных процессов"""
+ logger.info(f"Getting list of active processes (limit={limit})")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for processes request")
+ return []
+
+ try:
+ # Получаем список процессов через API
+ result = self._make_api_request("SYNO.Core.System.Process", "list", version=1,
+ params={"sort_by": "cpu", "order": "DESC", "limit": limit})
+
+ if not result:
+ logger.warning("Failed to get process list")
+ return []
+
+ return result.get("processes", [])
+
+ except Exception as e:
+ logger.error(f"Error getting process list: {str(e)}")
+ return []
+
+ def get_network_status(self) -> Dict[str, Any]:
+ """Получение информации о сетевых подключениях"""
+ logger.info("Getting network status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for network status request")
+ return {}
+
+ try:
+ # Получаем информацию о сетевых интерфейсах
+ interface_result = self._make_api_request("SYNO.Core.Network.Interface", "list", version=1)
+
+ # Получаем статистику использования сети
+ utilization_result = self._make_api_request("SYNO.Core.System.Utilization", "get", version=1)
+
+ interfaces = []
+ if interface_result:
+ interfaces = interface_result.get("interfaces", [])
+
+ network_stats = {}
+ if utilization_result and "network" in utilization_result:
+ network_stats = utilization_result.get("network", {})
+
+ # Объединяем данные
+ for interface in interfaces:
+ iface_id = interface.get("id", "")
+ if iface_id in network_stats:
+ interface["rx"] = network_stats[iface_id].get("rx", 0)
+ interface["tx"] = network_stats[iface_id].get("tx", 0)
+
+ return {
+ "interfaces": interfaces,
+ "statistics": network_stats
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting network status: {str(e)}")
+ return {}
+
+ def get_system_logs(self, limit: int = 20) -> List[Dict[str, Any]]:
+ """Получение журналов системы"""
+ logger.info(f"Getting system logs (limit={limit})")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for system logs request")
+ return []
+
+ try:
+ # Получаем журналы через API
+ result = self._make_api_request("SYNO.Core.SyslogClient.Log", "list", version=1,
+ params={"start": 0, "limit": limit, "sort": "time", "dir": "DESC"})
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for system logs")
+ alt_result = self._make_api_request("SYNO.Core.Log", "list", version=1,
+ params={"start": 0, "limit": limit, "sort": "time", "dir": "DESC"})
+
+ if alt_result:
+ return alt_result.get("logs", [])
+ return []
+
+ return result.get("logs", [])
+
+ except Exception as e:
+ logger.error(f"Error getting system logs: {str(e)}")
+ return []
+
+ def get_power_schedule(self) -> Dict[str, Any]:
+ """Получение расписания включения/выключения"""
+ logger.info("Getting power schedule")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for power schedule request")
+ return {}
+
+ try:
+ # Пробуем сначала более новый API
+ result = self._make_api_request("SYNO.Core.System.PowerSchedule", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ result = self._make_api_request("SYNO.Core.System", "get_power_schedule", version=1)
+
+ if not result:
+ # Если нет результатов, вернем структуру, которую ожидает код
+ logger.warning("PowerSchedule API not available, returning empty schedule structure")
+ return {
+ "boot_tasks": [],
+ "shutdown_tasks": []
+ }
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Error getting power schedule: {str(e)}")
+ return {
+ "boot_tasks": [],
+ "shutdown_tasks": []
+ }
+
+ def set_power_schedule(self, schedule_type: str, days: List[int], time: str, enabled: bool = True) -> bool:
+ """Настройка расписания включения/выключения
+
+ Args:
+ schedule_type: Тип расписания ('boot' или 'shutdown')
+ days: Список дней недели (0-6, где 0 - понедельник)
+ time: Время в формате 'HH:MM'
+ enabled: Включить или выключить расписание
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Setting power schedule for {schedule_type} at {time} on days {days}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for setting power schedule")
+ return False
+
+ try:
+ # Пробуем сначала более новый API
+ api_name = "SYNO.Core.System.PowerSchedule"
+ method = "set"
+ version = 1
+
+ # Подготавливаем новое расписание
+ params = {
+ "enabled": enabled,
+ "type": schedule_type,
+ "day": days,
+ "time": time
+ }
+
+ # Устанавливаем новое расписание
+ result = self._make_api_request(api_name, method, version, params=params)
+
+ if not result:
+ # Пробуем альтернативный API
+ api_name = "SYNO.Core.System"
+ method = "set_power_schedule"
+ result = self._make_api_request(api_name, method, version, params=params)
+
+ if not result:
+ logger.error("Failed to set power schedule with any available API")
+ return False
+
+ logger.info(f"Power schedule for {schedule_type} set successfully with {api_name}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error setting power schedule: {str(e)}")
+ return False
+
+ def get_temperature_status(self) -> Dict[str, Any]:
+ """Получение информации о температуре системы и дисков"""
+ logger.info("Getting temperature status")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for temperature status request")
+ return {}
+
+ try:
+ # Получаем информацию о системе для общей температуры
+ system_info = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ # Получаем информацию о дисках для их температуры
+ storage_info = self._make_api_request("SYNO.Storage.CGI.Storage", "load_info", version=1)
+
+ system_temp = None
+ disk_temps = []
+
+ if system_info:
+ system_temp = system_info.get("temperature")
+
+ if storage_info:
+ disks = storage_info.get("disks", [])
+ for disk in disks:
+ name = disk.get("name", "unknown")
+ model = disk.get("model", "unknown")
+ temp = disk.get("temp", None)
+ if temp is not None:
+ disk_temps.append({
+ "name": name,
+ "model": model,
+ "temperature": temp
+ })
+
+ return {
+ "system_temperature": system_temp,
+ "disk_temperatures": disk_temps,
+ "warning": system_info.get("temperature_warn", False) if system_info else False
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting temperature status: {str(e)}")
+ return {}
+
+ def browse_files(self, folder_path: str = "", limit: int = 100) -> Dict[str, Any]:
+ """Просмотр файлов в указанной директории
+
+ Args:
+ folder_path: Путь к папке (пустая строка для корневых общих папок)
+ limit: Максимальное количество элементов для возврата
+
+ Returns:
+ Словарь с информацией о файлах и папках
+ """
+ logger.info(f"Browsing files in {folder_path or 'root'}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file browsing")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ if not folder_path:
+ # Если путь не указан, получаем список общих папок
+ result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not result:
+ logger.error("Failed to list shared folders")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "items": result.get("shares", []),
+ "path": "",
+ "is_root": True
+ }
+ else:
+ # Получаем список файлов в указанной директории
+ params = {
+ "folder_path": folder_path,
+ "limit": limit,
+ "offset": 0,
+ "sort_by": "name",
+ "sort_direction": "ASC"
+ }
+
+ result = self._make_api_request("SYNO.FileStation.List", "list", version=2, params=params)
+
+ if not result:
+ logger.error(f"Failed to list files in {folder_path}")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "items": result.get("files", []),
+ "path": folder_path,
+ "is_root": False,
+ "total": result.get("total", 0)
+ }
+
+ except Exception as e:
+ logger.error(f"Error browsing files: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def manage_service(self, service_name: str, action: str = "status") -> Dict[str, Any]:
+ """Управление системным сервисом
+
+ Args:
+ service_name: Имя сервиса
+ action: Действие (status/start/stop/restart)
+
+ Returns:
+ Словарь с результатом операции
+ """
+ logger.info(f"Managing service {service_name}, action: {action}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for service management")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Проверяем доступное API для управления сервисами
+ if action == "status":
+ result = self._make_api_request("SYNO.Core.Service", "get", version=1,
+ params={"service": service_name})
+ else:
+ result = self._make_api_request("SYNO.Core.Service", action, version=1,
+ params={"service": service_name})
+
+ if not result:
+ logger.error(f"Failed to {action} service {service_name}")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "service": service_name,
+ "action": action,
+ "result": result,
+ "status": result.get("status") if action == "status" else "completed"
+ }
+
+ except Exception as e:
+ logger.error(f"Error managing service {service_name}: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def search_files(self, pattern: str, folder_path: str = "", limit: int = 100) -> Dict[str, Any]:
+ """Поиск файлов по шаблону
+
+ Args:
+ pattern: Шаблон для поиска
+ folder_path: Путь к папке для поиска (пустая строка для всех общих папок)
+ limit: Максимальное количество результатов
+
+ Returns:
+ Словарь с найденными файлами
+ """
+ logger.info(f"Searching for {pattern} in {folder_path or 'all shared folders'}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file search")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ if not folder_path:
+ # Получаем список всех общих папок для поиска
+ shares_result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not shares_result:
+ logger.error("Failed to list shared folders for search")
+ return {"success": False, "error": "api_error"}
+
+ # Формируем список путей для поиска
+ folder_paths = [share.get("path") for share in shares_result.get("shares", [])]
+ else:
+ folder_paths = [folder_path]
+
+ # Запускаем поиск
+ params = {
+ "folder_path": folder_paths,
+ "pattern": pattern,
+ "limit": limit,
+ "offset": 0
+ }
+
+ result = self._make_api_request("SYNO.FileStation.Search", "start", version=2, params=params)
+
+ if not result:
+ logger.error(f"Failed to start search for {pattern}")
+ return {"success": False, "error": "api_error"}
+
+ # Получаем taskid для проверки результатов
+ taskid = result.get("taskid")
+ if not taskid:
+ logger.error("No taskid received for search")
+ return {"success": False, "error": "no_task_id"}
+
+ # Ожидаем завершения поиска
+ search_result = {"finished": False, "progress": 0}
+ for _ in range(10): # Максимум 10 попыток
+ search_status = self._make_api_request("SYNO.FileStation.Search", "status", version=2,
+ params={"taskid": taskid})
+
+ if not search_status:
+ break
+
+ search_result["progress"] = search_status.get("progress", 0)
+
+ if search_status.get("finished", False):
+ search_result["finished"] = True
+ break
+
+ time.sleep(0.5) # Пауза между запросами
+
+ # Получаем результаты поиска
+ if search_result["finished"]:
+ list_result = self._make_api_request("SYNO.FileStation.Search", "list", version=2,
+ params={"taskid": taskid, "limit": limit})
+
+ if list_result:
+ files = list_result.get("files", [])
+ return {
+ "success": True,
+ "pattern": pattern,
+ "results": files,
+ "total": list_result.get("total", len(files))
+ }
+
+ # Если не удалось получить результаты, останавливаем поиск
+ self._make_api_request("SYNO.FileStation.Search", "stop", version=2,
+ params={"taskid": taskid})
+
+ return {
+ "success": False,
+ "error": "search_timeout",
+ "progress": search_result["progress"]
+ }
+
+ except Exception as e:
+ logger.error(f"Error searching files: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_backup_status(self) -> Dict[str, Any]:
+ """Получение информации о резервном копировании"""
+ logger.info("Getting backup status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for backup status request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о Hyper Backup
+ hyper_result = self._make_api_request("SYNO.Backup.Repository", "list", version=1)
+
+ # Пробуем получить информацию о задачах Time Backup
+ time_result = self._make_api_request("SYNO.BackupService.Task", "list", version=1)
+
+ # Проверяем статус резервного копирования USB
+ usb_result = self._make_api_request("SYNO.USBCopy", "status", version=1)
+
+ backups = {
+ "hyper_backup": hyper_result.get("backups", []) if hyper_result else [],
+ "time_backup": time_result.get("tasks", []) if time_result else [],
+ "usb_copy": {"enabled": usb_result.get("enable", False) if usb_result else False}
+ }
+
+ return {
+ "success": True,
+ "backups": backups,
+ "available_apis": {
+ "hyper_backup": hyper_result is not None,
+ "time_backup": time_result is not None,
+ "usb_copy": usb_result is not None
+ }
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting backup status: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def check_for_updates(self) -> Dict[str, Any]:
+ """Проверка наличия обновлений системы"""
+ logger.info("Checking for system updates")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for update check")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Получаем текущую информацию о системе
+ system_info = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ if not system_info:
+ logger.error("Failed to get system info for update check")
+ return {"success": False, "error": "api_error"}
+
+ # Проверяем наличие обновлений
+ update_result = self._make_api_request("SYNO.Core.Upgrade.Server", "check", version=1)
+
+ # Получаем настройки автоматического обновления
+ settings_result = self._make_api_request("SYNO.Core.Upgrade.Server.Setting", "get", version=1)
+
+ # Получаем информацию о доступных обновлениях
+ update_info = self._make_api_request("SYNO.Core.Upgrade.Server.Download", "list", version=1)
+
+ current_version = system_info.get("version_string", "unknown")
+ auto_update_enabled = settings_result.get("auto_update", False) if settings_result else False
+
+ updates = []
+ if update_info and "updates" in update_info:
+ updates = update_info.get("updates", [])
+
+ update_available = len(updates) > 0
+
+ return {
+ "success": True,
+ "current_version": current_version,
+ "update_available": update_available,
+ "auto_update_enabled": auto_update_enabled,
+ "updates": updates
+ }
+
+ except Exception as e:
+ logger.error(f"Error checking for updates: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_quota_info(self) -> Dict[str, Any]:
+ """Получение информации о квотах пользователей"""
+ logger.info("Getting user quota information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for quota info request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Получаем список пользователей
+ users_result = self._make_api_request("SYNO.Core.User", "list", version=1)
+
+ if not users_result:
+ logger.error("Failed to get user list for quota info")
+ return {"success": False, "error": "api_error"}
+
+ users = users_result.get("users", [])
+ user_quotas = []
+
+ # Получаем квоты для каждого пользователя
+ for user in users:
+ user_name = user.get("name")
+ if not user_name:
+ continue
+
+ quota_result = self._make_api_request("SYNO.Core.Quota", "get", version=1,
+ params={"user_name": user_name})
+
+ if quota_result and "quotas" in quota_result:
+ user_quotas.append({
+ "user": user_name,
+ "quotas": quota_result.get("quotas", [])
+ })
+
+ return {
+ "success": True,
+ "user_quotas": user_quotas
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting quota info: {str(e)}")
+ return {"success": False, "error": str(e)}
diff --git a/.history/src/api/synology_20250830104945.py b/.history/src/api/synology_20250830104945.py
new file mode 100644
index 0000000..30b422b
--- /dev/null
+++ b/.history/src/api/synology_20250830104945.py
@@ -0,0 +1,1877 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Модуль для взаимодействия с API Synology NAS
+"""
+
+import requests
+from requests.adapters import HTTPAdapter
+import json
+import logging
+import time
+import urllib3
+from urllib3.util import Retry
+from typing import Dict, Any, Optional, List
+import socket
+import struct
+from time import sleep
+
+from src.config.config import (
+ SYNOLOGY_HOST,
+ SYNOLOGY_PORT,
+ SYNOLOGY_USERNAME,
+ SYNOLOGY_PASSWORD,
+ SYNOLOGY_SECURE,
+ SYNOLOGY_TIMEOUT,
+ SYNOLOGY_MAC,
+ WOL_PORT,
+ SYNOLOGY_API_VERSION,
+ SYNOLOGY_POWER_API,
+ SYNOLOGY_INFO_API
+)
+from src.api.api_discovery import discover_available_apis, find_compatible_api
+
+# Отключение предупреждений о небезопасных SSL-соединениях
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+logger = logging.getLogger(__name__)
+
+class SynologyAPI:
+ """Класс для взаимодействия с API Synology NAS"""
+
+ def __init__(self):
+ """Инициализация класса SynologyAPI"""
+ logger.info("Creating API with auto-retry and connection pool")
+ logger.debug(f"Connection details: Host={SYNOLOGY_HOST}, Port={SYNOLOGY_PORT}, Secure={SYNOLOGY_SECURE}")
+
+ self.protocol = "https" if SYNOLOGY_SECURE else "http"
+ self.base_url = f"{self.protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/webapi"
+ self.sid = None
+ self.session = requests.Session()
+
+ # Настройка SSL
+ if self.protocol == "https":
+ logger.debug("SSL enabled, disabling certificate verification for internal network")
+ self.session.verify = False # Отключаем проверку SSL для внутренней сети
+
+ # Добавляем пользовательские заголовки для улучшения совместимости с API
+ custom_headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
+ 'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'Connection': 'keep-alive',
+ 'Referer': f'{self.protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/'
+ }
+ self.session.headers.update(custom_headers)
+ logger.debug("Added browser-like headers for API compatibility")
+
+ # Добавляем повторные попытки для HTTP-запросов
+ retry_strategy = Retry(
+ total=5, # Увеличиваем количество попыток
+ status_forcelist=[429, 500, 502, 503, 504, 404],
+ allowed_methods=["GET", "POST"],
+ backoff_factor=1.5, # Увеличиваем задержку между попытками
+ respect_retry_after_header=True
+ )
+ adapter = HTTPAdapter(
+ max_retries=retry_strategy,
+ pool_connections=3,
+ pool_maxsize=10
+ )
+ self.session.mount("http://", adapter)
+ self.session.mount("https://", adapter)
+
+ # Таймауты будут указаны в запросах
+ self.default_timeout = (SYNOLOGY_TIMEOUT, SYNOLOGY_TIMEOUT*2)
+ logger.debug(f"Setting default request timeout: {self.default_timeout}")
+
+ # Кэш для хранения результатов запросов
+ self._cache = {}
+ self._cache_ttl = {}
+ self._last_online_check = 0
+ self._last_online_status = False
+ self._online_check_interval = 30 # Интервал для кэширования проверок онлайн-статуса
+
+ # Время последней успешной аутентификации и срок действия сессии
+ self._last_auth_time = 0
+ self._auth_expiry = 3600 # По умолчанию 1 час
+
+ # Информация о доступных API
+ self._available_apis = {}
+ self._api_info_ttl = 0
+
+ # Инициализируем API version resolver для автоматического определения совместимых API
+ self.api_resolver = None # Будет создан при необходимости
+
+ def login(self) -> bool:
+ """Авторизация в API Synology NAS"""
+ # Сбрасываем SID для новой сессии
+ self.sid = None
+
+ logger.info("Attempting to authenticate with Synology NAS...")
+ logger.debug(f"Base URL: {self.base_url}")
+
+ # Проверяем доступность NAS перед запросом с помощью прямой TCP-проверки
+ # Избегаем вызова is_online(), чтобы не создавать рекурсию
+ online_status = self._check_tcp_connection()
+ if not online_status:
+ logger.error("Cannot login: Synology NAS is not reachable")
+ return False
+
+ # Пробуем различные версии API для аутентификации
+ # Начинаем с версии 3, которая показала лучшую совместимость в тестах
+ auth_versions_to_try = [3, 2, 1, 6] # Пробуем сначала более стабильные версии
+
+ for auth_version in auth_versions_to_try:
+ try:
+ # Определяем путь к API аутентификации
+ auth_path = "entry.cgi" # Используем entry.cgi вместо auth.cgi по умолчанию
+
+ # Проверка информации API для определения доступных версий API
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": "SYNO.API.Auth"
+ }
+
+ logger.debug(f"Querying API info for auth version {auth_version}")
+ try:
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ auth_info = api_info_data.get("data", {}).get("SYNO.API.Auth", {})
+ max_version = auth_info.get("maxVersion", 6)
+ min_version = auth_info.get("minVersion", 1)
+ auth_path = auth_info.get("path", "entry.cgi")
+ logger.debug(f"Auth API versions: min={min_version}, max={max_version}, path={auth_path}")
+
+ # Проверяем поддержку текущей версии
+ if auth_version < min_version or auth_version > max_version:
+ logger.warning(f"Auth API version {auth_version} not supported (min={min_version}, max={max_version}), trying next version")
+ continue
+ else:
+ logger.warning("Failed to query API info, using default auth path")
+ else:
+ logger.warning(f"API info request failed with status {api_info_response.status_code}, using default auth path")
+ except Exception as e:
+ logger.warning(f"Error querying API info: {str(e)}, using default auth path")
+
+ # Основной запрос авторизации
+ url = f"{self.base_url}/{auth_path}"
+ params = {
+ "api": "SYNO.API.Auth",
+ "version": str(auth_version),
+ "method": "login",
+ "account": SYNOLOGY_USERNAME,
+ "passwd": SYNOLOGY_PASSWORD,
+ "session": "SynologyPowerControlBot",
+ "format": "cookie"
+ }
+
+ # Для версии 6+ используем немного другой формат
+ if auth_version >= 6:
+ params["enable_syno_token"] = "yes"
+
+ logger.debug(f"Sending auth request to {url} with API version {auth_version}")
+ start_time = time.time()
+ response = self.session.get(
+ url,
+ params=params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+ elapsed_time = time.time() - start_time
+ logger.debug(f"Auth request completed in {elapsed_time:.2f}s with status code {response.status_code}")
+
+ # Проверка статуса HTTP
+ if response.status_code != 200:
+ logger.error(f"HTTP error: {response.status_code}")
+ logger.debug(f"Response content: {response.text[:200]}")
+ continue # Пробуем следующую версию
+
+ try:
+ data = response.json()
+ except json.JSONDecodeError:
+ logger.error("Failed to decode JSON response")
+ logger.debug(f"Response content: {response.text[:200]}")
+ continue # Пробуем следующую версию
+
+ if data.get("success"):
+ self.sid = data.get("data", {}).get("sid")
+ self._last_auth_time = time.time()
+ logger.info(f"Successfully logged in to Synology NAS using API version {auth_version}")
+ logger.debug(f"Session ID obtained: {self.sid[:5] if self.sid else 'None'}...")
+
+ # Получаем и сохраняем токен SYNO, если он есть
+ syno_token = data.get("data", {}).get("synotoken")
+ if syno_token:
+ self.session.headers.update({'X-SYNO-TOKEN': syno_token})
+ logger.debug("Added X-SYNO-TOKEN header for improved API compatibility")
+
+ # Также добавляем SID в cookies для улучшения совместимости
+ self.session.cookies.update({
+ 'id': self.sid,
+ 'sid': self.sid
+ })
+ logger.debug("Added SID to session cookies for improved compatibility")
+
+ # Проверка валидности полученной сессии с помощью простого запроса
+ # Будем использовать SYNO.API.Info без проверки сложных методов
+
+ # Даем системе немного времени для инициализации сессии
+ time.sleep(0.5)
+
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to log in with version {auth_version}: Error code {error_code}")
+
+ # Если ошибка связана с версией API, пробуем следующую версию
+ if error_code in [104, 105]:
+ logger.warning(f"Auth version {auth_version} not supported, trying next version")
+ continue
+
+ # Дополнительная диагностика
+ if error_code == 400:
+ logger.error("Authentication error: Invalid credentials")
+ elif error_code == 401:
+ logger.error("Authentication error: Account disabled")
+ elif error_code == 402:
+ logger.error("Authentication error: Permission denied")
+ elif error_code == 403:
+ logger.error("Authentication error: 2-factor authentication required")
+ elif error_code == 404:
+ logger.error("Authentication error: Failed to authenticate with 2-factor authentication")
+
+ # Если ошибка связана с неверными учетными данными, нет смысла пробовать другие версии API
+ if error_code in [400, 401, 402, 403, 404]:
+ return False
+
+ except requests.exceptions.Timeout:
+ logger.error(f"Connection timeout during auth with version {auth_version}")
+ continue # Пробуем следующую версию
+ except requests.exceptions.ConnectionError as e:
+ logger.error(f"Connection error during auth with version {auth_version}: {str(e)}")
+ continue # Пробуем следующую версию
+ except requests.RequestException as e:
+ logger.error(f"Request error during auth with version {auth_version}: {str(e)}")
+ continue # Пробуем следующую версию
+ except Exception as e:
+ logger.error(f"Unexpected error during login with version {auth_version}: {str(e)}", exc_info=True)
+ continue # Пробуем следующую версию
+
+ # Если все версии не сработали
+ logger.error("Failed to authenticate with any API version")
+ return False
+
+ def _validate_session(self) -> bool:
+ """Проверяет валидность сессии после авторизации"""
+ if not self.sid:
+ return False
+
+ # Попробуем сделать простой запрос для проверки сессии
+ test_apis = [
+ {"api": "SYNO.Core.System", "method": "info", "version": 1},
+ {"api": "SYNO.DSM.Info", "method": "getinfo", "version": 1}
+ ]
+
+ for test_api in test_apis:
+ try:
+ url = f"{self.base_url}/entry.cgi"
+ params = {
+ "api": test_api["api"],
+ "version": str(test_api["version"]),
+ "method": test_api["method"],
+ "_sid": self.sid
+ }
+
+ response = self.session.get(url, params=params, timeout=self.default_timeout, verify=False)
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("success"):
+ logger.debug(f"Session validation successful using {test_api['api']}")
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ if error_code != 119: # Не сессия истекла
+ logger.debug(f"Session validation with {test_api['api']} returned error code {error_code}")
+ return True # Считаем сессию валидной, если ошибка не связана с истечением сессии
+ except Exception as e:
+ logger.warning(f"Error during session validation with {test_api['api']}: {str(e)}")
+
+ logger.warning("Session validation failed with all test APIs")
+ return False
+
+ def logout(self) -> bool:
+ """Выход из API Synology NAS"""
+ if not self.sid:
+ return True
+
+ try:
+ url = f"{self.base_url}/auth.cgi"
+ params = {
+ "api": "SYNO.API.Auth",
+ "version": "1",
+ "method": "logout",
+ "session": "SynologyPowerControlBot",
+ "_sid": self.sid
+ }
+
+ response = self.session.get(url, params=params, timeout=SYNOLOGY_TIMEOUT, verify=False)
+ data = response.json()
+
+ if data.get("success"):
+ self.sid = None
+ logger.info("Successfully logged out from Synology NAS")
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to log out from Synology NAS: Error code {error_code}")
+ return False
+
+ except requests.RequestException as e:
+ logger.error(f"Connection error: {str(e)}")
+ return False
+
+ def _make_api_request(self, api_name: str, method: str, version: int = 1,
+ params: Optional[Dict[str, Any]] = None, retry_auth: bool = True, retry_count: int = 0) -> Optional[Dict[str, Any]]:
+ """Обобщенный метод для выполнения API запросов с обработкой ошибок"""
+ # Ограничение на количество повторных попыток
+ if retry_count >= 3:
+ logger.error(f"Too many retries for {api_name}.{method}, giving up")
+ return None
+
+ # Проверка наличия авторизации
+ if not self.sid and not self.login():
+ logger.error(f"Not authenticated for API request: {api_name}.{method}")
+ return None
+
+ # Проверка информации API для определения пути и поддерживаемой версии
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": api_name
+ }
+
+ api_path = "entry.cgi"
+ try:
+ logger.debug(f"Querying API info for {api_name}")
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ api_info = api_info_data.get("data", {}).get(api_name, {})
+ if api_info:
+ max_version = api_info.get("maxVersion", version)
+ min_version = api_info.get("minVersion", version)
+ api_path = api_info.get("path", "entry.cgi")
+
+ # Проверка, поддерживается ли запрошенная версия
+ if version < min_version:
+ logger.warning(f"API version {version} for {api_name} is below minimum {min_version}, using {min_version}")
+ version = min_version
+ elif version > max_version:
+ logger.warning(f"API version {version} for {api_name} exceeds maximum {max_version}, using {max_version}")
+ version = max_version
+
+ logger.debug(f"Using API path: {api_path}, version: {version}")
+ else:
+ logger.warning(f"API {api_name} not found in API info, using defaults")
+ except Exception as e:
+ logger.warning(f"Error querying API info for {api_name}: {str(e)}, using defaults")
+
+ # Подготовка базовых параметров запроса
+ base_params = {
+ "api": api_name,
+ "version": str(version),
+ "method": method,
+ "_sid": self.sid # Используем _sid вместо sid для лучшей совместимости
+ }
+
+ # Добавление дополнительных параметров, если они заданы
+ if params:
+ base_params.update(params)
+
+ url = f"{self.base_url}/{api_path}"
+ logger.debug(f"API request: {api_name}.{method} v{version} to {url}")
+ logger.debug(f"Full request params: {base_params}")
+
+ try:
+ start_time = time.time()
+ response = self.session.get(
+ url,
+ params=base_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+ elapsed_time = time.time() - start_time
+ logger.debug(f"API request completed in {elapsed_time:.2f}s with status {response.status_code}")
+
+ # Проверка статуса HTTP
+ if response.status_code != 200:
+ logger.error(f"HTTP error: {response.status_code} for {api_name}.{method}")
+
+ # Повторная попытка при ошибках соединения
+ if response.status_code in [500, 502, 503, 504]:
+ logger.info(f"Server error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+
+ try:
+ data = response.json()
+ except json.JSONDecodeError:
+ logger.error(f"Failed to decode JSON response for {api_name}.{method}")
+ logger.debug(f"Response content: {response.text[:200]}")
+
+ # Повторная попытка при ошибках декодирования
+ logger.info(f"JSON decode error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ if data.get("success"):
+ logger.info(f"API request successful for {api_name}.{method}")
+ return data.get("data", {})
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ error_desc = self._get_error_description(error_code)
+ logger.error(f"API error for {api_name}.{method}: {error_code} - {error_desc}")
+
+ # Ошибки доступа или прав часто встречаются, но они не критичные
+ # Например, ошибка 102 означает, что нет прав, но NAS доступен
+ if error_code in [102, 103, 104, 105]:
+ logger.warning(f"Permission-related error for {api_name}.{method}: {error_code}")
+ # Возвращаем пустой словарь вместо None,
+ # чтобы вызывающий код мог понять, что запрос выполнен
+ return {}
+
+ # Если ошибка связана с авторизацией и нам разрешено повторить попытку
+ if error_code in [106, 107, 119] and retry_auth:
+ logger.info(f"Session error (code {error_code}), creating fresh session...")
+ self.sid = None # Сбрасываем SID
+
+ # Для ошибки 119 (Session timeout) дадим системе немного времени
+ if error_code == 119:
+ logger.info("Session timeout detected, waiting before retry...")
+ sleep(3)
+
+ if self.login():
+ logger.info("Re-authenticated with fresh session, retrying API request...")
+ # Рекурсивный вызов, но со счетчиком повторов
+ return self._make_api_request(api_name, method, version, params, False, retry_count + 1)
+
+ # Для некоторых ошибок можно автоматически повторить запрос
+ if error_code in [408, 429, 500, 502, 503, 504]:
+ logger.info(f"Temporary error {error_code}, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+
+ except requests.exceptions.Timeout:
+ logger.error(f"Request timeout for {api_name}.{method}")
+
+ # Повторная попытка при таймауте
+ if retry_count < 2:
+ logger.info(f"Timeout, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+ except requests.exceptions.ConnectionError as e:
+ logger.error(f"Connection error for {api_name}.{method}: {str(e)}")
+
+ # Повторная попытка при ошибке соединения
+ if retry_count < 2:
+ logger.info(f"Connection error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+ except Exception as e:
+ logger.error(f"Unexpected error during {api_name}.{method}: {str(e)}")
+ return None
+
+ def get_system_status(self) -> Dict[str, Any]:
+ """Получение статуса системы"""
+ # Проверяем доступность системы
+ if not self.is_online():
+ logger.info("Device is offline, skipping API request")
+ return {"status": "offline"}
+
+ # Проверяем, есть ли кэшированный результат
+ cache_key = "system_status"
+ current_time = time.time()
+ if cache_key in self._cache and current_time - self._cache_ttl.get(cache_key, 0) < 60:
+ logger.debug("Using cached system status")
+ return self._cache[cache_key]
+
+ # Используем рекомендованный API для получения информации о системе
+ logger.info(f"Getting system info using {SYNOLOGY_INFO_API} v{SYNOLOGY_API_VERSION}")
+
+ # Для SYNO.DSM.Info метод getinfo, для других обычно используется get или info
+ if SYNOLOGY_INFO_API == "SYNO.DSM.Info":
+ method = "getinfo"
+ else:
+ method = "get"
+
+ result = self._make_api_request(SYNOLOGY_INFO_API, method, version=SYNOLOGY_API_VERSION)
+
+ if result:
+ logger.info(f"Successfully retrieved system info using {SYNOLOGY_INFO_API}")
+
+ # Формируем расширенный ответ с дополнительной информацией
+ system_info = {
+ "status": "online",
+ "hostname": result.get("hostname", "unknown"),
+ "model": result.get("model", "unknown"),
+ "version": result.get("version", "unknown"),
+ "uptime": result.get("uptime", 0),
+ "time": current_time,
+ "is_online": True,
+ "api_used": SYNOLOGY_INFO_API
+ }
+
+ # Сохраняем в кэше
+ self._cache[cache_key] = system_info
+ self._cache_ttl[cache_key] = current_time
+
+ return system_info
+
+ # Если основной API не сработал, пробуем резервные варианты
+ logger.warning(f"Failed to retrieve system info with {SYNOLOGY_INFO_API}, trying fallback APIs")
+
+ # Пробуем резервные API
+ apis_to_try = [
+ {"name": "SYNO.DSM.Info", "method": "getinfo", "version": 2},
+ {"name": "SYNO.Core.System", "method": "info", "version": 1},
+ {"name": "SYNO.Core.System.Status", "method": "get", "version": 1},
+ {"name": "SYNO.Core.System.Info", "method": "get", "version": 1},
+ ]
+
+ for api in apis_to_try:
+ if api["name"] == SYNOLOGY_INFO_API:
+ continue # Пропускаем уже проверенный API
+
+ logger.info(f"Trying to get system info using {api['name']}.{api['method']} v{api['version']}")
+ result = self._make_api_request(api["name"], api["method"], version=api["version"])
+
+ if result:
+ logger.info(f"Successfully retrieved system info using {api['name']}")
+
+ # Формируем расширенный ответ с дополнительной информацией
+ system_info = {
+ "status": "online",
+ "hostname": result.get("hostname", "unknown"),
+ "model": result.get("model", "unknown"),
+ "version": result.get("version", "unknown"),
+ "uptime": result.get("uptime", 0),
+ "time": current_time,
+ "is_online": True,
+ "api_used": api["name"]
+ }
+
+ # Сохраняем в кэше
+ self._cache[cache_key] = system_info
+ self._cache_ttl[cache_key] = current_time
+
+ return system_info
+
+ # Если все запросы не удались, но система онлайн, возвращаем базовую информацию
+ logger.warning("Failed to retrieve system info with all API methods")
+ return {
+ "status": "error",
+ "error": "Failed to fetch system information",
+ "is_online": True,
+ "time": current_time
+ }
+
+ def shutdown_system(self) -> bool:
+ """Выключение системы"""
+ # Проверяем, включено ли устройство перед попыткой его выключить
+ if not self.is_online(force_check=True):
+ logger.info("Synology NAS is already offline, no need to shut down")
+ return True
+
+ logger.info("Attempting to shutdown Synology NAS...")
+
+ # Попробуем сначала использовать предпочтительный API для управления питанием
+ logger.info(f"Trying shutdown with {SYNOLOGY_POWER_API} v{SYNOLOGY_API_VERSION}")
+
+ # Для SYNO.Core.Hardware.PowerRecovery используется метод setPowerOnState
+ # Для других API обычно используется метод shutdown или reboot
+ if SYNOLOGY_POWER_API == "SYNO.Core.Hardware.PowerRecovery":
+ # Для этого API нужны специальные параметры
+ params = {"state": "powerbtn"} # powerbtn имитирует нажатие кнопки питания
+ result = self._make_api_request(SYNOLOGY_POWER_API, "setPowerOnState", version=SYNOLOGY_API_VERSION, params=params)
+ else:
+ # Пробуем стандартный метод
+ result = self._make_api_request(SYNOLOGY_POWER_API, "shutdown", version=SYNOLOGY_API_VERSION)
+
+ if result is not None:
+ logger.info(f"Successfully initiated system shutdown using {SYNOLOGY_POWER_API}")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ # Проверяем статус
+ if not self.is_online(force_check=True):
+ logger.info("System is now offline. Shutdown confirmed successful.")
+ return True
+ else:
+ logger.info("System still appears to be online, but shutdown may be in progress.")
+ return True
+ else:
+ logger.warning(f"Failed to shutdown using {SYNOLOGY_POWER_API}, trying alternative methods")
+
+ # Если не сработал основной метод, пробуем резервные варианты
+ # Проверка всех доступных методов API для выключения
+ apis_to_try = [
+ {"name": "SYNO.Core.System", "method": "shutdown", "version": 3},
+ {"name": "SYNO.Core.System.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.DSM.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.System.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.DSM.System", "method": "shutdown", "version": 1}
+ ]
+
+ # Проверяем доступные API
+ try:
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": "SYNO.Core.System.Power,SYNO.DSM.Power,SYNO.Core.System,SYNO.System.Power,SYNO.DSM.System"
+ }
+
+ logger.debug("Checking available shutdown APIs")
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ available_apis = api_info_data.get("data", {})
+ logger.debug(f"Available APIs: {list(available_apis.keys())}")
+
+ # Фильтруем только доступные API
+ filtered_apis = []
+ for api in apis_to_try:
+ if api["name"] in available_apis:
+ api_info = available_apis[api["name"]]
+ # Проверка версии API
+ if api_info.get("minVersion", 1) <= api["version"] <= api_info.get("maxVersion", 1):
+ filtered_apis.append(api)
+ logger.debug(f"Adding {api['name']} to available shutdown APIs")
+ else:
+ logger.debug(f"API {api['name']} version {api['version']} not supported. Supported: {api_info.get('minVersion')}-{api_info.get('maxVersion')}")
+
+ if filtered_apis:
+ apis_to_try = filtered_apis
+ else:
+ logger.warning("No compatible APIs found, trying all methods as fallback")
+ else:
+ logger.warning("Failed to query API info, using default methods")
+ else:
+ logger.warning(f"API info request failed with status {api_info_response.status_code}, using default methods")
+ except Exception as e:
+ logger.warning(f"Error querying API info: {str(e)}, using default methods")
+
+ # Пробуем все доступные методы по порядку
+ for api in apis_to_try:
+ logger.info(f"Trying shutdown with {api['name']}.{api['method']} v{api['version']}")
+ result = self._make_api_request(api["name"], api["method"], version=api["version"])
+
+ if result is not None:
+ logger.info(f"Successfully initiated system shutdown using {api['name']}")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ # Проверяем статус
+ if not self.is_online(force_check=True):
+ logger.info("System is now offline. Shutdown confirmed successful.")
+ return True
+ else:
+ logger.info("System still appears to be online, but shutdown may be in progress.")
+ return True
+ else:
+ logger.warning(f"Failed to shutdown using {api['name']}.{api['method']} v{api['version']}")
+
+ # Если ни один метод не сработал, но система стала недоступна
+ if not self.is_online(force_check=True):
+ logger.info("System appears to be shutting down despite API errors")
+ return True
+
+ logger.error("Failed to shutdown system after trying multiple APIs")
+ return False
+
+ def reboot_system(self) -> bool:
+ """Перезагрузка системы"""
+ # Проверяем, включена ли система
+ if not self.is_online(force_check=True):
+ logger.error("Cannot reboot: System is offline")
+ return False
+
+ logger.info("Attempting to reboot Synology NAS...")
+
+ # Список API и методов для попытки перезагрузки
+ apis_to_try = [
+ {"api": "SYNO.Core.System", "method": "reboot", "version": 1},
+ {"api": "SYNO.DSM.System", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.System.Power", "method": "restart", "version": 1},
+ {"api": "SYNO.DSM.Power", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.System", "method": "reboot", "version": 3},
+ {"api": "SYNO.System.Power", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.Hardware.NeedReboot", "method": "reboot", "version": 1}
+ ]
+
+ # Добавляем настроенный в конфигурации API, если он отличается от уже добавленных
+ already_added = [item["api"] for item in apis_to_try]
+ if SYNOLOGY_POWER_API not in already_added:
+ for method in ["restart", "reboot"]:
+ apis_to_try.append({
+ "api": SYNOLOGY_POWER_API,
+ "method": method,
+ "version": SYNOLOGY_API_VERSION
+ })
+
+ # Перебираем все возможные API и методы
+ for api_info in apis_to_try:
+ try:
+ logger.info(f"Trying reboot with {api_info['api']} API using method {api_info['method']}")
+ result = self._make_api_request(api_info['api'], api_info['method'], version=api_info['version'])
+ if result is not None:
+ logger.info(f"Successfully initiated system reboot using {api_info['api']} API")
+
+ # Даем системе время начать процесс перезагрузки
+ logger.info("Waiting for reboot to initialize...")
+ sleep(5)
+
+ # Ждем, пока система станет недоступна (признак перезагрузки)
+ reboot_started = False
+ for i in range(12): # Проверяем в течение 60 секунд (12 * 5сек)
+ if not self.is_online(force_check=True):
+ logger.info(f"System went offline after {i*5} seconds, reboot in progress")
+ reboot_started = True
+ break
+ logger.debug(f"System still online, waiting... ({i+1}/12)")
+ sleep(5)
+
+ if reboot_started:
+ # Успешный вызов API и система ушла оффлайн - это признак успешной перезагрузки
+ return True
+ else:
+ # Успешный вызов API, но система не ушла оффлайн
+ logger.warning("System did not go offline after reboot command, but command was accepted")
+ # Даже если система не ушла оффлайн, команда могла быть принята
+ return True
+ except Exception as e:
+ logger.error(f"Error during reboot with {api_info['api']}: {str(e)}")
+
+ # Если все попытки не удались, возвращаем False
+ logger.error("All reboot attempts failed")
+ return False
+
+ def _get_error_description(self, error_code: int) -> str:
+ """Получение описания ошибки по коду"""
+ error_descriptions = {
+ 100: "Unknown error",
+ 101: "Invalid parameter",
+ 102: "API does not exist",
+ 103: "Method does not exist",
+ 104: "Version does not support",
+ 105: "Permission denied",
+ 106: "Session timeout",
+ 107: "Session interrupted by duplicate login",
+ 400: "Invalid credentials",
+ 401: "Account disabled",
+ 402: "Permission denied",
+ 403: "2FA required",
+ 404: "Failed to authenticate with 2FA"
+ }
+ return error_descriptions.get(error_code, "Unknown error code")
+
+ def _check_tcp_connection(self) -> bool:
+ """Проверка базового TCP-соединения с Synology NAS"""
+ try:
+ socket_obj = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ socket_obj.settimeout(SYNOLOGY_TIMEOUT)
+ result = socket_obj.connect_ex((SYNOLOGY_HOST, SYNOLOGY_PORT))
+ socket_obj.close()
+
+ return result == 0
+ except socket.error as e:
+ logger.error(f"Socket error during connection check: {str(e)}")
+ return False
+ except Exception as e:
+ logger.error(f"Unexpected error during connection check: {str(e)}")
+ return False
+
+ def is_online(self, force_check=False) -> bool:
+ """Проверка онлайн-статуса Synology NAS"""
+ # Используем кэшированное значение, если доступно и не устарело
+ current_time = time.time()
+ if not force_check and (current_time - self._last_online_check) < self._online_check_interval:
+ logger.debug(f"Using cached online status: {self._last_online_status}")
+ return self._last_online_status
+
+ logger.info("Checking if NAS is online...")
+
+ # Проверяем TCP-соединение
+ online_status = self._check_tcp_connection()
+ logger.info(f"Detected Synology NAS online status: {online_status}")
+
+ # Если TCP-соединение успешно и у нас есть действующий SID,
+ # попробуем более детальную проверку через API
+ if online_status and self.sid:
+ logger.info("Trying to fetch more detailed online status through API...")
+
+ # Пробуем разные API для проверки онлайн-статуса
+ api_checks = [
+ {"api": "SYNO.DSM.Info", "version": "2", "method": "getinfo"},
+ {"api": "SYNO.Core.System", "version": "1", "method": "info"},
+ {"api": "SYNO.Core.System.Status", "version": "1", "method": "get"}
+ ]
+
+ api_success = False
+ for api_check in api_checks:
+ try:
+ url = f"{self.base_url}/entry.cgi"
+ params = {
+ "api": api_check["api"],
+ "version": api_check["version"],
+ "method": api_check["method"],
+ "sid": self.sid
+ }
+
+ logger.debug(f"Trying online status check with {api_check['api']}")
+ response = self.session.get(
+ url,
+ params=params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("success"):
+ logger.info(f"API request successful for {api_check['api']}")
+ logger.info("Synology NAS is online with API access")
+ api_success = True
+ break
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.warning(f"API response indicates an error: {error_code}, but NAS is reachable")
+ else:
+ logger.warning(f"API returned status code {response.status_code}")
+ except Exception as e:
+ logger.warning(f"API check failed for {api_check['api']}, but TCP connection succeeded: {str(e)}")
+
+ if not api_success:
+ logger.warning("All API checks failed, but TCP connection is successful")
+
+ # Обновляем кэшированное значение
+ self._last_online_check = current_time
+ self._last_online_status = online_status
+
+ return online_status
+
+ def wake_on_lan(self) -> bool:
+ """Отправка Wake-on-LAN пакета для включения Synology NAS"""
+ if not SYNOLOGY_MAC:
+ logger.error("MAC address not configured")
+ return False
+
+ try:
+ # Преобразование MAC-адреса в байты
+ mac_address = SYNOLOGY_MAC.replace(':', '').replace('-', '')
+ if len(mac_address) != 12:
+ logger.error(f"Invalid MAC address format: {SYNOLOGY_MAC}")
+ return False
+
+ try:
+ mac_bytes = bytes.fromhex(mac_address)
+ except ValueError as e:
+ logger.error(f"Failed to parse MAC address: {str(e)}")
+ logger.error(f"MAC address must contain only hex characters: {SYNOLOGY_MAC}")
+ return False
+
+ # Создание Magic Packet
+ magic_packet = b'\xff' * 6 + mac_bytes * 16
+
+ # Отправка пакета на конкретный адрес
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.sendto(magic_packet, (SYNOLOGY_HOST, WOL_PORT))
+ sock.close()
+ logger.info(f"Wake-on-LAN packet sent to {SYNOLOGY_MAC} at {SYNOLOGY_HOST}:{WOL_PORT}")
+ except Exception as e:
+ logger.error(f"Error sending directed WoL packet: {str(e)}")
+ return False
+
+ # Для надежности отправляем также широковещательный пакет
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+ # Используем стандартный широковещательный адрес
+ broadcast_addr = "255.255.255.255"
+ sock.sendto(magic_packet, (broadcast_addr, WOL_PORT))
+ sock.close()
+ logger.info(f"Wake-on-LAN broadcast packet sent to {broadcast_addr}:{WOL_PORT}")
+ except Exception as e:
+ logger.warning(f"Error sending broadcast WoL packet (non-critical): {str(e)}")
+ # Не считаем ошибкой, т.к. основной пакет уже отправлен
+
+ return True
+
+ except Exception as e:
+ logger.error(f"Unexpected error in wake_on_lan: {str(e)}")
+ return False
+
+ def wait_for_boot(self, max_attempts: int = 30, delay: int = 10) -> bool:
+ """Ожидание загрузки Synology NAS"""
+ logger.info(f"Waiting for Synology NAS to boot (timeout: {max_attempts * delay}s)...")
+
+ for attempt in range(max_attempts):
+ # Принудительно проверяем статус без использования кэша
+ if self.is_online(force_check=True):
+ logger.info(f"Synology NAS is online after {attempt + 1} attempts ({(attempt + 1) * delay}s)")
+
+ # Проверяем, что не только сеть доступна, но и API загрузился
+ api_ready = False
+ logger.info("Waiting for API services to initialize...")
+
+ for api_check in range(5): # Даем еще до 50 секунд для загрузки API
+ if self.sid or self.login():
+ api_ready = True
+ logger.info(f"API services are ready after {api_check + 1} attempts")
+ break
+ logger.debug(f"API not ready yet, waiting... ({api_check + 1}/5)")
+ sleep(10)
+
+ if not api_ready:
+ logger.warning("System is online but API services may not be fully initialized")
+
+ # Дадим дополнительное время для полной загрузки всех сервисов
+ sleep(delay)
+ return True
+
+ sleep(delay)
+ logger.info(f"Waiting for boot... Attempt {attempt + 1}/{max_attempts}")
+
+ logger.error(f"Synology NAS didn't come online after {max_attempts * delay} seconds")
+ return False
+
+ def power_on(self) -> bool:
+ """Включение Synology NAS"""
+ # Принудительная проверка статуса
+ if self.is_online(force_check=True):
+ logger.info("Synology NAS is already online")
+ return True
+
+ logger.info("Powering on Synology NAS via Wake-on-LAN...")
+
+ # Проверяем, настроен ли MAC-адрес
+ if not SYNOLOGY_MAC:
+ logger.error("Cannot power on: MAC address not configured in settings")
+ return False
+
+ # Пробуем отправить несколько WoL пакетов для надежности
+ success = False
+ for attempt in range(3):
+ logger.debug(f"Sending WoL packet, attempt {attempt + 1}/3")
+ if self.wake_on_lan():
+ success = True
+ break
+ sleep(1)
+
+ if not success:
+ logger.error("Failed to send Wake-on-LAN packets")
+ return False
+
+ # Ожидание загрузки
+ logger.info("WoL packets sent successfully, waiting for system to boot...")
+ boot_result = self.wait_for_boot(max_attempts=30, delay=10)
+
+ if boot_result:
+ # Проверяем доступность API после загрузки
+ system_status = self.get_system_status()
+ if system_status.get("status") == "online":
+ logger.info("System booted successfully with API access")
+ return True
+ else:
+ logger.warning("System appears to be online but API may not be fully ready")
+ return True
+ else:
+ logger.error("System did not come online after WoL")
+ return False
+
+ def power_off(self) -> bool:
+ """Выключение Synology NAS"""
+ if not self.is_online(force_check=True):
+ logger.info("Synology NAS is already offline")
+ return True
+
+ logger.info("Powering off Synology NAS...")
+
+ # Список API и методов для попытки выключения
+ apis_to_try = [
+ {"api": "SYNO.Core.System", "method": "shutdown", "version": 1},
+ {"api": "SYNO.DSM.System", "method": "shutdown", "version": 1},
+ {"api": SYNOLOGY_POWER_API, "method": "shutdown", "version": SYNOLOGY_API_VERSION}
+ ]
+
+ # Перебираем все возможные API и методы
+ for api_info in apis_to_try:
+ try:
+ logger.info(f"Trying shutdown with {api_info['api']} API using method {api_info['method']}")
+ api_result = self._make_api_request(api_info['api'], api_info['method'], version=api_info['version'])
+ if api_result is not None:
+ logger.info(f"Successfully initiated system shutdown using {api_info['api']} API")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ return True
+ except Exception as e:
+ logger.error(f"Error during shutdown with {api_info['api']}: {str(e)}")
+
+ # Если все попытки не удались, возвращаем False
+ logger.error("All shutdown attempts failed")
+ return False
+
+ # Если все еще не сработало, используем оригинальный метод shutdown_system
+ if not result:
+ result = self.shutdown_system()
+
+ if result:
+ # Дополнительная проверка, что система действительно выключилась
+ logger.info("Verifying system is offline...")
+ for attempt in range(12): # Проверяем в течение 2 минут (12 * 10 сек)
+ if not self.is_online(force_check=True):
+ logger.info(f"System confirmed offline after {attempt * 10} seconds")
+ return True
+ logger.debug(f"System still shutting down, checking again... ({attempt + 1}/12)")
+ sleep(10)
+
+ logger.warning("System still appears to be online after shutdown command")
+ return False
+ else:
+ logger.error("Failed to initiate shutdown")
+ return False
+
+ # Заглушки для расширенных методов
+ def get_shared_folders(self) -> List[Dict[str, Any]]:
+ """Получение списка общих папок"""
+ logger.info("Getting list of shared folders")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for shared folders request")
+ return []
+
+ try:
+ # Запрашиваем список общих папок через FileStation API
+ result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for shared folders")
+ alt_result = self._make_api_request("SYNO.FileStation.List", "list_share", version=1)
+ if alt_result:
+ return alt_result.get("shares", [])
+ return []
+
+ return result.get("shares", [])
+
+ except Exception as e:
+ logger.error(f"Error getting shared folders: {str(e)}")
+ return []
+
+ def get_system_load(self) -> Dict[str, Any]:
+ """Получение информации о загрузке системы"""
+ logger.info("Getting system load information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for system load request")
+ return {}
+
+ try:
+ # Запрашиваем информацию о загрузке системы
+ result = self._make_api_request("SYNO.Core.System.Utilization", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for system load")
+ alt_result = self._make_api_request("SYNO.Core.System", "info", version=3)
+
+ if not alt_result:
+ return {}
+
+ # Формируем из частичных данных
+ return {
+ "cpu_load": alt_result.get("cpu_usage", 0),
+ "memory": {
+ "total": alt_result.get("memory_size", 0),
+ "used": alt_result.get("memory_usage", 0),
+ "usage_percent": alt_result.get("memory_usage_percent", 0)
+ }
+ }
+
+ # Формируем структурированный результат
+ return {
+ "cpu_load": result.get("cpu", {}).get("user", 0) + result.get("cpu", {}).get("system", 0),
+ "memory": result.get("memory", {}),
+ "network": result.get("network", {})
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting system load: {str(e)}")
+ return {}
+
+ def is_online_api(self) -> bool:
+ """Проверка онлайн-статуса Synology NAS с использованием API"""
+ if not self.is_online():
+ return False
+
+ # Проверяем доступность API через авторизацию
+ if not self.sid and not self.login():
+ return False
+
+ return True
+
+ def get_storage_status(self) -> Dict[str, Any]:
+ """Получение подробной информации о хранилище"""
+ logger.info("Getting storage status information")
+
+ # Проверяем доступность NAS и API
+ if not self.is_online_api():
+ logger.error("Cannot get storage status: NAS is not online or API is not accessible")
+ return {"error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о хранилище через SYNO.Storage.CGI.Storage API
+ result = self._make_api_request("SYNO.Storage.CGI.Storage", "load_info", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for storage info")
+ alt_result = self._make_api_request("SYNO.Core.Storage", "info", version=1)
+
+ if not alt_result:
+ # Пробуем еще один альтернативный API
+ logger.info("Trying SYNO.Core.System API for storage info")
+ sys_result = self._make_api_request("SYNO.Core.System", "info", version=3)
+
+ if not sys_result:
+ return {
+ "volumes": [],
+ "disks": [],
+ "total_size": 0,
+ "total_used": 0,
+ "error": "no_data"
+ }
+
+ # Извлекаем базовую информацию о хранилище из системной информации
+ return {
+ "volumes": [],
+ "disks": [],
+ "total_size": sys_result.get("hdd_total_size", 0) * 1024**2, # МБ в байты
+ "total_used": sys_result.get("hdd_total_size", 0) * sys_result.get("hdd_usage", 0) / 100 * 1024**2,
+ }
+
+ # Обрабатываем данные из альтернативного API
+ volumes = alt_result.get("volumes", [])
+ disks = alt_result.get("disks", [])
+
+ else:
+ # Обрабатываем данные из основного API
+ volumes = result.get("volumes", [])
+ disks = result.get("disks", [])
+
+ # Рассчитываем общие размеры
+ total_size = 0
+ total_used = 0
+
+ for volume in volumes:
+ volume_size = volume.get("size", {}).get("total", 0)
+ volume_used = volume.get("size", {}).get("used", 0)
+
+ total_size += volume_size
+ total_used += volume_used
+
+ return {
+ "volumes": volumes,
+ "disks": disks,
+ "total_size": total_size,
+ "total_used": total_used
+ }
+
+ except Exception as e:
+ logger.error(f"Error in get_storage_status: {str(e)}")
+ return {"error": str(e)}
+
+ def get_security_status(self) -> Dict[str, Any]:
+ """Получение информации о состоянии безопасности"""
+ logger.info("Getting security status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for security status request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о безопасности через API Security Scan
+ result = self._make_api_request("SYNO.Core.SecurityScan.Status", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for security status")
+ # Проверяем статус брандмауэра
+ firewall_result = self._make_api_request("SYNO.Core.Security.Firewall.Status", "get", version=1)
+
+ # Проверяем статус автоматических обновлений
+ update_result = self._make_api_request("SYNO.Core.Upgrade.Server.Setting", "get", version=1)
+
+ # Если ни один из API не отвечает
+ if not firewall_result and not update_result:
+ # Получаем общую информацию о системе для базовой проверки безопасности
+ sys_result = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ if not sys_result:
+ return {
+ "success": False,
+ "status": "unknown",
+ "last_check": None,
+ "is_secure": False,
+ "error": "no_security_api"
+ }
+
+ # Собираем базовые сведения из системной информации
+ return {
+ "success": True,
+ "status": "basic",
+ "last_check": None,
+ "is_secure": True, # Предполагаем, что система в целом безопасна
+ "firewall_enabled": None,
+ "auto_update": None,
+ "version_latest": sys_result.get("version_string", "")
+ }
+
+ # Собираем информацию из доступных результатов
+ firewall_enabled = firewall_result.get("enabled", False) if firewall_result else None
+ auto_update = update_result.get("auto_update", False) if update_result else None
+
+ # Определяем, насколько система безопасна
+ is_secure = True # По умолчанию предполагаем, что система безопасна
+ if firewall_enabled is not None and not firewall_enabled:
+ is_secure = False
+
+ return {
+ "success": True,
+ "status": "partial",
+ "last_check": None,
+ "is_secure": is_secure,
+ "firewall_enabled": firewall_enabled,
+ "auto_update": auto_update
+ }
+
+ # Если основное API отвечает, возвращаем его данные
+ return {
+ "success": True,
+ "status": result.get("status", "unknown"),
+ "last_check": result.get("last_check", None),
+ "is_secure": result.get("is_secure", False),
+ "details": result.get("details", {})
+ }
+
+ except Exception as e:
+ logger.error(f"Error in get_security_status: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_processes(self, limit: int = 20) -> List[Dict[str, Any]]:
+ """Получение списка активных процессов"""
+ logger.info(f"Getting list of active processes (limit={limit})")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for processes request")
+ return []
+
+ try:
+ # Получаем список процессов через API
+ result = self._make_api_request("SYNO.Core.System.Process", "list", version=1,
+ params={"sort_by": "cpu", "order": "DESC", "limit": limit})
+
+ if not result:
+ logger.warning("Failed to get process list")
+ return []
+
+ return result.get("processes", [])
+
+ except Exception as e:
+ logger.error(f"Error getting process list: {str(e)}")
+ return []
+
+ def get_network_status(self) -> Dict[str, Any]:
+ """Получение информации о сетевых подключениях"""
+ logger.info("Getting network status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for network status request")
+ return {}
+
+ try:
+ # Получаем информацию о сетевых интерфейсах
+ interface_result = self._make_api_request("SYNO.Core.Network.Interface", "list", version=1)
+
+ # Получаем статистику использования сети
+ utilization_result = self._make_api_request("SYNO.Core.System.Utilization", "get", version=1)
+
+ interfaces = []
+ if interface_result:
+ interfaces = interface_result.get("interfaces", [])
+
+ network_stats = {}
+ if utilization_result and "network" in utilization_result:
+ network_stats = utilization_result.get("network", {})
+
+ # Объединяем данные
+ for interface in interfaces:
+ iface_id = interface.get("id", "")
+ if iface_id in network_stats:
+ interface["rx"] = network_stats[iface_id].get("rx", 0)
+ interface["tx"] = network_stats[iface_id].get("tx", 0)
+
+ return {
+ "interfaces": interfaces,
+ "statistics": network_stats
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting network status: {str(e)}")
+ return {}
+
+ def get_system_logs(self, limit: int = 20) -> List[Dict[str, Any]]:
+ """Получение журналов системы"""
+ logger.info(f"Getting system logs (limit={limit})")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for system logs request")
+ return []
+
+ try:
+ # Получаем журналы через API
+ result = self._make_api_request("SYNO.Core.SyslogClient.Log", "list", version=1,
+ params={"start": 0, "limit": limit, "sort": "time", "dir": "DESC"})
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for system logs")
+ alt_result = self._make_api_request("SYNO.Core.Log", "list", version=1,
+ params={"start": 0, "limit": limit, "sort": "time", "dir": "DESC"})
+
+ if alt_result:
+ return alt_result.get("logs", [])
+ return []
+
+ return result.get("logs", [])
+
+ except Exception as e:
+ logger.error(f"Error getting system logs: {str(e)}")
+ return []
+
+ def get_power_schedule(self) -> Dict[str, Any]:
+ """Получение расписания включения/выключения"""
+ logger.info("Getting power schedule")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for power schedule request")
+ return {}
+
+ try:
+ # Пробуем сначала более новый API
+ result = self._make_api_request("SYNO.Core.System.PowerSchedule", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ result = self._make_api_request("SYNO.Core.System", "get_power_schedule", version=1)
+
+ if not result:
+ # Если нет результатов, вернем структуру, которую ожидает код
+ logger.warning("PowerSchedule API not available, returning empty schedule structure")
+ return {
+ "boot_tasks": [],
+ "shutdown_tasks": []
+ }
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Error getting power schedule: {str(e)}")
+ return {
+ "boot_tasks": [],
+ "shutdown_tasks": []
+ }
+
+ def set_power_schedule(self, schedule_type: str, days: List[int], time: str, enabled: bool = True) -> bool:
+ """Настройка расписания включения/выключения
+
+ Args:
+ schedule_type: Тип расписания ('boot' или 'shutdown')
+ days: Список дней недели (0-6, где 0 - понедельник)
+ time: Время в формате 'HH:MM'
+ enabled: Включить или выключить расписание
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Setting power schedule for {schedule_type} at {time} on days {days}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for setting power schedule")
+ return False
+
+ try:
+ # Пробуем сначала более новый API
+ api_name = "SYNO.Core.System.PowerSchedule"
+ method = "set"
+ version = 1
+
+ # Подготавливаем новое расписание
+ params = {
+ "enabled": enabled,
+ "type": schedule_type,
+ "day": days,
+ "time": time
+ }
+
+ # Устанавливаем новое расписание
+ result = self._make_api_request(api_name, method, version, params=params)
+
+ if not result:
+ # Пробуем альтернативный API
+ api_name = "SYNO.Core.System"
+ method = "set_power_schedule"
+ result = self._make_api_request(api_name, method, version, params=params)
+
+ if not result:
+ logger.error("Failed to set power schedule with any available API")
+ return False
+
+ logger.info(f"Power schedule for {schedule_type} set successfully with {api_name}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error setting power schedule: {str(e)}")
+ return False
+
+ def get_temperature_status(self) -> Dict[str, Any]:
+ """Получение информации о температуре системы и дисков"""
+ logger.info("Getting temperature status")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for temperature status request")
+ return {}
+
+ try:
+ # Получаем информацию о системе для общей температуры
+ system_info = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ # Получаем информацию о дисках для их температуры
+ storage_info = self._make_api_request("SYNO.Storage.CGI.Storage", "load_info", version=1)
+
+ system_temp = None
+ disk_temps = []
+
+ if system_info:
+ system_temp = system_info.get("temperature")
+
+ if storage_info:
+ disks = storage_info.get("disks", [])
+ for disk in disks:
+ name = disk.get("name", "unknown")
+ model = disk.get("model", "unknown")
+ temp = disk.get("temp", None)
+ if temp is not None:
+ disk_temps.append({
+ "name": name,
+ "model": model,
+ "temperature": temp
+ })
+
+ return {
+ "system_temperature": system_temp,
+ "disk_temperatures": disk_temps,
+ "warning": system_info.get("temperature_warn", False) if system_info else False
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting temperature status: {str(e)}")
+ return {}
+
+ def browse_files(self, folder_path: str = "", limit: int = 100) -> Dict[str, Any]:
+ """Просмотр файлов в указанной директории
+
+ Args:
+ folder_path: Путь к папке (пустая строка для корневых общих папок)
+ limit: Максимальное количество элементов для возврата
+
+ Returns:
+ Словарь с информацией о файлах и папках
+ """
+ logger.info(f"Browsing files in {folder_path or 'root'}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file browsing")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ if not folder_path:
+ # Если путь не указан, получаем список общих папок
+ result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not result:
+ logger.error("Failed to list shared folders")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "items": result.get("shares", []),
+ "path": "",
+ "is_root": True
+ }
+ else:
+ # Получаем список файлов в указанной директории
+ params = {
+ "folder_path": folder_path,
+ "limit": limit,
+ "offset": 0,
+ "sort_by": "name",
+ "sort_direction": "ASC"
+ }
+
+ result = self._make_api_request("SYNO.FileStation.List", "list", version=2, params=params)
+
+ if not result:
+ logger.error(f"Failed to list files in {folder_path}")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "items": result.get("files", []),
+ "path": folder_path,
+ "is_root": False,
+ "total": result.get("total", 0)
+ }
+
+ except Exception as e:
+ logger.error(f"Error browsing files: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def manage_service(self, service_name: str, action: str = "status") -> Dict[str, Any]:
+ """Управление системным сервисом
+
+ Args:
+ service_name: Имя сервиса
+ action: Действие (status/start/stop/restart)
+
+ Returns:
+ Словарь с результатом операции
+ """
+ logger.info(f"Managing service {service_name}, action: {action}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for service management")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Проверяем доступное API для управления сервисами
+ if action == "status":
+ result = self._make_api_request("SYNO.Core.Service", "get", version=1,
+ params={"service": service_name})
+ else:
+ result = self._make_api_request("SYNO.Core.Service", action, version=1,
+ params={"service": service_name})
+
+ if not result:
+ logger.error(f"Failed to {action} service {service_name}")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "service": service_name,
+ "action": action,
+ "result": result,
+ "status": result.get("status") if action == "status" else "completed"
+ }
+
+ except Exception as e:
+ logger.error(f"Error managing service {service_name}: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def search_files(self, pattern: str, folder_path: str = "", limit: int = 100) -> Dict[str, Any]:
+ """Поиск файлов по шаблону
+
+ Args:
+ pattern: Шаблон для поиска
+ folder_path: Путь к папке для поиска (пустая строка для всех общих папок)
+ limit: Максимальное количество результатов
+
+ Returns:
+ Словарь с найденными файлами
+ """
+ logger.info(f"Searching for {pattern} in {folder_path or 'all shared folders'}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file search")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ if not folder_path:
+ # Получаем список всех общих папок для поиска
+ shares_result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not shares_result:
+ logger.error("Failed to list shared folders for search")
+ return {"success": False, "error": "api_error"}
+
+ # Формируем список путей для поиска
+ folder_paths = [share.get("path") for share in shares_result.get("shares", [])]
+ else:
+ folder_paths = [folder_path]
+
+ # Запускаем поиск
+ params = {
+ "folder_path": folder_paths,
+ "pattern": pattern,
+ "limit": limit,
+ "offset": 0
+ }
+
+ result = self._make_api_request("SYNO.FileStation.Search", "start", version=2, params=params)
+
+ if not result:
+ logger.error(f"Failed to start search for {pattern}")
+ return {"success": False, "error": "api_error"}
+
+ # Получаем taskid для проверки результатов
+ taskid = result.get("taskid")
+ if not taskid:
+ logger.error("No taskid received for search")
+ return {"success": False, "error": "no_task_id"}
+
+ # Ожидаем завершения поиска
+ search_result = {"finished": False, "progress": 0}
+ for _ in range(10): # Максимум 10 попыток
+ search_status = self._make_api_request("SYNO.FileStation.Search", "status", version=2,
+ params={"taskid": taskid})
+
+ if not search_status:
+ break
+
+ search_result["progress"] = search_status.get("progress", 0)
+
+ if search_status.get("finished", False):
+ search_result["finished"] = True
+ break
+
+ time.sleep(0.5) # Пауза между запросами
+
+ # Получаем результаты поиска
+ if search_result["finished"]:
+ list_result = self._make_api_request("SYNO.FileStation.Search", "list", version=2,
+ params={"taskid": taskid, "limit": limit})
+
+ if list_result:
+ files = list_result.get("files", [])
+ return {
+ "success": True,
+ "pattern": pattern,
+ "results": files,
+ "total": list_result.get("total", len(files))
+ }
+
+ # Если не удалось получить результаты, останавливаем поиск
+ self._make_api_request("SYNO.FileStation.Search", "stop", version=2,
+ params={"taskid": taskid})
+
+ return {
+ "success": False,
+ "error": "search_timeout",
+ "progress": search_result["progress"]
+ }
+
+ except Exception as e:
+ logger.error(f"Error searching files: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_backup_status(self) -> Dict[str, Any]:
+ """Получение информации о резервном копировании"""
+ logger.info("Getting backup status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for backup status request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о Hyper Backup
+ hyper_result = self._make_api_request("SYNO.Backup.Repository", "list", version=1)
+
+ # Пробуем получить информацию о задачах Time Backup
+ time_result = self._make_api_request("SYNO.BackupService.Task", "list", version=1)
+
+ # Проверяем статус резервного копирования USB
+ usb_result = self._make_api_request("SYNO.USBCopy", "status", version=1)
+
+ backups = {
+ "hyper_backup": hyper_result.get("backups", []) if hyper_result else [],
+ "time_backup": time_result.get("tasks", []) if time_result else [],
+ "usb_copy": {"enabled": usb_result.get("enable", False) if usb_result else False}
+ }
+
+ return {
+ "success": True,
+ "backups": backups,
+ "available_apis": {
+ "hyper_backup": hyper_result is not None,
+ "time_backup": time_result is not None,
+ "usb_copy": usb_result is not None
+ }
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting backup status: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def check_for_updates(self) -> Dict[str, Any]:
+ """Проверка наличия обновлений системы"""
+ logger.info("Checking for system updates")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for update check")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Получаем текущую информацию о системе
+ system_info = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ if not system_info:
+ logger.error("Failed to get system info for update check")
+ return {"success": False, "error": "api_error"}
+
+ # Проверяем наличие обновлений
+ update_result = self._make_api_request("SYNO.Core.Upgrade.Server", "check", version=1)
+
+ # Получаем настройки автоматического обновления
+ settings_result = self._make_api_request("SYNO.Core.Upgrade.Server.Setting", "get", version=1)
+
+ # Получаем информацию о доступных обновлениях
+ update_info = self._make_api_request("SYNO.Core.Upgrade.Server.Download", "list", version=1)
+
+ current_version = system_info.get("version_string", "unknown")
+ auto_update_enabled = settings_result.get("auto_update", False) if settings_result else False
+
+ updates = []
+ if update_info and "updates" in update_info:
+ updates = update_info.get("updates", [])
+
+ update_available = len(updates) > 0
+
+ return {
+ "success": True,
+ "current_version": current_version,
+ "update_available": update_available,
+ "auto_update_enabled": auto_update_enabled,
+ "updates": updates
+ }
+
+ except Exception as e:
+ logger.error(f"Error checking for updates: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_quota_info(self) -> Dict[str, Any]:
+ """Получение информации о квотах пользователей"""
+ logger.info("Getting user quota information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for quota info request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Получаем список пользователей
+ users_result = self._make_api_request("SYNO.Core.User", "list", version=1)
+
+ if not users_result:
+ logger.error("Failed to get user list for quota info")
+ return {"success": False, "error": "api_error"}
+
+ users = users_result.get("users", [])
+ user_quotas = []
+
+ # Получаем квоты для каждого пользователя
+ for user in users:
+ user_name = user.get("name")
+ if not user_name:
+ continue
+
+ quota_result = self._make_api_request("SYNO.Core.Quota", "get", version=1,
+ params={"user_name": user_name})
+
+ if quota_result and "quotas" in quota_result:
+ user_quotas.append({
+ "user": user_name,
+ "quotas": quota_result.get("quotas", [])
+ })
+
+ return {
+ "success": True,
+ "user_quotas": user_quotas
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting quota info: {str(e)}")
+ return {"success": False, "error": str(e)}
diff --git a/.history/src/api/synology_20250830105105.py b/.history/src/api/synology_20250830105105.py
new file mode 100644
index 0000000..ca6117c
--- /dev/null
+++ b/.history/src/api/synology_20250830105105.py
@@ -0,0 +1,1894 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Модуль для взаимодействия с API Synology NAS
+"""
+
+import requests
+from requests.adapters import HTTPAdapter
+import json
+import logging
+import time
+import urllib3
+from urllib3.util import Retry
+from typing import Dict, Any, Optional, List
+import socket
+import struct
+from time import sleep
+
+from src.config.config import (
+ SYNOLOGY_HOST,
+ SYNOLOGY_PORT,
+ SYNOLOGY_USERNAME,
+ SYNOLOGY_PASSWORD,
+ SYNOLOGY_SECURE,
+ SYNOLOGY_TIMEOUT,
+ SYNOLOGY_MAC,
+ WOL_PORT,
+ SYNOLOGY_API_VERSION,
+ SYNOLOGY_POWER_API,
+ SYNOLOGY_INFO_API
+)
+from src.api.api_discovery import discover_available_apis, find_compatible_api
+
+# Отключение предупреждений о небезопасных SSL-соединениях
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+logger = logging.getLogger(__name__)
+
+class SynologyAPI:
+ """Класс для взаимодействия с API Synology NAS"""
+
+ def __init__(self):
+ """Инициализация класса SynologyAPI"""
+ logger.info("Creating API with auto-retry and connection pool")
+ logger.debug(f"Connection details: Host={SYNOLOGY_HOST}, Port={SYNOLOGY_PORT}, Secure={SYNOLOGY_SECURE}")
+
+ self.protocol = "https" if SYNOLOGY_SECURE else "http"
+ self.base_url = f"{self.protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/webapi"
+ self.sid = None
+ self.session = requests.Session()
+
+ # Настройка SSL
+ if self.protocol == "https":
+ logger.debug("SSL enabled, disabling certificate verification for internal network")
+ self.session.verify = False # Отключаем проверку SSL для внутренней сети
+
+ # Добавляем пользовательские заголовки для улучшения совместимости с API
+ custom_headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
+ 'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'Connection': 'keep-alive',
+ 'Referer': f'{self.protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/'
+ }
+ self.session.headers.update(custom_headers)
+ logger.debug("Added browser-like headers for API compatibility")
+
+ # Добавляем повторные попытки для HTTP-запросов
+ retry_strategy = Retry(
+ total=5, # Увеличиваем количество попыток
+ status_forcelist=[429, 500, 502, 503, 504, 404],
+ allowed_methods=["GET", "POST"],
+ backoff_factor=1.5, # Увеличиваем задержку между попытками
+ respect_retry_after_header=True
+ )
+ adapter = HTTPAdapter(
+ max_retries=retry_strategy,
+ pool_connections=3,
+ pool_maxsize=10
+ )
+ self.session.mount("http://", adapter)
+ self.session.mount("https://", adapter)
+
+ # Таймауты будут указаны в запросах
+ self.default_timeout = (SYNOLOGY_TIMEOUT, SYNOLOGY_TIMEOUT*2)
+ logger.debug(f"Setting default request timeout: {self.default_timeout}")
+
+ # Кэш для хранения результатов запросов
+ self._cache = {}
+ self._cache_ttl = {}
+ self._last_online_check = 0
+ self._last_online_status = False
+ self._online_check_interval = 30 # Интервал для кэширования проверок онлайн-статуса
+
+ # Время последней успешной аутентификации и срок действия сессии
+ self._last_auth_time = 0
+ self._auth_expiry = 3600 # По умолчанию 1 час
+
+ # Информация о доступных API
+ self._available_apis = {}
+ self._api_info_ttl = 0
+
+ # Инициализируем API version resolver для автоматического определения совместимых API
+ self.api_resolver = None # Будет создан при необходимости
+
+ def login(self) -> bool:
+ """Авторизация в API Synology NAS"""
+ # Сбрасываем SID для новой сессии
+ self.sid = None
+
+ logger.info("Attempting to authenticate with Synology NAS...")
+ logger.debug(f"Base URL: {self.base_url}")
+
+ # Проверяем доступность NAS перед запросом с помощью прямой TCP-проверки
+ # Избегаем вызова is_online(), чтобы не создавать рекурсию
+ online_status = self._check_tcp_connection()
+ if not online_status:
+ logger.error("Cannot login: Synology NAS is not reachable")
+ return False
+
+ # Пробуем различные версии API для аутентификации
+ # Начинаем с версии 3, которая показала лучшую совместимость в тестах
+ auth_versions_to_try = [3, 2, 1, 6] # Пробуем сначала более стабильные версии
+
+ for auth_version in auth_versions_to_try:
+ try:
+ # Определяем путь к API аутентификации
+ auth_path = "entry.cgi" # Используем entry.cgi вместо auth.cgi по умолчанию
+
+ # Проверка информации API для определения доступных версий API
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": "SYNO.API.Auth"
+ }
+
+ logger.debug(f"Querying API info for auth version {auth_version}")
+ try:
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ auth_info = api_info_data.get("data", {}).get("SYNO.API.Auth", {})
+ max_version = auth_info.get("maxVersion", 6)
+ min_version = auth_info.get("minVersion", 1)
+ auth_path = auth_info.get("path", "entry.cgi")
+ logger.debug(f"Auth API versions: min={min_version}, max={max_version}, path={auth_path}")
+
+ # Проверяем поддержку текущей версии
+ if auth_version < min_version or auth_version > max_version:
+ logger.warning(f"Auth API version {auth_version} not supported (min={min_version}, max={max_version}), trying next version")
+ continue
+ else:
+ logger.warning("Failed to query API info, using default auth path")
+ else:
+ logger.warning(f"API info request failed with status {api_info_response.status_code}, using default auth path")
+ except Exception as e:
+ logger.warning(f"Error querying API info: {str(e)}, using default auth path")
+
+ # Основной запрос авторизации
+ url = f"{self.base_url}/{auth_path}"
+ params = {
+ "api": "SYNO.API.Auth",
+ "version": str(auth_version),
+ "method": "login",
+ "account": SYNOLOGY_USERNAME,
+ "passwd": SYNOLOGY_PASSWORD,
+ "session": "SynologyPowerControlBot",
+ "format": "cookie"
+ }
+
+ # Для версии 6+ используем немного другой формат
+ if auth_version >= 6:
+ params["enable_syno_token"] = "yes"
+
+ logger.debug(f"Sending auth request to {url} with API version {auth_version}")
+ start_time = time.time()
+ response = self.session.get(
+ url,
+ params=params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+ elapsed_time = time.time() - start_time
+ logger.debug(f"Auth request completed in {elapsed_time:.2f}s with status code {response.status_code}")
+
+ # Проверка статуса HTTP
+ if response.status_code != 200:
+ logger.error(f"HTTP error: {response.status_code}")
+ logger.debug(f"Response content: {response.text[:200]}")
+ continue # Пробуем следующую версию
+
+ try:
+ data = response.json()
+ except json.JSONDecodeError:
+ logger.error("Failed to decode JSON response")
+ logger.debug(f"Response content: {response.text[:200]}")
+ continue # Пробуем следующую версию
+
+ if data.get("success"):
+ self.sid = data.get("data", {}).get("sid")
+ self._last_auth_time = time.time()
+ logger.info(f"Successfully logged in to Synology NAS using API version {auth_version}")
+ logger.debug(f"Session ID obtained: {self.sid[:5] if self.sid else 'None'}...")
+
+ # Получаем и сохраняем токен SYNO, если он есть
+ syno_token = data.get("data", {}).get("synotoken")
+ if syno_token:
+ self.session.headers.update({'X-SYNO-TOKEN': syno_token})
+ logger.debug("Added X-SYNO-TOKEN header for improved API compatibility")
+
+ # Также добавляем SID в cookies для улучшения совместимости
+ self.session.cookies.update({
+ 'id': self.sid,
+ 'sid': self.sid
+ })
+ logger.debug("Added SID to session cookies for improved compatibility")
+
+ # Проверка валидности полученной сессии с помощью простого запроса
+ # Будем использовать SYNO.API.Info без проверки сложных методов
+
+ # Даем системе немного времени для инициализации сессии
+ time.sleep(0.5)
+
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to log in with version {auth_version}: Error code {error_code}")
+
+ # Если ошибка связана с версией API, пробуем следующую версию
+ if error_code in [104, 105]:
+ logger.warning(f"Auth version {auth_version} not supported, trying next version")
+ continue
+
+ # Дополнительная диагностика
+ if error_code == 400:
+ logger.error("Authentication error: Invalid credentials")
+ elif error_code == 401:
+ logger.error("Authentication error: Account disabled")
+ elif error_code == 402:
+ logger.error("Authentication error: Permission denied")
+ elif error_code == 403:
+ logger.error("Authentication error: 2-factor authentication required")
+ elif error_code == 404:
+ logger.error("Authentication error: Failed to authenticate with 2-factor authentication")
+
+ # Если ошибка связана с неверными учетными данными, нет смысла пробовать другие версии API
+ if error_code in [400, 401, 402, 403, 404]:
+ return False
+
+ except requests.exceptions.Timeout:
+ logger.error(f"Connection timeout during auth with version {auth_version}")
+ continue # Пробуем следующую версию
+ except requests.exceptions.ConnectionError as e:
+ logger.error(f"Connection error during auth with version {auth_version}: {str(e)}")
+ continue # Пробуем следующую версию
+ except requests.RequestException as e:
+ logger.error(f"Request error during auth with version {auth_version}: {str(e)}")
+ continue # Пробуем следующую версию
+ except Exception as e:
+ logger.error(f"Unexpected error during login with version {auth_version}: {str(e)}", exc_info=True)
+ continue # Пробуем следующую версию
+
+ # Если все версии не сработали
+ logger.error("Failed to authenticate with any API version")
+ return False
+
+ def _validate_session(self) -> bool:
+ """Проверяет валидность сессии после авторизации"""
+ if not self.sid:
+ return False
+
+ # Попробуем сделать простой запрос для проверки сессии
+ test_apis = [
+ {"api": "SYNO.Core.System", "method": "info", "version": 1},
+ {"api": "SYNO.DSM.Info", "method": "getinfo", "version": 1}
+ ]
+
+ for test_api in test_apis:
+ try:
+ url = f"{self.base_url}/entry.cgi"
+ params = {
+ "api": test_api["api"],
+ "version": str(test_api["version"]),
+ "method": test_api["method"],
+ "_sid": self.sid
+ }
+
+ response = self.session.get(url, params=params, timeout=self.default_timeout, verify=False)
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("success"):
+ logger.debug(f"Session validation successful using {test_api['api']}")
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ if error_code != 119: # Не сессия истекла
+ logger.debug(f"Session validation with {test_api['api']} returned error code {error_code}")
+ return True # Считаем сессию валидной, если ошибка не связана с истечением сессии
+ except Exception as e:
+ logger.warning(f"Error during session validation with {test_api['api']}: {str(e)}")
+
+ logger.warning("Session validation failed with all test APIs")
+ return False
+
+ def logout(self) -> bool:
+ """Выход из API Synology NAS"""
+ if not self.sid:
+ return True
+
+ try:
+ url = f"{self.base_url}/auth.cgi"
+ params = {
+ "api": "SYNO.API.Auth",
+ "version": "1",
+ "method": "logout",
+ "session": "SynologyPowerControlBot",
+ "_sid": self.sid
+ }
+
+ response = self.session.get(url, params=params, timeout=SYNOLOGY_TIMEOUT, verify=False)
+ data = response.json()
+
+ if data.get("success"):
+ self.sid = None
+ logger.info("Successfully logged out from Synology NAS")
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to log out from Synology NAS: Error code {error_code}")
+ return False
+
+ except requests.RequestException as e:
+ logger.error(f"Connection error: {str(e)}")
+ return False
+
+ def _make_api_request(self, api_name: str, method: str, version: int = 1,
+ params: Optional[Dict[str, Any]] = None, retry_auth: bool = True, retry_count: int = 0) -> Optional[Dict[str, Any]]:
+ """Обобщенный метод для выполнения API запросов с обработкой ошибок"""
+ # Ограничение на количество повторных попыток
+ if retry_count >= 3:
+ logger.error(f"Too many retries for {api_name}.{method}, giving up")
+ return None
+
+ # Проверка наличия авторизации
+ if not self.sid and not self.login():
+ logger.error(f"Not authenticated for API request: {api_name}.{method}")
+ return None
+
+ # Проверка информации API для определения пути и поддерживаемой версии
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": api_name
+ }
+
+ api_path = "entry.cgi"
+ try:
+ logger.debug(f"Querying API info for {api_name}")
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ api_info = api_info_data.get("data", {}).get(api_name, {})
+ if api_info:
+ max_version = api_info.get("maxVersion", version)
+ min_version = api_info.get("minVersion", version)
+ api_path = api_info.get("path", "entry.cgi")
+
+ # Проверка, поддерживается ли запрошенная версия
+ if version < min_version:
+ logger.warning(f"API version {version} for {api_name} is below minimum {min_version}, using {min_version}")
+ version = min_version
+ elif version > max_version:
+ logger.warning(f"API version {version} for {api_name} exceeds maximum {max_version}, using {max_version}")
+ version = max_version
+
+ logger.debug(f"Using API path: {api_path}, version: {version}")
+ else:
+ logger.warning(f"API {api_name} not found in API info, using defaults")
+ except Exception as e:
+ logger.warning(f"Error querying API info for {api_name}: {str(e)}, using defaults")
+
+ # Подготовка базовых параметров запроса
+ base_params = {
+ "api": api_name,
+ "version": str(version),
+ "method": method,
+ "_sid": self.sid # Используем _sid вместо sid для лучшей совместимости
+ }
+
+ # Добавление дополнительных параметров, если они заданы
+ if params:
+ base_params.update(params)
+
+ url = f"{self.base_url}/{api_path}"
+ logger.debug(f"API request: {api_name}.{method} v{version} to {url}")
+ logger.debug(f"Full request params: {base_params}")
+
+ try:
+ start_time = time.time()
+ response = self.session.get(
+ url,
+ params=base_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+ elapsed_time = time.time() - start_time
+ logger.debug(f"API request completed in {elapsed_time:.2f}s with status {response.status_code}")
+
+ # Проверка статуса HTTP
+ if response.status_code != 200:
+ logger.error(f"HTTP error: {response.status_code} for {api_name}.{method}")
+
+ # Повторная попытка при ошибках соединения
+ if response.status_code in [500, 502, 503, 504]:
+ logger.info(f"Server error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+
+ try:
+ data = response.json()
+ except json.JSONDecodeError:
+ logger.error(f"Failed to decode JSON response for {api_name}.{method}")
+ logger.debug(f"Response content: {response.text[:200]}")
+
+ # Повторная попытка при ошибках декодирования
+ logger.info(f"JSON decode error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ if data.get("success"):
+ logger.info(f"API request successful for {api_name}.{method}")
+ return data.get("data", {})
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ error_desc = self._get_error_description(error_code)
+ logger.error(f"API error for {api_name}.{method}: {error_code} - {error_desc}")
+
+ # Ошибки доступа или прав часто встречаются, но они не критичные
+ # Например, ошибка 102 означает, что нет прав, но NAS доступен
+ if error_code in [102, 103, 104, 105]:
+ logger.warning(f"Permission-related error for {api_name}.{method}: {error_code}")
+ # Возвращаем пустой словарь вместо None,
+ # чтобы вызывающий код мог понять, что запрос выполнен
+ return {}
+
+ # Если ошибка связана с авторизацией и нам разрешено повторить попытку
+ if error_code in [106, 107, 119] and retry_auth:
+ logger.info(f"Session error (code {error_code}), creating fresh session...")
+ self.sid = None # Сбрасываем SID
+
+ # Для ошибки 119 (Session timeout) дадим системе немного времени
+ if error_code == 119:
+ logger.info("Session timeout detected, waiting before retry...")
+ sleep(3)
+
+ if self.login():
+ logger.info("Re-authenticated with fresh session, retrying API request...")
+ # Рекурсивный вызов, но со счетчиком повторов
+ return self._make_api_request(api_name, method, version, params, False, retry_count + 1)
+
+ # Для некоторых ошибок можно автоматически повторить запрос
+ if error_code in [408, 429, 500, 502, 503, 504]:
+ logger.info(f"Temporary error {error_code}, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+
+ except requests.exceptions.Timeout:
+ logger.error(f"Request timeout for {api_name}.{method}")
+
+ # Повторная попытка при таймауте
+ if retry_count < 2:
+ logger.info(f"Timeout, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+ except requests.exceptions.ConnectionError as e:
+ logger.error(f"Connection error for {api_name}.{method}: {str(e)}")
+
+ # Повторная попытка при ошибке соединения
+ if retry_count < 2:
+ logger.info(f"Connection error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+ except Exception as e:
+ logger.error(f"Unexpected error during {api_name}.{method}: {str(e)}")
+ return None
+
+ def get_system_status(self) -> Dict[str, Any]:
+ """Получение статуса системы"""
+ # Проверяем доступность системы
+ if not self.is_online():
+ logger.info("Device is offline, skipping API request")
+ return {"status": "offline"}
+
+ # Проверяем, есть ли кэшированный результат
+ cache_key = "system_status"
+ current_time = time.time()
+ if cache_key in self._cache and current_time - self._cache_ttl.get(cache_key, 0) < 60:
+ logger.debug("Using cached system status")
+ return self._cache[cache_key]
+
+ # Используем рекомендованный API для получения информации о системе
+ logger.info(f"Getting system info using {SYNOLOGY_INFO_API} v{SYNOLOGY_API_VERSION}")
+
+ # Для SYNO.DSM.Info метод getinfo, для других обычно используется get или info
+ if SYNOLOGY_INFO_API == "SYNO.DSM.Info":
+ method = "getinfo"
+ else:
+ method = "get"
+
+ result = self._make_api_request(SYNOLOGY_INFO_API, method, version=SYNOLOGY_API_VERSION)
+
+ if result:
+ logger.info(f"Successfully retrieved system info using {SYNOLOGY_INFO_API}")
+
+ # Формируем расширенный ответ с дополнительной информацией
+ system_info = {
+ "status": "online",
+ "hostname": result.get("hostname", "unknown"),
+ "model": result.get("model", "unknown"),
+ "version": result.get("version", "unknown"),
+ "uptime": result.get("uptime", 0),
+ "time": current_time,
+ "is_online": True,
+ "api_used": SYNOLOGY_INFO_API
+ }
+
+ # Сохраняем в кэше
+ self._cache[cache_key] = system_info
+ self._cache_ttl[cache_key] = current_time
+
+ return system_info
+
+ # Если основной API не сработал, пробуем резервные варианты
+ logger.warning(f"Failed to retrieve system info with {SYNOLOGY_INFO_API}, trying fallback APIs")
+
+ # Пробуем резервные API
+ apis_to_try = [
+ {"name": "SYNO.DSM.Info", "method": "getinfo", "version": 2},
+ {"name": "SYNO.Core.System", "method": "info", "version": 1},
+ {"name": "SYNO.Core.System.Status", "method": "get", "version": 1},
+ {"name": "SYNO.Core.System.Info", "method": "get", "version": 1},
+ ]
+
+ for api in apis_to_try:
+ if api["name"] == SYNOLOGY_INFO_API:
+ continue # Пропускаем уже проверенный API
+
+ logger.info(f"Trying to get system info using {api['name']}.{api['method']} v{api['version']}")
+ result = self._make_api_request(api["name"], api["method"], version=api["version"])
+
+ if result:
+ logger.info(f"Successfully retrieved system info using {api['name']}")
+
+ # Формируем расширенный ответ с дополнительной информацией
+ system_info = {
+ "status": "online",
+ "hostname": result.get("hostname", "unknown"),
+ "model": result.get("model", "unknown"),
+ "version": result.get("version", "unknown"),
+ "uptime": result.get("uptime", 0),
+ "time": current_time,
+ "is_online": True,
+ "api_used": api["name"]
+ }
+
+ # Сохраняем в кэше
+ self._cache[cache_key] = system_info
+ self._cache_ttl[cache_key] = current_time
+
+ return system_info
+
+ # Если все запросы не удались, но система онлайн, возвращаем базовую информацию
+ logger.warning("Failed to retrieve system info with all API methods")
+ return {
+ "status": "error",
+ "error": "Failed to fetch system information",
+ "is_online": True,
+ "time": current_time
+ }
+
+ def shutdown_system(self) -> bool:
+ """Выключение системы"""
+ # Проверяем, включено ли устройство перед попыткой его выключить
+ if not self.is_online(force_check=True):
+ logger.info("Synology NAS is already offline, no need to shut down")
+ return True
+
+ logger.info("Attempting to shutdown Synology NAS...")
+
+ # Попробуем сначала использовать предпочтительный API для управления питанием
+ logger.info(f"Trying shutdown with {SYNOLOGY_POWER_API} v{SYNOLOGY_API_VERSION}")
+
+ # Для SYNO.Core.Hardware.PowerRecovery используется метод setPowerOnState
+ # Для других API обычно используется метод shutdown или reboot
+ if SYNOLOGY_POWER_API == "SYNO.Core.Hardware.PowerRecovery":
+ # Для этого API нужны специальные параметры
+ params = {"state": "powerbtn"} # powerbtn имитирует нажатие кнопки питания
+ result = self._make_api_request(SYNOLOGY_POWER_API, "setPowerOnState", version=SYNOLOGY_API_VERSION, params=params)
+ else:
+ # Пробуем стандартный метод
+ result = self._make_api_request(SYNOLOGY_POWER_API, "shutdown", version=SYNOLOGY_API_VERSION)
+
+ if result is not None:
+ logger.info(f"Successfully initiated system shutdown using {SYNOLOGY_POWER_API}")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ # Проверяем статус
+ if not self.is_online(force_check=True):
+ logger.info("System is now offline. Shutdown confirmed successful.")
+ return True
+ else:
+ logger.info("System still appears to be online, but shutdown may be in progress.")
+ return True
+ else:
+ logger.warning(f"Failed to shutdown using {SYNOLOGY_POWER_API}, trying alternative methods")
+
+ # Если не сработал основной метод, пробуем резервные варианты
+ # Проверка всех доступных методов API для выключения
+ apis_to_try = [
+ {"name": "SYNO.Core.System", "method": "shutdown", "version": 3},
+ {"name": "SYNO.Core.System.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.DSM.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.System.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.DSM.System", "method": "shutdown", "version": 1}
+ ]
+
+ # Проверяем доступные API
+ try:
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": "SYNO.Core.System.Power,SYNO.DSM.Power,SYNO.Core.System,SYNO.System.Power,SYNO.DSM.System"
+ }
+
+ logger.debug("Checking available shutdown APIs")
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ available_apis = api_info_data.get("data", {})
+ logger.debug(f"Available APIs: {list(available_apis.keys())}")
+
+ # Фильтруем только доступные API
+ filtered_apis = []
+ for api in apis_to_try:
+ if api["name"] in available_apis:
+ api_info = available_apis[api["name"]]
+ # Проверка версии API
+ if api_info.get("minVersion", 1) <= api["version"] <= api_info.get("maxVersion", 1):
+ filtered_apis.append(api)
+ logger.debug(f"Adding {api['name']} to available shutdown APIs")
+ else:
+ logger.debug(f"API {api['name']} version {api['version']} not supported. Supported: {api_info.get('minVersion')}-{api_info.get('maxVersion')}")
+
+ if filtered_apis:
+ apis_to_try = filtered_apis
+ else:
+ logger.warning("No compatible APIs found, trying all methods as fallback")
+ else:
+ logger.warning("Failed to query API info, using default methods")
+ else:
+ logger.warning(f"API info request failed with status {api_info_response.status_code}, using default methods")
+ except Exception as e:
+ logger.warning(f"Error querying API info: {str(e)}, using default methods")
+
+ # Пробуем все доступные методы по порядку
+ for api in apis_to_try:
+ logger.info(f"Trying shutdown with {api['name']}.{api['method']} v{api['version']}")
+ result = self._make_api_request(api["name"], api["method"], version=api["version"])
+
+ if result is not None:
+ logger.info(f"Successfully initiated system shutdown using {api['name']}")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ # Проверяем статус
+ if not self.is_online(force_check=True):
+ logger.info("System is now offline. Shutdown confirmed successful.")
+ return True
+ else:
+ logger.info("System still appears to be online, but shutdown may be in progress.")
+ return True
+ else:
+ logger.warning(f"Failed to shutdown using {api['name']}.{api['method']} v{api['version']}")
+
+ # Если ни один метод не сработал, но система стала недоступна
+ if not self.is_online(force_check=True):
+ logger.info("System appears to be shutting down despite API errors")
+ return True
+
+ logger.error("Failed to shutdown system after trying multiple APIs")
+ return False
+
+ def reboot_system(self) -> bool:
+ """Перезагрузка системы"""
+ # Проверяем, включена ли система
+ if not self.is_online(force_check=True):
+ logger.error("Cannot reboot: System is offline")
+ return False
+
+ logger.info("Attempting to reboot Synology NAS...")
+
+ # Список API и методов для попытки перезагрузки
+ apis_to_try = [
+ {"api": "SYNO.Core.System", "method": "reboot", "version": 1},
+ {"api": "SYNO.DSM.System", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.System.Power", "method": "restart", "version": 1},
+ {"api": "SYNO.DSM.Power", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.System", "method": "reboot", "version": 3},
+ {"api": "SYNO.System.Power", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.Hardware.NeedReboot", "method": "reboot", "version": 1}
+ ]
+
+ # Добавляем настроенный в конфигурации API, если он отличается от уже добавленных
+ already_added = [item["api"] for item in apis_to_try]
+ if SYNOLOGY_POWER_API not in already_added:
+ for method in ["restart", "reboot"]:
+ apis_to_try.append({
+ "api": SYNOLOGY_POWER_API,
+ "method": method,
+ "version": SYNOLOGY_API_VERSION
+ })
+
+ # Перебираем все возможные API и методы
+ for api_info in apis_to_try:
+ try:
+ logger.info(f"Trying reboot with {api_info['api']} API using method {api_info['method']}")
+ result = self._make_api_request(api_info['api'], api_info['method'], version=api_info['version'])
+ if result is not None:
+ logger.info(f"Successfully initiated system reboot using {api_info['api']} API")
+
+ # Даем системе время начать процесс перезагрузки
+ logger.info("Waiting for reboot to initialize...")
+ sleep(5)
+
+ # Ждем, пока система станет недоступна (признак перезагрузки)
+ reboot_started = False
+ for i in range(12): # Проверяем в течение 60 секунд (12 * 5сек)
+ if not self.is_online(force_check=True):
+ logger.info(f"System went offline after {i*5} seconds, reboot in progress")
+ reboot_started = True
+ break
+ logger.debug(f"System still online, waiting... ({i+1}/12)")
+ sleep(5)
+
+ if reboot_started:
+ # Успешный вызов API и система ушла оффлайн - это признак успешной перезагрузки
+ return True
+ else:
+ # Успешный вызов API, но система не ушла оффлайн
+ logger.warning("System did not go offline after reboot command, but command was accepted")
+ # Даже если система не ушла оффлайн, команда могла быть принята
+ return True
+ except Exception as e:
+ logger.error(f"Error during reboot with {api_info['api']}: {str(e)}")
+
+ # Если все попытки не удались, возвращаем False
+ logger.error("All reboot attempts failed")
+ return False
+
+ def _get_error_description(self, error_code: int) -> str:
+ """Получение описания ошибки по коду"""
+ error_descriptions = {
+ 100: "Unknown error",
+ 101: "Invalid parameter",
+ 102: "API does not exist",
+ 103: "Method does not exist",
+ 104: "Version does not support",
+ 105: "Permission denied",
+ 106: "Session timeout",
+ 107: "Session interrupted by duplicate login",
+ 400: "Invalid credentials",
+ 401: "Account disabled",
+ 402: "Permission denied",
+ 403: "2FA required",
+ 404: "Failed to authenticate with 2FA"
+ }
+ return error_descriptions.get(error_code, "Unknown error code")
+
+ def _check_tcp_connection(self) -> bool:
+ """Проверка базового TCP-соединения с Synology NAS"""
+ try:
+ socket_obj = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ socket_obj.settimeout(SYNOLOGY_TIMEOUT)
+ result = socket_obj.connect_ex((SYNOLOGY_HOST, SYNOLOGY_PORT))
+ socket_obj.close()
+
+ return result == 0
+ except socket.error as e:
+ logger.error(f"Socket error during connection check: {str(e)}")
+ return False
+ except Exception as e:
+ logger.error(f"Unexpected error during connection check: {str(e)}")
+ return False
+
+ def is_online(self, force_check=False) -> bool:
+ """Проверка онлайн-статуса Synology NAS"""
+ # Используем кэшированное значение, если доступно и не устарело
+ current_time = time.time()
+ if not force_check and (current_time - self._last_online_check) < self._online_check_interval:
+ logger.debug(f"Using cached online status: {self._last_online_status}")
+ return self._last_online_status
+
+ logger.info("Checking if NAS is online...")
+
+ # Проверяем TCP-соединение
+ online_status = self._check_tcp_connection()
+ logger.info(f"Detected Synology NAS online status: {online_status}")
+
+ # Если TCP-соединение успешно и у нас есть действующий SID,
+ # попробуем более детальную проверку через API
+ if online_status and self.sid:
+ logger.info("Trying to fetch more detailed online status through API...")
+
+ # Пробуем разные API для проверки онлайн-статуса
+ api_checks = [
+ {"api": "SYNO.DSM.Info", "version": "2", "method": "getinfo"},
+ {"api": "SYNO.Core.System", "version": "1", "method": "info"},
+ {"api": "SYNO.Core.System.Status", "version": "1", "method": "get"}
+ ]
+
+ api_success = False
+ for api_check in api_checks:
+ try:
+ url = f"{self.base_url}/entry.cgi"
+ params = {
+ "api": api_check["api"],
+ "version": api_check["version"],
+ "method": api_check["method"],
+ "sid": self.sid
+ }
+
+ logger.debug(f"Trying online status check with {api_check['api']}")
+ response = self.session.get(
+ url,
+ params=params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("success"):
+ logger.info(f"API request successful for {api_check['api']}")
+ logger.info("Synology NAS is online with API access")
+ api_success = True
+ break
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.warning(f"API response indicates an error: {error_code}, but NAS is reachable")
+ else:
+ logger.warning(f"API returned status code {response.status_code}")
+ except Exception as e:
+ logger.warning(f"API check failed for {api_check['api']}, but TCP connection succeeded: {str(e)}")
+
+ if not api_success:
+ logger.warning("All API checks failed, but TCP connection is successful")
+
+ # Обновляем кэшированное значение
+ self._last_online_check = current_time
+ self._last_online_status = online_status
+
+ return online_status
+
+ def wake_on_lan(self) -> bool:
+ """Отправка Wake-on-LAN пакета для включения Synology NAS"""
+ if not SYNOLOGY_MAC:
+ logger.error("MAC address not configured")
+ return False
+
+ try:
+ # Преобразование MAC-адреса в байты
+ mac_address = SYNOLOGY_MAC.replace(':', '').replace('-', '')
+ if len(mac_address) != 12:
+ logger.error(f"Invalid MAC address format: {SYNOLOGY_MAC}")
+ return False
+
+ try:
+ mac_bytes = bytes.fromhex(mac_address)
+ except ValueError as e:
+ logger.error(f"Failed to parse MAC address: {str(e)}")
+ logger.error(f"MAC address must contain only hex characters: {SYNOLOGY_MAC}")
+ return False
+
+ # Создание Magic Packet
+ magic_packet = b'\xff' * 6 + mac_bytes * 16
+
+ # Отправка пакета на конкретный адрес
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.sendto(magic_packet, (SYNOLOGY_HOST, WOL_PORT))
+ sock.close()
+ logger.info(f"Wake-on-LAN packet sent to {SYNOLOGY_MAC} at {SYNOLOGY_HOST}:{WOL_PORT}")
+ except Exception as e:
+ logger.error(f"Error sending directed WoL packet: {str(e)}")
+ return False
+
+ # Для надежности отправляем также широковещательный пакет
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+ # Используем стандартный широковещательный адрес
+ broadcast_addr = "255.255.255.255"
+ sock.sendto(magic_packet, (broadcast_addr, WOL_PORT))
+ sock.close()
+ logger.info(f"Wake-on-LAN broadcast packet sent to {broadcast_addr}:{WOL_PORT}")
+ except Exception as e:
+ logger.warning(f"Error sending broadcast WoL packet (non-critical): {str(e)}")
+ # Не считаем ошибкой, т.к. основной пакет уже отправлен
+
+ return True
+
+ except Exception as e:
+ logger.error(f"Unexpected error in wake_on_lan: {str(e)}")
+ return False
+
+ def wait_for_boot(self, max_attempts: int = 30, delay: int = 10) -> bool:
+ """Ожидание загрузки Synology NAS"""
+ logger.info(f"Waiting for Synology NAS to boot (timeout: {max_attempts * delay}s)...")
+
+ for attempt in range(max_attempts):
+ # Принудительно проверяем статус без использования кэша
+ if self.is_online(force_check=True):
+ logger.info(f"Synology NAS is online after {attempt + 1} attempts ({(attempt + 1) * delay}s)")
+
+ # Проверяем, что не только сеть доступна, но и API загрузился
+ api_ready = False
+ logger.info("Waiting for API services to initialize...")
+
+ for api_check in range(5): # Даем еще до 50 секунд для загрузки API
+ if self.sid or self.login():
+ api_ready = True
+ logger.info(f"API services are ready after {api_check + 1} attempts")
+ break
+ logger.debug(f"API not ready yet, waiting... ({api_check + 1}/5)")
+ sleep(10)
+
+ if not api_ready:
+ logger.warning("System is online but API services may not be fully initialized")
+
+ # Дадим дополнительное время для полной загрузки всех сервисов
+ sleep(delay)
+ return True
+
+ sleep(delay)
+ logger.info(f"Waiting for boot... Attempt {attempt + 1}/{max_attempts}")
+
+ logger.error(f"Synology NAS didn't come online after {max_attempts * delay} seconds")
+ return False
+
+ def power_on(self) -> bool:
+ """Включение Synology NAS"""
+ # Принудительная проверка статуса
+ if self.is_online(force_check=True):
+ logger.info("Synology NAS is already online")
+ return True
+
+ logger.info("Powering on Synology NAS via Wake-on-LAN...")
+
+ # Проверяем, настроен ли MAC-адрес
+ if not SYNOLOGY_MAC:
+ logger.error("Cannot power on: MAC address not configured in settings")
+ return False
+
+ # Пробуем отправить несколько WoL пакетов для надежности
+ success = False
+ for attempt in range(3):
+ logger.debug(f"Sending WoL packet, attempt {attempt + 1}/3")
+ if self.wake_on_lan():
+ success = True
+ break
+ sleep(1)
+
+ if not success:
+ logger.error("Failed to send Wake-on-LAN packets")
+ return False
+
+ # Ожидание загрузки
+ logger.info("WoL packets sent successfully, waiting for system to boot...")
+ boot_result = self.wait_for_boot(max_attempts=30, delay=10)
+
+ if boot_result:
+ # Проверяем доступность API после загрузки
+ system_status = self.get_system_status()
+ if system_status.get("status") == "online":
+ logger.info("System booted successfully with API access")
+ return True
+ else:
+ logger.warning("System appears to be online but API may not be fully ready")
+ return True
+ else:
+ logger.error("System did not come online after WoL")
+ return False
+
+ def power_off(self) -> bool:
+ """Выключение Synology NAS"""
+ if not self.is_online(force_check=True):
+ logger.info("Synology NAS is already offline")
+ return True
+
+ logger.info("Powering off Synology NAS...")
+
+ # Список API и методов для попытки выключения
+ apis_to_try = [
+ {"api": "SYNO.Core.System", "method": "shutdown", "version": 1},
+ {"api": "SYNO.DSM.System", "method": "shutdown", "version": 1},
+ {"api": SYNOLOGY_POWER_API, "method": "shutdown", "version": SYNOLOGY_API_VERSION}
+ ]
+
+ # Перебираем все возможные API и методы
+ for api_info in apis_to_try:
+ try:
+ logger.info(f"Trying shutdown with {api_info['api']} API using method {api_info['method']}")
+ api_result = self._make_api_request(api_info['api'], api_info['method'], version=api_info['version'])
+ if api_result is not None:
+ logger.info(f"Successfully initiated system shutdown using {api_info['api']} API")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ return True
+ except Exception as e:
+ logger.error(f"Error during shutdown with {api_info['api']}: {str(e)}")
+
+ # Если все попытки не удались, возвращаем False
+ logger.error("All shutdown attempts failed")
+ return False
+
+ # Если все еще не сработало, используем оригинальный метод shutdown_system
+ if not result:
+ result = self.shutdown_system()
+
+ if result:
+ # Дополнительная проверка, что система действительно выключилась
+ logger.info("Verifying system is offline...")
+ for attempt in range(12): # Проверяем в течение 2 минут (12 * 10 сек)
+ if not self.is_online(force_check=True):
+ logger.info(f"System confirmed offline after {attempt * 10} seconds")
+ return True
+ logger.debug(f"System still shutting down, checking again... ({attempt + 1}/12)")
+ sleep(10)
+
+ logger.warning("System still appears to be online after shutdown command")
+ return False
+ else:
+ logger.error("Failed to initiate shutdown")
+ return False
+
+ # Заглушки для расширенных методов
+ def get_shared_folders(self) -> List[Dict[str, Any]]:
+ """Получение списка общих папок"""
+ logger.info("Getting list of shared folders")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for shared folders request")
+ return []
+
+ try:
+ # Запрашиваем список общих папок через FileStation API
+ result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for shared folders")
+ alt_result = self._make_api_request("SYNO.FileStation.List", "list_share", version=1)
+ if alt_result:
+ return alt_result.get("shares", [])
+ return []
+
+ return result.get("shares", [])
+
+ except Exception as e:
+ logger.error(f"Error getting shared folders: {str(e)}")
+ return []
+
+ def get_system_load(self) -> Dict[str, Any]:
+ """Получение информации о загрузке системы"""
+ logger.info("Getting system load information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for system load request")
+ return {}
+
+ try:
+ # Запрашиваем информацию о загрузке системы
+ result = self._make_api_request("SYNO.Core.System.Utilization", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for system load")
+ alt_result = self._make_api_request("SYNO.Core.System", "info", version=3)
+
+ if not alt_result:
+ return {}
+
+ # Формируем из частичных данных
+ return {
+ "cpu_load": alt_result.get("cpu_usage", 0),
+ "memory": {
+ "total": alt_result.get("memory_size", 0),
+ "used": alt_result.get("memory_usage", 0),
+ "usage_percent": alt_result.get("memory_usage_percent", 0)
+ }
+ }
+
+ # Формируем структурированный результат
+ return {
+ "cpu_load": result.get("cpu", {}).get("user", 0) + result.get("cpu", {}).get("system", 0),
+ "memory": result.get("memory", {}),
+ "network": result.get("network", {})
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting system load: {str(e)}")
+ return {}
+
+ def is_online_api(self) -> bool:
+ """Проверка онлайн-статуса Synology NAS с использованием API"""
+ if not self.is_online():
+ return False
+
+ # Проверяем доступность API через авторизацию
+ if not self.sid and not self.login():
+ return False
+
+ return True
+
+ def get_storage_status(self) -> Dict[str, Any]:
+ """Получение подробной информации о хранилище"""
+ logger.info("Getting storage status information")
+
+ # Проверяем доступность NAS и API
+ if not self.is_online_api():
+ logger.error("Cannot get storage status: NAS is not online or API is not accessible")
+ return {"error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о хранилище через SYNO.Storage.CGI.Storage API
+ result = self._make_api_request("SYNO.Storage.CGI.Storage", "load_info", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for storage info")
+ alt_result = self._make_api_request("SYNO.Core.Storage", "info", version=1)
+
+ if not alt_result:
+ # Пробуем еще один альтернативный API
+ logger.info("Trying SYNO.Core.System API for storage info")
+ sys_result = self._make_api_request("SYNO.Core.System", "info", version=3)
+
+ if not sys_result:
+ return {
+ "volumes": [],
+ "disks": [],
+ "total_size": 0,
+ "total_used": 0,
+ "error": "no_data"
+ }
+
+ # Извлекаем базовую информацию о хранилище из системной информации
+ return {
+ "volumes": [],
+ "disks": [],
+ "total_size": sys_result.get("hdd_total_size", 0) * 1024**2, # МБ в байты
+ "total_used": sys_result.get("hdd_total_size", 0) * sys_result.get("hdd_usage", 0) / 100 * 1024**2,
+ }
+
+ # Обрабатываем данные из альтернативного API
+ volumes = alt_result.get("volumes", [])
+ disks = alt_result.get("disks", [])
+
+ else:
+ # Обрабатываем данные из основного API
+ volumes = result.get("volumes", [])
+ disks = result.get("disks", [])
+
+ # Рассчитываем общие размеры
+ total_size = 0
+ total_used = 0
+
+ for volume in volumes:
+ volume_size = volume.get("size", {}).get("total", 0)
+ volume_used = volume.get("size", {}).get("used", 0)
+
+ total_size += volume_size
+ total_used += volume_used
+
+ return {
+ "volumes": volumes,
+ "disks": disks,
+ "total_size": total_size,
+ "total_used": total_used
+ }
+
+ except Exception as e:
+ logger.error(f"Error in get_storage_status: {str(e)}")
+ return {"error": str(e)}
+
+ def get_security_status(self) -> Dict[str, Any]:
+ """Получение информации о состоянии безопасности"""
+ logger.info("Getting security status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for security status request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о безопасности через API Security Scan
+ result = self._make_api_request("SYNO.Core.SecurityScan.Status", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for security status")
+ # Проверяем статус брандмауэра
+ firewall_result = self._make_api_request("SYNO.Core.Security.Firewall.Status", "get", version=1)
+
+ # Проверяем статус автоматических обновлений
+ update_result = self._make_api_request("SYNO.Core.Upgrade.Server.Setting", "get", version=1)
+
+ # Если ни один из API не отвечает
+ if not firewall_result and not update_result:
+ # Получаем общую информацию о системе для базовой проверки безопасности
+ sys_result = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ if not sys_result:
+ return {
+ "success": False,
+ "status": "unknown",
+ "last_check": None,
+ "is_secure": False,
+ "error": "no_security_api"
+ }
+
+ # Собираем базовые сведения из системной информации
+ return {
+ "success": True,
+ "status": "basic",
+ "last_check": None,
+ "is_secure": True, # Предполагаем, что система в целом безопасна
+ "firewall_enabled": None,
+ "auto_update": None,
+ "version_latest": sys_result.get("version_string", "")
+ }
+
+ # Собираем информацию из доступных результатов
+ firewall_enabled = firewall_result.get("enabled", False) if firewall_result else None
+ auto_update = update_result.get("auto_update", False) if update_result else None
+
+ # Определяем, насколько система безопасна
+ is_secure = True # По умолчанию предполагаем, что система безопасна
+ if firewall_enabled is not None and not firewall_enabled:
+ is_secure = False
+
+ return {
+ "success": True,
+ "status": "partial",
+ "last_check": None,
+ "is_secure": is_secure,
+ "firewall_enabled": firewall_enabled,
+ "auto_update": auto_update
+ }
+
+ # Если основное API отвечает, возвращаем его данные
+ return {
+ "success": True,
+ "status": result.get("status", "unknown"),
+ "last_check": result.get("last_check", None),
+ "is_secure": result.get("is_secure", False),
+ "details": result.get("details", {})
+ }
+
+ except Exception as e:
+ logger.error(f"Error in get_security_status: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_processes(self, limit: int = 20) -> List[Dict[str, Any]]:
+ """Получение списка активных процессов"""
+ logger.info(f"Getting list of active processes (limit={limit})")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for processes request")
+ return []
+
+ try:
+ # Получаем список процессов через API
+ result = self._make_api_request("SYNO.Core.System.Process", "list", version=1,
+ params={"sort_by": "cpu", "order": "DESC", "limit": limit})
+
+ if not result:
+ logger.warning("Failed to get process list")
+ return []
+
+ return result.get("processes", [])
+
+ except Exception as e:
+ logger.error(f"Error getting process list: {str(e)}")
+ return []
+
+ def get_network_status(self) -> Dict[str, Any]:
+ """Получение информации о сетевых подключениях"""
+ logger.info("Getting network status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for network status request")
+ return {}
+
+ try:
+ # Получаем информацию о сетевых интерфейсах
+ interface_result = self._make_api_request("SYNO.Core.Network.Interface", "list", version=1)
+
+ # Получаем статистику использования сети
+ utilization_result = self._make_api_request("SYNO.Core.System.Utilization", "get", version=1)
+
+ interfaces = []
+ if interface_result:
+ interfaces = interface_result.get("interfaces", [])
+
+ network_stats = {}
+ if utilization_result and "network" in utilization_result:
+ network_stats = utilization_result.get("network", {})
+
+ # Объединяем данные
+ for interface in interfaces:
+ iface_id = interface.get("id", "")
+ if iface_id in network_stats:
+ interface["rx"] = network_stats[iface_id].get("rx", 0)
+ interface["tx"] = network_stats[iface_id].get("tx", 0)
+
+ return {
+ "interfaces": interfaces,
+ "statistics": network_stats
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting network status: {str(e)}")
+ return {}
+
+ def get_system_logs(self, limit: int = 20) -> List[Dict[str, Any]]:
+ """Получение журналов системы"""
+ logger.info(f"Getting system logs (limit={limit})")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for system logs request")
+ return []
+
+ try:
+ # Получаем журналы через API
+ result = self._make_api_request("SYNO.Core.SyslogClient.Log", "list", version=1,
+ params={"start": 0, "limit": limit, "sort": "time", "dir": "DESC"})
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for system logs")
+ alt_result = self._make_api_request("SYNO.Core.Log", "list", version=1,
+ params={"start": 0, "limit": limit, "sort": "time", "dir": "DESC"})
+
+ if alt_result:
+ return alt_result.get("logs", [])
+ return []
+
+ return result.get("logs", [])
+
+ except Exception as e:
+ logger.error(f"Error getting system logs: {str(e)}")
+ return []
+
+ def get_power_schedule(self) -> Dict[str, Any]:
+ """Получение расписания включения/выключения"""
+ logger.info("Getting power schedule")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for power schedule request")
+ return {}
+
+ try:
+ # Список возможных API для получения расписания питания
+ apis_to_try = [
+ {"api": "SYNO.Core.System.PowerSchedule", "method": "get", "version": 1},
+ {"api": "SYNO.Core.System", "method": "get_power_schedule", "version": 1},
+ {"api": "SYNO.Core.Power", "method": "schedule", "version": 1},
+ {"api": "SYNO.Core.Power.Schedule", "method": "get", "version": 1},
+ {"api": "SYNO.PowerScheduler", "method": "load", "version": 1},
+ {"api": "SYNO.PowerSchedule", "method": "get", "version": 1}
+ ]
+
+ result = {}
+ # Пробуем все возможные API по очереди
+ for api_config in apis_to_try:
+ logger.debug(f"Trying API: {api_config['api']}.{api_config['method']} v{api_config['version']}")
+ temp_result = self._make_api_request(
+ api_config["api"],
+ api_config["method"],
+ version=api_config["version"]
+ )
+ if temp_result:
+ logger.info(f"Successfully retrieved power schedule using {api_config['api']}.{api_config['method']}")
+ result = temp_result
+ break
+
+ if not result:
+ # Если нет результатов, вернем структуру, которую ожидает код
+ logger.warning("No PowerSchedule API available, returning empty schedule structure")
+ return {
+ "boot_tasks": [],
+ "shutdown_tasks": []
+ }
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Error getting power schedule: {str(e)}")
+ return {
+ "boot_tasks": [],
+ "shutdown_tasks": []
+ }
+
+ def set_power_schedule(self, schedule_type: str, days: List[int], time: str, enabled: bool = True) -> bool:
+ """Настройка расписания включения/выключения
+
+ Args:
+ schedule_type: Тип расписания ('boot' или 'shutdown')
+ days: Список дней недели (0-6, где 0 - понедельник)
+ time: Время в формате 'HH:MM'
+ enabled: Включить или выключить расписание
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Setting power schedule for {schedule_type} at {time} on days {days}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for setting power schedule")
+ return False
+
+ try:
+ # Пробуем сначала более новый API
+ api_name = "SYNO.Core.System.PowerSchedule"
+ method = "set"
+ version = 1
+
+ # Подготавливаем новое расписание
+ params = {
+ "enabled": enabled,
+ "type": schedule_type,
+ "day": days,
+ "time": time
+ }
+
+ # Устанавливаем новое расписание
+ result = self._make_api_request(api_name, method, version, params=params)
+
+ if not result:
+ # Пробуем альтернативный API
+ api_name = "SYNO.Core.System"
+ method = "set_power_schedule"
+ result = self._make_api_request(api_name, method, version, params=params)
+
+ if not result:
+ logger.error("Failed to set power schedule with any available API")
+ return False
+
+ logger.info(f"Power schedule for {schedule_type} set successfully with {api_name}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error setting power schedule: {str(e)}")
+ return False
+
+ def get_temperature_status(self) -> Dict[str, Any]:
+ """Получение информации о температуре системы и дисков"""
+ logger.info("Getting temperature status")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for temperature status request")
+ return {}
+
+ try:
+ # Получаем информацию о системе для общей температуры
+ system_info = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ # Получаем информацию о дисках для их температуры
+ storage_info = self._make_api_request("SYNO.Storage.CGI.Storage", "load_info", version=1)
+
+ system_temp = None
+ disk_temps = []
+
+ if system_info:
+ system_temp = system_info.get("temperature")
+
+ if storage_info:
+ disks = storage_info.get("disks", [])
+ for disk in disks:
+ name = disk.get("name", "unknown")
+ model = disk.get("model", "unknown")
+ temp = disk.get("temp", None)
+ if temp is not None:
+ disk_temps.append({
+ "name": name,
+ "model": model,
+ "temperature": temp
+ })
+
+ return {
+ "system_temperature": system_temp,
+ "disk_temperatures": disk_temps,
+ "warning": system_info.get("temperature_warn", False) if system_info else False
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting temperature status: {str(e)}")
+ return {}
+
+ def browse_files(self, folder_path: str = "", limit: int = 100) -> Dict[str, Any]:
+ """Просмотр файлов в указанной директории
+
+ Args:
+ folder_path: Путь к папке (пустая строка для корневых общих папок)
+ limit: Максимальное количество элементов для возврата
+
+ Returns:
+ Словарь с информацией о файлах и папках
+ """
+ logger.info(f"Browsing files in {folder_path or 'root'}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file browsing")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ if not folder_path:
+ # Если путь не указан, получаем список общих папок
+ result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not result:
+ logger.error("Failed to list shared folders")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "items": result.get("shares", []),
+ "path": "",
+ "is_root": True
+ }
+ else:
+ # Получаем список файлов в указанной директории
+ params = {
+ "folder_path": folder_path,
+ "limit": limit,
+ "offset": 0,
+ "sort_by": "name",
+ "sort_direction": "ASC"
+ }
+
+ result = self._make_api_request("SYNO.FileStation.List", "list", version=2, params=params)
+
+ if not result:
+ logger.error(f"Failed to list files in {folder_path}")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "items": result.get("files", []),
+ "path": folder_path,
+ "is_root": False,
+ "total": result.get("total", 0)
+ }
+
+ except Exception as e:
+ logger.error(f"Error browsing files: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def manage_service(self, service_name: str, action: str = "status") -> Dict[str, Any]:
+ """Управление системным сервисом
+
+ Args:
+ service_name: Имя сервиса
+ action: Действие (status/start/stop/restart)
+
+ Returns:
+ Словарь с результатом операции
+ """
+ logger.info(f"Managing service {service_name}, action: {action}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for service management")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Проверяем доступное API для управления сервисами
+ if action == "status":
+ result = self._make_api_request("SYNO.Core.Service", "get", version=1,
+ params={"service": service_name})
+ else:
+ result = self._make_api_request("SYNO.Core.Service", action, version=1,
+ params={"service": service_name})
+
+ if not result:
+ logger.error(f"Failed to {action} service {service_name}")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "service": service_name,
+ "action": action,
+ "result": result,
+ "status": result.get("status") if action == "status" else "completed"
+ }
+
+ except Exception as e:
+ logger.error(f"Error managing service {service_name}: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def search_files(self, pattern: str, folder_path: str = "", limit: int = 100) -> Dict[str, Any]:
+ """Поиск файлов по шаблону
+
+ Args:
+ pattern: Шаблон для поиска
+ folder_path: Путь к папке для поиска (пустая строка для всех общих папок)
+ limit: Максимальное количество результатов
+
+ Returns:
+ Словарь с найденными файлами
+ """
+ logger.info(f"Searching for {pattern} in {folder_path or 'all shared folders'}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file search")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ if not folder_path:
+ # Получаем список всех общих папок для поиска
+ shares_result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not shares_result:
+ logger.error("Failed to list shared folders for search")
+ return {"success": False, "error": "api_error"}
+
+ # Формируем список путей для поиска
+ folder_paths = [share.get("path") for share in shares_result.get("shares", [])]
+ else:
+ folder_paths = [folder_path]
+
+ # Запускаем поиск
+ params = {
+ "folder_path": folder_paths,
+ "pattern": pattern,
+ "limit": limit,
+ "offset": 0
+ }
+
+ result = self._make_api_request("SYNO.FileStation.Search", "start", version=2, params=params)
+
+ if not result:
+ logger.error(f"Failed to start search for {pattern}")
+ return {"success": False, "error": "api_error"}
+
+ # Получаем taskid для проверки результатов
+ taskid = result.get("taskid")
+ if not taskid:
+ logger.error("No taskid received for search")
+ return {"success": False, "error": "no_task_id"}
+
+ # Ожидаем завершения поиска
+ search_result = {"finished": False, "progress": 0}
+ for _ in range(10): # Максимум 10 попыток
+ search_status = self._make_api_request("SYNO.FileStation.Search", "status", version=2,
+ params={"taskid": taskid})
+
+ if not search_status:
+ break
+
+ search_result["progress"] = search_status.get("progress", 0)
+
+ if search_status.get("finished", False):
+ search_result["finished"] = True
+ break
+
+ time.sleep(0.5) # Пауза между запросами
+
+ # Получаем результаты поиска
+ if search_result["finished"]:
+ list_result = self._make_api_request("SYNO.FileStation.Search", "list", version=2,
+ params={"taskid": taskid, "limit": limit})
+
+ if list_result:
+ files = list_result.get("files", [])
+ return {
+ "success": True,
+ "pattern": pattern,
+ "results": files,
+ "total": list_result.get("total", len(files))
+ }
+
+ # Если не удалось получить результаты, останавливаем поиск
+ self._make_api_request("SYNO.FileStation.Search", "stop", version=2,
+ params={"taskid": taskid})
+
+ return {
+ "success": False,
+ "error": "search_timeout",
+ "progress": search_result["progress"]
+ }
+
+ except Exception as e:
+ logger.error(f"Error searching files: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_backup_status(self) -> Dict[str, Any]:
+ """Получение информации о резервном копировании"""
+ logger.info("Getting backup status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for backup status request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о Hyper Backup
+ hyper_result = self._make_api_request("SYNO.Backup.Repository", "list", version=1)
+
+ # Пробуем получить информацию о задачах Time Backup
+ time_result = self._make_api_request("SYNO.BackupService.Task", "list", version=1)
+
+ # Проверяем статус резервного копирования USB
+ usb_result = self._make_api_request("SYNO.USBCopy", "status", version=1)
+
+ backups = {
+ "hyper_backup": hyper_result.get("backups", []) if hyper_result else [],
+ "time_backup": time_result.get("tasks", []) if time_result else [],
+ "usb_copy": {"enabled": usb_result.get("enable", False) if usb_result else False}
+ }
+
+ return {
+ "success": True,
+ "backups": backups,
+ "available_apis": {
+ "hyper_backup": hyper_result is not None,
+ "time_backup": time_result is not None,
+ "usb_copy": usb_result is not None
+ }
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting backup status: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def check_for_updates(self) -> Dict[str, Any]:
+ """Проверка наличия обновлений системы"""
+ logger.info("Checking for system updates")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for update check")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Получаем текущую информацию о системе
+ system_info = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ if not system_info:
+ logger.error("Failed to get system info for update check")
+ return {"success": False, "error": "api_error"}
+
+ # Проверяем наличие обновлений
+ update_result = self._make_api_request("SYNO.Core.Upgrade.Server", "check", version=1)
+
+ # Получаем настройки автоматического обновления
+ settings_result = self._make_api_request("SYNO.Core.Upgrade.Server.Setting", "get", version=1)
+
+ # Получаем информацию о доступных обновлениях
+ update_info = self._make_api_request("SYNO.Core.Upgrade.Server.Download", "list", version=1)
+
+ current_version = system_info.get("version_string", "unknown")
+ auto_update_enabled = settings_result.get("auto_update", False) if settings_result else False
+
+ updates = []
+ if update_info and "updates" in update_info:
+ updates = update_info.get("updates", [])
+
+ update_available = len(updates) > 0
+
+ return {
+ "success": True,
+ "current_version": current_version,
+ "update_available": update_available,
+ "auto_update_enabled": auto_update_enabled,
+ "updates": updates
+ }
+
+ except Exception as e:
+ logger.error(f"Error checking for updates: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_quota_info(self) -> Dict[str, Any]:
+ """Получение информации о квотах пользователей"""
+ logger.info("Getting user quota information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for quota info request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Получаем список пользователей
+ users_result = self._make_api_request("SYNO.Core.User", "list", version=1)
+
+ if not users_result:
+ logger.error("Failed to get user list for quota info")
+ return {"success": False, "error": "api_error"}
+
+ users = users_result.get("users", [])
+ user_quotas = []
+
+ # Получаем квоты для каждого пользователя
+ for user in users:
+ user_name = user.get("name")
+ if not user_name:
+ continue
+
+ quota_result = self._make_api_request("SYNO.Core.Quota", "get", version=1,
+ params={"user_name": user_name})
+
+ if quota_result and "quotas" in quota_result:
+ user_quotas.append({
+ "user": user_name,
+ "quotas": quota_result.get("quotas", [])
+ })
+
+ return {
+ "success": True,
+ "user_quotas": user_quotas
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting quota info: {str(e)}")
+ return {"success": False, "error": str(e)}
diff --git a/.history/src/api/synology_20250830105130.py b/.history/src/api/synology_20250830105130.py
new file mode 100644
index 0000000..145921f
--- /dev/null
+++ b/.history/src/api/synology_20250830105130.py
@@ -0,0 +1,1908 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Модуль для взаимодействия с API Synology NAS
+"""
+
+import requests
+from requests.adapters import HTTPAdapter
+import json
+import logging
+import time
+import urllib3
+from urllib3.util import Retry
+from typing import Dict, Any, Optional, List
+import socket
+import struct
+from time import sleep
+
+from src.config.config import (
+ SYNOLOGY_HOST,
+ SYNOLOGY_PORT,
+ SYNOLOGY_USERNAME,
+ SYNOLOGY_PASSWORD,
+ SYNOLOGY_SECURE,
+ SYNOLOGY_TIMEOUT,
+ SYNOLOGY_MAC,
+ WOL_PORT,
+ SYNOLOGY_API_VERSION,
+ SYNOLOGY_POWER_API,
+ SYNOLOGY_INFO_API
+)
+from src.api.api_discovery import discover_available_apis, find_compatible_api
+
+# Отключение предупреждений о небезопасных SSL-соединениях
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+logger = logging.getLogger(__name__)
+
+class SynologyAPI:
+ """Класс для взаимодействия с API Synology NAS"""
+
+ def __init__(self):
+ """Инициализация класса SynologyAPI"""
+ logger.info("Creating API with auto-retry and connection pool")
+ logger.debug(f"Connection details: Host={SYNOLOGY_HOST}, Port={SYNOLOGY_PORT}, Secure={SYNOLOGY_SECURE}")
+
+ self.protocol = "https" if SYNOLOGY_SECURE else "http"
+ self.base_url = f"{self.protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/webapi"
+ self.sid = None
+ self.session = requests.Session()
+
+ # Настройка SSL
+ if self.protocol == "https":
+ logger.debug("SSL enabled, disabling certificate verification for internal network")
+ self.session.verify = False # Отключаем проверку SSL для внутренней сети
+
+ # Добавляем пользовательские заголовки для улучшения совместимости с API
+ custom_headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
+ 'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'Connection': 'keep-alive',
+ 'Referer': f'{self.protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/'
+ }
+ self.session.headers.update(custom_headers)
+ logger.debug("Added browser-like headers for API compatibility")
+
+ # Добавляем повторные попытки для HTTP-запросов
+ retry_strategy = Retry(
+ total=5, # Увеличиваем количество попыток
+ status_forcelist=[429, 500, 502, 503, 504, 404],
+ allowed_methods=["GET", "POST"],
+ backoff_factor=1.5, # Увеличиваем задержку между попытками
+ respect_retry_after_header=True
+ )
+ adapter = HTTPAdapter(
+ max_retries=retry_strategy,
+ pool_connections=3,
+ pool_maxsize=10
+ )
+ self.session.mount("http://", adapter)
+ self.session.mount("https://", adapter)
+
+ # Таймауты будут указаны в запросах
+ self.default_timeout = (SYNOLOGY_TIMEOUT, SYNOLOGY_TIMEOUT*2)
+ logger.debug(f"Setting default request timeout: {self.default_timeout}")
+
+ # Кэш для хранения результатов запросов
+ self._cache = {}
+ self._cache_ttl = {}
+ self._last_online_check = 0
+ self._last_online_status = False
+ self._online_check_interval = 30 # Интервал для кэширования проверок онлайн-статуса
+
+ # Время последней успешной аутентификации и срок действия сессии
+ self._last_auth_time = 0
+ self._auth_expiry = 3600 # По умолчанию 1 час
+
+ # Информация о доступных API
+ self._available_apis = {}
+ self._api_info_ttl = 0
+
+ # Инициализируем API version resolver для автоматического определения совместимых API
+ self.api_resolver = None # Будет создан при необходимости
+
+ def login(self) -> bool:
+ """Авторизация в API Synology NAS"""
+ # Сбрасываем SID для новой сессии
+ self.sid = None
+
+ logger.info("Attempting to authenticate with Synology NAS...")
+ logger.debug(f"Base URL: {self.base_url}")
+
+ # Проверяем доступность NAS перед запросом с помощью прямой TCP-проверки
+ # Избегаем вызова is_online(), чтобы не создавать рекурсию
+ online_status = self._check_tcp_connection()
+ if not online_status:
+ logger.error("Cannot login: Synology NAS is not reachable")
+ return False
+
+ # Пробуем различные версии API для аутентификации
+ # Начинаем с версии 3, которая показала лучшую совместимость в тестах
+ auth_versions_to_try = [3, 2, 1, 6] # Пробуем сначала более стабильные версии
+
+ for auth_version in auth_versions_to_try:
+ try:
+ # Определяем путь к API аутентификации
+ auth_path = "entry.cgi" # Используем entry.cgi вместо auth.cgi по умолчанию
+
+ # Проверка информации API для определения доступных версий API
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": "SYNO.API.Auth"
+ }
+
+ logger.debug(f"Querying API info for auth version {auth_version}")
+ try:
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ auth_info = api_info_data.get("data", {}).get("SYNO.API.Auth", {})
+ max_version = auth_info.get("maxVersion", 6)
+ min_version = auth_info.get("minVersion", 1)
+ auth_path = auth_info.get("path", "entry.cgi")
+ logger.debug(f"Auth API versions: min={min_version}, max={max_version}, path={auth_path}")
+
+ # Проверяем поддержку текущей версии
+ if auth_version < min_version or auth_version > max_version:
+ logger.warning(f"Auth API version {auth_version} not supported (min={min_version}, max={max_version}), trying next version")
+ continue
+ else:
+ logger.warning("Failed to query API info, using default auth path")
+ else:
+ logger.warning(f"API info request failed with status {api_info_response.status_code}, using default auth path")
+ except Exception as e:
+ logger.warning(f"Error querying API info: {str(e)}, using default auth path")
+
+ # Основной запрос авторизации
+ url = f"{self.base_url}/{auth_path}"
+ params = {
+ "api": "SYNO.API.Auth",
+ "version": str(auth_version),
+ "method": "login",
+ "account": SYNOLOGY_USERNAME,
+ "passwd": SYNOLOGY_PASSWORD,
+ "session": "SynologyPowerControlBot",
+ "format": "cookie"
+ }
+
+ # Для версии 6+ используем немного другой формат
+ if auth_version >= 6:
+ params["enable_syno_token"] = "yes"
+
+ logger.debug(f"Sending auth request to {url} with API version {auth_version}")
+ start_time = time.time()
+ response = self.session.get(
+ url,
+ params=params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+ elapsed_time = time.time() - start_time
+ logger.debug(f"Auth request completed in {elapsed_time:.2f}s with status code {response.status_code}")
+
+ # Проверка статуса HTTP
+ if response.status_code != 200:
+ logger.error(f"HTTP error: {response.status_code}")
+ logger.debug(f"Response content: {response.text[:200]}")
+ continue # Пробуем следующую версию
+
+ try:
+ data = response.json()
+ except json.JSONDecodeError:
+ logger.error("Failed to decode JSON response")
+ logger.debug(f"Response content: {response.text[:200]}")
+ continue # Пробуем следующую версию
+
+ if data.get("success"):
+ self.sid = data.get("data", {}).get("sid")
+ self._last_auth_time = time.time()
+ logger.info(f"Successfully logged in to Synology NAS using API version {auth_version}")
+ logger.debug(f"Session ID obtained: {self.sid[:5] if self.sid else 'None'}...")
+
+ # Получаем и сохраняем токен SYNO, если он есть
+ syno_token = data.get("data", {}).get("synotoken")
+ if syno_token:
+ self.session.headers.update({'X-SYNO-TOKEN': syno_token})
+ logger.debug("Added X-SYNO-TOKEN header for improved API compatibility")
+
+ # Также добавляем SID в cookies для улучшения совместимости
+ self.session.cookies.update({
+ 'id': self.sid,
+ 'sid': self.sid
+ })
+ logger.debug("Added SID to session cookies for improved compatibility")
+
+ # Проверка валидности полученной сессии с помощью простого запроса
+ # Будем использовать SYNO.API.Info без проверки сложных методов
+
+ # Даем системе немного времени для инициализации сессии
+ time.sleep(0.5)
+
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to log in with version {auth_version}: Error code {error_code}")
+
+ # Если ошибка связана с версией API, пробуем следующую версию
+ if error_code in [104, 105]:
+ logger.warning(f"Auth version {auth_version} not supported, trying next version")
+ continue
+
+ # Дополнительная диагностика
+ if error_code == 400:
+ logger.error("Authentication error: Invalid credentials")
+ elif error_code == 401:
+ logger.error("Authentication error: Account disabled")
+ elif error_code == 402:
+ logger.error("Authentication error: Permission denied")
+ elif error_code == 403:
+ logger.error("Authentication error: 2-factor authentication required")
+ elif error_code == 404:
+ logger.error("Authentication error: Failed to authenticate with 2-factor authentication")
+
+ # Если ошибка связана с неверными учетными данными, нет смысла пробовать другие версии API
+ if error_code in [400, 401, 402, 403, 404]:
+ return False
+
+ except requests.exceptions.Timeout:
+ logger.error(f"Connection timeout during auth with version {auth_version}")
+ continue # Пробуем следующую версию
+ except requests.exceptions.ConnectionError as e:
+ logger.error(f"Connection error during auth with version {auth_version}: {str(e)}")
+ continue # Пробуем следующую версию
+ except requests.RequestException as e:
+ logger.error(f"Request error during auth with version {auth_version}: {str(e)}")
+ continue # Пробуем следующую версию
+ except Exception as e:
+ logger.error(f"Unexpected error during login with version {auth_version}: {str(e)}", exc_info=True)
+ continue # Пробуем следующую версию
+
+ # Если все версии не сработали
+ logger.error("Failed to authenticate with any API version")
+ return False
+
+ def _validate_session(self) -> bool:
+ """Проверяет валидность сессии после авторизации"""
+ if not self.sid:
+ return False
+
+ # Попробуем сделать простой запрос для проверки сессии
+ test_apis = [
+ {"api": "SYNO.Core.System", "method": "info", "version": 1},
+ {"api": "SYNO.DSM.Info", "method": "getinfo", "version": 1}
+ ]
+
+ for test_api in test_apis:
+ try:
+ url = f"{self.base_url}/entry.cgi"
+ params = {
+ "api": test_api["api"],
+ "version": str(test_api["version"]),
+ "method": test_api["method"],
+ "_sid": self.sid
+ }
+
+ response = self.session.get(url, params=params, timeout=self.default_timeout, verify=False)
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("success"):
+ logger.debug(f"Session validation successful using {test_api['api']}")
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ if error_code != 119: # Не сессия истекла
+ logger.debug(f"Session validation with {test_api['api']} returned error code {error_code}")
+ return True # Считаем сессию валидной, если ошибка не связана с истечением сессии
+ except Exception as e:
+ logger.warning(f"Error during session validation with {test_api['api']}: {str(e)}")
+
+ logger.warning("Session validation failed with all test APIs")
+ return False
+
+ def logout(self) -> bool:
+ """Выход из API Synology NAS"""
+ if not self.sid:
+ return True
+
+ try:
+ url = f"{self.base_url}/auth.cgi"
+ params = {
+ "api": "SYNO.API.Auth",
+ "version": "1",
+ "method": "logout",
+ "session": "SynologyPowerControlBot",
+ "_sid": self.sid
+ }
+
+ response = self.session.get(url, params=params, timeout=SYNOLOGY_TIMEOUT, verify=False)
+ data = response.json()
+
+ if data.get("success"):
+ self.sid = None
+ logger.info("Successfully logged out from Synology NAS")
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to log out from Synology NAS: Error code {error_code}")
+ return False
+
+ except requests.RequestException as e:
+ logger.error(f"Connection error: {str(e)}")
+ return False
+
+ def _make_api_request(self, api_name: str, method: str, version: int = 1,
+ params: Optional[Dict[str, Any]] = None, retry_auth: bool = True, retry_count: int = 0) -> Optional[Dict[str, Any]]:
+ """Обобщенный метод для выполнения API запросов с обработкой ошибок"""
+ # Ограничение на количество повторных попыток
+ if retry_count >= 3:
+ logger.error(f"Too many retries for {api_name}.{method}, giving up")
+ return None
+
+ # Проверка наличия авторизации
+ if not self.sid and not self.login():
+ logger.error(f"Not authenticated for API request: {api_name}.{method}")
+ return None
+
+ # Проверка информации API для определения пути и поддерживаемой версии
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": api_name
+ }
+
+ api_path = "entry.cgi"
+ try:
+ logger.debug(f"Querying API info for {api_name}")
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ api_info = api_info_data.get("data", {}).get(api_name, {})
+ if api_info:
+ max_version = api_info.get("maxVersion", version)
+ min_version = api_info.get("minVersion", version)
+ api_path = api_info.get("path", "entry.cgi")
+
+ # Проверка, поддерживается ли запрошенная версия
+ if version < min_version:
+ logger.warning(f"API version {version} for {api_name} is below minimum {min_version}, using {min_version}")
+ version = min_version
+ elif version > max_version:
+ logger.warning(f"API version {version} for {api_name} exceeds maximum {max_version}, using {max_version}")
+ version = max_version
+
+ logger.debug(f"Using API path: {api_path}, version: {version}")
+ else:
+ logger.warning(f"API {api_name} not found in API info, using defaults")
+ except Exception as e:
+ logger.warning(f"Error querying API info for {api_name}: {str(e)}, using defaults")
+
+ # Подготовка базовых параметров запроса
+ base_params = {
+ "api": api_name,
+ "version": str(version),
+ "method": method,
+ "_sid": self.sid # Используем _sid вместо sid для лучшей совместимости
+ }
+
+ # Добавление дополнительных параметров, если они заданы
+ if params:
+ base_params.update(params)
+
+ url = f"{self.base_url}/{api_path}"
+ logger.debug(f"API request: {api_name}.{method} v{version} to {url}")
+ logger.debug(f"Full request params: {base_params}")
+
+ try:
+ start_time = time.time()
+ response = self.session.get(
+ url,
+ params=base_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+ elapsed_time = time.time() - start_time
+ logger.debug(f"API request completed in {elapsed_time:.2f}s with status {response.status_code}")
+
+ # Проверка статуса HTTP
+ if response.status_code != 200:
+ logger.error(f"HTTP error: {response.status_code} for {api_name}.{method}")
+
+ # Повторная попытка при ошибках соединения
+ if response.status_code in [500, 502, 503, 504]:
+ logger.info(f"Server error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+
+ try:
+ data = response.json()
+ except json.JSONDecodeError:
+ logger.error(f"Failed to decode JSON response for {api_name}.{method}")
+ logger.debug(f"Response content: {response.text[:200]}")
+
+ # Повторная попытка при ошибках декодирования
+ logger.info(f"JSON decode error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ if data.get("success"):
+ logger.info(f"API request successful for {api_name}.{method}")
+ return data.get("data", {})
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ error_desc = self._get_error_description(error_code)
+ logger.error(f"API error for {api_name}.{method}: {error_code} - {error_desc}")
+
+ # Ошибки доступа или прав часто встречаются, но они не критичные
+ # Например, ошибка 102 означает, что нет прав, но NAS доступен
+ if error_code in [102, 103, 104, 105]:
+ logger.warning(f"Permission-related error for {api_name}.{method}: {error_code}")
+ # Возвращаем пустой словарь вместо None,
+ # чтобы вызывающий код мог понять, что запрос выполнен
+ return {}
+
+ # Если ошибка связана с авторизацией и нам разрешено повторить попытку
+ if error_code in [106, 107, 119] and retry_auth:
+ logger.info(f"Session error (code {error_code}), creating fresh session...")
+ self.sid = None # Сбрасываем SID
+
+ # Для ошибки 119 (Session timeout) дадим системе немного времени
+ if error_code == 119:
+ logger.info("Session timeout detected, waiting before retry...")
+ sleep(3)
+
+ if self.login():
+ logger.info("Re-authenticated with fresh session, retrying API request...")
+ # Рекурсивный вызов, но со счетчиком повторов
+ return self._make_api_request(api_name, method, version, params, False, retry_count + 1)
+
+ # Для некоторых ошибок можно автоматически повторить запрос
+ if error_code in [408, 429, 500, 502, 503, 504]:
+ logger.info(f"Temporary error {error_code}, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+
+ except requests.exceptions.Timeout:
+ logger.error(f"Request timeout for {api_name}.{method}")
+
+ # Повторная попытка при таймауте
+ if retry_count < 2:
+ logger.info(f"Timeout, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+ except requests.exceptions.ConnectionError as e:
+ logger.error(f"Connection error for {api_name}.{method}: {str(e)}")
+
+ # Повторная попытка при ошибке соединения
+ if retry_count < 2:
+ logger.info(f"Connection error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+ except Exception as e:
+ logger.error(f"Unexpected error during {api_name}.{method}: {str(e)}")
+ return None
+
+ def get_system_status(self) -> Dict[str, Any]:
+ """Получение статуса системы"""
+ # Проверяем доступность системы
+ if not self.is_online():
+ logger.info("Device is offline, skipping API request")
+ return {"status": "offline"}
+
+ # Проверяем, есть ли кэшированный результат
+ cache_key = "system_status"
+ current_time = time.time()
+ if cache_key in self._cache and current_time - self._cache_ttl.get(cache_key, 0) < 60:
+ logger.debug("Using cached system status")
+ return self._cache[cache_key]
+
+ # Используем рекомендованный API для получения информации о системе
+ logger.info(f"Getting system info using {SYNOLOGY_INFO_API} v{SYNOLOGY_API_VERSION}")
+
+ # Для SYNO.DSM.Info метод getinfo, для других обычно используется get или info
+ if SYNOLOGY_INFO_API == "SYNO.DSM.Info":
+ method = "getinfo"
+ else:
+ method = "get"
+
+ result = self._make_api_request(SYNOLOGY_INFO_API, method, version=SYNOLOGY_API_VERSION)
+
+ if result:
+ logger.info(f"Successfully retrieved system info using {SYNOLOGY_INFO_API}")
+
+ # Формируем расширенный ответ с дополнительной информацией
+ system_info = {
+ "status": "online",
+ "hostname": result.get("hostname", "unknown"),
+ "model": result.get("model", "unknown"),
+ "version": result.get("version", "unknown"),
+ "uptime": result.get("uptime", 0),
+ "time": current_time,
+ "is_online": True,
+ "api_used": SYNOLOGY_INFO_API
+ }
+
+ # Сохраняем в кэше
+ self._cache[cache_key] = system_info
+ self._cache_ttl[cache_key] = current_time
+
+ return system_info
+
+ # Если основной API не сработал, пробуем резервные варианты
+ logger.warning(f"Failed to retrieve system info with {SYNOLOGY_INFO_API}, trying fallback APIs")
+
+ # Пробуем резервные API
+ apis_to_try = [
+ {"name": "SYNO.DSM.Info", "method": "getinfo", "version": 2},
+ {"name": "SYNO.Core.System", "method": "info", "version": 1},
+ {"name": "SYNO.Core.System.Status", "method": "get", "version": 1},
+ {"name": "SYNO.Core.System.Info", "method": "get", "version": 1},
+ ]
+
+ for api in apis_to_try:
+ if api["name"] == SYNOLOGY_INFO_API:
+ continue # Пропускаем уже проверенный API
+
+ logger.info(f"Trying to get system info using {api['name']}.{api['method']} v{api['version']}")
+ result = self._make_api_request(api["name"], api["method"], version=api["version"])
+
+ if result:
+ logger.info(f"Successfully retrieved system info using {api['name']}")
+
+ # Формируем расширенный ответ с дополнительной информацией
+ system_info = {
+ "status": "online",
+ "hostname": result.get("hostname", "unknown"),
+ "model": result.get("model", "unknown"),
+ "version": result.get("version", "unknown"),
+ "uptime": result.get("uptime", 0),
+ "time": current_time,
+ "is_online": True,
+ "api_used": api["name"]
+ }
+
+ # Сохраняем в кэше
+ self._cache[cache_key] = system_info
+ self._cache_ttl[cache_key] = current_time
+
+ return system_info
+
+ # Если все запросы не удались, но система онлайн, возвращаем базовую информацию
+ logger.warning("Failed to retrieve system info with all API methods")
+ return {
+ "status": "error",
+ "error": "Failed to fetch system information",
+ "is_online": True,
+ "time": current_time
+ }
+
+ def shutdown_system(self) -> bool:
+ """Выключение системы"""
+ # Проверяем, включено ли устройство перед попыткой его выключить
+ if not self.is_online(force_check=True):
+ logger.info("Synology NAS is already offline, no need to shut down")
+ return True
+
+ logger.info("Attempting to shutdown Synology NAS...")
+
+ # Попробуем сначала использовать предпочтительный API для управления питанием
+ logger.info(f"Trying shutdown with {SYNOLOGY_POWER_API} v{SYNOLOGY_API_VERSION}")
+
+ # Для SYNO.Core.Hardware.PowerRecovery используется метод setPowerOnState
+ # Для других API обычно используется метод shutdown или reboot
+ if SYNOLOGY_POWER_API == "SYNO.Core.Hardware.PowerRecovery":
+ # Для этого API нужны специальные параметры
+ params = {"state": "powerbtn"} # powerbtn имитирует нажатие кнопки питания
+ result = self._make_api_request(SYNOLOGY_POWER_API, "setPowerOnState", version=SYNOLOGY_API_VERSION, params=params)
+ else:
+ # Пробуем стандартный метод
+ result = self._make_api_request(SYNOLOGY_POWER_API, "shutdown", version=SYNOLOGY_API_VERSION)
+
+ if result is not None:
+ logger.info(f"Successfully initiated system shutdown using {SYNOLOGY_POWER_API}")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ # Проверяем статус
+ if not self.is_online(force_check=True):
+ logger.info("System is now offline. Shutdown confirmed successful.")
+ return True
+ else:
+ logger.info("System still appears to be online, but shutdown may be in progress.")
+ return True
+ else:
+ logger.warning(f"Failed to shutdown using {SYNOLOGY_POWER_API}, trying alternative methods")
+
+ # Если не сработал основной метод, пробуем резервные варианты
+ # Проверка всех доступных методов API для выключения
+ apis_to_try = [
+ {"name": "SYNO.Core.System", "method": "shutdown", "version": 3},
+ {"name": "SYNO.Core.System.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.DSM.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.System.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.DSM.System", "method": "shutdown", "version": 1}
+ ]
+
+ # Проверяем доступные API
+ try:
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": "SYNO.Core.System.Power,SYNO.DSM.Power,SYNO.Core.System,SYNO.System.Power,SYNO.DSM.System"
+ }
+
+ logger.debug("Checking available shutdown APIs")
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ available_apis = api_info_data.get("data", {})
+ logger.debug(f"Available APIs: {list(available_apis.keys())}")
+
+ # Фильтруем только доступные API
+ filtered_apis = []
+ for api in apis_to_try:
+ if api["name"] in available_apis:
+ api_info = available_apis[api["name"]]
+ # Проверка версии API
+ if api_info.get("minVersion", 1) <= api["version"] <= api_info.get("maxVersion", 1):
+ filtered_apis.append(api)
+ logger.debug(f"Adding {api['name']} to available shutdown APIs")
+ else:
+ logger.debug(f"API {api['name']} version {api['version']} not supported. Supported: {api_info.get('minVersion')}-{api_info.get('maxVersion')}")
+
+ if filtered_apis:
+ apis_to_try = filtered_apis
+ else:
+ logger.warning("No compatible APIs found, trying all methods as fallback")
+ else:
+ logger.warning("Failed to query API info, using default methods")
+ else:
+ logger.warning(f"API info request failed with status {api_info_response.status_code}, using default methods")
+ except Exception as e:
+ logger.warning(f"Error querying API info: {str(e)}, using default methods")
+
+ # Пробуем все доступные методы по порядку
+ for api in apis_to_try:
+ logger.info(f"Trying shutdown with {api['name']}.{api['method']} v{api['version']}")
+ result = self._make_api_request(api["name"], api["method"], version=api["version"])
+
+ if result is not None:
+ logger.info(f"Successfully initiated system shutdown using {api['name']}")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ # Проверяем статус
+ if not self.is_online(force_check=True):
+ logger.info("System is now offline. Shutdown confirmed successful.")
+ return True
+ else:
+ logger.info("System still appears to be online, but shutdown may be in progress.")
+ return True
+ else:
+ logger.warning(f"Failed to shutdown using {api['name']}.{api['method']} v{api['version']}")
+
+ # Если ни один метод не сработал, но система стала недоступна
+ if not self.is_online(force_check=True):
+ logger.info("System appears to be shutting down despite API errors")
+ return True
+
+ logger.error("Failed to shutdown system after trying multiple APIs")
+ return False
+
+ def reboot_system(self) -> bool:
+ """Перезагрузка системы"""
+ # Проверяем, включена ли система
+ if not self.is_online(force_check=True):
+ logger.error("Cannot reboot: System is offline")
+ return False
+
+ logger.info("Attempting to reboot Synology NAS...")
+
+ # Список API и методов для попытки перезагрузки
+ apis_to_try = [
+ {"api": "SYNO.Core.System", "method": "reboot", "version": 1},
+ {"api": "SYNO.DSM.System", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.System.Power", "method": "restart", "version": 1},
+ {"api": "SYNO.DSM.Power", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.System", "method": "reboot", "version": 3},
+ {"api": "SYNO.System.Power", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.Hardware.NeedReboot", "method": "reboot", "version": 1}
+ ]
+
+ # Добавляем настроенный в конфигурации API, если он отличается от уже добавленных
+ already_added = [item["api"] for item in apis_to_try]
+ if SYNOLOGY_POWER_API not in already_added:
+ for method in ["restart", "reboot"]:
+ apis_to_try.append({
+ "api": SYNOLOGY_POWER_API,
+ "method": method,
+ "version": SYNOLOGY_API_VERSION
+ })
+
+ # Перебираем все возможные API и методы
+ for api_info in apis_to_try:
+ try:
+ logger.info(f"Trying reboot with {api_info['api']} API using method {api_info['method']}")
+ result = self._make_api_request(api_info['api'], api_info['method'], version=api_info['version'])
+ if result is not None:
+ logger.info(f"Successfully initiated system reboot using {api_info['api']} API")
+
+ # Даем системе время начать процесс перезагрузки
+ logger.info("Waiting for reboot to initialize...")
+ sleep(5)
+
+ # Ждем, пока система станет недоступна (признак перезагрузки)
+ reboot_started = False
+ for i in range(12): # Проверяем в течение 60 секунд (12 * 5сек)
+ if not self.is_online(force_check=True):
+ logger.info(f"System went offline after {i*5} seconds, reboot in progress")
+ reboot_started = True
+ break
+ logger.debug(f"System still online, waiting... ({i+1}/12)")
+ sleep(5)
+
+ if reboot_started:
+ # Успешный вызов API и система ушла оффлайн - это признак успешной перезагрузки
+ return True
+ else:
+ # Успешный вызов API, но система не ушла оффлайн
+ logger.warning("System did not go offline after reboot command, but command was accepted")
+ # Даже если система не ушла оффлайн, команда могла быть принята
+ return True
+ except Exception as e:
+ logger.error(f"Error during reboot with {api_info['api']}: {str(e)}")
+
+ # Если все попытки не удались, возвращаем False
+ logger.error("All reboot attempts failed")
+ return False
+
+ def _get_error_description(self, error_code: int) -> str:
+ """Получение описания ошибки по коду"""
+ error_descriptions = {
+ 100: "Unknown error",
+ 101: "Invalid parameter",
+ 102: "API does not exist",
+ 103: "Method does not exist",
+ 104: "Version does not support",
+ 105: "Permission denied",
+ 106: "Session timeout",
+ 107: "Session interrupted by duplicate login",
+ 400: "Invalid credentials",
+ 401: "Account disabled",
+ 402: "Permission denied",
+ 403: "2FA required",
+ 404: "Failed to authenticate with 2FA"
+ }
+ return error_descriptions.get(error_code, "Unknown error code")
+
+ def _check_tcp_connection(self) -> bool:
+ """Проверка базового TCP-соединения с Synology NAS"""
+ try:
+ socket_obj = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ socket_obj.settimeout(SYNOLOGY_TIMEOUT)
+ result = socket_obj.connect_ex((SYNOLOGY_HOST, SYNOLOGY_PORT))
+ socket_obj.close()
+
+ return result == 0
+ except socket.error as e:
+ logger.error(f"Socket error during connection check: {str(e)}")
+ return False
+ except Exception as e:
+ logger.error(f"Unexpected error during connection check: {str(e)}")
+ return False
+
+ def is_online(self, force_check=False) -> bool:
+ """Проверка онлайн-статуса Synology NAS"""
+ # Используем кэшированное значение, если доступно и не устарело
+ current_time = time.time()
+ if not force_check and (current_time - self._last_online_check) < self._online_check_interval:
+ logger.debug(f"Using cached online status: {self._last_online_status}")
+ return self._last_online_status
+
+ logger.info("Checking if NAS is online...")
+
+ # Проверяем TCP-соединение
+ online_status = self._check_tcp_connection()
+ logger.info(f"Detected Synology NAS online status: {online_status}")
+
+ # Если TCP-соединение успешно и у нас есть действующий SID,
+ # попробуем более детальную проверку через API
+ if online_status and self.sid:
+ logger.info("Trying to fetch more detailed online status through API...")
+
+ # Пробуем разные API для проверки онлайн-статуса
+ api_checks = [
+ {"api": "SYNO.DSM.Info", "version": "2", "method": "getinfo"},
+ {"api": "SYNO.Core.System", "version": "1", "method": "info"},
+ {"api": "SYNO.Core.System.Status", "version": "1", "method": "get"}
+ ]
+
+ api_success = False
+ for api_check in api_checks:
+ try:
+ url = f"{self.base_url}/entry.cgi"
+ params = {
+ "api": api_check["api"],
+ "version": api_check["version"],
+ "method": api_check["method"],
+ "sid": self.sid
+ }
+
+ logger.debug(f"Trying online status check with {api_check['api']}")
+ response = self.session.get(
+ url,
+ params=params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("success"):
+ logger.info(f"API request successful for {api_check['api']}")
+ logger.info("Synology NAS is online with API access")
+ api_success = True
+ break
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.warning(f"API response indicates an error: {error_code}, but NAS is reachable")
+ else:
+ logger.warning(f"API returned status code {response.status_code}")
+ except Exception as e:
+ logger.warning(f"API check failed for {api_check['api']}, but TCP connection succeeded: {str(e)}")
+
+ if not api_success:
+ logger.warning("All API checks failed, but TCP connection is successful")
+
+ # Обновляем кэшированное значение
+ self._last_online_check = current_time
+ self._last_online_status = online_status
+
+ return online_status
+
+ def wake_on_lan(self) -> bool:
+ """Отправка Wake-on-LAN пакета для включения Synology NAS"""
+ if not SYNOLOGY_MAC:
+ logger.error("MAC address not configured")
+ return False
+
+ try:
+ # Преобразование MAC-адреса в байты
+ mac_address = SYNOLOGY_MAC.replace(':', '').replace('-', '')
+ if len(mac_address) != 12:
+ logger.error(f"Invalid MAC address format: {SYNOLOGY_MAC}")
+ return False
+
+ try:
+ mac_bytes = bytes.fromhex(mac_address)
+ except ValueError as e:
+ logger.error(f"Failed to parse MAC address: {str(e)}")
+ logger.error(f"MAC address must contain only hex characters: {SYNOLOGY_MAC}")
+ return False
+
+ # Создание Magic Packet
+ magic_packet = b'\xff' * 6 + mac_bytes * 16
+
+ # Отправка пакета на конкретный адрес
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.sendto(magic_packet, (SYNOLOGY_HOST, WOL_PORT))
+ sock.close()
+ logger.info(f"Wake-on-LAN packet sent to {SYNOLOGY_MAC} at {SYNOLOGY_HOST}:{WOL_PORT}")
+ except Exception as e:
+ logger.error(f"Error sending directed WoL packet: {str(e)}")
+ return False
+
+ # Для надежности отправляем также широковещательный пакет
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+ # Используем стандартный широковещательный адрес
+ broadcast_addr = "255.255.255.255"
+ sock.sendto(magic_packet, (broadcast_addr, WOL_PORT))
+ sock.close()
+ logger.info(f"Wake-on-LAN broadcast packet sent to {broadcast_addr}:{WOL_PORT}")
+ except Exception as e:
+ logger.warning(f"Error sending broadcast WoL packet (non-critical): {str(e)}")
+ # Не считаем ошибкой, т.к. основной пакет уже отправлен
+
+ return True
+
+ except Exception as e:
+ logger.error(f"Unexpected error in wake_on_lan: {str(e)}")
+ return False
+
+ def wait_for_boot(self, max_attempts: int = 30, delay: int = 10) -> bool:
+ """Ожидание загрузки Synology NAS"""
+ logger.info(f"Waiting for Synology NAS to boot (timeout: {max_attempts * delay}s)...")
+
+ for attempt in range(max_attempts):
+ # Принудительно проверяем статус без использования кэша
+ if self.is_online(force_check=True):
+ logger.info(f"Synology NAS is online after {attempt + 1} attempts ({(attempt + 1) * delay}s)")
+
+ # Проверяем, что не только сеть доступна, но и API загрузился
+ api_ready = False
+ logger.info("Waiting for API services to initialize...")
+
+ for api_check in range(5): # Даем еще до 50 секунд для загрузки API
+ if self.sid or self.login():
+ api_ready = True
+ logger.info(f"API services are ready after {api_check + 1} attempts")
+ break
+ logger.debug(f"API not ready yet, waiting... ({api_check + 1}/5)")
+ sleep(10)
+
+ if not api_ready:
+ logger.warning("System is online but API services may not be fully initialized")
+
+ # Дадим дополнительное время для полной загрузки всех сервисов
+ sleep(delay)
+ return True
+
+ sleep(delay)
+ logger.info(f"Waiting for boot... Attempt {attempt + 1}/{max_attempts}")
+
+ logger.error(f"Synology NAS didn't come online after {max_attempts * delay} seconds")
+ return False
+
+ def power_on(self) -> bool:
+ """Включение Synology NAS"""
+ # Принудительная проверка статуса
+ if self.is_online(force_check=True):
+ logger.info("Synology NAS is already online")
+ return True
+
+ logger.info("Powering on Synology NAS via Wake-on-LAN...")
+
+ # Проверяем, настроен ли MAC-адрес
+ if not SYNOLOGY_MAC:
+ logger.error("Cannot power on: MAC address not configured in settings")
+ return False
+
+ # Пробуем отправить несколько WoL пакетов для надежности
+ success = False
+ for attempt in range(3):
+ logger.debug(f"Sending WoL packet, attempt {attempt + 1}/3")
+ if self.wake_on_lan():
+ success = True
+ break
+ sleep(1)
+
+ if not success:
+ logger.error("Failed to send Wake-on-LAN packets")
+ return False
+
+ # Ожидание загрузки
+ logger.info("WoL packets sent successfully, waiting for system to boot...")
+ boot_result = self.wait_for_boot(max_attempts=30, delay=10)
+
+ if boot_result:
+ # Проверяем доступность API после загрузки
+ system_status = self.get_system_status()
+ if system_status.get("status") == "online":
+ logger.info("System booted successfully with API access")
+ return True
+ else:
+ logger.warning("System appears to be online but API may not be fully ready")
+ return True
+ else:
+ logger.error("System did not come online after WoL")
+ return False
+
+ def power_off(self) -> bool:
+ """Выключение Synology NAS"""
+ if not self.is_online(force_check=True):
+ logger.info("Synology NAS is already offline")
+ return True
+
+ logger.info("Powering off Synology NAS...")
+
+ # Список API и методов для попытки выключения
+ apis_to_try = [
+ {"api": "SYNO.Core.System", "method": "shutdown", "version": 1},
+ {"api": "SYNO.DSM.System", "method": "shutdown", "version": 1},
+ {"api": SYNOLOGY_POWER_API, "method": "shutdown", "version": SYNOLOGY_API_VERSION}
+ ]
+
+ # Перебираем все возможные API и методы
+ for api_info in apis_to_try:
+ try:
+ logger.info(f"Trying shutdown with {api_info['api']} API using method {api_info['method']}")
+ api_result = self._make_api_request(api_info['api'], api_info['method'], version=api_info['version'])
+ if api_result is not None:
+ logger.info(f"Successfully initiated system shutdown using {api_info['api']} API")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ return True
+ except Exception as e:
+ logger.error(f"Error during shutdown with {api_info['api']}: {str(e)}")
+
+ # Если все попытки не удались, возвращаем False
+ logger.error("All shutdown attempts failed")
+ return False
+
+ # Если все еще не сработало, используем оригинальный метод shutdown_system
+ if not result:
+ result = self.shutdown_system()
+
+ if result:
+ # Дополнительная проверка, что система действительно выключилась
+ logger.info("Verifying system is offline...")
+ for attempt in range(12): # Проверяем в течение 2 минут (12 * 10 сек)
+ if not self.is_online(force_check=True):
+ logger.info(f"System confirmed offline after {attempt * 10} seconds")
+ return True
+ logger.debug(f"System still shutting down, checking again... ({attempt + 1}/12)")
+ sleep(10)
+
+ logger.warning("System still appears to be online after shutdown command")
+ return False
+ else:
+ logger.error("Failed to initiate shutdown")
+ return False
+
+ # Заглушки для расширенных методов
+ def get_shared_folders(self) -> List[Dict[str, Any]]:
+ """Получение списка общих папок"""
+ logger.info("Getting list of shared folders")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for shared folders request")
+ return []
+
+ try:
+ # Запрашиваем список общих папок через FileStation API
+ result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for shared folders")
+ alt_result = self._make_api_request("SYNO.FileStation.List", "list_share", version=1)
+ if alt_result:
+ return alt_result.get("shares", [])
+ return []
+
+ return result.get("shares", [])
+
+ except Exception as e:
+ logger.error(f"Error getting shared folders: {str(e)}")
+ return []
+
+ def get_system_load(self) -> Dict[str, Any]:
+ """Получение информации о загрузке системы"""
+ logger.info("Getting system load information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for system load request")
+ return {}
+
+ try:
+ # Запрашиваем информацию о загрузке системы
+ result = self._make_api_request("SYNO.Core.System.Utilization", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for system load")
+ alt_result = self._make_api_request("SYNO.Core.System", "info", version=3)
+
+ if not alt_result:
+ return {}
+
+ # Формируем из частичных данных
+ return {
+ "cpu_load": alt_result.get("cpu_usage", 0),
+ "memory": {
+ "total": alt_result.get("memory_size", 0),
+ "used": alt_result.get("memory_usage", 0),
+ "usage_percent": alt_result.get("memory_usage_percent", 0)
+ }
+ }
+
+ # Формируем структурированный результат
+ return {
+ "cpu_load": result.get("cpu", {}).get("user", 0) + result.get("cpu", {}).get("system", 0),
+ "memory": result.get("memory", {}),
+ "network": result.get("network", {})
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting system load: {str(e)}")
+ return {}
+
+ def is_online_api(self) -> bool:
+ """Проверка онлайн-статуса Synology NAS с использованием API"""
+ if not self.is_online():
+ return False
+
+ # Проверяем доступность API через авторизацию
+ if not self.sid and not self.login():
+ return False
+
+ return True
+
+ def get_storage_status(self) -> Dict[str, Any]:
+ """Получение подробной информации о хранилище"""
+ logger.info("Getting storage status information")
+
+ # Проверяем доступность NAS и API
+ if not self.is_online_api():
+ logger.error("Cannot get storage status: NAS is not online or API is not accessible")
+ return {"error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о хранилище через SYNO.Storage.CGI.Storage API
+ result = self._make_api_request("SYNO.Storage.CGI.Storage", "load_info", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for storage info")
+ alt_result = self._make_api_request("SYNO.Core.Storage", "info", version=1)
+
+ if not alt_result:
+ # Пробуем еще один альтернативный API
+ logger.info("Trying SYNO.Core.System API for storage info")
+ sys_result = self._make_api_request("SYNO.Core.System", "info", version=3)
+
+ if not sys_result:
+ return {
+ "volumes": [],
+ "disks": [],
+ "total_size": 0,
+ "total_used": 0,
+ "error": "no_data"
+ }
+
+ # Извлекаем базовую информацию о хранилище из системной информации
+ return {
+ "volumes": [],
+ "disks": [],
+ "total_size": sys_result.get("hdd_total_size", 0) * 1024**2, # МБ в байты
+ "total_used": sys_result.get("hdd_total_size", 0) * sys_result.get("hdd_usage", 0) / 100 * 1024**2,
+ }
+
+ # Обрабатываем данные из альтернативного API
+ volumes = alt_result.get("volumes", [])
+ disks = alt_result.get("disks", [])
+
+ else:
+ # Обрабатываем данные из основного API
+ volumes = result.get("volumes", [])
+ disks = result.get("disks", [])
+
+ # Рассчитываем общие размеры
+ total_size = 0
+ total_used = 0
+
+ for volume in volumes:
+ volume_size = volume.get("size", {}).get("total", 0)
+ volume_used = volume.get("size", {}).get("used", 0)
+
+ total_size += volume_size
+ total_used += volume_used
+
+ return {
+ "volumes": volumes,
+ "disks": disks,
+ "total_size": total_size,
+ "total_used": total_used
+ }
+
+ except Exception as e:
+ logger.error(f"Error in get_storage_status: {str(e)}")
+ return {"error": str(e)}
+
+ def get_security_status(self) -> Dict[str, Any]:
+ """Получение информации о состоянии безопасности"""
+ logger.info("Getting security status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for security status request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о безопасности через API Security Scan
+ result = self._make_api_request("SYNO.Core.SecurityScan.Status", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for security status")
+ # Проверяем статус брандмауэра
+ firewall_result = self._make_api_request("SYNO.Core.Security.Firewall.Status", "get", version=1)
+
+ # Проверяем статус автоматических обновлений
+ update_result = self._make_api_request("SYNO.Core.Upgrade.Server.Setting", "get", version=1)
+
+ # Если ни один из API не отвечает
+ if not firewall_result and not update_result:
+ # Получаем общую информацию о системе для базовой проверки безопасности
+ sys_result = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ if not sys_result:
+ return {
+ "success": False,
+ "status": "unknown",
+ "last_check": None,
+ "is_secure": False,
+ "error": "no_security_api"
+ }
+
+ # Собираем базовые сведения из системной информации
+ return {
+ "success": True,
+ "status": "basic",
+ "last_check": None,
+ "is_secure": True, # Предполагаем, что система в целом безопасна
+ "firewall_enabled": None,
+ "auto_update": None,
+ "version_latest": sys_result.get("version_string", "")
+ }
+
+ # Собираем информацию из доступных результатов
+ firewall_enabled = firewall_result.get("enabled", False) if firewall_result else None
+ auto_update = update_result.get("auto_update", False) if update_result else None
+
+ # Определяем, насколько система безопасна
+ is_secure = True # По умолчанию предполагаем, что система безопасна
+ if firewall_enabled is not None and not firewall_enabled:
+ is_secure = False
+
+ return {
+ "success": True,
+ "status": "partial",
+ "last_check": None,
+ "is_secure": is_secure,
+ "firewall_enabled": firewall_enabled,
+ "auto_update": auto_update
+ }
+
+ # Если основное API отвечает, возвращаем его данные
+ return {
+ "success": True,
+ "status": result.get("status", "unknown"),
+ "last_check": result.get("last_check", None),
+ "is_secure": result.get("is_secure", False),
+ "details": result.get("details", {})
+ }
+
+ except Exception as e:
+ logger.error(f"Error in get_security_status: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_processes(self, limit: int = 20) -> List[Dict[str, Any]]:
+ """Получение списка активных процессов"""
+ logger.info(f"Getting list of active processes (limit={limit})")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for processes request")
+ return []
+
+ try:
+ # Получаем список процессов через API
+ result = self._make_api_request("SYNO.Core.System.Process", "list", version=1,
+ params={"sort_by": "cpu", "order": "DESC", "limit": limit})
+
+ if not result:
+ logger.warning("Failed to get process list")
+ return []
+
+ return result.get("processes", [])
+
+ except Exception as e:
+ logger.error(f"Error getting process list: {str(e)}")
+ return []
+
+ def get_network_status(self) -> Dict[str, Any]:
+ """Получение информации о сетевых подключениях"""
+ logger.info("Getting network status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for network status request")
+ return {}
+
+ try:
+ # Получаем информацию о сетевых интерфейсах
+ interface_result = self._make_api_request("SYNO.Core.Network.Interface", "list", version=1)
+
+ # Получаем статистику использования сети
+ utilization_result = self._make_api_request("SYNO.Core.System.Utilization", "get", version=1)
+
+ interfaces = []
+ if interface_result:
+ interfaces = interface_result.get("interfaces", [])
+
+ network_stats = {}
+ if utilization_result and "network" in utilization_result:
+ network_stats = utilization_result.get("network", {})
+
+ # Объединяем данные
+ for interface in interfaces:
+ iface_id = interface.get("id", "")
+ if iface_id in network_stats:
+ interface["rx"] = network_stats[iface_id].get("rx", 0)
+ interface["tx"] = network_stats[iface_id].get("tx", 0)
+
+ return {
+ "interfaces": interfaces,
+ "statistics": network_stats
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting network status: {str(e)}")
+ return {}
+
+ def get_system_logs(self, limit: int = 20) -> List[Dict[str, Any]]:
+ """Получение журналов системы"""
+ logger.info(f"Getting system logs (limit={limit})")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for system logs request")
+ return []
+
+ try:
+ # Получаем журналы через API
+ result = self._make_api_request("SYNO.Core.SyslogClient.Log", "list", version=1,
+ params={"start": 0, "limit": limit, "sort": "time", "dir": "DESC"})
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for system logs")
+ alt_result = self._make_api_request("SYNO.Core.Log", "list", version=1,
+ params={"start": 0, "limit": limit, "sort": "time", "dir": "DESC"})
+
+ if alt_result:
+ return alt_result.get("logs", [])
+ return []
+
+ return result.get("logs", [])
+
+ except Exception as e:
+ logger.error(f"Error getting system logs: {str(e)}")
+ return []
+
+ def get_power_schedule(self) -> Dict[str, Any]:
+ """Получение расписания включения/выключения"""
+ logger.info("Getting power schedule")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for power schedule request")
+ return {}
+
+ try:
+ # Список возможных API для получения расписания питания
+ apis_to_try = [
+ {"api": "SYNO.Core.System.PowerSchedule", "method": "get", "version": 1},
+ {"api": "SYNO.Core.System", "method": "get_power_schedule", "version": 1},
+ {"api": "SYNO.Core.Power", "method": "schedule", "version": 1},
+ {"api": "SYNO.Core.Power.Schedule", "method": "get", "version": 1},
+ {"api": "SYNO.PowerScheduler", "method": "load", "version": 1},
+ {"api": "SYNO.PowerSchedule", "method": "get", "version": 1}
+ ]
+
+ result = {}
+ # Пробуем все возможные API по очереди
+ for api_config in apis_to_try:
+ logger.debug(f"Trying API: {api_config['api']}.{api_config['method']} v{api_config['version']}")
+ temp_result = self._make_api_request(
+ api_config["api"],
+ api_config["method"],
+ version=api_config["version"]
+ )
+ if temp_result:
+ logger.info(f"Successfully retrieved power schedule using {api_config['api']}.{api_config['method']}")
+ result = temp_result
+ break
+
+ if not result:
+ # Если нет результатов, вернем структуру, которую ожидает код
+ logger.warning("No PowerSchedule API available, returning empty schedule structure")
+ return {
+ "boot_tasks": [],
+ "shutdown_tasks": []
+ }
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Error getting power schedule: {str(e)}")
+ return {
+ "boot_tasks": [],
+ "shutdown_tasks": []
+ }
+
+ def set_power_schedule(self, schedule_type: str, days: List[int], time: str, enabled: bool = True) -> bool:
+ """Настройка расписания включения/выключения
+
+ Args:
+ schedule_type: Тип расписания ('boot' или 'shutdown')
+ days: Список дней недели (0-6, где 0 - понедельник)
+ time: Время в формате 'HH:MM'
+ enabled: Включить или выключить расписание
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Setting power schedule for {schedule_type} at {time} on days {days}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for setting power schedule")
+ return False
+
+ try:
+ # Подготавливаем базовые параметры расписания
+ params = {
+ "enabled": enabled,
+ "type": schedule_type,
+ "day": days,
+ "time": time
+ }
+
+ # Список возможных API для установки расписания питания
+ apis_to_try = [
+ {"api": "SYNO.Core.System.PowerSchedule", "method": "set", "version": 1},
+ {"api": "SYNO.Core.System", "method": "set_power_schedule", "version": 1},
+ {"api": "SYNO.Core.Power", "method": "set_schedule", "version": 1},
+ {"api": "SYNO.Core.Power.Schedule", "method": "set", "version": 1},
+ {"api": "SYNO.PowerScheduler", "method": "save", "version": 1},
+ {"api": "SYNO.PowerSchedule", "method": "set", "version": 1}
+ ]
+
+ success = False
+ last_used_api = ""
+
+ # Пробуем все возможные API по очереди
+ for api_config in apis_to_try:
+ api_name = api_config["api"]
+ method = api_config["method"]
+ version = api_config["version"]
+
+ logger.debug(f"Trying to set power schedule with API: {api_name}.{method} v{version}")
+ result = self._make_api_request(api_name, method, version, params=params)
+
+ if result:
+ logger.info(f"Successfully set power schedule using {api_name}.{method}")
+ success = True
+ last_used_api = api_name
+ break
+
+ if not success:
+ logger.error("Failed to set power schedule with any available API")
+ return False
+
+ logger.info(f"Power schedule for {schedule_type} set successfully with {last_used_api}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error setting power schedule: {str(e)}")
+ return False
+
+ def get_temperature_status(self) -> Dict[str, Any]:
+ """Получение информации о температуре системы и дисков"""
+ logger.info("Getting temperature status")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for temperature status request")
+ return {}
+
+ try:
+ # Получаем информацию о системе для общей температуры
+ system_info = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ # Получаем информацию о дисках для их температуры
+ storage_info = self._make_api_request("SYNO.Storage.CGI.Storage", "load_info", version=1)
+
+ system_temp = None
+ disk_temps = []
+
+ if system_info:
+ system_temp = system_info.get("temperature")
+
+ if storage_info:
+ disks = storage_info.get("disks", [])
+ for disk in disks:
+ name = disk.get("name", "unknown")
+ model = disk.get("model", "unknown")
+ temp = disk.get("temp", None)
+ if temp is not None:
+ disk_temps.append({
+ "name": name,
+ "model": model,
+ "temperature": temp
+ })
+
+ return {
+ "system_temperature": system_temp,
+ "disk_temperatures": disk_temps,
+ "warning": system_info.get("temperature_warn", False) if system_info else False
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting temperature status: {str(e)}")
+ return {}
+
+ def browse_files(self, folder_path: str = "", limit: int = 100) -> Dict[str, Any]:
+ """Просмотр файлов в указанной директории
+
+ Args:
+ folder_path: Путь к папке (пустая строка для корневых общих папок)
+ limit: Максимальное количество элементов для возврата
+
+ Returns:
+ Словарь с информацией о файлах и папках
+ """
+ logger.info(f"Browsing files in {folder_path or 'root'}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file browsing")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ if not folder_path:
+ # Если путь не указан, получаем список общих папок
+ result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not result:
+ logger.error("Failed to list shared folders")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "items": result.get("shares", []),
+ "path": "",
+ "is_root": True
+ }
+ else:
+ # Получаем список файлов в указанной директории
+ params = {
+ "folder_path": folder_path,
+ "limit": limit,
+ "offset": 0,
+ "sort_by": "name",
+ "sort_direction": "ASC"
+ }
+
+ result = self._make_api_request("SYNO.FileStation.List", "list", version=2, params=params)
+
+ if not result:
+ logger.error(f"Failed to list files in {folder_path}")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "items": result.get("files", []),
+ "path": folder_path,
+ "is_root": False,
+ "total": result.get("total", 0)
+ }
+
+ except Exception as e:
+ logger.error(f"Error browsing files: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def manage_service(self, service_name: str, action: str = "status") -> Dict[str, Any]:
+ """Управление системным сервисом
+
+ Args:
+ service_name: Имя сервиса
+ action: Действие (status/start/stop/restart)
+
+ Returns:
+ Словарь с результатом операции
+ """
+ logger.info(f"Managing service {service_name}, action: {action}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for service management")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Проверяем доступное API для управления сервисами
+ if action == "status":
+ result = self._make_api_request("SYNO.Core.Service", "get", version=1,
+ params={"service": service_name})
+ else:
+ result = self._make_api_request("SYNO.Core.Service", action, version=1,
+ params={"service": service_name})
+
+ if not result:
+ logger.error(f"Failed to {action} service {service_name}")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "service": service_name,
+ "action": action,
+ "result": result,
+ "status": result.get("status") if action == "status" else "completed"
+ }
+
+ except Exception as e:
+ logger.error(f"Error managing service {service_name}: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def search_files(self, pattern: str, folder_path: str = "", limit: int = 100) -> Dict[str, Any]:
+ """Поиск файлов по шаблону
+
+ Args:
+ pattern: Шаблон для поиска
+ folder_path: Путь к папке для поиска (пустая строка для всех общих папок)
+ limit: Максимальное количество результатов
+
+ Returns:
+ Словарь с найденными файлами
+ """
+ logger.info(f"Searching for {pattern} in {folder_path or 'all shared folders'}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file search")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ if not folder_path:
+ # Получаем список всех общих папок для поиска
+ shares_result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not shares_result:
+ logger.error("Failed to list shared folders for search")
+ return {"success": False, "error": "api_error"}
+
+ # Формируем список путей для поиска
+ folder_paths = [share.get("path") for share in shares_result.get("shares", [])]
+ else:
+ folder_paths = [folder_path]
+
+ # Запускаем поиск
+ params = {
+ "folder_path": folder_paths,
+ "pattern": pattern,
+ "limit": limit,
+ "offset": 0
+ }
+
+ result = self._make_api_request("SYNO.FileStation.Search", "start", version=2, params=params)
+
+ if not result:
+ logger.error(f"Failed to start search for {pattern}")
+ return {"success": False, "error": "api_error"}
+
+ # Получаем taskid для проверки результатов
+ taskid = result.get("taskid")
+ if not taskid:
+ logger.error("No taskid received for search")
+ return {"success": False, "error": "no_task_id"}
+
+ # Ожидаем завершения поиска
+ search_result = {"finished": False, "progress": 0}
+ for _ in range(10): # Максимум 10 попыток
+ search_status = self._make_api_request("SYNO.FileStation.Search", "status", version=2,
+ params={"taskid": taskid})
+
+ if not search_status:
+ break
+
+ search_result["progress"] = search_status.get("progress", 0)
+
+ if search_status.get("finished", False):
+ search_result["finished"] = True
+ break
+
+ time.sleep(0.5) # Пауза между запросами
+
+ # Получаем результаты поиска
+ if search_result["finished"]:
+ list_result = self._make_api_request("SYNO.FileStation.Search", "list", version=2,
+ params={"taskid": taskid, "limit": limit})
+
+ if list_result:
+ files = list_result.get("files", [])
+ return {
+ "success": True,
+ "pattern": pattern,
+ "results": files,
+ "total": list_result.get("total", len(files))
+ }
+
+ # Если не удалось получить результаты, останавливаем поиск
+ self._make_api_request("SYNO.FileStation.Search", "stop", version=2,
+ params={"taskid": taskid})
+
+ return {
+ "success": False,
+ "error": "search_timeout",
+ "progress": search_result["progress"]
+ }
+
+ except Exception as e:
+ logger.error(f"Error searching files: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_backup_status(self) -> Dict[str, Any]:
+ """Получение информации о резервном копировании"""
+ logger.info("Getting backup status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for backup status request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о Hyper Backup
+ hyper_result = self._make_api_request("SYNO.Backup.Repository", "list", version=1)
+
+ # Пробуем получить информацию о задачах Time Backup
+ time_result = self._make_api_request("SYNO.BackupService.Task", "list", version=1)
+
+ # Проверяем статус резервного копирования USB
+ usb_result = self._make_api_request("SYNO.USBCopy", "status", version=1)
+
+ backups = {
+ "hyper_backup": hyper_result.get("backups", []) if hyper_result else [],
+ "time_backup": time_result.get("tasks", []) if time_result else [],
+ "usb_copy": {"enabled": usb_result.get("enable", False) if usb_result else False}
+ }
+
+ return {
+ "success": True,
+ "backups": backups,
+ "available_apis": {
+ "hyper_backup": hyper_result is not None,
+ "time_backup": time_result is not None,
+ "usb_copy": usb_result is not None
+ }
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting backup status: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def check_for_updates(self) -> Dict[str, Any]:
+ """Проверка наличия обновлений системы"""
+ logger.info("Checking for system updates")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for update check")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Получаем текущую информацию о системе
+ system_info = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ if not system_info:
+ logger.error("Failed to get system info for update check")
+ return {"success": False, "error": "api_error"}
+
+ # Проверяем наличие обновлений
+ update_result = self._make_api_request("SYNO.Core.Upgrade.Server", "check", version=1)
+
+ # Получаем настройки автоматического обновления
+ settings_result = self._make_api_request("SYNO.Core.Upgrade.Server.Setting", "get", version=1)
+
+ # Получаем информацию о доступных обновлениях
+ update_info = self._make_api_request("SYNO.Core.Upgrade.Server.Download", "list", version=1)
+
+ current_version = system_info.get("version_string", "unknown")
+ auto_update_enabled = settings_result.get("auto_update", False) if settings_result else False
+
+ updates = []
+ if update_info and "updates" in update_info:
+ updates = update_info.get("updates", [])
+
+ update_available = len(updates) > 0
+
+ return {
+ "success": True,
+ "current_version": current_version,
+ "update_available": update_available,
+ "auto_update_enabled": auto_update_enabled,
+ "updates": updates
+ }
+
+ except Exception as e:
+ logger.error(f"Error checking for updates: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_quota_info(self) -> Dict[str, Any]:
+ """Получение информации о квотах пользователей"""
+ logger.info("Getting user quota information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for quota info request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Получаем список пользователей
+ users_result = self._make_api_request("SYNO.Core.User", "list", version=1)
+
+ if not users_result:
+ logger.error("Failed to get user list for quota info")
+ return {"success": False, "error": "api_error"}
+
+ users = users_result.get("users", [])
+ user_quotas = []
+
+ # Получаем квоты для каждого пользователя
+ for user in users:
+ user_name = user.get("name")
+ if not user_name:
+ continue
+
+ quota_result = self._make_api_request("SYNO.Core.Quota", "get", version=1,
+ params={"user_name": user_name})
+
+ if quota_result and "quotas" in quota_result:
+ user_quotas.append({
+ "user": user_name,
+ "quotas": quota_result.get("quotas", [])
+ })
+
+ return {
+ "success": True,
+ "user_quotas": user_quotas
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting quota info: {str(e)}")
+ return {"success": False, "error": str(e)}
diff --git a/.history/src/api/synology_20250830110338.py b/.history/src/api/synology_20250830110338.py
new file mode 100644
index 0000000..145921f
--- /dev/null
+++ b/.history/src/api/synology_20250830110338.py
@@ -0,0 +1,1908 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Модуль для взаимодействия с API Synology NAS
+"""
+
+import requests
+from requests.adapters import HTTPAdapter
+import json
+import logging
+import time
+import urllib3
+from urllib3.util import Retry
+from typing import Dict, Any, Optional, List
+import socket
+import struct
+from time import sleep
+
+from src.config.config import (
+ SYNOLOGY_HOST,
+ SYNOLOGY_PORT,
+ SYNOLOGY_USERNAME,
+ SYNOLOGY_PASSWORD,
+ SYNOLOGY_SECURE,
+ SYNOLOGY_TIMEOUT,
+ SYNOLOGY_MAC,
+ WOL_PORT,
+ SYNOLOGY_API_VERSION,
+ SYNOLOGY_POWER_API,
+ SYNOLOGY_INFO_API
+)
+from src.api.api_discovery import discover_available_apis, find_compatible_api
+
+# Отключение предупреждений о небезопасных SSL-соединениях
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+logger = logging.getLogger(__name__)
+
+class SynologyAPI:
+ """Класс для взаимодействия с API Synology NAS"""
+
+ def __init__(self):
+ """Инициализация класса SynologyAPI"""
+ logger.info("Creating API with auto-retry and connection pool")
+ logger.debug(f"Connection details: Host={SYNOLOGY_HOST}, Port={SYNOLOGY_PORT}, Secure={SYNOLOGY_SECURE}")
+
+ self.protocol = "https" if SYNOLOGY_SECURE else "http"
+ self.base_url = f"{self.protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/webapi"
+ self.sid = None
+ self.session = requests.Session()
+
+ # Настройка SSL
+ if self.protocol == "https":
+ logger.debug("SSL enabled, disabling certificate verification for internal network")
+ self.session.verify = False # Отключаем проверку SSL для внутренней сети
+
+ # Добавляем пользовательские заголовки для улучшения совместимости с API
+ custom_headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
+ 'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'Connection': 'keep-alive',
+ 'Referer': f'{self.protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/'
+ }
+ self.session.headers.update(custom_headers)
+ logger.debug("Added browser-like headers for API compatibility")
+
+ # Добавляем повторные попытки для HTTP-запросов
+ retry_strategy = Retry(
+ total=5, # Увеличиваем количество попыток
+ status_forcelist=[429, 500, 502, 503, 504, 404],
+ allowed_methods=["GET", "POST"],
+ backoff_factor=1.5, # Увеличиваем задержку между попытками
+ respect_retry_after_header=True
+ )
+ adapter = HTTPAdapter(
+ max_retries=retry_strategy,
+ pool_connections=3,
+ pool_maxsize=10
+ )
+ self.session.mount("http://", adapter)
+ self.session.mount("https://", adapter)
+
+ # Таймауты будут указаны в запросах
+ self.default_timeout = (SYNOLOGY_TIMEOUT, SYNOLOGY_TIMEOUT*2)
+ logger.debug(f"Setting default request timeout: {self.default_timeout}")
+
+ # Кэш для хранения результатов запросов
+ self._cache = {}
+ self._cache_ttl = {}
+ self._last_online_check = 0
+ self._last_online_status = False
+ self._online_check_interval = 30 # Интервал для кэширования проверок онлайн-статуса
+
+ # Время последней успешной аутентификации и срок действия сессии
+ self._last_auth_time = 0
+ self._auth_expiry = 3600 # По умолчанию 1 час
+
+ # Информация о доступных API
+ self._available_apis = {}
+ self._api_info_ttl = 0
+
+ # Инициализируем API version resolver для автоматического определения совместимых API
+ self.api_resolver = None # Будет создан при необходимости
+
+ def login(self) -> bool:
+ """Авторизация в API Synology NAS"""
+ # Сбрасываем SID для новой сессии
+ self.sid = None
+
+ logger.info("Attempting to authenticate with Synology NAS...")
+ logger.debug(f"Base URL: {self.base_url}")
+
+ # Проверяем доступность NAS перед запросом с помощью прямой TCP-проверки
+ # Избегаем вызова is_online(), чтобы не создавать рекурсию
+ online_status = self._check_tcp_connection()
+ if not online_status:
+ logger.error("Cannot login: Synology NAS is not reachable")
+ return False
+
+ # Пробуем различные версии API для аутентификации
+ # Начинаем с версии 3, которая показала лучшую совместимость в тестах
+ auth_versions_to_try = [3, 2, 1, 6] # Пробуем сначала более стабильные версии
+
+ for auth_version in auth_versions_to_try:
+ try:
+ # Определяем путь к API аутентификации
+ auth_path = "entry.cgi" # Используем entry.cgi вместо auth.cgi по умолчанию
+
+ # Проверка информации API для определения доступных версий API
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": "SYNO.API.Auth"
+ }
+
+ logger.debug(f"Querying API info for auth version {auth_version}")
+ try:
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ auth_info = api_info_data.get("data", {}).get("SYNO.API.Auth", {})
+ max_version = auth_info.get("maxVersion", 6)
+ min_version = auth_info.get("minVersion", 1)
+ auth_path = auth_info.get("path", "entry.cgi")
+ logger.debug(f"Auth API versions: min={min_version}, max={max_version}, path={auth_path}")
+
+ # Проверяем поддержку текущей версии
+ if auth_version < min_version or auth_version > max_version:
+ logger.warning(f"Auth API version {auth_version} not supported (min={min_version}, max={max_version}), trying next version")
+ continue
+ else:
+ logger.warning("Failed to query API info, using default auth path")
+ else:
+ logger.warning(f"API info request failed with status {api_info_response.status_code}, using default auth path")
+ except Exception as e:
+ logger.warning(f"Error querying API info: {str(e)}, using default auth path")
+
+ # Основной запрос авторизации
+ url = f"{self.base_url}/{auth_path}"
+ params = {
+ "api": "SYNO.API.Auth",
+ "version": str(auth_version),
+ "method": "login",
+ "account": SYNOLOGY_USERNAME,
+ "passwd": SYNOLOGY_PASSWORD,
+ "session": "SynologyPowerControlBot",
+ "format": "cookie"
+ }
+
+ # Для версии 6+ используем немного другой формат
+ if auth_version >= 6:
+ params["enable_syno_token"] = "yes"
+
+ logger.debug(f"Sending auth request to {url} with API version {auth_version}")
+ start_time = time.time()
+ response = self.session.get(
+ url,
+ params=params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+ elapsed_time = time.time() - start_time
+ logger.debug(f"Auth request completed in {elapsed_time:.2f}s with status code {response.status_code}")
+
+ # Проверка статуса HTTP
+ if response.status_code != 200:
+ logger.error(f"HTTP error: {response.status_code}")
+ logger.debug(f"Response content: {response.text[:200]}")
+ continue # Пробуем следующую версию
+
+ try:
+ data = response.json()
+ except json.JSONDecodeError:
+ logger.error("Failed to decode JSON response")
+ logger.debug(f"Response content: {response.text[:200]}")
+ continue # Пробуем следующую версию
+
+ if data.get("success"):
+ self.sid = data.get("data", {}).get("sid")
+ self._last_auth_time = time.time()
+ logger.info(f"Successfully logged in to Synology NAS using API version {auth_version}")
+ logger.debug(f"Session ID obtained: {self.sid[:5] if self.sid else 'None'}...")
+
+ # Получаем и сохраняем токен SYNO, если он есть
+ syno_token = data.get("data", {}).get("synotoken")
+ if syno_token:
+ self.session.headers.update({'X-SYNO-TOKEN': syno_token})
+ logger.debug("Added X-SYNO-TOKEN header for improved API compatibility")
+
+ # Также добавляем SID в cookies для улучшения совместимости
+ self.session.cookies.update({
+ 'id': self.sid,
+ 'sid': self.sid
+ })
+ logger.debug("Added SID to session cookies for improved compatibility")
+
+ # Проверка валидности полученной сессии с помощью простого запроса
+ # Будем использовать SYNO.API.Info без проверки сложных методов
+
+ # Даем системе немного времени для инициализации сессии
+ time.sleep(0.5)
+
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to log in with version {auth_version}: Error code {error_code}")
+
+ # Если ошибка связана с версией API, пробуем следующую версию
+ if error_code in [104, 105]:
+ logger.warning(f"Auth version {auth_version} not supported, trying next version")
+ continue
+
+ # Дополнительная диагностика
+ if error_code == 400:
+ logger.error("Authentication error: Invalid credentials")
+ elif error_code == 401:
+ logger.error("Authentication error: Account disabled")
+ elif error_code == 402:
+ logger.error("Authentication error: Permission denied")
+ elif error_code == 403:
+ logger.error("Authentication error: 2-factor authentication required")
+ elif error_code == 404:
+ logger.error("Authentication error: Failed to authenticate with 2-factor authentication")
+
+ # Если ошибка связана с неверными учетными данными, нет смысла пробовать другие версии API
+ if error_code in [400, 401, 402, 403, 404]:
+ return False
+
+ except requests.exceptions.Timeout:
+ logger.error(f"Connection timeout during auth with version {auth_version}")
+ continue # Пробуем следующую версию
+ except requests.exceptions.ConnectionError as e:
+ logger.error(f"Connection error during auth with version {auth_version}: {str(e)}")
+ continue # Пробуем следующую версию
+ except requests.RequestException as e:
+ logger.error(f"Request error during auth with version {auth_version}: {str(e)}")
+ continue # Пробуем следующую версию
+ except Exception as e:
+ logger.error(f"Unexpected error during login with version {auth_version}: {str(e)}", exc_info=True)
+ continue # Пробуем следующую версию
+
+ # Если все версии не сработали
+ logger.error("Failed to authenticate with any API version")
+ return False
+
+ def _validate_session(self) -> bool:
+ """Проверяет валидность сессии после авторизации"""
+ if not self.sid:
+ return False
+
+ # Попробуем сделать простой запрос для проверки сессии
+ test_apis = [
+ {"api": "SYNO.Core.System", "method": "info", "version": 1},
+ {"api": "SYNO.DSM.Info", "method": "getinfo", "version": 1}
+ ]
+
+ for test_api in test_apis:
+ try:
+ url = f"{self.base_url}/entry.cgi"
+ params = {
+ "api": test_api["api"],
+ "version": str(test_api["version"]),
+ "method": test_api["method"],
+ "_sid": self.sid
+ }
+
+ response = self.session.get(url, params=params, timeout=self.default_timeout, verify=False)
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("success"):
+ logger.debug(f"Session validation successful using {test_api['api']}")
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ if error_code != 119: # Не сессия истекла
+ logger.debug(f"Session validation with {test_api['api']} returned error code {error_code}")
+ return True # Считаем сессию валидной, если ошибка не связана с истечением сессии
+ except Exception as e:
+ logger.warning(f"Error during session validation with {test_api['api']}: {str(e)}")
+
+ logger.warning("Session validation failed with all test APIs")
+ return False
+
+ def logout(self) -> bool:
+ """Выход из API Synology NAS"""
+ if not self.sid:
+ return True
+
+ try:
+ url = f"{self.base_url}/auth.cgi"
+ params = {
+ "api": "SYNO.API.Auth",
+ "version": "1",
+ "method": "logout",
+ "session": "SynologyPowerControlBot",
+ "_sid": self.sid
+ }
+
+ response = self.session.get(url, params=params, timeout=SYNOLOGY_TIMEOUT, verify=False)
+ data = response.json()
+
+ if data.get("success"):
+ self.sid = None
+ logger.info("Successfully logged out from Synology NAS")
+ return True
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to log out from Synology NAS: Error code {error_code}")
+ return False
+
+ except requests.RequestException as e:
+ logger.error(f"Connection error: {str(e)}")
+ return False
+
+ def _make_api_request(self, api_name: str, method: str, version: int = 1,
+ params: Optional[Dict[str, Any]] = None, retry_auth: bool = True, retry_count: int = 0) -> Optional[Dict[str, Any]]:
+ """Обобщенный метод для выполнения API запросов с обработкой ошибок"""
+ # Ограничение на количество повторных попыток
+ if retry_count >= 3:
+ logger.error(f"Too many retries for {api_name}.{method}, giving up")
+ return None
+
+ # Проверка наличия авторизации
+ if not self.sid and not self.login():
+ logger.error(f"Not authenticated for API request: {api_name}.{method}")
+ return None
+
+ # Проверка информации API для определения пути и поддерживаемой версии
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": api_name
+ }
+
+ api_path = "entry.cgi"
+ try:
+ logger.debug(f"Querying API info for {api_name}")
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ api_info = api_info_data.get("data", {}).get(api_name, {})
+ if api_info:
+ max_version = api_info.get("maxVersion", version)
+ min_version = api_info.get("minVersion", version)
+ api_path = api_info.get("path", "entry.cgi")
+
+ # Проверка, поддерживается ли запрошенная версия
+ if version < min_version:
+ logger.warning(f"API version {version} for {api_name} is below minimum {min_version}, using {min_version}")
+ version = min_version
+ elif version > max_version:
+ logger.warning(f"API version {version} for {api_name} exceeds maximum {max_version}, using {max_version}")
+ version = max_version
+
+ logger.debug(f"Using API path: {api_path}, version: {version}")
+ else:
+ logger.warning(f"API {api_name} not found in API info, using defaults")
+ except Exception as e:
+ logger.warning(f"Error querying API info for {api_name}: {str(e)}, using defaults")
+
+ # Подготовка базовых параметров запроса
+ base_params = {
+ "api": api_name,
+ "version": str(version),
+ "method": method,
+ "_sid": self.sid # Используем _sid вместо sid для лучшей совместимости
+ }
+
+ # Добавление дополнительных параметров, если они заданы
+ if params:
+ base_params.update(params)
+
+ url = f"{self.base_url}/{api_path}"
+ logger.debug(f"API request: {api_name}.{method} v{version} to {url}")
+ logger.debug(f"Full request params: {base_params}")
+
+ try:
+ start_time = time.time()
+ response = self.session.get(
+ url,
+ params=base_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+ elapsed_time = time.time() - start_time
+ logger.debug(f"API request completed in {elapsed_time:.2f}s with status {response.status_code}")
+
+ # Проверка статуса HTTP
+ if response.status_code != 200:
+ logger.error(f"HTTP error: {response.status_code} for {api_name}.{method}")
+
+ # Повторная попытка при ошибках соединения
+ if response.status_code in [500, 502, 503, 504]:
+ logger.info(f"Server error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+
+ try:
+ data = response.json()
+ except json.JSONDecodeError:
+ logger.error(f"Failed to decode JSON response for {api_name}.{method}")
+ logger.debug(f"Response content: {response.text[:200]}")
+
+ # Повторная попытка при ошибках декодирования
+ logger.info(f"JSON decode error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ if data.get("success"):
+ logger.info(f"API request successful for {api_name}.{method}")
+ return data.get("data", {})
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ error_desc = self._get_error_description(error_code)
+ logger.error(f"API error for {api_name}.{method}: {error_code} - {error_desc}")
+
+ # Ошибки доступа или прав часто встречаются, но они не критичные
+ # Например, ошибка 102 означает, что нет прав, но NAS доступен
+ if error_code in [102, 103, 104, 105]:
+ logger.warning(f"Permission-related error for {api_name}.{method}: {error_code}")
+ # Возвращаем пустой словарь вместо None,
+ # чтобы вызывающий код мог понять, что запрос выполнен
+ return {}
+
+ # Если ошибка связана с авторизацией и нам разрешено повторить попытку
+ if error_code in [106, 107, 119] and retry_auth:
+ logger.info(f"Session error (code {error_code}), creating fresh session...")
+ self.sid = None # Сбрасываем SID
+
+ # Для ошибки 119 (Session timeout) дадим системе немного времени
+ if error_code == 119:
+ logger.info("Session timeout detected, waiting before retry...")
+ sleep(3)
+
+ if self.login():
+ logger.info("Re-authenticated with fresh session, retrying API request...")
+ # Рекурсивный вызов, но со счетчиком повторов
+ return self._make_api_request(api_name, method, version, params, False, retry_count + 1)
+
+ # Для некоторых ошибок можно автоматически повторить запрос
+ if error_code in [408, 429, 500, 502, 503, 504]:
+ logger.info(f"Temporary error {error_code}, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+
+ except requests.exceptions.Timeout:
+ logger.error(f"Request timeout for {api_name}.{method}")
+
+ # Повторная попытка при таймауте
+ if retry_count < 2:
+ logger.info(f"Timeout, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+ except requests.exceptions.ConnectionError as e:
+ logger.error(f"Connection error for {api_name}.{method}: {str(e)}")
+
+ # Повторная попытка при ошибке соединения
+ if retry_count < 2:
+ logger.info(f"Connection error, retrying request for {api_name}.{method}")
+ sleep(2)
+ return self._make_api_request(api_name, method, version, params, retry_auth, retry_count + 1)
+
+ return None
+ except Exception as e:
+ logger.error(f"Unexpected error during {api_name}.{method}: {str(e)}")
+ return None
+
+ def get_system_status(self) -> Dict[str, Any]:
+ """Получение статуса системы"""
+ # Проверяем доступность системы
+ if not self.is_online():
+ logger.info("Device is offline, skipping API request")
+ return {"status": "offline"}
+
+ # Проверяем, есть ли кэшированный результат
+ cache_key = "system_status"
+ current_time = time.time()
+ if cache_key in self._cache and current_time - self._cache_ttl.get(cache_key, 0) < 60:
+ logger.debug("Using cached system status")
+ return self._cache[cache_key]
+
+ # Используем рекомендованный API для получения информации о системе
+ logger.info(f"Getting system info using {SYNOLOGY_INFO_API} v{SYNOLOGY_API_VERSION}")
+
+ # Для SYNO.DSM.Info метод getinfo, для других обычно используется get или info
+ if SYNOLOGY_INFO_API == "SYNO.DSM.Info":
+ method = "getinfo"
+ else:
+ method = "get"
+
+ result = self._make_api_request(SYNOLOGY_INFO_API, method, version=SYNOLOGY_API_VERSION)
+
+ if result:
+ logger.info(f"Successfully retrieved system info using {SYNOLOGY_INFO_API}")
+
+ # Формируем расширенный ответ с дополнительной информацией
+ system_info = {
+ "status": "online",
+ "hostname": result.get("hostname", "unknown"),
+ "model": result.get("model", "unknown"),
+ "version": result.get("version", "unknown"),
+ "uptime": result.get("uptime", 0),
+ "time": current_time,
+ "is_online": True,
+ "api_used": SYNOLOGY_INFO_API
+ }
+
+ # Сохраняем в кэше
+ self._cache[cache_key] = system_info
+ self._cache_ttl[cache_key] = current_time
+
+ return system_info
+
+ # Если основной API не сработал, пробуем резервные варианты
+ logger.warning(f"Failed to retrieve system info with {SYNOLOGY_INFO_API}, trying fallback APIs")
+
+ # Пробуем резервные API
+ apis_to_try = [
+ {"name": "SYNO.DSM.Info", "method": "getinfo", "version": 2},
+ {"name": "SYNO.Core.System", "method": "info", "version": 1},
+ {"name": "SYNO.Core.System.Status", "method": "get", "version": 1},
+ {"name": "SYNO.Core.System.Info", "method": "get", "version": 1},
+ ]
+
+ for api in apis_to_try:
+ if api["name"] == SYNOLOGY_INFO_API:
+ continue # Пропускаем уже проверенный API
+
+ logger.info(f"Trying to get system info using {api['name']}.{api['method']} v{api['version']}")
+ result = self._make_api_request(api["name"], api["method"], version=api["version"])
+
+ if result:
+ logger.info(f"Successfully retrieved system info using {api['name']}")
+
+ # Формируем расширенный ответ с дополнительной информацией
+ system_info = {
+ "status": "online",
+ "hostname": result.get("hostname", "unknown"),
+ "model": result.get("model", "unknown"),
+ "version": result.get("version", "unknown"),
+ "uptime": result.get("uptime", 0),
+ "time": current_time,
+ "is_online": True,
+ "api_used": api["name"]
+ }
+
+ # Сохраняем в кэше
+ self._cache[cache_key] = system_info
+ self._cache_ttl[cache_key] = current_time
+
+ return system_info
+
+ # Если все запросы не удались, но система онлайн, возвращаем базовую информацию
+ logger.warning("Failed to retrieve system info with all API methods")
+ return {
+ "status": "error",
+ "error": "Failed to fetch system information",
+ "is_online": True,
+ "time": current_time
+ }
+
+ def shutdown_system(self) -> bool:
+ """Выключение системы"""
+ # Проверяем, включено ли устройство перед попыткой его выключить
+ if not self.is_online(force_check=True):
+ logger.info("Synology NAS is already offline, no need to shut down")
+ return True
+
+ logger.info("Attempting to shutdown Synology NAS...")
+
+ # Попробуем сначала использовать предпочтительный API для управления питанием
+ logger.info(f"Trying shutdown with {SYNOLOGY_POWER_API} v{SYNOLOGY_API_VERSION}")
+
+ # Для SYNO.Core.Hardware.PowerRecovery используется метод setPowerOnState
+ # Для других API обычно используется метод shutdown или reboot
+ if SYNOLOGY_POWER_API == "SYNO.Core.Hardware.PowerRecovery":
+ # Для этого API нужны специальные параметры
+ params = {"state": "powerbtn"} # powerbtn имитирует нажатие кнопки питания
+ result = self._make_api_request(SYNOLOGY_POWER_API, "setPowerOnState", version=SYNOLOGY_API_VERSION, params=params)
+ else:
+ # Пробуем стандартный метод
+ result = self._make_api_request(SYNOLOGY_POWER_API, "shutdown", version=SYNOLOGY_API_VERSION)
+
+ if result is not None:
+ logger.info(f"Successfully initiated system shutdown using {SYNOLOGY_POWER_API}")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ # Проверяем статус
+ if not self.is_online(force_check=True):
+ logger.info("System is now offline. Shutdown confirmed successful.")
+ return True
+ else:
+ logger.info("System still appears to be online, but shutdown may be in progress.")
+ return True
+ else:
+ logger.warning(f"Failed to shutdown using {SYNOLOGY_POWER_API}, trying alternative methods")
+
+ # Если не сработал основной метод, пробуем резервные варианты
+ # Проверка всех доступных методов API для выключения
+ apis_to_try = [
+ {"name": "SYNO.Core.System", "method": "shutdown", "version": 3},
+ {"name": "SYNO.Core.System.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.DSM.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.System.Power", "method": "shutdown", "version": 1},
+ {"name": "SYNO.DSM.System", "method": "shutdown", "version": 1}
+ ]
+
+ # Проверяем доступные API
+ try:
+ api_info_url = f"{self.base_url}/entry.cgi"
+ api_info_params = {
+ "api": "SYNO.API.Info",
+ "version": "1",
+ "method": "query",
+ "query": "SYNO.Core.System.Power,SYNO.DSM.Power,SYNO.Core.System,SYNO.System.Power,SYNO.DSM.System"
+ }
+
+ logger.debug("Checking available shutdown APIs")
+ api_info_response = self.session.get(
+ api_info_url,
+ params=api_info_params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if api_info_response.status_code == 200:
+ api_info_data = api_info_response.json()
+ if api_info_data.get("success"):
+ available_apis = api_info_data.get("data", {})
+ logger.debug(f"Available APIs: {list(available_apis.keys())}")
+
+ # Фильтруем только доступные API
+ filtered_apis = []
+ for api in apis_to_try:
+ if api["name"] in available_apis:
+ api_info = available_apis[api["name"]]
+ # Проверка версии API
+ if api_info.get("minVersion", 1) <= api["version"] <= api_info.get("maxVersion", 1):
+ filtered_apis.append(api)
+ logger.debug(f"Adding {api['name']} to available shutdown APIs")
+ else:
+ logger.debug(f"API {api['name']} version {api['version']} not supported. Supported: {api_info.get('minVersion')}-{api_info.get('maxVersion')}")
+
+ if filtered_apis:
+ apis_to_try = filtered_apis
+ else:
+ logger.warning("No compatible APIs found, trying all methods as fallback")
+ else:
+ logger.warning("Failed to query API info, using default methods")
+ else:
+ logger.warning(f"API info request failed with status {api_info_response.status_code}, using default methods")
+ except Exception as e:
+ logger.warning(f"Error querying API info: {str(e)}, using default methods")
+
+ # Пробуем все доступные методы по порядку
+ for api in apis_to_try:
+ logger.info(f"Trying shutdown with {api['name']}.{api['method']} v{api['version']}")
+ result = self._make_api_request(api["name"], api["method"], version=api["version"])
+
+ if result is not None:
+ logger.info(f"Successfully initiated system shutdown using {api['name']}")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ # Проверяем статус
+ if not self.is_online(force_check=True):
+ logger.info("System is now offline. Shutdown confirmed successful.")
+ return True
+ else:
+ logger.info("System still appears to be online, but shutdown may be in progress.")
+ return True
+ else:
+ logger.warning(f"Failed to shutdown using {api['name']}.{api['method']} v{api['version']}")
+
+ # Если ни один метод не сработал, но система стала недоступна
+ if not self.is_online(force_check=True):
+ logger.info("System appears to be shutting down despite API errors")
+ return True
+
+ logger.error("Failed to shutdown system after trying multiple APIs")
+ return False
+
+ def reboot_system(self) -> bool:
+ """Перезагрузка системы"""
+ # Проверяем, включена ли система
+ if not self.is_online(force_check=True):
+ logger.error("Cannot reboot: System is offline")
+ return False
+
+ logger.info("Attempting to reboot Synology NAS...")
+
+ # Список API и методов для попытки перезагрузки
+ apis_to_try = [
+ {"api": "SYNO.Core.System", "method": "reboot", "version": 1},
+ {"api": "SYNO.DSM.System", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.System.Power", "method": "restart", "version": 1},
+ {"api": "SYNO.DSM.Power", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.System", "method": "reboot", "version": 3},
+ {"api": "SYNO.System.Power", "method": "reboot", "version": 1},
+ {"api": "SYNO.Core.Hardware.NeedReboot", "method": "reboot", "version": 1}
+ ]
+
+ # Добавляем настроенный в конфигурации API, если он отличается от уже добавленных
+ already_added = [item["api"] for item in apis_to_try]
+ if SYNOLOGY_POWER_API not in already_added:
+ for method in ["restart", "reboot"]:
+ apis_to_try.append({
+ "api": SYNOLOGY_POWER_API,
+ "method": method,
+ "version": SYNOLOGY_API_VERSION
+ })
+
+ # Перебираем все возможные API и методы
+ for api_info in apis_to_try:
+ try:
+ logger.info(f"Trying reboot with {api_info['api']} API using method {api_info['method']}")
+ result = self._make_api_request(api_info['api'], api_info['method'], version=api_info['version'])
+ if result is not None:
+ logger.info(f"Successfully initiated system reboot using {api_info['api']} API")
+
+ # Даем системе время начать процесс перезагрузки
+ logger.info("Waiting for reboot to initialize...")
+ sleep(5)
+
+ # Ждем, пока система станет недоступна (признак перезагрузки)
+ reboot_started = False
+ for i in range(12): # Проверяем в течение 60 секунд (12 * 5сек)
+ if not self.is_online(force_check=True):
+ logger.info(f"System went offline after {i*5} seconds, reboot in progress")
+ reboot_started = True
+ break
+ logger.debug(f"System still online, waiting... ({i+1}/12)")
+ sleep(5)
+
+ if reboot_started:
+ # Успешный вызов API и система ушла оффлайн - это признак успешной перезагрузки
+ return True
+ else:
+ # Успешный вызов API, но система не ушла оффлайн
+ logger.warning("System did not go offline after reboot command, but command was accepted")
+ # Даже если система не ушла оффлайн, команда могла быть принята
+ return True
+ except Exception as e:
+ logger.error(f"Error during reboot with {api_info['api']}: {str(e)}")
+
+ # Если все попытки не удались, возвращаем False
+ logger.error("All reboot attempts failed")
+ return False
+
+ def _get_error_description(self, error_code: int) -> str:
+ """Получение описания ошибки по коду"""
+ error_descriptions = {
+ 100: "Unknown error",
+ 101: "Invalid parameter",
+ 102: "API does not exist",
+ 103: "Method does not exist",
+ 104: "Version does not support",
+ 105: "Permission denied",
+ 106: "Session timeout",
+ 107: "Session interrupted by duplicate login",
+ 400: "Invalid credentials",
+ 401: "Account disabled",
+ 402: "Permission denied",
+ 403: "2FA required",
+ 404: "Failed to authenticate with 2FA"
+ }
+ return error_descriptions.get(error_code, "Unknown error code")
+
+ def _check_tcp_connection(self) -> bool:
+ """Проверка базового TCP-соединения с Synology NAS"""
+ try:
+ socket_obj = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ socket_obj.settimeout(SYNOLOGY_TIMEOUT)
+ result = socket_obj.connect_ex((SYNOLOGY_HOST, SYNOLOGY_PORT))
+ socket_obj.close()
+
+ return result == 0
+ except socket.error as e:
+ logger.error(f"Socket error during connection check: {str(e)}")
+ return False
+ except Exception as e:
+ logger.error(f"Unexpected error during connection check: {str(e)}")
+ return False
+
+ def is_online(self, force_check=False) -> bool:
+ """Проверка онлайн-статуса Synology NAS"""
+ # Используем кэшированное значение, если доступно и не устарело
+ current_time = time.time()
+ if not force_check and (current_time - self._last_online_check) < self._online_check_interval:
+ logger.debug(f"Using cached online status: {self._last_online_status}")
+ return self._last_online_status
+
+ logger.info("Checking if NAS is online...")
+
+ # Проверяем TCP-соединение
+ online_status = self._check_tcp_connection()
+ logger.info(f"Detected Synology NAS online status: {online_status}")
+
+ # Если TCP-соединение успешно и у нас есть действующий SID,
+ # попробуем более детальную проверку через API
+ if online_status and self.sid:
+ logger.info("Trying to fetch more detailed online status through API...")
+
+ # Пробуем разные API для проверки онлайн-статуса
+ api_checks = [
+ {"api": "SYNO.DSM.Info", "version": "2", "method": "getinfo"},
+ {"api": "SYNO.Core.System", "version": "1", "method": "info"},
+ {"api": "SYNO.Core.System.Status", "version": "1", "method": "get"}
+ ]
+
+ api_success = False
+ for api_check in api_checks:
+ try:
+ url = f"{self.base_url}/entry.cgi"
+ params = {
+ "api": api_check["api"],
+ "version": api_check["version"],
+ "method": api_check["method"],
+ "sid": self.sid
+ }
+
+ logger.debug(f"Trying online status check with {api_check['api']}")
+ response = self.session.get(
+ url,
+ params=params,
+ timeout=self.default_timeout,
+ verify=False
+ )
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("success"):
+ logger.info(f"API request successful for {api_check['api']}")
+ logger.info("Synology NAS is online with API access")
+ api_success = True
+ break
+ else:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.warning(f"API response indicates an error: {error_code}, but NAS is reachable")
+ else:
+ logger.warning(f"API returned status code {response.status_code}")
+ except Exception as e:
+ logger.warning(f"API check failed for {api_check['api']}, but TCP connection succeeded: {str(e)}")
+
+ if not api_success:
+ logger.warning("All API checks failed, but TCP connection is successful")
+
+ # Обновляем кэшированное значение
+ self._last_online_check = current_time
+ self._last_online_status = online_status
+
+ return online_status
+
+ def wake_on_lan(self) -> bool:
+ """Отправка Wake-on-LAN пакета для включения Synology NAS"""
+ if not SYNOLOGY_MAC:
+ logger.error("MAC address not configured")
+ return False
+
+ try:
+ # Преобразование MAC-адреса в байты
+ mac_address = SYNOLOGY_MAC.replace(':', '').replace('-', '')
+ if len(mac_address) != 12:
+ logger.error(f"Invalid MAC address format: {SYNOLOGY_MAC}")
+ return False
+
+ try:
+ mac_bytes = bytes.fromhex(mac_address)
+ except ValueError as e:
+ logger.error(f"Failed to parse MAC address: {str(e)}")
+ logger.error(f"MAC address must contain only hex characters: {SYNOLOGY_MAC}")
+ return False
+
+ # Создание Magic Packet
+ magic_packet = b'\xff' * 6 + mac_bytes * 16
+
+ # Отправка пакета на конкретный адрес
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.sendto(magic_packet, (SYNOLOGY_HOST, WOL_PORT))
+ sock.close()
+ logger.info(f"Wake-on-LAN packet sent to {SYNOLOGY_MAC} at {SYNOLOGY_HOST}:{WOL_PORT}")
+ except Exception as e:
+ logger.error(f"Error sending directed WoL packet: {str(e)}")
+ return False
+
+ # Для надежности отправляем также широковещательный пакет
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+ # Используем стандартный широковещательный адрес
+ broadcast_addr = "255.255.255.255"
+ sock.sendto(magic_packet, (broadcast_addr, WOL_PORT))
+ sock.close()
+ logger.info(f"Wake-on-LAN broadcast packet sent to {broadcast_addr}:{WOL_PORT}")
+ except Exception as e:
+ logger.warning(f"Error sending broadcast WoL packet (non-critical): {str(e)}")
+ # Не считаем ошибкой, т.к. основной пакет уже отправлен
+
+ return True
+
+ except Exception as e:
+ logger.error(f"Unexpected error in wake_on_lan: {str(e)}")
+ return False
+
+ def wait_for_boot(self, max_attempts: int = 30, delay: int = 10) -> bool:
+ """Ожидание загрузки Synology NAS"""
+ logger.info(f"Waiting for Synology NAS to boot (timeout: {max_attempts * delay}s)...")
+
+ for attempt in range(max_attempts):
+ # Принудительно проверяем статус без использования кэша
+ if self.is_online(force_check=True):
+ logger.info(f"Synology NAS is online after {attempt + 1} attempts ({(attempt + 1) * delay}s)")
+
+ # Проверяем, что не только сеть доступна, но и API загрузился
+ api_ready = False
+ logger.info("Waiting for API services to initialize...")
+
+ for api_check in range(5): # Даем еще до 50 секунд для загрузки API
+ if self.sid or self.login():
+ api_ready = True
+ logger.info(f"API services are ready after {api_check + 1} attempts")
+ break
+ logger.debug(f"API not ready yet, waiting... ({api_check + 1}/5)")
+ sleep(10)
+
+ if not api_ready:
+ logger.warning("System is online but API services may not be fully initialized")
+
+ # Дадим дополнительное время для полной загрузки всех сервисов
+ sleep(delay)
+ return True
+
+ sleep(delay)
+ logger.info(f"Waiting for boot... Attempt {attempt + 1}/{max_attempts}")
+
+ logger.error(f"Synology NAS didn't come online after {max_attempts * delay} seconds")
+ return False
+
+ def power_on(self) -> bool:
+ """Включение Synology NAS"""
+ # Принудительная проверка статуса
+ if self.is_online(force_check=True):
+ logger.info("Synology NAS is already online")
+ return True
+
+ logger.info("Powering on Synology NAS via Wake-on-LAN...")
+
+ # Проверяем, настроен ли MAC-адрес
+ if not SYNOLOGY_MAC:
+ logger.error("Cannot power on: MAC address not configured in settings")
+ return False
+
+ # Пробуем отправить несколько WoL пакетов для надежности
+ success = False
+ for attempt in range(3):
+ logger.debug(f"Sending WoL packet, attempt {attempt + 1}/3")
+ if self.wake_on_lan():
+ success = True
+ break
+ sleep(1)
+
+ if not success:
+ logger.error("Failed to send Wake-on-LAN packets")
+ return False
+
+ # Ожидание загрузки
+ logger.info("WoL packets sent successfully, waiting for system to boot...")
+ boot_result = self.wait_for_boot(max_attempts=30, delay=10)
+
+ if boot_result:
+ # Проверяем доступность API после загрузки
+ system_status = self.get_system_status()
+ if system_status.get("status") == "online":
+ logger.info("System booted successfully with API access")
+ return True
+ else:
+ logger.warning("System appears to be online but API may not be fully ready")
+ return True
+ else:
+ logger.error("System did not come online after WoL")
+ return False
+
+ def power_off(self) -> bool:
+ """Выключение Synology NAS"""
+ if not self.is_online(force_check=True):
+ logger.info("Synology NAS is already offline")
+ return True
+
+ logger.info("Powering off Synology NAS...")
+
+ # Список API и методов для попытки выключения
+ apis_to_try = [
+ {"api": "SYNO.Core.System", "method": "shutdown", "version": 1},
+ {"api": "SYNO.DSM.System", "method": "shutdown", "version": 1},
+ {"api": SYNOLOGY_POWER_API, "method": "shutdown", "version": SYNOLOGY_API_VERSION}
+ ]
+
+ # Перебираем все возможные API и методы
+ for api_info in apis_to_try:
+ try:
+ logger.info(f"Trying shutdown with {api_info['api']} API using method {api_info['method']}")
+ api_result = self._make_api_request(api_info['api'], api_info['method'], version=api_info['version'])
+ if api_result is not None:
+ logger.info(f"Successfully initiated system shutdown using {api_info['api']} API")
+
+ # Даем системе время начать процесс выключения
+ logger.info("Waiting for shutdown to initialize...")
+ sleep(5)
+
+ return True
+ except Exception as e:
+ logger.error(f"Error during shutdown with {api_info['api']}: {str(e)}")
+
+ # Если все попытки не удались, возвращаем False
+ logger.error("All shutdown attempts failed")
+ return False
+
+ # Если все еще не сработало, используем оригинальный метод shutdown_system
+ if not result:
+ result = self.shutdown_system()
+
+ if result:
+ # Дополнительная проверка, что система действительно выключилась
+ logger.info("Verifying system is offline...")
+ for attempt in range(12): # Проверяем в течение 2 минут (12 * 10 сек)
+ if not self.is_online(force_check=True):
+ logger.info(f"System confirmed offline after {attempt * 10} seconds")
+ return True
+ logger.debug(f"System still shutting down, checking again... ({attempt + 1}/12)")
+ sleep(10)
+
+ logger.warning("System still appears to be online after shutdown command")
+ return False
+ else:
+ logger.error("Failed to initiate shutdown")
+ return False
+
+ # Заглушки для расширенных методов
+ def get_shared_folders(self) -> List[Dict[str, Any]]:
+ """Получение списка общих папок"""
+ logger.info("Getting list of shared folders")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for shared folders request")
+ return []
+
+ try:
+ # Запрашиваем список общих папок через FileStation API
+ result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for shared folders")
+ alt_result = self._make_api_request("SYNO.FileStation.List", "list_share", version=1)
+ if alt_result:
+ return alt_result.get("shares", [])
+ return []
+
+ return result.get("shares", [])
+
+ except Exception as e:
+ logger.error(f"Error getting shared folders: {str(e)}")
+ return []
+
+ def get_system_load(self) -> Dict[str, Any]:
+ """Получение информации о загрузке системы"""
+ logger.info("Getting system load information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for system load request")
+ return {}
+
+ try:
+ # Запрашиваем информацию о загрузке системы
+ result = self._make_api_request("SYNO.Core.System.Utilization", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for system load")
+ alt_result = self._make_api_request("SYNO.Core.System", "info", version=3)
+
+ if not alt_result:
+ return {}
+
+ # Формируем из частичных данных
+ return {
+ "cpu_load": alt_result.get("cpu_usage", 0),
+ "memory": {
+ "total": alt_result.get("memory_size", 0),
+ "used": alt_result.get("memory_usage", 0),
+ "usage_percent": alt_result.get("memory_usage_percent", 0)
+ }
+ }
+
+ # Формируем структурированный результат
+ return {
+ "cpu_load": result.get("cpu", {}).get("user", 0) + result.get("cpu", {}).get("system", 0),
+ "memory": result.get("memory", {}),
+ "network": result.get("network", {})
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting system load: {str(e)}")
+ return {}
+
+ def is_online_api(self) -> bool:
+ """Проверка онлайн-статуса Synology NAS с использованием API"""
+ if not self.is_online():
+ return False
+
+ # Проверяем доступность API через авторизацию
+ if not self.sid and not self.login():
+ return False
+
+ return True
+
+ def get_storage_status(self) -> Dict[str, Any]:
+ """Получение подробной информации о хранилище"""
+ logger.info("Getting storage status information")
+
+ # Проверяем доступность NAS и API
+ if not self.is_online_api():
+ logger.error("Cannot get storage status: NAS is not online or API is not accessible")
+ return {"error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о хранилище через SYNO.Storage.CGI.Storage API
+ result = self._make_api_request("SYNO.Storage.CGI.Storage", "load_info", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for storage info")
+ alt_result = self._make_api_request("SYNO.Core.Storage", "info", version=1)
+
+ if not alt_result:
+ # Пробуем еще один альтернативный API
+ logger.info("Trying SYNO.Core.System API for storage info")
+ sys_result = self._make_api_request("SYNO.Core.System", "info", version=3)
+
+ if not sys_result:
+ return {
+ "volumes": [],
+ "disks": [],
+ "total_size": 0,
+ "total_used": 0,
+ "error": "no_data"
+ }
+
+ # Извлекаем базовую информацию о хранилище из системной информации
+ return {
+ "volumes": [],
+ "disks": [],
+ "total_size": sys_result.get("hdd_total_size", 0) * 1024**2, # МБ в байты
+ "total_used": sys_result.get("hdd_total_size", 0) * sys_result.get("hdd_usage", 0) / 100 * 1024**2,
+ }
+
+ # Обрабатываем данные из альтернативного API
+ volumes = alt_result.get("volumes", [])
+ disks = alt_result.get("disks", [])
+
+ else:
+ # Обрабатываем данные из основного API
+ volumes = result.get("volumes", [])
+ disks = result.get("disks", [])
+
+ # Рассчитываем общие размеры
+ total_size = 0
+ total_used = 0
+
+ for volume in volumes:
+ volume_size = volume.get("size", {}).get("total", 0)
+ volume_used = volume.get("size", {}).get("used", 0)
+
+ total_size += volume_size
+ total_used += volume_used
+
+ return {
+ "volumes": volumes,
+ "disks": disks,
+ "total_size": total_size,
+ "total_used": total_used
+ }
+
+ except Exception as e:
+ logger.error(f"Error in get_storage_status: {str(e)}")
+ return {"error": str(e)}
+
+ def get_security_status(self) -> Dict[str, Any]:
+ """Получение информации о состоянии безопасности"""
+ logger.info("Getting security status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for security status request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о безопасности через API Security Scan
+ result = self._make_api_request("SYNO.Core.SecurityScan.Status", "get", version=1)
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for security status")
+ # Проверяем статус брандмауэра
+ firewall_result = self._make_api_request("SYNO.Core.Security.Firewall.Status", "get", version=1)
+
+ # Проверяем статус автоматических обновлений
+ update_result = self._make_api_request("SYNO.Core.Upgrade.Server.Setting", "get", version=1)
+
+ # Если ни один из API не отвечает
+ if not firewall_result and not update_result:
+ # Получаем общую информацию о системе для базовой проверки безопасности
+ sys_result = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ if not sys_result:
+ return {
+ "success": False,
+ "status": "unknown",
+ "last_check": None,
+ "is_secure": False,
+ "error": "no_security_api"
+ }
+
+ # Собираем базовые сведения из системной информации
+ return {
+ "success": True,
+ "status": "basic",
+ "last_check": None,
+ "is_secure": True, # Предполагаем, что система в целом безопасна
+ "firewall_enabled": None,
+ "auto_update": None,
+ "version_latest": sys_result.get("version_string", "")
+ }
+
+ # Собираем информацию из доступных результатов
+ firewall_enabled = firewall_result.get("enabled", False) if firewall_result else None
+ auto_update = update_result.get("auto_update", False) if update_result else None
+
+ # Определяем, насколько система безопасна
+ is_secure = True # По умолчанию предполагаем, что система безопасна
+ if firewall_enabled is not None and not firewall_enabled:
+ is_secure = False
+
+ return {
+ "success": True,
+ "status": "partial",
+ "last_check": None,
+ "is_secure": is_secure,
+ "firewall_enabled": firewall_enabled,
+ "auto_update": auto_update
+ }
+
+ # Если основное API отвечает, возвращаем его данные
+ return {
+ "success": True,
+ "status": result.get("status", "unknown"),
+ "last_check": result.get("last_check", None),
+ "is_secure": result.get("is_secure", False),
+ "details": result.get("details", {})
+ }
+
+ except Exception as e:
+ logger.error(f"Error in get_security_status: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_processes(self, limit: int = 20) -> List[Dict[str, Any]]:
+ """Получение списка активных процессов"""
+ logger.info(f"Getting list of active processes (limit={limit})")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for processes request")
+ return []
+
+ try:
+ # Получаем список процессов через API
+ result = self._make_api_request("SYNO.Core.System.Process", "list", version=1,
+ params={"sort_by": "cpu", "order": "DESC", "limit": limit})
+
+ if not result:
+ logger.warning("Failed to get process list")
+ return []
+
+ return result.get("processes", [])
+
+ except Exception as e:
+ logger.error(f"Error getting process list: {str(e)}")
+ return []
+
+ def get_network_status(self) -> Dict[str, Any]:
+ """Получение информации о сетевых подключениях"""
+ logger.info("Getting network status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for network status request")
+ return {}
+
+ try:
+ # Получаем информацию о сетевых интерфейсах
+ interface_result = self._make_api_request("SYNO.Core.Network.Interface", "list", version=1)
+
+ # Получаем статистику использования сети
+ utilization_result = self._make_api_request("SYNO.Core.System.Utilization", "get", version=1)
+
+ interfaces = []
+ if interface_result:
+ interfaces = interface_result.get("interfaces", [])
+
+ network_stats = {}
+ if utilization_result and "network" in utilization_result:
+ network_stats = utilization_result.get("network", {})
+
+ # Объединяем данные
+ for interface in interfaces:
+ iface_id = interface.get("id", "")
+ if iface_id in network_stats:
+ interface["rx"] = network_stats[iface_id].get("rx", 0)
+ interface["tx"] = network_stats[iface_id].get("tx", 0)
+
+ return {
+ "interfaces": interfaces,
+ "statistics": network_stats
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting network status: {str(e)}")
+ return {}
+
+ def get_system_logs(self, limit: int = 20) -> List[Dict[str, Any]]:
+ """Получение журналов системы"""
+ logger.info(f"Getting system logs (limit={limit})")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for system logs request")
+ return []
+
+ try:
+ # Получаем журналы через API
+ result = self._make_api_request("SYNO.Core.SyslogClient.Log", "list", version=1,
+ params={"start": 0, "limit": limit, "sort": "time", "dir": "DESC"})
+
+ if not result:
+ # Пробуем альтернативный API
+ logger.info("Trying alternative API for system logs")
+ alt_result = self._make_api_request("SYNO.Core.Log", "list", version=1,
+ params={"start": 0, "limit": limit, "sort": "time", "dir": "DESC"})
+
+ if alt_result:
+ return alt_result.get("logs", [])
+ return []
+
+ return result.get("logs", [])
+
+ except Exception as e:
+ logger.error(f"Error getting system logs: {str(e)}")
+ return []
+
+ def get_power_schedule(self) -> Dict[str, Any]:
+ """Получение расписания включения/выключения"""
+ logger.info("Getting power schedule")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for power schedule request")
+ return {}
+
+ try:
+ # Список возможных API для получения расписания питания
+ apis_to_try = [
+ {"api": "SYNO.Core.System.PowerSchedule", "method": "get", "version": 1},
+ {"api": "SYNO.Core.System", "method": "get_power_schedule", "version": 1},
+ {"api": "SYNO.Core.Power", "method": "schedule", "version": 1},
+ {"api": "SYNO.Core.Power.Schedule", "method": "get", "version": 1},
+ {"api": "SYNO.PowerScheduler", "method": "load", "version": 1},
+ {"api": "SYNO.PowerSchedule", "method": "get", "version": 1}
+ ]
+
+ result = {}
+ # Пробуем все возможные API по очереди
+ for api_config in apis_to_try:
+ logger.debug(f"Trying API: {api_config['api']}.{api_config['method']} v{api_config['version']}")
+ temp_result = self._make_api_request(
+ api_config["api"],
+ api_config["method"],
+ version=api_config["version"]
+ )
+ if temp_result:
+ logger.info(f"Successfully retrieved power schedule using {api_config['api']}.{api_config['method']}")
+ result = temp_result
+ break
+
+ if not result:
+ # Если нет результатов, вернем структуру, которую ожидает код
+ logger.warning("No PowerSchedule API available, returning empty schedule structure")
+ return {
+ "boot_tasks": [],
+ "shutdown_tasks": []
+ }
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Error getting power schedule: {str(e)}")
+ return {
+ "boot_tasks": [],
+ "shutdown_tasks": []
+ }
+
+ def set_power_schedule(self, schedule_type: str, days: List[int], time: str, enabled: bool = True) -> bool:
+ """Настройка расписания включения/выключения
+
+ Args:
+ schedule_type: Тип расписания ('boot' или 'shutdown')
+ days: Список дней недели (0-6, где 0 - понедельник)
+ time: Время в формате 'HH:MM'
+ enabled: Включить или выключить расписание
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Setting power schedule for {schedule_type} at {time} on days {days}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for setting power schedule")
+ return False
+
+ try:
+ # Подготавливаем базовые параметры расписания
+ params = {
+ "enabled": enabled,
+ "type": schedule_type,
+ "day": days,
+ "time": time
+ }
+
+ # Список возможных API для установки расписания питания
+ apis_to_try = [
+ {"api": "SYNO.Core.System.PowerSchedule", "method": "set", "version": 1},
+ {"api": "SYNO.Core.System", "method": "set_power_schedule", "version": 1},
+ {"api": "SYNO.Core.Power", "method": "set_schedule", "version": 1},
+ {"api": "SYNO.Core.Power.Schedule", "method": "set", "version": 1},
+ {"api": "SYNO.PowerScheduler", "method": "save", "version": 1},
+ {"api": "SYNO.PowerSchedule", "method": "set", "version": 1}
+ ]
+
+ success = False
+ last_used_api = ""
+
+ # Пробуем все возможные API по очереди
+ for api_config in apis_to_try:
+ api_name = api_config["api"]
+ method = api_config["method"]
+ version = api_config["version"]
+
+ logger.debug(f"Trying to set power schedule with API: {api_name}.{method} v{version}")
+ result = self._make_api_request(api_name, method, version, params=params)
+
+ if result:
+ logger.info(f"Successfully set power schedule using {api_name}.{method}")
+ success = True
+ last_used_api = api_name
+ break
+
+ if not success:
+ logger.error("Failed to set power schedule with any available API")
+ return False
+
+ logger.info(f"Power schedule for {schedule_type} set successfully with {last_used_api}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error setting power schedule: {str(e)}")
+ return False
+
+ def get_temperature_status(self) -> Dict[str, Any]:
+ """Получение информации о температуре системы и дисков"""
+ logger.info("Getting temperature status")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for temperature status request")
+ return {}
+
+ try:
+ # Получаем информацию о системе для общей температуры
+ system_info = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ # Получаем информацию о дисках для их температуры
+ storage_info = self._make_api_request("SYNO.Storage.CGI.Storage", "load_info", version=1)
+
+ system_temp = None
+ disk_temps = []
+
+ if system_info:
+ system_temp = system_info.get("temperature")
+
+ if storage_info:
+ disks = storage_info.get("disks", [])
+ for disk in disks:
+ name = disk.get("name", "unknown")
+ model = disk.get("model", "unknown")
+ temp = disk.get("temp", None)
+ if temp is not None:
+ disk_temps.append({
+ "name": name,
+ "model": model,
+ "temperature": temp
+ })
+
+ return {
+ "system_temperature": system_temp,
+ "disk_temperatures": disk_temps,
+ "warning": system_info.get("temperature_warn", False) if system_info else False
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting temperature status: {str(e)}")
+ return {}
+
+ def browse_files(self, folder_path: str = "", limit: int = 100) -> Dict[str, Any]:
+ """Просмотр файлов в указанной директории
+
+ Args:
+ folder_path: Путь к папке (пустая строка для корневых общих папок)
+ limit: Максимальное количество элементов для возврата
+
+ Returns:
+ Словарь с информацией о файлах и папках
+ """
+ logger.info(f"Browsing files in {folder_path or 'root'}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file browsing")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ if not folder_path:
+ # Если путь не указан, получаем список общих папок
+ result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not result:
+ logger.error("Failed to list shared folders")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "items": result.get("shares", []),
+ "path": "",
+ "is_root": True
+ }
+ else:
+ # Получаем список файлов в указанной директории
+ params = {
+ "folder_path": folder_path,
+ "limit": limit,
+ "offset": 0,
+ "sort_by": "name",
+ "sort_direction": "ASC"
+ }
+
+ result = self._make_api_request("SYNO.FileStation.List", "list", version=2, params=params)
+
+ if not result:
+ logger.error(f"Failed to list files in {folder_path}")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "items": result.get("files", []),
+ "path": folder_path,
+ "is_root": False,
+ "total": result.get("total", 0)
+ }
+
+ except Exception as e:
+ logger.error(f"Error browsing files: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def manage_service(self, service_name: str, action: str = "status") -> Dict[str, Any]:
+ """Управление системным сервисом
+
+ Args:
+ service_name: Имя сервиса
+ action: Действие (status/start/stop/restart)
+
+ Returns:
+ Словарь с результатом операции
+ """
+ logger.info(f"Managing service {service_name}, action: {action}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for service management")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Проверяем доступное API для управления сервисами
+ if action == "status":
+ result = self._make_api_request("SYNO.Core.Service", "get", version=1,
+ params={"service": service_name})
+ else:
+ result = self._make_api_request("SYNO.Core.Service", action, version=1,
+ params={"service": service_name})
+
+ if not result:
+ logger.error(f"Failed to {action} service {service_name}")
+ return {"success": False, "error": "api_error"}
+
+ return {
+ "success": True,
+ "service": service_name,
+ "action": action,
+ "result": result,
+ "status": result.get("status") if action == "status" else "completed"
+ }
+
+ except Exception as e:
+ logger.error(f"Error managing service {service_name}: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def search_files(self, pattern: str, folder_path: str = "", limit: int = 100) -> Dict[str, Any]:
+ """Поиск файлов по шаблону
+
+ Args:
+ pattern: Шаблон для поиска
+ folder_path: Путь к папке для поиска (пустая строка для всех общих папок)
+ limit: Максимальное количество результатов
+
+ Returns:
+ Словарь с найденными файлами
+ """
+ logger.info(f"Searching for {pattern} in {folder_path or 'all shared folders'}")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file search")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ if not folder_path:
+ # Получаем список всех общих папок для поиска
+ shares_result = self._make_api_request("SYNO.FileStation.List", "list_share", version=2)
+
+ if not shares_result:
+ logger.error("Failed to list shared folders for search")
+ return {"success": False, "error": "api_error"}
+
+ # Формируем список путей для поиска
+ folder_paths = [share.get("path") for share in shares_result.get("shares", [])]
+ else:
+ folder_paths = [folder_path]
+
+ # Запускаем поиск
+ params = {
+ "folder_path": folder_paths,
+ "pattern": pattern,
+ "limit": limit,
+ "offset": 0
+ }
+
+ result = self._make_api_request("SYNO.FileStation.Search", "start", version=2, params=params)
+
+ if not result:
+ logger.error(f"Failed to start search for {pattern}")
+ return {"success": False, "error": "api_error"}
+
+ # Получаем taskid для проверки результатов
+ taskid = result.get("taskid")
+ if not taskid:
+ logger.error("No taskid received for search")
+ return {"success": False, "error": "no_task_id"}
+
+ # Ожидаем завершения поиска
+ search_result = {"finished": False, "progress": 0}
+ for _ in range(10): # Максимум 10 попыток
+ search_status = self._make_api_request("SYNO.FileStation.Search", "status", version=2,
+ params={"taskid": taskid})
+
+ if not search_status:
+ break
+
+ search_result["progress"] = search_status.get("progress", 0)
+
+ if search_status.get("finished", False):
+ search_result["finished"] = True
+ break
+
+ time.sleep(0.5) # Пауза между запросами
+
+ # Получаем результаты поиска
+ if search_result["finished"]:
+ list_result = self._make_api_request("SYNO.FileStation.Search", "list", version=2,
+ params={"taskid": taskid, "limit": limit})
+
+ if list_result:
+ files = list_result.get("files", [])
+ return {
+ "success": True,
+ "pattern": pattern,
+ "results": files,
+ "total": list_result.get("total", len(files))
+ }
+
+ # Если не удалось получить результаты, останавливаем поиск
+ self._make_api_request("SYNO.FileStation.Search", "stop", version=2,
+ params={"taskid": taskid})
+
+ return {
+ "success": False,
+ "error": "search_timeout",
+ "progress": search_result["progress"]
+ }
+
+ except Exception as e:
+ logger.error(f"Error searching files: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_backup_status(self) -> Dict[str, Any]:
+ """Получение информации о резервном копировании"""
+ logger.info("Getting backup status information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for backup status request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Пробуем получить информацию о Hyper Backup
+ hyper_result = self._make_api_request("SYNO.Backup.Repository", "list", version=1)
+
+ # Пробуем получить информацию о задачах Time Backup
+ time_result = self._make_api_request("SYNO.BackupService.Task", "list", version=1)
+
+ # Проверяем статус резервного копирования USB
+ usb_result = self._make_api_request("SYNO.USBCopy", "status", version=1)
+
+ backups = {
+ "hyper_backup": hyper_result.get("backups", []) if hyper_result else [],
+ "time_backup": time_result.get("tasks", []) if time_result else [],
+ "usb_copy": {"enabled": usb_result.get("enable", False) if usb_result else False}
+ }
+
+ return {
+ "success": True,
+ "backups": backups,
+ "available_apis": {
+ "hyper_backup": hyper_result is not None,
+ "time_backup": time_result is not None,
+ "usb_copy": usb_result is not None
+ }
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting backup status: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def check_for_updates(self) -> Dict[str, Any]:
+ """Проверка наличия обновлений системы"""
+ logger.info("Checking for system updates")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for update check")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Получаем текущую информацию о системе
+ system_info = self._make_api_request("SYNO.DSM.Info", "getinfo", version=2)
+
+ if not system_info:
+ logger.error("Failed to get system info for update check")
+ return {"success": False, "error": "api_error"}
+
+ # Проверяем наличие обновлений
+ update_result = self._make_api_request("SYNO.Core.Upgrade.Server", "check", version=1)
+
+ # Получаем настройки автоматического обновления
+ settings_result = self._make_api_request("SYNO.Core.Upgrade.Server.Setting", "get", version=1)
+
+ # Получаем информацию о доступных обновлениях
+ update_info = self._make_api_request("SYNO.Core.Upgrade.Server.Download", "list", version=1)
+
+ current_version = system_info.get("version_string", "unknown")
+ auto_update_enabled = settings_result.get("auto_update", False) if settings_result else False
+
+ updates = []
+ if update_info and "updates" in update_info:
+ updates = update_info.get("updates", [])
+
+ update_available = len(updates) > 0
+
+ return {
+ "success": True,
+ "current_version": current_version,
+ "update_available": update_available,
+ "auto_update_enabled": auto_update_enabled,
+ "updates": updates
+ }
+
+ except Exception as e:
+ logger.error(f"Error checking for updates: {str(e)}")
+ return {"success": False, "error": str(e)}
+
+ def get_quota_info(self) -> Dict[str, Any]:
+ """Получение информации о квотах пользователей"""
+ logger.info("Getting user quota information")
+
+ # Аутентифицируемся перед запросом данных
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for quota info request")
+ return {"success": False, "error": "authentication_failed"}
+
+ try:
+ # Получаем список пользователей
+ users_result = self._make_api_request("SYNO.Core.User", "list", version=1)
+
+ if not users_result:
+ logger.error("Failed to get user list for quota info")
+ return {"success": False, "error": "api_error"}
+
+ users = users_result.get("users", [])
+ user_quotas = []
+
+ # Получаем квоты для каждого пользователя
+ for user in users:
+ user_name = user.get("name")
+ if not user_name:
+ continue
+
+ quota_result = self._make_api_request("SYNO.Core.Quota", "get", version=1,
+ params={"user_name": user_name})
+
+ if quota_result and "quotas" in quota_result:
+ user_quotas.append({
+ "user": user_name,
+ "quotas": quota_result.get("quotas", [])
+ })
+
+ return {
+ "success": True,
+ "user_quotas": user_quotas
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting quota info: {str(e)}")
+ return {"success": False, "error": str(e)}
diff --git a/.history/src/bot_20250830110611.py b/.history/src/bot_20250830110611.py
new file mode 100644
index 0000000..468e0ae
--- /dev/null
+++ b/.history/src/bot_20250830110611.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Главный модуль запуска телеграм-бота для управления Synology NAS
+"""
+
+import os
+import signal
+import sys
+import asyncio
+import logging
+from telegram.ext import (
+ Application,
+ CommandHandler,
+ CallbackQueryHandler,
+ MessageHandler,
+ filters
+)
+
+from src.config.config import TELEGRAM_TOKEN
+from src.handlers.help_handlers import (
+ start_command,
+ help_command
+)
+from src.handlers.command_handlers import (
+ status_command,
+ power_command,
+ power_callback
+)
+from src.handlers.extended_handlers import (
+ storage_command,
+ shares_command,
+ system_command,
+ load_command,
+ security_command,
+ check_api_command
+)
+from src.handlers.advanced_handlers import (
+ processes_command,
+ network_command,
+ temperature_command,
+ schedule_command,
+ browse_command,
+ search_command,
+ updates_command,
+ backup_command,
+ quickreboot_command,
+ reboot_command,
+ sleep_command,
+ wakeup_command,
+ quota_command,
+ schedule_callback,
+ browse_callback,
+ advanced_power_callback
+)
+from src.utils.admin_utils import (
+ add_admin,
+ remove_admin,
+ list_admins
+)
+from src.utils.logger import setup_logging
+
+async def shutdown(application: Application) -> None:
+ """Корректное завершение работы бота"""
+ logger = logging.getLogger(__name__)
+ logger.info("Stopping Synology Power Control Bot...")
+
+ # Останавливаем прием обновлений
+ await application.stop()
+ logger.info("Bot stopped successfully")
+
+def signal_handler(sig, frame, application=None):
+ """Обработчик сигналов для корректного завершения"""
+ logger = logging.getLogger(__name__)
+ logger.info(f"Received signal {sig}, shutting down gracefully")
+
+ if application:
+ # Создаем и запускаем задачу завершения в event loop
+ loop = asyncio.get_event_loop()
+ if loop.is_running():
+ loop.create_task(shutdown(application))
+ else:
+ loop.run_until_complete(shutdown(application))
+
+ sys.exit(0)
+
+def main() -> None:
+ """Основная функция запуска бота"""
+ # Настройка логирования
+ setup_logging()
+ logger = logging.getLogger(__name__)
+
+ # Проверка наличия токена
+ if not TELEGRAM_TOKEN:
+ logger.error("Telegram token is not set! Please configure TELEGRAM_TOKEN in .env file.")
+ return
+
+ # Создание и настройка приложения бота
+ logger.info("Starting Synology Power Control Bot")
+ application = Application.builder().token(TELEGRAM_TOKEN).build()
+
+ # Регистрация обработчиков команд
+ application.add_handler(CommandHandler("start", start_command))
+ application.add_handler(CommandHandler("help", help_command))
+ application.add_handler(CommandHandler("status", status_command))
+ application.add_handler(CommandHandler("power", power_command))
+
+ # Регистрация расширенных обработчиков команд
+ application.add_handler(CommandHandler("storage", storage_command))
+ application.add_handler(CommandHandler("shares", shares_command))
+ application.add_handler(CommandHandler("system", system_command))
+ application.add_handler(CommandHandler("load", load_command))
+ application.add_handler(CommandHandler("security", security_command))
+ application.add_handler(CommandHandler("checkapi", check_api_command))
+
+ # Регистрация продвинутых обработчиков команд
+ application.add_handler(CommandHandler("processes", processes_command))
+ application.add_handler(CommandHandler("network", network_command))
+ application.add_handler(CommandHandler("temperature", temperature_command))
+ application.add_handler(CommandHandler("schedule", schedule_command))
+ application.add_handler(CommandHandler("browse", browse_command))
+ application.add_handler(CommandHandler("search", search_command))
+ application.add_handler(CommandHandler("updates", updates_command))
+ application.add_handler(CommandHandler("backup", backup_command))
+ application.add_handler(CommandHandler("quickreboot", quickreboot_command))
+ application.add_handler(CommandHandler("reboot", reboot_command))
+ application.add_handler(CommandHandler("sleep", sleep_command))
+ application.add_handler(CommandHandler("wakeup", wakeup_command))
+ application.add_handler(CommandHandler("quota", quota_command))
+
+ # Регистрация обработчиков callback-запросов
+ # Сначала обрабатываем более специфичные паттерны
+ application.add_handler(CallbackQueryHandler(advanced_power_callback, pattern="^(confirm|cancel)_(reboot|sleep)$")) # Для advanced_handlers.py
+ # Затем более общие паттерны
+ application.add_handler(CallbackQueryHandler(power_callback, pattern="^power_|^reboot$|^cancel$")) # Для command_handlers.py
+ application.add_handler(CallbackQueryHandler(schedule_callback, pattern="^schedule_"))
+ application.add_handler(CallbackQueryHandler(browse_callback, pattern="^browse_"))
+
+ # Настройка обработчиков сигналов для корректного завершения
+ signal.signal(signal.SIGINT, lambda sig, frame: signal_handler(sig, frame, application))
+ signal.signal(signal.SIGTERM, lambda sig, frame: signal_handler(sig, frame, application))
+
+ # Запуск бота
+ logger.info("Bot started. Press Ctrl+C to stop.")
+ application.run_polling(allowed_updates=["message", "callback_query"])
+
+if __name__ == "__main__":
+ main()
diff --git a/.history/src/bot_20250830110630.py b/.history/src/bot_20250830110630.py
new file mode 100644
index 0000000..58a915d
--- /dev/null
+++ b/.history/src/bot_20250830110630.py
@@ -0,0 +1,154 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Главный модуль запуска телеграм-бота для управления Synology NAS
+"""
+
+import os
+import signal
+import sys
+import asyncio
+import logging
+from telegram.ext import (
+ Application,
+ CommandHandler,
+ CallbackQueryHandler,
+ MessageHandler,
+ filters
+)
+
+from src.config.config import TELEGRAM_TOKEN
+from src.handlers.help_handlers import (
+ start_command,
+ help_command
+)
+from src.handlers.command_handlers import (
+ status_command,
+ power_command,
+ power_callback
+)
+from src.handlers.extended_handlers import (
+ storage_command,
+ shares_command,
+ system_command,
+ load_command,
+ security_command,
+ check_api_command
+)
+from src.handlers.advanced_handlers import (
+ processes_command,
+ network_command,
+ temperature_command,
+ schedule_command,
+ browse_command,
+ search_command,
+ updates_command,
+ backup_command,
+ quickreboot_command,
+ reboot_command,
+ sleep_command,
+ wakeup_command,
+ quota_command,
+ schedule_callback,
+ browse_callback,
+ advanced_power_callback
+)
+from src.utils.admin_utils import (
+ add_admin,
+ remove_admin,
+ list_admins
+)
+from src.utils.logger import setup_logging
+
+async def shutdown(application: Application) -> None:
+ """Корректное завершение работы бота"""
+ logger = logging.getLogger(__name__)
+ logger.info("Stopping Synology Power Control Bot...")
+
+ # Останавливаем прием обновлений
+ await application.stop()
+ logger.info("Bot stopped successfully")
+
+def signal_handler(sig, frame, application=None):
+ """Обработчик сигналов для корректного завершения"""
+ logger = logging.getLogger(__name__)
+ logger.info(f"Received signal {sig}, shutting down gracefully")
+
+ if application:
+ # Создаем и запускаем задачу завершения в event loop
+ loop = asyncio.get_event_loop()
+ if loop.is_running():
+ loop.create_task(shutdown(application))
+ else:
+ loop.run_until_complete(shutdown(application))
+
+ sys.exit(0)
+
+def main() -> None:
+ """Основная функция запуска бота"""
+ # Настройка логирования
+ setup_logging()
+ logger = logging.getLogger(__name__)
+
+ # Проверка наличия токена
+ if not TELEGRAM_TOKEN:
+ logger.error("Telegram token is not set! Please configure TELEGRAM_TOKEN in .env file.")
+ return
+
+ # Создание и настройка приложения бота
+ logger.info("Starting Synology Power Control Bot")
+ application = Application.builder().token(TELEGRAM_TOKEN).build()
+
+ # Регистрация обработчиков команд
+ application.add_handler(CommandHandler("start", start_command))
+ application.add_handler(CommandHandler("help", help_command))
+ application.add_handler(CommandHandler("status", status_command))
+ application.add_handler(CommandHandler("power", power_command))
+
+ # Регистрация расширенных обработчиков команд
+ application.add_handler(CommandHandler("storage", storage_command))
+ application.add_handler(CommandHandler("shares", shares_command))
+ application.add_handler(CommandHandler("system", system_command))
+ application.add_handler(CommandHandler("load", load_command))
+ application.add_handler(CommandHandler("security", security_command))
+ application.add_handler(CommandHandler("checkapi", check_api_command))
+
+ # Регистрация продвинутых обработчиков команд
+ application.add_handler(CommandHandler("processes", processes_command))
+ application.add_handler(CommandHandler("network", network_command))
+ application.add_handler(CommandHandler("temperature", temperature_command))
+ application.add_handler(CommandHandler("schedule", schedule_command))
+ application.add_handler(CommandHandler("browse", browse_command))
+ application.add_handler(CommandHandler("search", search_command))
+ application.add_handler(CommandHandler("updates", updates_command))
+ application.add_handler(CommandHandler("backup", backup_command))
+ application.add_handler(CommandHandler("quickreboot", quickreboot_command))
+ application.add_handler(CommandHandler("reboot", reboot_command))
+ application.add_handler(CommandHandler("sleep", sleep_command))
+ application.add_handler(CommandHandler("wakeup", wakeup_command))
+ application.add_handler(CommandHandler("quota", quota_command))
+
+ # Регистрация обработчиков для управления администраторами
+ application.add_handler(CommandHandler("addadmin", add_admin))
+ application.add_handler(CommandHandler("removeadmin", remove_admin))
+ application.add_handler(CommandHandler("admins", list_admins))
+
+ # Регистрация обработчиков callback-запросов
+ # Сначала обрабатываем более специфичные паттерны
+ application.add_handler(CallbackQueryHandler(advanced_power_callback, pattern="^(confirm|cancel)_(reboot|sleep)$")) # Для advanced_handlers.py
+ # Затем более общие паттерны
+ application.add_handler(CallbackQueryHandler(power_callback, pattern="^power_|^reboot$|^cancel$")) # Для command_handlers.py
+ application.add_handler(CallbackQueryHandler(schedule_callback, pattern="^schedule_"))
+ application.add_handler(CallbackQueryHandler(browse_callback, pattern="^browse_"))
+
+ # Настройка обработчиков сигналов для корректного завершения
+ signal.signal(signal.SIGINT, lambda sig, frame: signal_handler(sig, frame, application))
+ signal.signal(signal.SIGTERM, lambda sig, frame: signal_handler(sig, frame, application))
+
+ # Запуск бота
+ logger.info("Bot started. Press Ctrl+C to stop.")
+ application.run_polling(allowed_updates=["message", "callback_query"])
+
+if __name__ == "__main__":
+ main()
diff --git a/.history/src/bot_20250830110906.py b/.history/src/bot_20250830110906.py
new file mode 100644
index 0000000..58a915d
--- /dev/null
+++ b/.history/src/bot_20250830110906.py
@@ -0,0 +1,154 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Главный модуль запуска телеграм-бота для управления Synology NAS
+"""
+
+import os
+import signal
+import sys
+import asyncio
+import logging
+from telegram.ext import (
+ Application,
+ CommandHandler,
+ CallbackQueryHandler,
+ MessageHandler,
+ filters
+)
+
+from src.config.config import TELEGRAM_TOKEN
+from src.handlers.help_handlers import (
+ start_command,
+ help_command
+)
+from src.handlers.command_handlers import (
+ status_command,
+ power_command,
+ power_callback
+)
+from src.handlers.extended_handlers import (
+ storage_command,
+ shares_command,
+ system_command,
+ load_command,
+ security_command,
+ check_api_command
+)
+from src.handlers.advanced_handlers import (
+ processes_command,
+ network_command,
+ temperature_command,
+ schedule_command,
+ browse_command,
+ search_command,
+ updates_command,
+ backup_command,
+ quickreboot_command,
+ reboot_command,
+ sleep_command,
+ wakeup_command,
+ quota_command,
+ schedule_callback,
+ browse_callback,
+ advanced_power_callback
+)
+from src.utils.admin_utils import (
+ add_admin,
+ remove_admin,
+ list_admins
+)
+from src.utils.logger import setup_logging
+
+async def shutdown(application: Application) -> None:
+ """Корректное завершение работы бота"""
+ logger = logging.getLogger(__name__)
+ logger.info("Stopping Synology Power Control Bot...")
+
+ # Останавливаем прием обновлений
+ await application.stop()
+ logger.info("Bot stopped successfully")
+
+def signal_handler(sig, frame, application=None):
+ """Обработчик сигналов для корректного завершения"""
+ logger = logging.getLogger(__name__)
+ logger.info(f"Received signal {sig}, shutting down gracefully")
+
+ if application:
+ # Создаем и запускаем задачу завершения в event loop
+ loop = asyncio.get_event_loop()
+ if loop.is_running():
+ loop.create_task(shutdown(application))
+ else:
+ loop.run_until_complete(shutdown(application))
+
+ sys.exit(0)
+
+def main() -> None:
+ """Основная функция запуска бота"""
+ # Настройка логирования
+ setup_logging()
+ logger = logging.getLogger(__name__)
+
+ # Проверка наличия токена
+ if not TELEGRAM_TOKEN:
+ logger.error("Telegram token is not set! Please configure TELEGRAM_TOKEN in .env file.")
+ return
+
+ # Создание и настройка приложения бота
+ logger.info("Starting Synology Power Control Bot")
+ application = Application.builder().token(TELEGRAM_TOKEN).build()
+
+ # Регистрация обработчиков команд
+ application.add_handler(CommandHandler("start", start_command))
+ application.add_handler(CommandHandler("help", help_command))
+ application.add_handler(CommandHandler("status", status_command))
+ application.add_handler(CommandHandler("power", power_command))
+
+ # Регистрация расширенных обработчиков команд
+ application.add_handler(CommandHandler("storage", storage_command))
+ application.add_handler(CommandHandler("shares", shares_command))
+ application.add_handler(CommandHandler("system", system_command))
+ application.add_handler(CommandHandler("load", load_command))
+ application.add_handler(CommandHandler("security", security_command))
+ application.add_handler(CommandHandler("checkapi", check_api_command))
+
+ # Регистрация продвинутых обработчиков команд
+ application.add_handler(CommandHandler("processes", processes_command))
+ application.add_handler(CommandHandler("network", network_command))
+ application.add_handler(CommandHandler("temperature", temperature_command))
+ application.add_handler(CommandHandler("schedule", schedule_command))
+ application.add_handler(CommandHandler("browse", browse_command))
+ application.add_handler(CommandHandler("search", search_command))
+ application.add_handler(CommandHandler("updates", updates_command))
+ application.add_handler(CommandHandler("backup", backup_command))
+ application.add_handler(CommandHandler("quickreboot", quickreboot_command))
+ application.add_handler(CommandHandler("reboot", reboot_command))
+ application.add_handler(CommandHandler("sleep", sleep_command))
+ application.add_handler(CommandHandler("wakeup", wakeup_command))
+ application.add_handler(CommandHandler("quota", quota_command))
+
+ # Регистрация обработчиков для управления администраторами
+ application.add_handler(CommandHandler("addadmin", add_admin))
+ application.add_handler(CommandHandler("removeadmin", remove_admin))
+ application.add_handler(CommandHandler("admins", list_admins))
+
+ # Регистрация обработчиков callback-запросов
+ # Сначала обрабатываем более специфичные паттерны
+ application.add_handler(CallbackQueryHandler(advanced_power_callback, pattern="^(confirm|cancel)_(reboot|sleep)$")) # Для advanced_handlers.py
+ # Затем более общие паттерны
+ application.add_handler(CallbackQueryHandler(power_callback, pattern="^power_|^reboot$|^cancel$")) # Для command_handlers.py
+ application.add_handler(CallbackQueryHandler(schedule_callback, pattern="^schedule_"))
+ application.add_handler(CallbackQueryHandler(browse_callback, pattern="^browse_"))
+
+ # Настройка обработчиков сигналов для корректного завершения
+ signal.signal(signal.SIGINT, lambda sig, frame: signal_handler(sig, frame, application))
+ signal.signal(signal.SIGTERM, lambda sig, frame: signal_handler(sig, frame, application))
+
+ # Запуск бота
+ logger.info("Bot started. Press Ctrl+C to stop.")
+ application.run_polling(allowed_updates=["message", "callback_query"])
+
+if __name__ == "__main__":
+ main()
diff --git a/.history/src/handlers/advanced_handlers_20250830104205.py b/.history/src/handlers/advanced_handlers_20250830104205.py
new file mode 100644
index 0000000..315405b
--- /dev/null
+++ b/.history/src/handlers/advanced_handlers_20250830104205.py
@@ -0,0 +1,972 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Расширенные обработчики команд для управления Synology NAS
+"""
+
+import logging
+from datetime import datetime
+from typing import List, Dict, Any
+from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
+from telegram.ext import ContextTypes
+
+from src.config.config import ADMIN_USER_IDS
+from src.api.synology import SynologyAPI
+
+logger = logging.getLogger(__name__)
+
+# Инициализация API Synology
+synology_api = SynologyAPI()
+
+async def processes_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /processes для получения списка активных процессов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о запущенных процессах...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о процессах.", parse_mode="HTML")
+ return
+
+ try:
+ processes = synology_api.get_processes(limit=15) # Получаем топ-15 процессов
+
+ if not processes:
+ await message.edit_text("❌ Ошибка получения информации о процессах\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о процессах\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о процессах
+ reply_text = f"⚙️ Активные процессы Synology NAS\n\n"
+
+ for process in processes:
+ name = process.get("name", "unknown")
+ pid = process.get("pid", "?")
+ cpu_usage = process.get("cpu_usage", 0)
+ memory_usage = process.get("memory_usage", 0)
+
+ reply_text += f"• {name} (PID: {pid})\n"
+ reply_text += f" └ CPU: {cpu_usage:.1f}%, Память: {memory_usage:.1f}%\n"
+
+ reply_text += f"\nПоказано {len(processes)} наиболее активных процессов"
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def network_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /network для получения информации о сетевых подключениях"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о сетевых подключениях...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о сетевых подключениях.", parse_mode="HTML")
+ return
+
+ try:
+ network_status = synology_api.get_network_status()
+
+ if not network_status:
+ await message.edit_text("❌ Ошибка получения информации о сетевых подключениях\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о сетевых подключениях\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о сетевых интерфейсах
+ interfaces = network_status.get("interfaces", [])
+
+ reply_text = f"🌐 Сетевые подключения Synology NAS\n\n"
+
+ for interface in interfaces:
+ name = interface.get("id", "unknown")
+ ip = interface.get("ip", "Нет данных")
+ mac = interface.get("mac", "Нет данных")
+ status = "Активен" if interface.get("status") else "Неактивен"
+
+ # Информация о трафике
+ rx_bytes = interface.get("rx", 0) / (1024**2) # Перевод в МБ
+ tx_bytes = interface.get("tx", 0) / (1024**2) # Перевод в МБ
+
+ reply_text += f"• {name} ({status})\n"
+ reply_text += f" └ IP: {ip}, MAC: {mac}\n"
+
+ if rx_bytes > 0 or tx_bytes > 0:
+ reply_text += f" └ Получено: {rx_bytes:.2f} МБ, Отправлено: {tx_bytes:.2f} МБ\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def temperature_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /temperature для мониторинга температуры"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о температуре...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о температуре.", parse_mode="HTML")
+ return
+
+ try:
+ temp_status = synology_api.get_temperature_status()
+
+ if not temp_status:
+ await message.edit_text("❌ Ошибка получения информации о температуре\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о температуре\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о температуре
+ system_temp = temp_status.get("system_temperature")
+ disk_temps = temp_status.get("disk_temperatures", [])
+ is_warning = temp_status.get("warning", False)
+
+ # Выбор emoji в зависимости от температуры
+ temp_emoji = "🔥" if is_warning else "🌡️"
+
+ reply_text = f"{temp_emoji} Температура Synology NAS\n\n"
+
+ if system_temp is not None:
+ temp_status_text = "❗ ПОВЫШЕННАЯ" if is_warning else "✅ Нормальная"
+ reply_text += f"Температура системы: {system_temp}°C ({temp_status_text})\n\n"
+
+ if disk_temps:
+ reply_text += "Температура дисков:\n"
+ for disk in disk_temps:
+ name = disk.get("name", "unknown")
+ model = disk.get("model", "unknown")
+ temp = disk.get("temperature", 0)
+
+ disk_temp_emoji = "🔥" if temp > 45 else "✅"
+ reply_text += f"• {disk_temp_emoji} {name} ({model}): {temp}°C\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def schedule_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /schedule для управления расписанием питания"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о расписании питания...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о расписании питания.", parse_mode="HTML")
+ return
+
+ try:
+ schedule = synology_api.get_power_schedule()
+
+ if not schedule:
+ await message.edit_text("❌ Ошибка получения информации о расписании питания\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о расписании питания\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о расписании питания
+ boot_tasks = schedule.get("boot_tasks", [])
+ shutdown_tasks = schedule.get("shutdown_tasks", [])
+
+ reply_text = f"⏱️ Расписание питания Synology NAS\n\n"
+
+ if boot_tasks:
+ reply_text += "Расписание включения:\n"
+ for task in boot_tasks:
+ days = task.get("day", [])
+ time = task.get("time", "00:00")
+ enabled = task.get("enabled", False)
+
+ # Преобразуем номера дней в названия
+ day_names = []
+ for day in days:
+ if day == 0: day_names.append("Пн")
+ elif day == 1: day_names.append("Вт")
+ elif day == 2: day_names.append("Ср")
+ elif day == 3: day_names.append("Чт")
+ elif day == 4: day_names.append("Пт")
+ elif day == 5: day_names.append("Сб")
+ elif day == 6: day_names.append("Вс")
+
+ status = "✅ Активно" if enabled else "❌ Отключено"
+ day_str = ", ".join(day_names) if day_names else "Нет дней"
+
+ reply_text += f"• {status}: {time} ({day_str})\n"
+ else:
+ reply_text += "Расписание включения: Не настроено\n"
+
+ reply_text += "\n"
+
+ if shutdown_tasks:
+ reply_text += "Расписание выключения:\n"
+ for task in shutdown_tasks:
+ days = task.get("day", [])
+ time = task.get("time", "00:00")
+ enabled = task.get("enabled", False)
+
+ # Преобразуем номера дней в названия
+ day_names = []
+ for day in days:
+ if day == 0: day_names.append("Пн")
+ elif day == 1: day_names.append("Вт")
+ elif day == 2: day_names.append("Ср")
+ elif day == 3: day_names.append("Чт")
+ elif day == 4: day_names.append("Пт")
+ elif day == 5: day_names.append("Сб")
+ elif day == 6: day_names.append("Вс")
+
+ status = "✅ Активно" if enabled else "❌ Отключено"
+ day_str = ", ".join(day_names) if day_names else "Нет дней"
+
+ reply_text += f"• {status}: {time} ({day_str})\n"
+ else:
+ reply_text += "Расписание выключения: Не настроено\n"
+
+ # Добавляем кнопки для управления расписанием
+ keyboard = [
+ [
+ InlineKeyboardButton("➕ Добавить включение", callback_data="schedule_add_boot"),
+ InlineKeyboardButton("➕ Добавить выключение", callback_data="schedule_add_shutdown")
+ ],
+ [
+ InlineKeyboardButton("🗑️ Удалить расписание", callback_data="schedule_delete")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+
+async def browse_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /browse для просмотра файлов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Получаем путь из аргументов команды или используем корневую директорию
+ path = " ".join(context.args) if context.args else ""
+
+ message = await update.message.reply_text(f"⏳ Получение содержимого {'папки ' + path if path else 'общих папок'}...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить список файлов.", parse_mode="HTML")
+ return
+
+ try:
+ browse_result = synology_api.browse_files(folder_path=path)
+
+ if not browse_result.get("success", False):
+ error = browse_result.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ items = browse_result.get("items", [])
+ current_path = browse_result.get("path", "")
+ is_root = browse_result.get("is_root", True)
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о файлах и папках
+ if is_root:
+ reply_text = f"📁 Общие папки Synology NAS\n\n"
+ else:
+ reply_text = f"📁 Содержимое папки\n{current_path}\n\n"
+
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ files = []
+
+ for item in items:
+ if is_root: # Для корневого уровня все элементы - это общие папки
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ folders.append((name, path, True))
+ else:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path, False))
+ else:
+ # Для файлов получаем размер
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ files.append((name, path, size_str))
+
+ # Добавляем папки в сообщение
+ if folders:
+ for name, path, is_share in folders:
+ # Для общих папок добавляем иконку дома
+ icon = "🏠" if is_share else "📁"
+ reply_text += f"{icon} {name}\n"
+
+ # Добавляем файлы в сообщение
+ if files:
+ for name, path, size in files:
+ # Выбираем иконку в зависимости от расширения
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+
+ # Если нет элементов для отображения
+ if not folders and not files:
+ reply_text += "📭 Папка пуста\n"
+
+ # Добавляем кнопку возврата наверх, если мы не в корне
+ if not is_root:
+ # Определяем родительскую директорию
+ parent_path = "/".join(current_path.split("/")[:-1]) if "/" in current_path else ""
+
+ keyboard = [
+ [InlineKeyboardButton("🔼 Вернуться на уровень выше", callback_data=f"browse_{parent_path}")]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+ else:
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def search_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /search для поиска файлов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Получаем шаблон поиска из аргументов команды
+ if not context.args:
+ await update.message.reply_text("❌ Укажите шаблон для поиска: /search <шаблон>")
+ return
+
+ pattern = " ".join(context.args)
+
+ message = await update.message.reply_text(f"⏳ Поиск файлов по шаблону «{pattern}»...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить поиск файлов.", parse_mode="HTML")
+ return
+
+ try:
+ search_result = synology_api.search_files(pattern=pattern, limit=20)
+
+ if not search_result.get("success", False):
+ error = search_result.get("error", "unknown")
+ progress = search_result.get("progress", 0)
+
+ if error == "search_timeout":
+ await message.edit_text(f"❌ Превышено время ожидания результатов поиска\n\nПроцесс поиска выполнен на {progress}%", parse_mode="HTML")
+ else:
+ await message.edit_text(f"❌ Ошибка при поиске файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ files = search_result.get("results", [])
+ total = search_result.get("total", len(files))
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при поиске файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение с результатами поиска
+ reply_text = f"🔍 Результаты поиска по шаблону «{pattern}»\n\n"
+
+ if not files:
+ reply_text += "📭 Файлы не найдены"
+ else:
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ found_files = []
+
+ for item in files:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path))
+ else:
+ # Для файлов получаем размер и путь к родительской папке
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ parent_path = "/".join(path.split("/")[:-1])
+ found_files.append((name, path, size_str, parent_path))
+
+ # Добавляем папки в сообщение
+ if folders:
+ reply_text += "Найденные папки:\n"
+ for name, path in folders[:5]: # Показываем первые 5 папок
+ reply_text += f"📁 {name}\n"
+
+ if len(folders) > 5:
+ reply_text += f"...и еще {len(folders) - 5} папок\n"
+
+ reply_text += "\n"
+
+ # Добавляем файлы в сообщение
+ if found_files:
+ reply_text += "Найденные файлы:\n"
+ for name, path, size, parent in found_files[:10]: # Показываем первые 10 файлов
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+ reply_text += f" Путь: .../{path.split('/')[-2]}/\n"
+
+ if len(found_files) > 10:
+ reply_text += f"...и еще {len(found_files) - 10} файлов\n"
+
+ # Добавляем информацию о общем количестве результатов
+ reply_text += f"\nВсего найдено: {total} элементов"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def updates_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /updates для проверки обновлений"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Проверка доступных обновлений...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно проверить наличие обновлений.", parse_mode="HTML")
+ return
+
+ try:
+ update_info = synology_api.check_for_updates()
+
+ if not update_info.get("success", False):
+ error = update_info.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка при проверке обновлений\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ current_version = update_info.get("current_version", "unknown")
+ update_available = update_info.get("update_available", False)
+ auto_update = update_info.get("auto_update_enabled", False)
+ updates = update_info.get("updates", [])
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при проверке обновлений\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение об обновлениях
+ if update_available:
+ reply_text = f"🔄 Доступны обновления DSM\n\n"
+ reply_text += f"Текущая версия: {current_version}\n"
+ reply_text += f"Автообновление: {'✅ Включено' if auto_update else '❌ Отключено'}\n\n"
+ reply_text += "Доступные обновления:\n"
+
+ for update_item in updates:
+ update_name = update_item.get("name", "unknown")
+ update_version = update_item.get("version", "unknown")
+ update_size = update_item.get("size", 0)
+ update_size_str = format_size(update_size)
+
+ reply_text += f"• {update_name} v{update_version}\n"
+ reply_text += f" └ Размер: {update_size_str}\n"
+ else:
+ reply_text = f"✅ Система в актуальном состоянии\n\n"
+ reply_text += f"Текущая версия: {current_version}\n"
+ reply_text += f"Автообновление: {'✅ Включено' if auto_update else '❌ Отключено'}\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def backup_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /backup для управления резервным копированием"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о резервном копировании...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о резервном копировании.", parse_mode="HTML")
+ return
+
+ try:
+ backup_status = synology_api.get_backup_status()
+
+ if not backup_status.get("success", False):
+ error = backup_status.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения информации о резервном копировании\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ backups = backup_status.get("backups", {})
+ api_status = backup_status.get("available_apis", {})
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о резервном копировании\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о резервном копировании
+ reply_text = f"💾 Резервное копирование Synology NAS\n\n"
+
+ # Информация о Hyper Backup
+ hyper_backups = backups.get("hyper_backup", [])
+ hyper_api_available = api_status.get("hyper_backup", False)
+
+ if hyper_api_available:
+ reply_text += "Hyper Backup:\n"
+
+ if hyper_backups:
+ for backup in hyper_backups:
+ name = backup.get("name", "unknown")
+ status = backup.get("status", "unknown")
+ last_backup = backup.get("last_backup", "never")
+
+ status_emoji = "✅" if status.lower() == "success" else "⚠️"
+ reply_text += f"• {status_emoji} {name}\n"
+ reply_text += f" └ Последнее копирование: {last_backup}\n"
+ else:
+ reply_text += "Задачи Hyper Backup не настроены\n"
+
+ reply_text += "\n"
+
+ # Информация о Time Backup
+ time_backups = backups.get("time_backup", [])
+ time_api_available = api_status.get("time_backup", False)
+
+ if time_api_available:
+ reply_text += "Time Backup:\n"
+
+ if time_backups:
+ for backup in time_backups:
+ name = backup.get("name", "unknown")
+ status = backup.get("status", "unknown")
+
+ status_emoji = "✅" if status.lower() == "normal" else "⚠️"
+ reply_text += f"• {status_emoji} {name}\n"
+ else:
+ reply_text += "Задачи Time Backup не настроены\n"
+
+ reply_text += "\n"
+
+ # Информация о USB Copy
+ usb_copy = backups.get("usb_copy", {})
+ usb_api_available = api_status.get("usb_copy", False)
+
+ if usb_api_available:
+ usb_enabled = usb_copy.get("enabled", False)
+ usb_status = "✅ Включено" if usb_enabled else "❌ Отключено"
+
+ reply_text += f"USB Copy: {usb_status}\n\n"
+
+ # Если ни один из API не доступен
+ if not any(api_status.values()):
+ reply_text += "API для резервного копирования не доступны на вашем NAS или требуют дополнительных прав доступа.\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def reboot_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /reboot для перезагрузки NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Добавляем подтверждение перед перезагрузкой
+ keyboard = [
+ [
+ InlineKeyboardButton("✅ Да, перезагрузить", callback_data="confirm_reboot"),
+ InlineKeyboardButton("❌ Отмена", callback_data="cancel_reboot")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await update.message.reply_text(
+ "⚠️ Вы уверены, что хотите перезагрузить Synology NAS?\n\n"
+ "Это действие может привести к прерыванию работы всех сервисов.",
+ parse_mode="HTML",
+ reply_markup=reply_markup
+ )
+
+async def sleep_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /sleep для перевода NAS в спящий режим"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Добавляем подтверждение перед отправкой в спящий режим
+ keyboard = [
+ [
+ InlineKeyboardButton("✅ Да, усыпить", callback_data="confirm_sleep"),
+ InlineKeyboardButton("❌ Отмена", callback_data="cancel_sleep")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await update.message.reply_text(
+ "⚠️ Вы уверены, что хотите перевести Synology NAS в спящий режим?\n\n"
+ "Это действие приведет к остановке всех сервисов и отключению NAS.",
+ parse_mode="HTML",
+ reply_markup=reply_markup
+ )
+
+async def quickreboot_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /quickreboot для быстрой перезагрузки NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Выполняется перезагрузка Synology NAS...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить перезагрузку.", parse_mode="HTML")
+ return
+
+ try:
+ # Выполняем перезагрузку
+ result = synology_api.reboot_system()
+
+ if result:
+ # Формируем сообщение об успешной перезагрузке
+ reply_text = "🔄 Synology NAS перезагружается\n\n"
+ reply_text += "Перезагрузка обычно занимает 3-5 минут. После перезагрузки NAS будет снова доступен."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при выполнении перезагрузки\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при выполнении перезагрузки\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+async def wakeup_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /wakeup для включения NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Отправка пакета Wake-on-LAN для включения Synology NAS...")
+
+ # Проверяем, не включен ли NAS уже
+ if synology_api.is_online(force_check=True):
+ await message.edit_text("ℹ️ Synology NAS уже включен\n\nНет необходимости отправлять сигнал пробуждения.", parse_mode="HTML")
+ return
+
+ try:
+ # Отправляем сигнал пробуждения
+ result = synology_api.power_on()
+
+ if result:
+ # Формируем сообщение об успешном включении
+ reply_text = "✅ Synology NAS успешно включен\n\n"
+ reply_text += "NAS полностью готов к работе."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при включении NAS\n\nВозможные причины:\n- Функция Wake-on-LAN не настроена на NAS\n- Неверно указан MAC-адрес\n- Проблемы с сетевым подключением", parse_mode="HTML")
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при включении NAS\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+async def quota_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /quota для просмотра информации о квотах"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о квотах пользователей...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о квотах.", parse_mode="HTML")
+ return
+
+ try:
+ quota_info = synology_api.get_quota_info()
+
+ if not quota_info.get("success", False):
+ error = quota_info.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения информации о квотах\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ user_quotas = quota_info.get("user_quotas", [])
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о квотах\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о квотах
+ reply_text = f"📊 Квоты пользователей Synology NAS\n\n"
+
+ if not user_quotas:
+ reply_text += "Квоты пользователей не настроены или недоступны"
+ else:
+ for user_quota in user_quotas:
+ user = user_quota.get("user", "unknown")
+ quotas = user_quota.get("quotas", [])
+
+ if quotas:
+ reply_text += f"Пользователь {user}:\n"
+
+ for quota in quotas:
+ volume = quota.get("volume_name", "unknown")
+ limit = quota.get("limit", 0)
+ used = quota.get("used", 0)
+
+ # Переводим байты в ГБ
+ limit_gb = limit / (1024**3) if limit > 0 else 0
+ used_gb = used / (1024**3)
+
+ # Рассчитываем процент использования
+ if limit_gb > 0:
+ usage_percent = (used_gb / limit_gb) * 100
+ reply_text += f"• Том {volume}: {used_gb:.2f} ГБ из {limit_gb:.2f} ГБ ({usage_percent:.1f}%)\n"
+ else:
+ reply_text += f"• Том {volume}: {used_gb:.2f} ГБ (без ограничений)\n"
+
+ reply_text += "\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def schedule_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для управления расписанием питания"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action.startswith("schedule_"):
+ action_type = action.split("_")[1]
+
+ if action_type == "add_boot":
+ # Логика добавления расписания включения
+ # В реальном боте здесь будет диалог для настройки расписания
+ await query.edit_message_text("⚙️ Добавление расписания включения\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+ elif action_type == "add_shutdown":
+ # Логика добавления расписания выключения
+ await query.edit_message_text("⚙️ Добавление расписания выключения\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+ elif action_type == "delete":
+ # Логика удаления расписания
+ await query.edit_message_text("⚙️ Удаление расписания\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+async def browse_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для навигации по файловой системе"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action.startswith("browse_"):
+ path = action[7:] # Убираем префикс "browse_"
+
+ # Используем команду browse с указанным путем
+ message = await query.edit_message_text(f"⏳ Получение содержимого {'папки ' + path if path else 'общих папок'}...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить список файлов.", parse_mode="HTML")
+ return
+
+ try:
+ browse_result = synology_api.browse_files(folder_path=path)
+
+ if not browse_result.get("success", False):
+ error = browse_result.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ items = browse_result.get("items", [])
+ current_path = browse_result.get("path", "")
+ is_root = browse_result.get("is_root", True)
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о файлах и папках (аналогично функции browse_command)
+ if is_root:
+ reply_text = f"📁 Общие папки Synology NAS\n\n"
+ else:
+ reply_text = f"📁 Содержимое папки\n{current_path}\n\n"
+
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ files = []
+
+ for item in items:
+ if is_root: # Для корневого уровня все элементы - это общие папки
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ folders.append((name, path, True))
+ else:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path, False))
+ else:
+ # Для файлов получаем размер
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ files.append((name, path, size_str))
+
+ # Добавляем папки в сообщение
+ if folders:
+ for name, path, is_share in folders:
+ # Для общих папок добавляем иконку дома
+ icon = "🏠" if is_share else "📁"
+ reply_text += f"{icon} {name}\n"
+
+ # Добавляем файлы в сообщение
+ if files:
+ for name, path, size in files:
+ # Выбираем иконку в зависимости от расширения
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+
+ # Если нет элементов для отображения
+ if not folders and not files:
+ reply_text += "📭 Папка пуста\n"
+
+ # Добавляем кнопку возврата наверх, если мы не в корне
+ if not is_root:
+ # Определяем родительскую директорию
+ parent_path = "/".join(current_path.split("/")[:-1]) if "/" in current_path else ""
+
+ keyboard = [
+ [InlineKeyboardButton("🔼 Вернуться на уровень выше", callback_data=f"browse_{parent_path}")]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+ else:
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def advanced_power_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для управления питанием"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action == "confirm_reboot":
+ # Выполняем перезагрузку
+ message = await query.edit_message_text("⏳ Выполняется перезагрузка Synology NAS...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить перезагрузку.", parse_mode="HTML")
+ return
+
+ try:
+ result = synology_api.reboot_system()
+
+ if result:
+ reply_text = "🔄 Synology NAS перезагружается\n\n"
+ reply_text += "Перезагрузка обычно занимает 3-5 минут. После перезагрузки NAS будет снова доступен."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при выполнении перезагрузки\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при выполнении перезагрузки\n\nПричина: {str(e)}", parse_mode="HTML")
+
+ elif action == "cancel_reboot":
+ # Отменяем перезагрузку
+ await query.edit_message_text("✅ Перезагрузка отменена", parse_mode="HTML")
+
+ elif action == "confirm_sleep":
+ # Выполняем переход в спящий режим (выключение)
+ message = await query.edit_message_text("⏳ Перевод Synology NAS в спящий режим...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS уже оффлайн\n\nНевозможно выполнить переход в спящий режим.", parse_mode="HTML")
+ return
+
+ try:
+ result = synology_api.power_off()
+
+ if result:
+ reply_text = "💤 Synology NAS переведен в спящий режим\n\n"
+ reply_text += "Для пробуждения используйте команду /wakeup"
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при переходе в спящий режим\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при переходе в спящий режим\n\nПричина: {str(e)}", parse_mode="HTML")
+
+ elif action == "cancel_sleep":
+ # Отменяем переход в спящий режим
+ await query.edit_message_text("✅ Переход в спящий режим отменен", parse_mode="HTML")
+
+# Вспомогательные функции
+
+def format_size(size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат"""
+ if size_bytes < 1024:
+ return f"{size_bytes} Б"
+ elif size_bytes < 1024**2:
+ return f"{size_bytes/1024:.1f} КБ"
+ elif size_bytes < 1024**3:
+ return f"{size_bytes/1024**2:.1f} МБ"
+ else:
+ return f"{size_bytes/1024**3:.1f} ГБ"
+
+def get_file_icon(filename: str) -> str:
+ """Возвращает эмодзи-иконку в зависимости от типа файла"""
+ extension = filename.lower().split('.')[-1] if '.' in filename else ''
+
+ if extension in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp']:
+ return "🖼️"
+ elif extension in ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv']:
+ return "🎬"
+ elif extension in ['mp3', 'wav', 'ogg', 'flac', 'aac']:
+ return "🎵"
+ elif extension in ['doc', 'docx', 'txt', 'rtf', 'odt']:
+ return "📄"
+ elif extension in ['xls', 'xlsx', 'csv']:
+ return "📊"
+ elif extension in ['ppt', 'pptx']:
+ return "📑"
+ elif extension in ['pdf']:
+ return "📕"
+ elif extension in ['zip', 'rar', '7z', 'tar', 'gz']:
+ return "🗜️"
+ elif extension in ['exe', 'msi']:
+ return "⚙️"
+ else:
+ return "📄"
diff --git a/.history/src/handlers/advanced_handlers_20250830104340.py b/.history/src/handlers/advanced_handlers_20250830104340.py
new file mode 100644
index 0000000..315405b
--- /dev/null
+++ b/.history/src/handlers/advanced_handlers_20250830104340.py
@@ -0,0 +1,972 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Расширенные обработчики команд для управления Synology NAS
+"""
+
+import logging
+from datetime import datetime
+from typing import List, Dict, Any
+from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
+from telegram.ext import ContextTypes
+
+from src.config.config import ADMIN_USER_IDS
+from src.api.synology import SynologyAPI
+
+logger = logging.getLogger(__name__)
+
+# Инициализация API Synology
+synology_api = SynologyAPI()
+
+async def processes_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /processes для получения списка активных процессов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о запущенных процессах...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о процессах.", parse_mode="HTML")
+ return
+
+ try:
+ processes = synology_api.get_processes(limit=15) # Получаем топ-15 процессов
+
+ if not processes:
+ await message.edit_text("❌ Ошибка получения информации о процессах\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о процессах\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о процессах
+ reply_text = f"⚙️ Активные процессы Synology NAS\n\n"
+
+ for process in processes:
+ name = process.get("name", "unknown")
+ pid = process.get("pid", "?")
+ cpu_usage = process.get("cpu_usage", 0)
+ memory_usage = process.get("memory_usage", 0)
+
+ reply_text += f"• {name} (PID: {pid})\n"
+ reply_text += f" └ CPU: {cpu_usage:.1f}%, Память: {memory_usage:.1f}%\n"
+
+ reply_text += f"\nПоказано {len(processes)} наиболее активных процессов"
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def network_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /network для получения информации о сетевых подключениях"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о сетевых подключениях...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о сетевых подключениях.", parse_mode="HTML")
+ return
+
+ try:
+ network_status = synology_api.get_network_status()
+
+ if not network_status:
+ await message.edit_text("❌ Ошибка получения информации о сетевых подключениях\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о сетевых подключениях\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о сетевых интерфейсах
+ interfaces = network_status.get("interfaces", [])
+
+ reply_text = f"🌐 Сетевые подключения Synology NAS\n\n"
+
+ for interface in interfaces:
+ name = interface.get("id", "unknown")
+ ip = interface.get("ip", "Нет данных")
+ mac = interface.get("mac", "Нет данных")
+ status = "Активен" if interface.get("status") else "Неактивен"
+
+ # Информация о трафике
+ rx_bytes = interface.get("rx", 0) / (1024**2) # Перевод в МБ
+ tx_bytes = interface.get("tx", 0) / (1024**2) # Перевод в МБ
+
+ reply_text += f"• {name} ({status})\n"
+ reply_text += f" └ IP: {ip}, MAC: {mac}\n"
+
+ if rx_bytes > 0 or tx_bytes > 0:
+ reply_text += f" └ Получено: {rx_bytes:.2f} МБ, Отправлено: {tx_bytes:.2f} МБ\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def temperature_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /temperature для мониторинга температуры"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о температуре...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о температуре.", parse_mode="HTML")
+ return
+
+ try:
+ temp_status = synology_api.get_temperature_status()
+
+ if not temp_status:
+ await message.edit_text("❌ Ошибка получения информации о температуре\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о температуре\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о температуре
+ system_temp = temp_status.get("system_temperature")
+ disk_temps = temp_status.get("disk_temperatures", [])
+ is_warning = temp_status.get("warning", False)
+
+ # Выбор emoji в зависимости от температуры
+ temp_emoji = "🔥" if is_warning else "🌡️"
+
+ reply_text = f"{temp_emoji} Температура Synology NAS\n\n"
+
+ if system_temp is not None:
+ temp_status_text = "❗ ПОВЫШЕННАЯ" if is_warning else "✅ Нормальная"
+ reply_text += f"Температура системы: {system_temp}°C ({temp_status_text})\n\n"
+
+ if disk_temps:
+ reply_text += "Температура дисков:\n"
+ for disk in disk_temps:
+ name = disk.get("name", "unknown")
+ model = disk.get("model", "unknown")
+ temp = disk.get("temperature", 0)
+
+ disk_temp_emoji = "🔥" if temp > 45 else "✅"
+ reply_text += f"• {disk_temp_emoji} {name} ({model}): {temp}°C\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def schedule_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /schedule для управления расписанием питания"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о расписании питания...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о расписании питания.", parse_mode="HTML")
+ return
+
+ try:
+ schedule = synology_api.get_power_schedule()
+
+ if not schedule:
+ await message.edit_text("❌ Ошибка получения информации о расписании питания\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о расписании питания\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о расписании питания
+ boot_tasks = schedule.get("boot_tasks", [])
+ shutdown_tasks = schedule.get("shutdown_tasks", [])
+
+ reply_text = f"⏱️ Расписание питания Synology NAS\n\n"
+
+ if boot_tasks:
+ reply_text += "Расписание включения:\n"
+ for task in boot_tasks:
+ days = task.get("day", [])
+ time = task.get("time", "00:00")
+ enabled = task.get("enabled", False)
+
+ # Преобразуем номера дней в названия
+ day_names = []
+ for day in days:
+ if day == 0: day_names.append("Пн")
+ elif day == 1: day_names.append("Вт")
+ elif day == 2: day_names.append("Ср")
+ elif day == 3: day_names.append("Чт")
+ elif day == 4: day_names.append("Пт")
+ elif day == 5: day_names.append("Сб")
+ elif day == 6: day_names.append("Вс")
+
+ status = "✅ Активно" if enabled else "❌ Отключено"
+ day_str = ", ".join(day_names) if day_names else "Нет дней"
+
+ reply_text += f"• {status}: {time} ({day_str})\n"
+ else:
+ reply_text += "Расписание включения: Не настроено\n"
+
+ reply_text += "\n"
+
+ if shutdown_tasks:
+ reply_text += "Расписание выключения:\n"
+ for task in shutdown_tasks:
+ days = task.get("day", [])
+ time = task.get("time", "00:00")
+ enabled = task.get("enabled", False)
+
+ # Преобразуем номера дней в названия
+ day_names = []
+ for day in days:
+ if day == 0: day_names.append("Пн")
+ elif day == 1: day_names.append("Вт")
+ elif day == 2: day_names.append("Ср")
+ elif day == 3: day_names.append("Чт")
+ elif day == 4: day_names.append("Пт")
+ elif day == 5: day_names.append("Сб")
+ elif day == 6: day_names.append("Вс")
+
+ status = "✅ Активно" if enabled else "❌ Отключено"
+ day_str = ", ".join(day_names) if day_names else "Нет дней"
+
+ reply_text += f"• {status}: {time} ({day_str})\n"
+ else:
+ reply_text += "Расписание выключения: Не настроено\n"
+
+ # Добавляем кнопки для управления расписанием
+ keyboard = [
+ [
+ InlineKeyboardButton("➕ Добавить включение", callback_data="schedule_add_boot"),
+ InlineKeyboardButton("➕ Добавить выключение", callback_data="schedule_add_shutdown")
+ ],
+ [
+ InlineKeyboardButton("🗑️ Удалить расписание", callback_data="schedule_delete")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+
+async def browse_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /browse для просмотра файлов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Получаем путь из аргументов команды или используем корневую директорию
+ path = " ".join(context.args) if context.args else ""
+
+ message = await update.message.reply_text(f"⏳ Получение содержимого {'папки ' + path if path else 'общих папок'}...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить список файлов.", parse_mode="HTML")
+ return
+
+ try:
+ browse_result = synology_api.browse_files(folder_path=path)
+
+ if not browse_result.get("success", False):
+ error = browse_result.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ items = browse_result.get("items", [])
+ current_path = browse_result.get("path", "")
+ is_root = browse_result.get("is_root", True)
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о файлах и папках
+ if is_root:
+ reply_text = f"📁 Общие папки Synology NAS\n\n"
+ else:
+ reply_text = f"📁 Содержимое папки\n{current_path}\n\n"
+
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ files = []
+
+ for item in items:
+ if is_root: # Для корневого уровня все элементы - это общие папки
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ folders.append((name, path, True))
+ else:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path, False))
+ else:
+ # Для файлов получаем размер
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ files.append((name, path, size_str))
+
+ # Добавляем папки в сообщение
+ if folders:
+ for name, path, is_share in folders:
+ # Для общих папок добавляем иконку дома
+ icon = "🏠" if is_share else "📁"
+ reply_text += f"{icon} {name}\n"
+
+ # Добавляем файлы в сообщение
+ if files:
+ for name, path, size in files:
+ # Выбираем иконку в зависимости от расширения
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+
+ # Если нет элементов для отображения
+ if not folders and not files:
+ reply_text += "📭 Папка пуста\n"
+
+ # Добавляем кнопку возврата наверх, если мы не в корне
+ if not is_root:
+ # Определяем родительскую директорию
+ parent_path = "/".join(current_path.split("/")[:-1]) if "/" in current_path else ""
+
+ keyboard = [
+ [InlineKeyboardButton("🔼 Вернуться на уровень выше", callback_data=f"browse_{parent_path}")]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+ else:
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def search_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /search для поиска файлов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Получаем шаблон поиска из аргументов команды
+ if not context.args:
+ await update.message.reply_text("❌ Укажите шаблон для поиска: /search <шаблон>")
+ return
+
+ pattern = " ".join(context.args)
+
+ message = await update.message.reply_text(f"⏳ Поиск файлов по шаблону «{pattern}»...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить поиск файлов.", parse_mode="HTML")
+ return
+
+ try:
+ search_result = synology_api.search_files(pattern=pattern, limit=20)
+
+ if not search_result.get("success", False):
+ error = search_result.get("error", "unknown")
+ progress = search_result.get("progress", 0)
+
+ if error == "search_timeout":
+ await message.edit_text(f"❌ Превышено время ожидания результатов поиска\n\nПроцесс поиска выполнен на {progress}%", parse_mode="HTML")
+ else:
+ await message.edit_text(f"❌ Ошибка при поиске файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ files = search_result.get("results", [])
+ total = search_result.get("total", len(files))
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при поиске файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение с результатами поиска
+ reply_text = f"🔍 Результаты поиска по шаблону «{pattern}»\n\n"
+
+ if not files:
+ reply_text += "📭 Файлы не найдены"
+ else:
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ found_files = []
+
+ for item in files:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path))
+ else:
+ # Для файлов получаем размер и путь к родительской папке
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ parent_path = "/".join(path.split("/")[:-1])
+ found_files.append((name, path, size_str, parent_path))
+
+ # Добавляем папки в сообщение
+ if folders:
+ reply_text += "Найденные папки:\n"
+ for name, path in folders[:5]: # Показываем первые 5 папок
+ reply_text += f"📁 {name}\n"
+
+ if len(folders) > 5:
+ reply_text += f"...и еще {len(folders) - 5} папок\n"
+
+ reply_text += "\n"
+
+ # Добавляем файлы в сообщение
+ if found_files:
+ reply_text += "Найденные файлы:\n"
+ for name, path, size, parent in found_files[:10]: # Показываем первые 10 файлов
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+ reply_text += f" Путь: .../{path.split('/')[-2]}/\n"
+
+ if len(found_files) > 10:
+ reply_text += f"...и еще {len(found_files) - 10} файлов\n"
+
+ # Добавляем информацию о общем количестве результатов
+ reply_text += f"\nВсего найдено: {total} элементов"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def updates_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /updates для проверки обновлений"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Проверка доступных обновлений...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно проверить наличие обновлений.", parse_mode="HTML")
+ return
+
+ try:
+ update_info = synology_api.check_for_updates()
+
+ if not update_info.get("success", False):
+ error = update_info.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка при проверке обновлений\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ current_version = update_info.get("current_version", "unknown")
+ update_available = update_info.get("update_available", False)
+ auto_update = update_info.get("auto_update_enabled", False)
+ updates = update_info.get("updates", [])
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при проверке обновлений\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение об обновлениях
+ if update_available:
+ reply_text = f"🔄 Доступны обновления DSM\n\n"
+ reply_text += f"Текущая версия: {current_version}\n"
+ reply_text += f"Автообновление: {'✅ Включено' if auto_update else '❌ Отключено'}\n\n"
+ reply_text += "Доступные обновления:\n"
+
+ for update_item in updates:
+ update_name = update_item.get("name", "unknown")
+ update_version = update_item.get("version", "unknown")
+ update_size = update_item.get("size", 0)
+ update_size_str = format_size(update_size)
+
+ reply_text += f"• {update_name} v{update_version}\n"
+ reply_text += f" └ Размер: {update_size_str}\n"
+ else:
+ reply_text = f"✅ Система в актуальном состоянии\n\n"
+ reply_text += f"Текущая версия: {current_version}\n"
+ reply_text += f"Автообновление: {'✅ Включено' if auto_update else '❌ Отключено'}\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def backup_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /backup для управления резервным копированием"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о резервном копировании...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о резервном копировании.", parse_mode="HTML")
+ return
+
+ try:
+ backup_status = synology_api.get_backup_status()
+
+ if not backup_status.get("success", False):
+ error = backup_status.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения информации о резервном копировании\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ backups = backup_status.get("backups", {})
+ api_status = backup_status.get("available_apis", {})
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о резервном копировании\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о резервном копировании
+ reply_text = f"💾 Резервное копирование Synology NAS\n\n"
+
+ # Информация о Hyper Backup
+ hyper_backups = backups.get("hyper_backup", [])
+ hyper_api_available = api_status.get("hyper_backup", False)
+
+ if hyper_api_available:
+ reply_text += "Hyper Backup:\n"
+
+ if hyper_backups:
+ for backup in hyper_backups:
+ name = backup.get("name", "unknown")
+ status = backup.get("status", "unknown")
+ last_backup = backup.get("last_backup", "never")
+
+ status_emoji = "✅" if status.lower() == "success" else "⚠️"
+ reply_text += f"• {status_emoji} {name}\n"
+ reply_text += f" └ Последнее копирование: {last_backup}\n"
+ else:
+ reply_text += "Задачи Hyper Backup не настроены\n"
+
+ reply_text += "\n"
+
+ # Информация о Time Backup
+ time_backups = backups.get("time_backup", [])
+ time_api_available = api_status.get("time_backup", False)
+
+ if time_api_available:
+ reply_text += "Time Backup:\n"
+
+ if time_backups:
+ for backup in time_backups:
+ name = backup.get("name", "unknown")
+ status = backup.get("status", "unknown")
+
+ status_emoji = "✅" if status.lower() == "normal" else "⚠️"
+ reply_text += f"• {status_emoji} {name}\n"
+ else:
+ reply_text += "Задачи Time Backup не настроены\n"
+
+ reply_text += "\n"
+
+ # Информация о USB Copy
+ usb_copy = backups.get("usb_copy", {})
+ usb_api_available = api_status.get("usb_copy", False)
+
+ if usb_api_available:
+ usb_enabled = usb_copy.get("enabled", False)
+ usb_status = "✅ Включено" if usb_enabled else "❌ Отключено"
+
+ reply_text += f"USB Copy: {usb_status}\n\n"
+
+ # Если ни один из API не доступен
+ if not any(api_status.values()):
+ reply_text += "API для резервного копирования не доступны на вашем NAS или требуют дополнительных прав доступа.\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def reboot_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /reboot для перезагрузки NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Добавляем подтверждение перед перезагрузкой
+ keyboard = [
+ [
+ InlineKeyboardButton("✅ Да, перезагрузить", callback_data="confirm_reboot"),
+ InlineKeyboardButton("❌ Отмена", callback_data="cancel_reboot")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await update.message.reply_text(
+ "⚠️ Вы уверены, что хотите перезагрузить Synology NAS?\n\n"
+ "Это действие может привести к прерыванию работы всех сервисов.",
+ parse_mode="HTML",
+ reply_markup=reply_markup
+ )
+
+async def sleep_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /sleep для перевода NAS в спящий режим"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Добавляем подтверждение перед отправкой в спящий режим
+ keyboard = [
+ [
+ InlineKeyboardButton("✅ Да, усыпить", callback_data="confirm_sleep"),
+ InlineKeyboardButton("❌ Отмена", callback_data="cancel_sleep")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await update.message.reply_text(
+ "⚠️ Вы уверены, что хотите перевести Synology NAS в спящий режим?\n\n"
+ "Это действие приведет к остановке всех сервисов и отключению NAS.",
+ parse_mode="HTML",
+ reply_markup=reply_markup
+ )
+
+async def quickreboot_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /quickreboot для быстрой перезагрузки NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Выполняется перезагрузка Synology NAS...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить перезагрузку.", parse_mode="HTML")
+ return
+
+ try:
+ # Выполняем перезагрузку
+ result = synology_api.reboot_system()
+
+ if result:
+ # Формируем сообщение об успешной перезагрузке
+ reply_text = "🔄 Synology NAS перезагружается\n\n"
+ reply_text += "Перезагрузка обычно занимает 3-5 минут. После перезагрузки NAS будет снова доступен."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при выполнении перезагрузки\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при выполнении перезагрузки\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+async def wakeup_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /wakeup для включения NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Отправка пакета Wake-on-LAN для включения Synology NAS...")
+
+ # Проверяем, не включен ли NAS уже
+ if synology_api.is_online(force_check=True):
+ await message.edit_text("ℹ️ Synology NAS уже включен\n\nНет необходимости отправлять сигнал пробуждения.", parse_mode="HTML")
+ return
+
+ try:
+ # Отправляем сигнал пробуждения
+ result = synology_api.power_on()
+
+ if result:
+ # Формируем сообщение об успешном включении
+ reply_text = "✅ Synology NAS успешно включен\n\n"
+ reply_text += "NAS полностью готов к работе."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при включении NAS\n\nВозможные причины:\n- Функция Wake-on-LAN не настроена на NAS\n- Неверно указан MAC-адрес\n- Проблемы с сетевым подключением", parse_mode="HTML")
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при включении NAS\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+async def quota_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /quota для просмотра информации о квотах"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о квотах пользователей...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о квотах.", parse_mode="HTML")
+ return
+
+ try:
+ quota_info = synology_api.get_quota_info()
+
+ if not quota_info.get("success", False):
+ error = quota_info.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения информации о квотах\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ user_quotas = quota_info.get("user_quotas", [])
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о квотах\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о квотах
+ reply_text = f"📊 Квоты пользователей Synology NAS\n\n"
+
+ if not user_quotas:
+ reply_text += "Квоты пользователей не настроены или недоступны"
+ else:
+ for user_quota in user_quotas:
+ user = user_quota.get("user", "unknown")
+ quotas = user_quota.get("quotas", [])
+
+ if quotas:
+ reply_text += f"Пользователь {user}:\n"
+
+ for quota in quotas:
+ volume = quota.get("volume_name", "unknown")
+ limit = quota.get("limit", 0)
+ used = quota.get("used", 0)
+
+ # Переводим байты в ГБ
+ limit_gb = limit / (1024**3) if limit > 0 else 0
+ used_gb = used / (1024**3)
+
+ # Рассчитываем процент использования
+ if limit_gb > 0:
+ usage_percent = (used_gb / limit_gb) * 100
+ reply_text += f"• Том {volume}: {used_gb:.2f} ГБ из {limit_gb:.2f} ГБ ({usage_percent:.1f}%)\n"
+ else:
+ reply_text += f"• Том {volume}: {used_gb:.2f} ГБ (без ограничений)\n"
+
+ reply_text += "\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def schedule_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для управления расписанием питания"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action.startswith("schedule_"):
+ action_type = action.split("_")[1]
+
+ if action_type == "add_boot":
+ # Логика добавления расписания включения
+ # В реальном боте здесь будет диалог для настройки расписания
+ await query.edit_message_text("⚙️ Добавление расписания включения\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+ elif action_type == "add_shutdown":
+ # Логика добавления расписания выключения
+ await query.edit_message_text("⚙️ Добавление расписания выключения\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+ elif action_type == "delete":
+ # Логика удаления расписания
+ await query.edit_message_text("⚙️ Удаление расписания\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+async def browse_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для навигации по файловой системе"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action.startswith("browse_"):
+ path = action[7:] # Убираем префикс "browse_"
+
+ # Используем команду browse с указанным путем
+ message = await query.edit_message_text(f"⏳ Получение содержимого {'папки ' + path if path else 'общих папок'}...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить список файлов.", parse_mode="HTML")
+ return
+
+ try:
+ browse_result = synology_api.browse_files(folder_path=path)
+
+ if not browse_result.get("success", False):
+ error = browse_result.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ items = browse_result.get("items", [])
+ current_path = browse_result.get("path", "")
+ is_root = browse_result.get("is_root", True)
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о файлах и папках (аналогично функции browse_command)
+ if is_root:
+ reply_text = f"📁 Общие папки Synology NAS\n\n"
+ else:
+ reply_text = f"📁 Содержимое папки\n{current_path}\n\n"
+
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ files = []
+
+ for item in items:
+ if is_root: # Для корневого уровня все элементы - это общие папки
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ folders.append((name, path, True))
+ else:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path, False))
+ else:
+ # Для файлов получаем размер
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ files.append((name, path, size_str))
+
+ # Добавляем папки в сообщение
+ if folders:
+ for name, path, is_share in folders:
+ # Для общих папок добавляем иконку дома
+ icon = "🏠" if is_share else "📁"
+ reply_text += f"{icon} {name}\n"
+
+ # Добавляем файлы в сообщение
+ if files:
+ for name, path, size in files:
+ # Выбираем иконку в зависимости от расширения
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+
+ # Если нет элементов для отображения
+ if not folders and not files:
+ reply_text += "📭 Папка пуста\n"
+
+ # Добавляем кнопку возврата наверх, если мы не в корне
+ if not is_root:
+ # Определяем родительскую директорию
+ parent_path = "/".join(current_path.split("/")[:-1]) if "/" in current_path else ""
+
+ keyboard = [
+ [InlineKeyboardButton("🔼 Вернуться на уровень выше", callback_data=f"browse_{parent_path}")]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+ else:
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def advanced_power_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для управления питанием"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action == "confirm_reboot":
+ # Выполняем перезагрузку
+ message = await query.edit_message_text("⏳ Выполняется перезагрузка Synology NAS...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить перезагрузку.", parse_mode="HTML")
+ return
+
+ try:
+ result = synology_api.reboot_system()
+
+ if result:
+ reply_text = "🔄 Synology NAS перезагружается\n\n"
+ reply_text += "Перезагрузка обычно занимает 3-5 минут. После перезагрузки NAS будет снова доступен."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при выполнении перезагрузки\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при выполнении перезагрузки\n\nПричина: {str(e)}", parse_mode="HTML")
+
+ elif action == "cancel_reboot":
+ # Отменяем перезагрузку
+ await query.edit_message_text("✅ Перезагрузка отменена", parse_mode="HTML")
+
+ elif action == "confirm_sleep":
+ # Выполняем переход в спящий режим (выключение)
+ message = await query.edit_message_text("⏳ Перевод Synology NAS в спящий режим...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS уже оффлайн\n\nНевозможно выполнить переход в спящий режим.", parse_mode="HTML")
+ return
+
+ try:
+ result = synology_api.power_off()
+
+ if result:
+ reply_text = "💤 Synology NAS переведен в спящий режим\n\n"
+ reply_text += "Для пробуждения используйте команду /wakeup"
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при переходе в спящий режим\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при переходе в спящий режим\n\nПричина: {str(e)}", parse_mode="HTML")
+
+ elif action == "cancel_sleep":
+ # Отменяем переход в спящий режим
+ await query.edit_message_text("✅ Переход в спящий режим отменен", parse_mode="HTML")
+
+# Вспомогательные функции
+
+def format_size(size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат"""
+ if size_bytes < 1024:
+ return f"{size_bytes} Б"
+ elif size_bytes < 1024**2:
+ return f"{size_bytes/1024:.1f} КБ"
+ elif size_bytes < 1024**3:
+ return f"{size_bytes/1024**2:.1f} МБ"
+ else:
+ return f"{size_bytes/1024**3:.1f} ГБ"
+
+def get_file_icon(filename: str) -> str:
+ """Возвращает эмодзи-иконку в зависимости от типа файла"""
+ extension = filename.lower().split('.')[-1] if '.' in filename else ''
+
+ if extension in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp']:
+ return "🖼️"
+ elif extension in ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv']:
+ return "🎬"
+ elif extension in ['mp3', 'wav', 'ogg', 'flac', 'aac']:
+ return "🎵"
+ elif extension in ['doc', 'docx', 'txt', 'rtf', 'odt']:
+ return "📄"
+ elif extension in ['xls', 'xlsx', 'csv']:
+ return "📊"
+ elif extension in ['ppt', 'pptx']:
+ return "📑"
+ elif extension in ['pdf']:
+ return "📕"
+ elif extension in ['zip', 'rar', '7z', 'tar', 'gz']:
+ return "🗜️"
+ elif extension in ['exe', 'msi']:
+ return "⚙️"
+ else:
+ return "📄"
diff --git a/.history/src/handlers/advanced_handlers_20250830105155.py b/.history/src/handlers/advanced_handlers_20250830105155.py
new file mode 100644
index 0000000..465de38
--- /dev/null
+++ b/.history/src/handlers/advanced_handlers_20250830105155.py
@@ -0,0 +1,982 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Расширенные обработчики команд для управления Synology NAS
+"""
+
+import logging
+from datetime import datetime
+from typing import List, Dict, Any
+from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
+from telegram.ext import ContextTypes
+
+from src.config.config import ADMIN_USER_IDS
+from src.api.synology import SynologyAPI
+
+logger = logging.getLogger(__name__)
+
+# Инициализация API Synology
+synology_api = SynologyAPI()
+
+async def processes_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /processes для получения списка активных процессов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о запущенных процессах...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о процессах.", parse_mode="HTML")
+ return
+
+ try:
+ processes = synology_api.get_processes(limit=15) # Получаем топ-15 процессов
+
+ if not processes:
+ await message.edit_text("❌ Ошибка получения информации о процессах\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о процессах\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о процессах
+ reply_text = f"⚙️ Активные процессы Synology NAS\n\n"
+
+ for process in processes:
+ name = process.get("name", "unknown")
+ pid = process.get("pid", "?")
+ cpu_usage = process.get("cpu_usage", 0)
+ memory_usage = process.get("memory_usage", 0)
+
+ reply_text += f"• {name} (PID: {pid})\n"
+ reply_text += f" └ CPU: {cpu_usage:.1f}%, Память: {memory_usage:.1f}%\n"
+
+ reply_text += f"\nПоказано {len(processes)} наиболее активных процессов"
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def network_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /network для получения информации о сетевых подключениях"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о сетевых подключениях...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о сетевых подключениях.", parse_mode="HTML")
+ return
+
+ try:
+ network_status = synology_api.get_network_status()
+
+ if not network_status:
+ await message.edit_text("❌ Ошибка получения информации о сетевых подключениях\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о сетевых подключениях\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о сетевых интерфейсах
+ interfaces = network_status.get("interfaces", [])
+
+ reply_text = f"🌐 Сетевые подключения Synology NAS\n\n"
+
+ for interface in interfaces:
+ name = interface.get("id", "unknown")
+ ip = interface.get("ip", "Нет данных")
+ mac = interface.get("mac", "Нет данных")
+ status = "Активен" if interface.get("status") else "Неактивен"
+
+ # Информация о трафике
+ rx_bytes = interface.get("rx", 0) / (1024**2) # Перевод в МБ
+ tx_bytes = interface.get("tx", 0) / (1024**2) # Перевод в МБ
+
+ reply_text += f"• {name} ({status})\n"
+ reply_text += f" └ IP: {ip}, MAC: {mac}\n"
+
+ if rx_bytes > 0 or tx_bytes > 0:
+ reply_text += f" └ Получено: {rx_bytes:.2f} МБ, Отправлено: {tx_bytes:.2f} МБ\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def temperature_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /temperature для мониторинга температуры"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о температуре...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о температуре.", parse_mode="HTML")
+ return
+
+ try:
+ temp_status = synology_api.get_temperature_status()
+
+ if not temp_status:
+ await message.edit_text("❌ Ошибка получения информации о температуре\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о температуре\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о температуре
+ system_temp = temp_status.get("system_temperature")
+ disk_temps = temp_status.get("disk_temperatures", [])
+ is_warning = temp_status.get("warning", False)
+
+ # Выбор emoji в зависимости от температуры
+ temp_emoji = "🔥" if is_warning else "🌡️"
+
+ reply_text = f"{temp_emoji} Температура Synology NAS\n\n"
+
+ if system_temp is not None:
+ temp_status_text = "❗ ПОВЫШЕННАЯ" if is_warning else "✅ Нормальная"
+ reply_text += f"Температура системы: {system_temp}°C ({temp_status_text})\n\n"
+
+ if disk_temps:
+ reply_text += "Температура дисков:\n"
+ for disk in disk_temps:
+ name = disk.get("name", "unknown")
+ model = disk.get("model", "unknown")
+ temp = disk.get("temperature", 0)
+
+ disk_temp_emoji = "🔥" if temp > 45 else "✅"
+ reply_text += f"• {disk_temp_emoji} {name} ({model}): {temp}°C\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def schedule_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /schedule для управления расписанием питания"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о расписании питания...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о расписании питания.", parse_mode="HTML")
+ return
+
+ try:
+ schedule = synology_api.get_power_schedule()
+
+ # Проверяем, пустая ли структура расписания
+ if not schedule:
+ await message.edit_text("❌ Ошибка получения информации о расписании питания\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+
+ # Проверяем, содержит ли расписание хотя бы одну задачу
+ boot_tasks = schedule.get("boot_tasks", [])
+ shutdown_tasks = schedule.get("shutdown_tasks", [])
+
+ if not boot_tasks and not shutdown_tasks:
+ await message.edit_text("ℹ️ Расписание питания не настроено\n\nНа вашем устройстве отсутствует настроенное расписание включения и выключения, либо API не поддерживается.", parse_mode="HTML")
+ return
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о расписании питания\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о расписании питания
+ boot_tasks = schedule.get("boot_tasks", [])
+ shutdown_tasks = schedule.get("shutdown_tasks", [])
+
+ reply_text = f"⏱️ Расписание питания Synology NAS\n\n"
+
+ if boot_tasks:
+ reply_text += "Расписание включения:\n"
+ for task in boot_tasks:
+ days = task.get("day", [])
+ time = task.get("time", "00:00")
+ enabled = task.get("enabled", False)
+
+ # Преобразуем номера дней в названия
+ day_names = []
+ for day in days:
+ if day == 0: day_names.append("Пн")
+ elif day == 1: day_names.append("Вт")
+ elif day == 2: day_names.append("Ср")
+ elif day == 3: day_names.append("Чт")
+ elif day == 4: day_names.append("Пт")
+ elif day == 5: day_names.append("Сб")
+ elif day == 6: day_names.append("Вс")
+
+ status = "✅ Активно" if enabled else "❌ Отключено"
+ day_str = ", ".join(day_names) if day_names else "Нет дней"
+
+ reply_text += f"• {status}: {time} ({day_str})\n"
+ else:
+ reply_text += "Расписание включения: Не настроено\n"
+
+ reply_text += "\n"
+
+ if shutdown_tasks:
+ reply_text += "Расписание выключения:\n"
+ for task in shutdown_tasks:
+ days = task.get("day", [])
+ time = task.get("time", "00:00")
+ enabled = task.get("enabled", False)
+
+ # Преобразуем номера дней в названия
+ day_names = []
+ for day in days:
+ if day == 0: day_names.append("Пн")
+ elif day == 1: day_names.append("Вт")
+ elif day == 2: day_names.append("Ср")
+ elif day == 3: day_names.append("Чт")
+ elif day == 4: day_names.append("Пт")
+ elif day == 5: day_names.append("Сб")
+ elif day == 6: day_names.append("Вс")
+
+ status = "✅ Активно" if enabled else "❌ Отключено"
+ day_str = ", ".join(day_names) if day_names else "Нет дней"
+
+ reply_text += f"• {status}: {time} ({day_str})\n"
+ else:
+ reply_text += "Расписание выключения: Не настроено\n"
+
+ # Добавляем кнопки для управления расписанием
+ keyboard = [
+ [
+ InlineKeyboardButton("➕ Добавить включение", callback_data="schedule_add_boot"),
+ InlineKeyboardButton("➕ Добавить выключение", callback_data="schedule_add_shutdown")
+ ],
+ [
+ InlineKeyboardButton("🗑️ Удалить расписание", callback_data="schedule_delete")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+
+async def browse_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /browse для просмотра файлов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Получаем путь из аргументов команды или используем корневую директорию
+ path = " ".join(context.args) if context.args else ""
+
+ message = await update.message.reply_text(f"⏳ Получение содержимого {'папки ' + path if path else 'общих папок'}...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить список файлов.", parse_mode="HTML")
+ return
+
+ try:
+ browse_result = synology_api.browse_files(folder_path=path)
+
+ if not browse_result.get("success", False):
+ error = browse_result.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ items = browse_result.get("items", [])
+ current_path = browse_result.get("path", "")
+ is_root = browse_result.get("is_root", True)
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о файлах и папках
+ if is_root:
+ reply_text = f"📁 Общие папки Synology NAS\n\n"
+ else:
+ reply_text = f"📁 Содержимое папки\n{current_path}\n\n"
+
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ files = []
+
+ for item in items:
+ if is_root: # Для корневого уровня все элементы - это общие папки
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ folders.append((name, path, True))
+ else:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path, False))
+ else:
+ # Для файлов получаем размер
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ files.append((name, path, size_str))
+
+ # Добавляем папки в сообщение
+ if folders:
+ for name, path, is_share in folders:
+ # Для общих папок добавляем иконку дома
+ icon = "🏠" if is_share else "📁"
+ reply_text += f"{icon} {name}\n"
+
+ # Добавляем файлы в сообщение
+ if files:
+ for name, path, size in files:
+ # Выбираем иконку в зависимости от расширения
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+
+ # Если нет элементов для отображения
+ if not folders and not files:
+ reply_text += "📭 Папка пуста\n"
+
+ # Добавляем кнопку возврата наверх, если мы не в корне
+ if not is_root:
+ # Определяем родительскую директорию
+ parent_path = "/".join(current_path.split("/")[:-1]) if "/" in current_path else ""
+
+ keyboard = [
+ [InlineKeyboardButton("🔼 Вернуться на уровень выше", callback_data=f"browse_{parent_path}")]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+ else:
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def search_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /search для поиска файлов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Получаем шаблон поиска из аргументов команды
+ if not context.args:
+ await update.message.reply_text("❌ Укажите шаблон для поиска: /search <шаблон>")
+ return
+
+ pattern = " ".join(context.args)
+
+ message = await update.message.reply_text(f"⏳ Поиск файлов по шаблону «{pattern}»...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить поиск файлов.", parse_mode="HTML")
+ return
+
+ try:
+ search_result = synology_api.search_files(pattern=pattern, limit=20)
+
+ if not search_result.get("success", False):
+ error = search_result.get("error", "unknown")
+ progress = search_result.get("progress", 0)
+
+ if error == "search_timeout":
+ await message.edit_text(f"❌ Превышено время ожидания результатов поиска\n\nПроцесс поиска выполнен на {progress}%", parse_mode="HTML")
+ else:
+ await message.edit_text(f"❌ Ошибка при поиске файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ files = search_result.get("results", [])
+ total = search_result.get("total", len(files))
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при поиске файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение с результатами поиска
+ reply_text = f"🔍 Результаты поиска по шаблону «{pattern}»\n\n"
+
+ if not files:
+ reply_text += "📭 Файлы не найдены"
+ else:
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ found_files = []
+
+ for item in files:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path))
+ else:
+ # Для файлов получаем размер и путь к родительской папке
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ parent_path = "/".join(path.split("/")[:-1])
+ found_files.append((name, path, size_str, parent_path))
+
+ # Добавляем папки в сообщение
+ if folders:
+ reply_text += "Найденные папки:\n"
+ for name, path in folders[:5]: # Показываем первые 5 папок
+ reply_text += f"📁 {name}\n"
+
+ if len(folders) > 5:
+ reply_text += f"...и еще {len(folders) - 5} папок\n"
+
+ reply_text += "\n"
+
+ # Добавляем файлы в сообщение
+ if found_files:
+ reply_text += "Найденные файлы:\n"
+ for name, path, size, parent in found_files[:10]: # Показываем первые 10 файлов
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+ reply_text += f" Путь: .../{path.split('/')[-2]}/\n"
+
+ if len(found_files) > 10:
+ reply_text += f"...и еще {len(found_files) - 10} файлов\n"
+
+ # Добавляем информацию о общем количестве результатов
+ reply_text += f"\nВсего найдено: {total} элементов"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def updates_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /updates для проверки обновлений"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Проверка доступных обновлений...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно проверить наличие обновлений.", parse_mode="HTML")
+ return
+
+ try:
+ update_info = synology_api.check_for_updates()
+
+ if not update_info.get("success", False):
+ error = update_info.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка при проверке обновлений\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ current_version = update_info.get("current_version", "unknown")
+ update_available = update_info.get("update_available", False)
+ auto_update = update_info.get("auto_update_enabled", False)
+ updates = update_info.get("updates", [])
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при проверке обновлений\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение об обновлениях
+ if update_available:
+ reply_text = f"🔄 Доступны обновления DSM\n\n"
+ reply_text += f"Текущая версия: {current_version}\n"
+ reply_text += f"Автообновление: {'✅ Включено' if auto_update else '❌ Отключено'}\n\n"
+ reply_text += "Доступные обновления:\n"
+
+ for update_item in updates:
+ update_name = update_item.get("name", "unknown")
+ update_version = update_item.get("version", "unknown")
+ update_size = update_item.get("size", 0)
+ update_size_str = format_size(update_size)
+
+ reply_text += f"• {update_name} v{update_version}\n"
+ reply_text += f" └ Размер: {update_size_str}\n"
+ else:
+ reply_text = f"✅ Система в актуальном состоянии\n\n"
+ reply_text += f"Текущая версия: {current_version}\n"
+ reply_text += f"Автообновление: {'✅ Включено' if auto_update else '❌ Отключено'}\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def backup_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /backup для управления резервным копированием"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о резервном копировании...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о резервном копировании.", parse_mode="HTML")
+ return
+
+ try:
+ backup_status = synology_api.get_backup_status()
+
+ if not backup_status.get("success", False):
+ error = backup_status.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения информации о резервном копировании\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ backups = backup_status.get("backups", {})
+ api_status = backup_status.get("available_apis", {})
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о резервном копировании\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о резервном копировании
+ reply_text = f"💾 Резервное копирование Synology NAS\n\n"
+
+ # Информация о Hyper Backup
+ hyper_backups = backups.get("hyper_backup", [])
+ hyper_api_available = api_status.get("hyper_backup", False)
+
+ if hyper_api_available:
+ reply_text += "Hyper Backup:\n"
+
+ if hyper_backups:
+ for backup in hyper_backups:
+ name = backup.get("name", "unknown")
+ status = backup.get("status", "unknown")
+ last_backup = backup.get("last_backup", "never")
+
+ status_emoji = "✅" if status.lower() == "success" else "⚠️"
+ reply_text += f"• {status_emoji} {name}\n"
+ reply_text += f" └ Последнее копирование: {last_backup}\n"
+ else:
+ reply_text += "Задачи Hyper Backup не настроены\n"
+
+ reply_text += "\n"
+
+ # Информация о Time Backup
+ time_backups = backups.get("time_backup", [])
+ time_api_available = api_status.get("time_backup", False)
+
+ if time_api_available:
+ reply_text += "Time Backup:\n"
+
+ if time_backups:
+ for backup in time_backups:
+ name = backup.get("name", "unknown")
+ status = backup.get("status", "unknown")
+
+ status_emoji = "✅" if status.lower() == "normal" else "⚠️"
+ reply_text += f"• {status_emoji} {name}\n"
+ else:
+ reply_text += "Задачи Time Backup не настроены\n"
+
+ reply_text += "\n"
+
+ # Информация о USB Copy
+ usb_copy = backups.get("usb_copy", {})
+ usb_api_available = api_status.get("usb_copy", False)
+
+ if usb_api_available:
+ usb_enabled = usb_copy.get("enabled", False)
+ usb_status = "✅ Включено" if usb_enabled else "❌ Отключено"
+
+ reply_text += f"USB Copy: {usb_status}\n\n"
+
+ # Если ни один из API не доступен
+ if not any(api_status.values()):
+ reply_text += "API для резервного копирования не доступны на вашем NAS или требуют дополнительных прав доступа.\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def reboot_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /reboot для перезагрузки NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Добавляем подтверждение перед перезагрузкой
+ keyboard = [
+ [
+ InlineKeyboardButton("✅ Да, перезагрузить", callback_data="confirm_reboot"),
+ InlineKeyboardButton("❌ Отмена", callback_data="cancel_reboot")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await update.message.reply_text(
+ "⚠️ Вы уверены, что хотите перезагрузить Synology NAS?\n\n"
+ "Это действие может привести к прерыванию работы всех сервисов.",
+ parse_mode="HTML",
+ reply_markup=reply_markup
+ )
+
+async def sleep_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /sleep для перевода NAS в спящий режим"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Добавляем подтверждение перед отправкой в спящий режим
+ keyboard = [
+ [
+ InlineKeyboardButton("✅ Да, усыпить", callback_data="confirm_sleep"),
+ InlineKeyboardButton("❌ Отмена", callback_data="cancel_sleep")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await update.message.reply_text(
+ "⚠️ Вы уверены, что хотите перевести Synology NAS в спящий режим?\n\n"
+ "Это действие приведет к остановке всех сервисов и отключению NAS.",
+ parse_mode="HTML",
+ reply_markup=reply_markup
+ )
+
+async def quickreboot_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /quickreboot для быстрой перезагрузки NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Выполняется перезагрузка Synology NAS...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить перезагрузку.", parse_mode="HTML")
+ return
+
+ try:
+ # Выполняем перезагрузку
+ result = synology_api.reboot_system()
+
+ if result:
+ # Формируем сообщение об успешной перезагрузке
+ reply_text = "🔄 Synology NAS перезагружается\n\n"
+ reply_text += "Перезагрузка обычно занимает 3-5 минут. После перезагрузки NAS будет снова доступен."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при выполнении перезагрузки\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при выполнении перезагрузки\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+async def wakeup_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /wakeup для включения NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Отправка пакета Wake-on-LAN для включения Synology NAS...")
+
+ # Проверяем, не включен ли NAS уже
+ if synology_api.is_online(force_check=True):
+ await message.edit_text("ℹ️ Synology NAS уже включен\n\nНет необходимости отправлять сигнал пробуждения.", parse_mode="HTML")
+ return
+
+ try:
+ # Отправляем сигнал пробуждения
+ result = synology_api.power_on()
+
+ if result:
+ # Формируем сообщение об успешном включении
+ reply_text = "✅ Synology NAS успешно включен\n\n"
+ reply_text += "NAS полностью готов к работе."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при включении NAS\n\nВозможные причины:\n- Функция Wake-on-LAN не настроена на NAS\n- Неверно указан MAC-адрес\n- Проблемы с сетевым подключением", parse_mode="HTML")
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при включении NAS\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+async def quota_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /quota для просмотра информации о квотах"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о квотах пользователей...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о квотах.", parse_mode="HTML")
+ return
+
+ try:
+ quota_info = synology_api.get_quota_info()
+
+ if not quota_info.get("success", False):
+ error = quota_info.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения информации о квотах\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ user_quotas = quota_info.get("user_quotas", [])
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о квотах\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о квотах
+ reply_text = f"📊 Квоты пользователей Synology NAS\n\n"
+
+ if not user_quotas:
+ reply_text += "Квоты пользователей не настроены или недоступны"
+ else:
+ for user_quota in user_quotas:
+ user = user_quota.get("user", "unknown")
+ quotas = user_quota.get("quotas", [])
+
+ if quotas:
+ reply_text += f"Пользователь {user}:\n"
+
+ for quota in quotas:
+ volume = quota.get("volume_name", "unknown")
+ limit = quota.get("limit", 0)
+ used = quota.get("used", 0)
+
+ # Переводим байты в ГБ
+ limit_gb = limit / (1024**3) if limit > 0 else 0
+ used_gb = used / (1024**3)
+
+ # Рассчитываем процент использования
+ if limit_gb > 0:
+ usage_percent = (used_gb / limit_gb) * 100
+ reply_text += f"• Том {volume}: {used_gb:.2f} ГБ из {limit_gb:.2f} ГБ ({usage_percent:.1f}%)\n"
+ else:
+ reply_text += f"• Том {volume}: {used_gb:.2f} ГБ (без ограничений)\n"
+
+ reply_text += "\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def schedule_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для управления расписанием питания"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action.startswith("schedule_"):
+ action_type = action.split("_")[1]
+
+ if action_type == "add_boot":
+ # Логика добавления расписания включения
+ # В реальном боте здесь будет диалог для настройки расписания
+ await query.edit_message_text("⚙️ Добавление расписания включения\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+ elif action_type == "add_shutdown":
+ # Логика добавления расписания выключения
+ await query.edit_message_text("⚙️ Добавление расписания выключения\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+ elif action_type == "delete":
+ # Логика удаления расписания
+ await query.edit_message_text("⚙️ Удаление расписания\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+async def browse_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для навигации по файловой системе"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action.startswith("browse_"):
+ path = action[7:] # Убираем префикс "browse_"
+
+ # Используем команду browse с указанным путем
+ message = await query.edit_message_text(f"⏳ Получение содержимого {'папки ' + path if path else 'общих папок'}...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить список файлов.", parse_mode="HTML")
+ return
+
+ try:
+ browse_result = synology_api.browse_files(folder_path=path)
+
+ if not browse_result.get("success", False):
+ error = browse_result.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ items = browse_result.get("items", [])
+ current_path = browse_result.get("path", "")
+ is_root = browse_result.get("is_root", True)
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о файлах и папках (аналогично функции browse_command)
+ if is_root:
+ reply_text = f"📁 Общие папки Synology NAS\n\n"
+ else:
+ reply_text = f"📁 Содержимое папки\n{current_path}\n\n"
+
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ files = []
+
+ for item in items:
+ if is_root: # Для корневого уровня все элементы - это общие папки
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ folders.append((name, path, True))
+ else:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path, False))
+ else:
+ # Для файлов получаем размер
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ files.append((name, path, size_str))
+
+ # Добавляем папки в сообщение
+ if folders:
+ for name, path, is_share in folders:
+ # Для общих папок добавляем иконку дома
+ icon = "🏠" if is_share else "📁"
+ reply_text += f"{icon} {name}\n"
+
+ # Добавляем файлы в сообщение
+ if files:
+ for name, path, size in files:
+ # Выбираем иконку в зависимости от расширения
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+
+ # Если нет элементов для отображения
+ if not folders and not files:
+ reply_text += "📭 Папка пуста\n"
+
+ # Добавляем кнопку возврата наверх, если мы не в корне
+ if not is_root:
+ # Определяем родительскую директорию
+ parent_path = "/".join(current_path.split("/")[:-1]) if "/" in current_path else ""
+
+ keyboard = [
+ [InlineKeyboardButton("🔼 Вернуться на уровень выше", callback_data=f"browse_{parent_path}")]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+ else:
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def advanced_power_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для управления питанием"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action == "confirm_reboot":
+ # Выполняем перезагрузку
+ message = await query.edit_message_text("⏳ Выполняется перезагрузка Synology NAS...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить перезагрузку.", parse_mode="HTML")
+ return
+
+ try:
+ result = synology_api.reboot_system()
+
+ if result:
+ reply_text = "🔄 Synology NAS перезагружается\n\n"
+ reply_text += "Перезагрузка обычно занимает 3-5 минут. После перезагрузки NAS будет снова доступен."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при выполнении перезагрузки\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при выполнении перезагрузки\n\nПричина: {str(e)}", parse_mode="HTML")
+
+ elif action == "cancel_reboot":
+ # Отменяем перезагрузку
+ await query.edit_message_text("✅ Перезагрузка отменена", parse_mode="HTML")
+
+ elif action == "confirm_sleep":
+ # Выполняем переход в спящий режим (выключение)
+ message = await query.edit_message_text("⏳ Перевод Synology NAS в спящий режим...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS уже оффлайн\n\nНевозможно выполнить переход в спящий режим.", parse_mode="HTML")
+ return
+
+ try:
+ result = synology_api.power_off()
+
+ if result:
+ reply_text = "💤 Synology NAS переведен в спящий режим\n\n"
+ reply_text += "Для пробуждения используйте команду /wakeup"
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при переходе в спящий режим\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при переходе в спящий режим\n\nПричина: {str(e)}", parse_mode="HTML")
+
+ elif action == "cancel_sleep":
+ # Отменяем переход в спящий режим
+ await query.edit_message_text("✅ Переход в спящий режим отменен", parse_mode="HTML")
+
+# Вспомогательные функции
+
+def format_size(size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат"""
+ if size_bytes < 1024:
+ return f"{size_bytes} Б"
+ elif size_bytes < 1024**2:
+ return f"{size_bytes/1024:.1f} КБ"
+ elif size_bytes < 1024**3:
+ return f"{size_bytes/1024**2:.1f} МБ"
+ else:
+ return f"{size_bytes/1024**3:.1f} ГБ"
+
+def get_file_icon(filename: str) -> str:
+ """Возвращает эмодзи-иконку в зависимости от типа файла"""
+ extension = filename.lower().split('.')[-1] if '.' in filename else ''
+
+ if extension in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp']:
+ return "🖼️"
+ elif extension in ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv']:
+ return "🎬"
+ elif extension in ['mp3', 'wav', 'ogg', 'flac', 'aac']:
+ return "🎵"
+ elif extension in ['doc', 'docx', 'txt', 'rtf', 'odt']:
+ return "📄"
+ elif extension in ['xls', 'xlsx', 'csv']:
+ return "📊"
+ elif extension in ['ppt', 'pptx']:
+ return "📑"
+ elif extension in ['pdf']:
+ return "📕"
+ elif extension in ['zip', 'rar', '7z', 'tar', 'gz']:
+ return "🗜️"
+ elif extension in ['exe', 'msi']:
+ return "⚙️"
+ else:
+ return "📄"
diff --git a/.history/src/handlers/advanced_handlers_20250830105216.py b/.history/src/handlers/advanced_handlers_20250830105216.py
new file mode 100644
index 0000000..50bb0c7
--- /dev/null
+++ b/.history/src/handlers/advanced_handlers_20250830105216.py
@@ -0,0 +1,980 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Расширенные обработчики команд для управления Synology NAS
+"""
+
+import logging
+from datetime import datetime
+from typing import List, Dict, Any
+from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
+from telegram.ext import ContextTypes
+
+from src.config.config import ADMIN_USER_IDS
+from src.api.synology import SynologyAPI
+
+logger = logging.getLogger(__name__)
+
+# Инициализация API Synology
+synology_api = SynologyAPI()
+
+async def processes_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /processes для получения списка активных процессов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о запущенных процессах...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о процессах.", parse_mode="HTML")
+ return
+
+ try:
+ processes = synology_api.get_processes(limit=15) # Получаем топ-15 процессов
+
+ if not processes:
+ await message.edit_text("❌ Ошибка получения информации о процессах\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о процессах\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о процессах
+ reply_text = f"⚙️ Активные процессы Synology NAS\n\n"
+
+ for process in processes:
+ name = process.get("name", "unknown")
+ pid = process.get("pid", "?")
+ cpu_usage = process.get("cpu_usage", 0)
+ memory_usage = process.get("memory_usage", 0)
+
+ reply_text += f"• {name} (PID: {pid})\n"
+ reply_text += f" └ CPU: {cpu_usage:.1f}%, Память: {memory_usage:.1f}%\n"
+
+ reply_text += f"\nПоказано {len(processes)} наиболее активных процессов"
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def network_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /network для получения информации о сетевых подключениях"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о сетевых подключениях...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о сетевых подключениях.", parse_mode="HTML")
+ return
+
+ try:
+ network_status = synology_api.get_network_status()
+
+ if not network_status:
+ await message.edit_text("❌ Ошибка получения информации о сетевых подключениях\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о сетевых подключениях\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о сетевых интерфейсах
+ interfaces = network_status.get("interfaces", [])
+
+ reply_text = f"🌐 Сетевые подключения Synology NAS\n\n"
+
+ for interface in interfaces:
+ name = interface.get("id", "unknown")
+ ip = interface.get("ip", "Нет данных")
+ mac = interface.get("mac", "Нет данных")
+ status = "Активен" if interface.get("status") else "Неактивен"
+
+ # Информация о трафике
+ rx_bytes = interface.get("rx", 0) / (1024**2) # Перевод в МБ
+ tx_bytes = interface.get("tx", 0) / (1024**2) # Перевод в МБ
+
+ reply_text += f"• {name} ({status})\n"
+ reply_text += f" └ IP: {ip}, MAC: {mac}\n"
+
+ if rx_bytes > 0 or tx_bytes > 0:
+ reply_text += f" └ Получено: {rx_bytes:.2f} МБ, Отправлено: {tx_bytes:.2f} МБ\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def temperature_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /temperature для мониторинга температуры"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о температуре...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о температуре.", parse_mode="HTML")
+ return
+
+ try:
+ temp_status = synology_api.get_temperature_status()
+
+ if not temp_status:
+ await message.edit_text("❌ Ошибка получения информации о температуре\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о температуре\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о температуре
+ system_temp = temp_status.get("system_temperature")
+ disk_temps = temp_status.get("disk_temperatures", [])
+ is_warning = temp_status.get("warning", False)
+
+ # Выбор emoji в зависимости от температуры
+ temp_emoji = "🔥" if is_warning else "🌡️"
+
+ reply_text = f"{temp_emoji} Температура Synology NAS\n\n"
+
+ if system_temp is not None:
+ temp_status_text = "❗ ПОВЫШЕННАЯ" if is_warning else "✅ Нормальная"
+ reply_text += f"Температура системы: {system_temp}°C ({temp_status_text})\n\n"
+
+ if disk_temps:
+ reply_text += "Температура дисков:\n"
+ for disk in disk_temps:
+ name = disk.get("name", "unknown")
+ model = disk.get("model", "unknown")
+ temp = disk.get("temperature", 0)
+
+ disk_temp_emoji = "🔥" if temp > 45 else "✅"
+ reply_text += f"• {disk_temp_emoji} {name} ({model}): {temp}°C\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def schedule_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /schedule для управления расписанием питания"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о расписании питания...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о расписании питания.", parse_mode="HTML")
+ return
+
+ try:
+ schedule = synology_api.get_power_schedule()
+
+ # Проверяем, пустая ли структура расписания
+ if not schedule:
+ await message.edit_text("❌ Ошибка получения информации о расписании питания\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+
+ # Получаем задачи расписания
+ boot_tasks = schedule.get("boot_tasks", [])
+ shutdown_tasks = schedule.get("shutdown_tasks", [])
+
+ if not boot_tasks and not shutdown_tasks:
+ await message.edit_text("ℹ️ Расписание питания не настроено\n\nНа вашем устройстве отсутствует настроенное расписание включения и выключения, либо API не поддерживается.", parse_mode="HTML")
+ return
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о расписании питания\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о расписании питания
+
+ reply_text = f"⏱️ Расписание питания Synology NAS\n\n"
+
+ if boot_tasks:
+ reply_text += "Расписание включения:\n"
+ for task in boot_tasks:
+ days = task.get("day", [])
+ time = task.get("time", "00:00")
+ enabled = task.get("enabled", False)
+
+ # Преобразуем номера дней в названия
+ day_names = []
+ for day in days:
+ if day == 0: day_names.append("Пн")
+ elif day == 1: day_names.append("Вт")
+ elif day == 2: day_names.append("Ср")
+ elif day == 3: day_names.append("Чт")
+ elif day == 4: day_names.append("Пт")
+ elif day == 5: day_names.append("Сб")
+ elif day == 6: day_names.append("Вс")
+
+ status = "✅ Активно" if enabled else "❌ Отключено"
+ day_str = ", ".join(day_names) if day_names else "Нет дней"
+
+ reply_text += f"• {status}: {time} ({day_str})\n"
+ else:
+ reply_text += "Расписание включения: Не настроено\n"
+
+ reply_text += "\n"
+
+ if shutdown_tasks:
+ reply_text += "Расписание выключения:\n"
+ for task in shutdown_tasks:
+ days = task.get("day", [])
+ time = task.get("time", "00:00")
+ enabled = task.get("enabled", False)
+
+ # Преобразуем номера дней в названия
+ day_names = []
+ for day in days:
+ if day == 0: day_names.append("Пн")
+ elif day == 1: day_names.append("Вт")
+ elif day == 2: day_names.append("Ср")
+ elif day == 3: day_names.append("Чт")
+ elif day == 4: day_names.append("Пт")
+ elif day == 5: day_names.append("Сб")
+ elif day == 6: day_names.append("Вс")
+
+ status = "✅ Активно" if enabled else "❌ Отключено"
+ day_str = ", ".join(day_names) if day_names else "Нет дней"
+
+ reply_text += f"• {status}: {time} ({day_str})\n"
+ else:
+ reply_text += "Расписание выключения: Не настроено\n"
+
+ # Добавляем кнопки для управления расписанием
+ keyboard = [
+ [
+ InlineKeyboardButton("➕ Добавить включение", callback_data="schedule_add_boot"),
+ InlineKeyboardButton("➕ Добавить выключение", callback_data="schedule_add_shutdown")
+ ],
+ [
+ InlineKeyboardButton("🗑️ Удалить расписание", callback_data="schedule_delete")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+
+async def browse_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /browse для просмотра файлов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Получаем путь из аргументов команды или используем корневую директорию
+ path = " ".join(context.args) if context.args else ""
+
+ message = await update.message.reply_text(f"⏳ Получение содержимого {'папки ' + path if path else 'общих папок'}...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить список файлов.", parse_mode="HTML")
+ return
+
+ try:
+ browse_result = synology_api.browse_files(folder_path=path)
+
+ if not browse_result.get("success", False):
+ error = browse_result.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ items = browse_result.get("items", [])
+ current_path = browse_result.get("path", "")
+ is_root = browse_result.get("is_root", True)
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о файлах и папках
+ if is_root:
+ reply_text = f"📁 Общие папки Synology NAS\n\n"
+ else:
+ reply_text = f"📁 Содержимое папки\n{current_path}\n\n"
+
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ files = []
+
+ for item in items:
+ if is_root: # Для корневого уровня все элементы - это общие папки
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ folders.append((name, path, True))
+ else:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path, False))
+ else:
+ # Для файлов получаем размер
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ files.append((name, path, size_str))
+
+ # Добавляем папки в сообщение
+ if folders:
+ for name, path, is_share in folders:
+ # Для общих папок добавляем иконку дома
+ icon = "🏠" if is_share else "📁"
+ reply_text += f"{icon} {name}\n"
+
+ # Добавляем файлы в сообщение
+ if files:
+ for name, path, size in files:
+ # Выбираем иконку в зависимости от расширения
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+
+ # Если нет элементов для отображения
+ if not folders and not files:
+ reply_text += "📭 Папка пуста\n"
+
+ # Добавляем кнопку возврата наверх, если мы не в корне
+ if not is_root:
+ # Определяем родительскую директорию
+ parent_path = "/".join(current_path.split("/")[:-1]) if "/" in current_path else ""
+
+ keyboard = [
+ [InlineKeyboardButton("🔼 Вернуться на уровень выше", callback_data=f"browse_{parent_path}")]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+ else:
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def search_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /search для поиска файлов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Получаем шаблон поиска из аргументов команды
+ if not context.args:
+ await update.message.reply_text("❌ Укажите шаблон для поиска: /search <шаблон>")
+ return
+
+ pattern = " ".join(context.args)
+
+ message = await update.message.reply_text(f"⏳ Поиск файлов по шаблону «{pattern}»...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить поиск файлов.", parse_mode="HTML")
+ return
+
+ try:
+ search_result = synology_api.search_files(pattern=pattern, limit=20)
+
+ if not search_result.get("success", False):
+ error = search_result.get("error", "unknown")
+ progress = search_result.get("progress", 0)
+
+ if error == "search_timeout":
+ await message.edit_text(f"❌ Превышено время ожидания результатов поиска\n\nПроцесс поиска выполнен на {progress}%", parse_mode="HTML")
+ else:
+ await message.edit_text(f"❌ Ошибка при поиске файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ files = search_result.get("results", [])
+ total = search_result.get("total", len(files))
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при поиске файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение с результатами поиска
+ reply_text = f"🔍 Результаты поиска по шаблону «{pattern}»\n\n"
+
+ if not files:
+ reply_text += "📭 Файлы не найдены"
+ else:
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ found_files = []
+
+ for item in files:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path))
+ else:
+ # Для файлов получаем размер и путь к родительской папке
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ parent_path = "/".join(path.split("/")[:-1])
+ found_files.append((name, path, size_str, parent_path))
+
+ # Добавляем папки в сообщение
+ if folders:
+ reply_text += "Найденные папки:\n"
+ for name, path in folders[:5]: # Показываем первые 5 папок
+ reply_text += f"📁 {name}\n"
+
+ if len(folders) > 5:
+ reply_text += f"...и еще {len(folders) - 5} папок\n"
+
+ reply_text += "\n"
+
+ # Добавляем файлы в сообщение
+ if found_files:
+ reply_text += "Найденные файлы:\n"
+ for name, path, size, parent in found_files[:10]: # Показываем первые 10 файлов
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+ reply_text += f" Путь: .../{path.split('/')[-2]}/\n"
+
+ if len(found_files) > 10:
+ reply_text += f"...и еще {len(found_files) - 10} файлов\n"
+
+ # Добавляем информацию о общем количестве результатов
+ reply_text += f"\nВсего найдено: {total} элементов"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def updates_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /updates для проверки обновлений"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Проверка доступных обновлений...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно проверить наличие обновлений.", parse_mode="HTML")
+ return
+
+ try:
+ update_info = synology_api.check_for_updates()
+
+ if not update_info.get("success", False):
+ error = update_info.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка при проверке обновлений\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ current_version = update_info.get("current_version", "unknown")
+ update_available = update_info.get("update_available", False)
+ auto_update = update_info.get("auto_update_enabled", False)
+ updates = update_info.get("updates", [])
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при проверке обновлений\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение об обновлениях
+ if update_available:
+ reply_text = f"🔄 Доступны обновления DSM\n\n"
+ reply_text += f"Текущая версия: {current_version}\n"
+ reply_text += f"Автообновление: {'✅ Включено' if auto_update else '❌ Отключено'}\n\n"
+ reply_text += "Доступные обновления:\n"
+
+ for update_item in updates:
+ update_name = update_item.get("name", "unknown")
+ update_version = update_item.get("version", "unknown")
+ update_size = update_item.get("size", 0)
+ update_size_str = format_size(update_size)
+
+ reply_text += f"• {update_name} v{update_version}\n"
+ reply_text += f" └ Размер: {update_size_str}\n"
+ else:
+ reply_text = f"✅ Система в актуальном состоянии\n\n"
+ reply_text += f"Текущая версия: {current_version}\n"
+ reply_text += f"Автообновление: {'✅ Включено' if auto_update else '❌ Отключено'}\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def backup_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /backup для управления резервным копированием"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о резервном копировании...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о резервном копировании.", parse_mode="HTML")
+ return
+
+ try:
+ backup_status = synology_api.get_backup_status()
+
+ if not backup_status.get("success", False):
+ error = backup_status.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения информации о резервном копировании\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ backups = backup_status.get("backups", {})
+ api_status = backup_status.get("available_apis", {})
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о резервном копировании\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о резервном копировании
+ reply_text = f"💾 Резервное копирование Synology NAS\n\n"
+
+ # Информация о Hyper Backup
+ hyper_backups = backups.get("hyper_backup", [])
+ hyper_api_available = api_status.get("hyper_backup", False)
+
+ if hyper_api_available:
+ reply_text += "Hyper Backup:\n"
+
+ if hyper_backups:
+ for backup in hyper_backups:
+ name = backup.get("name", "unknown")
+ status = backup.get("status", "unknown")
+ last_backup = backup.get("last_backup", "never")
+
+ status_emoji = "✅" if status.lower() == "success" else "⚠️"
+ reply_text += f"• {status_emoji} {name}\n"
+ reply_text += f" └ Последнее копирование: {last_backup}\n"
+ else:
+ reply_text += "Задачи Hyper Backup не настроены\n"
+
+ reply_text += "\n"
+
+ # Информация о Time Backup
+ time_backups = backups.get("time_backup", [])
+ time_api_available = api_status.get("time_backup", False)
+
+ if time_api_available:
+ reply_text += "Time Backup:\n"
+
+ if time_backups:
+ for backup in time_backups:
+ name = backup.get("name", "unknown")
+ status = backup.get("status", "unknown")
+
+ status_emoji = "✅" if status.lower() == "normal" else "⚠️"
+ reply_text += f"• {status_emoji} {name}\n"
+ else:
+ reply_text += "Задачи Time Backup не настроены\n"
+
+ reply_text += "\n"
+
+ # Информация о USB Copy
+ usb_copy = backups.get("usb_copy", {})
+ usb_api_available = api_status.get("usb_copy", False)
+
+ if usb_api_available:
+ usb_enabled = usb_copy.get("enabled", False)
+ usb_status = "✅ Включено" if usb_enabled else "❌ Отключено"
+
+ reply_text += f"USB Copy: {usb_status}\n\n"
+
+ # Если ни один из API не доступен
+ if not any(api_status.values()):
+ reply_text += "API для резервного копирования не доступны на вашем NAS или требуют дополнительных прав доступа.\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def reboot_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /reboot для перезагрузки NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Добавляем подтверждение перед перезагрузкой
+ keyboard = [
+ [
+ InlineKeyboardButton("✅ Да, перезагрузить", callback_data="confirm_reboot"),
+ InlineKeyboardButton("❌ Отмена", callback_data="cancel_reboot")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await update.message.reply_text(
+ "⚠️ Вы уверены, что хотите перезагрузить Synology NAS?\n\n"
+ "Это действие может привести к прерыванию работы всех сервисов.",
+ parse_mode="HTML",
+ reply_markup=reply_markup
+ )
+
+async def sleep_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /sleep для перевода NAS в спящий режим"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Добавляем подтверждение перед отправкой в спящий режим
+ keyboard = [
+ [
+ InlineKeyboardButton("✅ Да, усыпить", callback_data="confirm_sleep"),
+ InlineKeyboardButton("❌ Отмена", callback_data="cancel_sleep")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await update.message.reply_text(
+ "⚠️ Вы уверены, что хотите перевести Synology NAS в спящий режим?\n\n"
+ "Это действие приведет к остановке всех сервисов и отключению NAS.",
+ parse_mode="HTML",
+ reply_markup=reply_markup
+ )
+
+async def quickreboot_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /quickreboot для быстрой перезагрузки NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Выполняется перезагрузка Synology NAS...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить перезагрузку.", parse_mode="HTML")
+ return
+
+ try:
+ # Выполняем перезагрузку
+ result = synology_api.reboot_system()
+
+ if result:
+ # Формируем сообщение об успешной перезагрузке
+ reply_text = "🔄 Synology NAS перезагружается\n\n"
+ reply_text += "Перезагрузка обычно занимает 3-5 минут. После перезагрузки NAS будет снова доступен."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при выполнении перезагрузки\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при выполнении перезагрузки\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+async def wakeup_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /wakeup для включения NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Отправка пакета Wake-on-LAN для включения Synology NAS...")
+
+ # Проверяем, не включен ли NAS уже
+ if synology_api.is_online(force_check=True):
+ await message.edit_text("ℹ️ Synology NAS уже включен\n\nНет необходимости отправлять сигнал пробуждения.", parse_mode="HTML")
+ return
+
+ try:
+ # Отправляем сигнал пробуждения
+ result = synology_api.power_on()
+
+ if result:
+ # Формируем сообщение об успешном включении
+ reply_text = "✅ Synology NAS успешно включен\n\n"
+ reply_text += "NAS полностью готов к работе."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при включении NAS\n\nВозможные причины:\n- Функция Wake-on-LAN не настроена на NAS\n- Неверно указан MAC-адрес\n- Проблемы с сетевым подключением", parse_mode="HTML")
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при включении NAS\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+async def quota_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /quota для просмотра информации о квотах"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о квотах пользователей...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о квотах.", parse_mode="HTML")
+ return
+
+ try:
+ quota_info = synology_api.get_quota_info()
+
+ if not quota_info.get("success", False):
+ error = quota_info.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения информации о квотах\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ user_quotas = quota_info.get("user_quotas", [])
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о квотах\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о квотах
+ reply_text = f"📊 Квоты пользователей Synology NAS\n\n"
+
+ if not user_quotas:
+ reply_text += "Квоты пользователей не настроены или недоступны"
+ else:
+ for user_quota in user_quotas:
+ user = user_quota.get("user", "unknown")
+ quotas = user_quota.get("quotas", [])
+
+ if quotas:
+ reply_text += f"Пользователь {user}:\n"
+
+ for quota in quotas:
+ volume = quota.get("volume_name", "unknown")
+ limit = quota.get("limit", 0)
+ used = quota.get("used", 0)
+
+ # Переводим байты в ГБ
+ limit_gb = limit / (1024**3) if limit > 0 else 0
+ used_gb = used / (1024**3)
+
+ # Рассчитываем процент использования
+ if limit_gb > 0:
+ usage_percent = (used_gb / limit_gb) * 100
+ reply_text += f"• Том {volume}: {used_gb:.2f} ГБ из {limit_gb:.2f} ГБ ({usage_percent:.1f}%)\n"
+ else:
+ reply_text += f"• Том {volume}: {used_gb:.2f} ГБ (без ограничений)\n"
+
+ reply_text += "\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def schedule_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для управления расписанием питания"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action.startswith("schedule_"):
+ action_type = action.split("_")[1]
+
+ if action_type == "add_boot":
+ # Логика добавления расписания включения
+ # В реальном боте здесь будет диалог для настройки расписания
+ await query.edit_message_text("⚙️ Добавление расписания включения\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+ elif action_type == "add_shutdown":
+ # Логика добавления расписания выключения
+ await query.edit_message_text("⚙️ Добавление расписания выключения\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+ elif action_type == "delete":
+ # Логика удаления расписания
+ await query.edit_message_text("⚙️ Удаление расписания\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+async def browse_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для навигации по файловой системе"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action.startswith("browse_"):
+ path = action[7:] # Убираем префикс "browse_"
+
+ # Используем команду browse с указанным путем
+ message = await query.edit_message_text(f"⏳ Получение содержимого {'папки ' + path if path else 'общих папок'}...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить список файлов.", parse_mode="HTML")
+ return
+
+ try:
+ browse_result = synology_api.browse_files(folder_path=path)
+
+ if not browse_result.get("success", False):
+ error = browse_result.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ items = browse_result.get("items", [])
+ current_path = browse_result.get("path", "")
+ is_root = browse_result.get("is_root", True)
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о файлах и папках (аналогично функции browse_command)
+ if is_root:
+ reply_text = f"📁 Общие папки Synology NAS\n\n"
+ else:
+ reply_text = f"📁 Содержимое папки\n{current_path}\n\n"
+
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ files = []
+
+ for item in items:
+ if is_root: # Для корневого уровня все элементы - это общие папки
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ folders.append((name, path, True))
+ else:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path, False))
+ else:
+ # Для файлов получаем размер
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ files.append((name, path, size_str))
+
+ # Добавляем папки в сообщение
+ if folders:
+ for name, path, is_share in folders:
+ # Для общих папок добавляем иконку дома
+ icon = "🏠" if is_share else "📁"
+ reply_text += f"{icon} {name}\n"
+
+ # Добавляем файлы в сообщение
+ if files:
+ for name, path, size in files:
+ # Выбираем иконку в зависимости от расширения
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+
+ # Если нет элементов для отображения
+ if not folders and not files:
+ reply_text += "📭 Папка пуста\n"
+
+ # Добавляем кнопку возврата наверх, если мы не в корне
+ if not is_root:
+ # Определяем родительскую директорию
+ parent_path = "/".join(current_path.split("/")[:-1]) if "/" in current_path else ""
+
+ keyboard = [
+ [InlineKeyboardButton("🔼 Вернуться на уровень выше", callback_data=f"browse_{parent_path}")]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+ else:
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def advanced_power_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для управления питанием"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action == "confirm_reboot":
+ # Выполняем перезагрузку
+ message = await query.edit_message_text("⏳ Выполняется перезагрузка Synology NAS...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить перезагрузку.", parse_mode="HTML")
+ return
+
+ try:
+ result = synology_api.reboot_system()
+
+ if result:
+ reply_text = "🔄 Synology NAS перезагружается\n\n"
+ reply_text += "Перезагрузка обычно занимает 3-5 минут. После перезагрузки NAS будет снова доступен."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при выполнении перезагрузки\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при выполнении перезагрузки\n\nПричина: {str(e)}", parse_mode="HTML")
+
+ elif action == "cancel_reboot":
+ # Отменяем перезагрузку
+ await query.edit_message_text("✅ Перезагрузка отменена", parse_mode="HTML")
+
+ elif action == "confirm_sleep":
+ # Выполняем переход в спящий режим (выключение)
+ message = await query.edit_message_text("⏳ Перевод Synology NAS в спящий режим...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS уже оффлайн\n\nНевозможно выполнить переход в спящий режим.", parse_mode="HTML")
+ return
+
+ try:
+ result = synology_api.power_off()
+
+ if result:
+ reply_text = "💤 Synology NAS переведен в спящий режим\n\n"
+ reply_text += "Для пробуждения используйте команду /wakeup"
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при переходе в спящий режим\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при переходе в спящий режим\n\nПричина: {str(e)}", parse_mode="HTML")
+
+ elif action == "cancel_sleep":
+ # Отменяем переход в спящий режим
+ await query.edit_message_text("✅ Переход в спящий режим отменен", parse_mode="HTML")
+
+# Вспомогательные функции
+
+def format_size(size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат"""
+ if size_bytes < 1024:
+ return f"{size_bytes} Б"
+ elif size_bytes < 1024**2:
+ return f"{size_bytes/1024:.1f} КБ"
+ elif size_bytes < 1024**3:
+ return f"{size_bytes/1024**2:.1f} МБ"
+ else:
+ return f"{size_bytes/1024**3:.1f} ГБ"
+
+def get_file_icon(filename: str) -> str:
+ """Возвращает эмодзи-иконку в зависимости от типа файла"""
+ extension = filename.lower().split('.')[-1] if '.' in filename else ''
+
+ if extension in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp']:
+ return "🖼️"
+ elif extension in ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv']:
+ return "🎬"
+ elif extension in ['mp3', 'wav', 'ogg', 'flac', 'aac']:
+ return "🎵"
+ elif extension in ['doc', 'docx', 'txt', 'rtf', 'odt']:
+ return "📄"
+ elif extension in ['xls', 'xlsx', 'csv']:
+ return "📊"
+ elif extension in ['ppt', 'pptx']:
+ return "📑"
+ elif extension in ['pdf']:
+ return "📕"
+ elif extension in ['zip', 'rar', '7z', 'tar', 'gz']:
+ return "🗜️"
+ elif extension in ['exe', 'msi']:
+ return "⚙️"
+ else:
+ return "📄"
diff --git a/.history/src/handlers/advanced_handlers_20250830110338.py b/.history/src/handlers/advanced_handlers_20250830110338.py
new file mode 100644
index 0000000..50bb0c7
--- /dev/null
+++ b/.history/src/handlers/advanced_handlers_20250830110338.py
@@ -0,0 +1,980 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Расширенные обработчики команд для управления Synology NAS
+"""
+
+import logging
+from datetime import datetime
+from typing import List, Dict, Any
+from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
+from telegram.ext import ContextTypes
+
+from src.config.config import ADMIN_USER_IDS
+from src.api.synology import SynologyAPI
+
+logger = logging.getLogger(__name__)
+
+# Инициализация API Synology
+synology_api = SynologyAPI()
+
+async def processes_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /processes для получения списка активных процессов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о запущенных процессах...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о процессах.", parse_mode="HTML")
+ return
+
+ try:
+ processes = synology_api.get_processes(limit=15) # Получаем топ-15 процессов
+
+ if not processes:
+ await message.edit_text("❌ Ошибка получения информации о процессах\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о процессах\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о процессах
+ reply_text = f"⚙️ Активные процессы Synology NAS\n\n"
+
+ for process in processes:
+ name = process.get("name", "unknown")
+ pid = process.get("pid", "?")
+ cpu_usage = process.get("cpu_usage", 0)
+ memory_usage = process.get("memory_usage", 0)
+
+ reply_text += f"• {name} (PID: {pid})\n"
+ reply_text += f" └ CPU: {cpu_usage:.1f}%, Память: {memory_usage:.1f}%\n"
+
+ reply_text += f"\nПоказано {len(processes)} наиболее активных процессов"
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def network_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /network для получения информации о сетевых подключениях"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о сетевых подключениях...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о сетевых подключениях.", parse_mode="HTML")
+ return
+
+ try:
+ network_status = synology_api.get_network_status()
+
+ if not network_status:
+ await message.edit_text("❌ Ошибка получения информации о сетевых подключениях\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о сетевых подключениях\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о сетевых интерфейсах
+ interfaces = network_status.get("interfaces", [])
+
+ reply_text = f"🌐 Сетевые подключения Synology NAS\n\n"
+
+ for interface in interfaces:
+ name = interface.get("id", "unknown")
+ ip = interface.get("ip", "Нет данных")
+ mac = interface.get("mac", "Нет данных")
+ status = "Активен" if interface.get("status") else "Неактивен"
+
+ # Информация о трафике
+ rx_bytes = interface.get("rx", 0) / (1024**2) # Перевод в МБ
+ tx_bytes = interface.get("tx", 0) / (1024**2) # Перевод в МБ
+
+ reply_text += f"• {name} ({status})\n"
+ reply_text += f" └ IP: {ip}, MAC: {mac}\n"
+
+ if rx_bytes > 0 or tx_bytes > 0:
+ reply_text += f" └ Получено: {rx_bytes:.2f} МБ, Отправлено: {tx_bytes:.2f} МБ\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def temperature_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /temperature для мониторинга температуры"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о температуре...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о температуре.", parse_mode="HTML")
+ return
+
+ try:
+ temp_status = synology_api.get_temperature_status()
+
+ if not temp_status:
+ await message.edit_text("❌ Ошибка получения информации о температуре\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о температуре\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о температуре
+ system_temp = temp_status.get("system_temperature")
+ disk_temps = temp_status.get("disk_temperatures", [])
+ is_warning = temp_status.get("warning", False)
+
+ # Выбор emoji в зависимости от температуры
+ temp_emoji = "🔥" if is_warning else "🌡️"
+
+ reply_text = f"{temp_emoji} Температура Synology NAS\n\n"
+
+ if system_temp is not None:
+ temp_status_text = "❗ ПОВЫШЕННАЯ" if is_warning else "✅ Нормальная"
+ reply_text += f"Температура системы: {system_temp}°C ({temp_status_text})\n\n"
+
+ if disk_temps:
+ reply_text += "Температура дисков:\n"
+ for disk in disk_temps:
+ name = disk.get("name", "unknown")
+ model = disk.get("model", "unknown")
+ temp = disk.get("temperature", 0)
+
+ disk_temp_emoji = "🔥" if temp > 45 else "✅"
+ reply_text += f"• {disk_temp_emoji} {name} ({model}): {temp}°C\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def schedule_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /schedule для управления расписанием питания"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о расписании питания...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о расписании питания.", parse_mode="HTML")
+ return
+
+ try:
+ schedule = synology_api.get_power_schedule()
+
+ # Проверяем, пустая ли структура расписания
+ if not schedule:
+ await message.edit_text("❌ Ошибка получения информации о расписании питания\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+
+ # Получаем задачи расписания
+ boot_tasks = schedule.get("boot_tasks", [])
+ shutdown_tasks = schedule.get("shutdown_tasks", [])
+
+ if not boot_tasks and not shutdown_tasks:
+ await message.edit_text("ℹ️ Расписание питания не настроено\n\nНа вашем устройстве отсутствует настроенное расписание включения и выключения, либо API не поддерживается.", parse_mode="HTML")
+ return
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о расписании питания\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о расписании питания
+
+ reply_text = f"⏱️ Расписание питания Synology NAS\n\n"
+
+ if boot_tasks:
+ reply_text += "Расписание включения:\n"
+ for task in boot_tasks:
+ days = task.get("day", [])
+ time = task.get("time", "00:00")
+ enabled = task.get("enabled", False)
+
+ # Преобразуем номера дней в названия
+ day_names = []
+ for day in days:
+ if day == 0: day_names.append("Пн")
+ elif day == 1: day_names.append("Вт")
+ elif day == 2: day_names.append("Ср")
+ elif day == 3: day_names.append("Чт")
+ elif day == 4: day_names.append("Пт")
+ elif day == 5: day_names.append("Сб")
+ elif day == 6: day_names.append("Вс")
+
+ status = "✅ Активно" if enabled else "❌ Отключено"
+ day_str = ", ".join(day_names) if day_names else "Нет дней"
+
+ reply_text += f"• {status}: {time} ({day_str})\n"
+ else:
+ reply_text += "Расписание включения: Не настроено\n"
+
+ reply_text += "\n"
+
+ if shutdown_tasks:
+ reply_text += "Расписание выключения:\n"
+ for task in shutdown_tasks:
+ days = task.get("day", [])
+ time = task.get("time", "00:00")
+ enabled = task.get("enabled", False)
+
+ # Преобразуем номера дней в названия
+ day_names = []
+ for day in days:
+ if day == 0: day_names.append("Пн")
+ elif day == 1: day_names.append("Вт")
+ elif day == 2: day_names.append("Ср")
+ elif day == 3: day_names.append("Чт")
+ elif day == 4: day_names.append("Пт")
+ elif day == 5: day_names.append("Сб")
+ elif day == 6: day_names.append("Вс")
+
+ status = "✅ Активно" if enabled else "❌ Отключено"
+ day_str = ", ".join(day_names) if day_names else "Нет дней"
+
+ reply_text += f"• {status}: {time} ({day_str})\n"
+ else:
+ reply_text += "Расписание выключения: Не настроено\n"
+
+ # Добавляем кнопки для управления расписанием
+ keyboard = [
+ [
+ InlineKeyboardButton("➕ Добавить включение", callback_data="schedule_add_boot"),
+ InlineKeyboardButton("➕ Добавить выключение", callback_data="schedule_add_shutdown")
+ ],
+ [
+ InlineKeyboardButton("🗑️ Удалить расписание", callback_data="schedule_delete")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+
+async def browse_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /browse для просмотра файлов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Получаем путь из аргументов команды или используем корневую директорию
+ path = " ".join(context.args) if context.args else ""
+
+ message = await update.message.reply_text(f"⏳ Получение содержимого {'папки ' + path if path else 'общих папок'}...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить список файлов.", parse_mode="HTML")
+ return
+
+ try:
+ browse_result = synology_api.browse_files(folder_path=path)
+
+ if not browse_result.get("success", False):
+ error = browse_result.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ items = browse_result.get("items", [])
+ current_path = browse_result.get("path", "")
+ is_root = browse_result.get("is_root", True)
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о файлах и папках
+ if is_root:
+ reply_text = f"📁 Общие папки Synology NAS\n\n"
+ else:
+ reply_text = f"📁 Содержимое папки\n{current_path}\n\n"
+
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ files = []
+
+ for item in items:
+ if is_root: # Для корневого уровня все элементы - это общие папки
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ folders.append((name, path, True))
+ else:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path, False))
+ else:
+ # Для файлов получаем размер
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ files.append((name, path, size_str))
+
+ # Добавляем папки в сообщение
+ if folders:
+ for name, path, is_share in folders:
+ # Для общих папок добавляем иконку дома
+ icon = "🏠" if is_share else "📁"
+ reply_text += f"{icon} {name}\n"
+
+ # Добавляем файлы в сообщение
+ if files:
+ for name, path, size in files:
+ # Выбираем иконку в зависимости от расширения
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+
+ # Если нет элементов для отображения
+ if not folders and not files:
+ reply_text += "📭 Папка пуста\n"
+
+ # Добавляем кнопку возврата наверх, если мы не в корне
+ if not is_root:
+ # Определяем родительскую директорию
+ parent_path = "/".join(current_path.split("/")[:-1]) if "/" in current_path else ""
+
+ keyboard = [
+ [InlineKeyboardButton("🔼 Вернуться на уровень выше", callback_data=f"browse_{parent_path}")]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+ else:
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def search_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /search для поиска файлов"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Получаем шаблон поиска из аргументов команды
+ if not context.args:
+ await update.message.reply_text("❌ Укажите шаблон для поиска: /search <шаблон>")
+ return
+
+ pattern = " ".join(context.args)
+
+ message = await update.message.reply_text(f"⏳ Поиск файлов по шаблону «{pattern}»...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить поиск файлов.", parse_mode="HTML")
+ return
+
+ try:
+ search_result = synology_api.search_files(pattern=pattern, limit=20)
+
+ if not search_result.get("success", False):
+ error = search_result.get("error", "unknown")
+ progress = search_result.get("progress", 0)
+
+ if error == "search_timeout":
+ await message.edit_text(f"❌ Превышено время ожидания результатов поиска\n\nПроцесс поиска выполнен на {progress}%", parse_mode="HTML")
+ else:
+ await message.edit_text(f"❌ Ошибка при поиске файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ files = search_result.get("results", [])
+ total = search_result.get("total", len(files))
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при поиске файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение с результатами поиска
+ reply_text = f"🔍 Результаты поиска по шаблону «{pattern}»\n\n"
+
+ if not files:
+ reply_text += "📭 Файлы не найдены"
+ else:
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ found_files = []
+
+ for item in files:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path))
+ else:
+ # Для файлов получаем размер и путь к родительской папке
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ parent_path = "/".join(path.split("/")[:-1])
+ found_files.append((name, path, size_str, parent_path))
+
+ # Добавляем папки в сообщение
+ if folders:
+ reply_text += "Найденные папки:\n"
+ for name, path in folders[:5]: # Показываем первые 5 папок
+ reply_text += f"📁 {name}\n"
+
+ if len(folders) > 5:
+ reply_text += f"...и еще {len(folders) - 5} папок\n"
+
+ reply_text += "\n"
+
+ # Добавляем файлы в сообщение
+ if found_files:
+ reply_text += "Найденные файлы:\n"
+ for name, path, size, parent in found_files[:10]: # Показываем первые 10 файлов
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+ reply_text += f" Путь: .../{path.split('/')[-2]}/\n"
+
+ if len(found_files) > 10:
+ reply_text += f"...и еще {len(found_files) - 10} файлов\n"
+
+ # Добавляем информацию о общем количестве результатов
+ reply_text += f"\nВсего найдено: {total} элементов"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def updates_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /updates для проверки обновлений"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Проверка доступных обновлений...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно проверить наличие обновлений.", parse_mode="HTML")
+ return
+
+ try:
+ update_info = synology_api.check_for_updates()
+
+ if not update_info.get("success", False):
+ error = update_info.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка при проверке обновлений\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ current_version = update_info.get("current_version", "unknown")
+ update_available = update_info.get("update_available", False)
+ auto_update = update_info.get("auto_update_enabled", False)
+ updates = update_info.get("updates", [])
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при проверке обновлений\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение об обновлениях
+ if update_available:
+ reply_text = f"🔄 Доступны обновления DSM\n\n"
+ reply_text += f"Текущая версия: {current_version}\n"
+ reply_text += f"Автообновление: {'✅ Включено' if auto_update else '❌ Отключено'}\n\n"
+ reply_text += "Доступные обновления:\n"
+
+ for update_item in updates:
+ update_name = update_item.get("name", "unknown")
+ update_version = update_item.get("version", "unknown")
+ update_size = update_item.get("size", 0)
+ update_size_str = format_size(update_size)
+
+ reply_text += f"• {update_name} v{update_version}\n"
+ reply_text += f" └ Размер: {update_size_str}\n"
+ else:
+ reply_text = f"✅ Система в актуальном состоянии\n\n"
+ reply_text += f"Текущая версия: {current_version}\n"
+ reply_text += f"Автообновление: {'✅ Включено' if auto_update else '❌ Отключено'}\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def backup_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /backup для управления резервным копированием"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о резервном копировании...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о резервном копировании.", parse_mode="HTML")
+ return
+
+ try:
+ backup_status = synology_api.get_backup_status()
+
+ if not backup_status.get("success", False):
+ error = backup_status.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения информации о резервном копировании\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ backups = backup_status.get("backups", {})
+ api_status = backup_status.get("available_apis", {})
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о резервном копировании\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о резервном копировании
+ reply_text = f"💾 Резервное копирование Synology NAS\n\n"
+
+ # Информация о Hyper Backup
+ hyper_backups = backups.get("hyper_backup", [])
+ hyper_api_available = api_status.get("hyper_backup", False)
+
+ if hyper_api_available:
+ reply_text += "Hyper Backup:\n"
+
+ if hyper_backups:
+ for backup in hyper_backups:
+ name = backup.get("name", "unknown")
+ status = backup.get("status", "unknown")
+ last_backup = backup.get("last_backup", "never")
+
+ status_emoji = "✅" if status.lower() == "success" else "⚠️"
+ reply_text += f"• {status_emoji} {name}\n"
+ reply_text += f" └ Последнее копирование: {last_backup}\n"
+ else:
+ reply_text += "Задачи Hyper Backup не настроены\n"
+
+ reply_text += "\n"
+
+ # Информация о Time Backup
+ time_backups = backups.get("time_backup", [])
+ time_api_available = api_status.get("time_backup", False)
+
+ if time_api_available:
+ reply_text += "Time Backup:\n"
+
+ if time_backups:
+ for backup in time_backups:
+ name = backup.get("name", "unknown")
+ status = backup.get("status", "unknown")
+
+ status_emoji = "✅" if status.lower() == "normal" else "⚠️"
+ reply_text += f"• {status_emoji} {name}\n"
+ else:
+ reply_text += "Задачи Time Backup не настроены\n"
+
+ reply_text += "\n"
+
+ # Информация о USB Copy
+ usb_copy = backups.get("usb_copy", {})
+ usb_api_available = api_status.get("usb_copy", False)
+
+ if usb_api_available:
+ usb_enabled = usb_copy.get("enabled", False)
+ usb_status = "✅ Включено" if usb_enabled else "❌ Отключено"
+
+ reply_text += f"USB Copy: {usb_status}\n\n"
+
+ # Если ни один из API не доступен
+ if not any(api_status.values()):
+ reply_text += "API для резервного копирования не доступны на вашем NAS или требуют дополнительных прав доступа.\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def reboot_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /reboot для перезагрузки NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Добавляем подтверждение перед перезагрузкой
+ keyboard = [
+ [
+ InlineKeyboardButton("✅ Да, перезагрузить", callback_data="confirm_reboot"),
+ InlineKeyboardButton("❌ Отмена", callback_data="cancel_reboot")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await update.message.reply_text(
+ "⚠️ Вы уверены, что хотите перезагрузить Synology NAS?\n\n"
+ "Это действие может привести к прерыванию работы всех сервисов.",
+ parse_mode="HTML",
+ reply_markup=reply_markup
+ )
+
+async def sleep_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /sleep для перевода NAS в спящий режим"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ # Добавляем подтверждение перед отправкой в спящий режим
+ keyboard = [
+ [
+ InlineKeyboardButton("✅ Да, усыпить", callback_data="confirm_sleep"),
+ InlineKeyboardButton("❌ Отмена", callback_data="cancel_sleep")
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await update.message.reply_text(
+ "⚠️ Вы уверены, что хотите перевести Synology NAS в спящий режим?\n\n"
+ "Это действие приведет к остановке всех сервисов и отключению NAS.",
+ parse_mode="HTML",
+ reply_markup=reply_markup
+ )
+
+async def quickreboot_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /quickreboot для быстрой перезагрузки NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Выполняется перезагрузка Synology NAS...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить перезагрузку.", parse_mode="HTML")
+ return
+
+ try:
+ # Выполняем перезагрузку
+ result = synology_api.reboot_system()
+
+ if result:
+ # Формируем сообщение об успешной перезагрузке
+ reply_text = "🔄 Synology NAS перезагружается\n\n"
+ reply_text += "Перезагрузка обычно занимает 3-5 минут. После перезагрузки NAS будет снова доступен."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при выполнении перезагрузки\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при выполнении перезагрузки\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+async def wakeup_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /wakeup для включения NAS"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Отправка пакета Wake-on-LAN для включения Synology NAS...")
+
+ # Проверяем, не включен ли NAS уже
+ if synology_api.is_online(force_check=True):
+ await message.edit_text("ℹ️ Synology NAS уже включен\n\nНет необходимости отправлять сигнал пробуждения.", parse_mode="HTML")
+ return
+
+ try:
+ # Отправляем сигнал пробуждения
+ result = synology_api.power_on()
+
+ if result:
+ # Формируем сообщение об успешном включении
+ reply_text = "✅ Synology NAS успешно включен\n\n"
+ reply_text += "NAS полностью готов к работе."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при включении NAS\n\nВозможные причины:\n- Функция Wake-on-LAN не настроена на NAS\n- Неверно указан MAC-адрес\n- Проблемы с сетевым подключением", parse_mode="HTML")
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при включении NAS\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+async def quota_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /quota для просмотра информации о квотах"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о квотах пользователей...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о квотах.", parse_mode="HTML")
+ return
+
+ try:
+ quota_info = synology_api.get_quota_info()
+
+ if not quota_info.get("success", False):
+ error = quota_info.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения информации о квотах\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ user_quotas = quota_info.get("user_quotas", [])
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о квотах\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о квотах
+ reply_text = f"📊 Квоты пользователей Synology NAS\n\n"
+
+ if not user_quotas:
+ reply_text += "Квоты пользователей не настроены или недоступны"
+ else:
+ for user_quota in user_quotas:
+ user = user_quota.get("user", "unknown")
+ quotas = user_quota.get("quotas", [])
+
+ if quotas:
+ reply_text += f"Пользователь {user}:\n"
+
+ for quota in quotas:
+ volume = quota.get("volume_name", "unknown")
+ limit = quota.get("limit", 0)
+ used = quota.get("used", 0)
+
+ # Переводим байты в ГБ
+ limit_gb = limit / (1024**3) if limit > 0 else 0
+ used_gb = used / (1024**3)
+
+ # Рассчитываем процент использования
+ if limit_gb > 0:
+ usage_percent = (used_gb / limit_gb) * 100
+ reply_text += f"• Том {volume}: {used_gb:.2f} ГБ из {limit_gb:.2f} ГБ ({usage_percent:.1f}%)\n"
+ else:
+ reply_text += f"• Том {volume}: {used_gb:.2f} ГБ (без ограничений)\n"
+
+ reply_text += "\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def schedule_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для управления расписанием питания"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action.startswith("schedule_"):
+ action_type = action.split("_")[1]
+
+ if action_type == "add_boot":
+ # Логика добавления расписания включения
+ # В реальном боте здесь будет диалог для настройки расписания
+ await query.edit_message_text("⚙️ Добавление расписания включения\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+ elif action_type == "add_shutdown":
+ # Логика добавления расписания выключения
+ await query.edit_message_text("⚙️ Добавление расписания выключения\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+ elif action_type == "delete":
+ # Логика удаления расписания
+ await query.edit_message_text("⚙️ Удаление расписания\n\nЭта функция находится в разработке.", parse_mode="HTML")
+
+async def browse_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для навигации по файловой системе"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action.startswith("browse_"):
+ path = action[7:] # Убираем префикс "browse_"
+
+ # Используем команду browse с указанным путем
+ message = await query.edit_message_text(f"⏳ Получение содержимого {'папки ' + path if path else 'общих папок'}...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить список файлов.", parse_mode="HTML")
+ return
+
+ try:
+ browse_result = synology_api.browse_files(folder_path=path)
+
+ if not browse_result.get("success", False):
+ error = browse_result.get("error", "unknown")
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {error}", parse_mode="HTML")
+ return
+
+ items = browse_result.get("items", [])
+ current_path = browse_result.get("path", "")
+ is_root = browse_result.get("is_root", True)
+
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения списка файлов\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о файлах и папках (аналогично функции browse_command)
+ if is_root:
+ reply_text = f"📁 Общие папки Synology NAS\n\n"
+ else:
+ reply_text = f"📁 Содержимое папки\n{current_path}\n\n"
+
+ # Сортируем: сначала папки, потом файлы
+ folders = []
+ files = []
+
+ for item in items:
+ if is_root: # Для корневого уровня все элементы - это общие папки
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ folders.append((name, path, True))
+ else:
+ name = item.get("name", "unknown")
+ path = item.get("path", "")
+ is_dir = item.get("isdir", False)
+
+ if is_dir:
+ folders.append((name, path, False))
+ else:
+ # Для файлов получаем размер
+ size = item.get("additional", {}).get("size", 0)
+ size_str = format_size(size)
+ files.append((name, path, size_str))
+
+ # Добавляем папки в сообщение
+ if folders:
+ for name, path, is_share in folders:
+ # Для общих папок добавляем иконку дома
+ icon = "🏠" if is_share else "📁"
+ reply_text += f"{icon} {name}\n"
+
+ # Добавляем файлы в сообщение
+ if files:
+ for name, path, size in files:
+ # Выбираем иконку в зависимости от расширения
+ icon = get_file_icon(name)
+ reply_text += f"{icon} {name} ({size})\n"
+
+ # Если нет элементов для отображения
+ if not folders and not files:
+ reply_text += "📭 Папка пуста\n"
+
+ # Добавляем кнопку возврата наверх, если мы не в корне
+ if not is_root:
+ # Определяем родительскую директорию
+ parent_path = "/".join(current_path.split("/")[:-1]) if "/" in current_path else ""
+
+ keyboard = [
+ [InlineKeyboardButton("🔼 Вернуться на уровень выше", callback_data=f"browse_{parent_path}")]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ await message.edit_text(reply_text, parse_mode="HTML", reply_markup=reply_markup)
+ else:
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def advanced_power_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для управления питанием"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = update.effective_user.id
+ if user_id not in ADMIN_USER_IDS:
+ await query.edit_message_text("У вас нет доступа к этому боту.")
+ return
+
+ action = query.data
+
+ if action == "confirm_reboot":
+ # Выполняем перезагрузку
+ message = await query.edit_message_text("⏳ Выполняется перезагрузка Synology NAS...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно выполнить перезагрузку.", parse_mode="HTML")
+ return
+
+ try:
+ result = synology_api.reboot_system()
+
+ if result:
+ reply_text = "🔄 Synology NAS перезагружается\n\n"
+ reply_text += "Перезагрузка обычно занимает 3-5 минут. После перезагрузки NAS будет снова доступен."
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при выполнении перезагрузки\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при выполнении перезагрузки\n\nПричина: {str(e)}", parse_mode="HTML")
+
+ elif action == "cancel_reboot":
+ # Отменяем перезагрузку
+ await query.edit_message_text("✅ Перезагрузка отменена", parse_mode="HTML")
+
+ elif action == "confirm_sleep":
+ # Выполняем переход в спящий режим (выключение)
+ message = await query.edit_message_text("⏳ Перевод Synology NAS в спящий режим...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS уже оффлайн\n\nНевозможно выполнить переход в спящий режим.", parse_mode="HTML")
+ return
+
+ try:
+ result = synology_api.power_off()
+
+ if result:
+ reply_text = "💤 Synology NAS переведен в спящий режим\n\n"
+ reply_text += "Для пробуждения используйте команду /wakeup"
+ await message.edit_text(reply_text, parse_mode="HTML")
+ else:
+ await message.edit_text("❌ Ошибка при переходе в спящий режим\n\nПроверьте журналы NAS для получения дополнительной информации.", parse_mode="HTML")
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка при переходе в спящий режим\n\nПричина: {str(e)}", parse_mode="HTML")
+
+ elif action == "cancel_sleep":
+ # Отменяем переход в спящий режим
+ await query.edit_message_text("✅ Переход в спящий режим отменен", parse_mode="HTML")
+
+# Вспомогательные функции
+
+def format_size(size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат"""
+ if size_bytes < 1024:
+ return f"{size_bytes} Б"
+ elif size_bytes < 1024**2:
+ return f"{size_bytes/1024:.1f} КБ"
+ elif size_bytes < 1024**3:
+ return f"{size_bytes/1024**2:.1f} МБ"
+ else:
+ return f"{size_bytes/1024**3:.1f} ГБ"
+
+def get_file_icon(filename: str) -> str:
+ """Возвращает эмодзи-иконку в зависимости от типа файла"""
+ extension = filename.lower().split('.')[-1] if '.' in filename else ''
+
+ if extension in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp']:
+ return "🖼️"
+ elif extension in ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv']:
+ return "🎬"
+ elif extension in ['mp3', 'wav', 'ogg', 'flac', 'aac']:
+ return "🎵"
+ elif extension in ['doc', 'docx', 'txt', 'rtf', 'odt']:
+ return "📄"
+ elif extension in ['xls', 'xlsx', 'csv']:
+ return "📊"
+ elif extension in ['ppt', 'pptx']:
+ return "📑"
+ elif extension in ['pdf']:
+ return "📕"
+ elif extension in ['zip', 'rar', '7z', 'tar', 'gz']:
+ return "🗜️"
+ elif extension in ['exe', 'msi']:
+ return "⚙️"
+ else:
+ return "📄"
diff --git a/.history/src/handlers/command_handlers_20250830110734.py b/.history/src/handlers/command_handlers_20250830110734.py
new file mode 100644
index 0000000..3538a8c
--- /dev/null
+++ b/.history/src/handlers/command_handlers_20250830110734.py
@@ -0,0 +1,328 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Обработчики команд для телеграм-бота
+"""
+
+import logging
+import socket
+from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
+from telegram.ext import ContextTypes
+
+from src.config.config import (
+ ADMIN_USER_IDS, SYNOLOGY_HOST, SYNOLOGY_PORT, SYNOLOGY_MAC
+)
+from src.api.synology import SynologyAPI
+
+logger = logging.getLogger(__name__)
+
+# Инициализация API Synology
+synology_api = SynologyAPI()
+
+from src.utils.admin_utils import admin_required
+
+@admin_required
+async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /status"""
+ message = await update.message.reply_text("⏳ Проверяю статус Synology NAS...")
+
+ is_online = synology_api.is_online()
+
+ if is_online:
+ try:
+ # Если NAS включен, попробуем получить дополнительную информацию
+ system_info = synology_api.get_system_status()
+
+ if system_info and system_info.get("status") != "error":
+ model = system_info.get("model", "Неизвестная модель")
+ version = system_info.get("version_string", system_info.get("version", "Неизвестная версия"))
+ uptime_seconds = system_info.get("up_time", system_info.get("uptime", 0))
+
+ # Преобразование времени работы в удобочитаемый формат
+ days, remainder = divmod(int(uptime_seconds), 86400)
+ hours, remainder = divmod(remainder, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ uptime_str = f"{days}д {hours}ч {minutes}м {seconds}с"
+
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Модель: {model}\n"
+ f"Версия DSM: {version}\n"
+ f"Время работы: {uptime_str}",
+ parse_mode="HTML"
+ )
+ else:
+ # Обработка возможной ошибки API
+ error_info = ""
+ if system_info and system_info.get("status") == "error":
+ error_code = system_info.get("error_code", "неизвестно")
+ error_info = f"\nКод ошибки API: {error_code}"
+
+ # Проверяем порт и сеть
+ network_info = ""
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(1)
+ result = s.connect_ex((SYNOLOGY_HOST, SYNOLOGY_PORT))
+ s.close()
+ if result == 0:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт {SYNOLOGY_PORT}: открыт"
+ else:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт {SYNOLOGY_PORT}: закрыт (код {result})"
+ except Exception as e:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nОшибка проверки порта: {str(e)}"
+
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Устройство доступно по сети, но детальная информация через API недоступна. "
+ f"Возможно, необходимо проверить учетные данные или права доступа."
+ f"{error_info}"
+ f"{network_info}",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Ошибка при получении информации: {str(e)[:100]}...\n\n"
+ f"Сетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт: {SYNOLOGY_PORT}",
+ parse_mode="HTML"
+ )
+ else:
+ # Устройство не в сети, проверим соседние порты для диагностики
+ port_scan_info = ""
+ try:
+ for test_port in [SYNOLOGY_PORT-1, SYNOLOGY_PORT, SYNOLOGY_PORT+1, 5000, 5001]:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(1)
+ result = s.connect_ex((SYNOLOGY_HOST, test_port))
+ s.close()
+ status = "открыт" if result == 0 else "закрыт"
+ port_scan_info += f"Порт {test_port}: {status}\n"
+
+ # Добавим информацию о MAC-адресе для WoL
+ mac_info = f"MAC: {SYNOLOGY_MAC}" if SYNOLOGY_MAC else "MAC-адрес не настроен"
+
+ await message.edit_text(
+ f"❌ Synology NAS оффлайн\n\n"
+ f"Информация о сети:\n"
+ f"IP: {SYNOLOGY_HOST}\n"
+ f"{port_scan_info}\n"
+ f"{mac_info}\n\n"
+ f"Используйте /power для отправки Wake-on-LAN пакета",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ await message.edit_text(
+ f"❌ Synology NAS оффлайн\n\n"
+ f"Ошибка при сканировании портов: {str(e)[:100]}...\n\n"
+ f"Используйте /power для отправки Wake-on-LAN пакета",
+ parse_mode="HTML"
+ )
+
+async def power_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /power"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ is_online = synology_api.is_online()
+
+ keyboard = []
+
+ # Кнопка включения
+ if not is_online:
+ keyboard.append([InlineKeyboardButton("🟢 Включить", callback_data="power_on")])
+ else:
+ keyboard.append([InlineKeyboardButton("🟢 Включить (уже включено)", callback_data="power_on_no_op")])
+
+ # Кнопка выключения
+ if is_online:
+ keyboard.append([InlineKeyboardButton("🔴 Выключить", callback_data="power_off")])
+ else:
+ keyboard.append([InlineKeyboardButton("🔴 Выключить (уже выключено)", callback_data="power_off_no_op")])
+
+ # Кнопка перезагрузки
+ if is_online:
+ keyboard.append([InlineKeyboardButton("🔄 Перезагрузить", callback_data="reboot")])
+ else:
+ keyboard.append([InlineKeyboardButton("🔄 Перезагрузить (недоступно)", callback_data="reboot_no_op")])
+
+ # Кнопка отмены
+ keyboard.append([InlineKeyboardButton("❌ Отмена", callback_data="cancel")])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ status_text = "✅ Онлайн" if is_online else "❌ Оффлайн"
+
+ await update.message.reply_text(
+ f"Управление питанием Synology NAS\n\n"
+ f"Текущий статус: {status_text}\n\n"
+ f"Выберите действие:",
+ reply_markup=reply_markup,
+ parse_mode="HTML"
+ )
+
+async def power_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для кнопок управления питанием"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = query.from_user.id
+ if user_id not in ADMIN_USER_IDS:
+ return
+
+ action = query.data
+
+ if action == "cancel":
+ await query.edit_message_text("❌ Действие отменено")
+ return
+
+ # Обработка неактивных кнопок
+ if action in ["power_on_no_op", "power_off_no_op", "reboot_no_op"]:
+ if action == "power_on_no_op":
+ await query.edit_message_text("ℹ️ Synology NAS уже включен")
+ elif action == "power_off_no_op":
+ await query.edit_message_text("ℹ️ Synology NAS уже выключен")
+ else:
+ await query.edit_message_text("ℹ️ Невозможно перезагрузить выключенное устройство")
+ return
+
+ # Обработка основных действий
+ if action == "power_on":
+ await query.edit_message_text("⏳ Отправка сигнала включения Synology NAS...")
+
+ if await context.application.create_task(
+ handle_power_on(query.message.chat_id, context)
+ ):
+ # Функция вернула True, успешное включение
+ pass
+ else:
+ # Функция вернула False, ошибка включения
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при включении Synology NAS. Проверьте настройки Wake-on-LAN."
+ )
+
+ elif action == "power_off":
+ await query.edit_message_text("⏳ Отправка команды выключения Synology NAS...")
+
+ try:
+ success = await handle_power_off(query.message.chat_id, context)
+ # Если handle_power_off уже отправил сообщение об успехе или ошибке,
+ # дополнительных сообщений не требуется
+ except Exception as e:
+ logger.error(f"Exception in power_off callback: {str(e)}")
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при выключении Synology NAS. Проверьте соединение и учетные данные."
+ )
+
+ elif action == "reboot":
+ await query.edit_message_text("⏳ Отправка команды перезагрузки Synology NAS...")
+
+ if await context.application.create_task(
+ handle_reboot(query.message.chat_id, context)
+ ):
+ # Функция вернула True, успешная перезагрузка
+ pass
+ else:
+ # Функция вернула False, ошибка перезагрузки
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при перезагрузке Synology NAS. Проверьте соединение и учетные данные."
+ )
+
+async def handle_power_on(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для включения NAS"""
+ try:
+ # Отправка запроса на включение
+ success = synology_api.power_on()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Synology NAS успешно включен и доступен"
+ )
+ return True
+ else:
+ return False
+ except Exception as e:
+ logger.error(f"Error during power on: {str(e)}")
+ return False
+
+async def handle_power_off(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для выключения NAS"""
+ try:
+ # Проверка доступности NAS
+ if not synology_api.is_online():
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Synology NAS не в сети. Невозможно отправить команду выключения."
+ )
+ return False
+
+ # Отправка запроса на выключение
+ success = synology_api.power_off()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Команда выключения успешно отправлена. Synology NAS выключается..."
+ )
+ return True
+ else:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Ошибка при отправке команды выключения. Пожалуйста, проверьте журналы для получения дополнительной информации."
+ )
+ return False
+ except Exception as e:
+ error_msg = str(e)
+ logger.error(f"Error during power off: {error_msg}")
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text=f"❌ Ошибка при выключении: {error_msg[:100]}..."
+ )
+ return False
+
+async def handle_reboot(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для перезагрузки NAS"""
+ try:
+ # Отправка запроса на перезагрузку
+ success = synology_api.reboot_system()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Команда перезагрузки успешно отправлена. Synology NAS перезагружается..."
+ )
+
+ # Ждем некоторое время перед проверкой статуса
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⏳ Ожидание перезагрузки системы..."
+ )
+
+ # Создаем задачу для ожидания загрузки
+ wait_successful = synology_api.wait_for_boot()
+
+ if wait_successful:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Synology NAS успешно перезагружен и снова онлайн"
+ )
+ else:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Превышено время ожидания перезагрузки. Проверьте статус вручную."
+ )
+
+ return True
+ else:
+ return False
+ except Exception as e:
+ logger.error(f"Error during reboot: {str(e)}")
+ return False
diff --git a/.history/src/handlers/command_handlers_20250830110754.py b/.history/src/handlers/command_handlers_20250830110754.py
new file mode 100644
index 0000000..4346748
--- /dev/null
+++ b/.history/src/handlers/command_handlers_20250830110754.py
@@ -0,0 +1,329 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Обработчики команд для телеграм-бота
+"""
+
+import logging
+import socket
+from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
+from telegram.ext import ContextTypes
+
+from src.config.config import (
+ ADMIN_USER_IDS, SYNOLOGY_HOST, SYNOLOGY_PORT, SYNOLOGY_MAC
+)
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+logger = logging.getLogger(__name__)
+
+# Инициализация API Synology
+synology_api = SynologyAPI()
+
+from src.utils.admin_utils import admin_required
+
+@admin_required
+async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /status"""
+ message = await update.message.reply_text("⏳ Проверяю статус Synology NAS...")
+
+ is_online = synology_api.is_online()
+
+ if is_online:
+ try:
+ # Если NAS включен, попробуем получить дополнительную информацию
+ system_info = synology_api.get_system_status()
+
+ if system_info and system_info.get("status") != "error":
+ model = system_info.get("model", "Неизвестная модель")
+ version = system_info.get("version_string", system_info.get("version", "Неизвестная версия"))
+ uptime_seconds = system_info.get("up_time", system_info.get("uptime", 0))
+
+ # Преобразование времени работы в удобочитаемый формат
+ days, remainder = divmod(int(uptime_seconds), 86400)
+ hours, remainder = divmod(remainder, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ uptime_str = f"{days}д {hours}ч {minutes}м {seconds}с"
+
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Модель: {model}\n"
+ f"Версия DSM: {version}\n"
+ f"Время работы: {uptime_str}",
+ parse_mode="HTML"
+ )
+ else:
+ # Обработка возможной ошибки API
+ error_info = ""
+ if system_info and system_info.get("status") == "error":
+ error_code = system_info.get("error_code", "неизвестно")
+ error_info = f"\nКод ошибки API: {error_code}"
+
+ # Проверяем порт и сеть
+ network_info = ""
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(1)
+ result = s.connect_ex((SYNOLOGY_HOST, SYNOLOGY_PORT))
+ s.close()
+ if result == 0:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт {SYNOLOGY_PORT}: открыт"
+ else:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт {SYNOLOGY_PORT}: закрыт (код {result})"
+ except Exception as e:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nОшибка проверки порта: {str(e)}"
+
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Устройство доступно по сети, но детальная информация через API недоступна. "
+ f"Возможно, необходимо проверить учетные данные или права доступа."
+ f"{error_info}"
+ f"{network_info}",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Ошибка при получении информации: {str(e)[:100]}...\n\n"
+ f"Сетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт: {SYNOLOGY_PORT}",
+ parse_mode="HTML"
+ )
+ else:
+ # Устройство не в сети, проверим соседние порты для диагностики
+ port_scan_info = ""
+ try:
+ for test_port in [SYNOLOGY_PORT-1, SYNOLOGY_PORT, SYNOLOGY_PORT+1, 5000, 5001]:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(1)
+ result = s.connect_ex((SYNOLOGY_HOST, test_port))
+ s.close()
+ status = "открыт" if result == 0 else "закрыт"
+ port_scan_info += f"Порт {test_port}: {status}\n"
+
+ # Добавим информацию о MAC-адресе для WoL
+ mac_info = f"MAC: {SYNOLOGY_MAC}" if SYNOLOGY_MAC else "MAC-адрес не настроен"
+
+ await message.edit_text(
+ f"❌ Synology NAS оффлайн\n\n"
+ f"Информация о сети:\n"
+ f"IP: {SYNOLOGY_HOST}\n"
+ f"{port_scan_info}\n"
+ f"{mac_info}\n\n"
+ f"Используйте /power для отправки Wake-on-LAN пакета",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ await message.edit_text(
+ f"❌ Synology NAS оффлайн\n\n"
+ f"Ошибка при сканировании портов: {str(e)[:100]}...\n\n"
+ f"Используйте /power для отправки Wake-on-LAN пакета",
+ parse_mode="HTML"
+ )
+
+async def power_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /power"""
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ is_online = synology_api.is_online()
+
+ keyboard = []
+
+ # Кнопка включения
+ if not is_online:
+ keyboard.append([InlineKeyboardButton("🟢 Включить", callback_data="power_on")])
+ else:
+ keyboard.append([InlineKeyboardButton("🟢 Включить (уже включено)", callback_data="power_on_no_op")])
+
+ # Кнопка выключения
+ if is_online:
+ keyboard.append([InlineKeyboardButton("🔴 Выключить", callback_data="power_off")])
+ else:
+ keyboard.append([InlineKeyboardButton("🔴 Выключить (уже выключено)", callback_data="power_off_no_op")])
+
+ # Кнопка перезагрузки
+ if is_online:
+ keyboard.append([InlineKeyboardButton("🔄 Перезагрузить", callback_data="reboot")])
+ else:
+ keyboard.append([InlineKeyboardButton("🔄 Перезагрузить (недоступно)", callback_data="reboot_no_op")])
+
+ # Кнопка отмены
+ keyboard.append([InlineKeyboardButton("❌ Отмена", callback_data="cancel")])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ status_text = "✅ Онлайн" if is_online else "❌ Оффлайн"
+
+ await update.message.reply_text(
+ f"Управление питанием Synology NAS\n\n"
+ f"Текущий статус: {status_text}\n\n"
+ f"Выберите действие:",
+ reply_markup=reply_markup,
+ parse_mode="HTML"
+ )
+
+async def power_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для кнопок управления питанием"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = query.from_user.id
+ if user_id not in ADMIN_USER_IDS:
+ return
+
+ action = query.data
+
+ if action == "cancel":
+ await query.edit_message_text("❌ Действие отменено")
+ return
+
+ # Обработка неактивных кнопок
+ if action in ["power_on_no_op", "power_off_no_op", "reboot_no_op"]:
+ if action == "power_on_no_op":
+ await query.edit_message_text("ℹ️ Synology NAS уже включен")
+ elif action == "power_off_no_op":
+ await query.edit_message_text("ℹ️ Synology NAS уже выключен")
+ else:
+ await query.edit_message_text("ℹ️ Невозможно перезагрузить выключенное устройство")
+ return
+
+ # Обработка основных действий
+ if action == "power_on":
+ await query.edit_message_text("⏳ Отправка сигнала включения Synology NAS...")
+
+ if await context.application.create_task(
+ handle_power_on(query.message.chat_id, context)
+ ):
+ # Функция вернула True, успешное включение
+ pass
+ else:
+ # Функция вернула False, ошибка включения
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при включении Synology NAS. Проверьте настройки Wake-on-LAN."
+ )
+
+ elif action == "power_off":
+ await query.edit_message_text("⏳ Отправка команды выключения Synology NAS...")
+
+ try:
+ success = await handle_power_off(query.message.chat_id, context)
+ # Если handle_power_off уже отправил сообщение об успехе или ошибке,
+ # дополнительных сообщений не требуется
+ except Exception as e:
+ logger.error(f"Exception in power_off callback: {str(e)}")
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при выключении Synology NAS. Проверьте соединение и учетные данные."
+ )
+
+ elif action == "reboot":
+ await query.edit_message_text("⏳ Отправка команды перезагрузки Synology NAS...")
+
+ if await context.application.create_task(
+ handle_reboot(query.message.chat_id, context)
+ ):
+ # Функция вернула True, успешная перезагрузка
+ pass
+ else:
+ # Функция вернула False, ошибка перезагрузки
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при перезагрузке Synology NAS. Проверьте соединение и учетные данные."
+ )
+
+async def handle_power_on(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для включения NAS"""
+ try:
+ # Отправка запроса на включение
+ success = synology_api.power_on()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Synology NAS успешно включен и доступен"
+ )
+ return True
+ else:
+ return False
+ except Exception as e:
+ logger.error(f"Error during power on: {str(e)}")
+ return False
+
+async def handle_power_off(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для выключения NAS"""
+ try:
+ # Проверка доступности NAS
+ if not synology_api.is_online():
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Synology NAS не в сети. Невозможно отправить команду выключения."
+ )
+ return False
+
+ # Отправка запроса на выключение
+ success = synology_api.power_off()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Команда выключения успешно отправлена. Synology NAS выключается..."
+ )
+ return True
+ else:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Ошибка при отправке команды выключения. Пожалуйста, проверьте журналы для получения дополнительной информации."
+ )
+ return False
+ except Exception as e:
+ error_msg = str(e)
+ logger.error(f"Error during power off: {error_msg}")
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text=f"❌ Ошибка при выключении: {error_msg[:100]}..."
+ )
+ return False
+
+async def handle_reboot(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для перезагрузки NAS"""
+ try:
+ # Отправка запроса на перезагрузку
+ success = synology_api.reboot_system()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Команда перезагрузки успешно отправлена. Synology NAS перезагружается..."
+ )
+
+ # Ждем некоторое время перед проверкой статуса
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⏳ Ожидание перезагрузки системы..."
+ )
+
+ # Создаем задачу для ожидания загрузки
+ wait_successful = synology_api.wait_for_boot()
+
+ if wait_successful:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Synology NAS успешно перезагружен и снова онлайн"
+ )
+ else:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Превышено время ожидания перезагрузки. Проверьте статус вручную."
+ )
+
+ return True
+ else:
+ return False
+ except Exception as e:
+ logger.error(f"Error during reboot: {str(e)}")
+ return False
diff --git a/.history/src/handlers/command_handlers_20250830110810.py b/.history/src/handlers/command_handlers_20250830110810.py
new file mode 100644
index 0000000..82b5a96
--- /dev/null
+++ b/.history/src/handlers/command_handlers_20250830110810.py
@@ -0,0 +1,325 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Обработчики команд для телеграм-бота
+"""
+
+import logging
+import socket
+from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
+from telegram.ext import ContextTypes
+
+from src.config.config import (
+ ADMIN_USER_IDS, SYNOLOGY_HOST, SYNOLOGY_PORT, SYNOLOGY_MAC
+)
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+logger = logging.getLogger(__name__)
+
+# Инициализация API Synology
+synology_api = SynologyAPI()
+
+from src.utils.admin_utils import admin_required
+
+@admin_required
+async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /status"""
+ message = await update.message.reply_text("⏳ Проверяю статус Synology NAS...")
+
+ is_online = synology_api.is_online()
+
+ if is_online:
+ try:
+ # Если NAS включен, попробуем получить дополнительную информацию
+ system_info = synology_api.get_system_status()
+
+ if system_info and system_info.get("status") != "error":
+ model = system_info.get("model", "Неизвестная модель")
+ version = system_info.get("version_string", system_info.get("version", "Неизвестная версия"))
+ uptime_seconds = system_info.get("up_time", system_info.get("uptime", 0))
+
+ # Преобразование времени работы в удобочитаемый формат
+ days, remainder = divmod(int(uptime_seconds), 86400)
+ hours, remainder = divmod(remainder, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ uptime_str = f"{days}д {hours}ч {minutes}м {seconds}с"
+
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Модель: {model}\n"
+ f"Версия DSM: {version}\n"
+ f"Время работы: {uptime_str}",
+ parse_mode="HTML"
+ )
+ else:
+ # Обработка возможной ошибки API
+ error_info = ""
+ if system_info and system_info.get("status") == "error":
+ error_code = system_info.get("error_code", "неизвестно")
+ error_info = f"\nКод ошибки API: {error_code}"
+
+ # Проверяем порт и сеть
+ network_info = ""
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(1)
+ result = s.connect_ex((SYNOLOGY_HOST, SYNOLOGY_PORT))
+ s.close()
+ if result == 0:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт {SYNOLOGY_PORT}: открыт"
+ else:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт {SYNOLOGY_PORT}: закрыт (код {result})"
+ except Exception as e:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nОшибка проверки порта: {str(e)}"
+
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Устройство доступно по сети, но детальная информация через API недоступна. "
+ f"Возможно, необходимо проверить учетные данные или права доступа."
+ f"{error_info}"
+ f"{network_info}",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Ошибка при получении информации: {str(e)[:100]}...\n\n"
+ f"Сетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт: {SYNOLOGY_PORT}",
+ parse_mode="HTML"
+ )
+ else:
+ # Устройство не в сети, проверим соседние порты для диагностики
+ port_scan_info = ""
+ try:
+ for test_port in [SYNOLOGY_PORT-1, SYNOLOGY_PORT, SYNOLOGY_PORT+1, 5000, 5001]:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(1)
+ result = s.connect_ex((SYNOLOGY_HOST, test_port))
+ s.close()
+ status = "открыт" if result == 0 else "закрыт"
+ port_scan_info += f"Порт {test_port}: {status}\n"
+
+ # Добавим информацию о MAC-адресе для WoL
+ mac_info = f"MAC: {SYNOLOGY_MAC}" if SYNOLOGY_MAC else "MAC-адрес не настроен"
+
+ await message.edit_text(
+ f"❌ Synology NAS оффлайн\n\n"
+ f"Информация о сети:\n"
+ f"IP: {SYNOLOGY_HOST}\n"
+ f"{port_scan_info}\n"
+ f"{mac_info}\n\n"
+ f"Используйте /power для отправки Wake-on-LAN пакета",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ await message.edit_text(
+ f"❌ Synology NAS оффлайн\n\n"
+ f"Ошибка при сканировании портов: {str(e)[:100]}...\n\n"
+ f"Используйте /power для отправки Wake-on-LAN пакета",
+ parse_mode="HTML"
+ )
+
+@admin_required
+async def power_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /power"""
+
+ is_online = synology_api.is_online()
+
+ keyboard = []
+
+ # Кнопка включения
+ if not is_online:
+ keyboard.append([InlineKeyboardButton("🟢 Включить", callback_data="power_on")])
+ else:
+ keyboard.append([InlineKeyboardButton("🟢 Включить (уже включено)", callback_data="power_on_no_op")])
+
+ # Кнопка выключения
+ if is_online:
+ keyboard.append([InlineKeyboardButton("🔴 Выключить", callback_data="power_off")])
+ else:
+ keyboard.append([InlineKeyboardButton("🔴 Выключить (уже выключено)", callback_data="power_off_no_op")])
+
+ # Кнопка перезагрузки
+ if is_online:
+ keyboard.append([InlineKeyboardButton("🔄 Перезагрузить", callback_data="reboot")])
+ else:
+ keyboard.append([InlineKeyboardButton("🔄 Перезагрузить (недоступно)", callback_data="reboot_no_op")])
+
+ # Кнопка отмены
+ keyboard.append([InlineKeyboardButton("❌ Отмена", callback_data="cancel")])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ status_text = "✅ Онлайн" if is_online else "❌ Оффлайн"
+
+ await update.message.reply_text(
+ f"Управление питанием Synology NAS\n\n"
+ f"Текущий статус: {status_text}\n\n"
+ f"Выберите действие:",
+ reply_markup=reply_markup,
+ parse_mode="HTML"
+ )
+
+async def power_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для кнопок управления питанием"""
+ query = update.callback_query
+ await query.answer()
+
+ user_id = query.from_user.id
+ if user_id not in ADMIN_USER_IDS:
+ return
+
+ action = query.data
+
+ if action == "cancel":
+ await query.edit_message_text("❌ Действие отменено")
+ return
+
+ # Обработка неактивных кнопок
+ if action in ["power_on_no_op", "power_off_no_op", "reboot_no_op"]:
+ if action == "power_on_no_op":
+ await query.edit_message_text("ℹ️ Synology NAS уже включен")
+ elif action == "power_off_no_op":
+ await query.edit_message_text("ℹ️ Synology NAS уже выключен")
+ else:
+ await query.edit_message_text("ℹ️ Невозможно перезагрузить выключенное устройство")
+ return
+
+ # Обработка основных действий
+ if action == "power_on":
+ await query.edit_message_text("⏳ Отправка сигнала включения Synology NAS...")
+
+ if await context.application.create_task(
+ handle_power_on(query.message.chat_id, context)
+ ):
+ # Функция вернула True, успешное включение
+ pass
+ else:
+ # Функция вернула False, ошибка включения
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при включении Synology NAS. Проверьте настройки Wake-on-LAN."
+ )
+
+ elif action == "power_off":
+ await query.edit_message_text("⏳ Отправка команды выключения Synology NAS...")
+
+ try:
+ success = await handle_power_off(query.message.chat_id, context)
+ # Если handle_power_off уже отправил сообщение об успехе или ошибке,
+ # дополнительных сообщений не требуется
+ except Exception as e:
+ logger.error(f"Exception in power_off callback: {str(e)}")
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при выключении Synology NAS. Проверьте соединение и учетные данные."
+ )
+
+ elif action == "reboot":
+ await query.edit_message_text("⏳ Отправка команды перезагрузки Synology NAS...")
+
+ if await context.application.create_task(
+ handle_reboot(query.message.chat_id, context)
+ ):
+ # Функция вернула True, успешная перезагрузка
+ pass
+ else:
+ # Функция вернула False, ошибка перезагрузки
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при перезагрузке Synology NAS. Проверьте соединение и учетные данные."
+ )
+
+async def handle_power_on(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для включения NAS"""
+ try:
+ # Отправка запроса на включение
+ success = synology_api.power_on()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Synology NAS успешно включен и доступен"
+ )
+ return True
+ else:
+ return False
+ except Exception as e:
+ logger.error(f"Error during power on: {str(e)}")
+ return False
+
+async def handle_power_off(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для выключения NAS"""
+ try:
+ # Проверка доступности NAS
+ if not synology_api.is_online():
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Synology NAS не в сети. Невозможно отправить команду выключения."
+ )
+ return False
+
+ # Отправка запроса на выключение
+ success = synology_api.power_off()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Команда выключения успешно отправлена. Synology NAS выключается..."
+ )
+ return True
+ else:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Ошибка при отправке команды выключения. Пожалуйста, проверьте журналы для получения дополнительной информации."
+ )
+ return False
+ except Exception as e:
+ error_msg = str(e)
+ logger.error(f"Error during power off: {error_msg}")
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text=f"❌ Ошибка при выключении: {error_msg[:100]}..."
+ )
+ return False
+
+async def handle_reboot(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для перезагрузки NAS"""
+ try:
+ # Отправка запроса на перезагрузку
+ success = synology_api.reboot_system()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Команда перезагрузки успешно отправлена. Synology NAS перезагружается..."
+ )
+
+ # Ждем некоторое время перед проверкой статуса
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⏳ Ожидание перезагрузки системы..."
+ )
+
+ # Создаем задачу для ожидания загрузки
+ wait_successful = synology_api.wait_for_boot()
+
+ if wait_successful:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Synology NAS успешно перезагружен и снова онлайн"
+ )
+ else:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Превышено время ожидания перезагрузки. Проверьте статус вручную."
+ )
+
+ return True
+ else:
+ return False
+ except Exception as e:
+ logger.error(f"Error during reboot: {str(e)}")
+ return False
diff --git a/.history/src/handlers/command_handlers_20250830110839.py b/.history/src/handlers/command_handlers_20250830110839.py
new file mode 100644
index 0000000..68fa640
--- /dev/null
+++ b/.history/src/handlers/command_handlers_20250830110839.py
@@ -0,0 +1,322 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Обработчики команд для телеграм-бота
+"""
+
+import logging
+import socket
+from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
+from telegram.ext import ContextTypes
+
+from src.config.config import (
+ ADMIN_USER_IDS, SYNOLOGY_HOST, SYNOLOGY_PORT, SYNOLOGY_MAC
+)
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+logger = logging.getLogger(__name__)
+
+# Инициализация API Synology
+synology_api = SynologyAPI()
+
+from src.utils.admin_utils import admin_required
+
+@admin_required
+async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /status"""
+ message = await update.message.reply_text("⏳ Проверяю статус Synology NAS...")
+
+ is_online = synology_api.is_online()
+
+ if is_online:
+ try:
+ # Если NAS включен, попробуем получить дополнительную информацию
+ system_info = synology_api.get_system_status()
+
+ if system_info and system_info.get("status") != "error":
+ model = system_info.get("model", "Неизвестная модель")
+ version = system_info.get("version_string", system_info.get("version", "Неизвестная версия"))
+ uptime_seconds = system_info.get("up_time", system_info.get("uptime", 0))
+
+ # Преобразование времени работы в удобочитаемый формат
+ days, remainder = divmod(int(uptime_seconds), 86400)
+ hours, remainder = divmod(remainder, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ uptime_str = f"{days}д {hours}ч {minutes}м {seconds}с"
+
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Модель: {model}\n"
+ f"Версия DSM: {version}\n"
+ f"Время работы: {uptime_str}",
+ parse_mode="HTML"
+ )
+ else:
+ # Обработка возможной ошибки API
+ error_info = ""
+ if system_info and system_info.get("status") == "error":
+ error_code = system_info.get("error_code", "неизвестно")
+ error_info = f"\nКод ошибки API: {error_code}"
+
+ # Проверяем порт и сеть
+ network_info = ""
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(1)
+ result = s.connect_ex((SYNOLOGY_HOST, SYNOLOGY_PORT))
+ s.close()
+ if result == 0:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт {SYNOLOGY_PORT}: открыт"
+ else:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт {SYNOLOGY_PORT}: закрыт (код {result})"
+ except Exception as e:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nОшибка проверки порта: {str(e)}"
+
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Устройство доступно по сети, но детальная информация через API недоступна. "
+ f"Возможно, необходимо проверить учетные данные или права доступа."
+ f"{error_info}"
+ f"{network_info}",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Ошибка при получении информации: {str(e)[:100]}...\n\n"
+ f"Сетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт: {SYNOLOGY_PORT}",
+ parse_mode="HTML"
+ )
+ else:
+ # Устройство не в сети, проверим соседние порты для диагностики
+ port_scan_info = ""
+ try:
+ for test_port in [SYNOLOGY_PORT-1, SYNOLOGY_PORT, SYNOLOGY_PORT+1, 5000, 5001]:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(1)
+ result = s.connect_ex((SYNOLOGY_HOST, test_port))
+ s.close()
+ status = "открыт" if result == 0 else "закрыт"
+ port_scan_info += f"Порт {test_port}: {status}\n"
+
+ # Добавим информацию о MAC-адресе для WoL
+ mac_info = f"MAC: {SYNOLOGY_MAC}" if SYNOLOGY_MAC else "MAC-адрес не настроен"
+
+ await message.edit_text(
+ f"❌ Synology NAS оффлайн\n\n"
+ f"Информация о сети:\n"
+ f"IP: {SYNOLOGY_HOST}\n"
+ f"{port_scan_info}\n"
+ f"{mac_info}\n\n"
+ f"Используйте /power для отправки Wake-on-LAN пакета",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ await message.edit_text(
+ f"❌ Synology NAS оффлайн\n\n"
+ f"Ошибка при сканировании портов: {str(e)[:100]}...\n\n"
+ f"Используйте /power для отправки Wake-on-LAN пакета",
+ parse_mode="HTML"
+ )
+
+@admin_required
+async def power_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /power"""
+
+ is_online = synology_api.is_online()
+
+ keyboard = []
+
+ # Кнопка включения
+ if not is_online:
+ keyboard.append([InlineKeyboardButton("🟢 Включить", callback_data="power_on")])
+ else:
+ keyboard.append([InlineKeyboardButton("🟢 Включить (уже включено)", callback_data="power_on_no_op")])
+
+ # Кнопка выключения
+ if is_online:
+ keyboard.append([InlineKeyboardButton("🔴 Выключить", callback_data="power_off")])
+ else:
+ keyboard.append([InlineKeyboardButton("🔴 Выключить (уже выключено)", callback_data="power_off_no_op")])
+
+ # Кнопка перезагрузки
+ if is_online:
+ keyboard.append([InlineKeyboardButton("🔄 Перезагрузить", callback_data="reboot")])
+ else:
+ keyboard.append([InlineKeyboardButton("🔄 Перезагрузить (недоступно)", callback_data="reboot_no_op")])
+
+ # Кнопка отмены
+ keyboard.append([InlineKeyboardButton("❌ Отмена", callback_data="cancel")])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ status_text = "✅ Онлайн" if is_online else "❌ Оффлайн"
+
+ await update.message.reply_text(
+ f"Управление питанием Synology NAS\n\n"
+ f"Текущий статус: {status_text}\n\n"
+ f"Выберите действие:",
+ reply_markup=reply_markup,
+ parse_mode="HTML"
+ )
+
+@admin_required
+async def power_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для кнопок управления питанием"""
+ query = update.callback_query
+ await query.answer()
+
+ action = query.data
+
+ if action == "cancel":
+ await query.edit_message_text("❌ Действие отменено")
+ return
+
+ # Обработка неактивных кнопок
+ if action in ["power_on_no_op", "power_off_no_op", "reboot_no_op"]:
+ if action == "power_on_no_op":
+ await query.edit_message_text("ℹ️ Synology NAS уже включен")
+ elif action == "power_off_no_op":
+ await query.edit_message_text("ℹ️ Synology NAS уже выключен")
+ else:
+ await query.edit_message_text("ℹ️ Невозможно перезагрузить выключенное устройство")
+ return
+
+ # Обработка основных действий
+ if action == "power_on":
+ await query.edit_message_text("⏳ Отправка сигнала включения Synology NAS...")
+
+ if await context.application.create_task(
+ handle_power_on(query.message.chat_id, context)
+ ):
+ # Функция вернула True, успешное включение
+ pass
+ else:
+ # Функция вернула False, ошибка включения
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при включении Synology NAS. Проверьте настройки Wake-on-LAN."
+ )
+
+ elif action == "power_off":
+ await query.edit_message_text("⏳ Отправка команды выключения Synology NAS...")
+
+ try:
+ success = await handle_power_off(query.message.chat_id, context)
+ # Если handle_power_off уже отправил сообщение об успехе или ошибке,
+ # дополнительных сообщений не требуется
+ except Exception as e:
+ logger.error(f"Exception in power_off callback: {str(e)}")
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при выключении Synology NAS. Проверьте соединение и учетные данные."
+ )
+
+ elif action == "reboot":
+ await query.edit_message_text("⏳ Отправка команды перезагрузки Synology NAS...")
+
+ if await context.application.create_task(
+ handle_reboot(query.message.chat_id, context)
+ ):
+ # Функция вернула True, успешная перезагрузка
+ pass
+ else:
+ # Функция вернула False, ошибка перезагрузки
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при перезагрузке Synology NAS. Проверьте соединение и учетные данные."
+ )
+
+async def handle_power_on(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для включения NAS"""
+ try:
+ # Отправка запроса на включение
+ success = synology_api.power_on()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Synology NAS успешно включен и доступен"
+ )
+ return True
+ else:
+ return False
+ except Exception as e:
+ logger.error(f"Error during power on: {str(e)}")
+ return False
+
+async def handle_power_off(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для выключения NAS"""
+ try:
+ # Проверка доступности NAS
+ if not synology_api.is_online():
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Synology NAS не в сети. Невозможно отправить команду выключения."
+ )
+ return False
+
+ # Отправка запроса на выключение
+ success = synology_api.power_off()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Команда выключения успешно отправлена. Synology NAS выключается..."
+ )
+ return True
+ else:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Ошибка при отправке команды выключения. Пожалуйста, проверьте журналы для получения дополнительной информации."
+ )
+ return False
+ except Exception as e:
+ error_msg = str(e)
+ logger.error(f"Error during power off: {error_msg}")
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text=f"❌ Ошибка при выключении: {error_msg[:100]}..."
+ )
+ return False
+
+async def handle_reboot(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для перезагрузки NAS"""
+ try:
+ # Отправка запроса на перезагрузку
+ success = synology_api.reboot_system()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Команда перезагрузки успешно отправлена. Synology NAS перезагружается..."
+ )
+
+ # Ждем некоторое время перед проверкой статуса
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⏳ Ожидание перезагрузки системы..."
+ )
+
+ # Создаем задачу для ожидания загрузки
+ wait_successful = synology_api.wait_for_boot()
+
+ if wait_successful:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Synology NAS успешно перезагружен и снова онлайн"
+ )
+ else:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Превышено время ожидания перезагрузки. Проверьте статус вручную."
+ )
+
+ return True
+ else:
+ return False
+ except Exception as e:
+ logger.error(f"Error during reboot: {str(e)}")
+ return False
diff --git a/.history/src/handlers/command_handlers_20250830110906.py b/.history/src/handlers/command_handlers_20250830110906.py
new file mode 100644
index 0000000..68fa640
--- /dev/null
+++ b/.history/src/handlers/command_handlers_20250830110906.py
@@ -0,0 +1,322 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Обработчики команд для телеграм-бота
+"""
+
+import logging
+import socket
+from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
+from telegram.ext import ContextTypes
+
+from src.config.config import (
+ ADMIN_USER_IDS, SYNOLOGY_HOST, SYNOLOGY_PORT, SYNOLOGY_MAC
+)
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+logger = logging.getLogger(__name__)
+
+# Инициализация API Synology
+synology_api = SynologyAPI()
+
+from src.utils.admin_utils import admin_required
+
+@admin_required
+async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /status"""
+ message = await update.message.reply_text("⏳ Проверяю статус Synology NAS...")
+
+ is_online = synology_api.is_online()
+
+ if is_online:
+ try:
+ # Если NAS включен, попробуем получить дополнительную информацию
+ system_info = synology_api.get_system_status()
+
+ if system_info and system_info.get("status") != "error":
+ model = system_info.get("model", "Неизвестная модель")
+ version = system_info.get("version_string", system_info.get("version", "Неизвестная версия"))
+ uptime_seconds = system_info.get("up_time", system_info.get("uptime", 0))
+
+ # Преобразование времени работы в удобочитаемый формат
+ days, remainder = divmod(int(uptime_seconds), 86400)
+ hours, remainder = divmod(remainder, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ uptime_str = f"{days}д {hours}ч {minutes}м {seconds}с"
+
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Модель: {model}\n"
+ f"Версия DSM: {version}\n"
+ f"Время работы: {uptime_str}",
+ parse_mode="HTML"
+ )
+ else:
+ # Обработка возможной ошибки API
+ error_info = ""
+ if system_info and system_info.get("status") == "error":
+ error_code = system_info.get("error_code", "неизвестно")
+ error_info = f"\nКод ошибки API: {error_code}"
+
+ # Проверяем порт и сеть
+ network_info = ""
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(1)
+ result = s.connect_ex((SYNOLOGY_HOST, SYNOLOGY_PORT))
+ s.close()
+ if result == 0:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт {SYNOLOGY_PORT}: открыт"
+ else:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт {SYNOLOGY_PORT}: закрыт (код {result})"
+ except Exception as e:
+ network_info = f"\n\nСетевая информация:\nIP: {SYNOLOGY_HOST}\nОшибка проверки порта: {str(e)}"
+
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Устройство доступно по сети, но детальная информация через API недоступна. "
+ f"Возможно, необходимо проверить учетные данные или права доступа."
+ f"{error_info}"
+ f"{network_info}",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ await message.edit_text(
+ f"✅ Synology NAS онлайн\n\n"
+ f"Ошибка при получении информации: {str(e)[:100]}...\n\n"
+ f"Сетевая информация:\nIP: {SYNOLOGY_HOST}\nПорт: {SYNOLOGY_PORT}",
+ parse_mode="HTML"
+ )
+ else:
+ # Устройство не в сети, проверим соседние порты для диагностики
+ port_scan_info = ""
+ try:
+ for test_port in [SYNOLOGY_PORT-1, SYNOLOGY_PORT, SYNOLOGY_PORT+1, 5000, 5001]:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(1)
+ result = s.connect_ex((SYNOLOGY_HOST, test_port))
+ s.close()
+ status = "открыт" if result == 0 else "закрыт"
+ port_scan_info += f"Порт {test_port}: {status}\n"
+
+ # Добавим информацию о MAC-адресе для WoL
+ mac_info = f"MAC: {SYNOLOGY_MAC}" if SYNOLOGY_MAC else "MAC-адрес не настроен"
+
+ await message.edit_text(
+ f"❌ Synology NAS оффлайн\n\n"
+ f"Информация о сети:\n"
+ f"IP: {SYNOLOGY_HOST}\n"
+ f"{port_scan_info}\n"
+ f"{mac_info}\n\n"
+ f"Используйте /power для отправки Wake-on-LAN пакета",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ await message.edit_text(
+ f"❌ Synology NAS оффлайн\n\n"
+ f"Ошибка при сканировании портов: {str(e)[:100]}...\n\n"
+ f"Используйте /power для отправки Wake-on-LAN пакета",
+ parse_mode="HTML"
+ )
+
+@admin_required
+async def power_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /power"""
+
+ is_online = synology_api.is_online()
+
+ keyboard = []
+
+ # Кнопка включения
+ if not is_online:
+ keyboard.append([InlineKeyboardButton("🟢 Включить", callback_data="power_on")])
+ else:
+ keyboard.append([InlineKeyboardButton("🟢 Включить (уже включено)", callback_data="power_on_no_op")])
+
+ # Кнопка выключения
+ if is_online:
+ keyboard.append([InlineKeyboardButton("🔴 Выключить", callback_data="power_off")])
+ else:
+ keyboard.append([InlineKeyboardButton("🔴 Выключить (уже выключено)", callback_data="power_off_no_op")])
+
+ # Кнопка перезагрузки
+ if is_online:
+ keyboard.append([InlineKeyboardButton("🔄 Перезагрузить", callback_data="reboot")])
+ else:
+ keyboard.append([InlineKeyboardButton("🔄 Перезагрузить (недоступно)", callback_data="reboot_no_op")])
+
+ # Кнопка отмены
+ keyboard.append([InlineKeyboardButton("❌ Отмена", callback_data="cancel")])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ status_text = "✅ Онлайн" if is_online else "❌ Оффлайн"
+
+ await update.message.reply_text(
+ f"Управление питанием Synology NAS\n\n"
+ f"Текущий статус: {status_text}\n\n"
+ f"Выберите действие:",
+ reply_markup=reply_markup,
+ parse_mode="HTML"
+ )
+
+@admin_required
+async def power_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик callback-запросов для кнопок управления питанием"""
+ query = update.callback_query
+ await query.answer()
+
+ action = query.data
+
+ if action == "cancel":
+ await query.edit_message_text("❌ Действие отменено")
+ return
+
+ # Обработка неактивных кнопок
+ if action in ["power_on_no_op", "power_off_no_op", "reboot_no_op"]:
+ if action == "power_on_no_op":
+ await query.edit_message_text("ℹ️ Synology NAS уже включен")
+ elif action == "power_off_no_op":
+ await query.edit_message_text("ℹ️ Synology NAS уже выключен")
+ else:
+ await query.edit_message_text("ℹ️ Невозможно перезагрузить выключенное устройство")
+ return
+
+ # Обработка основных действий
+ if action == "power_on":
+ await query.edit_message_text("⏳ Отправка сигнала включения Synology NAS...")
+
+ if await context.application.create_task(
+ handle_power_on(query.message.chat_id, context)
+ ):
+ # Функция вернула True, успешное включение
+ pass
+ else:
+ # Функция вернула False, ошибка включения
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при включении Synology NAS. Проверьте настройки Wake-on-LAN."
+ )
+
+ elif action == "power_off":
+ await query.edit_message_text("⏳ Отправка команды выключения Synology NAS...")
+
+ try:
+ success = await handle_power_off(query.message.chat_id, context)
+ # Если handle_power_off уже отправил сообщение об успехе или ошибке,
+ # дополнительных сообщений не требуется
+ except Exception as e:
+ logger.error(f"Exception in power_off callback: {str(e)}")
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при выключении Synology NAS. Проверьте соединение и учетные данные."
+ )
+
+ elif action == "reboot":
+ await query.edit_message_text("⏳ Отправка команды перезагрузки Synology NAS...")
+
+ if await context.application.create_task(
+ handle_reboot(query.message.chat_id, context)
+ ):
+ # Функция вернула True, успешная перезагрузка
+ pass
+ else:
+ # Функция вернула False, ошибка перезагрузки
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="❌ Ошибка при перезагрузке Synology NAS. Проверьте соединение и учетные данные."
+ )
+
+async def handle_power_on(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для включения NAS"""
+ try:
+ # Отправка запроса на включение
+ success = synology_api.power_on()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Synology NAS успешно включен и доступен"
+ )
+ return True
+ else:
+ return False
+ except Exception as e:
+ logger.error(f"Error during power on: {str(e)}")
+ return False
+
+async def handle_power_off(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для выключения NAS"""
+ try:
+ # Проверка доступности NAS
+ if not synology_api.is_online():
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Synology NAS не в сети. Невозможно отправить команду выключения."
+ )
+ return False
+
+ # Отправка запроса на выключение
+ success = synology_api.power_off()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Команда выключения успешно отправлена. Synology NAS выключается..."
+ )
+ return True
+ else:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Ошибка при отправке команды выключения. Пожалуйста, проверьте журналы для получения дополнительной информации."
+ )
+ return False
+ except Exception as e:
+ error_msg = str(e)
+ logger.error(f"Error during power off: {error_msg}")
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text=f"❌ Ошибка при выключении: {error_msg[:100]}..."
+ )
+ return False
+
+async def handle_reboot(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Асинхронная функция для перезагрузки NAS"""
+ try:
+ # Отправка запроса на перезагрузку
+ success = synology_api.reboot_system()
+
+ if success:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Команда перезагрузки успешно отправлена. Synology NAS перезагружается..."
+ )
+
+ # Ждем некоторое время перед проверкой статуса
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⏳ Ожидание перезагрузки системы..."
+ )
+
+ # Создаем задачу для ожидания загрузки
+ wait_successful = synology_api.wait_for_boot()
+
+ if wait_successful:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="✅ Synology NAS успешно перезагружен и снова онлайн"
+ )
+ else:
+ await context.bot.send_message(
+ chat_id=chat_id,
+ text="⚠️ Превышено время ожидания перезагрузки. Проверьте статус вручную."
+ )
+
+ return True
+ else:
+ return False
+ except Exception as e:
+ logger.error(f"Error during reboot: {str(e)}")
+ return False
diff --git a/.history/src/handlers/extended_handlers_20250830104501.py b/.history/src/handlers/extended_handlers_20250830104501.py
new file mode 100644
index 0000000..de55528
--- /dev/null
+++ b/.history/src/handlers/extended_handlers_20250830104501.py
@@ -0,0 +1,378 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Дополнительные обработчики команд для телеграм-бота
+"""
+
+import logging
+from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
+from telegram.ext import ContextTypes
+
+from src.config.config import ADMIN_USER_IDS
+from src.api.synology import SynologyAPI
+
+logger = logging.getLogger(__name__)
+
+# Инициализация API Synology
+synology_api = SynologyAPI()
+
+async def check_api_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /checkapi для диагностики проблем с API"""
+ if not update.message or not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Проверка доступных API Synology NAS...")
+
+ from src.api.api_discovery import discover_available_apis
+ from src.config.config import (
+ SYNOLOGY_HOST,
+ SYNOLOGY_PORT,
+ SYNOLOGY_SECURE,
+ SYNOLOGY_POWER_API,
+ SYNOLOGY_INFO_API,
+ SYNOLOGY_API_VERSION
+ )
+
+ # Формируем базовый URL
+ protocol = "https" if SYNOLOGY_SECURE else "http"
+ base_url = f"{protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/webapi"
+
+ # Получаем список доступных API
+ apis = discover_available_apis(base_url)
+
+ if not apis:
+ await message.edit_text(
+ "❌ Не удалось получить список доступных API\n\n"
+ "Проверьте доступность NAS и сетевое подключение.",
+ parse_mode="HTML"
+ )
+ return
+
+ # Поиск API для управления питанием
+ power_apis = [name for name in apis.keys() if "power" in name.lower()]
+ system_info_apis = [name for name in apis.keys() if ("system" in name.lower() or "dsm" in name.lower()) and "info" in name.lower()]
+ reboot_apis = [name for name in apis.keys() if any(word in name.lower() for word in ["restart", "reboot", "power"])]
+
+ # Формируем рекомендации
+ recommended_power_api = max(power_apis, key=lambda x: apis[x].get('maxVersion', 1)) if power_apis else "SYNO.Core.System"
+ recommended_info_api = max(system_info_apis, key=lambda x: apis[x].get('maxVersion', 1)) if system_info_apis else "SYNO.DSM.Info"
+
+ # Формируем текст отчета
+ api_report = (
+ f"✅ Найдено {len(apis)} доступных API\n\n"
+ f"API для управления питанием:\n"
+ f"{', '.join(power_apis[:5]) or 'Не найдены'}\n\n"
+ f"API для информации о системе:\n"
+ f"{', '.join(system_info_apis[:5]) or 'Не найдены'}\n\n"
+ f"API для перезагрузки:\n"
+ f"{', '.join(reboot_apis[:5]) or 'Не найдены'}\n\n"
+ f"Рекомендуемые настройки:\n"
+ f"Power API: {recommended_power_api}\n"
+ f"Info API: {recommended_info_api}\n\n"
+ f"Текущие настройки в конфигурации:\n"
+ f"SYNOLOGY_POWER_API = {SYNOLOGY_POWER_API}\n"
+ f"SYNOLOGY_INFO_API = {SYNOLOGY_INFO_API}\n"
+ f"SYNOLOGY_API_VERSION = {SYNOLOGY_API_VERSION}"
+ )
+
+ await message.edit_text(api_report, parse_mode="HTML")
+
+async def storage_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /storage"""
+ if not update.message or not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о хранилище...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о хранилище.", parse_mode="HTML")
+ return
+
+ try:
+ storage_info = synology_api.get_storage_status()
+
+ if not storage_info:
+ await message.edit_text("❌ Ошибка получения информации о хранилище\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о хранилище\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о состоянии хранилища
+ summary = storage_info.get("summary", {})
+ total_size_gb = summary.get("total_space_gb", 0)
+ total_used_gb = summary.get("used_space_gb", 0)
+ free_space_gb = summary.get("free_space_gb", 0)
+ usage_percent = summary.get("usage_percent", 0)
+
+ reply_text = f"📊 Информация о хранилище Synology NAS\n\n"
+ reply_text += f"Общий размер: {total_size_gb:.2f} ГБ\n"
+ reply_text += f"Использовано: {total_used_gb:.2f} ГБ ({usage_percent:.1f}%)\n"
+ reply_text += f"Свободно: {free_space_gb:.2f} ГБ\n\n"
+
+ # Добавляем информацию о томах
+ volumes = storage_info.get("volumes", [])
+ if volumes:
+ reply_text += "Тома:\n"
+ for volume in volumes:
+ name = volume.get("name", "Неизвестно")
+ status = volume.get("status", "Неизвестно")
+ size = volume.get("size", 0)
+ used_size = volume.get("used_size", 0)
+ size_gb = size / (1024**3)
+ used_gb = used_size / (1024**3)
+ percent = round((used_size / size) * 100, 1) if size > 0 else 0
+
+ reply_text += f"• {name} ({status})\n"
+ reply_text += f" └ {used_gb:.1f} ГБ из {size_gb:.1f} ГБ ({percent}%)\n"
+
+ # Добавляем информацию о дисках
+ disks = storage_info.get("disks", [])
+ if disks:
+ reply_text += "\nДиски:\n"
+ for disk in disks:
+ name = disk.get("name", "Неизвестно")
+ model = disk.get("model", "Неизвестно")
+ status = disk.get("status", "Неизвестно")
+ temp = disk.get("temp", "?")
+
+ reply_text += f"• {name} - {model}\n"
+ reply_text += f" └ Статус: {status}, Температура: {temp}°C\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def shares_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /shares"""
+ if not update.message or not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации об общих папках...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию об общих папках.", parse_mode="HTML")
+ return
+
+ try:
+ shares = synology_api.get_shared_folders()
+
+ if not shares:
+ await message.edit_text("❌ Ошибка получения информации об общих папках\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации об общих папках\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение об общих папках
+ reply_text = f"📁 Общие папки Synology NAS\n\n"
+
+ for share in shares:
+ name = share.get("name", "Неизвестно")
+ path = share.get("path", "Неизвестно")
+ desc = share.get("desc", "")
+
+ reply_text += f"• {name}\n"
+ reply_text += f" └ Путь: {path}\n"
+
+ if desc:
+ reply_text += f" └ Описание: {desc}\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def system_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /system"""
+ if not update.message or not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о системе...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о системе.", parse_mode="HTML")
+ return
+
+ try:
+ system_status = synology_api.get_system_status()
+
+ if not system_status:
+ await message.edit_text("❌ Ошибка получения информации о системе", parse_mode="HTML")
+ return
+
+ # Если получен статус с ошибкой
+ if system_status.get("status") == "error":
+ error_code = system_status.get("error_code", "неизвестно")
+ await message.edit_text(f"❌ Ошибка получения информации о системе\n\nКод ошибки API: {error_code}", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о системе\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о состоянии системы
+ model = system_status.get("model", "Неизвестно")
+ version = system_status.get("version", "Неизвестно")
+ serial = system_status.get("serial", "Неизвестно")
+ uptime_seconds = system_status.get("uptime", 0)
+ temperature = system_status.get("temperature", "?")
+
+ # Преобразование времени работы в удобочитаемый формат
+ days, remainder = divmod(uptime_seconds, 86400)
+ hours, remainder = divmod(remainder, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ uptime_str = f"{days}д {hours}ч {minutes}м {seconds}с"
+
+ reply_text = f"🖥️ Информация о системе Synology NAS\n\n"
+ reply_text += f"Модель: {model}\n"
+ reply_text += f"Серийный номер: {serial}\n"
+ reply_text += f"Версия DSM: {version}\n"
+ reply_text += f"Время работы: {uptime_str}\n"
+ reply_text += f"Температура: {temperature}°C\n\n"
+
+ # Добавляем информацию о CPU и памяти
+ memory = system_status.get("memory", {})
+ total_memory_gb = memory.get("total_mb", 0) / 1024
+ available_memory_gb = memory.get("available_mb", 0) / 1024
+ memory_usage = memory.get("usage_percent", 0)
+ cpu_usage = system_status.get("cpu_usage", 0)
+
+ reply_text += f"Загрузка CPU: {cpu_usage}%\n"
+ reply_text += f"Память: {total_memory_gb:.1f} ГБ (используется {memory_usage}%)\n"
+ reply_text += f"Доступно памяти: {available_memory_gb:.1f} ГБ\n\n"
+
+ # Добавляем информацию о сетевых интерфейсах
+ network_info = system_status.get("network", [])
+ if network_info:
+ reply_text += "Сетевые интерфейсы:\n"
+ for interface in network_info:
+ device = interface.get("device", "Неизвестно")
+ ip = interface.get("ip", "Неизвестно")
+ mac = interface.get("mac", "Неизвестно")
+
+ reply_text += f"• {device}\n"
+ reply_text += f" └ IP: {ip}, MAC: {mac}\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def load_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /load"""
+ if not update.message or not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о нагрузке системы...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о нагрузке системы.", parse_mode="HTML")
+ return
+
+ try:
+ system_load = synology_api.get_system_load()
+
+ if not system_load:
+ await message.edit_text("❌ Ошибка получения информации о нагрузке системы\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о нагрузке системы\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о нагрузке системы
+ cpu_load = system_load.get("cpu_load", 0)
+ memory = system_load.get("memory", {})
+ memory_usage = memory.get("usage_percent", 0)
+
+ reply_text = f"📈 Текущая нагрузка Synology NAS\n\n"
+ reply_text += f"Загрузка CPU: {cpu_load}%\n"
+ reply_text += f"Загрузка памяти: {memory_usage}%\n\n"
+
+ # Добавляем информацию о сетевой активности
+ network = system_load.get("network", [])
+ if network:
+ reply_text += "Сетевая активность:\n"
+ if isinstance(network, dict):
+ # Если это словарь (старое API)
+ for device, stats in network.items():
+ rx = int(stats.get("rx_bytes", 0)) / (1024**2) # МБ
+ tx = int(stats.get("tx_bytes", 0)) / (1024**2) # МБ
+
+ reply_text += f"• {device}\n"
+ reply_text += f" └ Получено: {rx:.1f} МБ, Отправлено: {tx:.1f} МБ\n"
+ elif isinstance(network, list):
+ # Если это список (новое API)
+ for interface in network:
+ device = interface.get("device", "неизвестно")
+ rx = int(interface.get("rx", 0)) / (1024**2) # МБ
+ tx = int(interface.get("tx", 0)) / (1024**2) # МБ
+
+ reply_text += f"• {device}\n"
+ reply_text += f" └ Получено: {rx:.1f} МБ, Отправлено: {tx:.1f} МБ\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def security_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /security"""
+ if not update.message or not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о безопасности...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о безопасности.", parse_mode="HTML")
+ return
+
+ try:
+ security_info = synology_api.get_security_status()
+
+ if not security_info.get("success", False):
+ await message.edit_text("❌ Ошибка получения информации о безопасности\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о безопасности\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о безопасности
+ status = security_info.get("status", "unknown")
+ is_secure = security_info.get("is_secure", False)
+ last_check = security_info.get("last_check", "Неизвестно")
+
+ status_emoji = "✅" if is_secure else "⚠️"
+ status_text = "Безопасно" if is_secure else "Требуется внимание"
+
+ reply_text = f"🔐 Статус безопасности Synology NAS\n\n"
+ reply_text += f"Статус: {status_emoji} {status_text}\n"
+ reply_text += f"Подробности: {status}\n"
+ reply_text += f"Последняя проверка: {last_check}\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
diff --git a/.history/src/handlers/extended_handlers_20250830104715.py b/.history/src/handlers/extended_handlers_20250830104715.py
new file mode 100644
index 0000000..de55528
--- /dev/null
+++ b/.history/src/handlers/extended_handlers_20250830104715.py
@@ -0,0 +1,378 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Дополнительные обработчики команд для телеграм-бота
+"""
+
+import logging
+from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
+from telegram.ext import ContextTypes
+
+from src.config.config import ADMIN_USER_IDS
+from src.api.synology import SynologyAPI
+
+logger = logging.getLogger(__name__)
+
+# Инициализация API Synology
+synology_api = SynologyAPI()
+
+async def check_api_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /checkapi для диагностики проблем с API"""
+ if not update.message or not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Проверка доступных API Synology NAS...")
+
+ from src.api.api_discovery import discover_available_apis
+ from src.config.config import (
+ SYNOLOGY_HOST,
+ SYNOLOGY_PORT,
+ SYNOLOGY_SECURE,
+ SYNOLOGY_POWER_API,
+ SYNOLOGY_INFO_API,
+ SYNOLOGY_API_VERSION
+ )
+
+ # Формируем базовый URL
+ protocol = "https" if SYNOLOGY_SECURE else "http"
+ base_url = f"{protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/webapi"
+
+ # Получаем список доступных API
+ apis = discover_available_apis(base_url)
+
+ if not apis:
+ await message.edit_text(
+ "❌ Не удалось получить список доступных API\n\n"
+ "Проверьте доступность NAS и сетевое подключение.",
+ parse_mode="HTML"
+ )
+ return
+
+ # Поиск API для управления питанием
+ power_apis = [name for name in apis.keys() if "power" in name.lower()]
+ system_info_apis = [name for name in apis.keys() if ("system" in name.lower() or "dsm" in name.lower()) and "info" in name.lower()]
+ reboot_apis = [name for name in apis.keys() if any(word in name.lower() for word in ["restart", "reboot", "power"])]
+
+ # Формируем рекомендации
+ recommended_power_api = max(power_apis, key=lambda x: apis[x].get('maxVersion', 1)) if power_apis else "SYNO.Core.System"
+ recommended_info_api = max(system_info_apis, key=lambda x: apis[x].get('maxVersion', 1)) if system_info_apis else "SYNO.DSM.Info"
+
+ # Формируем текст отчета
+ api_report = (
+ f"✅ Найдено {len(apis)} доступных API\n\n"
+ f"API для управления питанием:\n"
+ f"{', '.join(power_apis[:5]) or 'Не найдены'}\n\n"
+ f"API для информации о системе:\n"
+ f"{', '.join(system_info_apis[:5]) or 'Не найдены'}\n\n"
+ f"API для перезагрузки:\n"
+ f"{', '.join(reboot_apis[:5]) or 'Не найдены'}\n\n"
+ f"Рекомендуемые настройки:\n"
+ f"Power API: {recommended_power_api}\n"
+ f"Info API: {recommended_info_api}\n\n"
+ f"Текущие настройки в конфигурации:\n"
+ f"SYNOLOGY_POWER_API = {SYNOLOGY_POWER_API}\n"
+ f"SYNOLOGY_INFO_API = {SYNOLOGY_INFO_API}\n"
+ f"SYNOLOGY_API_VERSION = {SYNOLOGY_API_VERSION}"
+ )
+
+ await message.edit_text(api_report, parse_mode="HTML")
+
+async def storage_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /storage"""
+ if not update.message or not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о хранилище...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о хранилище.", parse_mode="HTML")
+ return
+
+ try:
+ storage_info = synology_api.get_storage_status()
+
+ if not storage_info:
+ await message.edit_text("❌ Ошибка получения информации о хранилище\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о хранилище\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о состоянии хранилища
+ summary = storage_info.get("summary", {})
+ total_size_gb = summary.get("total_space_gb", 0)
+ total_used_gb = summary.get("used_space_gb", 0)
+ free_space_gb = summary.get("free_space_gb", 0)
+ usage_percent = summary.get("usage_percent", 0)
+
+ reply_text = f"📊 Информация о хранилище Synology NAS\n\n"
+ reply_text += f"Общий размер: {total_size_gb:.2f} ГБ\n"
+ reply_text += f"Использовано: {total_used_gb:.2f} ГБ ({usage_percent:.1f}%)\n"
+ reply_text += f"Свободно: {free_space_gb:.2f} ГБ\n\n"
+
+ # Добавляем информацию о томах
+ volumes = storage_info.get("volumes", [])
+ if volumes:
+ reply_text += "Тома:\n"
+ for volume in volumes:
+ name = volume.get("name", "Неизвестно")
+ status = volume.get("status", "Неизвестно")
+ size = volume.get("size", 0)
+ used_size = volume.get("used_size", 0)
+ size_gb = size / (1024**3)
+ used_gb = used_size / (1024**3)
+ percent = round((used_size / size) * 100, 1) if size > 0 else 0
+
+ reply_text += f"• {name} ({status})\n"
+ reply_text += f" └ {used_gb:.1f} ГБ из {size_gb:.1f} ГБ ({percent}%)\n"
+
+ # Добавляем информацию о дисках
+ disks = storage_info.get("disks", [])
+ if disks:
+ reply_text += "\nДиски:\n"
+ for disk in disks:
+ name = disk.get("name", "Неизвестно")
+ model = disk.get("model", "Неизвестно")
+ status = disk.get("status", "Неизвестно")
+ temp = disk.get("temp", "?")
+
+ reply_text += f"• {name} - {model}\n"
+ reply_text += f" └ Статус: {status}, Температура: {temp}°C\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def shares_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /shares"""
+ if not update.message or not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации об общих папках...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию об общих папках.", parse_mode="HTML")
+ return
+
+ try:
+ shares = synology_api.get_shared_folders()
+
+ if not shares:
+ await message.edit_text("❌ Ошибка получения информации об общих папках\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации об общих папках\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение об общих папках
+ reply_text = f"📁 Общие папки Synology NAS\n\n"
+
+ for share in shares:
+ name = share.get("name", "Неизвестно")
+ path = share.get("path", "Неизвестно")
+ desc = share.get("desc", "")
+
+ reply_text += f"• {name}\n"
+ reply_text += f" └ Путь: {path}\n"
+
+ if desc:
+ reply_text += f" └ Описание: {desc}\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def system_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /system"""
+ if not update.message or not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о системе...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о системе.", parse_mode="HTML")
+ return
+
+ try:
+ system_status = synology_api.get_system_status()
+
+ if not system_status:
+ await message.edit_text("❌ Ошибка получения информации о системе", parse_mode="HTML")
+ return
+
+ # Если получен статус с ошибкой
+ if system_status.get("status") == "error":
+ error_code = system_status.get("error_code", "неизвестно")
+ await message.edit_text(f"❌ Ошибка получения информации о системе\n\nКод ошибки API: {error_code}", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о системе\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о состоянии системы
+ model = system_status.get("model", "Неизвестно")
+ version = system_status.get("version", "Неизвестно")
+ serial = system_status.get("serial", "Неизвестно")
+ uptime_seconds = system_status.get("uptime", 0)
+ temperature = system_status.get("temperature", "?")
+
+ # Преобразование времени работы в удобочитаемый формат
+ days, remainder = divmod(uptime_seconds, 86400)
+ hours, remainder = divmod(remainder, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ uptime_str = f"{days}д {hours}ч {minutes}м {seconds}с"
+
+ reply_text = f"🖥️ Информация о системе Synology NAS\n\n"
+ reply_text += f"Модель: {model}\n"
+ reply_text += f"Серийный номер: {serial}\n"
+ reply_text += f"Версия DSM: {version}\n"
+ reply_text += f"Время работы: {uptime_str}\n"
+ reply_text += f"Температура: {temperature}°C\n\n"
+
+ # Добавляем информацию о CPU и памяти
+ memory = system_status.get("memory", {})
+ total_memory_gb = memory.get("total_mb", 0) / 1024
+ available_memory_gb = memory.get("available_mb", 0) / 1024
+ memory_usage = memory.get("usage_percent", 0)
+ cpu_usage = system_status.get("cpu_usage", 0)
+
+ reply_text += f"Загрузка CPU: {cpu_usage}%\n"
+ reply_text += f"Память: {total_memory_gb:.1f} ГБ (используется {memory_usage}%)\n"
+ reply_text += f"Доступно памяти: {available_memory_gb:.1f} ГБ\n\n"
+
+ # Добавляем информацию о сетевых интерфейсах
+ network_info = system_status.get("network", [])
+ if network_info:
+ reply_text += "Сетевые интерфейсы:\n"
+ for interface in network_info:
+ device = interface.get("device", "Неизвестно")
+ ip = interface.get("ip", "Неизвестно")
+ mac = interface.get("mac", "Неизвестно")
+
+ reply_text += f"• {device}\n"
+ reply_text += f" └ IP: {ip}, MAC: {mac}\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def load_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /load"""
+ if not update.message or not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о нагрузке системы...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о нагрузке системы.", parse_mode="HTML")
+ return
+
+ try:
+ system_load = synology_api.get_system_load()
+
+ if not system_load:
+ await message.edit_text("❌ Ошибка получения информации о нагрузке системы\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о нагрузке системы\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о нагрузке системы
+ cpu_load = system_load.get("cpu_load", 0)
+ memory = system_load.get("memory", {})
+ memory_usage = memory.get("usage_percent", 0)
+
+ reply_text = f"📈 Текущая нагрузка Synology NAS\n\n"
+ reply_text += f"Загрузка CPU: {cpu_load}%\n"
+ reply_text += f"Загрузка памяти: {memory_usage}%\n\n"
+
+ # Добавляем информацию о сетевой активности
+ network = system_load.get("network", [])
+ if network:
+ reply_text += "Сетевая активность:\n"
+ if isinstance(network, dict):
+ # Если это словарь (старое API)
+ for device, stats in network.items():
+ rx = int(stats.get("rx_bytes", 0)) / (1024**2) # МБ
+ tx = int(stats.get("tx_bytes", 0)) / (1024**2) # МБ
+
+ reply_text += f"• {device}\n"
+ reply_text += f" └ Получено: {rx:.1f} МБ, Отправлено: {tx:.1f} МБ\n"
+ elif isinstance(network, list):
+ # Если это список (новое API)
+ for interface in network:
+ device = interface.get("device", "неизвестно")
+ rx = int(interface.get("rx", 0)) / (1024**2) # МБ
+ tx = int(interface.get("tx", 0)) / (1024**2) # МБ
+
+ reply_text += f"• {device}\n"
+ reply_text += f" └ Получено: {rx:.1f} МБ, Отправлено: {tx:.1f} МБ\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
+
+async def security_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Обработчик команды /security"""
+ if not update.message or not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+
+ if user_id not in ADMIN_USER_IDS:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ message = await update.message.reply_text("⏳ Получение информации о безопасности...")
+
+ if not synology_api.is_online():
+ await message.edit_text("❌ Synology NAS оффлайн\n\nНевозможно получить информацию о безопасности.", parse_mode="HTML")
+ return
+
+ try:
+ security_info = synology_api.get_security_status()
+
+ if not security_info.get("success", False):
+ await message.edit_text("❌ Ошибка получения информации о безопасности\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
+ return
+ except Exception as e:
+ await message.edit_text(f"❌ Ошибка получения информации о безопасности\n\nПричина: {str(e)}", parse_mode="HTML")
+ return
+
+ # Формируем сообщение о безопасности
+ status = security_info.get("status", "unknown")
+ is_secure = security_info.get("is_secure", False)
+ last_check = security_info.get("last_check", "Неизвестно")
+
+ status_emoji = "✅" if is_secure else "⚠️"
+ status_text = "Безопасно" if is_secure else "Требуется внимание"
+
+ reply_text = f"🔐 Статус безопасности Synology NAS\n\n"
+ reply_text += f"Статус: {status_emoji} {status_text}\n"
+ reply_text += f"Подробности: {status}\n"
+ reply_text += f"Последняя проверка: {last_check}\n"
+
+ await message.edit_text(reply_text, parse_mode="HTML")
diff --git a/.history/src/handlers/help_handlers_20250830110705.py b/.history/src/handlers/help_handlers_20250830110705.py
new file mode 100644
index 0000000..702186c
--- /dev/null
+++ b/.history/src/handlers/help_handlers_20250830110705.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Модуль с функциями для генерации справочных сообщений о командах бота
+"""
+
+import logging
+from telegram import Update
+from telegram.ext import ContextTypes
+from telegram.constants import ParseMode
+
+from src.config.config import ADMIN_USER_IDS
+
+logger = logging.getLogger(__name__)
+
+async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """
+ Обработчик команды /help - выводит справку по всем доступным командам
+ """
+ user_id = update.effective_user.id if update.effective_user else "Unknown"
+ username = update.effective_user.username if update.effective_user else "Unknown"
+
+ logger.info(f"User {user_id} (@{username}) requested help")
+
+ # Проверка прав доступа
+ if user_id not in ADMIN_USER_IDS and isinstance(user_id, int):
+ if update.message:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ help_text = (
+ "🤖 Synology Power Control Bot\n\n"
+ "БАЗОВЫЕ КОМАНДЫ:\n"
+ "/status - Проверить состояние NAS\n"
+ "/power - Управление питанием NAS (меню)\n"
+ "/reboot - Перезагрузка NAS (с подтверждением)\n"
+ "/sleep - Перевод NAS в спящий режим (с подтверждением)\n\n"
+
+ "ИНФОРМАЦИОННЫЕ КОМАНДЫ:\n"
+ "/system - Информация о системе\n"
+ "/storage - Состояние хранилища\n"
+ "/shares - Список общих папок\n"
+ "/load - Нагрузка на систему\n"
+ "/security - Информация о безопасности\n"
+ "/temperature - Температура устройства\n"
+ "/processes - Список активных процессов\n"
+ "/network - Сетевая информация\n\n"
+
+ "РАСШИРЕННЫЕ КОМАНДЫ:\n"
+ "/schedule - Расписание питания\n"
+ "/browse - Просмотр файлов\n"
+ "/search <запрос> - Поиск файлов\n"
+ "/updates - Проверка обновлений\n"
+ "/backup - Статус резервного копирования\n"
+ "/quota - Квоты пользователей\n\n"
+
+ "БЫСТРЫЕ КОМАНДЫ:\n"
+ "/quickreboot - Быстрая перезагрузка\n"
+ "/wakeup - Пробуждение NAS (WOL)\n\n"
+
+ "СЛУЖЕБНЫЕ КОМАНДЫ:\n"
+ "/checkapi - Проверка API\n"
+ "/help - Эта справка\n\n"
+
+ "УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ:\n"
+ "/admins - Список администраторов\n"
+ "/addadmin <id> - Добавить администратора\n"
+ "/removeadmin <id> - Удалить администратора\n"
+ )
+
+ if update.message:
+ await update.message.reply_text(help_text, parse_mode=ParseMode.HTML)
+ elif update.callback_query:
+ await update.callback_query.answer()
+ if update.callback_query.message:
+ try:
+ await update.callback_query.message.edit_text(help_text, parse_mode=ParseMode.HTML)
+ except Exception as e:
+ logger.error(f"Failed to edit message: {e}")
+ # Отправляем новое сообщение в текущий чат
+ await context.bot.send_message(
+ chat_id=update.callback_query.message.chat_id,
+ text=help_text,
+ parse_mode=ParseMode.HTML
+ )
+
+async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """
+ Обработчик команды /start - приветствие и краткая информация
+ """
+ user_id = update.effective_user.id if update.effective_user else "Unknown"
+ username = update.effective_user.username if update.effective_user else "Unknown"
+
+ logger.info(f"User {user_id} (@{username}) started the bot")
+
+ # Проверка прав доступа
+ if user_id not in ADMIN_USER_IDS and isinstance(user_id, int):
+ if update.message:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ welcome_text = (
+ "👋 Добро пожаловать в Synology Power Control Bot!\n\n"
+ "С помощью этого бота вы можете управлять питанием вашего Synology NAS "
+ "и получать различную информацию о его состоянии.\n\n"
+ "Для просмотра списка доступных команд используйте /help\n\n"
+ "Базовые команды:\n"
+ "• /status - Проверить состояние NAS\n"
+ "• /power - Управление питанием\n"
+ "• /system - Информация о системе\n"
+ "• /storage - Состояние хранилища"
+ )
+
+ if update.message:
+ await update.message.reply_text(welcome_text, parse_mode=ParseMode.HTML)
diff --git a/.history/src/handlers/help_handlers_20250830110906.py b/.history/src/handlers/help_handlers_20250830110906.py
new file mode 100644
index 0000000..702186c
--- /dev/null
+++ b/.history/src/handlers/help_handlers_20250830110906.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Модуль с функциями для генерации справочных сообщений о командах бота
+"""
+
+import logging
+from telegram import Update
+from telegram.ext import ContextTypes
+from telegram.constants import ParseMode
+
+from src.config.config import ADMIN_USER_IDS
+
+logger = logging.getLogger(__name__)
+
+async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """
+ Обработчик команды /help - выводит справку по всем доступным командам
+ """
+ user_id = update.effective_user.id if update.effective_user else "Unknown"
+ username = update.effective_user.username if update.effective_user else "Unknown"
+
+ logger.info(f"User {user_id} (@{username}) requested help")
+
+ # Проверка прав доступа
+ if user_id not in ADMIN_USER_IDS and isinstance(user_id, int):
+ if update.message:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ help_text = (
+ "🤖 Synology Power Control Bot\n\n"
+ "БАЗОВЫЕ КОМАНДЫ:\n"
+ "/status - Проверить состояние NAS\n"
+ "/power - Управление питанием NAS (меню)\n"
+ "/reboot - Перезагрузка NAS (с подтверждением)\n"
+ "/sleep - Перевод NAS в спящий режим (с подтверждением)\n\n"
+
+ "ИНФОРМАЦИОННЫЕ КОМАНДЫ:\n"
+ "/system - Информация о системе\n"
+ "/storage - Состояние хранилища\n"
+ "/shares - Список общих папок\n"
+ "/load - Нагрузка на систему\n"
+ "/security - Информация о безопасности\n"
+ "/temperature - Температура устройства\n"
+ "/processes - Список активных процессов\n"
+ "/network - Сетевая информация\n\n"
+
+ "РАСШИРЕННЫЕ КОМАНДЫ:\n"
+ "/schedule - Расписание питания\n"
+ "/browse - Просмотр файлов\n"
+ "/search <запрос> - Поиск файлов\n"
+ "/updates - Проверка обновлений\n"
+ "/backup - Статус резервного копирования\n"
+ "/quota - Квоты пользователей\n\n"
+
+ "БЫСТРЫЕ КОМАНДЫ:\n"
+ "/quickreboot - Быстрая перезагрузка\n"
+ "/wakeup - Пробуждение NAS (WOL)\n\n"
+
+ "СЛУЖЕБНЫЕ КОМАНДЫ:\n"
+ "/checkapi - Проверка API\n"
+ "/help - Эта справка\n\n"
+
+ "УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ:\n"
+ "/admins - Список администраторов\n"
+ "/addadmin <id> - Добавить администратора\n"
+ "/removeadmin <id> - Удалить администратора\n"
+ )
+
+ if update.message:
+ await update.message.reply_text(help_text, parse_mode=ParseMode.HTML)
+ elif update.callback_query:
+ await update.callback_query.answer()
+ if update.callback_query.message:
+ try:
+ await update.callback_query.message.edit_text(help_text, parse_mode=ParseMode.HTML)
+ except Exception as e:
+ logger.error(f"Failed to edit message: {e}")
+ # Отправляем новое сообщение в текущий чат
+ await context.bot.send_message(
+ chat_id=update.callback_query.message.chat_id,
+ text=help_text,
+ parse_mode=ParseMode.HTML
+ )
+
+async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """
+ Обработчик команды /start - приветствие и краткая информация
+ """
+ user_id = update.effective_user.id if update.effective_user else "Unknown"
+ username = update.effective_user.username if update.effective_user else "Unknown"
+
+ logger.info(f"User {user_id} (@{username}) started the bot")
+
+ # Проверка прав доступа
+ if user_id not in ADMIN_USER_IDS and isinstance(user_id, int):
+ if update.message:
+ await update.message.reply_text("У вас нет доступа к этому боту.")
+ return
+
+ welcome_text = (
+ "👋 Добро пожаловать в Synology Power Control Bot!\n\n"
+ "С помощью этого бота вы можете управлять питанием вашего Synology NAS "
+ "и получать различную информацию о его состоянии.\n\n"
+ "Для просмотра списка доступных команд используйте /help\n\n"
+ "Базовые команды:\n"
+ "• /status - Проверить состояние NAS\n"
+ "• /power - Управление питанием\n"
+ "• /system - Информация о системе\n"
+ "• /storage - Состояние хранилища"
+ )
+
+ if update.message:
+ await update.message.reply_text(welcome_text, parse_mode=ParseMode.HTML)
diff --git a/.history/src/utils/admin_utils_20250830110540.py b/.history/src/utils/admin_utils_20250830110540.py
new file mode 100644
index 0000000..f85b013
--- /dev/null
+++ b/.history/src/utils/admin_utils_20250830110540.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Утилиты для управления администраторами бота
+"""
+
+import os
+import logging
+from typing import List, Optional, Callable, Any, Union
+from functools import wraps
+from telegram import Update
+from telegram.ext import ContextTypes
+from src.config.config import ADMIN_USER_IDS
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+def is_admin(user_id: int) -> bool:
+ """Проверяет, является ли пользователь администратором бота
+
+ Args:
+ user_id: ID пользователя Telegram
+
+ Returns:
+ True если пользователь администратор, иначе False
+ """
+ return user_id in ADMIN_USER_IDS
+
+def admin_required(func: Callable) -> Callable:
+ """Декоратор для проверки, является ли пользователь администратором
+
+ Args:
+ func: Оригинальная функция обработчика
+
+ Returns:
+ Обернутая функция с проверкой прав администратора
+ """
+ @wraps(func)
+ async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args: Any, **kwargs: Any) -> Any:
+ # Проверяем доступность объекта update и effective_user
+ if not update or not update.effective_user:
+ logger.warning("Update object is incomplete, unable to check admin status")
+ return
+
+ user_id = update.effective_user.id
+ username = update.effective_user.username or "Unknown"
+
+ if not is_admin(user_id):
+ logger.warning(f"Unauthorized access attempt by user {user_id} (@{username})")
+
+ # Если это сообщение, отправляем уведомление
+ if update.message:
+ await update.message.reply_text(
+ "⛔️ У вас нет прав на использование этой команды.\n"
+ "Обратитесь к владельцу бота, чтобы получить доступ."
+ )
+ # Если это callback query, отвечаем на него
+ elif update.callback_query:
+ await update.callback_query.answer(
+ "⛔️ У вас нет прав на использование этой функции."
+ )
+ return
+
+ # Если пользователь админ, вызываем оригинальную функцию
+ return await func(update, context, *args, **kwargs)
+
+ return wrapper
+
+async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Добавляет нового администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ await update.message.reply_text(
+ "❌ Ошибка: Необходимо указать ID пользователя.\n\n"
+ "Пример использования:\n"
+ "/addadmin 123456789",
+ parse_mode="HTML"
+ )
+ return
+
+ try:
+ # Парсим ID нового администратора
+ new_admin_id = int(context.args[0])
+
+ # Проверяем, не является ли пользователь уже администратором
+ if new_admin_id in ADMIN_USER_IDS:
+ await update.message.reply_text(f"ℹ️ Пользователь с ID {new_admin_id} уже является администратором.")
+ return
+
+ # Добавляем нового администратора
+ env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
+
+ # Читаем текущий файл .env
+ env_content = ""
+ with open(env_path, 'r', encoding='utf-8') as f:
+ env_content = f.read()
+
+ # Находим строку с ADMIN_USER_IDS
+ lines = env_content.split('\n')
+ admin_line_idx = -1
+
+ for i, line in enumerate(lines):
+ if line.startswith('ADMIN_USER_IDS='):
+ admin_line_idx = i
+ break
+
+ # Обновляем или добавляем строку с администраторами
+ if admin_line_idx >= 0:
+ current_ids = lines[admin_line_idx].split('=')[1].strip()
+ new_ids = f"{current_ids},{new_admin_id}" if current_ids else str(new_admin_id)
+ lines[admin_line_idx] = f"ADMIN_USER_IDS={new_ids}"
+ else:
+ lines.append(f"ADMIN_USER_IDS={new_admin_id}")
+
+ # Записываем обновленный файл
+ with open(env_path, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(lines))
+
+ # Обновляем список в памяти
+ ADMIN_USER_IDS.append(new_admin_id)
+
+ await update.message.reply_text(
+ f"✅ Успешно!\n\n"
+ f"Пользователь с ID {new_admin_id} добавлен в список администраторов.",
+ parse_mode="HTML"
+ )
+
+ logger.info(f"User {update.effective_user.id} added a new admin: {new_admin_id}")
+
+ except ValueError:
+ await update.message.reply_text(
+ "❌ Ошибка: ID пользователя должен быть целым числом.\n\n"
+ "Пример использования:\n"
+ "/addadmin 123456789",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ logger.error(f"Error adding admin: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при добавлении администратора:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
+
+async def remove_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Удаляет администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ await update.message.reply_text(
+ "❌ Ошибка: Необходимо указать ID пользователя.\n\n"
+ "Пример использования:\n"
+ "/removeadmin 123456789",
+ parse_mode="HTML"
+ )
+ return
+
+ try:
+ # Парсим ID администратора для удаления
+ admin_id = int(context.args[0])
+
+ # Проверяем, не удаляет ли админ сам себя
+ if admin_id == update.effective_user.id:
+ await update.message.reply_text("⚠️ Вы не можете удалить самого себя из администраторов.")
+ return
+
+ # Проверяем, является ли пользователь администратором
+ if admin_id not in ADMIN_USER_IDS:
+ await update.message.reply_text(f"ℹ️ Пользователь с ID {admin_id} не является администратором.")
+ return
+
+ # Удаляем администратора
+ env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
+
+ # Читаем текущий файл .env
+ env_content = ""
+ with open(env_path, 'r', encoding='utf-8') as f:
+ env_content = f.read()
+
+ # Находим строку с ADMIN_USER_IDS
+ lines = env_content.split('\n')
+ admin_line_idx = -1
+
+ for i, line in enumerate(lines):
+ if line.startswith('ADMIN_USER_IDS='):
+ admin_line_idx = i
+ break
+
+ # Удаляем ID из строки с администраторами
+ if admin_line_idx >= 0:
+ current_ids = lines[admin_line_idx].split('=')[1].strip().split(',')
+ new_ids = [id for id in current_ids if int(id) != admin_id]
+
+ if not new_ids:
+ # Если не осталось администраторов, добавляем текущего пользователя
+ # чтобы избежать ситуации, когда нет администраторов
+ new_ids = [str(update.effective_user.id)]
+
+ lines[admin_line_idx] = f"ADMIN_USER_IDS={','.join(new_ids)}"
+
+ # Записываем обновленный файл
+ with open(env_path, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(lines))
+
+ # Обновляем список в памяти
+ ADMIN_USER_IDS.remove(admin_id)
+
+ await update.message.reply_text(
+ f"✅ Успешно!\n\n"
+ f"Пользователь с ID {admin_id} удален из списка администраторов.",
+ parse_mode="HTML"
+ )
+
+ logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
+ else:
+ await update.message.reply_text("❌ Ошибка: Не удалось найти настройки администраторов.")
+
+ except ValueError:
+ await update.message.reply_text(
+ "❌ Ошибка: ID пользователя должен быть целым числом.\n\n"
+ "Пример использования:\n"
+ "/removeadmin 123456789",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ logger.error(f"Error removing admin: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при удалении администратора:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
+
+async def list_admins(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Показывает список администраторов бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ try:
+ if not ADMIN_USER_IDS:
+ await update.message.reply_text("⚠️ Список администраторов пуст.")
+ return
+
+ # Формируем сообщение со списком администраторов
+ admin_list = "\n".join([f"• {admin_id}" for admin_id in ADMIN_USER_IDS])
+
+ await update.message.reply_text(
+ f"👑 Список администраторов бота:\n\n"
+ f"{admin_list}\n\n"
+ f"Всего администраторов: {len(ADMIN_USER_IDS)}",
+ parse_mode="HTML"
+ )
+
+ except Exception as e:
+ logger.error(f"Error listing admins: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при получении списка администраторов:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
diff --git a/.history/src/utils/admin_utils_20250830110906.py b/.history/src/utils/admin_utils_20250830110906.py
new file mode 100644
index 0000000..f85b013
--- /dev/null
+++ b/.history/src/utils/admin_utils_20250830110906.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Утилиты для управления администраторами бота
+"""
+
+import os
+import logging
+from typing import List, Optional, Callable, Any, Union
+from functools import wraps
+from telegram import Update
+from telegram.ext import ContextTypes
+from src.config.config import ADMIN_USER_IDS
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+def is_admin(user_id: int) -> bool:
+ """Проверяет, является ли пользователь администратором бота
+
+ Args:
+ user_id: ID пользователя Telegram
+
+ Returns:
+ True если пользователь администратор, иначе False
+ """
+ return user_id in ADMIN_USER_IDS
+
+def admin_required(func: Callable) -> Callable:
+ """Декоратор для проверки, является ли пользователь администратором
+
+ Args:
+ func: Оригинальная функция обработчика
+
+ Returns:
+ Обернутая функция с проверкой прав администратора
+ """
+ @wraps(func)
+ async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args: Any, **kwargs: Any) -> Any:
+ # Проверяем доступность объекта update и effective_user
+ if not update or not update.effective_user:
+ logger.warning("Update object is incomplete, unable to check admin status")
+ return
+
+ user_id = update.effective_user.id
+ username = update.effective_user.username or "Unknown"
+
+ if not is_admin(user_id):
+ logger.warning(f"Unauthorized access attempt by user {user_id} (@{username})")
+
+ # Если это сообщение, отправляем уведомление
+ if update.message:
+ await update.message.reply_text(
+ "⛔️ У вас нет прав на использование этой команды.\n"
+ "Обратитесь к владельцу бота, чтобы получить доступ."
+ )
+ # Если это callback query, отвечаем на него
+ elif update.callback_query:
+ await update.callback_query.answer(
+ "⛔️ У вас нет прав на использование этой функции."
+ )
+ return
+
+ # Если пользователь админ, вызываем оригинальную функцию
+ return await func(update, context, *args, **kwargs)
+
+ return wrapper
+
+async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Добавляет нового администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ await update.message.reply_text(
+ "❌ Ошибка: Необходимо указать ID пользователя.\n\n"
+ "Пример использования:\n"
+ "/addadmin 123456789",
+ parse_mode="HTML"
+ )
+ return
+
+ try:
+ # Парсим ID нового администратора
+ new_admin_id = int(context.args[0])
+
+ # Проверяем, не является ли пользователь уже администратором
+ if new_admin_id in ADMIN_USER_IDS:
+ await update.message.reply_text(f"ℹ️ Пользователь с ID {new_admin_id} уже является администратором.")
+ return
+
+ # Добавляем нового администратора
+ env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
+
+ # Читаем текущий файл .env
+ env_content = ""
+ with open(env_path, 'r', encoding='utf-8') as f:
+ env_content = f.read()
+
+ # Находим строку с ADMIN_USER_IDS
+ lines = env_content.split('\n')
+ admin_line_idx = -1
+
+ for i, line in enumerate(lines):
+ if line.startswith('ADMIN_USER_IDS='):
+ admin_line_idx = i
+ break
+
+ # Обновляем или добавляем строку с администраторами
+ if admin_line_idx >= 0:
+ current_ids = lines[admin_line_idx].split('=')[1].strip()
+ new_ids = f"{current_ids},{new_admin_id}" if current_ids else str(new_admin_id)
+ lines[admin_line_idx] = f"ADMIN_USER_IDS={new_ids}"
+ else:
+ lines.append(f"ADMIN_USER_IDS={new_admin_id}")
+
+ # Записываем обновленный файл
+ with open(env_path, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(lines))
+
+ # Обновляем список в памяти
+ ADMIN_USER_IDS.append(new_admin_id)
+
+ await update.message.reply_text(
+ f"✅ Успешно!\n\n"
+ f"Пользователь с ID {new_admin_id} добавлен в список администраторов.",
+ parse_mode="HTML"
+ )
+
+ logger.info(f"User {update.effective_user.id} added a new admin: {new_admin_id}")
+
+ except ValueError:
+ await update.message.reply_text(
+ "❌ Ошибка: ID пользователя должен быть целым числом.\n\n"
+ "Пример использования:\n"
+ "/addadmin 123456789",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ logger.error(f"Error adding admin: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при добавлении администратора:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
+
+async def remove_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Удаляет администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ await update.message.reply_text(
+ "❌ Ошибка: Необходимо указать ID пользователя.\n\n"
+ "Пример использования:\n"
+ "/removeadmin 123456789",
+ parse_mode="HTML"
+ )
+ return
+
+ try:
+ # Парсим ID администратора для удаления
+ admin_id = int(context.args[0])
+
+ # Проверяем, не удаляет ли админ сам себя
+ if admin_id == update.effective_user.id:
+ await update.message.reply_text("⚠️ Вы не можете удалить самого себя из администраторов.")
+ return
+
+ # Проверяем, является ли пользователь администратором
+ if admin_id not in ADMIN_USER_IDS:
+ await update.message.reply_text(f"ℹ️ Пользователь с ID {admin_id} не является администратором.")
+ return
+
+ # Удаляем администратора
+ env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
+
+ # Читаем текущий файл .env
+ env_content = ""
+ with open(env_path, 'r', encoding='utf-8') as f:
+ env_content = f.read()
+
+ # Находим строку с ADMIN_USER_IDS
+ lines = env_content.split('\n')
+ admin_line_idx = -1
+
+ for i, line in enumerate(lines):
+ if line.startswith('ADMIN_USER_IDS='):
+ admin_line_idx = i
+ break
+
+ # Удаляем ID из строки с администраторами
+ if admin_line_idx >= 0:
+ current_ids = lines[admin_line_idx].split('=')[1].strip().split(',')
+ new_ids = [id for id in current_ids if int(id) != admin_id]
+
+ if not new_ids:
+ # Если не осталось администраторов, добавляем текущего пользователя
+ # чтобы избежать ситуации, когда нет администраторов
+ new_ids = [str(update.effective_user.id)]
+
+ lines[admin_line_idx] = f"ADMIN_USER_IDS={','.join(new_ids)}"
+
+ # Записываем обновленный файл
+ with open(env_path, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(lines))
+
+ # Обновляем список в памяти
+ ADMIN_USER_IDS.remove(admin_id)
+
+ await update.message.reply_text(
+ f"✅ Успешно!\n\n"
+ f"Пользователь с ID {admin_id} удален из списка администраторов.",
+ parse_mode="HTML"
+ )
+
+ logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
+ else:
+ await update.message.reply_text("❌ Ошибка: Не удалось найти настройки администраторов.")
+
+ except ValueError:
+ await update.message.reply_text(
+ "❌ Ошибка: ID пользователя должен быть целым числом.\n\n"
+ "Пример использования:\n"
+ "/removeadmin 123456789",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ logger.error(f"Error removing admin: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при удалении администратора:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
+
+async def list_admins(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Показывает список администраторов бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ try:
+ if not ADMIN_USER_IDS:
+ await update.message.reply_text("⚠️ Список администраторов пуст.")
+ return
+
+ # Формируем сообщение со списком администраторов
+ admin_list = "\n".join([f"• {admin_id}" for admin_id in ADMIN_USER_IDS])
+
+ await update.message.reply_text(
+ f"👑 Список администраторов бота:\n\n"
+ f"{admin_list}\n\n"
+ f"Всего администраторов: {len(ADMIN_USER_IDS)}",
+ parse_mode="HTML"
+ )
+
+ except Exception as e:
+ logger.error(f"Error listing admins: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при получении списка администраторов:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
diff --git a/.history/src/utils/admin_utils_20250830114406.py b/.history/src/utils/admin_utils_20250830114406.py
new file mode 100644
index 0000000..f226808
--- /dev/null
+++ b/.history/src/utils/admin_utils_20250830114406.py
@@ -0,0 +1,302 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Утилиты для управления администраторами бота
+"""
+
+import os
+import logging
+from typing import List, Optional, Callable, Any, Union
+from functools import wraps
+from telegram import Update
+from telegram.ext import ContextTypes
+from src.config.config import ADMIN_USER_IDS
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+def is_admin(user_id: int) -> bool:
+ """Проверяет, является ли пользователь администратором бота
+
+ Args:
+ user_id: ID пользователя Telegram
+
+ Returns:
+ True если пользователь администратор, иначе False
+ """
+ # Получаем актуальный список администраторов из .env файла
+ try:
+ env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
+
+ if os.path.exists(env_path):
+ with open(env_path, 'r', encoding='utf-8') as f:
+ env_content = f.read()
+
+ # Ищем строку с ADMIN_USER_IDS
+ for line in env_content.split('\n'):
+ if line.startswith('ADMIN_USER_IDS='):
+ admin_ids_str = line.split('=')[1].strip()
+ if admin_ids_str:
+ admin_ids = list(map(int, admin_ids_str.split(',')))
+ return user_id in admin_ids
+ except Exception as e:
+ logger.error(f"Error reading admin IDs from .env: {e}")
+
+ # Если не удалось прочитать из файла, используем загруженные при старте
+ return user_id in ADMIN_USER_IDS
+
+def admin_required(func: Callable) -> Callable:
+ """Декоратор для проверки, является ли пользователь администратором
+
+ Args:
+ func: Оригинальная функция обработчика
+
+ Returns:
+ Обернутая функция с проверкой прав администратора
+ """
+ @wraps(func)
+ async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args: Any, **kwargs: Any) -> Any:
+ # Проверяем доступность объекта update и effective_user
+ if not update or not update.effective_user:
+ logger.warning("Update object is incomplete, unable to check admin status")
+ return
+
+ user_id = update.effective_user.id
+ username = update.effective_user.username or "Unknown"
+
+ if not is_admin(user_id):
+ logger.warning(f"Unauthorized access attempt by user {user_id} (@{username})")
+
+ # Если это сообщение, отправляем уведомление
+ if update.message:
+ await update.message.reply_text(
+ "⛔️ У вас нет прав на использование этой команды.\n"
+ "Обратитесь к владельцу бота, чтобы получить доступ."
+ )
+ # Если это callback query, отвечаем на него
+ elif update.callback_query:
+ await update.callback_query.answer(
+ "⛔️ У вас нет прав на использование этой функции."
+ )
+ return
+
+ # Если пользователь админ, вызываем оригинальную функцию
+ return await func(update, context, *args, **kwargs)
+
+ return wrapper
+
+async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Добавляет нового администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ await update.message.reply_text(
+ "❌ Ошибка: Необходимо указать ID пользователя.\n\n"
+ "Пример использования:\n"
+ "/addadmin 123456789",
+ parse_mode="HTML"
+ )
+ return
+
+ try:
+ # Парсим ID нового администратора
+ new_admin_id = int(context.args[0])
+
+ # Проверяем, не является ли пользователь уже администратором
+ if new_admin_id in ADMIN_USER_IDS:
+ await update.message.reply_text(f"ℹ️ Пользователь с ID {new_admin_id} уже является администратором.")
+ return
+
+ # Добавляем нового администратора
+ env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
+
+ # Читаем текущий файл .env
+ env_content = ""
+ with open(env_path, 'r', encoding='utf-8') as f:
+ env_content = f.read()
+
+ # Находим строку с ADMIN_USER_IDS
+ lines = env_content.split('\n')
+ admin_line_idx = -1
+
+ for i, line in enumerate(lines):
+ if line.startswith('ADMIN_USER_IDS='):
+ admin_line_idx = i
+ break
+
+ # Обновляем или добавляем строку с администраторами
+ if admin_line_idx >= 0:
+ current_ids = lines[admin_line_idx].split('=')[1].strip()
+ new_ids = f"{current_ids},{new_admin_id}" if current_ids else str(new_admin_id)
+ lines[admin_line_idx] = f"ADMIN_USER_IDS={new_ids}"
+ else:
+ lines.append(f"ADMIN_USER_IDS={new_admin_id}")
+
+ # Записываем обновленный файл
+ with open(env_path, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(lines))
+
+ # Обновляем список в памяти
+ ADMIN_USER_IDS.append(new_admin_id)
+
+ await update.message.reply_text(
+ f"✅ Успешно!\n\n"
+ f"Пользователь с ID {new_admin_id} добавлен в список администраторов.",
+ parse_mode="HTML"
+ )
+
+ logger.info(f"User {update.effective_user.id} added a new admin: {new_admin_id}")
+
+ except ValueError:
+ await update.message.reply_text(
+ "❌ Ошибка: ID пользователя должен быть целым числом.\n\n"
+ "Пример использования:\n"
+ "/addadmin 123456789",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ logger.error(f"Error adding admin: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при добавлении администратора:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
+
+async def remove_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Удаляет администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ await update.message.reply_text(
+ "❌ Ошибка: Необходимо указать ID пользователя.\n\n"
+ "Пример использования:\n"
+ "/removeadmin 123456789",
+ parse_mode="HTML"
+ )
+ return
+
+ try:
+ # Парсим ID администратора для удаления
+ admin_id = int(context.args[0])
+
+ # Проверяем, не удаляет ли админ сам себя
+ if admin_id == update.effective_user.id:
+ await update.message.reply_text("⚠️ Вы не можете удалить самого себя из администраторов.")
+ return
+
+ # Проверяем, является ли пользователь администратором
+ if admin_id not in ADMIN_USER_IDS:
+ await update.message.reply_text(f"ℹ️ Пользователь с ID {admin_id} не является администратором.")
+ return
+
+ # Удаляем администратора
+ env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
+
+ # Читаем текущий файл .env
+ env_content = ""
+ with open(env_path, 'r', encoding='utf-8') as f:
+ env_content = f.read()
+
+ # Находим строку с ADMIN_USER_IDS
+ lines = env_content.split('\n')
+ admin_line_idx = -1
+
+ for i, line in enumerate(lines):
+ if line.startswith('ADMIN_USER_IDS='):
+ admin_line_idx = i
+ break
+
+ # Удаляем ID из строки с администраторами
+ if admin_line_idx >= 0:
+ current_ids = lines[admin_line_idx].split('=')[1].strip().split(',')
+ new_ids = [id for id in current_ids if int(id) != admin_id]
+
+ if not new_ids:
+ # Если не осталось администраторов, добавляем текущего пользователя
+ # чтобы избежать ситуации, когда нет администраторов
+ new_ids = [str(update.effective_user.id)]
+
+ lines[admin_line_idx] = f"ADMIN_USER_IDS={','.join(new_ids)}"
+
+ # Записываем обновленный файл
+ with open(env_path, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(lines))
+
+ # Обновляем список в памяти
+ ADMIN_USER_IDS.remove(admin_id)
+
+ await update.message.reply_text(
+ f"✅ Успешно!\n\n"
+ f"Пользователь с ID {admin_id} удален из списка администраторов.",
+ parse_mode="HTML"
+ )
+
+ logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
+ else:
+ await update.message.reply_text("❌ Ошибка: Не удалось найти настройки администраторов.")
+
+ except ValueError:
+ await update.message.reply_text(
+ "❌ Ошибка: ID пользователя должен быть целым числом.\n\n"
+ "Пример использования:\n"
+ "/removeadmin 123456789",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ logger.error(f"Error removing admin: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при удалении администратора:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
+
+async def list_admins(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Показывает список администраторов бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ try:
+ if not ADMIN_USER_IDS:
+ await update.message.reply_text("⚠️ Список администраторов пуст.")
+ return
+
+ # Формируем сообщение со списком администраторов
+ admin_list = "\n".join([f"• {admin_id}" for admin_id in ADMIN_USER_IDS])
+
+ await update.message.reply_text(
+ f"👑 Список администраторов бота:\n\n"
+ f"{admin_list}\n\n"
+ f"Всего администраторов: {len(ADMIN_USER_IDS)}",
+ parse_mode="HTML"
+ )
+
+ except Exception as e:
+ logger.error(f"Error listing admins: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при получении списка администраторов:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
diff --git a/.history/src/utils/admin_utils_20250830114514.py b/.history/src/utils/admin_utils_20250830114514.py
new file mode 100644
index 0000000..f85b013
--- /dev/null
+++ b/.history/src/utils/admin_utils_20250830114514.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Утилиты для управления администраторами бота
+"""
+
+import os
+import logging
+from typing import List, Optional, Callable, Any, Union
+from functools import wraps
+from telegram import Update
+from telegram.ext import ContextTypes
+from src.config.config import ADMIN_USER_IDS
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+def is_admin(user_id: int) -> bool:
+ """Проверяет, является ли пользователь администратором бота
+
+ Args:
+ user_id: ID пользователя Telegram
+
+ Returns:
+ True если пользователь администратор, иначе False
+ """
+ return user_id in ADMIN_USER_IDS
+
+def admin_required(func: Callable) -> Callable:
+ """Декоратор для проверки, является ли пользователь администратором
+
+ Args:
+ func: Оригинальная функция обработчика
+
+ Returns:
+ Обернутая функция с проверкой прав администратора
+ """
+ @wraps(func)
+ async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args: Any, **kwargs: Any) -> Any:
+ # Проверяем доступность объекта update и effective_user
+ if not update or not update.effective_user:
+ logger.warning("Update object is incomplete, unable to check admin status")
+ return
+
+ user_id = update.effective_user.id
+ username = update.effective_user.username or "Unknown"
+
+ if not is_admin(user_id):
+ logger.warning(f"Unauthorized access attempt by user {user_id} (@{username})")
+
+ # Если это сообщение, отправляем уведомление
+ if update.message:
+ await update.message.reply_text(
+ "⛔️ У вас нет прав на использование этой команды.\n"
+ "Обратитесь к владельцу бота, чтобы получить доступ."
+ )
+ # Если это callback query, отвечаем на него
+ elif update.callback_query:
+ await update.callback_query.answer(
+ "⛔️ У вас нет прав на использование этой функции."
+ )
+ return
+
+ # Если пользователь админ, вызываем оригинальную функцию
+ return await func(update, context, *args, **kwargs)
+
+ return wrapper
+
+async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Добавляет нового администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ await update.message.reply_text(
+ "❌ Ошибка: Необходимо указать ID пользователя.\n\n"
+ "Пример использования:\n"
+ "/addadmin 123456789",
+ parse_mode="HTML"
+ )
+ return
+
+ try:
+ # Парсим ID нового администратора
+ new_admin_id = int(context.args[0])
+
+ # Проверяем, не является ли пользователь уже администратором
+ if new_admin_id in ADMIN_USER_IDS:
+ await update.message.reply_text(f"ℹ️ Пользователь с ID {new_admin_id} уже является администратором.")
+ return
+
+ # Добавляем нового администратора
+ env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
+
+ # Читаем текущий файл .env
+ env_content = ""
+ with open(env_path, 'r', encoding='utf-8') as f:
+ env_content = f.read()
+
+ # Находим строку с ADMIN_USER_IDS
+ lines = env_content.split('\n')
+ admin_line_idx = -1
+
+ for i, line in enumerate(lines):
+ if line.startswith('ADMIN_USER_IDS='):
+ admin_line_idx = i
+ break
+
+ # Обновляем или добавляем строку с администраторами
+ if admin_line_idx >= 0:
+ current_ids = lines[admin_line_idx].split('=')[1].strip()
+ new_ids = f"{current_ids},{new_admin_id}" if current_ids else str(new_admin_id)
+ lines[admin_line_idx] = f"ADMIN_USER_IDS={new_ids}"
+ else:
+ lines.append(f"ADMIN_USER_IDS={new_admin_id}")
+
+ # Записываем обновленный файл
+ with open(env_path, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(lines))
+
+ # Обновляем список в памяти
+ ADMIN_USER_IDS.append(new_admin_id)
+
+ await update.message.reply_text(
+ f"✅ Успешно!\n\n"
+ f"Пользователь с ID {new_admin_id} добавлен в список администраторов.",
+ parse_mode="HTML"
+ )
+
+ logger.info(f"User {update.effective_user.id} added a new admin: {new_admin_id}")
+
+ except ValueError:
+ await update.message.reply_text(
+ "❌ Ошибка: ID пользователя должен быть целым числом.\n\n"
+ "Пример использования:\n"
+ "/addadmin 123456789",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ logger.error(f"Error adding admin: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при добавлении администратора:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
+
+async def remove_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Удаляет администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ await update.message.reply_text(
+ "❌ Ошибка: Необходимо указать ID пользователя.\n\n"
+ "Пример использования:\n"
+ "/removeadmin 123456789",
+ parse_mode="HTML"
+ )
+ return
+
+ try:
+ # Парсим ID администратора для удаления
+ admin_id = int(context.args[0])
+
+ # Проверяем, не удаляет ли админ сам себя
+ if admin_id == update.effective_user.id:
+ await update.message.reply_text("⚠️ Вы не можете удалить самого себя из администраторов.")
+ return
+
+ # Проверяем, является ли пользователь администратором
+ if admin_id not in ADMIN_USER_IDS:
+ await update.message.reply_text(f"ℹ️ Пользователь с ID {admin_id} не является администратором.")
+ return
+
+ # Удаляем администратора
+ env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
+
+ # Читаем текущий файл .env
+ env_content = ""
+ with open(env_path, 'r', encoding='utf-8') as f:
+ env_content = f.read()
+
+ # Находим строку с ADMIN_USER_IDS
+ lines = env_content.split('\n')
+ admin_line_idx = -1
+
+ for i, line in enumerate(lines):
+ if line.startswith('ADMIN_USER_IDS='):
+ admin_line_idx = i
+ break
+
+ # Удаляем ID из строки с администраторами
+ if admin_line_idx >= 0:
+ current_ids = lines[admin_line_idx].split('=')[1].strip().split(',')
+ new_ids = [id for id in current_ids if int(id) != admin_id]
+
+ if not new_ids:
+ # Если не осталось администраторов, добавляем текущего пользователя
+ # чтобы избежать ситуации, когда нет администраторов
+ new_ids = [str(update.effective_user.id)]
+
+ lines[admin_line_idx] = f"ADMIN_USER_IDS={','.join(new_ids)}"
+
+ # Записываем обновленный файл
+ with open(env_path, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(lines))
+
+ # Обновляем список в памяти
+ ADMIN_USER_IDS.remove(admin_id)
+
+ await update.message.reply_text(
+ f"✅ Успешно!\n\n"
+ f"Пользователь с ID {admin_id} удален из списка администраторов.",
+ parse_mode="HTML"
+ )
+
+ logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
+ else:
+ await update.message.reply_text("❌ Ошибка: Не удалось найти настройки администраторов.")
+
+ except ValueError:
+ await update.message.reply_text(
+ "❌ Ошибка: ID пользователя должен быть целым числом.\n\n"
+ "Пример использования:\n"
+ "/removeadmin 123456789",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ logger.error(f"Error removing admin: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при удалении администратора:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
+
+async def list_admins(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Показывает список администраторов бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ try:
+ if not ADMIN_USER_IDS:
+ await update.message.reply_text("⚠️ Список администраторов пуст.")
+ return
+
+ # Формируем сообщение со списком администраторов
+ admin_list = "\n".join([f"• {admin_id}" for admin_id in ADMIN_USER_IDS])
+
+ await update.message.reply_text(
+ f"👑 Список администраторов бота:\n\n"
+ f"{admin_list}\n\n"
+ f"Всего администраторов: {len(ADMIN_USER_IDS)}",
+ parse_mode="HTML"
+ )
+
+ except Exception as e:
+ logger.error(f"Error listing admins: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при получении списка администраторов:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
diff --git a/src/api/synology.py b/src/api/synology.py
index 6d5a673..145921f 100644
--- a/src/api/synology.py
+++ b/src/api/synology.py
@@ -1425,17 +1425,46 @@ class SynologyAPI:
return {}
try:
- # Получаем расписание через API
- result = self._make_api_request("SYNO.Core.Hardware.PowerSchedule", "get", version=1)
+ # Список возможных API для получения расписания питания
+ apis_to_try = [
+ {"api": "SYNO.Core.System.PowerSchedule", "method": "get", "version": 1},
+ {"api": "SYNO.Core.System", "method": "get_power_schedule", "version": 1},
+ {"api": "SYNO.Core.Power", "method": "schedule", "version": 1},
+ {"api": "SYNO.Core.Power.Schedule", "method": "get", "version": 1},
+ {"api": "SYNO.PowerScheduler", "method": "load", "version": 1},
+ {"api": "SYNO.PowerSchedule", "method": "get", "version": 1}
+ ]
+ result = {}
+ # Пробуем все возможные API по очереди
+ for api_config in apis_to_try:
+ logger.debug(f"Trying API: {api_config['api']}.{api_config['method']} v{api_config['version']}")
+ temp_result = self._make_api_request(
+ api_config["api"],
+ api_config["method"],
+ version=api_config["version"]
+ )
+ if temp_result:
+ logger.info(f"Successfully retrieved power schedule using {api_config['api']}.{api_config['method']}")
+ result = temp_result
+ break
+
if not result:
- return {}
+ # Если нет результатов, вернем структуру, которую ожидает код
+ logger.warning("No PowerSchedule API available, returning empty schedule structure")
+ return {
+ "boot_tasks": [],
+ "shutdown_tasks": []
+ }
return result
except Exception as e:
logger.error(f"Error getting power schedule: {str(e)}")
- return {}
+ return {
+ "boot_tasks": [],
+ "shutdown_tasks": []
+ }
def set_power_schedule(self, schedule_type: str, days: List[int], time: str, enabled: bool = True) -> bool:
"""Настройка расписания включения/выключения
@@ -1457,14 +1486,7 @@ class SynologyAPI:
return False
try:
- # Получаем текущее расписание
- current_schedule = self._make_api_request("SYNO.Core.Hardware.PowerSchedule", "get", version=1)
-
- if not current_schedule:
- logger.error("Failed to get current power schedule")
- return False
-
- # Подготавливаем новое расписание
+ # Подготавливаем базовые параметры расписания
params = {
"enabled": enabled,
"type": schedule_type,
@@ -1472,14 +1494,39 @@ class SynologyAPI:
"time": time
}
- # Устанавливаем новое расписание
- result = self._make_api_request("SYNO.Core.Hardware.PowerSchedule", "set", version=1, params=params)
+ # Список возможных API для установки расписания питания
+ apis_to_try = [
+ {"api": "SYNO.Core.System.PowerSchedule", "method": "set", "version": 1},
+ {"api": "SYNO.Core.System", "method": "set_power_schedule", "version": 1},
+ {"api": "SYNO.Core.Power", "method": "set_schedule", "version": 1},
+ {"api": "SYNO.Core.Power.Schedule", "method": "set", "version": 1},
+ {"api": "SYNO.PowerScheduler", "method": "save", "version": 1},
+ {"api": "SYNO.PowerSchedule", "method": "set", "version": 1}
+ ]
- if not result:
- logger.error("Failed to set power schedule")
+ success = False
+ last_used_api = ""
+
+ # Пробуем все возможные API по очереди
+ for api_config in apis_to_try:
+ api_name = api_config["api"]
+ method = api_config["method"]
+ version = api_config["version"]
+
+ logger.debug(f"Trying to set power schedule with API: {api_name}.{method} v{version}")
+ result = self._make_api_request(api_name, method, version, params=params)
+
+ if result:
+ logger.info(f"Successfully set power schedule using {api_name}.{method}")
+ success = True
+ last_used_api = api_name
+ break
+
+ if not success:
+ logger.error("Failed to set power schedule with any available API")
return False
- logger.info(f"Power schedule for {schedule_type} set successfully")
+ logger.info(f"Power schedule for {schedule_type} set successfully with {last_used_api}")
return True
except Exception as e:
diff --git a/src/bot.py b/src/bot.py
index 54d7e0e..58a915d 100644
--- a/src/bot.py
+++ b/src/bot.py
@@ -54,6 +54,11 @@ from src.handlers.advanced_handlers import (
browse_callback,
advanced_power_callback
)
+from src.utils.admin_utils import (
+ add_admin,
+ remove_admin,
+ list_admins
+)
from src.utils.logger import setup_logging
async def shutdown(application: Application) -> None:
@@ -124,6 +129,11 @@ def main() -> None:
application.add_handler(CommandHandler("wakeup", wakeup_command))
application.add_handler(CommandHandler("quota", quota_command))
+ # Регистрация обработчиков для управления администраторами
+ application.add_handler(CommandHandler("addadmin", add_admin))
+ application.add_handler(CommandHandler("removeadmin", remove_admin))
+ application.add_handler(CommandHandler("admins", list_admins))
+
# Регистрация обработчиков callback-запросов
# Сначала обрабатываем более специфичные паттерны
application.add_handler(CallbackQueryHandler(advanced_power_callback, pattern="^(confirm|cancel)_(reboot|sleep)$")) # Для advanced_handlers.py
diff --git a/src/handlers/advanced_handlers.py b/src/handlers/advanced_handlers.py
index 9cc9ee6..50bb0c7 100644
--- a/src/handlers/advanced_handlers.py
+++ b/src/handlers/advanced_handlers.py
@@ -172,16 +172,24 @@ async def schedule_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -
try:
schedule = synology_api.get_power_schedule()
+ # Проверяем, пустая ли структура расписания
if not schedule:
await message.edit_text("❌ Ошибка получения информации о расписании питания\n\nВозможно, API не поддерживает эту функцию или требуются дополнительные права доступа.", parse_mode="HTML")
return
+
+ # Получаем задачи расписания
+ boot_tasks = schedule.get("boot_tasks", [])
+ shutdown_tasks = schedule.get("shutdown_tasks", [])
+
+ if not boot_tasks and not shutdown_tasks:
+ await message.edit_text("ℹ️ Расписание питания не настроено\n\nНа вашем устройстве отсутствует настроенное расписание включения и выключения, либо API не поддерживается.", parse_mode="HTML")
+ return
+
except Exception as e:
await message.edit_text(f"❌ Ошибка получения информации о расписании питания\n\nПричина: {str(e)}", parse_mode="HTML")
return
# Формируем сообщение о расписании питания
- boot_tasks = schedule.get("boot_tasks", [])
- shutdown_tasks = schedule.get("shutdown_tasks", [])
reply_text = f"⏱️ Расписание питания Synology NAS\n\n"
@@ -354,7 +362,7 @@ async def search_command(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
# Получаем шаблон поиска из аргументов команды
if not context.args:
- await update.message.reply_text("❌ Укажите шаблон для поиска: /search <шаблон>")
+ await update.message.reply_text("❌ Укажите шаблон для поиска: /search <шаблон>")
return
pattern = " ".join(context.args)
diff --git a/src/handlers/command_handlers.py b/src/handlers/command_handlers.py
index 1b50d8e..68fa640 100644
--- a/src/handlers/command_handlers.py
+++ b/src/handlers/command_handlers.py
@@ -14,20 +14,18 @@ from src.config.config import (
ADMIN_USER_IDS, SYNOLOGY_HOST, SYNOLOGY_PORT, SYNOLOGY_MAC
)
from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
logger = logging.getLogger(__name__)
# Инициализация API Synology
synology_api = SynologyAPI()
+from src.utils.admin_utils import admin_required
+
+@admin_required
async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Обработчик команды /status"""
- user_id = update.effective_user.id
-
- if user_id not in ADMIN_USER_IDS:
- await update.message.reply_text("У вас нет доступа к этому боту.")
- return
-
message = await update.message.reply_text("⏳ Проверяю статус Synology NAS...")
is_online = synology_api.is_online()
@@ -123,13 +121,9 @@ async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
parse_mode="HTML"
)
+@admin_required
async def power_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Обработчик команды /power"""
- user_id = update.effective_user.id
-
- if user_id not in ADMIN_USER_IDS:
- await update.message.reply_text("У вас нет доступа к этому боту.")
- return
is_online = synology_api.is_online()
@@ -168,15 +162,12 @@ async def power_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
parse_mode="HTML"
)
+@admin_required
async def power_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Обработчик callback-запросов для кнопок управления питанием"""
query = update.callback_query
await query.answer()
- user_id = query.from_user.id
- if user_id not in ADMIN_USER_IDS:
- return
-
action = query.data
if action == "cancel":
diff --git a/src/handlers/extended_handlers.py b/src/handlers/extended_handlers.py
index ca60d52..de55528 100644
--- a/src/handlers/extended_handlers.py
+++ b/src/handlers/extended_handlers.py
@@ -312,15 +312,26 @@ async def load_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
reply_text += f"Загрузка памяти: {memory_usage}%\n\n"
# Добавляем информацию о сетевой активности
- network = system_load.get("network", {})
+ network = system_load.get("network", [])
if network:
reply_text += "Сетевая активность:\n"
- for device, stats in network.items():
- rx = int(stats.get("rx_bytes", 0)) / (1024**2) # МБ
- tx = int(stats.get("tx_bytes", 0)) / (1024**2) # МБ
-
- reply_text += f"• {device}\n"
- reply_text += f" └ Получено: {rx:.1f} МБ, Отправлено: {tx:.1f} МБ\n"
+ if isinstance(network, dict):
+ # Если это словарь (старое API)
+ for device, stats in network.items():
+ rx = int(stats.get("rx_bytes", 0)) / (1024**2) # МБ
+ tx = int(stats.get("tx_bytes", 0)) / (1024**2) # МБ
+
+ reply_text += f"• {device}\n"
+ reply_text += f" └ Получено: {rx:.1f} МБ, Отправлено: {tx:.1f} МБ\n"
+ elif isinstance(network, list):
+ # Если это список (новое API)
+ for interface in network:
+ device = interface.get("device", "неизвестно")
+ rx = int(interface.get("rx", 0)) / (1024**2) # МБ
+ tx = int(interface.get("tx", 0)) / (1024**2) # МБ
+
+ reply_text += f"• {device}\n"
+ reply_text += f" └ Получено: {rx:.1f} МБ, Отправлено: {tx:.1f} МБ\n"
await message.edit_text(reply_text, parse_mode="HTML")
diff --git a/src/handlers/help_handlers.py b/src/handlers/help_handlers.py
index 80fa480..702186c 100644
--- a/src/handlers/help_handlers.py
+++ b/src/handlers/help_handlers.py
@@ -61,7 +61,12 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
"СЛУЖЕБНЫЕ КОМАНДЫ:\n"
"/checkapi - Проверка API\n"
- "/help - Эта справка\n"
+ "/help - Эта справка\n\n"
+
+ "УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ:\n"
+ "/admins - Список администраторов\n"
+ "/addadmin <id> - Добавить администратора\n"
+ "/removeadmin <id> - Удалить администратора\n"
)
if update.message:
diff --git a/src/utils/admin_utils.py b/src/utils/admin_utils.py
new file mode 100644
index 0000000..f85b013
--- /dev/null
+++ b/src/utils/admin_utils.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Утилиты для управления администраторами бота
+"""
+
+import os
+import logging
+from typing import List, Optional, Callable, Any, Union
+from functools import wraps
+from telegram import Update
+from telegram.ext import ContextTypes
+from src.config.config import ADMIN_USER_IDS
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+def is_admin(user_id: int) -> bool:
+ """Проверяет, является ли пользователь администратором бота
+
+ Args:
+ user_id: ID пользователя Telegram
+
+ Returns:
+ True если пользователь администратор, иначе False
+ """
+ return user_id in ADMIN_USER_IDS
+
+def admin_required(func: Callable) -> Callable:
+ """Декоратор для проверки, является ли пользователь администратором
+
+ Args:
+ func: Оригинальная функция обработчика
+
+ Returns:
+ Обернутая функция с проверкой прав администратора
+ """
+ @wraps(func)
+ async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args: Any, **kwargs: Any) -> Any:
+ # Проверяем доступность объекта update и effective_user
+ if not update or not update.effective_user:
+ logger.warning("Update object is incomplete, unable to check admin status")
+ return
+
+ user_id = update.effective_user.id
+ username = update.effective_user.username or "Unknown"
+
+ if not is_admin(user_id):
+ logger.warning(f"Unauthorized access attempt by user {user_id} (@{username})")
+
+ # Если это сообщение, отправляем уведомление
+ if update.message:
+ await update.message.reply_text(
+ "⛔️ У вас нет прав на использование этой команды.\n"
+ "Обратитесь к владельцу бота, чтобы получить доступ."
+ )
+ # Если это callback query, отвечаем на него
+ elif update.callback_query:
+ await update.callback_query.answer(
+ "⛔️ У вас нет прав на использование этой функции."
+ )
+ return
+
+ # Если пользователь админ, вызываем оригинальную функцию
+ return await func(update, context, *args, **kwargs)
+
+ return wrapper
+
+async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Добавляет нового администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ await update.message.reply_text(
+ "❌ Ошибка: Необходимо указать ID пользователя.\n\n"
+ "Пример использования:\n"
+ "/addadmin 123456789",
+ parse_mode="HTML"
+ )
+ return
+
+ try:
+ # Парсим ID нового администратора
+ new_admin_id = int(context.args[0])
+
+ # Проверяем, не является ли пользователь уже администратором
+ if new_admin_id in ADMIN_USER_IDS:
+ await update.message.reply_text(f"ℹ️ Пользователь с ID {new_admin_id} уже является администратором.")
+ return
+
+ # Добавляем нового администратора
+ env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
+
+ # Читаем текущий файл .env
+ env_content = ""
+ with open(env_path, 'r', encoding='utf-8') as f:
+ env_content = f.read()
+
+ # Находим строку с ADMIN_USER_IDS
+ lines = env_content.split('\n')
+ admin_line_idx = -1
+
+ for i, line in enumerate(lines):
+ if line.startswith('ADMIN_USER_IDS='):
+ admin_line_idx = i
+ break
+
+ # Обновляем или добавляем строку с администраторами
+ if admin_line_idx >= 0:
+ current_ids = lines[admin_line_idx].split('=')[1].strip()
+ new_ids = f"{current_ids},{new_admin_id}" if current_ids else str(new_admin_id)
+ lines[admin_line_idx] = f"ADMIN_USER_IDS={new_ids}"
+ else:
+ lines.append(f"ADMIN_USER_IDS={new_admin_id}")
+
+ # Записываем обновленный файл
+ with open(env_path, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(lines))
+
+ # Обновляем список в памяти
+ ADMIN_USER_IDS.append(new_admin_id)
+
+ await update.message.reply_text(
+ f"✅ Успешно!\n\n"
+ f"Пользователь с ID {new_admin_id} добавлен в список администраторов.",
+ parse_mode="HTML"
+ )
+
+ logger.info(f"User {update.effective_user.id} added a new admin: {new_admin_id}")
+
+ except ValueError:
+ await update.message.reply_text(
+ "❌ Ошибка: ID пользователя должен быть целым числом.\n\n"
+ "Пример использования:\n"
+ "/addadmin 123456789",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ logger.error(f"Error adding admin: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при добавлении администратора:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
+
+async def remove_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Удаляет администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ await update.message.reply_text(
+ "❌ Ошибка: Необходимо указать ID пользователя.\n\n"
+ "Пример использования:\n"
+ "/removeadmin 123456789",
+ parse_mode="HTML"
+ )
+ return
+
+ try:
+ # Парсим ID администратора для удаления
+ admin_id = int(context.args[0])
+
+ # Проверяем, не удаляет ли админ сам себя
+ if admin_id == update.effective_user.id:
+ await update.message.reply_text("⚠️ Вы не можете удалить самого себя из администраторов.")
+ return
+
+ # Проверяем, является ли пользователь администратором
+ if admin_id not in ADMIN_USER_IDS:
+ await update.message.reply_text(f"ℹ️ Пользователь с ID {admin_id} не является администратором.")
+ return
+
+ # Удаляем администратора
+ env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
+
+ # Читаем текущий файл .env
+ env_content = ""
+ with open(env_path, 'r', encoding='utf-8') as f:
+ env_content = f.read()
+
+ # Находим строку с ADMIN_USER_IDS
+ lines = env_content.split('\n')
+ admin_line_idx = -1
+
+ for i, line in enumerate(lines):
+ if line.startswith('ADMIN_USER_IDS='):
+ admin_line_idx = i
+ break
+
+ # Удаляем ID из строки с администраторами
+ if admin_line_idx >= 0:
+ current_ids = lines[admin_line_idx].split('=')[1].strip().split(',')
+ new_ids = [id for id in current_ids if int(id) != admin_id]
+
+ if not new_ids:
+ # Если не осталось администраторов, добавляем текущего пользователя
+ # чтобы избежать ситуации, когда нет администраторов
+ new_ids = [str(update.effective_user.id)]
+
+ lines[admin_line_idx] = f"ADMIN_USER_IDS={','.join(new_ids)}"
+
+ # Записываем обновленный файл
+ with open(env_path, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(lines))
+
+ # Обновляем список в памяти
+ ADMIN_USER_IDS.remove(admin_id)
+
+ await update.message.reply_text(
+ f"✅ Успешно!\n\n"
+ f"Пользователь с ID {admin_id} удален из списка администраторов.",
+ parse_mode="HTML"
+ )
+
+ logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
+ else:
+ await update.message.reply_text("❌ Ошибка: Не удалось найти настройки администраторов.")
+
+ except ValueError:
+ await update.message.reply_text(
+ "❌ Ошибка: ID пользователя должен быть целым числом.\n\n"
+ "Пример использования:\n"
+ "/removeadmin 123456789",
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ logger.error(f"Error removing admin: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при удалении администратора:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
+
+async def list_admins(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Показывает список администраторов бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ try:
+ if not ADMIN_USER_IDS:
+ await update.message.reply_text("⚠️ Список администраторов пуст.")
+ return
+
+ # Формируем сообщение со списком администраторов
+ admin_list = "\n".join([f"• {admin_id}" for admin_id in ADMIN_USER_IDS])
+
+ await update.message.reply_text(
+ f"👑 Список администраторов бота:\n\n"
+ f"{admin_list}\n\n"
+ f"Всего администраторов: {len(ADMIN_USER_IDS)}",
+ parse_mode="HTML"
+ )
+
+ except Exception as e:
+ logger.error(f"Error listing admins: {str(e)}")
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при получении списка администраторов:\n\n{str(e)}",
+ parse_mode="HTML"
+ )